hydrogen icon indicating copy to clipboard operation
hydrogen copied to clipboard

Add monorail analytics proxy as virtual resource route

Open juanpprieto opened this issue 3 months ago • 1 comments

Fixes: https://github.com/Shopify/hydrogen-internal/issues/254

Summary

Adds /.well-known/shopify/monorail/unstable/produce_batch as a virtual resource route that proxies Shopify analytics requests to the configured Shopify store domain, eliminating runtime errors in Multipass/Hybrid setups where users navigate between Hydrogen and Shopify theme subdomains.

Why

Problem: In Multipass/Hybrid setups, Shopify theme analytics scripts persist after subdomain navigation and POST to the Hydrogen domain, causing runtime errors:

POST /.well-known/shopify/monorail/unstable/produce_batch → 404 Not Found
Error: "did not provide an action for route routes/($locale).$"

Affected setups: Any Hydrogen + Multipass configuration with subdomain redirects to Shopify themes (e.g., xyz.com)

Impact:

  • Runtime error noise in logs
  • Potential analytics data loss
  • User confusion from errors

What

Implementation

Virtual Resource Route:

Request Flow:
┌──────────────────┐
│  Theme Scripts   │ (trekkie.js, consent-tracking-api.js)
│  POST analytics  │
└────────┬─────────┘
         │
         ▼
┌──────────────────┐
│ Hydrogen Domain  │ 
│ /.well-known/... │ (xyz.com)
└────────┬─────────┘
         │
         ▼ (proxy)
┌──────────────────┐
│ Shopify Theme    │ (xyz.myshopify.com)
│ /.well-known/... │
└────────┬─────────┘
         │
         ▼
    200 OK / 400
Implementation Details

Resource Route (no default export):

  • Returns raw responses without React Router layout wrapping
  • Uses response.body stream to preserve Shopify's exact response
  • Forwards all headers (CORS, x-request-id, x-robots-tag, etc.)
export async function action({request, context}: ActionFunctionArgs) {
  const shopifyStoreDomain = context?.env?.SHOPIFY_STORE_DOMAIN;

  if (!shopifyStoreDomain) {
    return new Response('', {status: 204});
  }

  const response = await fetch(
    `https://${shopifyStoreDomain}${url.pathname}`,
    {method: 'POST', headers: {...}, body}
  );

  return new Response(response.body, {
    status: response.status,
    headers: respHeaders,
  });
}

Registration:

// packages/hydrogen/src/vite/get-virtual-routes.ts
{
  id: 'vite/virtual-routes/routes/[.]well-known.shopify.monorail.unstable.produce_batch',
  path: '.well-known/shopify/monorail/unstable/produce_batch',
  file: '[.]well-known.shopify.monorail.unstable.produce_batch.jsx',
  index: false,
}

Key Features

Feature Implementation Benefit
Resource Route No default export Returns raw responses (no HTML layout)
Conditional Proxy Checks SHOPIFY_STORE_DOMAIN env var Works out-of-box when domain set, silent fallback when not
Header Forwarding Copies all Shopify response headers Preserves CORS, validation messages, Shopify metadata
Stream Response Uses response.body not await response.text() Avoids serialization, exact Shopify response
Error Handling Try-catch with 204 fallback Graceful degradation if proxy fails

robots.txt SEO Fix

Removed: Disallow: /.well-known/shopify/monorail

Rationale:

  • Shopify's endpoint returns x-robots-tag: noindex header
  • Google best practice: Don't use both robots.txt Disallow AND noindex headers
  • With robots.txt blocking, crawlers never see the noindex header
  • Proper approach: Let x-robots-tag: noindex header prevent indexing

Reference: Google - Block Search Indexing with noindex

"For the noindex rule to be effective, the page or resource must not be blocked by a robots.txt file."

🎩 Top Hat

Prerequisites

  • [ ] Hydrogen project (2025.7.0+)
  • [ ] Multipass setup with theme subdomain (optional - route works without)
  • [ ] SHOPIFY_STORE_DOMAIN environment variable (optional)

Testing Steps

Test 1: Verify Route Exists and Returns Raw Response

# Start dev server
cd templates/skeleton
npm run dev

# In another terminal - test POST
curl -X POST http://localhost:3000/.well-known/shopify/monorail/unstable/produce_batch \
  -H "Content-Type: text/plain" \
  -d '{"events":[]}' \
  -i

# Expected: HTTP 200 or 400, NO HTML in response body
# With SHOPIFY_STORE_DOMAIN set: Proxies to Shopify, returns their response
# Without SHOPIFY_STORE_DOMAIN: Returns 204 No Content

# Test GET (should reject)
curl http://localhost:3000/.well-known/shopify/monorail/unstable/produce_batch

# Expected: 405 Method Not Allowed

Test 2: Verify Proxy Functionality with Valid Payload

# Set environment variable
export SHOPIFY_STORE_DOMAIN=hydrogen-preview.myshopify.com

# Post valid analytics payload
curl -X POST http://localhost:3000/.well-known/shopify/monorail/unstable/produce_batch \
  -H "Content-Type: text/plain" \
  -d '{
  "events": [{
    "schema_id": "trekkie_storefront_page_view/1.4",
    "payload": {
      "shopId": 1,
      "currency": "USD",
      "uniqToken": "test-token",
      "visitToken": "test-visit",
      "microSessionId": "test-session",
      "microSessionCount": 1,
      "url": "https://shop.com",
      "path": "/",
      "search": "",
      "referrer": "",
      "title": "Home",
      "appClientId": "12875497473",
      "isMerchantRequest": false,
      "hydrogenSubchannelId": "0",
      "isPersistentCookie": true,
      "contentLanguage": "en"
    },
    "metadata": {"event_created_at_ms": 1759436400000}
  }],
  "metadata": {"event_sent_at_ms": 1759436400000}
}' -i

# Expected: HTTP 200 OK with Shopify analytics response
# Should see CORS headers from Shopify

Test 3: Verify robots.txt Fix

curl http://localhost:3000/robots.txt | grep "monorail"

# Expected: NO output (Disallow line removed)
# Allows crawlers to see x-robots-tag: noindex header

Test 4: Run Unit Tests

cd packages/hydrogen
npm test -- "monorail"

# Expected: 7/7 tests passing
# - Proxies POST to Shopify domain
# - Returns 405 for GET
# - Returns 204 fallback when no domain
# - Handles proxy errors
# - Forwards headers correctly
# - Handles large payloads
# - Proxies valid Hydrogen analytics payloads

Edge Cases to Test

  • [ ] Route works without SHOPIFY_STORE_DOMAIN (returns 204)
  • [ ] Route works with SHOPIFY_STORE_DOMAIN set (proxies correctly)
  • [ ] Invalid analytics payloads return 400 from Shopify
  • [ ] Valid analytics payloads return 200 from Shopify
  • [ ] Network errors to Shopify gracefully fallback to 204
  • [ ] GET requests properly rejected with 405
  • [ ] Large analytics payloads handled without issues
  • [ ] No HTML layout wrapping in responses
  • [ ] All Shopify headers preserved (CORS, x-*, etc.)

Validation Checklist

  • [ ] All tests pass: npm test (445/445)
  • [ ] No TypeScript errors: npm run typecheck
  • [ ] Monorail route returns raw responses (no HTML)
  • [ ] Proxy forwards to correct Shopify domain
  • [ ] robots.txt no longer blocks monorail endpoint
  • [ ] Works in both dev and production (tested on skeleton.hydrogen.shop)
  • [ ] No breaking changes to existing analytics (monorail-edge.shopifysvc.com still works)

juanpprieto avatar Oct 03 '25 15:10 juanpprieto