Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/monorail-proxy-route.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@shopify/hydrogen': patch
'skeleton': patch
---

Add virtual resource route for Shopify monorail analytics endpoint

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.

**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.

**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.

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.
14 changes: 10 additions & 4 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@
"Bash(gh pr view:*)",
"Bash(npm run ci:checks:*)",
"Bash(npm run cookbook:*)",
"WebFetch(domain:gist.githubusercontent.com)"
"WebFetch(domain:shopify.dev)",
"mcp__browsermcp__browser_navigate",
"mcp__browsermcp__browser_get_console_logs",
"mcp__browsermcp__browser_screenshot",
"mcp__docs-react-router__fetch_generic_url_content",
"Bash(curl:*)"
]
},
"enabledMcpjsonServers": [
Expand All @@ -17,9 +22,10 @@
"docs-vite",
"docs-react",
"docs-react-router",
"docs-remix",
"docs-hydrogen",
"docs-xstate",
"shopify-dev-mcp"
"shopify-dev-mcp",
"browsermcp",
"chrome-devtools"
]
}
}
4 changes: 4 additions & 0 deletions .mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@
"shopify-dev-mcp": {
"command": "npx",
"args": ["-y", "@shopify/dev-mcp@latest"]
},
"chrome-devtools": {
"command": "npx",
"args": ["-y", "chrome-devtools-mcp@latest"]
}
}
}
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions packages/hydrogen/src/vite/get-virtual-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,15 @@ export async function getVirtualRoutesV3() {
),
index: false,
},
{
id: `${VIRTUAL_ROUTES_DIR}/[.]well-known.shopify.monorail.unstable.produce_batch`,
path: '.well-known/shopify/monorail/unstable/produce_batch',
file: getVirtualRoutesPath(
VIRTUAL_ROUTES_ROUTES_DIR_PARTS,
'[.]well-known.shopify.monorail.unstable.produce_batch.jsx',
),
index: false,
},
{
id: `${VIRTUAL_ROUTES_DIR}/index`,
path: '',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest';
import {
action,
loader,
} from './[.]well-known.shopify.monorail.unstable.produce_batch';

const SHOPIFY_STORE_DOMAIN = 'hydrogen-preview.myshopify.com';

describe('Monorail analytics proxy route', () => {
let originalFetch: typeof global.fetch;

beforeEach(() => {
originalFetch = global.fetch;
vi.clearAllMocks();
});

afterEach(() => {
global.fetch = originalFetch;
});

it('proxies POST requests to Shopify theme domain when PUBLIC_STORE_DOMAIN is set', async () => {
const mockFetch = vi.fn().mockResolvedValue(
new Response('Monorail Events Array cannot be empty.', {
status: 400,
headers: new Headers({
'Content-Type': 'text/plain; charset=utf-8',
'Access-Control-Allow-Methods': 'OPTIONS,POST',
}),
}),
);
global.fetch = mockFetch;

const request = new Request(
'http://localhost/.well-known/shopify/monorail/unstable/produce_batch',
{
method: 'POST',
headers: {'Content-Type': 'text/plain'},
body: JSON.stringify({
events: [
{
schema_id: 'trekkie_storefront_page_view/1.4',
payload: {shopId: 12345, pageType: 'home'},
},
],
metadata: {event_sent_at_ms: Date.now()},
}),
},
);

const context = {
env: {SHOPIFY_STORE_DOMAIN},
};

// @ts-expect-error - partial context for testing
const response = await action({request, context});

expect(mockFetch).toHaveBeenCalledWith(
'https://hydrogen-preview.myshopify.com/.well-known/shopify/monorail/unstable/produce_batch',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'Content-Type': 'text/plain',
}),
}),
);

expect(response.status).toBe(400);
const text = await response.text();
expect(text).toBe('Monorail Events Array cannot be empty.');
});

it('returns 405 Method Not Allowed for GET requests', async () => {
const response = await loader();

expect(response.status).toBe(405);

const text = await response.text();
expect(text).toBe('Method not allowed');
});

it('returns 204 fallback when PUBLIC_STORE_DOMAIN is not set', async () => {
const request = new Request(
'http://localhost/.well-known/shopify/monorail/unstable/produce_batch',
{
method: 'POST',
headers: {'Content-Type': 'text/plain'},
body: JSON.stringify({events: []}),
},
);

const context = {env: {}};

// @ts-expect-error - partial context for testing
const response = await action({request, context});

expect(response.status).toBe(204);
const text = await response.text();
expect(text).toBe('');
});

it('returns 204 and logs error when proxy fails', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error');
const mockFetch = vi.fn().mockRejectedValue(new Error('Network error'));
global.fetch = mockFetch;

const request = new Request(
'http://localhost/.well-known/shopify/monorail/unstable/produce_batch',
{
method: 'POST',
body: JSON.stringify({events: []}),
},
);

const context = {
env: {SHOPIFY_STORE_DOMAIN},
};

// @ts-expect-error - partial context for testing
const response = await action({request, context});

expect(response.status).toBe(204);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'[Monorail Proxy] Error forwarding to Shopify theme:',
expect.any(Error),
);
});

it('forwards request with correct headers and body', async () => {
const mockFetch = vi
.fn()
.mockResolvedValue(new Response('{"status": "ok"}', {status: 200}));
global.fetch = mockFetch;

const payload = {
events: [{schema_id: 'test', payload: {data: 'value'}}],
metadata: {event_sent_at_ms: 123456},
};

const request = new Request(
'http://localhost/.well-known/shopify/monorail/unstable/produce_batch',
{
method: 'POST',
headers: {'Content-Type': 'text/plain'},
body: JSON.stringify(payload),
},
);

const context = {
env: {SHOPIFY_STORE_DOMAIN},
};

// @ts-expect-error - partial context for testing
await action({request, context});

const fetchCall = mockFetch.mock.calls[0];
expect(fetchCall[0]).toBe(
`https://${SHOPIFY_STORE_DOMAIN}/.well-known/shopify/monorail/unstable/produce_batch`,
);
expect(fetchCall[1]).toMatchObject({
method: 'POST',
headers: {'Content-Type': 'text/plain'},
body: JSON.stringify(payload),
});
});

it('forwards all response headers and status from Shopify theme', async () => {
const shopifyHeaders = new Headers({
'Content-Type': 'text/plain; charset=utf-8',
'Access-Control-Allow-Methods': 'OPTIONS,POST',
'Access-Control-Allow-Credentials': 'true',
'x-request-id': 'test-request-id',
'x-robots-tag': 'noindex',
'x-shopify-location': 'us-east',
});

const mockFetch = vi.fn().mockResolvedValue(
new Response('Monorail Events Array cannot be empty.', {
status: 400,
headers: shopifyHeaders,
}),
);
global.fetch = mockFetch;

const request = new Request(
'http://localhost/.well-known/shopify/monorail/unstable/produce_batch',
{
method: 'POST',
body: JSON.stringify({events: []}),
},
);

const context = {
env: {SHOPIFY_STORE_DOMAIN},
};

// @ts-expect-error - partial context for testing
const response = await action({request, context});

expect(response.status).toBe(400);

const text = await response.text();
expect(text).toBe('Monorail Events Array cannot be empty.');

expect(response.headers.get('Content-Type')).toBe(
'text/plain; charset=utf-8',
);
expect(response.headers.get('Access-Control-Allow-Methods')).toBe(
'OPTIONS,POST',
);
expect(response.headers.get('x-request-id')).toBe('test-request-id');
expect(response.headers.get('x-robots-tag')).toBe('noindex');
});

it('successfully proxies valid Hydrogen analytics payload structure', async () => {
const mockFetch = vi.fn().mockResolvedValue(
new Response('{"result":[{"status":200,"message":"ok"}]}', {
status: 200,
headers: new Headers({
'Content-Type': 'application/json',
}),
}),
);
global.fetch = mockFetch;

const validHydrogenPayload = {
events: [
{
schema_id: 'trekkie_storefront_page_view/1.4',
payload: {
shopId: 1,
currency: 'USD',
uniqToken: 'test-unique-token',
visitToken: 'test-visit-token',
microSessionId: 'test-micro-session-id',
microSessionCount: 1,
url: 'https://shop.com',
path: '/',
search: '',
referrer: '',
title: 'Home Page',
appClientId: '12875497473',
isMerchantRequest: false,
hydrogenSubchannelId: '0',
isPersistentCookie: true,
contentLanguage: 'en',
},
metadata: {
event_created_at_ms: Date.now(),
},
},
],
metadata: {
event_sent_at_ms: Date.now(),
},
};

const request = new Request(
'http://localhost/.well-known/shopify/monorail/unstable/produce_batch',
{
method: 'POST',
headers: {'Content-Type': 'text/plain'},
body: JSON.stringify(validHydrogenPayload),
},
);

const context = {
env: {SHOPIFY_STORE_DOMAIN},
};

// @ts-expect-error - partial context for testing
const response = await action({request, context});

expect(response.status).toBe(200);
const body = await response.json();
expect(body).toEqual({result: [{status: 200, message: 'ok'}]});
});
});
Loading