| 1 | import { deriveWsBaseUrl, normalizeHttpBaseUrl } from "./advertisedEndpoint.ts"; |
| 2 | import * as Schema from "effect/Schema"; |
| 3 | |
| 4 | const PAIRING_TOKEN_PARAM = "token"; |
| 5 | const HOSTED_PAIRING_HOST_PARAM = "host"; |
| 6 | const HOSTED_PAIRING_LABEL_PARAM = "label"; |
| 7 | const SUPPORTED_REMOTE_BACKEND_PROTOCOLS = new Set(["http:", "https:", "ws:", "wss:"]); |
| 8 | |
| 9 | export const readHashParams = (url: URL): URLSearchParams => |
| 10 | new URLSearchParams(url.hash.startsWith("#") ? url.hash.slice(1) : url.hash); |
| 11 | |
| 12 | export class RemoteBackendUrlMissingError extends Schema.TaggedError<RemoteBackendUrlMissingError>()( |
| 13 | "RemoteBackendUrlMissingError", |
| 14 | {}, |
| 15 | ) { |
| 16 | override get message(): string { |
| 17 | return "Enter a backend URL."; |
| 18 | } |
| 19 | } |
| 20 | |
| 21 | export class RemotePairingUrlInvalidError extends Schema.TaggedError<RemotePairingUrlInvalidError>()( |
| 22 | "RemotePairingUrlInvalidError", |
| 23 | { |
| 24 | cause: Schema.optional(Schema.Defect()), |
| 25 | protocol: Schema.optional(Schema.String), |
| 26 | }, |
| 27 | ) { |
| 28 | override get message(): string { |
| 29 | return "Pairing URL is invalid."; |
| 30 | } |
| 31 | } |
| 32 | |
| 33 | export class RemoteBackendUrlInvalidError extends Schema.TaggedError<RemoteBackendUrlInvalidError>()( |
| 34 | "RemoteBackendUrlInvalidError", |
| 35 | { |
| 36 | source: Schema.Literals(["direct-host", "hosted-pairing-host"]), |
| 37 | cause: Schema.optional(Schema.Defect()), |
| 38 | protocol: Schema.optional(Schema.String), |
| 39 | }, |
| 40 | ) { |
| 41 | override get message(): string { |
| 42 | return "Backend URL is invalid."; |
| 43 | } |
| 44 | } |
| 45 | |
| 46 | export class RemotePairingTokenMissingError extends Schema.TaggedError<RemotePairingTokenMissingError>()( |
| 47 | "RemotePairingTokenMissingError", |
| 48 | { host: Schema.String }, |
| 49 | ) { |
| 50 | override get message(): string { |
| 51 | return "Pairing URL is missing its token."; |
| 52 | } |
| 53 | } |
| 54 | |
| 55 | export class RemotePairingCodeMissingError extends Schema.TaggedError<RemotePairingCodeMissingError>()( |
| 56 | "RemotePairingCodeMissingError", |
| 57 | { host: Schema.String }, |
| 58 | ) { |
| 59 | override get message(): string { |
| 60 | return "Enter a pairing code."; |
| 61 | } |
| 62 | } |
| 63 | |
| 64 | export const RemotePairingTargetError = Schema.Union([ |
| 65 | RemoteBackendUrlMissingError, |
| 66 | RemotePairingUrlInvalidError, |
| 67 | RemoteBackendUrlInvalidError, |
| 68 | RemotePairingTokenMissingError, |
| 69 | RemotePairingCodeMissingError, |
| 70 | ]); |
| 71 | export type RemotePairingTargetError = typeof RemotePairingTargetError.Type; |
| 72 | |
| 73 | const hasSupportedRemoteBackendProtocol = (url: URL): boolean => |
| 74 | SUPPORTED_REMOTE_BACKEND_PROTOCOLS.has(url.protocol); |
| 75 | |
| 76 | const normalizeRemoteBaseUrl = ( |
| 77 | rawValue: string, |
| 78 | source: RemoteBackendUrlInvalidError["source"], |
| 79 | ): URL => { |
| 80 | const trimmed = rawValue.trim(); |
| 81 | if (!trimmed) { |
| 82 | throw new RemoteBackendUrlMissingError(); |
| 83 | } |
| 84 | |
| 85 | const withoutLeadingSlashes = trimmed.replace(/^\/+/, ""); |
| 86 | const normalizedInput = /^[a-zA-Z][a-zA-Z\d+-]*:\/\//.test(withoutLeadingSlashes) |
| 87 | ? withoutLeadingSlashes |
| 88 | : `https://${withoutLeadingSlashes}`; |
| 89 | let url: URL; |
| 90 | try { |
| 91 | url = new URL(normalizedInput); |
| 92 | } catch (cause) { |
| 93 | throw new RemoteBackendUrlInvalidError({ source, cause }); |
| 94 | } |
| 95 | if (!hasSupportedRemoteBackendProtocol(url)) { |
| 96 | throw new RemoteBackendUrlInvalidError({ |
| 97 | source, |
| 98 | protocol: url.protocol, |
| 99 | }); |
| 100 | } |
| 101 | return url; |
| 102 | }; |
| 103 | |
| 104 | export interface ResolvedRemotePairingTarget { |
| 105 | readonly credential: string; |
| 106 | readonly httpBaseUrl: string; |
| 107 | readonly wsBaseUrl: string; |
| 108 | } |
| 109 | |
| 110 | export interface HostedPairingRequest { |
| 111 | readonly host: string; |
| 112 | readonly token: string; |
| 113 | readonly label: string; |
| 114 | } |
| 115 | |
| 116 | export const getPairingTokenFromUrl = (url: URL): string | null => { |
| 117 | const hashToken = readHashParams(url).get(PAIRING_TOKEN_PARAM)?.trim() ?? ""; |
| 118 | if (hashToken.length > 0) { |
| 119 | return hashToken; |
| 120 | } |
| 121 | |
| 122 | const searchToken = url.searchParams.get(PAIRING_TOKEN_PARAM)?.trim() ?? ""; |
| 123 | return searchToken.length > 0 ? searchToken : null; |
| 124 | }; |
| 125 | |
| 126 | export const stripPairingTokenFromUrl = (url: URL): URL => { |
| 127 | const next = new URL(url.toString()); |
| 128 | const hashParams = readHashParams(next); |
| 129 | if (hashParams.has(PAIRING_TOKEN_PARAM)) { |
| 130 | hashParams.delete(PAIRING_TOKEN_PARAM); |
| 131 | next.hash = hashParams.toString(); |
| 132 | } |
| 133 | next.searchParams.delete(PAIRING_TOKEN_PARAM); |
| 134 | return next; |
| 135 | }; |
| 136 | |
| 137 | export const setPairingTokenOnUrl = (url: URL, credential: string): URL => { |
| 138 | const next = new URL(url.toString()); |
| 139 | next.searchParams.delete(PAIRING_TOKEN_PARAM); |
| 140 | next.hash = new URLSearchParams([[PAIRING_TOKEN_PARAM, credential]]).toString(); |
| 141 | return next; |
| 142 | }; |
| 143 | |
| 144 | export const readHostedPairingRequest = (url: URL): HostedPairingRequest | null => { |
| 145 | const host = url.searchParams.get(HOSTED_PAIRING_HOST_PARAM)?.trim() ?? ""; |
| 146 | const token = getPairingTokenFromUrl(url)?.trim() ?? ""; |
| 147 | const label = url.searchParams.get(HOSTED_PAIRING_LABEL_PARAM)?.trim() ?? ""; |
| 148 | |
| 149 | if (!host || !token) { |
| 150 | return null; |
| 151 | } |
| 152 | |
| 153 | return { |
| 154 | host, |
| 155 | token, |
| 156 | label, |
| 157 | }; |
| 158 | }; |
| 159 | |
| 160 | export const resolveRemotePairingTarget = (input: { |
| 161 | readonly pairingUrl?: string; |
| 162 | readonly host?: string; |
| 163 | readonly pairingCode?: string; |
| 164 | }): ResolvedRemotePairingTarget => { |
| 165 | const pairingUrl = input.pairingUrl?.trim() ?? ""; |
| 166 | if (pairingUrl.length > 0) { |
| 167 | let url: URL; |
| 168 | try { |
| 169 | url = new URL(pairingUrl); |
| 170 | } catch (cause) { |
| 171 | throw new RemotePairingUrlInvalidError({ cause }); |
| 172 | } |
| 173 | if (!hasSupportedRemoteBackendProtocol(url)) { |
| 174 | throw new RemotePairingUrlInvalidError({ |
| 175 | protocol: url.protocol, |
| 176 | }); |
| 177 | } |
| 178 | const hostedPairingRequest = readHostedPairingRequest(url); |
| 179 | if (hostedPairingRequest) { |
| 180 | const hostedBackendUrl = normalizeRemoteBaseUrl( |
| 181 | hostedPairingRequest.host, |
| 182 | "hosted-pairing-host", |
| 183 | ); |
| 184 | return { |
| 185 | credential: hostedPairingRequest.token, |
| 186 | httpBaseUrl: normalizeHttpBaseUrl(hostedBackendUrl.toString()), |
| 187 | wsBaseUrl: deriveWsBaseUrl(hostedBackendUrl.toString()), |
| 188 | }; |
| 189 | } |
| 190 | |
| 191 | const credential = getPairingTokenFromUrl(url) ?? ""; |
| 192 | if (!credential) { |
| 193 | throw new RemotePairingTokenMissingError({ host: url.host }); |
| 194 | } |
| 195 | url.pathname = url.pathname.replace(/\/pair\/?$/, "/"); |
| 196 | return { |
| 197 | credential, |
| 198 | httpBaseUrl: normalizeHttpBaseUrl(url.toString()), |
| 199 | wsBaseUrl: deriveWsBaseUrl(url.toString()), |
| 200 | }; |
| 201 | } |
| 202 | |
| 203 | const host = input.host?.trim() ?? ""; |
| 204 | const pairingCode = input.pairingCode?.trim() ?? ""; |
| 205 | if (!host) { |
| 206 | throw new RemoteBackendUrlMissingError(); |
| 207 | } |
| 208 | const normalizedHost = normalizeRemoteBaseUrl(host, "direct-host"); |
| 209 | if (!pairingCode) { |
| 210 | throw new RemotePairingCodeMissingError({ host: normalizedHost.host }); |
| 211 | } |
| 212 | |
| 213 | return { |
| 214 | credential: pairingCode, |
| 215 | httpBaseUrl: normalizeHttpBaseUrl(normalizedHost.toString()), |
| 216 | wsBaseUrl: deriveWsBaseUrl(normalizedHost.toString()), |
| 217 | }; |
| 218 | }; |