1import { deriveWsBaseUrl, normalizeHttpBaseUrl } from "./advertisedEndpoint.ts";
2import * as Schema from "effect/Schema";
3
4const PAIRING_TOKEN_PARAM = "token";
5const HOSTED_PAIRING_HOST_PARAM = "host";
6const HOSTED_PAIRING_LABEL_PARAM = "label";
7const SUPPORTED_REMOTE_BACKEND_PROTOCOLS = new Set(["http:", "https:", "ws:", "wss:"]);
8
9export const readHashParams = (url: URL): URLSearchParams =>
10 new URLSearchParams(url.hash.startsWith("#") ? url.hash.slice(1) : url.hash);
11
12export class RemoteBackendUrlMissingError extends Schema.TaggedError<RemoteBackendUrlMissingError>()(
13 "RemoteBackendUrlMissingError",
14 {},
15) {
16 override get message(): string {
17 return "Enter a backend URL.";
18 }
19}
20
21export 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
33export 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
46export 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
55export 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
64export const RemotePairingTargetError = Schema.Union([
65 RemoteBackendUrlMissingError,
66 RemotePairingUrlInvalidError,
67 RemoteBackendUrlInvalidError,
68 RemotePairingTokenMissingError,
69 RemotePairingCodeMissingError,
70]);
71export type RemotePairingTargetError = typeof RemotePairingTargetError.Type;
72
73const hasSupportedRemoteBackendProtocol = (url: URL): boolean =>
74 SUPPORTED_REMOTE_BACKEND_PROTOCOLS.has(url.protocol);
75
76const 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
104export interface ResolvedRemotePairingTarget {
105 readonly credential: string;
106 readonly httpBaseUrl: string;
107 readonly wsBaseUrl: string;
108}
109
110export interface HostedPairingRequest {
111 readonly host: string;
112 readonly token: string;
113 readonly label: string;
114}
115
116export 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
126export 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
137export 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
144export 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
160export 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};