1import {
2 CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS,
3 PREVIEW_URL_MAX_LENGTH,
4 type DiscoveredLocalServer,
5 type EnvironmentId,
6 type ThreadId,
7} from "@t3tools/contracts";
8import { isLoopbackHost } from "@t3tools/shared/preview";
9import { useMemo } from "react";
10
11import { previewEnvironment } from "./state/preview";
12import { useEnvironmentQuery } from "./state/query";
13
14const EMPTY_PORTS: ReadonlyArray<DiscoveredLocalServer> = Object.freeze([]);
15
16interface DiscoveredPortsState {
17 readonly servers: ReadonlyArray<DiscoveredLocalServer>;
18 readonly configuredUrlProbing: boolean;
19}
20
21export function boundConfiguredLocalServerUrls(
22 urls: ReadonlyArray<string> | undefined,
23): ReadonlyArray<string> {
24 const bounded: string[] = [];
25 const seen = new Set<string>();
26 for (const raw of urls ?? []) {
27 if (raw.length === 0 || raw.length > PREVIEW_URL_MAX_LENGTH || raw.trim().length !== raw.length)
28 continue;
29 try {
30 const url = new URL(raw);
31 if (url.protocol !== "http:" && url.protocol !== "https:") continue;
32 if (!isLoopbackHost(url.hostname) || url.href.length > PREVIEW_URL_MAX_LENGTH) continue;
33 const resourceUrl = new URL(url.href);
34 resourceUrl.hash = "";
35 if (seen.has(resourceUrl.href)) continue;
36 seen.add(resourceUrl.href);
37 bounded.push(url.href);
38 if (bounded.length >= CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS) break;
39 } catch {
40 // Invalid and non-local project preview URLs are not discovery candidates.
41 }
42 }
43 return bounded;
44}
45
46function useDiscoveredPorts(
47 environmentId: EnvironmentId | null,
48 configuredUrls?: ReadonlyArray<string>,
49): ReadonlyArray<DiscoveredLocalServer> {
50 return useDiscoveredPortsState(environmentId, configuredUrls).servers;
51}
52
53export function useDiscoveredPortsState(
54 environmentId: EnvironmentId | null,
55 configuredUrls?: ReadonlyArray<string>,
56): DiscoveredPortsState {
57 const boundedConfiguredUrls = boundConfiguredLocalServerUrls(configuredUrls);
58 const query = useEnvironmentQuery(
59 environmentId === null
60 ? null
61 : previewEnvironment.discoveredServers({
62 environmentId,
63 input: boundedConfiguredUrls.length ? { configuredUrls: boundedConfiguredUrls } : {},
64 }),
65 );
66 return useMemo(
67 () => ({
68 servers: query.data?.servers ?? EMPTY_PORTS,
69 configuredUrlProbing: query.data?.configuredUrlProbing === true,
70 }),
71 [query.data?.configuredUrlProbing, query.data?.servers],
72 );
73}
74
75export function useThreadDiscoveredPorts(input: {
76 readonly environmentId: EnvironmentId | null;
77 readonly threadId: ThreadId | null;
78}): ReadonlyArray<DiscoveredLocalServer> {
79 const ports = useDiscoveredPorts(input.environmentId);
80 return useMemo(
81 () =>
82 input.threadId
83 ? ports.filter((port) => port.terminal?.threadId === input.threadId)
84 : EMPTY_PORTS,
85 [input.threadId, ports],
86 );
87}