/* * Copyright (C) 2026 Mateusz GruszczyƄski @linuxiarz.pl * Source-Available Code / Dual-Licensed. * * Free for non-commercial and evaluation use under terms of BSL/GPLv3. * Commercial or production use requires a valid paid license. * See LICENSE file in repository root for details. */ import { logError, logInfo, logWarn } from "@rustpad/logger"; const HEARTBEAT_INTERVAL_MS = 10000; const HEARTBEAT_TIMEOUT_MS = 30000; const MAX_RECONNECT_DELAY_MS = 12000; class RoomSocket { constructor(options) { Object.assign(this, options); this.socket = null; this.reconnectTimer = null; this.pingTimer = null; this.stopped = false; this.intentionalClose = false; this.reconnectAttempt = 0; this.pendingPings = new Map(); this.handleOnline = () => this.reconnectNow("Network connection restored."); this.handleOffline = () => this.handleNetworkOffline(); this.handleVisibility = () => this.checkHeartbeat(); window.addEventListener("online", this.handleOnline); window.addEventListener("offline", this.handleOffline); document.addEventListener("visibilitychange", this.handleVisibility); } get url() { throw new Error("Socket URL not implemented"); } get kind() { return "room"; } connect() { clearTimeout(this.reconnectTimer); clearInterval(this.pingTimer); if (this.stopped) return; if (!navigator.onLine) { this.scheduleReconnect("Your device is offline."); return; } this.intentionalClose = false; this.onStatus?.(this.reconnectAttempt ? "reconnecting" : "connecting", { attempt: this.reconnectAttempt, message: this.reconnectAttempt ? "Re-establishing the live connection." : "Opening the live connection.", }); let socket; try { socket = new WebSocket(this.url); } catch (error) { logError("websocket.create", error, { kind: this.kind }); this.scheduleReconnect("The live connection could not be opened."); return; } this.socket = socket; socket.addEventListener("open", () => { if (socket !== this.socket || this.stopped) return; logInfo("websocket.open", { kind: this.kind }); this.send({ type: "authenticate", password: this.password || null, access_token: this.accessToken || null, nickname: this.nickname || null, guest_id: this.guestId || null, color: this.color || null, }); }); socket.addEventListener("message", event => { if (socket !== this.socket || this.stopped) return; let message; try { message = JSON.parse(event.data); } catch { return; } if (message.type === "error") { this.intentionalClose = true; this.onError?.(message.message); socket.close(); return; } if (message.type === "authenticated") { const restored = this.reconnectAttempt > 0; this.reconnectAttempt = 0; this.onStatus?.("online", { restored }); this.onAuthenticated?.(message); this.startPing(); return; } if (message.type === "document") this.onDocument?.(message); if (message.type === "presence") this.onPresence?.(message.users || []); if (message.type === "chat") this.onChat?.(message); if (message.type === "pong") { const started = this.pendingPings.get(message.nonce); if (started !== undefined) { this.pendingPings.delete(message.nonce); this.onLatency?.(Math.max(0, Math.round(performance.now() - started))); } } }); socket.addEventListener("close", event => { if (socket !== this.socket) return; clearInterval(this.pingTimer); this.pendingPings.clear(); this.onPresence?.([]); this.onLatency?.(null); logWarn("websocket.close", { kind: this.kind, code: event.code, reason: event.reason || "", intentional: this.intentionalClose || this.stopped, }); if (!this.intentionalClose && !this.stopped) { this.scheduleReconnect(this.closeMessage(event)); } }); socket.addEventListener("error", event => { if (socket !== this.socket || this.stopped) return; logError("websocket.error", event, { kind: this.kind }); if (socket.readyState !== WebSocket.CLOSING && socket.readyState !== WebSocket.CLOSED) { socket.close(); } }); } closeMessage(event) { if (!navigator.onLine) return "Your device is offline."; if (event.reason === "heartbeat timeout") return "The connection stopped responding after the tab was inactive."; return "The server connection was interrupted."; } scheduleReconnect(message) { if (this.stopped) return; clearTimeout(this.reconnectTimer); this.reconnectAttempt += 1; const baseDelay = Math.min(MAX_RECONNECT_DELAY_MS, 750 * (2 ** Math.min(this.reconnectAttempt - 1, 4))); const retryInMs = navigator.onLine ? baseDelay : 3000; this.onStatus?.("reconnecting", { attempt: this.reconnectAttempt, message, retryInMs }); this.reconnectTimer = window.setTimeout(() => this.connect(), retryInMs); } reconnectNow(message) { if (this.stopped || this.socket?.readyState === WebSocket.OPEN || this.socket?.readyState === WebSocket.CONNECTING) return; clearTimeout(this.reconnectTimer); this.onStatus?.("reconnecting", { attempt: this.reconnectAttempt, message, retryInMs: 0 }); this.connect(); } handleNetworkOffline() { if (this.stopped) return; this.onStatus?.("reconnecting", { attempt: this.reconnectAttempt + 1, message: "Your device is offline.", retryInMs: 3000 }); if (this.socket?.readyState === WebSocket.OPEN || this.socket?.readyState === WebSocket.CONNECTING) { this.socket.close(); } else { this.scheduleReconnect("Your device is offline."); } } checkHeartbeat() { if (this.stopped || this.socket?.readyState !== WebSocket.OPEN) return; const now = performance.now(); const expired = [...this.pendingPings.values()].some(started => now - started >= HEARTBEAT_TIMEOUT_MS); if (expired) { logWarn("websocket.heartbeat_timeout", { kind: this.kind }); this.socket.close(4000, "heartbeat timeout"); } } startPing() { clearInterval(this.pingTimer); const ping = () => { this.checkHeartbeat(); if (this.socket?.readyState !== WebSocket.OPEN) return; const nonce = Date.now(); this.pendingPings.set(nonce, performance.now()); this.send({ type: "ping", nonce }); }; ping(); this.pingTimer = window.setInterval(ping, HEARTBEAT_INTERVAL_MS); } send(message) { if (this.socket?.readyState !== WebSocket.OPEN) return false; this.socket.send(JSON.stringify(message)); return true; } update(content, ownerMap = "[]") { this.send({ type: "update", content, owner_map: ownerMap }); } chat(text) { this.send({ type: "chat", text }); } setColor(color) { this.color = color || null; this.send({ type: "set_color", color: this.color }); } stop() { this.stopped = true; this.intentionalClose = true; clearTimeout(this.reconnectTimer); clearInterval(this.pingTimer); window.removeEventListener("online", this.handleOnline); window.removeEventListener("offline", this.handleOffline); document.removeEventListener("visibilitychange", this.handleVisibility); this.socket?.close(); } } export class NoteSocket extends RoomSocket { get kind() { return "note"; } get url() { const p = location.protocol === "https:" ? "wss:" : "ws:"; return `${p}//${location.host}/ws/${encodeURIComponent(this.workspaceSlug)}/${encodeURIComponent(this.noteSlug)}`; } } export class PadSocket extends RoomSocket { get kind() { return "pad"; } get url() { const p = location.protocol === "https:" ? "wss:" : "ws:"; return `${p}//${location.host}/ws/p/${encodeURIComponent(this.slug)}`; } }