From f08567717780b777c812574e771a1d0e84148cdb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 15 Jul 2026 18:02:23 +0200 Subject: [PATCH 1/3] Resolve localhost preview URLs for remote environments - Map loopback preview navigation to private environment hosts - Reuse resolved URLs for preview automation opens --- .../src/browser/browserTargetResolver.test.ts | 66 ++++++++++- apps/web/src/browser/browserTargetResolver.ts | 110 ++++++++++++------ .../preview/PreviewAutomationHosts.tsx | 16 +-- 3 files changed, 145 insertions(+), 47 deletions(-) diff --git a/apps/web/src/browser/browserTargetResolver.test.ts b/apps/web/src/browser/browserTargetResolver.test.ts index d3c7f6a8dab..2af106a06f7 100644 --- a/apps/web/src/browser/browserTargetResolver.test.ts +++ b/apps/web/src/browser/browserTargetResolver.test.ts @@ -25,6 +25,49 @@ describe("browser target resolver", () => { }); }); + it("maps localhost URL navigation onto a remote Tailscale IPv4 host", async () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://100.65.180.100:3773" }); + const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); + expect( + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "url", + url: "http://localhost:5173/dashboard?mode=test#results", + }), + ).toEqual({ + requestedUrl: "http://localhost:5173/dashboard?mode=test#results", + resolvedUrl: "http://100.65.180.100:5173/dashboard?mode=test#results", + resolutionKind: "direct-private-network", + environmentId: "environment-1", + }); + }); + + it("maps schemeless localhost navigation onto a remote environment host", async () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.1.25:3773" }); + const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); + expect( + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "url", + url: "localhost:3000/app", + }).resolvedUrl, + ).toBe("http://192.168.1.25:3000/app"); + }); + + it("keeps localhost navigation local for a local environment", async () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://127.0.0.1:3773" }); + const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); + expect( + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "url", + url: "localhost:3000/app", + }), + ).toEqual({ + requestedUrl: "localhost:3000/app", + resolvedUrl: "localhost:3000/app", + resolutionKind: "direct", + environmentId: "environment-1", + }); + }); + it("refuses public relay hosts until the authenticated gateway exists", async () => { readPreparedConnection.mockReturnValue({ httpBaseUrl: "https://relay.example.com" }); const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); @@ -34,6 +77,12 @@ describe("browser target resolver", () => { port: 5173, }), ).toThrow(/authenticated preview gateway/); + expect(() => + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "url", + url: "http://localhost:5173", + }), + ).toThrow(/authenticated preview gateway/); }); it("normalizes schemeless localhost server-picker values", async () => { @@ -63,7 +112,9 @@ describe("browser target resolver", () => { }); it("supports private IPv6 environment hosts", async () => { - readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://[::1]:3773" }); + readPreparedConnection.mockReturnValue({ + httpBaseUrl: "http://[fd7a:115c:a1e0::53]:3773", + }); const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); expect( resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { @@ -71,7 +122,18 @@ describe("browser target resolver", () => { port: 5173, path: "/app?mode=test", }).resolvedUrl, - ).toBe("http://[::1]:5173/app?mode=test"); + ).toBe("http://[fd7a:115c:a1e0::53]:5173/app?mode=test"); + }); + + it("supports a local IPv6 environment host", async () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://[::1]:3773" }); + const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); + expect( + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "environment-port", + port: 5173, + }).resolvedUrl, + ).toBe("http://[::1]:5173/"); }); it("leaves malformed input for the normal navigation error path", async () => { diff --git a/apps/web/src/browser/browserTargetResolver.ts b/apps/web/src/browser/browserTargetResolver.ts index 9142cce1e72..363b546c786 100644 --- a/apps/web/src/browser/browserTargetResolver.ts +++ b/apps/web/src/browser/browserTargetResolver.ts @@ -14,13 +14,25 @@ const isPrivateNetworkHost = (host: string): boolean => { } if (normalized.endsWith(".ts.net")) return true; const parts = normalized.split(".").map(Number); - if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part))) return false; + if ( + parts.length === 4 && + parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255) + ) { + return ( + parts[0] === 10 || + (parts[0] === 100 && parts[1]! >= 64 && parts[1]! <= 127) || + (parts[0] === 172 && parts[1]! >= 16 && parts[1]! <= 31) || + (parts[0] === 192 && parts[1] === 168) || + parts[0] === 127 || + (parts[0] === 169 && parts[1] === 254) + ); + } + const firstIpv6Token = normalized.split(":", 1)[0] ?? ""; + if (!normalized.includes(":") || !/^[\da-f]{1,4}$/u.test(firstIpv6Token)) return false; + const firstIpv6Hextet = Number.parseInt(firstIpv6Token, 16); return ( - parts[0] === 10 || - (parts[0] === 172 && parts[1]! >= 16 && parts[1]! <= 31) || - (parts[0] === 192 && parts[1] === 168) || - parts[0] === 127 || - (parts[0] === 169 && parts[1] === 254) + Number.isInteger(firstIpv6Hextet) && + ((firstIpv6Hextet & 0xfe00) === 0xfc00 || (firstIpv6Hextet & 0xffc0) === 0xfe80) ); }; @@ -29,21 +41,18 @@ const isLocalLoopbackHost = (host: string): boolean => { return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1"; }; -export function resolveBrowserNavigationTarget( - environmentId: EnvironmentId, - target: BrowserNavigationTarget, -): PreviewUrlResolution { - if (target.kind === "url") { - return { - requestedUrl: target.url, - resolvedUrl: target.url, - resolutionKind: "direct", - environmentId, - }; - } +const readEnvironmentUrl = (environmentId: EnvironmentId): URL => { const connection = readPreparedConnection(environmentId); if (!connection) throw new Error(`Environment ${environmentId} is not connected.`); - const environmentUrl = new URL(connection.httpBaseUrl); + return new URL(connection.httpBaseUrl); +}; + +const resolveEnvironmentPortTarget = ( + environmentId: EnvironmentId, + target: Extract, + environmentUrl: URL, + requestedUrl?: string, +): PreviewUrlResolution => { if (!isPrivateNetworkHost(environmentUrl.hostname)) { throw new Error( "This environment port needs the planned authenticated preview gateway; its server address is not directly private-network reachable.", @@ -51,40 +60,65 @@ export function resolveBrowserNavigationTarget( } const protocol = target.protocol ?? "http"; const path = target.path?.startsWith("/") ? target.path : `/${target.path ?? ""}`; - const requestedUrl = `${protocol}://localhost:${target.port}${path}`; const normalizedEnvironmentHost = environmentUrl.hostname.replace(/^\[|\]$/g, ""); const resolvedHost = normalizedEnvironmentHost.includes(":") ? `[${normalizedEnvironmentHost}]` : normalizedEnvironmentHost; const resolved = new URL(path, `${protocol}://${resolvedHost}:${target.port}`); return { - requestedUrl, + requestedUrl: requestedUrl ?? `${protocol}://localhost:${target.port}${path}`, resolvedUrl: resolved.toString(), - resolutionKind: - normalizedEnvironmentHost === "localhost" || normalizedEnvironmentHost === "127.0.0.1" - ? "direct" - : "direct-private-network", + resolutionKind: isLocalLoopbackHost(normalizedEnvironmentHost) + ? "direct" + : "direct-private-network", environmentId, }; +}; + +export function resolveBrowserNavigationTarget( + environmentId: EnvironmentId, + target: BrowserNavigationTarget, +): PreviewUrlResolution { + if (target.kind === "url") { + let parsed: URL | null = null; + try { + parsed = new URL(normalizePreviewUrl(target.url)); + } catch { + // Preserve the existing direct-navigation behavior so the preview host + // reports malformed URL errors through its normal navigation path. + } + if (parsed && isLoopbackHost(parsed.hostname)) { + const environmentUrl = readEnvironmentUrl(environmentId); + if (parsed.hostname === "0.0.0.0" || !isLocalLoopbackHost(environmentUrl.hostname)) { + return resolveEnvironmentPortTarget( + environmentId, + { + kind: "environment-port", + port: Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80)), + protocol: parsed.protocol === "https:" ? "https" : "http", + path: `${parsed.pathname}${parsed.search}${parsed.hash}`, + }, + environmentUrl, + target.url, + ); + } + } + return { + requestedUrl: target.url, + resolvedUrl: target.url, + resolutionKind: "direct", + environmentId, + }; + } + return resolveEnvironmentPortTarget(environmentId, target, readEnvironmentUrl(environmentId)); } export function resolveDiscoveredServerUrl(environmentId: EnvironmentId, rawUrl: string): string { try { const normalizedUrl = normalizePreviewUrl(rawUrl); - const parsed = new URL(normalizedUrl); - if (!isLoopbackHost(parsed.hostname)) return normalizedUrl; - const connection = readPreparedConnection(environmentId); - if (!connection) throw new Error(`Environment ${environmentId} is not connected.`); - const environmentUrl = new URL(connection.httpBaseUrl); - if (parsed.hostname !== "0.0.0.0" && isLocalLoopbackHost(environmentUrl.hostname)) { - return normalizedUrl; - } - const port = Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80)); return resolveBrowserNavigationTarget(environmentId, { - kind: "environment-port", - port, - protocol: parsed.protocol === "https:" ? "https" : "http", - path: `${parsed.pathname}${parsed.search}${parsed.hash}`, + kind: "url", + url: normalizedUrl, }).resolvedUrl; } catch { return rawUrl; diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx index d637611a786..06954b73f37 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx @@ -343,6 +343,12 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) return await currentStatus(threadRef, tabId); case "open": { const input = request.input as PreviewAutomationOpenInput; + const resolvedInputUrl = input.url + ? resolveBrowserNavigationTarget(environmentId, { + kind: "url", + url: input.url, + }).resolvedUrl + : undefined; let activeTabId = resolvePreviewAutomationOpenTab( state, request.tabId, @@ -358,7 +364,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) environmentId, input: { threadId: request.threadId, - ...(input.url ? { url: input.url } : {}), + ...(resolvedInputUrl ? { url: resolvedInputUrl } : {}), }, }); if (result._tag === "Failure") { @@ -381,12 +387,8 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) request.timeoutMs, ); } - if (reusedExistingTab && input.url && previewBridge) { - const resolution = resolveBrowserNavigationTarget(environmentId, { - kind: "url", - url: input.url, - }); - await previewBridge.navigate(activeTabId, resolution.resolvedUrl); + if (reusedExistingTab && resolvedInputUrl && previewBridge) { + await previewBridge.navigate(activeTabId, resolvedInputUrl); await waitForNavigationReadiness( threadRef, request.requestId, From 140ae9361368e6bdcb4b31c05fdacdf1d08501d6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 15 Jul 2026 18:45:05 +0200 Subject: [PATCH 2/3] Address remote preview URL review feedback Preserve URL credentials while rewriting loopback targets and treat the full IPv4 loopback range as local. Co-authored-by: codex --- .../src/browser/browserTargetResolver.test.ts | 27 ++++++++++++ apps/web/src/browser/browserTargetResolver.ts | 43 +++++++++++++------ 2 files changed, 56 insertions(+), 14 deletions(-) diff --git a/apps/web/src/browser/browserTargetResolver.test.ts b/apps/web/src/browser/browserTargetResolver.test.ts index 2af106a06f7..a1f015ca93c 100644 --- a/apps/web/src/browser/browserTargetResolver.test.ts +++ b/apps/web/src/browser/browserTargetResolver.test.ts @@ -41,6 +41,17 @@ describe("browser target resolver", () => { }); }); + it("preserves URL credentials when mapping localhost onto a remote host", async () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://100.65.180.100:3773" }); + const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); + expect( + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "url", + url: "http://user:p%40ss@localhost:5173/dashboard", + }).resolvedUrl, + ).toBe("http://user:p%40ss@100.65.180.100:5173/dashboard"); + }); + it("maps schemeless localhost navigation onto a remote environment host", async () => { readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.1.25:3773" }); const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); @@ -68,6 +79,22 @@ describe("browser target resolver", () => { }); }); + it("keeps localhost navigation local for the full IPv4 loopback range", async () => { + readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://127.0.0.2:3773" }); + const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); + expect( + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "url", + url: "http://localhost:3000/app", + }), + ).toEqual({ + requestedUrl: "http://localhost:3000/app", + resolvedUrl: "http://localhost:3000/app", + resolutionKind: "direct", + environmentId: "environment-1", + }); + }); + it("refuses public relay hosts until the authenticated gateway exists", async () => { readPreparedConnection.mockReturnValue({ httpBaseUrl: "https://relay.example.com" }); const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); diff --git a/apps/web/src/browser/browserTargetResolver.ts b/apps/web/src/browser/browserTargetResolver.ts index 363b546c786..9b201dbdbae 100644 --- a/apps/web/src/browser/browserTargetResolver.ts +++ b/apps/web/src/browser/browserTargetResolver.ts @@ -7,23 +7,35 @@ import { isLoopbackHost, normalizePreviewUrl } from "@t3tools/shared/preview"; import { readPreparedConnection } from "~/state/session"; +const normalizeHostname = (host: string): string => host.toLowerCase().replace(/^\[|\]$/g, ""); + +const parseIpv4Address = (host: string): readonly number[] | null => { + const parts = normalizeHostname(host).split(".").map(Number); + return parts.length === 4 && + parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255) + ? parts + : null; +}; + +const isLocalLoopbackHost = (host: string): boolean => { + const normalized = normalizeHostname(host); + if (normalized === "localhost" || normalized === "::1") return true; + return parseIpv4Address(normalized)?.[0] === 127; +}; + const isPrivateNetworkHost = (host: string): boolean => { - const normalized = host.toLowerCase().replace(/^\[|\]$/g, ""); - if (normalized === "localhost" || normalized === "::1" || normalized.endsWith(".local")) { + const normalized = normalizeHostname(host); + if (isLocalLoopbackHost(normalized) || normalized.endsWith(".local")) { return true; } if (normalized.endsWith(".ts.net")) return true; - const parts = normalized.split(".").map(Number); - if ( - parts.length === 4 && - parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255) - ) { + const parts = parseIpv4Address(normalized); + if (parts) { return ( parts[0] === 10 || (parts[0] === 100 && parts[1]! >= 64 && parts[1]! <= 127) || (parts[0] === 172 && parts[1]! >= 16 && parts[1]! <= 31) || (parts[0] === 192 && parts[1] === 168) || - parts[0] === 127 || (parts[0] === 169 && parts[1] === 254) ); } @@ -36,11 +48,6 @@ const isPrivateNetworkHost = (host: string): boolean => { ); }; -const isLocalLoopbackHost = (host: string): boolean => { - const normalized = host.toLowerCase().replace(/^\[|\]$/g, ""); - return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1"; -}; - const readEnvironmentUrl = (environmentId: EnvironmentId): URL => { const connection = readPreparedConnection(environmentId); if (!connection) throw new Error(`Environment ${environmentId} is not connected.`); @@ -52,6 +59,7 @@ const resolveEnvironmentPortTarget = ( target: Extract, environmentUrl: URL, requestedUrl?: string, + sourceUrl?: URL, ): PreviewUrlResolution => { if (!isPrivateNetworkHost(environmentUrl.hostname)) { throw new Error( @@ -64,7 +72,13 @@ const resolveEnvironmentPortTarget = ( const resolvedHost = normalizedEnvironmentHost.includes(":") ? `[${normalizedEnvironmentHost}]` : normalizedEnvironmentHost; - const resolved = new URL(path, `${protocol}://${resolvedHost}:${target.port}`); + const resolved = sourceUrl + ? new URL(sourceUrl) + : new URL(path, `${protocol}://${resolvedHost}:${target.port}`); + if (sourceUrl) { + resolved.hostname = resolvedHost; + resolved.port = String(target.port); + } return { requestedUrl: requestedUrl ?? `${protocol}://localhost:${target.port}${path}`, resolvedUrl: resolved.toString(), @@ -100,6 +114,7 @@ export function resolveBrowserNavigationTarget( }, environmentUrl, target.url, + parsed, ); } } From 5c215dacc76e55d5d8d83388916ebcb3c4d33afc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 15 Jul 2026 18:49:53 +0200 Subject: [PATCH 3/3] Cover credentialed IPv6 preview URLs Document the WHATWG URL hostname behavior used by remote preview rewriting with a focused regression test. Co-authored-by: codex --- apps/web/src/browser/browserTargetResolver.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/apps/web/src/browser/browserTargetResolver.test.ts b/apps/web/src/browser/browserTargetResolver.test.ts index a1f015ca93c..6f89d86df88 100644 --- a/apps/web/src/browser/browserTargetResolver.test.ts +++ b/apps/web/src/browser/browserTargetResolver.test.ts @@ -52,6 +52,19 @@ describe("browser target resolver", () => { ).toBe("http://user:p%40ss@100.65.180.100:5173/dashboard"); }); + it("maps credentialed localhost URLs onto private IPv6 hosts", async () => { + readPreparedConnection.mockReturnValue({ + httpBaseUrl: "http://[fd7a:115c:a1e0::53]:3773", + }); + const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); + expect( + resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { + kind: "url", + url: "http://user:p%40ss@localhost:5173/dashboard?mode=test#results", + }).resolvedUrl, + ).toBe("http://user:p%40ss@[fd7a:115c:a1e0::53]:5173/dashboard?mode=test#results"); + }); + it("maps schemeless localhost navigation onto a remote environment host", async () => { readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.1.25:3773" }); const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver");