Skip to content
Source
Sign inSign up

remote.ts

260 lines7.6 KB

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