1import type {
2 AdvertisedEndpoint,
3 AdvertisedEndpointHostedHttpsCompatibility,
4 AdvertisedEndpointProvider,
5 AdvertisedEndpointReachability,
6 AdvertisedEndpointSource,
7 AdvertisedEndpointStatus,
8} from "@t3tools/contracts";
9
10export interface CreateAdvertisedEndpointInput {
11 readonly id: string;
12 readonly label: string;
13 readonly provider: AdvertisedEndpointProvider;
14 readonly httpBaseUrl: string;
15 readonly reachability: AdvertisedEndpointReachability;
16 readonly hostedHttpsCompatibility?: AdvertisedEndpointHostedHttpsCompatibility;
17 readonly desktopCompatibility?: "compatible" | "unknown";
18 readonly source: AdvertisedEndpointSource;
19 readonly status?: AdvertisedEndpointStatus;
20 readonly isDefault?: boolean;
21 readonly description?: string;
22}
23
24export function normalizeHttpBaseUrl(rawValue: string): string {
25 const url = new URL(rawValue);
26 if (url.protocol === "ws:") {
27 url.protocol = "http:";
28 } else if (url.protocol === "wss:") {
29 url.protocol = "https:";
30 }
31
32 if (url.protocol !== "http:" && url.protocol !== "https:") {
33 throw new Error(`Endpoint must use HTTP or HTTPS. Received ${url.protocol}`);
34 }
35
36 url.pathname = `${url.pathname.replace(/\/+$/, "")}/`;
37 url.search = "";
38 url.hash = "";
39 return url.toString();
40}
41
42export function environmentEndpointUrl(baseUrl: string, path: string): string {
43 const url = new URL(baseUrl);
44 url.pathname = `${url.pathname.replace(/\/+$/, "")}/`;
45 url.search = "";
46 url.hash = "";
47 return new URL(path.replace(/^\/(?!\/)/, ""), url).toString();
48}
49
50/** Canonical bases end in a slash; existing socket endpoints already include their route. */
51export function environmentWebSocketUrl(wsBaseUrl: string): string {
52 const url = new URL(wsBaseUrl);
53 return url.pathname.endsWith("/") ? environmentEndpointUrl(wsBaseUrl, "/ws") : url.toString();
54}
55
56export function deriveWsBaseUrl(httpBaseUrl: string): string {
57 const url = new URL(normalizeHttpBaseUrl(httpBaseUrl));
58 url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
59 return url.toString();
60}
61
62export function classifyHostedHttpsCompatibility(
63 httpBaseUrl: string,
64 fallback: AdvertisedEndpointHostedHttpsCompatibility = "unknown",
65): AdvertisedEndpointHostedHttpsCompatibility {
66 const url = new URL(normalizeHttpBaseUrl(httpBaseUrl));
67 if (url.protocol === "http:") {
68 return "mixed-content-blocked";
69 }
70 return fallback === "mixed-content-blocked" ? "unknown" : fallback;
71}
72
73export function createAdvertisedEndpoint(input: CreateAdvertisedEndpointInput): AdvertisedEndpoint {
74 const httpBaseUrl = normalizeHttpBaseUrl(input.httpBaseUrl);
75 return {
76 id: input.id,
77 label: input.label,
78 provider: input.provider,
79 httpBaseUrl,
80 wsBaseUrl: deriveWsBaseUrl(httpBaseUrl),
81 reachability: input.reachability,
82 compatibility: {
83 hostedHttpsApp:
84 input.hostedHttpsCompatibility ?? classifyHostedHttpsCompatibility(httpBaseUrl),
85 desktopApp: input.desktopCompatibility ?? "compatible",
86 },
87 source: input.source,
88 status: input.status ?? "available",
89 ...(input.isDefault === undefined ? {} : { isDefault: input.isDefault }),
90 ...(input.description === undefined ? {} : { description: input.description }),
91 };
92}