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
This commit is contained in:
Bohdan Triapitsyn
2026-06-07 09:53:39 +03:00
parent 3b4d4e7caa
commit 14e75359a5
2 changed files with 77 additions and 2 deletions
@@ -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');
@@ -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({