import type { WSMessage, WSEventType } from "../types/events"; import { type Logger, noopLogger } from "../logger"; type EventHandler = (payload: unknown, actorId?: string, actorType?: string) => void; // Cap how much of an unparseable frame we put into the log. A malformed or // rogue server can stream arbitrarily large garbage, and the warn handler may // be a console / IPC bridge whose buffers we don't want to blow. const UNPARSEABLE_LOG_MAX_CHARS = 200; function summarizeUnparseable(data: unknown): string { const text = typeof data === "string" ? data : String(data); if (text.length <= UNPARSEABLE_LOG_MAX_CHARS) return text; return `${text.slice(0, UNPARSEABLE_LOG_MAX_CHARS)}… (truncated, ${text.length} chars total)`; } /** Identifies the WS client to the server. Sent as `client_platform`, * `client_version`, and `client_os` query parameters on the upgrade URL — * browsers cannot set custom headers on WebSocket handshakes, so query * params are the only portable channel. */ export interface WSClientIdentity { platform?: string; version?: string; os?: string; } export class WSClient { private ws: WebSocket | null = null; private baseUrl: string; private token: string | null = null; private workspaceSlug: string | null = null; private cookieAuth = false; private identity: WSClientIdentity | undefined; private handlers = new Map>(); private reconnectTimer: ReturnType | null = null; private hasConnectedBefore = false; private onReconnectCallbacks = new Set<() => void>(); private anyHandlers = new Set<(msg: WSMessage) => void>(); private logger: Logger; constructor( url: string, options?: { logger?: Logger; cookieAuth?: boolean; identity?: WSClientIdentity; }, ) { this.baseUrl = url; this.logger = options?.logger ?? noopLogger; this.cookieAuth = options?.cookieAuth ?? false; this.identity = options?.identity; } setAuth(token: string | null, workspaceSlug: string) { this.token = token; this.workspaceSlug = workspaceSlug; } connect() { const url = new URL(this.baseUrl); // Token is never sent as a URL query parameter — it would be logged by // proxies, CDNs, and browser history. In cookie mode the HttpOnly cookie // is sent automatically with the upgrade request. In token mode the token // is delivered as the first WebSocket message after the connection opens. if (this.workspaceSlug) url.searchParams.set("workspace_slug", this.workspaceSlug); if (this.identity?.platform) url.searchParams.set("client_platform", this.identity.platform); if (this.identity?.version) url.searchParams.set("client_version", this.identity.version); if (this.identity?.os) url.searchParams.set("client_os", this.identity.os); this.ws = new WebSocket(url.toString()); this.ws.onopen = () => { if (!this.cookieAuth && this.token) { this.ws!.send( JSON.stringify({ type: "auth", payload: { token: this.token } }), ); return; } this.onAuthenticated(); }; this.ws.onmessage = (event) => { let msg: WSMessage; try { msg = JSON.parse(event.data as string) as WSMessage; } catch { this.logger.warn( "ws: received unparseable message", summarizeUnparseable(event.data), ); return; } if ((msg as any).type === "auth_ack") { this.onAuthenticated(); return; } this.logger.debug("received", msg.type); const eventHandlers = this.handlers.get(msg.type); if (eventHandlers) { for (const handler of eventHandlers) { handler(msg.payload, msg.actor_id, msg.actor_type); } } for (const handler of this.anyHandlers) { handler(msg); } }; this.ws.onclose = () => { this.logger.warn("disconnected, reconnecting in 3s"); this.reconnectTimer = setTimeout(() => this.connect(), 3000); }; this.ws.onerror = () => { // Suppress — onclose handles reconnect; errors during StrictMode // double-fire are expected in dev and harmless. }; } private onAuthenticated() { this.logger.info("connected"); if (this.hasConnectedBefore) { for (const cb of this.onReconnectCallbacks) { try { cb(); } catch { // ignore reconnect callback errors } } } this.hasConnectedBefore = true; } disconnect() { if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } if (this.ws) { // Remove handlers before close to prevent onclose from scheduling a reconnect this.ws.onclose = null; this.ws.onerror = null; this.ws.close(); this.ws = null; } this.hasConnectedBefore = false; this.handlers.clear(); this.anyHandlers.clear(); this.onReconnectCallbacks.clear(); } on(event: WSEventType, handler: EventHandler) { if (!this.handlers.has(event)) { this.handlers.set(event, new Set()); } this.handlers.get(event)!.add(handler); return () => { this.handlers.get(event)?.delete(handler); }; } onAny(handler: (msg: WSMessage) => void) { this.anyHandlers.add(handler); return () => { this.anyHandlers.delete(handler); }; } onReconnect(callback: () => void) { this.onReconnectCallbacks.add(callback); return () => { this.onReconnectCallbacks.delete(callback); }; } send(message: WSMessage) { if (this.ws?.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify(message)); } } }