| 1 | import type { |
| 2 | BrowserNavigationTarget, |
| 3 | EnvironmentId, |
| 4 | PreviewUrlResolution, |
| 5 | } from "@t3tools/contracts"; |
| 6 | import { isLoopbackHost, normalizePreviewUrl } from "@t3tools/shared/preview"; |
| 7 | import { isLocalLoopbackHost, isPrivateNetworkHost } from "@t3tools/shared/hostClassification"; |
| 8 | |
| 9 | import type { ConnectionTarget } from "@t3tools/client-runtime/connection"; |
| 10 | |
| 11 | import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal"; |
| 12 | import { isElectron } from "~/env"; |
| 13 | import { readPreparedConnection } from "~/state/session"; |
| 14 | |
| 15 | export { |
| 16 | normalizeHostname, |
| 17 | isLocalLoopbackHost, |
| 18 | isPrivateNetworkHost, |
| 19 | isPublicFaviconHost, |
| 20 | } from "@t3tools/shared/hostClassification"; |
| 21 | |
| 22 | const 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 | |
| 28 | const 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 | |
| 67 | export 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 | */ |
| 89 | const reachesEnvironmentLoopback = (target: ConnectionTarget): boolean => |
| 90 | isElectron && |
| 91 | (target._tag === "PrimaryConnectionTarget" || isDesktopLocalConnectionTarget(target)); |
| 92 | |
| 93 | export 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 | } |