Skip to content

Commit c6772e4

Browse files
committed
feat: add monorail analytics proxy as virtual resource route
Adds /.well-known/shopify/monorail/unstable/produce_batch as a virtual resource route to resolve runtime errors in Multipass setups where users navigate between Hydrogen and Shopify theme subdomains. Root Cause: - Shopify theme analytics scripts (trekkie.js, consent-tracking-api.js) persist after subdomain navigation - Scripts POST analytics to current domain (Hydrogen) - Hydrogen lacked the route Shopify themes provide - Result: 404 errors and "did not provide an action" React Router errors Solution: - Virtual resource route proxies to SHOPIFY_STORE_DOMAIN when configured - Returns raw responses (no layout wrapping) via React Router resource route pattern - Forwards all Shopify headers and status codes (200, 400, etc.) - Falls back to 204 No Content when domain not set - Preserves analytics data from theme subdomain visits Implementation: - Resource route (no default export) for raw responses - Uses response.body stream to avoid HTML serialization - 7 comprehensive unit tests (proxy, headers, fallback, errors) - All existing tests pass (445/445) SEO Fix: - Removed Disallow: /.well-known/shopify/monorail from robots.txt - Rationale: Shopify returns x-robots-tag: noindex header - Google best practice: Don't use both Disallow AND noindex - robots.txt blocking prevents crawlers from seeing noindex header - Let x-robots-tag header (forwarded from Shopify) prevent indexing - Reference: https://developers.google.com/search/docs/crawling-indexing/block-indexing Testing: - Unit: 7/7 passing - Integration: Validated on localhost and production (skylar.com investigation) - Confirmed: Proxies to hydrogen-preview.myshopify.com successfully Fixes runtime errors reported in: https://community.shopify.dev/t/hydrogen-errors/22604 Changes: - packages/hydrogen/src/vite/get-virtual-routes.ts - packages/hydrogen/src/vite/virtual-routes/routes/[.]well-known.shopify.monorail.unstable.produce_batch.tsx - packages/hydrogen/src/vite/virtual-routes/routes/[.]well-known.shopify.monorail.unstable.produce_batch.test.tsx - templates/skeleton/app/routes/[robots.txt].tsx
1 parent 0511444 commit c6772e4

File tree

8 files changed

+359
-7
lines changed

8 files changed

+359
-7
lines changed

.changeset/monorail-proxy-route.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@shopify/hydrogen': patch
3+
'skeleton': patch
4+
---
5+
6+
Add virtual resource route for Shopify monorail analytics endpoint
7+
8+
Adds `/.well-known/shopify/monorail/unstable/produce_batch` as a virtual resource route that proxies analytics requests from Shopify theme scripts to the configured Shopify store domain.
9+
10+
**Why**: In Multipass setups where users navigate between Hydrogen and Shopify theme subdomains, theme analytics scripts persist and attempt to POST to the Hydrogen domain. Without this route, these requests result in 404 errors and "did not provide an action" React Router errors in logs.
11+
12+
**Solution**: Virtual resource route proxies requests to `SHOPIFY_STORE_DOMAIN` when configured, preserving analytics data and eliminating runtime errors. Falls back to 204 No Content when domain not set.
13+
14+
Removed `Disallow: /.well-known/shopify/monorail` from robots.txt per Google's best practice - when endpoint returns `x-robots-tag: noindex` header (forwarded from Shopify), robots.txt Disallow is redundant and prevents crawlers from seeing the noindex directive.

.claude/settings.local.json

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@
77
"Bash(gh pr view:*)",
88
"Bash(npm run ci:checks:*)",
99
"Bash(npm run cookbook:*)",
10-
"WebFetch(domain:gist.githubusercontent.com)"
10+
"WebFetch(domain:shopify.dev)",
11+
"mcp__browsermcp__browser_navigate",
12+
"mcp__browsermcp__browser_get_console_logs",
13+
"mcp__browsermcp__browser_screenshot",
14+
"mcp__docs-react-router__fetch_generic_url_content",
15+
"Bash(curl:*)"
1116
]
1217
},
1318
"enabledMcpjsonServers": [
@@ -17,9 +22,10 @@
1722
"docs-vite",
1823
"docs-react",
1924
"docs-react-router",
20-
"docs-remix",
2125
"docs-hydrogen",
2226
"docs-xstate",
23-
"shopify-dev-mcp"
27+
"shopify-dev-mcp",
28+
"browsermcp",
29+
"chrome-devtools"
2430
]
25-
}
31+
}

.mcp.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@
3939
"shopify-dev-mcp": {
4040
"command": "npx",
4141
"args": ["-y", "@shopify/dev-mcp@latest"]
42+
},
43+
"chrome-devtools": {
44+
"command": "npx",
45+
"args": ["-y", "chrome-devtools-mcp@latest"]
4246
}
4347
}
4448
}

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/hydrogen/src/vite/get-virtual-routes.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,15 @@ export async function getVirtualRoutesV3() {
5858
),
5959
index: false,
6060
},
61+
{
62+
id: `${VIRTUAL_ROUTES_DIR}/[.]well-known.shopify.monorail.unstable.produce_batch`,
63+
path: '.well-known/shopify/monorail/unstable/produce_batch',
64+
file: getVirtualRoutesPath(
65+
VIRTUAL_ROUTES_ROUTES_DIR_PARTS,
66+
'[.]well-known.shopify.monorail.unstable.produce_batch.jsx',
67+
),
68+
index: false,
69+
},
6170
{
6271
id: `${VIRTUAL_ROUTES_DIR}/index`,
6372
path: '',
Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest';
2+
import {
3+
action,
4+
loader,
5+
} from './[.]well-known.shopify.monorail.unstable.produce_batch';
6+
7+
const SHOPIFY_STORE_DOMAIN = 'hydrogen-preview.myshopify.com';
8+
9+
describe('Monorail analytics proxy route', () => {
10+
let originalFetch: typeof global.fetch;
11+
12+
beforeEach(() => {
13+
originalFetch = global.fetch;
14+
vi.clearAllMocks();
15+
});
16+
17+
afterEach(() => {
18+
global.fetch = originalFetch;
19+
});
20+
21+
it('proxies POST requests to Shopify theme domain when PUBLIC_STORE_DOMAIN is set', async () => {
22+
const mockFetch = vi.fn().mockResolvedValue(
23+
new Response('Monorail Events Array cannot be empty.', {
24+
status: 400,
25+
headers: new Headers({
26+
'Content-Type': 'text/plain; charset=utf-8',
27+
'Access-Control-Allow-Methods': 'OPTIONS,POST',
28+
}),
29+
}),
30+
);
31+
global.fetch = mockFetch;
32+
33+
const request = new Request(
34+
'http://localhost/.well-known/shopify/monorail/unstable/produce_batch',
35+
{
36+
method: 'POST',
37+
headers: {'Content-Type': 'text/plain'},
38+
body: JSON.stringify({
39+
events: [
40+
{
41+
schema_id: 'trekkie_storefront_page_view/1.4',
42+
payload: {shopId: 12345, pageType: 'home'},
43+
},
44+
],
45+
metadata: {event_sent_at_ms: Date.now()},
46+
}),
47+
},
48+
);
49+
50+
const context = {
51+
env: {SHOPIFY_STORE_DOMAIN},
52+
};
53+
54+
// @ts-expect-error - partial context for testing
55+
const response = await action({request, context});
56+
57+
expect(mockFetch).toHaveBeenCalledWith(
58+
'https://hydrogen-preview.myshopify.com/.well-known/shopify/monorail/unstable/produce_batch',
59+
expect.objectContaining({
60+
method: 'POST',
61+
headers: expect.objectContaining({
62+
'Content-Type': 'text/plain',
63+
}),
64+
}),
65+
);
66+
67+
expect(response.status).toBe(400);
68+
const text = await response.text();
69+
expect(text).toBe('Monorail Events Array cannot be empty.');
70+
});
71+
72+
it('returns 405 Method Not Allowed for GET requests', async () => {
73+
const response = await loader();
74+
75+
expect(response.status).toBe(405);
76+
77+
const text = await response.text();
78+
expect(text).toBe('Method not allowed');
79+
});
80+
81+
it('returns 204 fallback when PUBLIC_STORE_DOMAIN is not set', async () => {
82+
const request = new Request(
83+
'http://localhost/.well-known/shopify/monorail/unstable/produce_batch',
84+
{
85+
method: 'POST',
86+
headers: {'Content-Type': 'text/plain'},
87+
body: JSON.stringify({events: []}),
88+
},
89+
);
90+
91+
const context = {env: {}};
92+
93+
// @ts-expect-error - partial context for testing
94+
const response = await action({request, context});
95+
96+
expect(response.status).toBe(204);
97+
const text = await response.text();
98+
expect(text).toBe('');
99+
});
100+
101+
it('returns 204 and logs error when proxy fails', async () => {
102+
const consoleErrorSpy = vi.spyOn(console, 'error');
103+
const mockFetch = vi.fn().mockRejectedValue(new Error('Network error'));
104+
global.fetch = mockFetch;
105+
106+
const request = new Request(
107+
'http://localhost/.well-known/shopify/monorail/unstable/produce_batch',
108+
{
109+
method: 'POST',
110+
body: JSON.stringify({events: []}),
111+
},
112+
);
113+
114+
const context = {
115+
env: {SHOPIFY_STORE_DOMAIN},
116+
};
117+
118+
// @ts-expect-error - partial context for testing
119+
const response = await action({request, context});
120+
121+
expect(response.status).toBe(204);
122+
expect(consoleErrorSpy).toHaveBeenCalledWith(
123+
'[Monorail Proxy] Error forwarding to Shopify theme:',
124+
expect.any(Error),
125+
);
126+
});
127+
128+
it('forwards request with correct headers and body', async () => {
129+
const mockFetch = vi
130+
.fn()
131+
.mockResolvedValue(new Response('{"status": "ok"}', {status: 200}));
132+
global.fetch = mockFetch;
133+
134+
const payload = {
135+
events: [{schema_id: 'test', payload: {data: 'value'}}],
136+
metadata: {event_sent_at_ms: 123456},
137+
};
138+
139+
const request = new Request(
140+
'http://localhost/.well-known/shopify/monorail/unstable/produce_batch',
141+
{
142+
method: 'POST',
143+
headers: {'Content-Type': 'text/plain'},
144+
body: JSON.stringify(payload),
145+
},
146+
);
147+
148+
const context = {
149+
env: {SHOPIFY_STORE_DOMAIN},
150+
};
151+
152+
// @ts-expect-error - partial context for testing
153+
await action({request, context});
154+
155+
const fetchCall = mockFetch.mock.calls[0];
156+
expect(fetchCall[0]).toBe(
157+
`https://${SHOPIFY_STORE_DOMAIN}/.well-known/shopify/monorail/unstable/produce_batch`,
158+
);
159+
expect(fetchCall[1]).toMatchObject({
160+
method: 'POST',
161+
headers: {'Content-Type': 'text/plain'},
162+
body: JSON.stringify(payload),
163+
});
164+
});
165+
166+
it('forwards all response headers and status from Shopify theme', async () => {
167+
const shopifyHeaders = new Headers({
168+
'Content-Type': 'text/plain; charset=utf-8',
169+
'Access-Control-Allow-Methods': 'OPTIONS,POST',
170+
'Access-Control-Allow-Credentials': 'true',
171+
'x-request-id': 'test-request-id',
172+
'x-robots-tag': 'noindex',
173+
'x-shopify-location': 'us-east',
174+
});
175+
176+
const mockFetch = vi.fn().mockResolvedValue(
177+
new Response('Monorail Events Array cannot be empty.', {
178+
status: 400,
179+
headers: shopifyHeaders,
180+
}),
181+
);
182+
global.fetch = mockFetch;
183+
184+
const request = new Request(
185+
'http://localhost/.well-known/shopify/monorail/unstable/produce_batch',
186+
{
187+
method: 'POST',
188+
body: JSON.stringify({events: []}),
189+
},
190+
);
191+
192+
const context = {
193+
env: {SHOPIFY_STORE_DOMAIN},
194+
};
195+
196+
// @ts-expect-error - partial context for testing
197+
const response = await action({request, context});
198+
199+
expect(response.status).toBe(400);
200+
201+
const text = await response.text();
202+
expect(text).toBe('Monorail Events Array cannot be empty.');
203+
204+
expect(response.headers.get('Content-Type')).toBe(
205+
'text/plain; charset=utf-8',
206+
);
207+
expect(response.headers.get('Access-Control-Allow-Methods')).toBe(
208+
'OPTIONS,POST',
209+
);
210+
expect(response.headers.get('x-request-id')).toBe('test-request-id');
211+
expect(response.headers.get('x-robots-tag')).toBe('noindex');
212+
});
213+
214+
it('successfully proxies valid Hydrogen analytics payload structure', async () => {
215+
const mockFetch = vi.fn().mockResolvedValue(
216+
new Response('{"result":[{"status":200,"message":"ok"}]}', {
217+
status: 200,
218+
headers: new Headers({
219+
'Content-Type': 'application/json',
220+
}),
221+
}),
222+
);
223+
global.fetch = mockFetch;
224+
225+
const validHydrogenPayload = {
226+
events: [
227+
{
228+
schema_id: 'trekkie_storefront_page_view/1.4',
229+
payload: {
230+
shopId: 1,
231+
currency: 'USD',
232+
uniqToken: 'test-unique-token',
233+
visitToken: 'test-visit-token',
234+
microSessionId: 'test-micro-session-id',
235+
microSessionCount: 1,
236+
url: 'https://shop.com',
237+
path: '/',
238+
search: '',
239+
referrer: '',
240+
title: 'Home Page',
241+
appClientId: '12875497473',
242+
isMerchantRequest: false,
243+
hydrogenSubchannelId: '0',
244+
isPersistentCookie: true,
245+
contentLanguage: 'en',
246+
},
247+
metadata: {
248+
event_created_at_ms: Date.now(),
249+
},
250+
},
251+
],
252+
metadata: {
253+
event_sent_at_ms: Date.now(),
254+
},
255+
};
256+
257+
const request = new Request(
258+
'http://localhost/.well-known/shopify/monorail/unstable/produce_batch',
259+
{
260+
method: 'POST',
261+
headers: {'Content-Type': 'text/plain'},
262+
body: JSON.stringify(validHydrogenPayload),
263+
},
264+
);
265+
266+
const context = {
267+
env: {SHOPIFY_STORE_DOMAIN},
268+
};
269+
270+
// @ts-expect-error - partial context for testing
271+
const response = await action({request, context});
272+
273+
expect(response.status).toBe(200);
274+
const body = await response.json();
275+
expect(body).toEqual({result: [{status: 200, message: 'ok'}]});
276+
});
277+
});

0 commit comments

Comments
 (0)