Files
2026-08-05 09:53:28 +02:00

353 lines
14 KiB
JavaScript

/*
* 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 DEFAULT_HEARTBEAT_INTERVAL_MS = 10000;
const DEFAULT_HEARTBEAT_TIMEOUT_MS = 30000;
const DEFAULT_MAX_RECONNECT_DELAY_MS = 12000;
const DEFAULT_LATENCY_SAMPLE_WINDOW = 20;
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.heartbeatIntervalMs = DEFAULT_HEARTBEAT_INTERVAL_MS;
this.heartbeatTimeoutMs = DEFAULT_HEARTBEAT_TIMEOUT_MS;
this.maxReconnectDelayMs = DEFAULT_MAX_RECONNECT_DELAY_MS;
this.latencySampleWindow = DEFAULT_LATENCY_SAMPLE_WINDOW;
this.latencySamples = [];
this.totalReconnects = 0;
this.authenticatedAt = null;
this.lastConnectionUptimeMs = 0;
this.lastMessageAt = null;
this.lastClose = null;
this.bytesSent = 0;
this.bytesReceived = 0;
this.serverDiagnostics = null;
this.diagnosticsTimer = null;
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"; }
clientDiagnostics() {
return {
language: navigator.language || null,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || null,
platform: navigator.userAgentData?.platform || navigator.platform || null,
};
}
connect() {
clearTimeout(this.reconnectTimer);
clearInterval(this.pingTimer);
clearInterval(this.diagnosticsTimer);
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.",
});
this.emitDiagnostics();
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.serverDiagnostics = null;
this.lastClose = null;
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,
diagnostics: this.clientDiagnostics(),
client_id: this.clientId || null,
known_revision_id: this.getKnownRevision?.() ?? null,
});
this.emitDiagnostics();
});
socket.addEventListener("message", event => {
if (socket !== this.socket || this.stopped) return;
let message;
try { message = JSON.parse(event.data); } catch { return; }
this.lastMessageAt = Date.now();
this.bytesReceived += typeof event.data === "string" ? new Blob([event.data]).size : Number(event.data?.byteLength || 0);
if (message.type === "password_required") {
this.intentionalClose = true;
this.onPasswordRequired?.();
socket.close();
return;
}
if (message.type === "password_changed") {
this.intentionalClose = true;
socket.close();
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.authenticatedAt = Date.now();
this.latencySamples = [];
this.onStatus?.("online", { restored });
this.onAuthenticated?.(message);
this.startPing();
this.startDiagnostics();
this.emitDiagnostics();
return;
}
if (message.type === "diagnostics") {
this.applyServerDiagnostics(message.diagnostics || {});
return;
}
if (message.type === "document") this.onDocument?.(message);
if (message.type === "resync") this.onResync?.(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);
const latency = Math.max(0, Math.round(performance.now() - started));
this.latencySamples.push(latency);
while (this.latencySamples.length > this.latencySampleWindow) this.latencySamples.shift();
this.onLatency?.(latency);
this.emitDiagnostics();
}
}
});
socket.addEventListener("close", event => {
if (socket !== this.socket) return;
clearInterval(this.pingTimer);
clearInterval(this.diagnosticsTimer);
this.pendingPings.clear();
this.onPresence?.([]);
this.onLatency?.(null);
const closedAt = Date.now();
this.lastConnectionUptimeMs = this.authenticatedAt ? Math.max(0, closedAt - this.authenticatedAt) : this.lastConnectionUptimeMs;
this.authenticatedAt = null;
this.lastClose = { code: event.code, reason: event.reason || "", at: closedAt };
this.emitDiagnostics();
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(this.maxReconnectDelayMs, 750 * (2 ** Math.min(this.reconnectAttempt - 1, 4)));
const retryInMs = navigator.onLine ? baseDelay : 3000;
this.totalReconnects += 1;
this.onStatus?.("reconnecting", { attempt: this.reconnectAttempt, message, retryInMs });
this.emitDiagnostics();
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 >= this.heartbeatTimeoutMs);
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, this.heartbeatIntervalMs);
}
startDiagnostics() {
clearInterval(this.diagnosticsTimer);
this.diagnosticsTimer = window.setInterval(() => this.emitDiagnostics(), 1000);
}
applyServerDiagnostics(diagnostics) {
this.serverDiagnostics = diagnostics;
const positiveNumber = (value, fallback, min, max) => {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed >= min && parsed <= max ? parsed : fallback;
};
this.heartbeatIntervalMs = positiveNumber(diagnostics.heartbeat_interval_ms, this.heartbeatIntervalMs, 1000, 120000);
this.heartbeatTimeoutMs = positiveNumber(diagnostics.heartbeat_timeout_ms, this.heartbeatTimeoutMs, this.heartbeatIntervalMs * 2, 300000);
this.maxReconnectDelayMs = positiveNumber(diagnostics.max_reconnect_delay_ms, this.maxReconnectDelayMs, 1000, 120000);
this.latencySampleWindow = Math.round(positiveNumber(diagnostics.latency_sample_window, this.latencySampleWindow, 3, 100));
while (this.latencySamples.length > this.latencySampleWindow) this.latencySamples.shift();
if (this.socket?.readyState === WebSocket.OPEN && this.authenticatedAt) this.startPing();
this.emitDiagnostics();
}
latencyStats() {
if (!this.latencySamples.length) return { current: null, average: null, minimum: null, maximum: null, jitter: null, quality: "unknown" };
const samples = this.latencySamples;
const current = samples.at(-1);
const average = Math.round(samples.reduce((sum, value) => sum + value, 0) / samples.length);
const minimum = Math.min(...samples);
const maximum = Math.max(...samples);
const differences = samples.slice(1).map((value, index) => Math.abs(value - samples[index]));
const jitter = differences.length ? Math.round(differences.reduce((sum, value) => sum + value, 0) / differences.length) : 0;
const thresholds = this.serverDiagnostics?.quality_thresholds || {};
const excellent = Number(thresholds.excellent_max_ms ?? 100);
const good = Number(thresholds.good_max_ms ?? 250);
const degraded = Number(thresholds.degraded_max_ms ?? 600);
const quality = current <= excellent ? "excellent" : current <= good ? "good" : current <= degraded ? "degraded" : "poor";
return { current, average, minimum, maximum, jitter, quality };
}
emitDiagnostics() {
const readyStates = ["connecting", "open", "closing", "closed"];
this.onDiagnostics?.({
server: this.serverDiagnostics,
runtime: {
state: readyStates[this.socket?.readyState ?? WebSocket.CLOSED] || "closed",
visibility: document.visibilityState,
uptime_ms: this.authenticatedAt ? Math.max(0, Date.now() - this.authenticatedAt) : 0,
last_connection_uptime_ms: this.lastConnectionUptimeMs,
reconnect_attempt: this.reconnectAttempt,
total_reconnects: this.totalReconnects,
authenticated_at: this.authenticatedAt,
last_message_at: this.lastMessageAt,
last_close: this.lastClose,
buffered_amount: this.socket?.bufferedAmount || 0,
bytes_sent: this.bytesSent,
bytes_received: this.bytesReceived,
latency: this.latencyStats(),
},
});
}
send(message) {
if (this.socket?.readyState !== WebSocket.OPEN) return false;
const payload = JSON.stringify(message);
this.bytesSent += new Blob([payload]).size;
this.socket.send(payload);
return true;
}
update(baseRevisionId, updateId, operation, ownerReplacements = []) {
return this.send({
type: "update",
base_revision_id: baseRevisionId,
update_id: updateId,
operation,
owner_replacements: ownerReplacements,
});
}
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);
clearInterval(this.diagnosticsTimer);
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)}`; }
}