1import type { DiscoveredLocalServer } from "@t3tools/contracts";
2import { isLoopbackHost } from "@t3tools/shared/preview";
3import { useMemo } from "react";
4
5import type { EnvironmentId } from "@t3tools/contracts";
6import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver";
7import { useDiscoveredPortsState } from "~/portDiscoveryState";
8
9export 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
18interface 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 */
27export 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
47export 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
85function canonicalKey(host: string, port: number): string {
86 const normalizedHost = host.toLowerCase();
87 return `${isLoopbackHost(normalizedHost) ? "loopback" : normalizedHost}:${port}`;
88}
89
90function 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}