From 058432130628d00764663ecf819a9adc6793768d Mon Sep 17 00:00:00 2001 From: salmonik Date: Sun, 8 Nov 2026 03:29:35 +0100 Subject: v5.0.0: protocol v5 and the browser frontend Protocol v5 replaces newline lines with bounded [u32 len][text] frames and REQ/RES/EVT envelopes. Requests carry correlation ids and the server replays the last 256 replies per connection, so a gateway retry cannot double-debit and a late reply cannot clobber a newer request. HELLO/PONG carry server time; crash rounds announce started_at and push coalesced 10 Hz ticks. Crash now commits sha256(seed) before a round and reveals the seed when it ends, so clients recompute the crash point. Adds the webd/ gateway: one blocking thread, one ClientCore and one pinned upstream TLS connection per session, an HttpOnly session cookie, takeover, and a 60-second grace window. The browser sends Command JSON and renders View JSON; it never speaks the game protocol or stores credentials. Adds the vanilla TypeScript browser app at /play/ (login, lobby, all four games, history, notices, crash verification), the nginx /play and /ws routes, and the headless DOM smoke test. webd and the app stay out of the TUI/GUI/server build graph. The client/server version moves to 5.0.0 in lockstep; v4 peers are refused at HELLO. --- web/play/src/net.ts | 120 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 web/play/src/net.ts (limited to 'web/play/src/net.ts') diff --git a/web/play/src/net.ts b/web/play/src/net.ts new file mode 100644 index 0000000..0a963ba --- /dev/null +++ b/web/play/src/net.ts @@ -0,0 +1,120 @@ +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; + } + } +} -- cgit v1.2.3