export type HostPortParts = { host: string; port: number; isIpv6: boolean; }; export type RelayRole = "server" | "client"; export type RelayProtocolVersion = "1" | "2"; export const CURRENT_RELAY_PROTOCOL_VERSION: RelayProtocolVersion = "2"; export function normalizeRelayProtocolVersion( value: unknown, fallback: RelayProtocolVersion = CURRENT_RELAY_PROTOCOL_VERSION, ): RelayProtocolVersion { if (value == null) { return fallback; } const normalized = typeof value === "string" ? value.trim() : typeof value === "number" ? String(value) : ""; if (!normalized) { return fallback; } if (normalized === "1" || normalized === "2") { return normalized; } throw new Error('Relay version must be "1" or "2"'); } function parsePort(portStr: string, context: string): number { const port = Number(portStr); if (!Number.isInteger(port) || port < 1 || port > 65535) { throw new Error(`${context}: port must be between 1 and 65535`); } return port; } export function parseHostPort(input: string): HostPortParts { const trimmed = input.trim(); if (!trimmed) { throw new Error("Host is required"); } // IPv6: [::1]:6767 if (trimmed.startsWith("[")) { const match = trimmed.match(/^\[([^\]]+)\]:(\d{1,5})$/); if (!match) { throw new Error("Invalid host:port (expected [::1]:6767)"); } const host = match[1].trim(); if (!host) throw new Error("Host is required"); const port = parsePort(match[2], "Invalid host:port"); return { host, port, isIpv6: true }; } const match = trimmed.match(/^(.+):(\d{1,5})$/); if (!match) { throw new Error("Invalid host:port (expected localhost:6767)"); } const host = match[1].trim(); if (!host) throw new Error("Host is required"); const port = parsePort(match[2], "Invalid host:port"); return { host, port, isIpv6: false }; } export function normalizeHostPort(input: string): string { const { host, port, isIpv6 } = parseHostPort(input); return isIpv6 ? `[${host}]:${port}` : `${host}:${port}`; } export function normalizeLoopbackToLocalhost(endpoint: string): string { const { host, port, isIpv6 } = parseHostPort(endpoint); if (host === "127.0.0.1" || (!isIpv6 && host === "0.0.0.0")) { return `localhost:${port}`; } if (isIpv6 && (host === "::1" || host === "::")) { return `localhost:${port}`; } return endpoint; } export function deriveLabelFromEndpoint(endpoint: string): string { try { const { host } = parseHostPort(endpoint); return host || "Unnamed Host"; } catch { return "Unnamed Host"; } } function shouldUseSecureWebSocket(port: number): boolean { return port === 443; } export function buildDaemonWebSocketUrl(endpoint: string): string { const { host, port, isIpv6 } = parseHostPort(endpoint); const protocol = shouldUseSecureWebSocket(port) ? "wss" : "ws"; const hostPart = isIpv6 ? `[${host}]` : host; return new URL(`${protocol}://${hostPart}:${port}/ws`).toString(); } export function buildRelayWebSocketUrl(params: { endpoint: string; serverId: string; role: RelayRole; /** * Per-connection routing identifier used by the daemon to open server data sockets. * Clients should NOT provide this — the relay assigns a routing ID on connect. */ connectionId?: string; version?: RelayProtocolVersion | 1 | 2; }): string { const { host, port, isIpv6 } = parseHostPort(params.endpoint); const protocol = shouldUseSecureWebSocket(port) ? "wss" : "ws"; const hostPart = isIpv6 ? `[${host}]` : host; const url = new URL(`${protocol}://${hostPart}:${port}/ws`); url.searchParams.set("serverId", params.serverId); url.searchParams.set("role", params.role); url.searchParams.set("v", normalizeRelayProtocolVersion(params.version)); if (params.connectionId) { url.searchParams.set("connectionId", params.connectionId); } return url.toString(); } export function extractHostPortFromWebSocketUrl(wsUrl: string): string { const parsed = new URL(wsUrl); if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") { throw new Error("Invalid WebSocket URL protocol"); } if (parsed.pathname.replace(/\/+$/, "") !== "/ws") { throw new Error("Invalid WebSocket URL (expected /ws path)"); } const host = parsed.hostname; const port = parsed.port ? Number(parsed.port) : parsed.protocol === "wss:" ? 443 : 80; if (!host) { throw new Error("Invalid WebSocket URL (missing hostname)"); } if (!Number.isInteger(port) || port < 1 || port > 65535) { throw new Error("Invalid WebSocket URL (invalid port)"); } const isIpv6 = host.includes(":") && !host.startsWith("[") && !host.endsWith("]"); return isIpv6 ? `[${host}]:${port}` : `${host}:${port}`; } export function isRelayClientWebSocketUrl(url: string): boolean { try { const parsed = new URL(url); return parsed.searchParams.get("role") === "client" && parsed.searchParams.has("serverId"); } catch { return false; } }