feat: migrate from wsUrl to endpoints + add relay transport and pairing
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
import { Buffer } from "buffer";
|
||||
|
||||
export type HostPortParts = {
|
||||
host: string;
|
||||
port: number;
|
||||
isIpv6: boolean;
|
||||
};
|
||||
|
||||
function decodeBase64UrlToUtf8(input: string): string {
|
||||
const base64 = input.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "=");
|
||||
return Buffer.from(padded, "base64").toString("utf8");
|
||||
}
|
||||
|
||||
export function decodeOfferFragmentPayload(encoded: string): unknown {
|
||||
const json = decodeBase64UrlToUtf8(encoded);
|
||||
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();
|
||||
}
|
||||
|
||||
export function deriveLabelFromEndpoint(endpoint: string): string {
|
||||
try {
|
||||
const { host } = parseHostPort(endpoint);
|
||||
return host || "Unnamed Host";
|
||||
} catch {
|
||||
return "Unnamed Host";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import type { DaemonTransport, DaemonTransportFactory } from "@server/client/daemon-client-v2";
|
||||
|
||||
type TauriWebSocketMessage =
|
||||
| { type: "Text"; data: string }
|
||||
| { type: "Binary"; data: number[] }
|
||||
| { type: "Ping"; data: number[] }
|
||||
| { type: "Pong"; data: number[] }
|
||||
| { type: "Close"; data: { code: number; reason: string } | null };
|
||||
|
||||
type TauriWebSocketConnection = {
|
||||
addListener(cb: (msg: TauriWebSocketMessage) => void): () => void;
|
||||
send(message: string | number[] | TauriWebSocketMessage): Promise<void>;
|
||||
disconnect(): Promise<void>;
|
||||
};
|
||||
|
||||
type TauriWebSocketModule = {
|
||||
connect(url: string, config?: unknown): Promise<TauriWebSocketConnection>;
|
||||
};
|
||||
|
||||
function isTauriEnvironment(): boolean {
|
||||
return typeof window !== "undefined" && (window as any).__TAURI__ !== undefined;
|
||||
}
|
||||
|
||||
function getTauriWebSocketModule(): TauriWebSocketModule | null {
|
||||
if (!isTauriEnvironment()) {
|
||||
return null;
|
||||
}
|
||||
const ws = (window as any).__TAURI__?.websocket;
|
||||
if (ws && typeof ws.connect === "function") {
|
||||
return ws as TauriWebSocketModule;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function createTauriWebSocketTransportFactory(): DaemonTransportFactory | null {
|
||||
if (!isTauriEnvironment()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ({ url }) => {
|
||||
let ws: TauriWebSocketConnection | null = null;
|
||||
let wsListenerCleanup: (() => void) | null = null;
|
||||
let disposed = false;
|
||||
let opened = false;
|
||||
let closeEmitted = false;
|
||||
|
||||
const openHandlers = new Set<() => void>();
|
||||
const closeHandlers = new Set<(event?: unknown) => void>();
|
||||
const errorHandlers = new Set<(event?: unknown) => void>();
|
||||
const messageHandlers = new Set<(data: unknown) => void>();
|
||||
|
||||
const pendingSends: string[] = [];
|
||||
let closeRequested: { code?: number; reason?: string } | null = null;
|
||||
|
||||
const emitOpen = () => {
|
||||
if (disposed || opened) return;
|
||||
opened = true;
|
||||
for (const handler of openHandlers) {
|
||||
try {
|
||||
handler();
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const emitClose = (event?: unknown) => {
|
||||
if (disposed || closeEmitted) return;
|
||||
closeEmitted = true;
|
||||
for (const handler of closeHandlers) {
|
||||
try {
|
||||
handler(event);
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const emitError = (event?: unknown) => {
|
||||
if (disposed) return;
|
||||
for (const handler of errorHandlers) {
|
||||
try {
|
||||
handler(event);
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const emitMessage = (data: unknown) => {
|
||||
if (disposed) return;
|
||||
for (const handler of messageHandlers) {
|
||||
try {
|
||||
handler(data);
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const connect = async () => {
|
||||
try {
|
||||
let module: TauriWebSocketModule | null = getTauriWebSocketModule();
|
||||
if (!module) {
|
||||
const start = Date.now();
|
||||
while (!module && Date.now() - start < 2000) {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
module = getTauriWebSocketModule();
|
||||
}
|
||||
}
|
||||
if (!module) {
|
||||
throw new Error(
|
||||
"Tauri WebSocket plugin is not available (expected window.__TAURI__.websocket). Did you enable tauri-plugin-websocket and websocket:default capability?"
|
||||
);
|
||||
}
|
||||
|
||||
const socket = await module.connect(url);
|
||||
if (disposed) {
|
||||
try {
|
||||
await socket.disconnect();
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
ws = socket;
|
||||
|
||||
wsListenerCleanup = ws.addListener((msg) => {
|
||||
if (msg.type === "Text") {
|
||||
emitMessage({ data: msg.data });
|
||||
return;
|
||||
}
|
||||
if (msg.type === "Close") {
|
||||
emitClose(msg.data ?? undefined);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
emitOpen();
|
||||
|
||||
while (pendingSends.length > 0) {
|
||||
const next = pendingSends.shift();
|
||||
if (!next) break;
|
||||
try {
|
||||
await ws.send(next);
|
||||
} catch (error) {
|
||||
emitError(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (closeRequested) {
|
||||
try {
|
||||
await ws.disconnect();
|
||||
} catch (error) {
|
||||
emitError(error);
|
||||
} finally {
|
||||
emitClose(closeRequested);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
emitError(error);
|
||||
}
|
||||
};
|
||||
|
||||
void connect();
|
||||
|
||||
const transport: DaemonTransport = {
|
||||
send: (data) => {
|
||||
if (disposed) return;
|
||||
if (!ws) {
|
||||
pendingSends.push(data);
|
||||
return;
|
||||
}
|
||||
void ws.send(data).catch((error) => emitError(error));
|
||||
},
|
||||
close: (code?: number, reason?: string) => {
|
||||
if (disposed) return;
|
||||
closeRequested = { code, reason };
|
||||
if (!ws) return;
|
||||
void ws
|
||||
.disconnect()
|
||||
.catch((error) => emitError(error))
|
||||
.finally(() => emitClose(closeRequested));
|
||||
},
|
||||
onMessage: (handler) => {
|
||||
messageHandlers.add(handler);
|
||||
return () => messageHandlers.delete(handler);
|
||||
},
|
||||
onOpen: (handler) => {
|
||||
openHandlers.add(handler);
|
||||
if (opened) {
|
||||
try {
|
||||
handler();
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
return () => openHandlers.delete(handler);
|
||||
},
|
||||
onClose: (handler) => {
|
||||
closeHandlers.add(handler);
|
||||
if (closeEmitted) {
|
||||
try {
|
||||
handler(closeRequested ?? undefined);
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
return () => closeHandlers.delete(handler);
|
||||
},
|
||||
onError: (handler) => {
|
||||
errorHandlers.add(handler);
|
||||
return () => errorHandlers.delete(handler);
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
...transport,
|
||||
close: (code?: number, reason?: string) => {
|
||||
transport.close(code, reason);
|
||||
disposed = true;
|
||||
try {
|
||||
wsListenerCleanup?.();
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
wsListenerCleanup = null;
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user