/* * 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"; class RoomSocket { constructor(options) { Object.assign(this, options); this.socket = null; this.timer = null; this.pingTimer = null; this.closed = false; this.pendingPings = new Map(); } get url() { throw new Error("Socket URL not implemented"); } get kind() { return "room"; } connect() { clearTimeout(this.timer); clearInterval(this.pingTimer); this.closed = false; this.onStatus?.("connecting"); this.socket = new WebSocket(this.url); this.socket.addEventListener("open", () => { 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 }); }); this.socket.addEventListener("message", event => { let message; try { message = JSON.parse(event.data); } catch { return; } if (message.type === "error") { this.onError?.(message.message); this.closed = true; this.socket.close(); return; } if (message.type === "authenticated") { this.onStatus?.("online"); 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))); } } }); this.socket.addEventListener("close", event => { 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.closed }); if (!this.closed) { this.onStatus?.("offline"); this.timer = setTimeout(() => this.connect(), 1500); } }); this.socket.addEventListener("error", event => { logError("websocket.error", event, { kind: this.kind }); this.onError?.("Failed to connect to the WebSocket server"); this.socket.close(); }); } startPing() { clearInterval(this.pingTimer); const ping = () => { if (this.socket?.readyState !== WebSocket.OPEN) return; const nonce = Date.now(); this.pendingPings.set(nonce, performance.now()); for (const key of this.pendingPings.keys()) if (key < nonce - 30000) this.pendingPings.delete(key); this.send({ type: "ping", nonce }); }; ping(); this.pingTimer = setInterval(ping, 10000); } send(message) { if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(JSON.stringify(message)); } 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.closed = true; clearTimeout(this.timer); clearInterval(this.pingTimer); 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)}`; } }