This commit is contained in:
Timothy Jaeryang Baek
2026-03-23 22:00:37 -05:00
parent 6890618221
commit 1dc647f43b
@@ -5,6 +5,7 @@
const i18n = getContext('i18n');
export let baseUrl: string;
export let port: number;
export let path: string = '';
@@ -23,7 +24,6 @@
$: canGoForward = historyIndex < history.length - 1;
const pushHistory = (newPath: string) => {
// Trim forward history when navigating from a non-tip position
if (historyIndex < history.length - 1) {
history = history.slice(0, historyIndex + 1);
}
@@ -35,6 +35,7 @@
if (!canGoBack) return;
historyIndex -= 1;
path = history[historyIndex];
syncUrlBar();
iframeKey += 1;
};
@@ -42,28 +43,26 @@
if (!canGoForward) return;
historyIndex += 1;
path = history[historyIndex];
syncUrlBar();
iframeKey += 1;
};
// ── URLs ─────────────────────────────────────────────────────────────
$: proxyUrl = getPortProxyUrl(baseUrl, port, path);
// The proxy path prefix, e.g. "/proxy/8080/"
$: proxyPathPrefix = (() => {
try {
return new URL(getPortProxyUrl(baseUrl, port, '')).pathname;
return new URL(getPortProxyUrl(baseUrl, port, ''), window.location.origin).pathname;
} catch {
return `/proxy/${port}/`;
}
})();
// Display-friendly localhost URL for the address bar
$: displayUrl = `localhost:${port}${path ? '/' + path : ''}`;
$: urlInput = displayUrl;
const makeDisplayUrl = (p: string) => `localhost:${port}${p ? '/' + p : ''}`;
const syncUrlBar = () => { urlInput = makeDisplayUrl(path); };
urlInput = makeDisplayUrl(path);
const refresh = () => {
iframeKey += 1;
};
const refresh = () => { iframeKey += 1; };
const openExternal = () => {
window.open(proxyUrl, '_blank', 'noopener,noreferrer');
@@ -84,113 +83,36 @@
path = newPath;
pushHistory(path);
}
syncUrlBar();
iframeKey += 1;
};
/**
* After each iframe load:
* 1. Sync the address bar with the current iframe path
* 2. Inject a click interceptor to rewrite links BEFORE navigation
* 3. Redirect if the page already escaped the proxy (fallback)
* Read the iframe's current location and sync the URL bar.
* If the iframe escaped the proxy prefix, redirect it back.
*/
const onIframeLoad = () => {
isLoading = false;
if (!iframeEl) return;
try {
const doc = iframeEl.contentDocument;
const win = iframeEl.contentWindow;
if (!doc || !win) return;
const loc = win.location;
const iframePath = loc?.pathname ?? '';
const iframeSearch = loc?.search ?? '';
const iframeHash = loc?.hash ?? '';
const loc = iframeEl.contentWindow?.location;
if (!loc) return;
const iframePath = loc.pathname ?? '';
const iframeSearch = loc.search ?? '';
const iframeHash = loc.hash ?? '';
if (iframePath.startsWith(proxyPathPrefix)) {
// Still inside the proxy — sync address bar
const relativePath = iframePath.slice(proxyPathPrefix.length) + iframeSearch + iframeHash;
if (relativePath !== path) {
path = relativePath;
pushHistory(path);
urlInput = displayUrl;
syncUrlBar();
}
// Inject click interceptor to rewrite links before navigation
injectLinkInterceptor(doc, win);
} else if (iframePath && iframePath !== 'about:blank') {
// Escaped the proxy — redirect back through it
const escapedPath = iframePath.replace(/^\//, '') + iframeSearch + iframeHash;
const correctedUrl = getPortProxyUrl(baseUrl, port, escapedPath);
loc?.replace(correctedUrl);
}
} catch {
// Cross-origin — can't access
}
};
/**
* Inject a click interceptor into the iframe document that rewrites
* anchor hrefs to stay within the proxy path.
*/
const injectLinkInterceptor = (doc: Document, win: Window) => {
// Skip if we already injected
if ((win as any).__portPreviewIntercepted) return;
(win as any).__portPreviewIntercepted = true;
const prefix = proxyPathPrefix;
// Intercept all click events on anchors
doc.addEventListener('click', (e: MouseEvent) => {
const anchor = (e.target as HTMLElement)?.closest?.('a');
if (!anchor || !anchor.href) return;
try {
const url = new URL(anchor.href, win.location.href);
// Only intercept same-origin links that escaped the proxy
if (url.origin === win.location.origin && !url.pathname.startsWith(prefix)) {
e.preventDefault();
e.stopPropagation();
// Rewrite to go through proxy
const rewrittenPath = prefix + url.pathname.replace(/^\//, '') + url.search + url.hash;
win.location.href = url.origin + rewrittenPath;
}
} catch {
// Invalid URL — let browser handle it
}
}, true); // Use capture phase to intercept before SPA routers
// Also intercept form submissions
doc.addEventListener('submit', (e: Event) => {
const form = e.target as HTMLFormElement;
if (!form?.action) return;
try {
const url = new URL(form.action, win.location.href);
if (url.origin === win.location.origin && !url.pathname.startsWith(prefix)) {
form.action = url.origin + prefix + url.pathname.replace(/^\//, '') + url.search;
}
} catch {}
}, true);
// Intercept window.open and programmatic navigation
const origOpen = win.open.bind(win);
win.open = function(url?: string | URL, ...args: any[]) {
if (url && typeof url === 'string') {
try {
const parsed = new URL(url, win.location.href);
if (parsed.origin === win.location.origin && !parsed.pathname.startsWith(prefix)) {
url = parsed.origin + prefix + parsed.pathname.replace(/^\//, '') + parsed.search + parsed.hash;
}
} catch {}
}
return origOpen(url, ...args);
};
};
const onIframeLoadStart = () => {
isLoading = true;
};
</script>
<div class="flex flex-col h-full min-h-0">
@@ -230,22 +152,16 @@
</button>
</Tooltip>
<!-- Refresh / Stop -->
<!-- Refresh -->
<Tooltip content={$i18n.t('Refresh')}>
<button
class="p-1 rounded text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-800 hover:text-gray-700 dark:hover:text-gray-300 transition"
on:click={refresh}
aria-label={$i18n.t('Refresh')}
>
{#if isLoading}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-3.5 animate-spin">
<path fill-rule="evenodd" d="M15.312 11.424a5.5 5.5 0 0 1-9.201 2.466l-.312-.311h2.451a.75.75 0 0 0 0-1.5H4.5a.75.75 0 0 0-.75.75v3.75a.75.75 0 0 0 1.5 0v-2.127l.13.13a7 7 0 0 0 11.712-3.138.75.75 0 0 0-1.449-.39Zm-10.624-2.85a5.5 5.5 0 0 1 9.201-2.465l.312.31H11.75a.75.75 0 0 0 0 1.5h3.75a.75.75 0 0 0 .75-.75V3.42a.75.75 0 0 0-1.5 0v2.126l-.13-.129A7 7 0 0 0 3.239 8.555a.75.75 0 0 0 1.449.39Z" clip-rule="evenodd" />
</svg>
{:else}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-3.5">
<path fill-rule="evenodd" d="M15.312 11.424a5.5 5.5 0 0 1-9.201 2.466l-.312-.311h2.451a.75.75 0 0 0 0-1.5H4.5a.75.75 0 0 0-.75.75v3.75a.75.75 0 0 0 1.5 0v-2.127l.13.13a7 7 0 0 0 11.712-3.138.75.75 0 0 0-1.449-.39Zm-10.624-2.85a5.5 5.5 0 0 1 9.201-2.465l.312.31H11.75a.75.75 0 0 0 0 1.5h3.75a.75.75 0 0 0 .75-.75V3.42a.75.75 0 0 0-1.5 0v2.126l-.13-.129A7 7 0 0 0 3.239 8.555a.75.75 0 0 0 1.449.39Z" clip-rule="evenodd" />
</svg>
{/if}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-3.5" class:animate-spin={isLoading}>
<path fill-rule="evenodd" d="M15.312 11.424a5.5 5.5 0 0 1-9.201 2.466l-.312-.311h2.451a.75.75 0 0 0 0-1.5H4.5a.75.75 0 0 0-.75.75v3.75a.75.75 0 0 0 1.5 0v-2.127l.13.13a7 7 0 0 0 11.712-3.138.75.75 0 0 0-1.449-.39Zm-10.624-2.85a5.5 5.5 0 0 1 9.201-2.465l.312.31H11.75a.75.75 0 0 0 0 1.5h3.75a.75.75 0 0 0 .75-.75V3.42a.75.75 0 0 0-1.5 0v2.126l-.13-.129A7 7 0 0 0 3.239 8.555a.75.75 0 0 0 1.449.39Z" clip-rule="evenodd" />
</svg>
</button>
</Tooltip>
@@ -275,7 +191,7 @@
</button>
</Tooltip>
<!-- Close preview -->
<!-- Close -->
<Tooltip content={$i18n.t('Close')}>
<button
class="p-1 rounded text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-800 hover:text-gray-700 dark:hover:text-gray-300 transition"
@@ -296,7 +212,7 @@
</div>
{/if}
<!-- Iframe content -->
<!-- Iframe -->
{#key iframeKey}
<iframe
bind:this={iframeEl}