Skip to content

Commit e7e0e20

Browse files
committed
fix: forward Inertia headers through preview proxy
Preserves Inertia navigation inside preview panel Forwards request and response Inertia headers Adds preview proxy header passthrough tests
1 parent fcb727b commit e7e0e20

2 files changed

Lines changed: 77 additions & 2 deletions

File tree

packages/web/server/lib/preview/proxy-runtime.js

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ const TOKEN_COOKIE_NAME = 'oc_preview_token';
33
const TOKEN_QUERY_PARAM = 'oc_preview_token';
44
const CLIENT_TOKEN_QUERY_PARAM = 'oc_client_token';
55
const URL_AUTH_TOKEN_QUERY_PARAM = 'oc_url_token';
6+
const PREVIEW_PASSTHROUGH_REQUEST_HEADERS = ['x-inertia', 'x-inertia-version'];
7+
const PREVIEW_PASSTHROUGH_RESPONSE_HEADERS = ['x-inertia', 'x-inertia-location'];
68

79
const LOOPBACK_HOSTS = new Set([
810
'localhost',
@@ -25,6 +27,34 @@ const parsePreviewResourcePath = (url) => {
2527
}
2628
};
2729

30+
const readHeader = (headers, name) => {
31+
if (!headers || typeof headers !== 'object') return undefined;
32+
const direct = headers[name];
33+
if (direct !== undefined) return direct;
34+
const lowerName = name.toLowerCase();
35+
const key = Object.keys(headers).find((entry) => entry.toLowerCase() === lowerName);
36+
return key ? headers[key] : undefined;
37+
};
38+
39+
export const applyPreviewPassthroughRequestHeaders = (req, proxyReq) => {
40+
for (const headerName of PREVIEW_PASSTHROUGH_REQUEST_HEADERS) {
41+
const value = readHeader(req?.headers, headerName);
42+
if (value !== undefined) {
43+
proxyReq.setHeader(headerName, value);
44+
}
45+
}
46+
};
47+
48+
export const applyPreviewPassthroughResponseHeaders = (proxyRes, res) => {
49+
if (!res || res.headersSent || typeof res.setHeader !== 'function') return;
50+
for (const headerName of PREVIEW_PASSTHROUGH_RESPONSE_HEADERS) {
51+
const value = readHeader(proxyRes?.headers, headerName);
52+
if (value !== undefined) {
53+
res.setHeader(headerName, value);
54+
}
55+
}
56+
};
57+
2858
const previewResourceNoiseRuleSets = [
2959
{
3060
name: 'vite',
@@ -1412,14 +1442,16 @@ export const createPreviewProxyRuntime = ({
14121442
return `${strippedPath}${withoutUrlAuthToken}`;
14131443
},
14141444
on: {
1415-
proxyReq: (proxyReq) => {
1445+
proxyReq: (proxyReq, req) => {
1446+
applyPreviewPassthroughRequestHeaders(req, proxyReq);
14161447
// Keep local dev servers from receiving OpenChamber credentials.
14171448
proxyReq.removeHeader('cookie');
14181449
proxyReq.removeHeader('authorization');
14191450
proxyReq.removeHeader('x-openchamber-ui-session');
14201451
proxyReq.setHeader('accept-encoding', 'identity');
14211452
},
1422-
proxyRes: responseInterceptor(async (responseBuffer, proxyRes, req) => {
1453+
proxyRes: responseInterceptor(async (responseBuffer, proxyRes, req, res) => {
1454+
applyPreviewPassthroughResponseHeaders(proxyRes, res);
14231455
// Per-response nonce lets the injected bridge run under the dev
14241456
// server's CSP without dropping its script restrictions wholesale.
14251457
const bridgeNonce = crypto.randomBytes(16).toString('base64');

packages/web/server/lib/preview/proxy-runtime.test.js

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { describe, expect, it } from 'vitest';
22

33
import {
4+
applyPreviewPassthroughRequestHeaders,
5+
applyPreviewPassthroughResponseHeaders,
46
classifyPreviewNavigation,
57
classifyPreviewResourceError,
68
normalizeProxyTargetUrl,
@@ -16,6 +18,47 @@ const rewrite = (bodyText, kind) => rewritePreviewBody({
1618
targetOrigin: 'http://127.0.0.1:3000',
1719
});
1820

21+
describe('preview Inertia header passthrough', () => {
22+
it('forwards Inertia request headers to the preview target', () => {
23+
const forwarded = new Map();
24+
const proxyReq = {
25+
setHeader: (name, value) => forwarded.set(name, value),
26+
};
27+
28+
applyPreviewPassthroughRequestHeaders({
29+
headers: {
30+
'x-inertia': 'true',
31+
'x-inertia-version': 'asset-hash',
32+
'x-unrelated': 'ignored',
33+
},
34+
}, proxyReq);
35+
36+
expect(forwarded.get('x-inertia')).toBe('true');
37+
expect(forwarded.get('x-inertia-version')).toBe('asset-hash');
38+
expect(forwarded.has('x-unrelated')).toBe(false);
39+
});
40+
41+
it('forwards Inertia response headers back to the preview client', () => {
42+
const forwarded = new Map();
43+
const res = {
44+
headersSent: false,
45+
setHeader: (name, value) => forwarded.set(name, value),
46+
};
47+
48+
applyPreviewPassthroughResponseHeaders({
49+
headers: {
50+
'x-inertia': 'true',
51+
'x-inertia-location': 'http://127.0.0.1:8000/login',
52+
'x-unrelated': 'ignored',
53+
},
54+
}, res);
55+
56+
expect(forwarded.get('x-inertia')).toBe('true');
57+
expect(forwarded.get('x-inertia-location')).toBe('http://127.0.0.1:8000/login');
58+
expect(forwarded.has('x-unrelated')).toBe(false);
59+
});
60+
});
61+
1962
describe('preview resource error classification', () => {
2063
it('suppresses Astro/Vite stylesheet modules reported as failed scripts', () => {
2164
expect(classifyPreviewResourceError({

0 commit comments

Comments
 (0)