| 1 | import type { |
| 2 | AdvertisedEndpoint, |
| 3 | AdvertisedEndpointHostedHttpsCompatibility, |
| 4 | AdvertisedEndpointProvider, |
| 5 | AdvertisedEndpointReachability, |
| 6 | AdvertisedEndpointSource, |
| 7 | AdvertisedEndpointStatus, |
| 8 | } from "@t3tools/contracts"; |
| 9 | |
| 10 | export 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 | |
| 24 | export 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 | |
| 42 | export 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. */ |
| 51 | export function environmentWebSocketUrl(wsBaseUrl: string): string { |
| 52 | const url = new URL(wsBaseUrl); |
| 53 | return url.pathname.endsWith("/") ? environmentEndpointUrl(wsBaseUrl, "/ws") : url.toString(); |
| 54 | } |
| 55 | |
| 56 | export 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 | |
| 62 | export 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 | |
| 73 | export 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 | } |