Files
openchamber/packages/web/server/lib/preview/proxy-runtime.js
T
Bohdan Triapitsyn 2031e3b4a8 Decouple bundled UI from runtime API and add remote instance tooling (#1228)
Add a packaged-client runtime boundary so the shared UI can talk to local,
desktop, remote, and VS Code runtimes through the right transport instead of
assuming one same-origin web server.

Centralize OpenChamber-owned API access behind RuntimeAPIs, runtimeFetch, and
runtime URL helpers, while keeping official OpenCode traffic on the SDK path.
Support runtime switching, remote host selection, desktop client credentials,
and headless connection links for pairing packaged clients with remote
OpenChamber servers.

Harden the new auth model by moving long-lived client tokens out of browser
URLs, introducing short-lived scoped URL tokens for browser-owned transports,
restricting URL-token access to explicit readable/realtime routes, and making
client-token management session-scoped or self-scoped as appropriate.

Update browser-owned assets and preview proxy flows to work with the split
runtime model, including authenticated project icons, preview token propagation,
CSP-safe preview bridge injection, and preview proxy auth that survives
short-lived URL-token expiry.

Tighten Electron security boundaries for packaged clients by gating privileged
preload state to trusted origins and requiring explicit confirmation before
connect deep-links import or switch remote runtimes.

Also refresh agent guidance and project skills so future runtime/API, auth,
preview, UI, CLI, settings, locale, and drag-to-reorder work follows the new
architecture.
2026-06-02 00:43:05 +03:00

1554 lines
61 KiB
JavaScript

const DEFAULT_TARGET_TTL_MS = 30 * 60 * 1000;
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 LOOPBACK_HOSTS = new Set([
'localhost',
'127.0.0.1',
'::1',
'[::1]',
'0.0.0.0',
]);
const PREVIEW_BRIDGE_SCRIPT_ID = 'openchamber-preview-bridge';
const parsePreviewResourcePath = (url) => {
try {
const parsed = new URL(String(url || ''), 'http://localhost');
const match = parsed.pathname.match(/^\/api\/preview\/proxy\/[a-f0-9]{16,64}(\/.*)?$/i);
const path = match ? (match[1] || '/') : parsed.pathname;
return path + parsed.search;
} catch {
return String(url || '');
}
};
const previewResourceNoiseRuleSets = [
{
name: 'vite',
suppress: ({ lower, path, tag }) => path === '/@vite/client'
|| path === '/@react-refresh'
|| path.startsWith('/@id/__x00__vite/')
|| lower.includes('/node_modules/.vite/')
|| lower.includes('/vite/dist/client/')
|| (tag === 'script' && lower.includes('/@id/')),
},
{
name: 'astro',
suppress: ({ lower, path, tag }) => path.startsWith('/@id/astro:')
|| lower.includes('/astro/dist/runtime/client/dev-toolbar/')
|| (tag === 'script' && lower.includes('.astro?') && lower.includes('type=script'))
|| (tag === 'script' && (
lower.endsWith('.css')
|| lower.includes('.css?')
|| lower.includes('type=style')
|| lower.includes('lang.css')
)),
},
{
name: 'next',
suppress: ({ lower, path, tag }) => tag === 'script' && (
path === '/_next/webpack-hmr'
|| lower.includes('/_next/static/webpack/')
|| lower.includes('/_next/static/chunks/webpack')
|| lower.includes('/_next/static/chunks/react-refresh')
|| lower.includes('/_next/static/development/')
),
},
{
name: 'sveltekit',
suppress: ({ lower, tag }) => tag === 'script' && (
lower.includes('/@id/__x00__virtual:')
|| lower.includes('/@id/virtual:')
|| lower.includes('/.svelte-kit/generated/')
|| lower.includes('/node_modules/.vite/deps/')
),
},
{
name: 'remix',
suppress: ({ lower, tag }) => tag === 'script' && (
lower.includes('/@remix-run/dev/')
|| lower.includes('/__manifest')
|| lower.includes('/__hmr')
),
},
{
name: 'nuxt',
suppress: ({ lower, tag }) => tag === 'script' && (
lower.includes('/_nuxt/@vite/client')
|| lower.includes('/_nuxt/@id/')
|| lower.includes('/_nuxt/node_modules/.vite/')
|| lower.includes('/__nuxt_error')
|| lower.includes('/__nuxt_vite_node__')
),
},
{
name: 'webpack',
suppress: ({ lower, path, tag }) => tag === 'script' && (
path === '/sockjs-node/info'
|| lower.includes('/webpack-dev-server/')
|| lower.includes('/webpack/hot/')
|| lower.includes('/__webpack_hmr')
|| lower.includes('/ws') && lower.includes('webpack')
),
},
];
export const classifyPreviewResourceError = ({ tagName, url }) => {
const tag = typeof tagName === 'string' ? tagName.toLowerCase() : '';
if (tag !== 'script' && tag !== 'link') return 'report';
const pathAndSearch = parsePreviewResourcePath(url);
const lower = pathAndSearch.toLowerCase();
const path = pathAndSearch.split('?', 1)[0] || '';
const context = { tag, path, pathAndSearch, lower };
if (previewResourceNoiseRuleSets.some((ruleSet) => ruleSet.suppress(context))) return 'suppress';
return 'report';
};
export const classifyPreviewNavigation = ({ url, currentUrl, targetOrigin }) => {
let parsed;
try {
parsed = new URL(String(url || ''), currentUrl || 'http://localhost/');
} catch {
return { action: 'allow', url: String(url || '') };
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return { action: 'allow', url: parsed.toString() };
}
let current;
try {
current = new URL(currentUrl || 'http://localhost/');
} catch {
current = null;
}
if (current
&& parsed.origin === current.origin
&& parsed.pathname === current.pathname
&& parsed.search === current.search
&& parsed.hash
) {
return { action: 'allow', url: parsed.toString() };
}
const path = parsed.pathname || '/';
const proxyMatch = current?.pathname?.match(/^(\/api\/preview\/proxy\/[a-f0-9]{16,64})(?:\/|$)/i);
if (parsed.origin === current?.origin && path.startsWith('/api/preview/proxy/')) {
return { action: 'allow', url: parsed.toString() };
}
if (proxyMatch && parsed.origin === current?.origin && path.startsWith('/') && !path.startsWith(proxyMatch[1])) {
try {
if (targetOrigin) {
const upstreamUrl = new URL(`${parsed.pathname}${parsed.search}${parsed.hash}`, targetOrigin);
return { action: 'proxy', url: upstreamUrl.toString() };
}
} catch {
// fall through to the default policy
}
}
const host = parsed.hostname;
const isLoopback = host === 'localhost' || host === '127.0.0.1' || host === '0.0.0.0' || host === '::1' || host === '[::1]';
if (isLoopback || (parsed.origin === current?.origin && path.startsWith('/'))) {
return { action: 'proxy', url: parsed.toString() };
}
return { action: 'external', url: parsed.toString() };
};
const PREVIEW_BRIDGE_SCRIPT = String.raw`(() => {
if (window.__openchamberPreviewBridgeInstalled) return;
window.__openchamberPreviewBridgeInstalled = true;
const SOURCE = 'openchamber-preview-bridge';
const VERSION = 1;
const MAX_TEXT = 500;
const MAX_ARG = 1000;
const TARGET_ORIGIN = typeof window.__openchamberPreviewTargetOrigin === 'string' ? window.__openchamberPreviewTargetOrigin : '';
let inspectMode = false;
let lastHoverKey = '';
let pendingHover = null;
let previewColorScheme = null;
let nativeMatchMedia = null;
const colorSchemeListeners = new Set();
const parentOrigin = (() => {
try {
const ancestorOrigins = window.location && window.location.ancestorOrigins;
const ancestorOrigin = ancestorOrigins && ancestorOrigins.length > 0 ? ancestorOrigins[0] : '';
if (ancestorOrigin && ancestorOrigin !== 'null') return ancestorOrigin;
const origin = document.referrer ? new URL(document.referrer).origin : '';
return origin && origin !== 'null' ? origin : '';
} catch {
return '';
}
})();
const post = (payload) => {
try {
if (parentOrigin && window.parent && typeof window.parent.postMessage === 'function') {
const message = Object.assign({ source: SOURCE, version: VERSION }, payload || {});
window.parent.postMessage(message, parentOrigin);
}
} catch {}
};
const clip = (value, max = MAX_TEXT) => {
const text = String(value == null ? '' : value).replace(/\s+/g, ' ').trim();
return text.length > max ? text.slice(0, max) + '...' : text;
};
const stringifyArg = (value) => {
if (typeof value === 'string') return clip(value, MAX_ARG);
if (value instanceof Error) return clip(value.stack || value.message || String(value), MAX_ARG);
try {
return clip(JSON.stringify(value), MAX_ARG);
} catch {
return clip(String(value), MAX_ARG);
}
};
const normalizeColorScheme = (value) => value === 'dark' ? 'dark' : value === 'light' ? 'light' : null;
const mediaQueryColorScheme = (query) => {
const normalized = String(query || '').replace(/\s+/g, ' ').trim().toLowerCase();
if (normalized === '(prefers-color-scheme: dark)') return 'dark';
if (normalized === '(prefers-color-scheme: light)') return 'light';
return null;
};
const mediaQueryMatchesPreviewScheme = (query) => {
const scheme = mediaQueryColorScheme(query);
if (!scheme || !previewColorScheme) return null;
return previewColorScheme === scheme;
};
const notifyColorSchemeListeners = () => {
for (const listener of Array.from(colorSchemeListeners)) {
try {
const matches = mediaQueryMatchesPreviewScheme(listener.media);
if (matches === null) continue;
const event = { matches, media: listener.media, type: 'change', target: listener.mql, currentTarget: listener.mql };
listener.callback.call(listener.mql, event);
} catch {}
}
};
const installColorSchemeMatchMediaPatch = () => {
if (window.__openchamberPreviewColorSchemePatched || typeof window.matchMedia !== 'function') return;
window.__openchamberPreviewColorSchemePatched = true;
nativeMatchMedia = window.matchMedia.bind(window);
window.matchMedia = function(query) {
const nativeMql = nativeMatchMedia(query);
if (!mediaQueryColorScheme(query)) return nativeMql;
const listenersForMql = new Map();
const mql = Object.create(nativeMql);
Object.defineProperty(mql, 'matches', { get: () => mediaQueryMatchesPreviewScheme(query) ?? nativeMql.matches });
Object.defineProperty(mql, 'media', { get: () => nativeMql.media });
mql.addEventListener = function(type, callback, options) {
if (type !== 'change' || typeof callback !== 'function') return nativeMql.addEventListener?.(type, callback, options);
const entry = { media: query, mql, callback };
listenersForMql.set(callback, entry);
colorSchemeListeners.add(entry);
};
mql.removeEventListener = function(type, callback, options) {
if (type !== 'change' || typeof callback !== 'function') return nativeMql.removeEventListener?.(type, callback, options);
const entry = listenersForMql.get(callback);
if (entry) colorSchemeListeners.delete(entry);
listenersForMql.delete(callback);
};
mql.addListener = function(callback) { mql.addEventListener('change', callback); };
mql.removeListener = function(callback) { mql.removeEventListener('change', callback); };
return mql;
};
};
const shouldSyncDataTheme = () => {
try {
const root = document.documentElement;
if (!root) return false;
if (root.hasAttribute('data-theme')) return true;
if (document.querySelector('starlight-theme-select, starlight-menu-button')) return true;
const generator = document.querySelector('meta[name="generator"]');
const generatorContent = generator && typeof generator.getAttribute === 'function' ? generator.getAttribute('content') || '' : '';
if (generatorContent.toLowerCase().indexOf('starlight') >= 0) return true;
const styles = window.getComputedStyle(root);
return Boolean(styles.getPropertyValue('--sl-color-bg').trim()
|| styles.getPropertyValue('--sl-color-text').trim()
|| styles.getPropertyValue('--sl-color-accent').trim());
} catch {
return false;
}
};
const applyPreviewColorScheme = (scheme) => {
const next = normalizeColorScheme(scheme);
if (!next || previewColorScheme === next) return;
previewColorScheme = next;
try {
const root = document.documentElement;
root.style.colorScheme = next;
root.dataset.openchamberPreviewColorScheme = next;
if (shouldSyncDataTheme()) {
root.dataset.theme = next;
}
} catch {}
notifyColorSchemeListeners();
};
const readElementUrl = (element) => {
return element.currentSrc || element.src || element.href || element.action || '';
};
const upstreamPathForUrl = (value) => {
try {
const parsed = new URL(value, window.location.href);
const match = parsed.pathname.match(/^\/api\/preview\/proxy\/[a-f0-9]{16,64}(\/.*)?$/i);
return match ? (match[1] || '/') : parsed.pathname;
} catch {
return String(value || '');
}
};
const upstreamPathAndSearchForUrl = (value) => {
try {
const parsed = new URL(value, window.location.href);
const match = parsed.pathname.match(/^\/api\/preview\/proxy\/[a-f0-9]{16,64}(\/.*)?$/i);
const path = match ? (match[1] || '/') : parsed.pathname;
return path + parsed.search;
} catch {
return String(value || '');
}
};
const isInternalDevToolResource = (element, value) => {
const tag = element && element.tagName && typeof element.tagName.toLowerCase === 'function' ? element.tagName.toLowerCase() : '';
if (tag !== 'script' && tag !== 'link') return false;
if (tag === 'script' && typeof element.hasAttribute === 'function' && element.hasAttribute('data-cf-beacon')) return true;
const pathAndSearch = upstreamPathAndSearchForUrl(value);
const lower = pathAndSearch.toLowerCase();
const path = pathAndSearch.split('?', 1)[0] || '';
const viteNoise = path === '/@vite/client'
|| path === '/@react-refresh'
|| path.indexOf('/@id/__x00__vite/') === 0
|| lower.indexOf('/node_modules/.vite/') >= 0
|| lower.indexOf('/vite/dist/client/') >= 0
|| (tag === 'script' && lower.indexOf('/@id/') >= 0);
const astroNoise = path.indexOf('/@id/astro:') === 0
|| lower.indexOf('/astro/dist/runtime/client/dev-toolbar/') >= 0
|| (tag === 'script' && lower.indexOf('.astro?') >= 0 && lower.indexOf('type=script') >= 0)
|| (tag === 'script' && (
lower.endsWith('.css')
|| lower.indexOf('.css?') >= 0
|| lower.indexOf('type=style') >= 0
|| lower.indexOf('lang.css') >= 0
));
const nextNoise = tag === 'script' && (
path === '/_next/webpack-hmr'
|| lower.indexOf('/_next/static/webpack/') >= 0
|| lower.indexOf('/_next/static/chunks/webpack') >= 0
|| lower.indexOf('/_next/static/chunks/react-refresh') >= 0
|| lower.indexOf('/_next/static/development/') >= 0
);
const svelteKitNoise = tag === 'script' && (
lower.indexOf('/@id/__x00__virtual:') >= 0
|| lower.indexOf('/@id/virtual:') >= 0
|| lower.indexOf('/.svelte-kit/generated/') >= 0
|| lower.indexOf('/node_modules/.vite/deps/') >= 0
);
const remixNoise = tag === 'script' && (
lower.indexOf('/@remix-run/dev/') >= 0
|| lower.indexOf('/__manifest') >= 0
|| lower.indexOf('/__hmr') >= 0
);
const nuxtNoise = tag === 'script' && (
lower.indexOf('/_nuxt/@vite/client') >= 0
|| lower.indexOf('/_nuxt/@id/') >= 0
|| lower.indexOf('/_nuxt/node_modules/.vite/') >= 0
|| lower.indexOf('/__nuxt_error') >= 0
|| lower.indexOf('/__nuxt_vite_node__') >= 0
);
const webpackNoise = tag === 'script' && (
path === '/sockjs-node/info'
|| lower.indexOf('/webpack-dev-server/') >= 0
|| lower.indexOf('/webpack/hot/') >= 0
|| lower.indexOf('/__webpack_hmr') >= 0
|| (lower.indexOf('/ws') >= 0 && lower.indexOf('webpack') >= 0)
);
if (viteNoise || astroNoise || nextNoise || svelteKitNoise || remixNoise || nuxtNoise || webpackNoise) return true;
return false;
};
installColorSchemeMatchMediaPatch();
const classifyNavigation = (value) => {
try {
const parsed = new URL(value, window.location.href);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return { action: 'allow', url: parsed.toString() };
const current = new URL(window.location.href);
const proxyMatch = current.pathname.match(/^(\/api\/preview\/proxy\/[a-f0-9]{16,64})(?:\/|$)/i);
if (parsed.origin === current.origin && parsed.pathname === current.pathname && parsed.search === current.search && parsed.hash) {
return { action: 'allow', url: parsed.toString() };
}
if (parsed.origin === current.origin && parsed.pathname.startsWith('/api/preview/proxy/')) {
return { action: 'allow', url: parsed.toString() };
}
if (proxyMatch && parsed.origin === current.origin && parsed.pathname.startsWith('/') && parsed.pathname.indexOf(proxyMatch[1]) !== 0 && TARGET_ORIGIN) {
try {
const upstreamUrl = new URL(parsed.pathname + parsed.search + parsed.hash, TARGET_ORIGIN);
return { action: 'proxy', url: upstreamUrl.toString() };
} catch {}
}
const host = parsed.hostname;
const isLoopback = host === 'localhost' || host === '127.0.0.1' || host === '0.0.0.0' || host === '::1' || host === '[::1]';
if (isLoopback || (parsed.origin === current.origin && parsed.pathname.startsWith('/'))) {
return { action: 'proxy', url: parsed.toString() };
}
return { action: 'external', url: parsed.toString() };
} catch {
return { action: 'allow', url: String(value || '') };
}
};
const isInternalDevToolRuntimeError = (filename) => {
const path = upstreamPathForUrl(filename || '');
const pathAndSearch = upstreamPathAndSearchForUrl(filename || '');
const lowerPathAndSearch = pathAndSearch.toLowerCase();
const isStyleRuntimeNoise = lowerPathAndSearch.endsWith('.css')
|| lowerPathAndSearch.indexOf('.css?') >= 0
|| lowerPathAndSearch.indexOf('type=style') >= 0
|| lowerPathAndSearch.indexOf('lang.css') >= 0;
return path === '/@vite/client'
|| path === '/@react-refresh'
|| path.indexOf('/astro/dist/runtime/client/dev-toolbar/') >= 0
|| path.indexOf('/node_modules/.vite/') >= 0
|| isStyleRuntimeNoise;
};
const isInternalDevToolConsoleNoise = (level, args) => {
if (level !== 'error' || typeof args[0] !== 'string' || args[0].indexOf('[vite]') !== 0) return false;
const text = args.map((arg) => stringifyArg(arg)).join(' ');
return text.indexOf('failed to connect to websocket') >= 0
|| text.indexOf("Cannot read properties of undefined (reading 'send')") >= 0
|| text.indexOf('Cannot read properties of undefined (reading "send")') >= 0;
};
const installViteHmrProxyPatch = () => {
if (window.__openchamberViteHmrProxyPatched || typeof window.WebSocket !== 'function') return;
window.__openchamberViteHmrProxyPatched = true;
const NativeWebSocket = window.WebSocket;
const proxyMatch = window.location.pathname.match(/^(\/api\/preview\/proxy\/[a-f0-9]{16,64})(?:\/|$)/i);
if (!proxyMatch) return;
const proxyBase = proxyMatch[1] + '/';
const currentSearchParams = new URL(window.location.href).searchParams;
const previewToken = currentSearchParams.get('oc_preview_token') || '';
const urlAuthToken = currentSearchParams.get('oc_url_token') || '';
let reloadTimer = 0;
const schedulePreviewReload = () => {
if (reloadTimer) return;
reloadTimer = window.setTimeout(() => {
reloadTimer = 0;
try {
window.location.reload();
} catch {}
}, 80);
};
const rewriteUrl = (url, protocols) => {
const protocolList = Array.isArray(protocols) ? protocols : [protocols];
const isViteSocket = protocolList.indexOf('vite-hmr') >= 0 || protocolList.indexOf('vite-ping') >= 0;
if (!isViteSocket) return url;
try {
const parsed = new URL(String(url), window.location.href);
if (parsed.host !== window.location.host) return url;
if (parsed.pathname.indexOf(proxyBase) !== 0) {
parsed.pathname = proxyBase;
}
if (previewToken) parsed.searchParams.set('oc_preview_token', previewToken);
if (urlAuthToken) parsed.searchParams.set('oc_url_token', urlAuthToken);
return parsed.toString();
} catch {
return url;
}
};
function OpenChamberPreviewWebSocket(url, protocols) {
const protocolList = Array.isArray(protocols) ? protocols : [protocols];
const isViteSocket = protocolList.indexOf('vite-hmr') >= 0;
const nextUrl = rewriteUrl(url, protocols);
const socket = arguments.length === 1
? new NativeWebSocket(nextUrl)
: new NativeWebSocket(nextUrl, protocols);
if (isViteSocket) {
socket.addEventListener('message', (event) => {
try {
const payload = JSON.parse(String(event.data || ''));
if (payload && (payload.type === 'update' || payload.type === 'full-reload')) {
schedulePreviewReload();
}
} catch {}
});
}
return socket;
}
OpenChamberPreviewWebSocket.prototype = NativeWebSocket.prototype;
Object.setPrototypeOf(OpenChamberPreviewWebSocket, NativeWebSocket);
Object.defineProperty(OpenChamberPreviewWebSocket, 'name', { value: 'WebSocket' });
window.WebSocket = OpenChamberPreviewWebSocket;
};
const installAppRequestProxyPatch = () => {
if (window.__openchamberAppRequestProxyPatched) return;
window.__openchamberAppRequestProxyPatched = true;
const proxyMatch = window.location.pathname.match(/^(\/api\/preview\/proxy\/[a-f0-9]{16,64})(?:\/|$)/i);
if (!proxyMatch) return;
const proxyBase = proxyMatch[1];
const currentSearchParams = new URL(window.location.href).searchParams;
const previewToken = currentSearchParams.get('oc_preview_token') || '';
const urlAuthToken = currentSearchParams.get('oc_url_token') || '';
const withProxyAuth = (value) => {
if (typeof value !== 'string' || value.indexOf(proxyBase) !== 0) return value;
if (!previewToken && !urlAuthToken) return value;
try {
const parsed = new URL(value, window.location.origin);
parsed.searchParams.delete('oc_client_token');
if (previewToken) parsed.searchParams.set('oc_preview_token', previewToken);
if (urlAuthToken) parsed.searchParams.set('oc_url_token', urlAuthToken);
return parsed.pathname + parsed.search + parsed.hash;
} catch {
return value;
}
};
const shouldProxyPath = (pathname) => {
if (typeof pathname !== 'string' || !pathname.startsWith('/') || pathname.startsWith('//')) return false;
if (pathname.indexOf(proxyBase) === 0) return false;
return true;
};
const proxiedUrl = (value) => {
if (typeof value !== 'string') return value;
if (value.startsWith('/')) {
if (value.indexOf(proxyBase) === 0) return withProxyAuth(value);
if (!shouldProxyPath(value)) return value;
return withProxyAuth(proxyBase + value);
}
try {
const parsed = new URL(value, window.location.href);
if (parsed.origin === window.location.origin && shouldProxyPath(parsed.pathname)) {
return withProxyAuth(proxyBase + parsed.pathname + parsed.search + parsed.hash);
}
} catch {}
return value;
};
const proxiedWebSocketUrl = (value) => {
if (typeof value !== 'string') return value;
try {
const parsed = new URL(value, window.location.href);
const current = new URL(window.location.href);
const sameHost = parsed.host === current.host;
const isWebSocketProtocol = parsed.protocol === 'ws:' || parsed.protocol === 'wss:';
if (sameHost && isWebSocketProtocol && shouldProxyPath(parsed.pathname)) {
parsed.pathname = proxyBase + parsed.pathname;
parsed.searchParams.delete('oc_client_token');
if (previewToken) parsed.searchParams.set('oc_preview_token', previewToken);
if (urlAuthToken) parsed.searchParams.set('oc_url_token', urlAuthToken);
return parsed.toString();
}
} catch {}
return value;
};
const proxiedNavigationUrl = (value) => {
if (typeof value !== 'string') return value;
try {
const parsed = new URL(value, window.location.href);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return value;
if (parsed.origin === window.location.origin && parsed.pathname.indexOf(proxyBase) === 0) {
return withProxyAuth(parsed.pathname + parsed.search + parsed.hash);
}
const host = parsed.hostname;
const isLoopback = host === 'localhost' || host === '127.0.0.1' || host === '0.0.0.0' || host === '::1' || host === '[::1]';
if (!isLoopback && parsed.origin !== window.location.origin) return value;
if (!shouldProxyPath(parsed.pathname)) return value;
return withProxyAuth(proxyBase + parsed.pathname + parsed.search + parsed.hash);
} catch {
return proxiedUrl(value);
}
};
if (window.history && typeof window.history.pushState === 'function') {
const nativePushState = window.history.pushState.bind(window.history);
window.history.pushState = function(state, unused, url) {
return nativePushState(state, unused, url === undefined ? url : proxiedNavigationUrl(String(url)));
};
}
if (window.history && typeof window.history.replaceState === 'function') {
const nativeReplaceState = window.history.replaceState.bind(window.history);
window.history.replaceState = function(state, unused, url) {
return nativeReplaceState(state, unused, url === undefined ? url : proxiedNavigationUrl(String(url)));
};
}
if (typeof window.fetch === 'function') {
const nativeFetch = window.fetch.bind(window);
window.fetch = function(input, init) {
if (typeof input === 'string') {
return nativeFetch(proxiedUrl(input), init);
}
if (input instanceof Request) {
try {
const parsed = new URL(input.url);
if (parsed.origin === window.location.origin && shouldProxyPath(parsed.pathname)) {
const nextUrl = withProxyAuth(proxyBase + parsed.pathname + parsed.search + parsed.hash);
return nativeFetch(new Request(nextUrl, input), init);
}
} catch {}
}
return nativeFetch(input, init);
};
}
if (window.XMLHttpRequest && window.XMLHttpRequest.prototype) {
const nativeOpen = window.XMLHttpRequest.prototype.open;
window.XMLHttpRequest.prototype.open = function(method, url) {
const args = Array.prototype.slice.call(arguments);
if (typeof url === 'string') {
args[1] = proxiedUrl(url);
}
return nativeOpen.apply(this, args);
};
}
if (typeof window.EventSource === 'function') {
const NativeEventSource = window.EventSource;
function OpenChamberPreviewEventSource(url, eventSourceInitDict) {
return new NativeEventSource(proxiedUrl(String(url)), eventSourceInitDict);
}
OpenChamberPreviewEventSource.prototype = NativeEventSource.prototype;
Object.setPrototypeOf(OpenChamberPreviewEventSource, NativeEventSource);
Object.defineProperty(OpenChamberPreviewEventSource, 'name', { value: 'EventSource' });
window.EventSource = OpenChamberPreviewEventSource;
}
if (typeof window.WebSocket === 'function') {
const NativeWebSocket = window.WebSocket;
function OpenChamberPreviewAppWebSocket(url, protocols) {
const nextUrl = proxiedWebSocketUrl(String(url));
return arguments.length === 1
? new NativeWebSocket(nextUrl)
: new NativeWebSocket(nextUrl, protocols);
}
OpenChamberPreviewAppWebSocket.prototype = NativeWebSocket.prototype;
Object.setPrototypeOf(OpenChamberPreviewAppWebSocket, NativeWebSocket);
Object.defineProperty(OpenChamberPreviewAppWebSocket, 'name', { value: 'WebSocket' });
window.WebSocket = OpenChamberPreviewAppWebSocket;
}
};
const selectorPart = (element) => {
const tag = element.tagName.toLowerCase();
if (element.id && /^[A-Za-z][\w:.-]*$/.test(element.id)) return tag + '#' + CSS.escape(element.id);
const testId = element.getAttribute('data-testid') || element.getAttribute('data-test') || element.getAttribute('data-cy');
if (testId) return tag + '[data-testid="' + CSS.escape(testId) + '"]';
const classes = Array.from(element.classList || []).slice(0, 3).map((entry) => '.' + CSS.escape(entry)).join('');
return tag + classes;
};
const buildSelector = (element) => {
const parts = [];
let current = element;
while (current && current.nodeType === Node.ELEMENT_NODE && current !== document.documentElement) {
let part = selectorPart(current);
const parent = current.parentElement;
if (parent) {
const siblings = Array.from(parent.children).filter((child) => child.tagName === current.tagName);
if (siblings.length > 1 && !part.includes('#') && !part.includes('[data-testid=')) {
part += ':nth-of-type(' + (siblings.indexOf(current) + 1) + ')';
}
}
parts.unshift(part);
if (part.includes('#')) break;
current = parent;
}
return parts.join(' > ');
};
const metadataForElement = (element) => {
if (!element || element.nodeType !== Node.ELEMENT_NODE) return null;
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
const attributes = {};
for (const name of ['id', 'class', 'role', 'aria-label', 'href', 'src', 'data-testid', 'data-test', 'data-cy']) {
const value = typeof element.getAttribute === 'function' ? element.getAttribute(name) : null;
if (value) attributes[name] = clip(value, 300);
}
const ancestry = [];
let current = element;
while (current && current.nodeType === Node.ELEMENT_NODE && ancestry.length < 6) {
ancestry.unshift({
tag: current.tagName.toLowerCase(),
id: current.id || undefined,
className: clip(current.className || '', 200) || undefined,
selectorPart: selectorPart(current),
});
current = current.parentElement;
}
return {
frame: 'top',
tag: element.tagName.toLowerCase(),
text: clip(element.innerText || element.textContent || ''),
selector: buildSelector(element),
path: ancestry.map((entry) => entry.tag).join(' > '),
bounds: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
center: { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 },
attributes,
computedStyle: {
display: style.display,
position: style.position,
color: style.color,
backgroundColor: style.backgroundColor,
fontFamily: style.fontFamily,
fontSize: style.fontSize,
fontWeight: style.fontWeight,
lineHeight: style.lineHeight,
zIndex: style.zIndex,
},
ancestry,
};
};
const hoverKeyForTarget = (target) => {
if (!target) return '';
const bounds = target.bounds || {};
return [target.selector, Math.round(bounds.x), Math.round(bounds.y), Math.round(bounds.width), Math.round(bounds.height)].join('|');
};
const sendHover = (event) => {
if (!inspectMode) return;
pendingHover = event;
if (window.__openchamberPreviewHoverFrame) return;
window.__openchamberPreviewHoverFrame = window.requestAnimationFrame(() => {
window.__openchamberPreviewHoverFrame = 0;
const currentEvent = pendingHover;
pendingHover = null;
if (!currentEvent || !inspectMode) return;
const element = document.elementFromPoint(currentEvent.clientX, currentEvent.clientY);
const target = metadataForElement(element);
const key = hoverKeyForTarget(target);
if (key === lastHoverKey) return;
lastHoverKey = key;
post({ type: 'hover', target, pointer: { x: currentEvent.clientX, y: currentEvent.clientY }, ts: Date.now() });
});
};
const setInspectMode = (enabled) => {
inspectMode = Boolean(enabled);
lastHoverKey = '';
document.documentElement.style.cursor = inspectMode ? 'crosshair' : '';
if (!inspectMode) {
post({ type: 'hover', target: null, pointer: { x: 0, y: 0 }, ts: Date.now() });
}
};
for (const level of ['log', 'info', 'warn', 'error', 'debug']) {
const original = console[level];
console[level] = function() {
const args = Array.prototype.slice.call(arguments);
if (level === 'debug' && typeof args[0] === 'string' && args[0].indexOf('[vite]') === 0) {
return original.apply(console, args);
}
if (isInternalDevToolConsoleNoise(level, args)) {
return original.apply(console, args);
}
post({ type: 'console', level, args: args.map(stringifyArg), ts: Date.now() });
return original.apply(console, args);
};
}
installViteHmrProxyPatch();
installAppRequestProxyPatch();
window.addEventListener('error', (event) => {
const target = event.target;
if (target && target !== window && target.nodeType === Node.ELEMENT_NODE) {
const url = readElementUrl(target);
if (isInternalDevToolResource(target, url)) {
return;
}
post({
type: 'resource-error',
tag: target.tagName.toLowerCase(),
url: clip(url, 1000),
outerHTML: clip(target.outerHTML || '', 1000),
ts: Date.now(),
});
return;
}
if (isInternalDevToolRuntimeError(event.filename)) {
return;
}
post({
type: 'runtime-error',
message: clip(event.message || 'Unknown error', 1000),
stack: clip(event.error && event.error.stack ? event.error.stack : '', 2000) || undefined,
filename: event.filename,
line: event.lineno,
column: event.colno,
ts: Date.now(),
});
}, true);
window.addEventListener('unhandledrejection', (event) => {
post({
type: 'runtime-error',
message: clip(event.reason && event.reason.message ? event.reason.message : event.reason || 'Unhandled promise rejection', 1000),
stack: clip(event.reason && event.reason.stack ? event.reason.stack : '', 2000) || undefined,
ts: Date.now(),
});
});
window.addEventListener('message', (event) => {
if (event.source !== window.parent) return;
const data = event.data;
if (!data || data.source !== 'openchamber-preview-parent' || data.version !== VERSION) return;
if (data.type === 'set-inspect-mode') {
setInspectMode(data.enabled === true);
}
if (data.type === 'set-color-scheme') {
applyPreviewColorScheme(data.scheme);
}
});
window.addEventListener('mousemove', sendHover, true);
window.addEventListener('mouseleave', () => {
if (!inspectMode) return;
lastHoverKey = '';
post({ type: 'hover', target: null, pointer: { x: 0, y: 0 }, ts: Date.now() });
}, true);
window.addEventListener('click', (event) => {
const anchor = event.target && typeof event.target.closest === 'function' ? event.target.closest('a[href]') : null;
if (anchor && !inspectMode) {
const navigation = classifyNavigation(anchor.href);
if (navigation.action === 'proxy' || navigation.action === 'external') {
event.preventDefault();
event.stopPropagation();
post({ type: 'navigate-preview', url: navigation.url, navigation: navigation.action, ts: Date.now() });
return;
}
}
if (!inspectMode) return;
event.preventDefault();
event.stopPropagation();
const element = document.elementFromPoint(event.clientX, event.clientY);
const target = metadataForElement(element);
if (target) {
post({ type: 'select', target, pointer: { x: event.clientX, y: event.clientY }, ts: Date.now() });
}
}, true);
window.addEventListener('DOMContentLoaded', () => {
post({ type: 'ready', url: window.location.href, title: document.title || '' });
});
post({ type: 'ready', url: window.location.href, title: document.title || '' });
})();`;
const parseCookieHeader = (cookieHeader) => {
const result = new Map();
if (typeof cookieHeader !== 'string' || cookieHeader.length === 0) {
return result;
}
const parts = cookieHeader.split(';');
for (const part of parts) {
const idx = part.indexOf('=');
if (idx <= 0) {
continue;
}
const key = part.slice(0, idx).trim();
const value = part.slice(idx + 1).trim();
if (!key) {
continue;
}
result.set(key, value);
}
return result;
};
const buildCookie = ({
name,
value,
path,
maxAgeSeconds,
secure,
}) => {
const chunks = [`${name}=${value}`];
if (path) chunks.push(`Path=${path}`);
if (typeof maxAgeSeconds === 'number' && Number.isFinite(maxAgeSeconds)) {
chunks.push(`Max-Age=${Math.max(0, Math.trunc(maxAgeSeconds))}`);
}
chunks.push('HttpOnly');
chunks.push('SameSite=Lax');
if (secure) chunks.push('Secure');
return chunks.join('; ');
};
// SSRF guard for the `allowExternal` path: refuse to proxy private, loopback and
// reserved addresses (incl. cloud-metadata 169.254.169.254). Operates on the
// WHATWG-normalized hostname, so decimal/hex/octal IPv4 forms are already canonical
// dotted-decimal here. NOTE: this blocks IP *literals* only — a hostname that
// resolves to a private IP (DNS rebinding) is not caught and would need
// resolve-time IP pinning. Loopback for local preview goes through the non-external
// path (allowExternal=false), which is unaffected.
const isBlockedExternalHost = (hostname) => {
if (!hostname) return true;
let host = hostname.toLowerCase();
if (host.startsWith('[') && host.endsWith(']')) host = host.slice(1, -1);
if (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local')) return true;
const v4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (v4) {
const a = Number(v4[1]);
const b = Number(v4[2]);
if (a === 0 || a === 127 || a === 10) return true; // this-host / loopback / private
if (a === 169 && b === 254) return true; // link-local incl. cloud metadata
if (a === 172 && b >= 16 && b <= 31) return true; // private
if (a === 192 && b === 168) return true; // private
if (a === 100 && b >= 64 && b <= 127) return true; // carrier-grade NAT
return false;
}
if (host.includes(':')) {
if (host === '::1' || host === '::') return true; // loopback / unspecified
if (host.startsWith('fe80')) return true; // link-local
if (host.startsWith('fc') || host.startsWith('fd')) return true; // unique local fc00::/7
if (host.includes('::ffff:')) return true; // IPv4-mapped (dotted or hex form)
return false;
}
return false;
};
export const normalizeProxyTargetUrl = (rawUrl, { allowExternal = false } = {}) => {
let url;
try {
url = new URL(rawUrl);
} catch {
return { ok: false, error: 'Invalid URL' };
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return { ok: false, error: 'Only http(s) URLs are supported' };
}
const hostname = url.hostname;
if (!allowExternal) {
if (!LOOPBACK_HOSTS.has(hostname)) {
return { ok: false, error: 'Only loopback hosts are supported' };
}
} else if (isBlockedExternalHost(hostname)) {
return { ok: false, error: 'Refusing to proxy private or reserved addresses' };
}
const port = url.port ? Number.parseInt(url.port, 10) : (url.protocol === 'https:' ? 443 : 80);
if (!Number.isFinite(port) || port <= 0 || port > 65535) {
return { ok: false, error: 'Invalid port' };
}
// Normalize common loopback hostnames to IPv4 to avoid environments where
// `localhost` resolves to ::1 but the dev server only binds IPv4.
if (LOOPBACK_HOSTS.has(hostname) && (hostname === '0.0.0.0' || hostname === 'localhost' || hostname === '::1' || hostname === '[::1]')) {
url.hostname = '127.0.0.1';
}
// Only keep origin here; the proxy path is preserved on the OpenChamber side.
return { ok: true, origin: url.origin };
};
const appendProxyAuthToProxyUrl = (value, { previewToken = '', urlAuthToken = '' } = {}) => {
if (typeof value !== 'string' || !value) return value;
const needsQueryRewrite = previewToken
|| urlAuthToken
|| value.includes(CLIENT_TOKEN_QUERY_PARAM)
|| value.includes(URL_AUTH_TOKEN_QUERY_PARAM);
if (!needsQueryRewrite) return value;
try {
const parsed = new URL(value, 'http://openchamber-preview.local');
parsed.searchParams.delete(CLIENT_TOKEN_QUERY_PARAM);
parsed.searchParams.delete(URL_AUTH_TOKEN_QUERY_PARAM);
if (previewToken) parsed.searchParams.set(TOKEN_QUERY_PARAM, previewToken);
if (urlAuthToken) parsed.searchParams.set(URL_AUTH_TOKEN_QUERY_PARAM, urlAuthToken);
return `${parsed.pathname}${parsed.search}${parsed.hash}`;
} catch {
return value;
}
};
const normalizeLoopbackUrl = (rawUrl) => normalizeProxyTargetUrl(rawUrl, { allowExternal: false });
export const rewritePreviewBody = ({ bodyText, proxyBasePath, targetOrigin, kind, previewToken = '', urlAuthToken = '' }) => {
if (typeof bodyText !== 'string' || bodyText.length === 0) {
return bodyText;
}
const prefix = proxyBasePath.endsWith('/') ? proxyBasePath.slice(0, -1) : proxyBasePath;
const target = targetOrigin ? new URL(targetOrigin) : null;
const isSameTargetOrigin = (url) => {
if (!target) return false;
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
if (url.origin === target.origin) return true;
const host = url.hostname;
if (host !== 'localhost' && host !== '127.0.0.1' && host !== '0.0.0.0' && host !== '::1' && host !== '[::1]') {
return false;
}
return url.port === target.port;
};
const rewriteResourceUrl = (value) => {
if (typeof value !== 'string' || value.length === 0) return value;
if (value.startsWith('/') && !value.startsWith('//')) {
if (value.startsWith('/api/preview/proxy/')) return appendProxyAuthToProxyUrl(value, { previewToken, urlAuthToken });
return appendProxyAuthToProxyUrl(`${prefix}${value}`, { previewToken, urlAuthToken });
}
try {
const parsed = new URL(value);
if (isSameTargetOrigin(parsed)) {
return appendProxyAuthToProxyUrl(`${prefix}${parsed.pathname}${parsed.search}${parsed.hash}`, { previewToken, urlAuthToken });
}
} catch {
return value;
}
return value;
};
const rewriteHtml = (text) => text
.replace(/\b(src|href|action)=(['"])([^'"]*)\2/gi, (_match, attr, quote, value) => {
return `${attr}=${quote}${rewriteResourceUrl(value)}${quote}`;
})
.replace(/\bsrcset=(['"])([^'"]*)\1/gi, (_match, quote, value) => {
const rewritten = String(value).split(',').map((part) => {
const trimmed = part.trim();
if (!trimmed) return trimmed;
const segments = trimmed.split(/\s+/);
const url = segments[0] || '';
segments[0] = rewriteResourceUrl(url);
return segments.join(' ');
}).join(', ');
return `srcset=${quote}${rewritten}${quote}`;
});
const stripPreviewCspMeta = (text) => text
.replace(/<meta\b(?=[^>]*\bhttp-equiv\s*=\s*(['"])content-security-policy\1)[^>]*>/gi, '')
.replace(/<meta\b(?=[^>]*\bhttp-equiv\s*=\s*content-security-policy\b)[^>]*>/gi, '');
const rewriteCss = (text) => text
.replace(/url\((['"]?)([^)'"]*)\1\)/gi, (_match, quote, value) => {
const q = quote || '';
return `url(${q}${rewriteResourceUrl(value)}${q})`;
})
.replace(/@import\s+(['"])\/(?!\/)([^'"]*)\1/gi, (_match, quote, path) => {
return `@import ${quote}${rewriteResourceUrl(`/${path}`)}${quote}`;
});
const rewriteJavaScript = (text) => text
.replace(/\bfrom\s+(['"])\/(?!\/)([^'"]*)\1/gi, (_match, quote, path) => {
return `from ${quote}${rewriteResourceUrl(`/${path}`)}${quote}`;
})
.replace(/\bimport\s+(['"])\/(?!\/)([^'"]*)\1/gi, (_match, quote, path) => {
return `import ${quote}${rewriteResourceUrl(`/${path}`)}${quote}`;
})
.replace(/\bimport\(\s*(['"])\/(?!\/)([^'"]*)\1\s*\)/gi, (_match, quote, path) => {
return `import(${quote}${rewriteResourceUrl(`/${path}`)}${quote})`;
});
if (kind === 'html') return stripPreviewCspMeta(rewriteHtml(bodyText));
if (kind === 'css') return rewriteCss(bodyText);
if (kind === 'javascript') return rewriteJavaScript(bodyText);
return bodyText;
};
// Rewrite a dev server's CSP so the injected preview bridge can run via a
// per-response nonce, while keeping the dev server's own script restrictions.
// frame-ancestors is dropped (it blocks embedding) and require-trusted-types-for
// is dropped (it can block the bridge's DOM use); everything else is preserved.
export const rewritePreviewCspHeader = (cspValue, nonce) => {
if (typeof cspValue !== 'string' || cspValue.length === 0) return cspValue;
const nonceSource = nonce ? `'nonce-${nonce}'` : '';
const directives = cspValue
.split(';')
.map((part) => part.trim())
.filter(Boolean)
.map((part) => {
const tokens = part.split(/\s+/);
return { name: (tokens[0] || '').toLowerCase(), tokens };
})
.filter((directive) => directive.name !== 'frame-ancestors' && directive.name !== 'require-trusted-types-for');
if (nonceSource) {
const byName = new Map(directives.map((directive) => [directive.name, directive]));
const allowNonce = (directive) => {
// Drop a lone 'none' so the nonce takes effect, then add our nonce.
directive.tokens = directive.tokens.filter((token) => token.toLowerCase() !== "'none'");
if (!directive.tokens.includes(nonceSource)) directive.tokens.push(nonceSource);
};
const scriptElem = byName.get('script-src-elem');
const scriptSrc = byName.get('script-src');
if (scriptElem) allowNonce(scriptElem);
if (scriptSrc) allowNonce(scriptSrc);
if (!scriptElem && !scriptSrc && byName.has('default-src')) {
const base = byName.get('default-src').tokens.slice(1).filter((token) => token.toLowerCase() !== "'none'");
directives.push({ name: 'script-src', tokens: ['script-src', ...base, nonceSource] });
}
}
const rebuilt = directives.map((directive) => directive.tokens.join(' '));
return rebuilt.length > 0 ? rebuilt.join('; ') : null;
};
export const rewritePreviewRedirectLocation = ({ location, proxyBasePath, targetOrigin, previewToken = '', urlAuthToken = '' }) => {
if (typeof location !== 'string' || !location) return location;
const prefix = proxyBasePath.endsWith('/') ? proxyBasePath.slice(0, -1) : proxyBasePath;
const target = targetOrigin ? new URL(targetOrigin) : null;
if (!target) return location;
try {
const parsed = new URL(location, target);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return location;
const host = parsed.hostname;
const isLoopback = host === 'localhost' || host === '127.0.0.1' || host === '0.0.0.0' || host === '::1' || host === '[::1]';
if (!isLoopback || parsed.port !== target.port) return location;
return appendProxyAuthToProxyUrl(`${prefix}${parsed.pathname}${parsed.search}${parsed.hash}`, { previewToken, urlAuthToken });
} catch {
return location;
}
};
export const createPreviewProxyRuntime = ({
crypto,
URL,
createProxyMiddleware,
responseInterceptor,
}) => {
const targets = new Map();
let sweepTimer = null;
const now = () => Date.now();
const sweepExpired = () => {
const t = now();
for (const [id, entry] of targets.entries()) {
if (entry.expiresAt <= t) {
targets.delete(id);
}
}
};
const ensureSweeper = () => {
if (sweepTimer) {
return;
}
sweepTimer = setInterval(sweepExpired, 30_000);
// Don't keep the process alive.
sweepTimer.unref?.();
};
const createTarget = (origin, ttlMs) => {
const id = crypto.randomBytes(16).toString('hex');
const token = crypto.randomBytes(16).toString('hex');
const createdAt = now();
const expiresAt = createdAt + (Number.isFinite(ttlMs) ? Math.max(15_000, Math.trunc(ttlMs)) : DEFAULT_TARGET_TTL_MS);
targets.set(id, {
id,
origin,
token,
createdAt,
expiresAt,
});
return { id, token, expiresAt };
};
const resolveTargetFromRequest = (req) => {
const rawUrl = req?.originalUrl || req?.url || '';
const parsed = new URL(rawUrl, 'http://localhost');
const pathname = parsed.pathname || '';
const match = pathname.match(/^\/api\/preview\/proxy\/([a-f0-9]{16,64})(?:\/|$)/i);
const id = match?.[1] || '';
if (!id) {
return { ok: false, status: 404, error: 'Preview target not found' };
}
const entry = targets.get(id);
if (!entry || entry.expiresAt <= now()) {
targets.delete(id);
return { ok: false, status: 404, error: 'Preview target expired' };
}
const cookies = parseCookieHeader(req.headers?.cookie);
const token = parsed.searchParams.get(TOKEN_QUERY_PARAM) || cookies.get(TOKEN_COOKIE_NAME) || '';
if (!token || token !== entry.token) {
return { ok: false, status: 403, error: 'Preview token missing' };
}
return { ok: true, id, entry, parsed };
};
const stripProxyPrefix = (pathname, id) => {
const prefix = `/api/preview/proxy/${id}`;
if (!pathname.startsWith(prefix)) {
return pathname;
}
const rest = pathname.slice(prefix.length);
return rest.length === 0 ? '/' : rest;
};
const removeRawQueryParam = (search, paramName) => {
if (typeof search !== 'string' || search.length <= 1) {
return '';
}
const query = search.startsWith('?') ? search.slice(1) : search;
const parts = query.split('&').filter((part) => {
const name = part.split('=', 1)[0] || '';
return decodeURIComponent(name.replace(/\+/g, ' ')) !== paramName;
});
return parts.length > 0 ? `?${parts.join('&')}` : '';
};
// Drop only CSP directives that prevent framing or the injected preview bridge.
// Preview targets are restricted to loopback dev servers.
const stripFrameBustingHeaders = (headers, bridgeNonce) => {
if (!headers || typeof headers !== 'object') {
return;
}
const headerKeys = Object.keys(headers);
for (const key of headerKeys) {
const lowerKey = key.toLowerCase();
if (lowerKey === 'x-frame-options') {
delete headers[key];
continue;
}
if (lowerKey === 'content-security-policy' || lowerKey === 'content-security-policy-report-only') {
const original = headers[key];
const values = Array.isArray(original) ? original : [original];
const rewritten = values
.map((value) => rewritePreviewCspHeader(value, bridgeNonce))
.filter((value) => typeof value === 'string' && value.length > 0);
if (rewritten.length === 0) {
delete headers[key];
} else {
headers[key] = Array.isArray(original) ? rewritten : rewritten[0];
}
}
}
};
const attach = (app, {
server,
express,
uiAuthController,
isRequestOriginAllowed,
rejectWebSocketUpgrade,
}) => {
ensureSweeper();
const injectPreviewBridge = (bodyText, targetOrigin, bridgeNonce) => {
if (typeof bodyText !== 'string' || bodyText.includes(PREVIEW_BRIDGE_SCRIPT_ID)) {
return bodyText;
}
const nonceAttr = bridgeNonce ? ` nonce="${bridgeNonce}"` : '';
const targetOriginScript = `<script${nonceAttr}>window.__openchamberPreviewTargetOrigin=${JSON.stringify(targetOrigin || '')};</script>`;
const script = `${targetOriginScript}<script id="${PREVIEW_BRIDGE_SCRIPT_ID}"${nonceAttr}>${PREVIEW_BRIDGE_SCRIPT}</script>`;
if (/<head(?:\s[^>]*)?>/i.test(bodyText)) {
return bodyText.replace(/<head(\s[^>]*)?>/i, (match) => `${match}${script}`);
}
if (bodyText.includes('</body>')) {
return bodyText.replace('</body>', `${script}</body>`);
}
return `${bodyText}${script}`;
};
const rewriteViteClientHmr = (bodyText, proxyBasePath) => {
if (typeof bodyText !== 'string' || !bodyText.includes('vite-hmr')) {
return bodyText;
}
const base = proxyBasePath.endsWith('/') ? proxyBasePath : `${proxyBasePath}/`;
const escapedBase = JSON.stringify(base).slice(1, -1);
return bodyText
.replace(/const base\$1 = [^;]+;/, () => `const base$1 = ${JSON.stringify(base)};`)
.replace(/const base = [^;]+;/, () => `const base = ${JSON.stringify(base)};`)
.replace(/const hmrPort = [^;]+;/, () => 'const hmrPort = importMetaUrl.port;')
.replace(/const socketHost = [^;]+;/, () => `const socketHost = \`\${importMetaUrl.hostname}\${importMetaUrl.port ? ':' + importMetaUrl.port : ''}${escapedBase}\`;`)
.replace(/const directSocketHost = [^;]+;/, () => 'const directSocketHost = socketHost;')
.replace(
/const socketHost = `\$\{[^;]+?;\nconst directSocketHost = [^;]+;/s,
() => `const socketHost = \`\${importMetaUrl.hostname}\${importMetaUrl.port ? ':' + importMetaUrl.port : ''}${escapedBase}\`;\nconst directSocketHost = socketHost;`,
);
};
app.post('/api/preview/targets', express.json(), async (req, res) => {
try {
if (uiAuthController?.enabled) {
const sessionToken = await uiAuthController?.ensureSessionToken?.(req, res);
if (!sessionToken) {
return res.status(401).json({ error: 'UI authentication required' });
}
const originAllowed = await isRequestOriginAllowed(req);
if (!originAllowed) {
return res.status(403).json({ error: 'Invalid origin' });
}
}
const rawUrl = typeof req.body?.url === 'string' ? req.body.url.trim() : '';
if (!rawUrl) {
return res.status(400).json({ error: 'url is required' });
}
const ttlMs = typeof req.body?.ttlMs === 'number' ? req.body.ttlMs : DEFAULT_TARGET_TTL_MS;
const allowExternal = req.body?.allowExternal === true;
const normalized = allowExternal
? normalizeProxyTargetUrl(rawUrl, { allowExternal: true })
: normalizeLoopbackUrl(rawUrl);
if (!normalized.ok) {
return res.status(400).json({ error: normalized.error });
}
const target = createTarget(normalized.origin, ttlMs);
const cookiePath = `/api/preview/proxy/${target.id}`;
const secure = Boolean(req.secure);
res.setHeader('Set-Cookie', buildCookie({
name: TOKEN_COOKIE_NAME,
value: target.token,
path: cookiePath,
maxAgeSeconds: Math.round((target.expiresAt - now()) / 1000),
secure,
}));
return res.json({
id: target.id,
proxyBasePath: cookiePath,
previewToken: target.token,
expiresAt: target.expiresAt,
});
} catch (error) {
console.error('[preview-proxy] Failed to create target:', error);
return res.status(500).json({ error: 'Failed to create preview target' });
}
});
const proxy = createProxyMiddleware({
target: 'http://127.0.0.1',
changeOrigin: true,
ws: true,
selfHandleResponse: true,
// Restrict the proxy (especially its auto-attached `upgrade` listener,
// which is registered globally on the underlying HTTP server when
// `ws: true`) to preview paths. Without this, every WebSocket upgrade
// on the server (e.g. `/api/terminal/ws`) gets proxied to
// `http://127.0.0.1` and tears the socket down with ECONNREFUSED.
//
// We use a function so the same filter handles both cases:
// - HTTP requests through Express, where `req.url` has been stripped
// of the `/api/preview/proxy` mount-point, so we check `originalUrl`.
// - Raw upgrade events from the HTTP server, where `req.url` still
// contains the full path.
pathFilter: (pathname, req) => {
const target = req?.originalUrl || pathname || req?.url || '';
return target.startsWith('/api/preview/proxy/');
},
router: (req) => {
const resolved = resolveTargetFromRequest(req);
if (!resolved.ok) {
return 'http://127.0.0.1';
}
return resolved.entry.origin;
},
pathRewrite: (pathValue, req) => {
const resolved = resolveTargetFromRequest(req);
if (!resolved.ok) {
return pathValue;
}
const parsed = new URL(req.originalUrl || req.url || '', 'http://localhost');
// Never forward our auth cookie token to the dev server.
const strippedPath = stripProxyPrefix(parsed.pathname, resolved.id);
const withoutReloadParam = removeRawQueryParam(parsed.search, 'ocPreview');
const withoutPreviewToken = removeRawQueryParam(withoutReloadParam, TOKEN_QUERY_PARAM);
const withoutClientToken = removeRawQueryParam(withoutPreviewToken, CLIENT_TOKEN_QUERY_PARAM);
const withoutUrlAuthToken = removeRawQueryParam(withoutClientToken, URL_AUTH_TOKEN_QUERY_PARAM);
return `${strippedPath}${withoutUrlAuthToken}`;
},
on: {
proxyReq: (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) => {
// 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');
// Allow the dev server response to be framed inside OpenChamber even
// if it normally sets X-Frame-Options or a CSP frame-ancestors rule.
// The proxy is same-origin so embedding is otherwise safe.
stripFrameBustingHeaders(proxyRes.headers, bridgeNonce);
const resolved = resolveTargetFromRequest(req);
if (!resolved.ok) {
return responseBuffer;
}
const proxyBasePath = `/api/preview/proxy/${resolved.id}`;
const urlAuthToken = resolved.parsed.searchParams.get(URL_AUTH_TOKEN_QUERY_PARAM) || '';
if (typeof proxyRes.headers?.location === 'string') {
proxyRes.headers.location = rewritePreviewRedirectLocation({
location: proxyRes.headers.location,
proxyBasePath,
targetOrigin: resolved.entry.origin,
previewToken: resolved.entry.token,
urlAuthToken,
});
}
const contentType = String(proxyRes.headers?.['content-type'] || '').toLowerCase();
const isHtml = contentType.includes('text/html');
const isCss = contentType.includes('text/css');
const isJavaScript = contentType.includes('javascript') || contentType.includes('ecmascript');
if (!isHtml && !isCss && !isJavaScript) {
return responseBuffer;
}
proxyRes.headers['cache-control'] = 'no-store, no-cache, must-revalidate, proxy-revalidate';
proxyRes.headers.pragma = 'no-cache';
proxyRes.headers.expires = '0';
delete proxyRes.headers.etag;
delete proxyRes.headers['last-modified'];
const parsed = new URL(req.originalUrl || req.url || '', 'http://localhost');
const upstreamPath = stripProxyPrefix(parsed.pathname, resolved.id);
if (isJavaScript && upstreamPath === '/@vite/client') {
return rewritePreviewBody({
bodyText: rewriteViteClientHmr(responseBuffer.toString('utf8'), proxyBasePath),
proxyBasePath,
targetOrigin: resolved.entry.origin,
kind: 'javascript',
previewToken: resolved.entry.token,
urlAuthToken,
});
}
const rewrittenBody = rewritePreviewBody({
bodyText: responseBuffer.toString('utf8'),
proxyBasePath,
targetOrigin: resolved.entry.origin,
kind: isHtml ? 'html' : isCss ? 'css' : 'javascript',
previewToken: resolved.entry.token,
urlAuthToken,
});
return isHtml ? injectPreviewBridge(rewrittenBody, resolved.entry.origin, bridgeNonce) : rewrittenBody;
}),
error: (err, _req, res) => {
const isDev = typeof process !== 'undefined'
&& process
&& process.env
&& process.env.NODE_ENV !== 'production';
const message = err && typeof err === 'object' && typeof err.message === 'string'
? err.message
: 'Unknown proxy error';
console.error('[preview-proxy] proxy error:', message);
if (res && !res.headersSent && typeof res.status === 'function') {
const payload = { error: 'Preview proxy error' };
if (isDev) {
try {
const resolved = resolveTargetFromRequest(_req);
payload.details = {
message,
code: err && typeof err === 'object' ? err.code : undefined,
targetOrigin: resolved?.ok ? resolved.entry.origin : undefined,
};
} catch {
payload.details = { message };
}
}
res.status(502).json(payload);
}
},
},
});
app.use('/api/preview/proxy', (req, res, next) => {
const resolved = resolveTargetFromRequest(req);
if (!resolved.ok) {
return res.status(resolved.status).json({ error: resolved.error });
}
next();
}, proxy);
server.on('upgrade', (req, socket, head) => {
const resolved = resolveTargetFromRequest(req);
if (!resolved.ok) {
return;
}
const handleUpgrade = async () => {
try {
if (uiAuthController?.enabled) {
const originAllowed = await isRequestOriginAllowed(req);
if (!originAllowed) {
rejectWebSocketUpgrade(socket, 403, 'Invalid origin');
return;
}
}
// Rewrite req.url to what the dev server expects.
const rawUrl = req.url || '';
req.originalUrl = rawUrl;
const parsed = new URL(rawUrl, 'http://localhost');
const nextPath = stripProxyPrefix(parsed.pathname, resolved.id);
parsed.searchParams.delete('ocPreview');
parsed.searchParams.delete(TOKEN_QUERY_PARAM);
parsed.searchParams.delete(CLIENT_TOKEN_QUERY_PARAM);
parsed.searchParams.delete(URL_AUTH_TOKEN_QUERY_PARAM);
const search = parsed.searchParams.toString();
req.url = `${nextPath}${search ? `?${search}` : ''}`;
proxy.upgrade(req, socket, head);
} catch {
rejectWebSocketUpgrade(socket, 500, 'Upgrade failed');
}
};
void handleUpgrade();
});
};
return {
attach,
};
};