1import type {
2 BrowserNavigationTarget,
3 EnvironmentId,
4 PreviewUrlResolution,
5} from "@t3tools/contracts";
6import { isLoopbackHost, normalizePreviewUrl } from "@t3tools/shared/preview";
7import { isLocalLoopbackHost, isPrivateNetworkHost } from "@t3tools/shared/hostClassification";
8
9import type { ConnectionTarget } from "@t3tools/client-runtime/connection";
10
11import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal";
12import { isElectron } from "~/env";
13import { readPreparedConnection } from "~/state/session";
14
15export {
16 normalizeHostname,
17 isLocalLoopbackHost,
18 isPrivateNetworkHost,
19 isPublicFaviconHost,
20} from "@t3tools/shared/hostClassification";
21
22const readEnvironmentUrl = (environmentId: EnvironmentId): URL => {
23 const connection = readPreparedConnection(environmentId);
24 if (!connection) throw new Error(`Environment ${environmentId} is not connected.`);
25 return new URL(connection.httpBaseUrl);
26};
27
28const resolveEnvironmentPortTarget = (
29 environmentId: EnvironmentId,
30 target: Extract<BrowserNavigationTarget, { readonly kind: "environment-port" }>,
31 environmentUrl: URL,
32 requestedUrl?: string,
33 sourceUrl?: URL,
34): PreviewUrlResolution => {
35 if (!isPrivateNetworkHost(environmentUrl.hostname)) {
36 throw new Error(
37 "This environment port needs the planned authenticated preview gateway; its server address is not directly private-network reachable.",
38 );
39 }
40 const protocol = target.protocol ?? "http";
41 const path = target.path?.startsWith("/") ? target.path : `/${target.path ?? ""}`;
42 const normalizedEnvironmentHost = environmentUrl.hostname.replace(/^\[|\]$/g, "");
43 // Local loopback environments should advertise `localhost` so Chromium
44 // dual-stack lookup can reach a Vite server bound only to ::1 or 127.0.0.1.
45 const resolvedHost = isLocalLoopbackHost(normalizedEnvironmentHost)
46 ? "localhost"
47 : normalizedEnvironmentHost.includes(":")
48 ? `[${normalizedEnvironmentHost}]`
49 : normalizedEnvironmentHost;
50 const resolved = sourceUrl
51 ? new URL(sourceUrl)
52 : new URL(path, `${protocol}://${resolvedHost}:${target.port}`);
53 if (sourceUrl) {
54 resolved.hostname = resolvedHost;
55 resolved.port = String(target.port);
56 }
57 return {
58 requestedUrl: requestedUrl ?? `${protocol}://localhost:${target.port}${path}`,
59 resolvedUrl: resolved.toString(),
60 resolutionKind: isLocalLoopbackHost(normalizedEnvironmentHost)
61 ? "direct"
62 : "direct-private-network",
63 environmentId,
64 };
65};
66
67export function resolveBrowserNavigationTarget(
68 environmentId: EnvironmentId,
69 target: BrowserNavigationTarget,
70): PreviewUrlResolution {
71 if (target.kind === "url") {
72 return {
73 requestedUrl: target.url,
74 resolvedUrl: target.url,
75 resolutionKind: "direct",
76 environmentId,
77 };
78 }
79 return resolveEnvironmentPortTarget(environmentId, target, readEnvironmentUrl(environmentId));
80}
81
82/**
83 * Whether a discovered loopback server is reachable as-is from this client's
84 * own browser tabs. The desktop app draws tabs on the machine that runs its
85 * primary backend and any desktop-local backend (WSL). Its connection URL can
86 * still be a LAN address (WSL NAT mode binds the distro IP), but a dev server
87 * bound to loopback only answers on `localhost` there.
88 */
89const reachesEnvironmentLoopback = (target: ConnectionTarget): boolean =>
90 isElectron &&
91 (target._tag === "PrimaryConnectionTarget" || isDesktopLocalConnectionTarget(target));
92
93export function resolveDiscoveredServerUrl(environmentId: EnvironmentId, rawUrl: string): string {
94 try {
95 const normalizedUrl = normalizePreviewUrl(rawUrl);
96 const parsed = new URL(normalizedUrl);
97 if (!isLoopbackHost(parsed.hostname)) return normalizedUrl;
98 const target = readPreparedConnection(environmentId)?.target;
99 if (target && reachesEnvironmentLoopback(target)) return normalizedUrl;
100 return resolveEnvironmentPortTarget(
101 environmentId,
102 {
103 kind: "environment-port",
104 port: Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80)),
105 protocol: parsed.protocol === "https:" ? "https" : "http",
106 path: `${parsed.pathname}${parsed.search}${parsed.hash}`,
107 },
108 readEnvironmentUrl(environmentId),
109 rawUrl,
110 parsed,
111 ).resolvedUrl;
112 } catch {
113 return rawUrl;
114 }
115}