Skip to content
Merged
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
106 changes: 104 additions & 2 deletions apps/web/src/browser/browserTargetResolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,89 @@ describe("browser target resolver", () => {
});
});

it("maps localhost URL navigation onto a remote Tailscale IPv4 host", async () => {
readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://100.65.180.100:3773" });
const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver");
expect(
resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), {
kind: "url",
url: "http://localhost:5173/dashboard?mode=test#results",
}),
).toEqual({
requestedUrl: "http://localhost:5173/dashboard?mode=test#results",
resolvedUrl: "http://100.65.180.100:5173/dashboard?mode=test#results",
resolutionKind: "direct-private-network",
environmentId: "environment-1",
});
});

it("preserves URL credentials when mapping localhost onto a remote host", async () => {
readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://100.65.180.100:3773" });
const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver");
expect(
resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), {
kind: "url",
url: "http://user:p%40ss@localhost:5173/dashboard",
}).resolvedUrl,
).toBe("http://user:p%40ss@100.65.180.100:5173/dashboard");
});

it("maps credentialed localhost URLs onto private IPv6 hosts", async () => {
readPreparedConnection.mockReturnValue({
httpBaseUrl: "http://[fd7a:115c:a1e0::53]:3773",
});
const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver");
expect(
resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), {
kind: "url",
url: "http://user:p%40ss@localhost:5173/dashboard?mode=test#results",
}).resolvedUrl,
).toBe("http://user:p%40ss@[fd7a:115c:a1e0::53]:5173/dashboard?mode=test#results");
});

it("maps schemeless localhost navigation onto a remote environment host", async () => {
readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://192.168.1.25:3773" });
const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver");
expect(
resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), {
kind: "url",
url: "localhost:3000/app",
}).resolvedUrl,
).toBe("http://192.168.1.25:3000/app");
});

it("keeps localhost navigation local for a local environment", async () => {
readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://127.0.0.1:3773" });
const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver");
expect(
resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), {
kind: "url",
url: "localhost:3000/app",
}),
).toEqual({
requestedUrl: "localhost:3000/app",
resolvedUrl: "localhost:3000/app",
resolutionKind: "direct",
environmentId: "environment-1",
});
});

it("keeps localhost navigation local for the full IPv4 loopback range", async () => {
readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://127.0.0.2:3773" });
const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver");
expect(
resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), {
kind: "url",
url: "http://localhost:3000/app",
}),
).toEqual({
requestedUrl: "http://localhost:3000/app",
resolvedUrl: "http://localhost:3000/app",
resolutionKind: "direct",
environmentId: "environment-1",
});
});

it("refuses public relay hosts until the authenticated gateway exists", async () => {
readPreparedConnection.mockReturnValue({ httpBaseUrl: "https://relay.example.com" });
const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver");
Expand All @@ -34,6 +117,12 @@ describe("browser target resolver", () => {
port: 5173,
}),
).toThrow(/authenticated preview gateway/);
expect(() =>
resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), {
kind: "url",
url: "http://localhost:5173",
}),
).toThrow(/authenticated preview gateway/);
});

it("normalizes schemeless localhost server-picker values", async () => {
Expand Down Expand Up @@ -63,15 +152,28 @@ describe("browser target resolver", () => {
});

it("supports private IPv6 environment hosts", async () => {
readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://[::1]:3773" });
readPreparedConnection.mockReturnValue({
httpBaseUrl: "http://[fd7a:115c:a1e0::53]:3773",
});
const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver");
expect(
resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), {
kind: "environment-port",
port: 5173,
path: "/app?mode=test",
}).resolvedUrl,
).toBe("http://[::1]:5173/app?mode=test");
).toBe("http://[fd7a:115c:a1e0::53]:5173/app?mode=test");
});

it("supports a local IPv6 environment host", async () => {
readPreparedConnection.mockReturnValue({ httpBaseUrl: "http://[::1]:3773" });
const { resolveBrowserNavigationTarget } = await import("./browserTargetResolver");
expect(
resolveBrowserNavigationTarget(EnvironmentId.make("environment-1"), {
kind: "environment-port",
port: 5173,
}).resolvedUrl,
).toBe("http://[::1]:5173/");
});

it("leaves malformed input for the normal navigation error path", async () => {
Expand Down
141 changes: 95 additions & 46 deletions apps/web/src/browser/browserTargetResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,84 +7,133 @@ import { isLoopbackHost, normalizePreviewUrl } from "@t3tools/shared/preview";

import { readPreparedConnection } from "~/state/session";

const normalizeHostname = (host: string): string => host.toLowerCase().replace(/^\[|\]$/g, "");

const parseIpv4Address = (host: string): readonly number[] | null => {
const parts = normalizeHostname(host).split(".").map(Number);
return parts.length === 4 &&
parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255)
? parts
: null;
};

const isLocalLoopbackHost = (host: string): boolean => {
const normalized = normalizeHostname(host);
if (normalized === "localhost" || normalized === "::1") return true;
return parseIpv4Address(normalized)?.[0] === 127;
};

const isPrivateNetworkHost = (host: string): boolean => {
const normalized = host.toLowerCase().replace(/^\[|\]$/g, "");
if (normalized === "localhost" || normalized === "::1" || normalized.endsWith(".local")) {
const normalized = normalizeHostname(host);
if (isLocalLoopbackHost(normalized) || normalized.endsWith(".local")) {
return true;
}
if (normalized.endsWith(".ts.net")) return true;
const parts = normalized.split(".").map(Number);
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part))) return false;
const parts = parseIpv4Address(normalized);
if (parts) {
return (
parts[0] === 10 ||
(parts[0] === 100 && parts[1]! >= 64 && parts[1]! <= 127) ||
(parts[0] === 172 && parts[1]! >= 16 && parts[1]! <= 31) ||
(parts[0] === 192 && parts[1] === 168) ||
(parts[0] === 169 && parts[1] === 254)
);
}
const firstIpv6Token = normalized.split(":", 1)[0] ?? "";
if (!normalized.includes(":") || !/^[\da-f]{1,4}$/u.test(firstIpv6Token)) return false;
const firstIpv6Hextet = Number.parseInt(firstIpv6Token, 16);
return (
parts[0] === 10 ||
(parts[0] === 172 && parts[1]! >= 16 && parts[1]! <= 31) ||
(parts[0] === 192 && parts[1] === 168) ||
parts[0] === 127 ||
(parts[0] === 169 && parts[1] === 254)
Number.isInteger(firstIpv6Hextet) &&
((firstIpv6Hextet & 0xfe00) === 0xfc00 || (firstIpv6Hextet & 0xffc0) === 0xfe80)
);
};

const isLocalLoopbackHost = (host: string): boolean => {
const normalized = host.toLowerCase().replace(/^\[|\]$/g, "");
return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1";
const readEnvironmentUrl = (environmentId: EnvironmentId): URL => {
const connection = readPreparedConnection(environmentId);
if (!connection) throw new Error(`Environment ${environmentId} is not connected.`);
return new URL(connection.httpBaseUrl);
};

export function resolveBrowserNavigationTarget(
const resolveEnvironmentPortTarget = (
environmentId: EnvironmentId,
target: BrowserNavigationTarget,
): PreviewUrlResolution {
if (target.kind === "url") {
return {
requestedUrl: target.url,
resolvedUrl: target.url,
resolutionKind: "direct",
environmentId,
};
}
const connection = readPreparedConnection(environmentId);
if (!connection) throw new Error(`Environment ${environmentId} is not connected.`);
const environmentUrl = new URL(connection.httpBaseUrl);
target: Extract<BrowserNavigationTarget, { readonly kind: "environment-port" }>,
environmentUrl: URL,
requestedUrl?: string,
sourceUrl?: URL,
): PreviewUrlResolution => {
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.",
);
}
const protocol = target.protocol ?? "http";
const path = target.path?.startsWith("/") ? target.path : `/${target.path ?? ""}`;
const requestedUrl = `${protocol}://localhost:${target.port}${path}`;
const normalizedEnvironmentHost = environmentUrl.hostname.replace(/^\[|\]$/g, "");
const resolvedHost = normalizedEnvironmentHost.includes(":")
? `[${normalizedEnvironmentHost}]`
: normalizedEnvironmentHost;
const resolved = new URL(path, `${protocol}://${resolvedHost}:${target.port}`);
const resolved = sourceUrl
? new URL(sourceUrl)
: new URL(path, `${protocol}://${resolvedHost}:${target.port}`);
if (sourceUrl) {
resolved.hostname = resolvedHost;
resolved.port = String(target.port);
Comment thread
juliusmarminge marked this conversation as resolved.
}
return {
requestedUrl,
requestedUrl: requestedUrl ?? `${protocol}://localhost:${target.port}${path}`,
resolvedUrl: resolved.toString(),
resolutionKind:
normalizedEnvironmentHost === "localhost" || normalizedEnvironmentHost === "127.0.0.1"
? "direct"
: "direct-private-network",
resolutionKind: isLocalLoopbackHost(normalizedEnvironmentHost)
? "direct"
: "direct-private-network",
environmentId,
};
};

export function resolveBrowserNavigationTarget(
environmentId: EnvironmentId,
target: BrowserNavigationTarget,
): PreviewUrlResolution {
if (target.kind === "url") {
let parsed: URL | null = null;
try {
parsed = new URL(normalizePreviewUrl(target.url));
} catch {
// Preserve the existing direct-navigation behavior so the preview host
// reports malformed URL errors through its normal navigation path.
}
if (parsed && isLoopbackHost(parsed.hostname)) {
const environmentUrl = readEnvironmentUrl(environmentId);
if (parsed.hostname === "0.0.0.0" || !isLocalLoopbackHost(environmentUrl.hostname)) {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
return resolveEnvironmentPortTarget(
environmentId,
{
kind: "environment-port",
port: Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80)),
protocol: parsed.protocol === "https:" ? "https" : "http",
path: `${parsed.pathname}${parsed.search}${parsed.hash}`,
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
},
environmentUrl,
target.url,
parsed,
);
}
}
return {
requestedUrl: target.url,
resolvedUrl: target.url,
resolutionKind: "direct",
environmentId,
};
}
return resolveEnvironmentPortTarget(environmentId, target, readEnvironmentUrl(environmentId));
}

export function resolveDiscoveredServerUrl(environmentId: EnvironmentId, rawUrl: string): string {
try {
const normalizedUrl = normalizePreviewUrl(rawUrl);
const parsed = new URL(normalizedUrl);
if (!isLoopbackHost(parsed.hostname)) return normalizedUrl;
const connection = readPreparedConnection(environmentId);
if (!connection) throw new Error(`Environment ${environmentId} is not connected.`);
const environmentUrl = new URL(connection.httpBaseUrl);
if (parsed.hostname !== "0.0.0.0" && isLocalLoopbackHost(environmentUrl.hostname)) {
return normalizedUrl;
}
const port = Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80));
return resolveBrowserNavigationTarget(environmentId, {
kind: "environment-port",
port,
protocol: parsed.protocol === "https:" ? "https" : "http",
path: `${parsed.pathname}${parsed.search}${parsed.hash}`,
kind: "url",
url: normalizedUrl,
}).resolvedUrl;
} catch {
return rawUrl;
Expand Down
16 changes: 9 additions & 7 deletions apps/web/src/components/preview/PreviewAutomationHosts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,12 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
return await currentStatus(threadRef, tabId);
case "open": {
const input = request.input as PreviewAutomationOpenInput;
const resolvedInputUrl = input.url
? resolveBrowserNavigationTarget(environmentId, {
kind: "url",
url: input.url,
}).resolvedUrl
: undefined;
let activeTabId = resolvePreviewAutomationOpenTab(
state,
request.tabId,
Expand All @@ -358,7 +364,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
environmentId,
input: {
threadId: request.threadId,
...(input.url ? { url: input.url } : {}),
...(resolvedInputUrl ? { url: resolvedInputUrl } : {}),
},
});
if (result._tag === "Failure") {
Expand All @@ -381,12 +387,8 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
request.timeoutMs,
);
}
if (reusedExistingTab && input.url && previewBridge) {
const resolution = resolveBrowserNavigationTarget(environmentId, {
kind: "url",
url: input.url,
});
await previewBridge.navigate(activeTabId, resolution.resolvedUrl);
if (reusedExistingTab && resolvedInputUrl && previewBridge) {
await previewBridge.navigate(activeTabId, resolvedInputUrl);
await waitForNavigationReadiness(
threadRef,
request.requestId,
Expand Down
Loading