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", () => {
2525 });
2626 });
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 () => {
2929 readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://100.65.180.100:3773" });
3030 const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver");
3131 expect(
......@@ -35,13 +35,29 @@ describe("browser target resolver", () => {
3535 }),
3636 ).toEqual({
3737 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",
38 resolvedUrl: "http://localhost:5173/dashboard?mode=test#results",
39 resolutionKind: "direct",
4040 environmentId: "environment-1",
4141 });
4242 });
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 () => {
4561 readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://100.65.180.100:3773" });
4662 const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver");
4763 expect(
......@@ -49,10 +65,10 @@ describe("browser target resolver", () => {
4965 kind: "url",
5066 url: "http://user:p%40ss@localhost:5173/dashboard",
5167 }).resolvedUrl,
52 ).toBe("http://user:p%40ss@100.65.180.100:5173/dashboard");
68 ).toBe("http://user:p%40ss@localhost:5173/dashboard");
5369 });
5470
55 it("maps credentialed localhost URLs onto private IPv6 hosts", async () => {
71 it("preserves credentialed loopback URLs for private IPv6 environments", async () => {
5672 readPreparedConnection.mockReturnValue({
5773 httpBaseUrl: "http://[fd7a:115c:a1e0::53]:3773",
5874 });
......@@ -62,10 +78,10 @@ describe("browser target resolver", () => {
6278 kind: "url",
6379 url: "http://user:p%40ss@localhost:5173/dashboard?mode=test#results",
6480 }).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");
6682 });
6783
68 it("maps schemeless localhost navigation onto a remote environment host", async () => {
84 it("preserves schemeless localhost navigation for a remote environment", async () => {
6985 readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.1.25:3773" });
7086 const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver");
7187 expect(
......@@ -73,7 +89,7 @@ describe("browser target resolver", () => {
7389 kind: "url",
7490 url: "localhost:3000/app",
7591 }).resolvedUrl,
76 ).toBe("http://192.168.1.25:3000/app");
92 ).toBe("localhost:3000/app");
7793 });
7894
7995 it("keeps localhost navigation local for a local environment", async () => {
......@@ -117,12 +133,12 @@ describe("browser target resolver", () => {
117133 port: 5173,
118134 }),
119135 ).toThrow(/authenticated preview gateway/);
120 expect(() =>
136 expect(
121137 resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), {
122138 kind: "url",
123139 url: "http://localhost:5173",
124140 }),
125 ).toThrow(/authenticated preview gateway/);
141 ).toMatchObject({ resolvedUrl: "http://localhost:5173", resolutionKind: "direct" });
126142 });
127143
128144 it("normalizes schemeless localhost server-picker values", async () => {
......@@ -136,6 +152,14 @@ describe("browser target resolver", () => {
136152 ).toBe("http://localhost:3000/app");
137153 });
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
139163 it("preserves localhost server-picker values when the prepared base is 127.0.0.1", async () => {
140164 readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://127.0.0.1:3773" });
141165 const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver");
apps/web/src/browser/browserTargetResolver.ts+14-28
......@@ -207,30 +207,6 @@ export function resolveBrowserNavigationTarget(
207207 target: BrowserNavigationTarget,
208208): PreviewUrlResolution {
209209 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 }
234210 return {
235211 requestedUrl: target.url,
236212 resolvedUrl: target.url,
......@@ -244,10 +220,20 @@ export function resolveBrowserNavigationTarget(
244220export function resolveDiscoveredServerUrl(environmentId: EnvironmentId, rawUrl: string): string {
245221 try {
246222 const normalizedUrl = normalizePreviewUrl(rawUrl);
247 return resolveBrowserNavigationTarget(environmentId, {
248 kind: "url",
249 url: normalizedUrl,
250 }).resolvedUrl;
223 const parsed = new URL(normalizedUrl);
224 if (!isLoopbackHost(parsed.hostname)) return normalizedUrl;
225 return resolveEnvironmentPortTarget(
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;
251237 } catch {
252238 return rawUrl;
253239 }