import type { Handlers } from "./types"; const MAX_ATTEMPTS = 6; /** * Browser side of the gateway socket. * * The cookie is HttpOnly and handled by the browser; this class never reads or * stores a credential. A transient close reconnects inside the gateway's grace * window; a deliberate takeover stops for good. */ export class Gateway { private socket: WebSocket | null = null; private stopped = false; private replaced = false; private attempts = 0; private timer: number | undefined; constructor( private readonly url: string, private readonly handlers: Handlers, ) {} connect(): void { if (this.stopped) return; this.clearTimer(); let socket: WebSocket; try { socket = new WebSocket(this.url); } catch { const retrying = this.scheduleReconnect(); if (!retrying) this.handlers.onClosed(false); return; } this.socket = socket; socket.addEventListener("open", () => { this.attempts = 0; this.handlers.onOpen(); }); socket.addEventListener("message", (event) => { let message: any; try { message = JSON.parse(String(event.data)); } catch { return; } switch (message.type) { case "hello": this.handlers.onHello(message); break; case "view": this.handlers.onView(message.view); break; case "notice": this.handlers.onNotice(String(message.text ?? "")); break; case "error": this.handlers.onError(String(message.text ?? "")); break; case "replaced": this.replaced = true; this.handlers.onReplaced(); break; case "pong": this.handlers.onPong(); break; } }); socket.addEventListener("close", () => { const wasThisSocket = this.socket === socket; if (wasThisSocket) this.socket = null; if (this.replaced || this.stopped) { this.handlers.onClosed(false); return; } const retrying = this.scheduleReconnect(); this.handlers.onClosed(retrying); }); socket.addEventListener("error", () => { // The close event follows; reconnect is scheduled there. }); } send(command: unknown): void { if (this.socket?.readyState !== WebSocket.OPEN) return; this.socket.send(JSON.stringify({ type: "command", command })); } ping(): void { if (this.socket?.readyState === WebSocket.OPEN) { this.socket.send(JSON.stringify({ type: "ping" })); } } close(): void { this.stopped = true; this.clearTimer(); this.socket?.close(); } private scheduleReconnect(): boolean { if (this.stopped || this.replaced || this.attempts >= MAX_ATTEMPTS) return false; this.attempts += 1; const delay = Math.min(250 * 2 ** (this.attempts - 1), 4000); this.timer = window.setTimeout(() => this.connect(), delay); return true; } private clearTimer(): void { if (this.timer !== undefined) { window.clearTimeout(this.timer); this.timer = undefined; } } }