diff --git a/packages/web/server/lib/preview/proxy-runtime.js b/packages/web/server/lib/preview/proxy-runtime.js index 7aefcc5d..f02c8387 100644 --- a/packages/web/server/lib/preview/proxy-runtime.js +++ b/packages/web/server/lib/preview/proxy-runtime.js @@ -3,6 +3,8 @@ const TOKEN_COOKIE_NAME = 'oc_preview_token'; const TOKEN_QUERY_PARAM = 'oc_preview_token'; const CLIENT_TOKEN_QUERY_PARAM = 'oc_client_token'; const URL_AUTH_TOKEN_QUERY_PARAM = 'oc_url_token'; +const PREVIEW_PASSTHROUGH_REQUEST_HEADERS = ['x-inertia', 'x-inertia-version']; +const PREVIEW_PASSTHROUGH_RESPONSE_HEADERS = ['x-inertia', 'x-inertia-location']; const LOOPBACK_HOSTS = new Set([ 'localhost', @@ -25,6 +27,34 @@ const parsePreviewResourcePath = (url) => { } }; +const readHeader = (headers, name) => { + if (!headers || typeof headers !== 'object') return undefined; + const direct = headers[name]; + if (direct !== undefined) return direct; + const lowerName = name.toLowerCase(); + const key = Object.keys(headers).find((entry) => entry.toLowerCase() === lowerName); + return key ? headers[key] : undefined; +}; + +export const applyPreviewPassthroughRequestHeaders = (req, proxyReq) => { + for (const headerName of PREVIEW_PASSTHROUGH_REQUEST_HEADERS) { + const value = readHeader(req?.headers, headerName); + if (value !== undefined) { + proxyReq.setHeader(headerName, value); + } + } +}; + +export const applyPreviewPassthroughResponseHeaders = (proxyRes, res) => { + if (!res || res.headersSent || typeof res.setHeader !== 'function') return; + for (const headerName of PREVIEW_PASSTHROUGH_RESPONSE_HEADERS) { + const value = readHeader(proxyRes?.headers, headerName); + if (value !== undefined) { + res.setHeader(headerName, value); + } + } +}; + const previewResourceNoiseRuleSets = [ { name: 'vite', @@ -1412,14 +1442,16 @@ export const createPreviewProxyRuntime = ({ return `${strippedPath}${withoutUrlAuthToken}`; }, on: { - proxyReq: (proxyReq) => { + proxyReq: (proxyReq, req) => { + applyPreviewPassthroughRequestHeaders(req, proxyReq); // Keep local dev servers from receiving OpenChamber credentials. proxyReq.removeHeader('cookie'); proxyReq.removeHeader('authorization'); proxyReq.removeHeader('x-openchamber-ui-session'); proxyReq.setHeader('accept-encoding', 'identity'); }, - proxyRes: responseInterceptor(async (responseBuffer, proxyRes, req) => { + proxyRes: responseInterceptor(async (responseBuffer, proxyRes, req, res) => { + applyPreviewPassthroughResponseHeaders(proxyRes, res); // Per-response nonce lets the injected bridge run under the dev // server's CSP without dropping its script restrictions wholesale. const bridgeNonce = crypto.randomBytes(16).toString('base64'); diff --git a/packages/web/server/lib/preview/proxy-runtime.test.js b/packages/web/server/lib/preview/proxy-runtime.test.js index e810a173..1f38add6 100644 --- a/packages/web/server/lib/preview/proxy-runtime.test.js +++ b/packages/web/server/lib/preview/proxy-runtime.test.js @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest'; import { + applyPreviewPassthroughRequestHeaders, + applyPreviewPassthroughResponseHeaders, classifyPreviewNavigation, classifyPreviewResourceError, normalizeProxyTargetUrl, @@ -16,6 +18,47 @@ const rewrite = (bodyText, kind) => rewritePreviewBody({ targetOrigin: 'http://127.0.0.1:3000', }); +describe('preview Inertia header passthrough', () => { + it('forwards Inertia request headers to the preview target', () => { + const forwarded = new Map(); + const proxyReq = { + setHeader: (name, value) => forwarded.set(name, value), + }; + + applyPreviewPassthroughRequestHeaders({ + headers: { + 'x-inertia': 'true', + 'x-inertia-version': 'asset-hash', + 'x-unrelated': 'ignored', + }, + }, proxyReq); + + expect(forwarded.get('x-inertia')).toBe('true'); + expect(forwarded.get('x-inertia-version')).toBe('asset-hash'); + expect(forwarded.has('x-unrelated')).toBe(false); + }); + + it('forwards Inertia response headers back to the preview client', () => { + const forwarded = new Map(); + const res = { + headersSent: false, + setHeader: (name, value) => forwarded.set(name, value), + }; + + applyPreviewPassthroughResponseHeaders({ + headers: { + 'x-inertia': 'true', + 'x-inertia-location': 'http://127.0.0.1:8000/login', + 'x-unrelated': 'ignored', + }, + }, res); + + expect(forwarded.get('x-inertia')).toBe('true'); + expect(forwarded.get('x-inertia-location')).toBe('http://127.0.0.1:8000/login'); + expect(forwarded.has('x-unrelated')).toBe(false); + }); +}); + describe('preview resource error classification', () => { it('suppresses Astro/Vite stylesheet modules reported as failed scripts', () => { expect(classifyPreviewResourceError({