diff --git a/packages/shared/src/remote.test.ts b/packages/shared/src/remote.test.ts index 5ed058b9dc5..54c78907421 100644 --- a/packages/shared/src/remote.test.ts +++ b/packages/shared/src/remote.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from "vite-plus/test"; -import { resolveRemotePairingTarget } from "./remote.ts"; +import { + RemoteBackendUrlInvalidError, + RemoteBackendUrlMissingError, + RemotePairingTokenMissingError, + RemotePairingUrlInvalidError, + resolveRemotePairingTarget, +} from "./remote.ts"; describe("remote", () => { it("derives backend urls and token from a pairing url", () => { @@ -65,4 +71,42 @@ describe("remote", () => { wsBaseUrl: "wss://myserver.com:3000/", }); }); + + it("uses distinct structural errors for missing pairing inputs", () => { + expect(() => resolveRemotePairingTarget({})).toThrowError(RemoteBackendUrlMissingError); + expect(() => + resolveRemotePairingTarget({ pairingUrl: "https://remote.example.com/pair" }), + ).toThrowError(RemotePairingTokenMissingError); + expect(() => + resolveRemotePairingTarget({ + host: "https://user:secret@remote.example.com/path?token=sensitive#fragment", + }), + ).toThrowError( + expect.objectContaining({ + _tag: "RemotePairingCodeMissingError", + host: "remote.example.com", + }), + ); + }); + + it("preserves URL parsing causes with their input source", () => { + let pairingUrlError: unknown; + try { + resolveRemotePairingTarget({ pairingUrl: "not a url" }); + } catch (cause) { + pairingUrlError = cause; + } + expect(pairingUrlError).toBeInstanceOf(RemotePairingUrlInvalidError); + expect((pairingUrlError as RemotePairingUrlInvalidError).cause).toBeInstanceOf(TypeError); + + let hostError: unknown; + try { + resolveRemotePairingTarget({ host: "https://[invalid", pairingCode: "pairing-token" }); + } catch (cause) { + hostError = cause; + } + expect(hostError).toBeInstanceOf(RemoteBackendUrlInvalidError); + expect(hostError).toMatchObject({ source: "direct-host" }); + expect((hostError as RemoteBackendUrlInvalidError).cause).toBeInstanceOf(TypeError); + }); }); diff --git a/packages/shared/src/remote.ts b/packages/shared/src/remote.ts index c2d6079680d..703811609b8 100644 --- a/packages/shared/src/remote.ts +++ b/packages/shared/src/remote.ts @@ -1,3 +1,5 @@ +import * as Schema from "effect/Schema"; + const PAIRING_TOKEN_PARAM = "token"; const HOSTED_PAIRING_HOST_PARAM = "host"; const HOSTED_PAIRING_LABEL_PARAM = "label"; @@ -5,17 +7,82 @@ const HOSTED_PAIRING_LABEL_PARAM = "label"; const readHashParams = (url: URL): URLSearchParams => new URLSearchParams(url.hash.startsWith("#") ? url.hash.slice(1) : url.hash); -const normalizeRemoteBaseUrl = (rawValue: string): URL => { +export class RemoteBackendUrlMissingError extends Schema.TaggedErrorClass()( + "RemoteBackendUrlMissingError", + {}, +) { + override get message(): string { + return "Enter a backend URL."; + } +} + +export class RemotePairingUrlInvalidError extends Schema.TaggedErrorClass()( + "RemotePairingUrlInvalidError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Pairing URL is invalid."; + } +} + +export class RemoteBackendUrlInvalidError extends Schema.TaggedErrorClass()( + "RemoteBackendUrlInvalidError", + { + source: Schema.Literals(["direct-host", "hosted-pairing-host"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Backend URL is invalid."; + } +} + +export class RemotePairingTokenMissingError extends Schema.TaggedErrorClass()( + "RemotePairingTokenMissingError", + { host: Schema.String }, +) { + override get message(): string { + return "Pairing URL is missing its token."; + } +} + +export class RemotePairingCodeMissingError extends Schema.TaggedErrorClass()( + "RemotePairingCodeMissingError", + { host: Schema.String }, +) { + override get message(): string { + return "Enter a pairing code."; + } +} + +export const RemotePairingTargetError = Schema.Union([ + RemoteBackendUrlMissingError, + RemotePairingUrlInvalidError, + RemoteBackendUrlInvalidError, + RemotePairingTokenMissingError, + RemotePairingCodeMissingError, +]); +export type RemotePairingTargetError = typeof RemotePairingTargetError.Type; + +const normalizeRemoteBaseUrl = ( + rawValue: string, + source: RemoteBackendUrlInvalidError["source"], +): URL => { const trimmed = rawValue.trim(); if (!trimmed) { - throw new Error("Enter a backend URL."); + throw new RemoteBackendUrlMissingError(); } const normalizedInput = /^[a-zA-Z][a-zA-Z\d+-]*:\/\//.test(trimmed) || trimmed.startsWith("//") ? trimmed : `https://${trimmed}`; - const url = new URL(normalizedInput); + let url: URL; + try { + url = new URL(normalizedInput); + } catch (cause) { + throw new RemoteBackendUrlInvalidError({ source, cause }); + } url.pathname = "/"; url.search = ""; url.hash = ""; @@ -111,10 +178,18 @@ export const resolveRemotePairingTarget = (input: { }): ResolvedRemotePairingTarget => { const pairingUrl = input.pairingUrl?.trim() ?? ""; if (pairingUrl.length > 0) { - const url = new URL(pairingUrl); + let url: URL; + try { + url = new URL(pairingUrl); + } catch (cause) { + throw new RemotePairingUrlInvalidError({ cause }); + } const hostedPairingRequest = readHostedPairingRequest(url); if (hostedPairingRequest) { - const hostedBackendUrl = normalizeRemoteBaseUrl(hostedPairingRequest.host); + const hostedBackendUrl = normalizeRemoteBaseUrl( + hostedPairingRequest.host, + "hosted-pairing-host", + ); return { credential: hostedPairingRequest.token, httpBaseUrl: toHttpBaseUrl(hostedBackendUrl), @@ -124,7 +199,7 @@ export const resolveRemotePairingTarget = (input: { const credential = getPairingTokenFromUrl(url) ?? ""; if (!credential) { - throw new Error("Pairing URL is missing its token."); + throw new RemotePairingTokenMissingError({ host: url.host }); } return { credential, @@ -136,13 +211,13 @@ export const resolveRemotePairingTarget = (input: { const host = input.host?.trim() ?? ""; const pairingCode = input.pairingCode?.trim() ?? ""; if (!host) { - throw new Error("Enter a backend URL."); + throw new RemoteBackendUrlMissingError(); } + const normalizedHost = normalizeRemoteBaseUrl(host, "direct-host"); if (!pairingCode) { - throw new Error("Enter a pairing code."); + throw new RemotePairingCodeMissingError({ host: normalizedHost.host }); } - const normalizedHost = normalizeRemoteBaseUrl(host); return { credential: pairingCode, httpBaseUrl: toHttpBaseUrl(normalizedHost),