tokens and more
This commit is contained in:
+139
-8
@@ -9,9 +9,10 @@
|
||||
|
||||
import { logError, logInfo, logWarn } from "@rustpad/logger";
|
||||
|
||||
const HEARTBEAT_INTERVAL_MS = 10000;
|
||||
const HEARTBEAT_TIMEOUT_MS = 30000;
|
||||
const MAX_RECONNECT_DELAY_MS = 12000;
|
||||
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) {
|
||||
@@ -23,6 +24,21 @@ class RoomSocket {
|
||||
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.connectedAt = null;
|
||||
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();
|
||||
@@ -34,9 +50,23 @@ class RoomSocket {
|
||||
get url() { throw new Error("Socket URL not implemented"); }
|
||||
get kind() { return "room"; }
|
||||
|
||||
clientDiagnostics() {
|
||||
const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
|
||||
return {
|
||||
language: navigator.language || null,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || null,
|
||||
platform: navigator.userAgentData?.platform || navigator.platform || null,
|
||||
effective_type: connection?.effectiveType || null,
|
||||
downlink_mbps: Number.isFinite(connection?.downlink) ? connection.downlink : null,
|
||||
network_rtt_ms: Number.isFinite(connection?.rtt) ? Math.max(0, Math.round(connection.rtt)) : null,
|
||||
save_data: typeof connection?.saveData === "boolean" ? connection.saveData : null,
|
||||
};
|
||||
}
|
||||
|
||||
connect() {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
clearInterval(this.pingTimer);
|
||||
clearInterval(this.diagnosticsTimer);
|
||||
if (this.stopped) return;
|
||||
if (!navigator.onLine) {
|
||||
this.scheduleReconnect("Your device is offline.");
|
||||
@@ -48,6 +78,7 @@ class RoomSocket {
|
||||
attempt: this.reconnectAttempt,
|
||||
message: this.reconnectAttempt ? "Re-establishing the live connection." : "Opening the live connection.",
|
||||
});
|
||||
this.emitDiagnostics();
|
||||
|
||||
let socket;
|
||||
try {
|
||||
@@ -62,6 +93,9 @@ class RoomSocket {
|
||||
socket.addEventListener("open", () => {
|
||||
if (socket !== this.socket || this.stopped) return;
|
||||
logInfo("websocket.open", { kind: this.kind });
|
||||
this.connectedAt = Date.now();
|
||||
this.serverDiagnostics = null;
|
||||
this.lastClose = null;
|
||||
this.send({
|
||||
type: "authenticate",
|
||||
password: this.password || null,
|
||||
@@ -69,13 +103,17 @@ class RoomSocket {
|
||||
nickname: this.nickname || null,
|
||||
guest_id: this.guestId || null,
|
||||
color: this.color || null,
|
||||
diagnostics: this.clientDiagnostics(),
|
||||
});
|
||||
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 === "error") {
|
||||
this.intentionalClose = true;
|
||||
this.onError?.(message.message);
|
||||
@@ -85,9 +123,17 @@ class RoomSocket {
|
||||
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);
|
||||
@@ -97,7 +143,11 @@ class RoomSocket {
|
||||
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)));
|
||||
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();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -105,9 +155,16 @@ class RoomSocket {
|
||||
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.connectedAt = null;
|
||||
this.authenticatedAt = null;
|
||||
this.lastClose = { code: event.code, reason: event.reason || "", at: closedAt };
|
||||
this.emitDiagnostics();
|
||||
logWarn("websocket.close", {
|
||||
kind: this.kind,
|
||||
code: event.code,
|
||||
@@ -138,9 +195,11 @@ class RoomSocket {
|
||||
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 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);
|
||||
}
|
||||
|
||||
@@ -164,7 +223,7 @@ class RoomSocket {
|
||||
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);
|
||||
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");
|
||||
@@ -181,12 +240,83 @@ class RoomSocket {
|
||||
this.send({ type: "ping", nonce });
|
||||
};
|
||||
ping();
|
||||
this.pingTimer = window.setInterval(ping, HEARTBEAT_INTERVAL_MS);
|
||||
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.onServerDiagnostics?.(diagnostics);
|
||||
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 connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
|
||||
const readyStates = ["connecting", "open", "closing", "closed"];
|
||||
this.onDiagnostics?.({
|
||||
server: this.serverDiagnostics,
|
||||
runtime: {
|
||||
state: readyStates[this.socket?.readyState ?? WebSocket.CLOSED] || "closed",
|
||||
online: navigator.onLine,
|
||||
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,
|
||||
connected_at: this.connectedAt,
|
||||
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,
|
||||
network: {
|
||||
effective_type: connection?.effectiveType || null,
|
||||
downlink_mbps: Number.isFinite(connection?.downlink) ? connection.downlink : null,
|
||||
rtt_ms: Number.isFinite(connection?.rtt) ? connection.rtt : null,
|
||||
save_data: typeof connection?.saveData === "boolean" ? connection.saveData : null,
|
||||
},
|
||||
latency: this.latencyStats(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
send(message) {
|
||||
if (this.socket?.readyState !== WebSocket.OPEN) return false;
|
||||
this.socket.send(JSON.stringify(message));
|
||||
const payload = JSON.stringify(message);
|
||||
this.bytesSent += new Blob([payload]).size;
|
||||
this.socket.send(payload);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -199,6 +329,7 @@ class RoomSocket {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user