authorgravatar for 92423096+nateEc@users.noreply.github.comYukun Shan <92423096+nateEc@users.noreply.github.com> 2026-09-04 00:32:40+08:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-03 09:32:40-07:00
log098bf5329727fcd7d973bf842e6b4d50d6e7b924
treeb957d70b69b9cb112191418bbc65c4adaeac7409
parent77e35c561259733d880ab62a43aad0894d301d9b
signature Signed by PGP key B5690EEEBB952194

fix(web): preserve explicit preview navigation URLs (#8902)


2 files changed, 49 insertions(+), 39 deletions(-)

apps/web/src/browser/browserTargetResolver.test.ts+35-11
...@@ -25,7 +25,7 @@ describe("browser target resolver", () => {...@@ -25,7 +25,7 @@ describe("browser target resolver", () => {
25 });25 });
26 });26 });
2727
28 it("maps localhost URL navigation onto a remote Tailscale IPv4 host", async () => {28 it("preserves explicit loopback URL navigation for a remote Tailscale environment", async () => {
29 readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://100.65.180.100:3773" });29 readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://100.65.180.100:3773" });
30 const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver");30 const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver");
31 expect(31 expect(
...@@ -35,13 +35,29 @@ describe("browser target resolver", () => {...@@ -35,13 +35,29 @@ describe("browser target resolver", () => {
35 }),35 }),
36 ).toEqual({36 ).toEqual({
37 requestedUrl: "http://localhost:5173/dashboard?mode=test#results",37 requestedUrl: "http://localhost:5173/dashboard?mode=test#results",
38 resolvedUrl: "http://100.65.180.100:5173/dashboard?mode=test#results",38 resolvedUrl: "http://localhost:5173/dashboard?mode=test#results",
39 resolutionKind: "direct-private-network",39 resolutionKind: "direct",
40 environmentId: "environment-1",40 environmentId: "environment-1",
41 });41 });
42 });42 });
4343
44 it("preserves URL credentials when mapping localhost onto a remote host", async () => {44 it("preserves explicit IPv4 loopback URL navigation for a private network environment", async () => {
45 readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.1.50:3773" });
46 const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver");
47 expect(
48 resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), {
49 kind: "url",
50 url: "http://127.0.0.1:5999/",
51 }),
52 ).toEqual({
53 requestedUrl: "http://127.0.0.1:5999/",
54 resolvedUrl: "http://127.0.0.1:5999/",
55 resolutionKind: "direct",
56 environmentId: "environment-1",
57 });
58 });
59
60 it("preserves URL credentials on explicit loopback navigation", async () => {
45 readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://100.65.180.100:3773" });61 readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://100.65.180.100:3773" });
46 const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver");62 const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver");
47 expect(63 expect(
...@@ -49,10 +65,10 @@ describe("browser target resolver", () => {...@@ -49,10 +65,10 @@ describe("browser target resolver", () => {
49 kind: "url",65 kind: "url",
50 url: "http://user:p%40ss@localhost:5173/dashboard",66 url: "http://user:p%40ss@localhost:5173/dashboard",
51 }).resolvedUrl,67 }).resolvedUrl,
52 ).toBe("http://user:p%40ss@100.65.180.100:5173/dashboard");68 ).toBe("http://user:p%40ss@localhost:5173/dashboard");
53 });69 });
5470
55 it("maps credentialed localhost URLs onto private IPv6 hosts", async () => {71 it("preserves credentialed loopback URLs for private IPv6 environments", async () => {
56 readPreparedConnection.mockReturnValue({72 readPreparedConnection.mockReturnValue({
57 httpBaseUrl: "http://[fd7a:115c:a1e0::53]:3773",73 httpBaseUrl: "http://[fd7a:115c:a1e0::53]:3773",
58 });74 });
...@@ -62,10 +78,10 @@ describe("browser target resolver", () => {...@@ -62,10 +78,10 @@ describe("browser target resolver", () => {
62 kind: "url",78 kind: "url",
63 url: "http://user:p%40ss@localhost:5173/dashboard?mode=test#results",79 url: "http://user:p%40ss@localhost:5173/dashboard?mode=test#results",
64 }).resolvedUrl,80 }).resolvedUrl,
65 ).toBe("http://user:p%40ss@[fd7a:115c:a1e0::53]:5173/dashboard?mode=test#results");81 ).toBe("http://user:p%40ss@localhost:5173/dashboard?mode=test#results");
66 });82 });
6783
68 it("maps schemeless localhost navigation onto a remote environment host", async () => {84 it("preserves schemeless localhost navigation for a remote environment", async () => {
69 readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.1.25:3773" });85 readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.1.25:3773" });
70 const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver");86 const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver");
71 expect(87 expect(
...@@ -73,7 +89,7 @@ describe("browser target resolver", () => {...@@ -73,7 +89,7 @@ describe("browser target resolver", () => {
73 kind: "url",89 kind: "url",
74 url: "localhost:3000/app",90 url: "localhost:3000/app",
75 }).resolvedUrl,91 }).resolvedUrl,
76 ).toBe("http://192.168.1.25:3000/app");92 ).toBe("localhost:3000/app");
77 });93 });
7894
79 it("keeps localhost navigation local for a local environment", async () => {95 it("keeps localhost navigation local for a local environment", async () => {
...@@ -117,12 +133,12 @@ describe("browser target resolver", () => {...@@ -117,12 +133,12 @@ describe("browser target resolver", () => {
117 port: 5173,133 port: 5173,
118 }),134 }),
119 ).toThrow(/authenticated preview gateway/);135 ).toThrow(/authenticated preview gateway/);
120 expect(() =>136 expect(
121 resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), {137 resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), {
122 kind: "url",138 kind: "url",
123 url: "http://localhost:5173",139 url: "http://localhost:5173",
124 }),140 }),
125 ).toThrow(/authenticated preview gateway/);141 ).toMatchObject({ resolvedUrl: "http://localhost:5173", resolutionKind: "direct" });
126 });142 });
127143
128 it("normalizes schemeless localhost server-picker values", async () => {144 it("normalizes schemeless localhost server-picker values", async () => {
...@@ -136,6 +152,14 @@ describe("browser target resolver", () => {...@@ -136,6 +152,14 @@ describe("browser target resolver", () => {
136 ).toBe("http://localhost:3000/app");152 ).toBe("http://localhost:3000/app");
137 });153 });
138154
155 it("maps discovered loopback servers onto a remote environment host", async () => {
156 readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.1.25:3773" });
157 const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver");
158 expect(
159 resolveDiscoveredServerUrl(EnvironmentId.make("environment-1"), "localhost:3000/app"),
160 ).toBe("http://192.168.1.25:3000/app");
161 });
162
139 it("preserves localhost server-picker values when the prepared base is 127.0.0.1", async () => {163 it("preserves localhost server-picker values when the prepared base is 127.0.0.1", async () => {
140 readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://127.0.0.1:3773" });164 readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://127.0.0.1:3773" });
141 const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver");165 const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver");
apps/web/src/browser/browserTargetResolver.ts+14-28
...@@ -207,30 +207,6 @@ export function resolveBrowserNavigationTarget(...@@ -207,30 +207,6 @@ export function resolveBrowserNavigationTarget(
207 target: BrowserNavigationTarget,207 target: BrowserNavigationTarget,
208): PreviewUrlResolution {208): PreviewUrlResolution {
209 if (target.kind === "url") {209 if (target.kind === "url") {
210 let parsed: URL | null = null;
211 try {
212 parsed = new URL(normalizePreviewUrl(target.url));
213 } catch {
214 // Preserve the existing direct-navigation behavior so the preview host
215 // reports malformed URL errors through its normal navigation path.
216 }
217 if (parsed && isLoopbackHost(parsed.hostname)) {
218 const environmentUrl = readEnvironmentUrl(environmentId);
219 if (parsed.hostname === "0.0.0.0" || !isLocalLoopbackHost(environmentUrl.hostname)) {
220 return resolveEnvironmentPortTarget(
221 environmentId,
222 {
223 kind: "environment-port",
224 port: Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80)),
225 protocol: parsed.protocol === "https:" ? "https" : "http",
226 path: `${parsed.pathname}${parsed.search}${parsed.hash}`,
227 },
228 environmentUrl,
229 target.url,
230 parsed,
231 );
232 }
233 }
234 return {210 return {
235 requestedUrl: target.url,211 requestedUrl: target.url,
236 resolvedUrl: target.url,212 resolvedUrl: target.url,
...@@ -244,10 +220,20 @@ export function resolveBrowserNavigationTarget(...@@ -244,10 +220,20 @@ export function resolveBrowserNavigationTarget(
244export function resolveDiscoveredServerUrl(environmentId: EnvironmentId, rawUrl: string): string {220export function resolveDiscoveredServerUrl(environmentId: EnvironmentId, rawUrl: string): string {
245 try {221 try {
246 const normalizedUrl = normalizePreviewUrl(rawUrl);222 const normalizedUrl = normalizePreviewUrl(rawUrl);
247 return resolveBrowserNavigationTarget(environmentId, {223 const parsed = new URL(normalizedUrl);
248 kind: "url",224 if (!isLoopbackHost(parsed.hostname)) return normalizedUrl;
249 url: normalizedUrl,225 return resolveEnvironmentPortTarget(
250 }).resolvedUrl;226 environmentId,
227 {
228 kind: "environment-port",
229 port: Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80)),
230 protocol: parsed.protocol === "https:" ? "https" : "http",
231 path: `${parsed.pathname}${parsed.search}${parsed.hash}`,
232 },
233 readEnvironmentUrl(environmentId),
234 rawUrl,
235 parsed,
236 ).resolvedUrl;
251 } catch {237 } catch {
252 return rawUrl;238 return rawUrl;
253 }239 }