export const INVITE_EXPIRED_ERROR = "invite_expired"; export const INVITE_EXHAUSTED_ERROR = "invite_exhausted"; /** * Parsed invite — either a full (relay + code) or bare-code form. * * URL inputs (`https://`, `http://`, `buzz://join`) always carry a * `relayWsUrl` (already normalised to `ws(s)://`). A bare code (no scheme, * no slashes) omits it — the caller decides which relay to target. */ export type ParsedInvite = | { relayWsUrl: string; code: string } | { code: string }; /** * Parse an invite input into a structured form. * * Accepted input forms: * - `https:///invite/` → `{ relayWsUrl: "wss://", code }` * - `http:///invite/` → `{ relayWsUrl: "ws://", code }` * - `buzz://join?relay=&code=` → `{ relayWsUrl, code }` * - bare code (no `://`, no `/`) → `{ code }` * * Returns `null` for empty input or inputs that don't match any form. */ export function parseInviteInput(input: string): ParsedInvite | null { const trimmed = input.trim(); if (!trimmed) return null; // Try URL-form parse first. try { const url = new URL(trimmed); // buzz://join?relay=...&code=... // Non-special schemes put the authority in `host`, not `pathname`. if (url.protocol === "buzz:") { if (url.host !== "join") return null; const relay = url.searchParams.get("relay"); const code = url.searchParams.get("code"); if (!relay || !code) return null; if (!relay.startsWith("ws://") && !relay.startsWith("wss://")) return null; if (url.username || url.password || url.hash) return null; // Reject credentials or fragments smuggled inside the nested relay param. try { const relayUrl = new URL(relay); if (relayUrl.username || relayUrl.password || relayUrl.hash) return null; } catch { return null; } return { relayWsUrl: relay, code }; } // https(s):///invite/ if (url.protocol === "https:" || url.protocol === "http:") { if (url.username || url.password || url.hash) return null; // pathname must be /invite/ with optional single trailing slash const match = url.pathname.match(/^\/invite\/([^/]+)\/?$/); if (!match?.[1]) return null; const code = decodeURIComponent(match[1]); // Convert scheme: https → wss, http → ws. url.host already includes port. const relayWsUrl = url.protocol === "https:" ? `wss://${url.host}` : `ws://${url.host}`; return { relayWsUrl, code }; } // ws/wss or any other scheme — not an invite URL. return null; } catch { // Not a URL — fall through to bare-code check. } // Bare code: no scheme, no slashes. if (trimmed.includes("://") || trimmed.includes("/")) return null; return { code: trimmed }; } /** Convert a ws(s) relay URL to its http(s) equivalent. */ export function relayHttpFromWs(wsUrl: string): string { if (wsUrl.startsWith("wss://")) return `https://${wsUrl.slice(6)}`; if (wsUrl.startsWith("ws://")) return `http://${wsUrl.slice(5)}`; throw new Error(`Expected ws:// or wss:// relay URL, got: ${wsUrl}`); } export function inviteErrorMessage(error: unknown): string { return error instanceof Error ? error.message : `${error}`; } export function isInviteExpiredError(error: unknown): boolean { return inviteErrorMessage(error) === INVITE_EXPIRED_ERROR; } export function isInviteExhaustedError(error: unknown): boolean { return inviteErrorMessage(error) === INVITE_EXHAUSTED_ERROR; }