authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-07-16 11:07:38+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-16 11:07:38+02:00
logbde0a4c0dd2d420a5fd71f39448d5db1bab078da
treefba85221e6da92e8a4bab189c915ffb5dc6eba16
parent54f167e2e0b857a60946590bfa983fb93b4f138a
signature Signed by PGP key B5690EEEBB952194

Resolve localhost preview URLs for remote environments (#4011)

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", () => {
2525 });
2626 });
2727
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
28111 it("refuses public relay hosts until the authenticated gateway exists", async () => {
29112 readPreparedConnection.mockReturnValue({ httpBaseUrl: "https://relay.example.com" });
30113 const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver");
......@@ -34,6 +117,12 @@ describe("browser target resolver", () => {
34117 port: 5173,
35118 }),
36119 ).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/);
37126 });
38127
39128 it("normalizes schemeless localhost server-picker values", async () => {
......@@ -63,7 +152,9 @@ describe("browser target resolver", () => {
63152 });
64153
65154 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 });
67158 const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver");
68159 expect(
69160 resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), {
......@@ -71,7 +162,18 @@ describe("browser target resolver", () => {
71162 port: 5173,
72163 path: "/app?mode=test",
73164 }).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/");
75177 });
76178
77179 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";
77
88import { readPreparedConnection } from "~/state/session";
99
10const normalizeHostname = (host: string): string => host.toLowerCase().replace(/^\[|\]$/g, "");
11
12const 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
20const 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
1026const 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")) {
1329 return true;
1430 }
1531 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);
1845 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)
2448 );
2549};
2650
27const isLocalLoopbackHost = (host: string): boolean => {
28 const normalized = host.toLowerCase().replace(/^\[|\]$/g, "");
29 return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1";
51const 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);
3055};
3156
32export function resolveBrowserNavigationTarget(
57const resolveEnvironmentPortTarget = (
3358 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 => {
4764 if (!isPrivateNetworkHost(environmentUrl.hostname)) {
4865 throw new Error(
4966 "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(
5168 }
5269 const protocol = target.protocol ?? "http";
5370 const path = target.path?.startsWith("/") ? target.path : `/${target.path ?? ""}`;
54 const requestedUrl = `${protocol}://localhost:${target.port}${path}`;
5571 const normalizedEnvironmentHost = environmentUrl.hostname.replace(/^\[|\]$/g, "");
5672 const resolvedHost = normalizedEnvironmentHost.includes(":")
5773 ? `[${normalizedEnvironmentHost}]`
5874 : 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 }
6082 return {
61 requestedUrl,
83 requestedUrl: requestedUrl ?? `${protocol}://localhost:${target.port}${path}`,
6284 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",
6788 environmentId,
6889 };
90};
91
92export 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));
69129}
70130
71131export function resolveDiscoveredServerUrl(environmentId: EnvironmentId, rawUrl: string): string {
72132 try {
73133 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));
83134 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,
88137 }).resolvedUrl;
89138 } catch {
90139 return rawUrl;
apps/web/src/components/preview/PreviewAutomationHosts.tsx+9-7
......@@ -343,6 +343,12 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
343343 return await currentStatus(threadRef, tabId);
344344 case "open": {
345345 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;
346352 let activeTabId = resolvePreviewAutomationOpenTab(
347353 state,
348354 request.tabId,
......@@ -358,7 +364,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
358364 environmentId,
359365 input: {
360366 threadId: request.threadId,
361 ...(input.url ? { url: input.url } : {}),
367 ...(resolvedInputUrl ? { url: resolvedInputUrl } : {}),
362368 },
363369 });
364370 if (result._tag === "Failure") {
......@@ -381,12 +387,8 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
381387 request.timeoutMs,
382388 );
383389 }
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);
390392 await waitForNavigationReadiness(
391393 threadRef,
392394 request.requestId,