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
This commit is contained in:
@@ -54,6 +54,7 @@ type PreviewBridgeMessage = {
|
|||||||
title?: unknown;
|
title?: unknown;
|
||||||
ts?: unknown;
|
ts?: unknown;
|
||||||
target?: unknown;
|
target?: unknown;
|
||||||
|
navigation?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PreviewElementMetadata = {
|
type PreviewElementMetadata = {
|
||||||
@@ -386,6 +387,7 @@ const getCachedProxyTarget = (url: string): CachedProxyTarget | null => {
|
|||||||
|
|
||||||
const PreviewPane: React.FC<PreviewPaneProps> = ({ rawUrl, onNavigate }) => {
|
const PreviewPane: React.FC<PreviewPaneProps> = ({ rawUrl, onNavigate }) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const { currentTheme } = useThemeSystem();
|
||||||
const [reloadNonce, bumpReload] = React.useReducer((x: number) => x + 1, 0);
|
const [reloadNonce, bumpReload] = React.useReducer((x: number) => x + 1, 0);
|
||||||
const [proxyRegistrationNonce, bumpProxyRegistration] = React.useReducer((x: number) => x + 1, 0);
|
const [proxyRegistrationNonce, bumpProxyRegistration] = React.useReducer((x: number) => x + 1, 0);
|
||||||
const [proxyState, setProxyState] = React.useState<PreviewProxyState>({ status: 'idle' });
|
const [proxyState, setProxyState] = React.useState<PreviewProxyState>({ status: 'idle' });
|
||||||
@@ -424,6 +426,7 @@ const PreviewPane: React.FC<PreviewPaneProps> = ({ rawUrl, onNavigate }) => {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
const targetKey = normalizedUrl ? normalizedUrl.toString() : '';
|
const targetKey = normalizedUrl ? normalizedUrl.toString() : '';
|
||||||
|
const previewColorScheme = currentTheme.metadata.variant;
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!targetKey || !isLoopback) {
|
if (!targetKey || !isLoopback) {
|
||||||
@@ -581,6 +584,19 @@ const PreviewPane: React.FC<PreviewPaneProps> = ({ rawUrl, onNavigate }) => {
|
|||||||
}, window.location.origin);
|
}, window.location.origin);
|
||||||
}, [bridgeReady, inspectMode]);
|
}, [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(() => {
|
React.useEffect(() => {
|
||||||
if (!inspectMode || typeof window === 'undefined') return;
|
if (!inspectMode || typeof window === 'undefined') return;
|
||||||
const handler = (event: KeyboardEvent) => {
|
const handler = (event: KeyboardEvent) => {
|
||||||
@@ -690,6 +706,11 @@ const PreviewPane: React.FC<PreviewPaneProps> = ({ rawUrl, onNavigate }) => {
|
|||||||
|
|
||||||
if (data.type === 'navigate-preview') {
|
if (data.type === 'navigate-preview') {
|
||||||
const nextUrl = typeof data.url === 'string' ? data.url : '';
|
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) {
|
if (nextUrl) {
|
||||||
onNavigate(nextUrl);
|
onNavigate(nextUrl);
|
||||||
}
|
}
|
||||||
@@ -974,6 +995,7 @@ const PreviewPane: React.FC<PreviewPaneProps> = ({ rawUrl, onNavigate }) => {
|
|||||||
src={effectiveSrc}
|
src={effectiveSrc}
|
||||||
title={t('contextPanel.preview.iframeTitle')}
|
title={t('contextPanel.preview.iframeTitle')}
|
||||||
className="h-full w-full border-0"
|
className="h-full w-full border-0"
|
||||||
|
style={{ colorScheme: previewColorScheme }}
|
||||||
onLoad={handlePreviewFrameLoad}
|
onLoad={handlePreviewFrameLoad}
|
||||||
sandbox={isLoopback
|
sandbox={isLoopback
|
||||||
? 'allow-scripts allow-same-origin allow-forms allow-popups allow-downloads'
|
? 'allow-scripts allow-same-origin allow-forms allow-popups allow-downloads'
|
||||||
|
|||||||
@@ -11,6 +11,144 @@ const LOOPBACK_HOSTS = new Set([
|
|||||||
|
|
||||||
const PREVIEW_BRIDGE_SCRIPT_ID = 'openchamber-preview-bridge';
|
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`(() => {
|
const PREVIEW_BRIDGE_SCRIPT = String.raw`(() => {
|
||||||
if (window.__openchamberPreviewBridgeInstalled) return;
|
if (window.__openchamberPreviewBridgeInstalled) return;
|
||||||
window.__openchamberPreviewBridgeInstalled = true;
|
window.__openchamberPreviewBridgeInstalled = true;
|
||||||
@@ -22,6 +160,9 @@ const PREVIEW_BRIDGE_SCRIPT = String.raw`(() => {
|
|||||||
let inspectMode = false;
|
let inspectMode = false;
|
||||||
let lastHoverKey = '';
|
let lastHoverKey = '';
|
||||||
let pendingHover = null;
|
let pendingHover = null;
|
||||||
|
let previewColorScheme = null;
|
||||||
|
let nativeMatchMedia = null;
|
||||||
|
const colorSchemeListeners = new Set();
|
||||||
|
|
||||||
const post = (payload) => {
|
const post = (payload) => {
|
||||||
try {
|
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) => {
|
const readElementUrl = (element) => {
|
||||||
return element.currentSrc || element.src || element.href || element.action || '';
|
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() : '';
|
const tag = element && element.tagName && typeof element.tagName.toLowerCase === 'function' ? element.tagName.toLowerCase() : '';
|
||||||
if (tag !== 'script' && tag !== 'link') return false;
|
if (tag !== 'script' && tag !== 'link') return false;
|
||||||
if (tag === 'script' && typeof element.hasAttribute === 'function' && element.hasAttribute('data-cf-beacon')) return true;
|
if (tag === 'script' && typeof element.hasAttribute === 'function' && element.hasAttribute('data-cf-beacon')) return true;
|
||||||
const path = upstreamPathForUrl(value);
|
|
||||||
const pathAndSearch = upstreamPathAndSearchForUrl(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 === '/@react-refresh'
|
||||||
|| path.indexOf('/@id/astro:') === 0
|
|| path.indexOf('/@id/__x00__vite/') === 0
|
||||||
|| path.startsWith('/@id/__x00__vite/')
|
|| lower.indexOf('/node_modules/.vite/') >= 0
|
||||||
|| path.includes('/node_modules/.vite/')
|
|| lower.indexOf('/vite/dist/client/') >= 0
|
||||||
|| path.includes('/vite/dist/client/')
|
|| (tag === 'script' && lower.indexOf('/@id/') >= 0);
|
||||||
|| path.includes('/astro/dist/runtime/client/dev-toolbar/')
|
const astroNoise = path.indexOf('/@id/astro:') === 0
|
||||||
|| (pathAndSearch.indexOf('/node_modules/') >= 0 && pathAndSearch.indexOf('.astro?') >= 0 && pathAndSearch.indexOf('type=script') >= 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 {
|
try {
|
||||||
const parsed = new URL(value, window.location.href);
|
const parsed = new URL(value, window.location.href);
|
||||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return { action: 'allow', url: parsed.toString() };
|
||||||
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);
|
|
||||||
const current = new URL(window.location.href);
|
const current = new URL(window.location.href);
|
||||||
if (parsed.origin !== current.origin) return true;
|
if (parsed.origin === current.origin && parsed.pathname === current.pathname && parsed.search === current.search && parsed.hash) {
|
||||||
return !parsed.pathname.startsWith('/api/preview/proxy/');
|
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 {
|
} 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') {
|
if (data.type === 'set-inspect-mode') {
|
||||||
setInspectMode(data.enabled === true);
|
setInspectMode(data.enabled === true);
|
||||||
}
|
}
|
||||||
|
if (data.type === 'set-color-scheme') {
|
||||||
|
applyPreviewColorScheme(data.scheme);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener('mousemove', sendHover, true);
|
window.addEventListener('mousemove', sendHover, true);
|
||||||
@@ -440,11 +714,11 @@ const PREVIEW_BRIDGE_SCRIPT = String.raw`(() => {
|
|||||||
window.addEventListener('click', (event) => {
|
window.addEventListener('click', (event) => {
|
||||||
const anchor = event.target && typeof event.target.closest === 'function' ? event.target.closest('a[href]') : null;
|
const anchor = event.target && typeof event.target.closest === 'function' ? event.target.closest('a[href]') : null;
|
||||||
if (anchor && !inspectMode) {
|
if (anchor && !inspectMode) {
|
||||||
const nextUrl = toLoopbackHttpUrl(anchor.href);
|
const navigation = classifyNavigation(anchor.href);
|
||||||
if (shouldParentHandleNavigation(nextUrl)) {
|
if (navigation.action === 'proxy' || navigation.action === 'external') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
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;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -537,6 +811,78 @@ const normalizeLoopbackUrl = (rawUrl) => {
|
|||||||
return { ok: true, origin: url.origin };
|
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 = ({
|
export const createPreviewProxyRuntime = ({
|
||||||
crypto,
|
crypto,
|
||||||
URL,
|
URL,
|
||||||
@@ -688,76 +1034,6 @@ export const createPreviewProxyRuntime = ({
|
|||||||
}) => {
|
}) => {
|
||||||
ensureSweeper();
|
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) => {
|
const injectPreviewBridge = (bodyText) => {
|
||||||
if (typeof bodyText !== 'string' || bodyText.includes(PREVIEW_BRIDGE_SCRIPT_ID)) {
|
if (typeof bodyText !== 'string' || bodyText.includes(PREVIEW_BRIDGE_SCRIPT_ID)) {
|
||||||
return bodyText;
|
return bodyText;
|
||||||
@@ -914,10 +1190,20 @@ export const createPreviewProxyRuntime = ({
|
|||||||
const parsed = new URL(req.originalUrl || req.url || '', 'http://localhost');
|
const parsed = new URL(req.originalUrl || req.url || '', 'http://localhost');
|
||||||
const upstreamPath = stripProxyPrefix(parsed.pathname, resolved.id);
|
const upstreamPath = stripProxyPrefix(parsed.pathname, resolved.id);
|
||||||
if (isJavaScript && upstreamPath === '/@vite/client') {
|
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;
|
return isHtml ? injectPreviewBridge(rewrittenBody) : rewrittenBody;
|
||||||
}),
|
}),
|
||||||
error: (err, _req, res) => {
|
error: (err, _req, res) => {
|
||||||
|
|||||||
@@ -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 = '<img src="/logo.png"><a href="/docs">Docs</a><script>const url = "/api/data";</script>';
|
||||||
|
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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user