Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 45 additions & 1 deletion packages/shared/src/remote.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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);
});
});
93 changes: 84 additions & 9 deletions packages/shared/src/remote.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,88 @@
import * as Schema from "effect/Schema";

const PAIRING_TOKEN_PARAM = "token";
const HOSTED_PAIRING_HOST_PARAM = "host";
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>()(
"RemoteBackendUrlMissingError",
{},
) {
override get message(): string {
return "Enter a backend URL.";
}
}

export class RemotePairingUrlInvalidError extends Schema.TaggedErrorClass<RemotePairingUrlInvalidError>()(
"RemotePairingUrlInvalidError",
{ cause: Schema.Defect() },
) {
override get message(): string {
return "Pairing URL is invalid.";
}
}

export class RemoteBackendUrlInvalidError extends Schema.TaggedErrorClass<RemoteBackendUrlInvalidError>()(
"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>()(
"RemotePairingTokenMissingError",
{ host: Schema.String },
) {
override get message(): string {
return "Pairing URL is missing its token.";
}
}

export class RemotePairingCodeMissingError extends Schema.TaggedErrorClass<RemotePairingCodeMissingError>()(
"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 = "";
Expand Down Expand Up @@ -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),
Expand All @@ -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,
Expand All @@ -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),
Expand Down
Loading