Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 115 additions & 1 deletion apps/web/src/browser/browserTargetResolver.test.ts
Original file line number Diff line number Diff line change
@@ -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");
Expand Down Expand Up @@ -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");
Expand Down
52 changes: 41 additions & 11 deletions apps/web/src/browser/browserTargetResolver.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { ConnectionTarget } from "@t3tools/client-runtime/connection";
import type {
BrowserNavigationTarget,
EnvironmentId,
Expand All @@ -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 {
Expand All @@ -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<BrowserNavigationTarget, { readonly kind: "environment-port" }>,
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.",
Expand All @@ -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}]`
Expand All @@ -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,
};
};
Expand All @@ -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 {
Expand All @@ -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;
Expand Down
58 changes: 55 additions & 3 deletions apps/web/src/components/preview/PreviewView.test.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { BearerConnectionTarget } from "@t3tools/client-runtime/connection";
import {
BUILT_IN_BROWSER_PROFILES,
DEFAULT_BROWSER_PROFILE_ID,
Expand All @@ -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<void> => 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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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(
<PreviewView
Expand Down Expand Up @@ -501,6 +512,47 @@ describe("PreviewView navigation", () => {
);
});

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(
<PreviewView
threadRef={{
environmentId: EnvironmentId.make("environment-1"),
threadId: ThreadId.make("thread-1"),
}}
tabId="tab-1"
visible
/>,
);

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: {
Expand Down
Loading