diff --git a/packages/app/src/components/multi-daemon-session-host.tsx b/packages/app/src/components/multi-daemon-session-host.tsx index a5e97a625..a6b2d9383 100644 --- a/packages/app/src/components/multi-daemon-session-host.tsx +++ b/packages/app/src/components/multi-daemon-session-host.tsx @@ -4,7 +4,7 @@ import { type HostProfile, } from "@/contexts/daemon-registry-context"; import { useDaemonConnections } from "@/contexts/daemon-connections-context"; -import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "@/utils/daemon-endpoints"; +import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl, extractHostPortFromWebSocketUrl } from "@/utils/daemon-endpoints"; import { useEffect, useMemo, useRef, useState } from "react"; function buildCandidateUrls(daemon: HostProfile): string[] { @@ -41,18 +41,6 @@ function buildCandidateUrls(daemon: HostProfile): string[] { return out; } -function extractEndpointFromUrl(url: string): string | null { - try { - const parsed = new URL(url); - const host = parsed.hostname; - const port = parsed.port ? Number(parsed.port) : parsed.protocol === "wss:" ? 443 : 80; - const isIpv6 = host.includes(":") && !host.startsWith("[") && !host.endsWith("]"); - return isIpv6 ? `[${host}]:${port}` : `${host}:${port}`; - } catch { - return null; - } -} - function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) { const { connectionStates } = useDaemonConnections(); const { updateDaemon } = useDaemonRegistry(); @@ -87,9 +75,15 @@ function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) { useEffect(() => { if (!connection) return; - if (status === "online") { - if (pendingMetadataWriteRef.current) return; - const endpoint = extractEndpointFromUrl(activeUrl); + if (status === "online") { + if (pendingMetadataWriteRef.current) return; + const endpoint = (() => { + try { + return extractHostPortFromWebSocketUrl(activeUrl); + } catch { + return null; + } + })(); if (!endpoint) return; if (daemon.metadata?.lastKnownGoodEndpoint === endpoint) return; diff --git a/packages/app/src/utils/daemon-endpoints.ts b/packages/app/src/utils/daemon-endpoints.ts index 34f134b40..f3885b5b8 100644 --- a/packages/app/src/utils/daemon-endpoints.ts +++ b/packages/app/src/utils/daemon-endpoints.ts @@ -1,9 +1,22 @@ import { Buffer } from "buffer"; +import { + buildDaemonWebSocketUrl, + buildRelayWebSocketUrl as buildSharedRelayWebSocketUrl, + deriveLabelFromEndpoint, + extractHostPortFromWebSocketUrl, + normalizeHostPort, + parseHostPort, + type HostPortParts, +} from "@server/shared/daemon-endpoints"; -export type HostPortParts = { - host: string; - port: number; - isIpv6: boolean; +export type { HostPortParts }; + +export { + buildDaemonWebSocketUrl, + deriveLabelFromEndpoint, + extractHostPortFromWebSocketUrl, + normalizeHostPort, + parseHostPort, }; function decodeBase64UrlToUtf8(input: string): string { @@ -17,98 +30,6 @@ export function decodeOfferFragmentPayload(encoded: string): unknown { return JSON.parse(json) as unknown; } -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(); - const port = Number(match[2]); - if (!host) throw new Error("Host is required"); - if (!Number.isInteger(port) || port < 1 || port > 65535) { - throw new Error("Port must be between 1 and 65535"); - } - 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(); - const port = Number(match[2]); - if (!host) throw new Error("Host is required"); - if (!Number.isInteger(port) || port < 1 || port > 65535) { - throw new Error("Port must be between 1 and 65535"); - } - return { host, port, isIpv6: false }; -} - -export function normalizeHostPort(input: string): string { - const { host, port, isIpv6 } = parseHostPort(input); - if (isIpv6) { - return `[${host}]:${port}`; - } - return `${host}:${port}`; -} - -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("]"); - const hostPort = isIpv6 ? `[${host}]:${port}` : `${host}:${port}`; - return hostPort; -} - -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 `${protocol}://${hostPart}:${port}/ws`; -} - export function buildRelayWebSocketUrl(params: { endpoint: string; sessionId: string }): 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("session", params.sessionId); - url.searchParams.set("role", "client"); - return url.toString(); + return buildSharedRelayWebSocketUrl({ ...params, role: "client" }); } - -export function deriveLabelFromEndpoint(endpoint: string): string { - try { - const { host } = parseHostPort(endpoint); - return host || "Unnamed Host"; - } catch { - return "Unnamed Host"; - } -} - diff --git a/packages/server/src/client/daemon-client-v2.ts b/packages/server/src/client/daemon-client-v2.ts index fab6edbd2..0e82bb815 100644 --- a/packages/server/src/client/daemon-client-v2.ts +++ b/packages/server/src/client/daemon-client-v2.ts @@ -58,6 +58,7 @@ import { type EncryptedChannel, type Transport as RelayTransport, } from "@paseo/relay/e2ee"; +import { isRelayClientWebSocketUrl } from "../shared/daemon-endpoints.js"; export interface Logger { debug(obj: object, msg?: string): void; @@ -2359,18 +2360,6 @@ function createRelayE2eeTransportFactory( }; } -function isRelayClientWebSocketUrl(url: string): boolean { - try { - const parsed = new URL(url); - return ( - parsed.searchParams.get("role") === "client" && - parsed.searchParams.has("session") - ); - } catch { - return false; - } -} - function createEncryptedTransport( base: DaemonTransport, daemonPublicKeyB64: string, diff --git a/packages/server/src/server/relay-transport.ts b/packages/server/src/server/relay-transport.ts index 3a33d8219..ca8c22093 100644 --- a/packages/server/src/server/relay-transport.ts +++ b/packages/server/src/server/relay-transport.ts @@ -7,6 +7,7 @@ import { type EncryptedChannel, type Transport as RelayTransport, } from "@paseo/relay/e2ee"; +import { buildRelayWebSocketUrl } from "../shared/daemon-endpoints.js"; type RelayTransportOptions = { logger: pino.Logger; @@ -61,7 +62,11 @@ export function startRelayTransport({ const connect = (): void => { if (stopped) return; - const url = buildRelayWebSocketUrl(relayEndpoint, sessionId, "server"); + const url = buildRelayWebSocketUrl({ + endpoint: relayEndpoint, + sessionId, + role: "server", + }); const socket = new WebSocket(url); ws = socket; @@ -247,46 +252,4 @@ function bufferFromWsData(data: unknown): Buffer | null { return null; } -function buildRelayWebSocketUrl( - relayEndpoint: string, - sessionId: string, - role: "server" | "client" -): string { - const { host, port } = parseHostPort(relayEndpoint); - const protocol = port === 443 ? "wss" : "ws"; - return `${protocol}://${host}:${port}/ws?session=${encodeURIComponent( - sessionId - )}&role=${role}`; -} - -function parseHostPort(input: string): { host: string; port: number } { - const trimmed = input.trim(); - - if (trimmed.startsWith("[")) { - const endIdx = trimmed.indexOf("]"); - if (endIdx === -1) { - throw new Error(`Invalid relay endpoint: ${input}`); - } - const host = trimmed.slice(1, endIdx); - const rest = trimmed.slice(endIdx + 1); - if (!rest.startsWith(":")) { - throw new Error(`Invalid relay endpoint: ${input}`); - } - const port = Number.parseInt(rest.slice(1), 10); - if (!Number.isFinite(port)) { - throw new Error(`Invalid relay port: ${input}`); - } - return { host: `[${host}]`, port }; - } - - const idx = trimmed.lastIndexOf(":"); - if (idx === -1) { - throw new Error(`Invalid relay endpoint (expected host:port): ${input}`); - } - const host = trimmed.slice(0, idx); - const port = Number.parseInt(trimmed.slice(idx + 1), 10); - if (!host || !Number.isFinite(port)) { - throw new Error(`Invalid relay endpoint: ${input}`); - } - return { host, port }; -} +// buildRelayWebSocketUrl + parseHostPort live in ../shared/daemon-endpoints.ts diff --git a/packages/server/src/shared/connection-offer.ts b/packages/server/src/shared/connection-offer.ts index 37be5c9bb..a21d3e4ef 100644 --- a/packages/server/src/shared/connection-offer.ts +++ b/packages/server/src/shared/connection-offer.ts @@ -14,4 +14,3 @@ export const ConnectionOfferV1Schema = z.object({ }); export type ConnectionOfferV1 = z.infer; - diff --git a/packages/server/src/shared/daemon-endpoints.ts b/packages/server/src/shared/daemon-endpoints.ts new file mode 100644 index 000000000..7eb50dbe0 --- /dev/null +++ b/packages/server/src/shared/daemon-endpoints.ts @@ -0,0 +1,117 @@ +export type HostPortParts = { + host: string; + port: number; + isIpv6: boolean; +}; + +export type RelayRole = "server" | "client"; + +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 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 `${protocol}://${hostPart}:${port}/ws`; +} + +export function buildRelayWebSocketUrl(params: { + endpoint: string; + sessionId: string; + role: RelayRole; +}): 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("session", params.sessionId); + url.searchParams.set("role", params.role); + 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("session") + ); + } catch { + return false; + } +} +