diff --git a/apps/web/src/browser/browserTargetResolver.test.ts b/apps/web/src/browser/browserTargetResolver.test.ts index c2b3432402ed..e9d9fc2ae30c 100644 --- a/apps/web/src/browser/browserTargetResolver.test.ts +++ b/apps/web/src/browser/browserTargetResolver.test.ts @@ -1,13 +1,34 @@ +import { + BearerConnectionTarget, + PrimaryConnectionTarget, +} from "@t3tools/client-runtime/connection"; import { EnvironmentId } from "@t3tools/contracts"; -import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { desktopLocalConnectionId } from "~/connection/desktopLocal"; const readPreparedConnection = vi.fn(); vi.mock("~/state/session", () => ({ readPreparedConnection })); +const environmentId = EnvironmentId.make("environment-1"); + +const wslDesktopConnection = { + httpBaseUrl: "http://172.24.66.27:3773", + target: new BearerConnectionTarget({ + connectionId: desktopLocalConnectionId("wsl:Ubuntu"), + environmentId, + label: "WSL (Ubuntu)", + }), +}; + describe("browser target resolver", () => { beforeEach(() => readPreparedConnection.mockReset()); + afterEach(() => { + vi.unstubAllGlobals(); + }); + it("maps environment ports onto a private network host", async () => { readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.1.25:3773" }); const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver"); @@ -160,6 +181,99 @@ describe("browser target resolver", () => { ).toBe("http://192.168.1.25:3000/app"); }); + it("keeps discovered loopback servers on localhost for a desktop-local WSL environment", async () => { + readPreparedConnection.mockReturnValue(wslDesktopConnection); + const { resolveDiscoveredServerUrl, resolveBrowserNavigationTarget } = + await import("./browserTargetResolver"); + expect(resolveDiscoveredServerUrl(environmentId, "localhost:3001")).toBe( + "http://localhost:3001/", + ); + expect(resolveDiscoveredServerUrl(environmentId, "http://127.0.0.1:3001/app?x=1#top")).toBe( + "http://localhost:3001/app?x=1#top", + ); + expect(resolveDiscoveredServerUrl(environmentId, "0.0.0.0:3000/app")).toBe( + "http://localhost:3000/app", + ); + expect( + resolveBrowserNavigationTarget(environmentId, { + kind: "environment-port", + port: 3001, + path: "/", + }), + ).toMatchObject({ + resolvedUrl: "http://localhost:3001/", + resolutionKind: "direct", + }); + }); + + it("maps loopback discoveries onto a private primary host opened outside the desktop app", async () => { + readPreparedConnection.mockReturnValue({ + httpBaseUrl: "http://172.24.66.27:3773", + target: new PrimaryConnectionTarget({ + environmentId, + label: "LAN", + httpBaseUrl: "http://172.24.66.27:3773", + wsBaseUrl: "ws://172.24.66.27:3773", + }), + }); + const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver"); + expect(resolveDiscoveredServerUrl(environmentId, "localhost:3001")).toBe( + "http://172.24.66.27:3001/", + ); + }); + + it("keeps discovered loopback servers on localhost for a wsl-only desktop primary", async () => { + vi.stubGlobal("window", { desktopBridge: {} }); + readPreparedConnection.mockReturnValue({ + httpBaseUrl: "http://172.24.66.27:3773", + target: new PrimaryConnectionTarget({ + environmentId, + label: "WSL", + httpBaseUrl: "http://172.24.66.27:3773", + wsBaseUrl: "ws://172.24.66.27:3773", + }), + }); + const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver"); + expect(resolveDiscoveredServerUrl(environmentId, "http://localhost:3001/")).toBe( + "http://localhost:3001/", + ); + }); + + it("still maps loopback discoveries onto a saved remote host from the desktop renderer", async () => { + vi.stubGlobal("window", { desktopBridge: {} }); + readPreparedConnection.mockReturnValue({ + httpBaseUrl: "http://192.168.1.25:3773", + target: new BearerConnectionTarget({ + connectionId: "saved-lan", + environmentId, + label: "LAN", + }), + }); + const { resolveDiscoveredServerUrl, resolveBrowserNavigationTarget } = + await import("./browserTargetResolver"); + expect(resolveDiscoveredServerUrl(environmentId, "localhost:3000/app")).toBe( + "http://192.168.1.25:3000/app", + ); + expect( + resolveBrowserNavigationTarget(environmentId, { + kind: "environment-port", + port: 3000, + path: "/app", + }).resolvedUrl, + ).toBe("http://192.168.1.25:3000/app"); + }); + + it("leaves non-loopback server URLs on their own host for a desktop-local WSL environment", async () => { + readPreparedConnection.mockReturnValue(wslDesktopConnection); + const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver"); + expect(resolveDiscoveredServerUrl(environmentId, "http://192.168.1.50:3001/docs")).toBe( + "http://192.168.1.50:3001/docs", + ); + expect(resolveDiscoveredServerUrl(environmentId, "http://172.24.66.27:3001/")).toBe( + "http://172.24.66.27:3001/", + ); + }); + it("preserves localhost server-picker values when the prepared base is 127.0.0.1", async () => { readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://127.0.0.1:3773" }); const { resolveDiscoveredServerUrl } = await import("./browserTargetResolver"); diff --git a/apps/web/src/browser/browserTargetResolver.ts b/apps/web/src/browser/browserTargetResolver.ts index 4acb1e2b487f..09cb215fab78 100644 --- a/apps/web/src/browser/browserTargetResolver.ts +++ b/apps/web/src/browser/browserTargetResolver.ts @@ -1,3 +1,4 @@ +import type { ConnectionTarget } from "@t3tools/client-runtime/connection"; import type { BrowserNavigationTarget, EnvironmentId, @@ -6,6 +7,7 @@ import type { import { isLoopbackHost, normalizePreviewUrl } from "@t3tools/shared/preview"; import { isLocalLoopbackHost, isPrivateNetworkHost } from "@t3tools/shared/hostClassification"; +import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal"; import { readPreparedConnection } from "~/state/session"; export { @@ -15,19 +17,42 @@ export { isPublicFaviconHost, } from "@t3tools/shared/hostClassification"; -const readEnvironmentUrl = (environmentId: EnvironmentId): URL => { +interface PreviewEnvironmentConnection { + readonly httpBaseUrl: string; + readonly target?: ConnectionTarget | undefined; +} + +const readEnvironmentConnection = (environmentId: EnvironmentId): PreviewEnvironmentConnection => { const connection = readPreparedConnection(environmentId); if (!connection) throw new Error(`Environment ${environmentId} is not connected.`); - return new URL(connection.httpBaseUrl); + return connection; +}; + +const isDesktopRenderer = (): boolean => + typeof window !== "undefined" && window.desktopBridge !== undefined; + +/** + * Desktop-local backends share the renderer's loopback namespace. WSL2 NAT + * advertises the distro eth0 address for the T3 server, which is bound on + * 0.0.0.0 because wslhost forwarding is flaky for that process. A dev server + * bound only to 127.0.0.1 is reached from the Windows webview at localhost. + * Saved remote hosts keep their own address. + */ +const prefersClientLoopback = (connection: PreviewEnvironmentConnection): boolean => { + const target = connection.target; + if (!target) return false; + if (isDesktopLocalConnectionTarget(target)) return true; + return target._tag === "PrimaryConnectionTarget" && isDesktopRenderer(); }; const resolveEnvironmentPortTarget = ( environmentId: EnvironmentId, target: Extract, - environmentUrl: URL, + connection: PreviewEnvironmentConnection, requestedUrl?: string, sourceUrl?: URL, ): PreviewUrlResolution => { + const environmentUrl = new URL(connection.httpBaseUrl); if (!isPrivateNetworkHost(environmentUrl.hostname)) { throw new Error( "This environment port needs the planned authenticated preview gateway; its server address is not directly private-network reachable.", @@ -36,9 +61,12 @@ const resolveEnvironmentPortTarget = ( const protocol = target.protocol ?? "http"; const path = target.path?.startsWith("/") ? target.path : `/${target.path ?? ""}`; const normalizedEnvironmentHost = environmentUrl.hostname.replace(/^\[|\]$/g, ""); - // Local loopback environments should advertise `localhost` so Chromium - // dual-stack lookup can reach a Vite server bound only to ::1 or 127.0.0.1. - const resolvedHost = isLocalLoopbackHost(normalizedEnvironmentHost) + // Loopback environments, and desktop-local ones reached through a + // non-loopback advertisement, use `localhost` so Chromium's dual-stack + // lookup can reach a server bound only to ::1 or 127.0.0.1. + const preserveLoopback = + prefersClientLoopback(connection) || isLocalLoopbackHost(normalizedEnvironmentHost); + const resolvedHost = preserveLoopback ? "localhost" : normalizedEnvironmentHost.includes(":") ? `[${normalizedEnvironmentHost}]` @@ -53,9 +81,7 @@ const resolveEnvironmentPortTarget = ( return { requestedUrl: requestedUrl ?? `${protocol}://localhost:${target.port}${path}`, resolvedUrl: resolved.toString(), - resolutionKind: isLocalLoopbackHost(normalizedEnvironmentHost) - ? "direct" - : "direct-private-network", + resolutionKind: preserveLoopback ? "direct" : "direct-private-network", environmentId, }; }; @@ -72,7 +98,11 @@ export function resolveBrowserNavigationTarget( environmentId, }; } - return resolveEnvironmentPortTarget(environmentId, target, readEnvironmentUrl(environmentId)); + return resolveEnvironmentPortTarget( + environmentId, + target, + readEnvironmentConnection(environmentId), + ); } export function resolveDiscoveredServerUrl(environmentId: EnvironmentId, rawUrl: string): string { @@ -88,7 +118,7 @@ export function resolveDiscoveredServerUrl(environmentId: EnvironmentId, rawUrl: protocol: parsed.protocol === "https:" ? "https" : "http", path: `${parsed.pathname}${parsed.search}${parsed.hash}`, }, - readEnvironmentUrl(environmentId), + readEnvironmentConnection(environmentId), rawUrl, parsed, ).resolvedUrl; diff --git a/apps/web/src/components/preview/PreviewView.test.tsx b/apps/web/src/components/preview/PreviewView.test.tsx index a2aa8c9a5bdd..3159bbb20b35 100644 --- a/apps/web/src/components/preview/PreviewView.test.tsx +++ b/apps/web/src/components/preview/PreviewView.test.tsx @@ -1,3 +1,4 @@ +import { BearerConnectionTarget } from "@t3tools/client-runtime/connection"; import { BUILT_IN_BROWSER_PROFILES, DEFAULT_BROWSER_PROFILE_ID, @@ -11,10 +12,17 @@ import { act, createElement, Profiler } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { desktopLocalConnectionId } from "~/connection/desktopLocal"; + const mocks = vi.hoisted(() => ({ navigate: vi.fn(async (_tabId: string, _url: string): Promise => undefined), rememberPreviewUrl: vi.fn(), - readPreparedConnection: vi.fn(() => ({ httpBaseUrl: "http://172.25.85.75:3773" })), + readPreparedConnection: vi.fn( + (): { + httpBaseUrl: string; + target?: BearerConnectionTarget; + } => ({ httpBaseUrl: "http://172.25.85.75:3773" }), + ), submittedUrl: null as ((url: string) => void) | null, emptyStateUrl: null as ((url: string) => void) | null, togglePictureInPicture: null as (() => void) | null, @@ -330,7 +338,10 @@ describe("PreviewView navigation", () => { beforeEach(() => { mocks.navigate.mockClear(); mocks.rememberPreviewUrl.mockClear(); - mocks.readPreparedConnection.mockClear(); + mocks.readPreparedConnection.mockReset(); + mocks.readPreparedConnection.mockImplementation(() => ({ + httpBaseUrl: "http://172.25.85.75:3773", + })); mocks.submittedUrl = null; mocks.emptyStateUrl = null; mocks.togglePictureInPicture = null; @@ -464,7 +475,7 @@ describe("PreviewView navigation", () => { }); }); - it("maps an empty-state localhost server onto the WSL host", async () => { + it("maps an empty-state localhost server onto a remote private-network host", async () => { mocks.showEmptyState = true; renderToStaticMarkup( { ); }); + it("opens an empty-state localhost server on localhost for a desktop-local WSL environment", async () => { + mocks.readPreparedConnection.mockReturnValue({ + httpBaseUrl: "http://172.24.66.27:3773", + target: new BearerConnectionTarget({ + connectionId: desktopLocalConnectionId("wsl:Ubuntu"), + environmentId: EnvironmentId.make("environment-1"), + label: "WSL (Ubuntu)", + }), + }); + mocks.showEmptyState = true; + renderToStaticMarkup( + , + ); + + mocks.emptyStateUrl?.("http://localhost:3001/"); + + await vi.waitFor(() => + expect(mocks.navigate).toHaveBeenCalledWith(TEST_RUNTIME_TAB_ID, "http://localhost:3001/"), + ); + expect(mocks.rememberPreviewUrl).toHaveBeenCalledWith( + { + environmentId: "environment-1", + threadId: "thread-1", + }, + "http://localhost:3001/", + ); + await vi.waitFor(() => + expect(mocks.recordVisitForThread).toHaveBeenCalledWith( + expect.objectContaining({ threadId: expect.anything() }), + "http://localhost:3001/", + ), + ); + }); + it("opens and closes a thread-scoped floating preview for the active tab", async () => { const props = { threadRef: {