| 1 | import type { DiscoveredLocalServer } from "@t3tools/contracts"; |
| 2 | import { isLoopbackHost } from "@t3tools/shared/preview"; |
| 3 | import { useMemo } from "react"; |
| 4 | |
| 5 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 6 | import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; |
| 7 | import { useDiscoveredPortsState } from "~/portDiscoveryState"; |
| 8 | |
| 9 | export interface PreviewableServer extends DiscoveredLocalServer { |
| 10 | source: "scanner" | "configured"; |
| 11 | /** |
| 12 | * Pre-resolution loopback url. `url` is the resolved navigation target |
| 13 | * (volatile on a remote environment); history must key off this instead. |
| 14 | */ |
| 15 | requestedUrl: string; |
| 16 | } |
| 17 | |
| 18 | interface UseDiscoveredLocalServersInput { |
| 19 | environmentId: EnvironmentId; |
| 20 | configuredUrls?: ReadonlyArray<string> | undefined; |
| 21 | } |
| 22 | |
| 23 | /** |
| 24 | * Enrich the environment-level live server snapshot with matching configured |
| 25 | * URLs and return a stable sorted list. |
| 26 | */ |
| 27 | export function useDiscoveredLocalServers( |
| 28 | input: UseDiscoveredLocalServersInput, |
| 29 | ): ReadonlyArray<PreviewableServer> { |
| 30 | const scannerState = useDiscoveredPortsState(input.environmentId, input.configuredUrls); |
| 31 | |
| 32 | return useMemo( |
| 33 | () => |
| 34 | mergeServers({ |
| 35 | scanner: scannerState.servers.map((server) => ({ |
| 36 | ...server, |
| 37 | url: resolveDiscoveredServerUrl(input.environmentId, server.url), |
| 38 | requestedUrl: server.url, |
| 39 | })), |
| 40 | configuredUrls: input.configuredUrls ?? [], |
| 41 | configuredUrlProbing: scannerState.configuredUrlProbing, |
| 42 | }), |
| 43 | [input.environmentId, scannerState, input.configuredUrls], |
| 44 | ); |
| 45 | } |
| 46 | |
| 47 | export function mergeServers(input: { |
| 48 | scanner: ReadonlyArray<DiscoveredLocalServer & { requestedUrl: string }>; |
| 49 | configuredUrls: ReadonlyArray<string>; |
| 50 | configuredUrlProbing?: boolean; |
| 51 | }): ReadonlyArray<PreviewableServer> { |
| 52 | const configuredByServer = new Map<string, { host: string; port: number; url: string }>(); |
| 53 | |
| 54 | for (const url of input.configuredUrls) { |
| 55 | const parsed = parseLocalUrl(url); |
| 56 | if (!parsed) continue; |
| 57 | const key = canonicalKey(parsed.host, parsed.port); |
| 58 | if (!configuredByServer.has(key)) configuredByServer.set(key, parsed); |
| 59 | } |
| 60 | |
| 61 | const live: PreviewableServer[] = []; |
| 62 | for (const server of input.scanner) { |
| 63 | const key = canonicalKey(server.host, server.port); |
| 64 | const configured = configuredByServer.get(key); |
| 65 | live.push({ |
| 66 | ...server, |
| 67 | requestedUrl: |
| 68 | configured && input.configuredUrlProbing === false ? configured.url : server.requestedUrl, |
| 69 | source: configured ? "configured" : "scanner", |
| 70 | }); |
| 71 | } |
| 72 | |
| 73 | return live.toSorted((a, b) => { |
| 74 | const sourceOrder: Record<PreviewableServer["source"], number> = { |
| 75 | configured: 0, |
| 76 | scanner: 1, |
| 77 | }; |
| 78 | if (sourceOrder[a.source] !== sourceOrder[b.source]) { |
| 79 | return sourceOrder[a.source] - sourceOrder[b.source]; |
| 80 | } |
| 81 | return a.port - b.port; |
| 82 | }); |
| 83 | } |
| 84 | |
| 85 | function canonicalKey(host: string, port: number): string { |
| 86 | const normalizedHost = host.toLowerCase(); |
| 87 | return `${isLoopbackHost(normalizedHost) ? "loopback" : normalizedHost}:${port}`; |
| 88 | } |
| 89 | |
| 90 | function parseLocalUrl(raw: string): { host: string; port: number; url: string } | null { |
| 91 | try { |
| 92 | const parsed = new URL(raw); |
| 93 | if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null; |
| 94 | if (!isLoopbackHost(parsed.hostname)) return null; |
| 95 | const port = parsed.port |
| 96 | ? Number.parseInt(parsed.port, 10) |
| 97 | : parsed.protocol === "http:" |
| 98 | ? 80 |
| 99 | : 443; |
| 100 | if (!Number.isFinite(port) || port <= 0) return null; |
| 101 | return { host: parsed.hostname, port, url: parsed.href }; |
| 102 | } catch { |
| 103 | return null; |
| 104 | } |
| 105 | } |