Replace the legacy terminal flow with a shared authenticated WebSocket runtime used across web, desktop, relay, and mobile surfaces. - introduce the v3 terminal protocol with scoped attachments, snapshots, ordered output, bounded replay history, reconnects, and explicit lifecycle - harden PTY creation, restart, resize, close, force-kill, idle cleanup, shell selection, login mode, environment sanitization, and appearance sync - add runtime-aware terminal APIs with relay authentication and Electron parity - add a fullscreen mobile terminal workspace with touch scrolling, long-press selection, safe-area controls, quick keys, and Ctrl/Alt input - add terminal selection attachments, preview detection, project actions, shell settings, and localized UI - harden Ghostty rendering, resize recovery, Unicode handling, block characters, line height, and stale-row behavior - remove the obsolete terminal SSE path and update reverse-proxy guidance - expand terminal runtime, transport, input, selection, and store coverage - avoid duplicate web builds when preparing mobile assets in root CI builds
1605 lines
64 KiB
JavaScript
1605 lines
64 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 PREVIEW_PASSTHROUGH_REQUEST_HEADERS = ['x-inertia', 'x-inertia-version'];
|
|
const PREVIEW_PASSTHROUGH_RESPONSE_HEADERS = ['x-inertia', 'x-inertia-location'];
|
|
export const PREVIEW_TARGET_ERROR_HEADER = 'x-openchamber-preview-target-error';
|
|
|
|
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 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',
|
|
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 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})`;
|
|
});
|
|
const rewriteInlineModuleScripts = (text) => text.replace(
|
|
/<script\b([^>]*)>([\s\S]*?)<\/script>/gi,
|
|
(match, attrs, scriptBody) => {
|
|
if (/\bsrc\s*=/i.test(attrs)) return match;
|
|
|
|
const typeMatch = String(attrs || '').match(/\btype\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i);
|
|
const type = String(typeMatch?.[1] ?? typeMatch?.[2] ?? typeMatch?.[3] ?? '').trim().toLowerCase();
|
|
if (type !== 'module') return match;
|
|
|
|
const rewrittenScriptBody = rewriteJavaScript(scriptBody);
|
|
if (rewrittenScriptBody === scriptBody) return match;
|
|
return `<script${attrs}>${rewrittenScriptBody}</script>`;
|
|
},
|
|
);
|
|
const rewriteHtml = (text) => rewriteInlineModuleScripts(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}`;
|
|
}));
|
|
|
|
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, code: 'missing', error: 'Preview target not found' };
|
|
}
|
|
|
|
const entry = targets.get(id);
|
|
if (!entry || entry.expiresAt <= now()) {
|
|
targets.delete(id);
|
|
return { ok: false, status: 404, code: 'expired', 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, code: 'invalid-token', 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, 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, res) => {
|
|
// This header is reserved for failures produced before proxying. Do
|
|
// not let an upstream application response impersonate that signal.
|
|
res.removeHeader?.(PREVIEW_TARGET_ERROR_HEADER);
|
|
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');
|
|
// 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) {
|
|
res.setHeader(PREVIEW_TARGET_ERROR_HEADER, resolved.code);
|
|
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,
|
|
};
|
|
};
|