| author | |
| committer | |
| log | bde0a4c0dd2d420a5fd71f39448d5db1bab078da |
| tree | fba85221e6da92e8a4bab189c915ffb5dc6eba16 |
| parent | 54f167e2e0b857a60946590bfa983fb93b4f138a |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>3 files changed, 208 insertions(+), 55 deletions(-)
apps/web/src/browser/browserTargetResolver.test.ts+104-2| ... | ... | @@ -25,6 +25,89 @@ describe("browser target resolver", () => { |
| 25 | 25 | }); |
| 26 | 26 | }); |
| 27 | 27 | |
| 28 | it("maps localhost URL navigation onto a remote Tailscale IPv4 host", async () => { | |
| 29 | readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://100.65.180.100:3773" }); | |
| 30 | const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); | |
| 31 | expect( | |
| 32 | resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { | |
| 33 | kind: "url", | |
| 34 | url: "http://localhost:5173/dashboard?mode=test#results", | |
| 35 | }), | |
| 36 | ).toEqual({ | |
| 37 | requestedUrl: "http://localhost:5173/dashboard?mode=test#results", | |
| 38 | resolvedUrl: "http://100.65.180.100:5173/dashboard?mode=test#results", | |
| 39 | resolutionKind: "direct-private-network", | |
| 40 | environmentId: "environment-1", | |
| 41 | }); | |
| 42 | }); | |
| 43 | ||
| 44 | it("preserves URL credentials when mapping localhost onto a remote host", async () => { | |
| 45 | readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://100.65.180.100:3773" }); | |
| 46 | const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); | |
| 47 | expect( | |
| 48 | resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { | |
| 49 | kind: "url", | |
| 50 | url: "http://user:p%40ss@localhost:5173/dashboard", | |
| 51 | }).resolvedUrl, | |
| 52 | ).toBe("http://user:p%40ss@100.65.180.100:5173/dashboard"); | |
| 53 | }); | |
| 54 | ||
| 55 | it("maps credentialed localhost URLs onto private IPv6 hosts", async () => { | |
| 56 | readPreparedConnection.mockReturnValue({ | |
| 57 | httpBaseUrl: "http://[fd7a:115c:a1e0::53]:3773", | |
| 58 | }); | |
| 59 | const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); | |
| 60 | expect( | |
| 61 | resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { | |
| 62 | kind: "url", | |
| 63 | url: "http://user:p%40ss@localhost:5173/dashboard?mode=test#results", | |
| 64 | }).resolvedUrl, | |
| 65 | ).toBe("http://user:p%40ss@[fd7a:115c:a1e0::53]:5173/dashboard?mode=test#results"); | |
| 66 | }); | |
| 67 | ||
| 68 | it("maps schemeless localhost navigation onto a remote environment host", async () => { | |
| 69 | readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.1.25:3773" }); | |
| 70 | const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); | |
| 71 | expect( | |
| 72 | resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { | |
| 73 | kind: "url", | |
| 74 | url: "localhost:3000/app", | |
| 75 | }).resolvedUrl, | |
| 76 | ).toBe("http://192.168.1.25:3000/app"); | |
| 77 | }); | |
| 78 | ||
| 79 | it("keeps localhost navigation local for a local environment", async () => { | |
| 80 | readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://127.0.0.1:3773" }); | |
| 81 | const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); | |
| 82 | expect( | |
| 83 | resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { | |
| 84 | kind: "url", | |
| 85 | url: "localhost:3000/app", | |
| 86 | }), | |
| 87 | ).toEqual({ | |
| 88 | requestedUrl: "localhost:3000/app", | |
| 89 | resolvedUrl: "localhost:3000/app", | |
| 90 | resolutionKind: "direct", | |
| 91 | environmentId: "environment-1", | |
| 92 | }); | |
| 93 | }); | |
| 94 | ||
| 95 | it("keeps localhost navigation local for the full IPv4 loopback range", async () => { | |
| 96 | readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://127.0.0.2:3773" }); | |
| 97 | const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); | |
| 98 | expect( | |
| 99 | resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { | |
| 100 | kind: "url", | |
| 101 | url: "http://localhost:3000/app", | |
| 102 | }), | |
| 103 | ).toEqual({ | |
| 104 | requestedUrl: "http://localhost:3000/app", | |
| 105 | resolvedUrl: "http://localhost:3000/app", | |
| 106 | resolutionKind: "direct", | |
| 107 | environmentId: "environment-1", | |
| 108 | }); | |
| 109 | }); | |
| 110 | ||
| 28 | 111 | it("refuses public relay hosts until the authenticated gateway exists", async () => { |
| 29 | 112 | readPreparedConnection.mockReturnValue({ httpBaseUrl: "https://relay.example.com" }); |
| 30 | 113 | const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); |
| ... | ... | @@ -34,6 +117,12 @@ describe("browser target resolver", () => { |
| 34 | 117 | port: 5173, |
| 35 | 118 | }), |
| 36 | 119 | ).toThrow(/authenticated preview gateway/); |
| 120 | expect(() => | |
| 121 | resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { | |
| 122 | kind: "url", | |
| 123 | url: "http://localhost:5173", | |
| 124 | }), | |
| 125 | ).toThrow(/authenticated preview gateway/); | |
| 37 | 126 | }); |
| 38 | 127 | |
| 39 | 128 | it("normalizes schemeless localhost server-picker values", async () => { |
| ... | ... | @@ -63,7 +152,9 @@ describe("browser target resolver", () => { |
| 63 | 152 | }); |
| 64 | 153 | |
| 65 | 154 | it("supports private IPv6 environment hosts", async () => { |
| 66 | readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://[::1]:3773" }); | |
| 155 | readPreparedConnection.mockReturnValue({ | |
| 156 | httpBaseUrl: "http://[fd7a:115c:a1e0::53]:3773", | |
| 157 | }); | |
| 67 | 158 | const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); |
| 68 | 159 | expect( |
| 69 | 160 | resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { |
| ... | ... | @@ -71,7 +162,18 @@ describe("browser target resolver", () => { |
| 71 | 162 | port: 5173, |
| 72 | 163 | path: "/app?mode=test", |
| 73 | 164 | }).resolvedUrl, |
| 74 | ).toBe("http://[::1]:5173/app?mode=test"); | |
| 165 | ).toBe("http://[fd7a:115c:a1e0::53]:5173/app?mode=test"); | |
| 166 | }); | |
| 167 | ||
| 168 | it("supports a local IPv6 environment host", async () => { | |
| 169 | readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://[::1]:3773" }); | |
| 170 | const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); | |
| 171 | expect( | |
| 172 | resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), { | |
| 173 | kind: "environment-port", | |
| 174 | port: 5173, | |
| 175 | }).resolvedUrl, | |
| 176 | ).toBe("http://[::1]:5173/"); | |
| 75 | 177 | }); |
| 76 | 178 | |
| 77 | 179 | it("leaves malformed input for the normal navigation error path", async () => { |
apps/web/src/browser/browserTargetResolver.ts+95-46| ... | ... | @@ -7,43 +7,60 @@ import { isLoopbackHost, normalizePreviewUrl } from "@t3tools/shared/preview"; |
| 7 | 7 | |
| 8 | 8 | import { readPreparedConnection } from "~/state/session"; |
| 9 | 9 | |
| 10 | const normalizeHostname = (host: string): string => host.toLowerCase().replace(/^\[|\]$/g, ""); | |
| 11 | ||
| 12 | const parseIpv4Address = (host: string): readonly number[] | null => { | |
| 13 | const parts = normalizeHostname(host).split(".").map(Number); | |
| 14 | return parts.length === 4 && | |
| 15 | parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255) | |
| 16 | ? parts | |
| 17 | : null; | |
| 18 | }; | |
| 19 | ||
| 20 | const isLocalLoopbackHost = (host: string): boolean => { | |
| 21 | const normalized = normalizeHostname(host); | |
| 22 | if (normalized === "localhost" || normalized === "::1") return true; | |
| 23 | return parseIpv4Address(normalized)?.[0] === 127; | |
| 24 | }; | |
| 25 | ||
| 10 | 26 | const isPrivateNetworkHost = (host: string): boolean => { |
| 11 | const normalized = host.toLowerCase().replace(/^\[|\]$/g, ""); | |
| 12 | if (normalized === "localhost" || normalized === "::1" || normalized.endsWith(".local")) { | |
| 27 | const normalized = normalizeHostname(host); | |
| 28 | if (isLocalLoopbackHost(normalized) || normalized.endsWith(".local")) { | |
| 13 | 29 | return true; |
| 14 | 30 | } |
| 15 | 31 | if (normalized.endsWith(".ts.net")) return true; |
| 16 | const parts = normalized.split(".").map(Number); | |
| 17 | if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part))) return false; | |
| 32 | const parts = parseIpv4Address(normalized); | |
| 33 | if (parts) { | |
| 34 | return ( | |
| 35 | parts[0] === 10 || | |
| 36 | (parts[0] === 100 && parts[1]! >= 64 && parts[1]! <= 127) || | |
| 37 | (parts[0] === 172 && parts[1]! >= 16 && parts[1]! <= 31) || | |
| 38 | (parts[0] === 192 && parts[1] === 168) || | |
| 39 | (parts[0] === 169 && parts[1] === 254) | |
| 40 | ); | |
| 41 | } | |
| 42 | const firstIpv6Token = normalized.split(":", 1)[0] ?? ""; | |
| 43 | if (!normalized.includes(":") || !/^[\da-f]{1,4}$/u.test(firstIpv6Token)) return false; | |
| 44 | const firstIpv6Hextet = Number.parseInt(firstIpv6Token, 16); | |
| 18 | 45 | return ( |
| 19 | parts[0] === 10 || | |
| 20 | (parts[0] === 172 && parts[1]! >= 16 && parts[1]! <= 31) || | |
| 21 | (parts[0] === 192 && parts[1] === 168) || | |
| 22 | parts[0] === 127 || | |
| 23 | (parts[0] === 169 && parts[1] === 254) | |
| 46 | Number.isInteger(firstIpv6Hextet) && | |
| 47 | ((firstIpv6Hextet & 0xfe00) === 0xfc00 || (firstIpv6Hextet & 0xffc0) === 0xfe80) | |
| 24 | 48 | ); |
| 25 | 49 | }; |
| 26 | 50 | |
| 27 | const isLocalLoopbackHost = (host: string): boolean => { | |
| 28 | const normalized = host.toLowerCase().replace(/^\[|\]$/g, ""); | |
| 29 | return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1"; | |
| 51 | const readEnvironmentUrl = (environmentId: EnvironmentId): URL => { | |
| 52 | const connection = readPreparedConnection(environmentId); | |
| 53 | if (!connection) throw new Error(`Environment ${environmentId} is not connected.`); | |
| 54 | return new URL(connection.httpBaseUrl); | |
| 30 | 55 | }; |
| 31 | 56 | |
| 32 | export function resolveBrowserNavigationTarget( | |
| 57 | const resolveEnvironmentPortTarget = ( | |
| 33 | 58 | environmentId: EnvironmentId, |
| 34 | target: BrowserNavigationTarget, | |
| 35 | ): PreviewUrlResolution { | |
| 36 | if (target.kind === "url") { | |
| 37 | return { | |
| 38 | requestedUrl: target.url, | |
| 39 | resolvedUrl: target.url, | |
| 40 | resolutionKind: "direct", | |
| 41 | environmentId, | |
| 42 | }; | |
| 43 | } | |
| 44 | const connection = readPreparedConnection(environmentId); | |
| 45 | if (!connection) throw new Error(`Environment ${environmentId} is not connected.`); | |
| 46 | const environmentUrl = new URL(connection.httpBaseUrl); | |
| 59 | target: Extract<BrowserNavigationTarget, { readonly kind: "environment-port" }>, | |
| 60 | environmentUrl: URL, | |
| 61 | requestedUrl?: string, | |
| 62 | sourceUrl?: URL, | |
| 63 | ): PreviewUrlResolution => { | |
| 47 | 64 | if (!isPrivateNetworkHost(environmentUrl.hostname)) { |
| 48 | 65 | throw new Error( |
| 49 | 66 | "This environment port needs the planned authenticated preview gateway; its server address is not directly private-network reachable.", |
| ... | ... | @@ -51,40 +68,72 @@ export function resolveBrowserNavigationTarget( |
| 51 | 68 | } |
| 52 | 69 | const protocol = target.protocol ?? "http"; |
| 53 | 70 | const path = target.path?.startsWith("/") ? target.path : `/${target.path ?? ""}`; |
| 54 | const requestedUrl = `${protocol}://localhost:${target.port}${path}`; | |
| 55 | 71 | const normalizedEnvironmentHost = environmentUrl.hostname.replace(/^\[|\]$/g, ""); |
| 56 | 72 | const resolvedHost = normalizedEnvironmentHost.includes(":") |
| 57 | 73 | ? `[${normalizedEnvironmentHost}]` |
| 58 | 74 | : normalizedEnvironmentHost; |
| 59 | const resolved = new URL(path, `${protocol}://${resolvedHost}:${target.port}`); | |
| 75 | const resolved = sourceUrl | |
| 76 | ? new URL(sourceUrl) | |
| 77 | : new URL(path, `${protocol}://${resolvedHost}:${target.port}`); | |
| 78 | if (sourceUrl) { | |
| 79 | resolved.hostname = resolvedHost; | |
| 80 | resolved.port = String(target.port); | |
| 81 | } | |
| 60 | 82 | return { |
| 61 | requestedUrl, | |
| 83 | requestedUrl: requestedUrl ?? `${protocol}://localhost:${target.port}${path}`, | |
| 62 | 84 | resolvedUrl: resolved.toString(), |
| 63 | resolutionKind: | |
| 64 | normalizedEnvironmentHost === "localhost" || normalizedEnvironmentHost === "127.0.0.1" | |
| 65 | ? "direct" | |
| 66 | : "direct-private-network", | |
| 85 | resolutionKind: isLocalLoopbackHost(normalizedEnvironmentHost) | |
| 86 | ? "direct" | |
| 87 | : "direct-private-network", | |
| 67 | 88 | environmentId, |
| 68 | 89 | }; |
| 90 | }; | |
| 91 | ||
| 92 | export function resolveBrowserNavigationTarget( | |
| 93 | environmentId: EnvironmentId, | |
| 94 | target: BrowserNavigationTarget, | |
| 95 | ): PreviewUrlResolution { | |
| 96 | if (target.kind === "url") { | |
| 97 | let parsed: URL | null = null; | |
| 98 | try { | |
| 99 | parsed = new URL(normalizePreviewUrl(target.url)); | |
| 100 | } catch { | |
| 101 | // Preserve the existing direct-navigation behavior so the preview host | |
| 102 | // reports malformed URL errors through its normal navigation path. | |
| 103 | } | |
| 104 | if (parsed && isLoopbackHost(parsed.hostname)) { | |
| 105 | const environmentUrl = readEnvironmentUrl(environmentId); | |
| 106 | if (parsed.hostname === "0.0.0.0" || !isLocalLoopbackHost(environmentUrl.hostname)) { | |
| 107 | return resolveEnvironmentPortTarget( | |
| 108 | environmentId, | |
| 109 | { | |
| 110 | kind: "environment-port", | |
| 111 | port: Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80)), | |
| 112 | protocol: parsed.protocol === "https:" ? "https" : "http", | |
| 113 | path: `${parsed.pathname}${parsed.search}${parsed.hash}`, | |
| 114 | }, | |
| 115 | environmentUrl, | |
| 116 | target.url, | |
| 117 | parsed, | |
| 118 | ); | |
| 119 | } | |
| 120 | } | |
| 121 | return { | |
| 122 | requestedUrl: target.url, | |
| 123 | resolvedUrl: target.url, | |
| 124 | resolutionKind: "direct", | |
| 125 | environmentId, | |
| 126 | }; | |
| 127 | } | |
| 128 | return resolveEnvironmentPortTarget(environmentId, target, readEnvironmentUrl(environmentId)); | |
| 69 | 129 | } |
| 70 | 130 | |
| 71 | 131 | export function resolveDiscoveredServerUrl(environmentId: EnvironmentId, rawUrl: string): string { |
| 72 | 132 | try { |
| 73 | 133 | const normalizedUrl = normalizePreviewUrl(rawUrl); |
| 74 | const parsed = new URL(normalizedUrl); | |
| 75 | if (!isLoopbackHost(parsed.hostname)) return normalizedUrl; | |
| 76 | const connection = readPreparedConnection(environmentId); | |
| 77 | if (!connection) throw new Error(`Environment ${environmentId} is not connected.`); | |
| 78 | const environmentUrl = new URL(connection.httpBaseUrl); | |
| 79 | if (parsed.hostname !== "0.0.0.0" && isLocalLoopbackHost(environmentUrl.hostname)) { | |
| 80 | return normalizedUrl; | |
| 81 | } | |
| 82 | const port = Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80)); | |
| 83 | 134 | return resolveBrowserNavigationTarget(environmentId, { |
| 84 | kind: "environment-port", | |
| 85 | port, | |
| 86 | protocol: parsed.protocol === "https:" ? "https" : "http", | |
| 87 | path: `${parsed.pathname}${parsed.search}${parsed.hash}`, | |
| 135 | kind: "url", | |
| 136 | url: normalizedUrl, | |
| 88 | 137 | }).resolvedUrl; |
| 89 | 138 | } catch { |
| 90 | 139 | return rawUrl; |
apps/web/src/components/preview/PreviewAutomationHosts.tsx+9-7| ... | ... | @@ -343,6 +343,12 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) |
| 343 | 343 | return await currentStatus(threadRef, tabId); |
| 344 | 344 | case "open": { |
| 345 | 345 | const input = request.input as PreviewAutomationOpenInput; |
| 346 | const resolvedInputUrl = input.url | |
| 347 | ? resolveBrowserNavigationTarget(environmentId, { | |
| 348 | kind: "url", | |
| 349 | url: input.url, | |
| 350 | }).resolvedUrl | |
| 351 | : undefined; | |
| 346 | 352 | let activeTabId = resolvePreviewAutomationOpenTab( |
| 347 | 353 | state, |
| 348 | 354 | request.tabId, |
| ... | ... | @@ -358,7 +364,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) |
| 358 | 364 | environmentId, |
| 359 | 365 | input: { |
| 360 | 366 | threadId: request.threadId, |
| 361 | ...(input.url ? { url: input.url } : {}), | |
| 367 | ...(resolvedInputUrl ? { url: resolvedInputUrl } : {}), | |
| 362 | 368 | }, |
| 363 | 369 | }); |
| 364 | 370 | if (result._tag === "Failure") { |
| ... | ... | @@ -381,12 +387,8 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) |
| 381 | 387 | request.timeoutMs, |
| 382 | 388 | ); |
| 383 | 389 | } |
| 384 | if (reusedExistingTab && input.url && previewBridge) { | |
| 385 | const resolution = resolveBrowserNavigationTarget(environmentId, { | |
| 386 | kind: "url", | |
| 387 | url: input.url, | |
| 388 | }); | |
| 389 | await previewBridge.navigate(activeTabId, resolution.resolvedUrl); | |
| 390 | if (reusedExistingTab && resolvedInputUrl && previewBridge) { | |
| 391 | await previewBridge.navigate(activeTabId, resolvedInputUrl); | |
| 390 | 392 | await waitForNavigationReadiness( |
| 391 | 393 | threadRef, |
| 392 | 394 | request.requestId, |