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", () => {...@@ -25,6 +25,89 @@ describe("browser target resolver", () => {
25 });25 });
26 });26 });
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
28 it("refuses public relay hosts until the authenticated gateway exists", async () => {111 it("refuses public relay hosts until the authenticated gateway exists", async () => {
29 readPreparedConnection.mockReturnValue({ httpBaseUrl: "https://relay.example.com" });112 readPreparedConnection.mockReturnValue({ httpBaseUrl: "https://relay.example.com" });
30 const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver");113 const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver");
...@@ -34,6 +117,12 @@ describe("browser target resolver", () => {...@@ -34,6 +117,12 @@ describe("browser target resolver", () => {
34 port: 5173,117 port: 5173,
35 }),118 }),
36 ).toThrow(/authenticated preview gateway/);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 });
38127
39 it("normalizes schemeless localhost server-picker values", async () => {128 it("normalizes schemeless localhost server-picker values", async () => {
...@@ -63,7 +152,9 @@ describe("browser target resolver", () => {...@@ -63,7 +152,9 @@ describe("browser target resolver", () => {
63 });152 });
64153
65 it("supports private IPv6 environment hosts", async () => {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 const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver");158 const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver");
68 expect(159 expect(
69 resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), {160 resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), {
...@@ -71,7 +162,18 @@ describe("browser target resolver", () => {...@@ -71,7 +162,18 @@ describe("browser target resolver", () => {
71 port: 5173,162 port: 5173,
72 path: "/app?mode=test",163 path: "/app?mode=test",
73 }).resolvedUrl,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 });
76178
77 it("leaves malformed input for the normal navigation error path", async () => {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,43 +7,60 @@ import { isLoopbackHost, normalizePreviewUrl } from "@t3tools/shared/preview";
77
8import { readPreparedConnection } from "~/state/session";8import { 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
10const isPrivateNetworkHost = (host: string): boolean => {26const isPrivateNetworkHost = (host: string): boolean => {
11 const normalized = host.toLowerCase().replace(/^\[|\]$/g, "");27 const normalized = normalizeHostname(host);
12 if (normalized === "localhost" || normalized === "::1" || normalized.endsWith(".local")) {28 if (isLocalLoopbackHost(normalized) || normalized.endsWith(".local")) {
13 return true;29 return true;
14 }30 }
15 if (normalized.endsWith(".ts.net")) return true;31 if (normalized.endsWith(".ts.net")) return true;
16 const parts = normalized.split(".").map(Number);32 const parts = parseIpv4Address(normalized);
17 if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part))) return false;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 return (45 return (
19 parts[0] === 10 ||46 Number.isInteger(firstIpv6Hextet) &&
20 (parts[0] === 172 && parts[1]! >= 16 && parts[1]! <= 31) ||47 ((firstIpv6Hextet & 0xfe00) === 0xfc00 || (firstIpv6Hextet & 0xffc0) === 0xfe80)
21 (parts[0] === 192 && parts[1] === 168) ||
22 parts[0] === 127 ||
23 (parts[0] === 169 && parts[1] === 254)
24 );48 );
25};49};
2650
27const isLocalLoopbackHost = (host: string): boolean => {51const readEnvironmentUrl = (environmentId: EnvironmentId): URL => {
28 const normalized = host.toLowerCase().replace(/^\[|\]$/g, "");52 const connection = readPreparedConnection(environmentId);
29 return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1";53 if (!connection) throw new Error(`Environment ${environmentId} is not connected.`);
54 return new URL(connection.httpBaseUrl);
30};55};
3156
32export function resolveBrowserNavigationTarget(57const resolveEnvironmentPortTarget = (
33 environmentId: EnvironmentId,58 environmentId: EnvironmentId,
34 target: BrowserNavigationTarget,59 target: Extract<BrowserNavigationTarget, { readonly kind: "environment-port" }>,
35): PreviewUrlResolution {60 environmentUrl: URL,
36 if (target.kind === "url") {61 requestedUrl?: string,
37 return {62 sourceUrl?: URL,
38 requestedUrl: target.url,63): PreviewUrlResolution => {
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);
47 if (!isPrivateNetworkHost(environmentUrl.hostname)) {64 if (!isPrivateNetworkHost(environmentUrl.hostname)) {
48 throw new Error(65 throw new Error(
49 "This environment port needs the planned authenticated preview gateway; its server address is not directly private-network reachable.",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,40 +68,72 @@ export function resolveBrowserNavigationTarget(
51 }68 }
52 const protocol = target.protocol ?? "http";69 const protocol = target.protocol ?? "http";
53 const path = target.path?.startsWith("/") ? target.path : `/${target.path ?? ""}`;70 const path = target.path?.startsWith("/") ? target.path : `/${target.path ?? ""}`;
54 const requestedUrl = `${protocol}://localhost:${target.port}${path}`;
55 const normalizedEnvironmentHost = environmentUrl.hostname.replace(/^\[|\]$/g, "");71 const normalizedEnvironmentHost = environmentUrl.hostname.replace(/^\[|\]$/g, "");
56 const resolvedHost = normalizedEnvironmentHost.includes(":")72 const resolvedHost = normalizedEnvironmentHost.includes(":")
57 ? `[${normalizedEnvironmentHost}]`73 ? `[${normalizedEnvironmentHost}]`
58 : normalizedEnvironmentHost;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 return {82 return {
61 requestedUrl,83 requestedUrl: requestedUrl ?? `${protocol}://localhost:${target.port}${path}`,
62 resolvedUrl: resolved.toString(),84 resolvedUrl: resolved.toString(),
63 resolutionKind:85 resolutionKind: isLocalLoopbackHost(normalizedEnvironmentHost)
64 normalizedEnvironmentHost === "localhost" || normalizedEnvironmentHost === "127.0.0.1"86 ? "direct"
65 ? "direct"87 : "direct-private-network",
66 : "direct-private-network",
67 environmentId,88 environmentId,
68 };89 };
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));
69}129}
70130
71export function resolveDiscoveredServerUrl(environmentId: EnvironmentId, rawUrl: string): string {131export function resolveDiscoveredServerUrl(environmentId: EnvironmentId, rawUrl: string): string {
72 try {132 try {
73 const normalizedUrl = normalizePreviewUrl(rawUrl);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 return resolveBrowserNavigationTarget(environmentId, {134 return resolveBrowserNavigationTarget(environmentId, {
84 kind: "environment-port",135 kind: "url",
85 port,136 url: normalizedUrl,
86 protocol: parsed.protocol === "https:" ? "https" : "http",
87 path: `${parsed.pathname}${parsed.search}${parsed.hash}`,
88 }).resolvedUrl;137 }).resolvedUrl;
89 } catch {138 } catch {
90 return rawUrl;139 return rawUrl;
apps/web/src/components/preview/PreviewAutomationHosts.tsx+9-7
...@@ -343,6 +343,12 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })...@@ -343,6 +343,12 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
343 return await currentStatus(threadRef, tabId);343 return await currentStatus(threadRef, tabId);
344 case "open": {344 case "open": {
345 const input = request.input as PreviewAutomationOpenInput;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 let activeTabId = resolvePreviewAutomationOpenTab(352 let activeTabId = resolvePreviewAutomationOpenTab(
347 state,353 state,
348 request.tabId,354 request.tabId,
...@@ -358,7 +364,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })...@@ -358,7 +364,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
358 environmentId,364 environmentId,
359 input: {365 input: {
360 threadId: request.threadId,366 threadId: request.threadId,
361 ...(input.url ? { url: input.url } : {}),367 ...(resolvedInputUrl ? { url: resolvedInputUrl } : {}),
362 },368 },
363 });369 });
364 if (result._tag === "Failure") {370 if (result._tag === "Failure") {
...@@ -381,12 +387,8 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })...@@ -381,12 +387,8 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
381 request.timeoutMs,387 request.timeoutMs,
382 );388 );
383 }389 }
384 if (reusedExistingTab && input.url && previewBridge) {390 if (reusedExistingTab && resolvedInputUrl && previewBridge) {
385 const resolution = resolveBrowserNavigationTarget(environmentId, {391 await previewBridge.navigate(activeTabId, resolvedInputUrl);
386 kind: "url",
387 url: input.url,
388 });
389 await previewBridge.navigate(activeTabId, resolution.resolvedUrl);
390 await waitForNavigationReadiness(392 await waitForNavigationReadiness(
391 threadRef,393 threadRef,
392 request.requestId,394 request.requestId,