import type { DiscoveredLocalServer } from "@t3tools/contracts"; import { isLoopbackHost } from "@t3tools/shared/preview"; import { useMemo } from "react"; import type { EnvironmentId } from "@t3tools/contracts"; import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; import { useDiscoveredPortsState } from "~/portDiscoveryState"; export interface PreviewableServer extends DiscoveredLocalServer { source: "scanner" | "configured"; /** * Pre-resolution loopback url. `url` is the resolved navigation target * (volatile on a remote environment); history must key off this instead. */ requestedUrl: string; } interface UseDiscoveredLocalServersInput { environmentId: EnvironmentId; configuredUrls?: ReadonlyArray | undefined; } /** * Enrich the environment-level live server snapshot with matching configured * URLs and return a stable sorted list. */ export function useDiscoveredLocalServers( input: UseDiscoveredLocalServersInput, ): ReadonlyArray { const scannerState = useDiscoveredPortsState(input.environmentId, input.configuredUrls); return useMemo( () => mergeServers({ scanner: scannerState.servers.map((server) => ({ ...server, url: resolveDiscoveredServerUrl(input.environmentId, server.url), requestedUrl: server.url, })), configuredUrls: input.configuredUrls ?? [], configuredUrlProbing: scannerState.configuredUrlProbing, }), [input.environmentId, scannerState, input.configuredUrls], ); } export function mergeServers(input: { scanner: ReadonlyArray; configuredUrls: ReadonlyArray; configuredUrlProbing?: boolean; }): ReadonlyArray { const configuredByServer = new Map(); for (const url of input.configuredUrls) { const parsed = parseLocalUrl(url); if (!parsed) continue; const key = canonicalKey(parsed.host, parsed.port); if (!configuredByServer.has(key)) configuredByServer.set(key, parsed); } const live: PreviewableServer[] = []; for (const server of input.scanner) { const key = canonicalKey(server.host, server.port); const configured = configuredByServer.get(key); live.push({ ...server, requestedUrl: configured && input.configuredUrlProbing === false ? configured.url : server.requestedUrl, source: configured ? "configured" : "scanner", }); } return live.toSorted((a, b) => { const sourceOrder: Record = { configured: 0, scanner: 1, }; if (sourceOrder[a.source] !== sourceOrder[b.source]) { return sourceOrder[a.source] - sourceOrder[b.source]; } return a.port - b.port; }); } function canonicalKey(host: string, port: number): string { const normalizedHost = host.toLowerCase(); return `${isLoopbackHost(normalizedHost) ? "loopback" : normalizedHost}:${port}`; } function parseLocalUrl(raw: string): { host: string; port: number; url: string } | null { try { const parsed = new URL(raw); if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null; if (!isLoopbackHost(parsed.hostname)) return null; const port = parsed.port ? Number.parseInt(parsed.port, 10) : parsed.protocol === "http:" ? 80 : 443; if (!Number.isFinite(port) || port <= 0) return null; return { host: parsed.hostname, port, url: parsed.href }; } catch { return null; } }