From fd3557e5c35481254b1cbe99b8335ad9722988ed Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Thu, 7 May 2026 00:02:01 +0300 Subject: [PATCH] feat: improve preview proxy behavior Sort dev-server false positive preview errors Improves preview URL rewriting and navigation handling Syncs app theme into preview iFrames --- .../ui/src/components/layout/ContextPanel.tsx | 22 + .../web/server/lib/preview/proxy-runtime.js | 490 ++++++++++++++---- .../server/lib/preview/proxy-runtime.test.js | 144 +++++ 3 files changed, 554 insertions(+), 102 deletions(-) create mode 100644 packages/web/server/lib/preview/proxy-runtime.test.js diff --git a/packages/ui/src/components/layout/ContextPanel.tsx b/packages/ui/src/components/layout/ContextPanel.tsx index def9bbf2..22e2406e 100644 --- a/packages/ui/src/components/layout/ContextPanel.tsx +++ b/packages/ui/src/components/layout/ContextPanel.tsx @@ -54,6 +54,7 @@ type PreviewBridgeMessage = { title?: unknown; ts?: unknown; target?: unknown; + navigation?: unknown; }; type PreviewElementMetadata = { @@ -386,6 +387,7 @@ const getCachedProxyTarget = (url: string): CachedProxyTarget | null => { const PreviewPane: React.FC = ({ rawUrl, onNavigate }) => { const { t } = useI18n(); + const { currentTheme } = useThemeSystem(); const [reloadNonce, bumpReload] = React.useReducer((x: number) => x + 1, 0); const [proxyRegistrationNonce, bumpProxyRegistration] = React.useReducer((x: number) => x + 1, 0); const [proxyState, setProxyState] = React.useState({ status: 'idle' }); @@ -424,6 +426,7 @@ const PreviewPane: React.FC = ({ rawUrl, onNavigate }) => { : null; const targetKey = normalizedUrl ? normalizedUrl.toString() : ''; + const previewColorScheme = currentTheme.metadata.variant; React.useEffect(() => { if (!targetKey || !isLoopback) { @@ -581,6 +584,19 @@ const PreviewPane: React.FC = ({ rawUrl, onNavigate }) => { }, window.location.origin); }, [bridgeReady, inspectMode]); + React.useEffect(() => { + const frameWindow = iframeRef.current?.contentWindow; + if (!bridgeReady || !frameWindow) { + return; + } + frameWindow.postMessage({ + source: 'openchamber-preview-parent', + version: 1, + type: 'set-color-scheme', + scheme: previewColorScheme, + }, window.location.origin); + }, [bridgeReady, previewColorScheme]); + React.useEffect(() => { if (!inspectMode || typeof window === 'undefined') return; const handler = (event: KeyboardEvent) => { @@ -690,6 +706,11 @@ const PreviewPane: React.FC = ({ rawUrl, onNavigate }) => { if (data.type === 'navigate-preview') { const nextUrl = typeof data.url === 'string' ? data.url : ''; + const navigation = data.navigation === 'external' ? 'external' : 'proxy'; + if (nextUrl && navigation === 'external') { + void openExternalUrl(nextUrl); + return; + } if (nextUrl) { onNavigate(nextUrl); } @@ -974,6 +995,7 @@ const PreviewPane: React.FC = ({ rawUrl, onNavigate }) => { src={effectiveSrc} title={t('contextPanel.preview.iframeTitle')} className="h-full w-full border-0" + style={{ colorScheme: previewColorScheme }} onLoad={handlePreviewFrameLoad} sandbox={isLoopback ? 'allow-scripts allow-same-origin allow-forms allow-popups allow-downloads' diff --git a/packages/web/server/lib/preview/proxy-runtime.js b/packages/web/server/lib/preview/proxy-runtime.js index e5348059..20c1e10b 100644 --- a/packages/web/server/lib/preview/proxy-runtime.js +++ b/packages/web/server/lib/preview/proxy-runtime.js @@ -11,6 +11,144 @@ const LOOPBACK_HOSTS = new Set([ 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 }) => { + 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 || '/'; + if (parsed.origin === current?.origin && path.startsWith('/api/preview/proxy/')) { + return { action: 'allow', url: parsed.toString() }; + } + + 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; @@ -22,6 +160,9 @@ const PREVIEW_BRIDGE_SCRIPT = String.raw`(() => { let inspectMode = false; let lastHoverKey = ''; let pendingHover = null; + let previewColorScheme = null; + let nativeMatchMedia = null; + const colorSchemeListeners = new Set(); const post = (payload) => { try { @@ -47,6 +188,94 @@ const PREVIEW_BRIDGE_SCRIPT = String.raw`(() => { } }; + 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 || ''; }; @@ -76,41 +305,83 @@ const PREVIEW_BRIDGE_SCRIPT = String.raw`(() => { 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 path = upstreamPathForUrl(value); const pathAndSearch = upstreamPathAndSearchForUrl(value); - return path === '/@vite/client' + const lower = pathAndSearch.toLowerCase(); + const path = pathAndSearch.split('?', 1)[0] || ''; + + const viteNoise = path === '/@vite/client' || path === '/@react-refresh' - || path.indexOf('/@id/astro:') === 0 - || path.startsWith('/@id/__x00__vite/') - || path.includes('/node_modules/.vite/') - || path.includes('/vite/dist/client/') - || path.includes('/astro/dist/runtime/client/dev-toolbar/') - || (pathAndSearch.indexOf('/node_modules/') >= 0 && pathAndSearch.indexOf('.astro?') >= 0 && pathAndSearch.indexOf('type=script') >= 0); + || 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; }; - const toLoopbackHttpUrl = (value) => { + installColorSchemeMatchMediaPatch(); + + const classifyNavigation = (value) => { try { const parsed = new URL(value, window.location.href); - if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null; - const host = parsed.hostname; - if (host !== 'localhost' && host !== '127.0.0.1' && host !== '0.0.0.0' && host !== '::1' && host !== '[::1]') { - return null; - } - return parsed.toString(); - } catch { - return null; - } - }; - - const shouldParentHandleNavigation = (url) => { - if (!url) return false; - try { - const parsed = new URL(url); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return { action: 'allow', url: parsed.toString() }; const current = new URL(window.location.href); - if (parsed.origin !== current.origin) return true; - return !parsed.pathname.startsWith('/api/preview/proxy/'); + 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() }; + } + 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 false; + return { action: 'allow', url: String(value || '') }; } }; @@ -429,6 +700,9 @@ const PREVIEW_BRIDGE_SCRIPT = String.raw`(() => { if (data.type === 'set-inspect-mode') { setInspectMode(data.enabled === true); } + if (data.type === 'set-color-scheme') { + applyPreviewColorScheme(data.scheme); + } }); window.addEventListener('mousemove', sendHover, true); @@ -440,11 +714,11 @@ const PREVIEW_BRIDGE_SCRIPT = String.raw`(() => { window.addEventListener('click', (event) => { const anchor = event.target && typeof event.target.closest === 'function' ? event.target.closest('a[href]') : null; if (anchor && !inspectMode) { - const nextUrl = toLoopbackHttpUrl(anchor.href); - if (shouldParentHandleNavigation(nextUrl)) { + const navigation = classifyNavigation(anchor.href); + if (navigation.action === 'proxy' || navigation.action === 'external') { event.preventDefault(); event.stopPropagation(); - post({ type: 'navigate-preview', url: nextUrl, ts: Date.now() }); + post({ type: 'navigate-preview', url: navigation.url, navigation: navigation.action, ts: Date.now() }); return; } } @@ -537,6 +811,78 @@ const normalizeLoopbackUrl = (rawUrl) => { return { ok: true, origin: url.origin }; }; +export const rewritePreviewBody = ({ bodyText, proxyBasePath, targetOrigin, kind }) => { + 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 isSameLoopbackTarget = (url) => { + if (!target) return false; + if (url.protocol !== 'http:' && url.protocol !== 'https:') return false; + 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 value; + return `${prefix}${value}`; + } + try { + const parsed = new URL(value); + if (isSameLoopbackTarget(parsed)) { + return `${prefix}${parsed.pathname}${parsed.search}${parsed.hash}`; + } + } 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 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 rewriteHtml(bodyText); + if (kind === 'css') return rewriteCss(bodyText); + if (kind === 'javascript') return rewriteJavaScript(bodyText); + return bodyText; +}; + export const createPreviewProxyRuntime = ({ crypto, URL, @@ -688,76 +1034,6 @@ export const createPreviewProxyRuntime = ({ }) => { ensureSweeper(); - const rewritePreviewBody = (bodyText, proxyBasePath, targetOrigin) => { - 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 isSameLoopbackTarget = (url) => { - if (!target) return false; - if (url.protocol !== 'http:' && url.protocol !== 'https:') return false; - 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 rewriteRootPath = (value) => { - if (typeof value !== 'string' || value.length === 0) { - return value; - } - if (value.startsWith('/') && !value.startsWith('//')) { - if (value.startsWith('/api/preview/proxy/')) { - return value; - } - return `${prefix}${value}`; - } - try { - const parsed = new URL(value); - if (isSameLoopbackTarget(parsed)) { - return `${prefix}${parsed.pathname}${parsed.search}${parsed.hash}`; - } - } catch { - return value; - } - return value; - }; - - return bodyText - .replace(/\b(src|href|action)=(["'])([^"']*)\2/gi, (_match, attr, quote, value) => { - return `${attr}=${quote}${rewriteRootPath(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] = rewriteRootPath(url); - return segments.join(' '); - }).join(', '); - return `srcset=${quote}${rewritten}${quote}`; - }) - .replace(/url\((['"]?)([^)'"]*)\1\)/gi, (_match, quote, value) => { - const q = quote || ''; - return `url(${q}${rewriteRootPath(value)}${q})`; - }) - .replace(/@import\s+(["'])\/(?!\/)([^"']*)\1/gi, (_match, quote, path) => { - return `@import ${quote}${rewriteRootPath(`/${path}`)}${quote}`; - }) - .replace(/\bfrom\s+(["'])\/(?!\/)([^"']*)\1/gi, (_match, quote, path) => { - return `from ${quote}${rewriteRootPath(`/${path}`)}${quote}`; - }) - .replace(/\bimport\s+(["'])\/(?!\/)([^"']*)\1/gi, (_match, quote, path) => { - return `import ${quote}${rewriteRootPath(`/${path}`)}${quote}`; - }) - .replace(/\bimport\(\s*(["'])\/(?!\/)([^"']*)\1\s*\)/gi, (_match, quote, path) => { - return `import(${quote}${rewriteRootPath(`/${path}`)}${quote})`; - }); - }; - const injectPreviewBridge = (bodyText) => { if (typeof bodyText !== 'string' || bodyText.includes(PREVIEW_BRIDGE_SCRIPT_ID)) { return bodyText; @@ -914,10 +1190,20 @@ export const createPreviewProxyRuntime = ({ const parsed = new URL(req.originalUrl || req.url || '', 'http://localhost'); const upstreamPath = stripProxyPrefix(parsed.pathname, resolved.id); if (isJavaScript && upstreamPath === '/@vite/client') { - return rewritePreviewBody(rewriteViteClientHmr(responseBuffer.toString('utf8'), proxyBasePath), proxyBasePath, resolved.entry.origin); + return rewritePreviewBody({ + bodyText: rewriteViteClientHmr(responseBuffer.toString('utf8'), proxyBasePath), + proxyBasePath, + targetOrigin: resolved.entry.origin, + kind: 'javascript', + }); } - const rewrittenBody = rewritePreviewBody(responseBuffer.toString('utf8'), proxyBasePath, resolved.entry.origin); + const rewrittenBody = rewritePreviewBody({ + bodyText: responseBuffer.toString('utf8'), + proxyBasePath, + targetOrigin: resolved.entry.origin, + kind: isHtml ? 'html' : isCss ? 'css' : 'javascript', + }); return isHtml ? injectPreviewBridge(rewrittenBody) : rewrittenBody; }), error: (err, _req, res) => { diff --git a/packages/web/server/lib/preview/proxy-runtime.test.js b/packages/web/server/lib/preview/proxy-runtime.test.js new file mode 100644 index 00000000..74a2335c --- /dev/null +++ b/packages/web/server/lib/preview/proxy-runtime.test.js @@ -0,0 +1,144 @@ +import { describe, expect, it } from 'vitest'; + +import { classifyPreviewNavigation, classifyPreviewResourceError, rewritePreviewBody } from './proxy-runtime.js'; + +const rewrite = (bodyText, kind) => rewritePreviewBody({ + bodyText, + kind, + proxyBasePath: '/api/preview/proxy/abc123', + targetOrigin: 'http://127.0.0.1:3000', +}); + +describe('preview resource error classification', () => { + it('suppresses Astro/Vite stylesheet modules reported as failed scripts', () => { + expect(classifyPreviewResourceError({ + tagName: 'script', + url: 'http://127.0.0.1:57123/api/preview/proxy/f4af70b4261d77706743959516f9cecc/src/styles/global.css', + })).toBe('suppress'); + + expect(classifyPreviewResourceError({ + tagName: 'script', + url: 'http://127.0.0.1:57123/api/preview/proxy/f4af70b4261d77706743959516f9cecc/src/pages/support.astro?astro&type=style&index=0&lang.css', + })).toBe('suppress'); + }); + + it('suppresses framework virtual modules reported by dev servers', () => { + expect(classifyPreviewResourceError({ + tagName: 'script', + url: 'http://127.0.0.1:57123/api/preview/proxy/f4af70b4261d77706743959516f9cecc/src/layouts/BaseLayout.astro?astro&type=script&index=0&lang.ts', + })).toBe('suppress'); + + expect(classifyPreviewResourceError({ + tagName: 'script', + url: 'http://127.0.0.1:57123/api/preview/proxy/f4af70b4261d77706743959516f9cecc/@vite/client', + })).toBe('suppress'); + + expect(classifyPreviewResourceError({ + tagName: 'link', + url: 'http://127.0.0.1:57123/api/preview/proxy/f4af70b4261d77706743959516f9cecc/@id/astro:scripts/page.js', + })).toBe('suppress'); + }); + + it('suppresses conservative ecosystem dev-runtime resources', () => { + const noisyResources = [ + '/_next/static/chunks/webpack.js', + '/_next/static/chunks/react-refresh.js', + '/.svelte-kit/generated/client/app.js', + '/@id/__x00__virtual:sveltekit:browser', + '/@remix-run/dev/dist/browser.js', + '/__hmr?runtime=remix', + '/_nuxt/@vite/client', + '/_nuxt/@id/virtual:nuxt:%2FUsers%2Fapp', + '/webpack-dev-server/client/index.js', + '/webpack/hot/dev-server.js', + '/__webpack_hmr', + ]; + + for (const resource of noisyResources) { + expect(classifyPreviewResourceError({ + tagName: 'script', + url: `http://127.0.0.1:57123/api/preview/proxy/f4af70b4261d77706743959516f9cecc${resource}`, + })).toBe('suppress'); + } + }); + + it('keeps ordinary application resource failures visible', () => { + expect(classifyPreviewResourceError({ + tagName: 'script', + url: 'http://127.0.0.1:57123/api/preview/proxy/f4af70b4261d77706743959516f9cecc/assets/app.js', + })).toBe('report'); + + expect(classifyPreviewResourceError({ + tagName: 'img', + url: 'http://127.0.0.1:57123/api/preview/proxy/f4af70b4261d77706743959516f9cecc/missing.png', + })).toBe('report'); + + expect(classifyPreviewResourceError({ + tagName: 'link', + url: 'http://127.0.0.1:57123/api/preview/proxy/f4af70b4261d77706743959516f9cecc/styles/missing.css', + })).toBe('report'); + }); +}); + +describe('preview body URL rewriting', () => { + it('rewrites only HTML resource attributes in HTML responses', () => { + const input = 'Docs'; + const output = rewrite(input, 'html'); + + expect(output).toContain('src="/api/preview/proxy/abc123/logo.png"'); + expect(output).toContain('href="/api/preview/proxy/abc123/docs"'); + expect(output).toContain('const url = "/api/data";'); + }); + + it('rewrites only CSS imports and url references in CSS responses', () => { + const input = '@import "/theme.css"; .hero { background: url(/hero.png); } .copy::after { content: "/not-a-url"; }'; + const output = rewrite(input, 'css'); + + expect(output).toContain('@import "/api/preview/proxy/abc123/theme.css"'); + expect(output).toContain('url(/api/preview/proxy/abc123/hero.png)'); + expect(output).toContain('content: "/not-a-url"'); + }); + + it('rewrites only JavaScript static import specifiers in JavaScript responses', () => { + const input = 'import "/entry.js"; import value from "/module.js"; const url = "/api/data"; fetch("/api/data");'; + const output = rewrite(input, 'javascript'); + + expect(output).toContain('import "/api/preview/proxy/abc123/entry.js"'); + expect(output).toContain('from "/api/preview/proxy/abc123/module.js"'); + expect(output).toContain('const url = "/api/data"'); + expect(output).toContain('fetch("/api/data")'); + }); +}); + +describe('preview navigation policy', () => { + const currentUrl = 'http://127.0.0.1:57123/api/preview/proxy/f4af70b4261d77706743959516f9cecc/docs'; + + it('keeps same-page hash and already-proxied links in the iframe', () => { + expect(classifyPreviewNavigation({ url: '#section', currentUrl }).action).toBe('allow'); + expect(classifyPreviewNavigation({ + url: 'http://127.0.0.1:57123/api/preview/proxy/f4af70b4261d77706743959516f9cecc/roadmap', + currentUrl, + }).action).toBe('allow'); + }); + + it('routes loopback absolute links through the preview proxy', () => { + expect(classifyPreviewNavigation({ url: 'http://localhost:3000/roadmap', currentUrl })).toEqual({ + action: 'proxy', + url: 'http://localhost:3000/roadmap', + }); + }); + + it('sends non-loopback http links outside the preview iframe', () => { + expect(classifyPreviewNavigation({ url: 'https://example.com/docs', currentUrl })).toEqual({ + action: 'external', + url: 'https://example.com/docs', + }); + }); + + it('leaves non-http links to browser defaults', () => { + expect(classifyPreviewNavigation({ url: 'mailto:test@example.com', currentUrl })).toEqual({ + action: 'allow', + url: 'mailto:test@example.com', + }); + }); +});