some changes
This commit is contained in:
+145
-22
@@ -9,33 +9,83 @@
|
||||
|
||||
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.timer = null;
|
||||
this.reconnectTimer = null;
|
||||
this.pingTimer = null;
|
||||
this.closed = false;
|
||||
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.timer);
|
||||
clearTimeout(this.reconnectTimer);
|
||||
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 });
|
||||
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.socket.addEventListener("message", event => {
|
||||
|
||||
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.onError?.(message.message); this.closed = true; this.socket.close(); return; }
|
||||
if (message.type === "error") {
|
||||
this.intentionalClose = true;
|
||||
this.onError?.(message.message);
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
if (message.type === "authenticated") {
|
||||
this.onStatus?.("online");
|
||||
const restored = this.reconnectAttempt > 0;
|
||||
this.reconnectAttempt = 0;
|
||||
this.onStatus?.("online", { restored });
|
||||
this.onAuthenticated?.(message);
|
||||
this.startPing();
|
||||
return;
|
||||
@@ -51,43 +101,116 @@ class RoomSocket {
|
||||
}
|
||||
}
|
||||
});
|
||||
this.socket.addEventListener("close", event => {
|
||||
|
||||
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.closed });
|
||||
if (!this.closed) { this.onStatus?.("offline"); this.timer = setTimeout(() => this.connect(), 1500); }
|
||||
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));
|
||||
}
|
||||
});
|
||||
this.socket.addEventListener("error", event => {
|
||||
|
||||
socket.addEventListener("error", event => {
|
||||
if (socket !== this.socket || this.stopped) return;
|
||||
logError("websocket.error", event, { kind: this.kind });
|
||||
this.onError?.("Failed to connect to the WebSocket server");
|
||||
this.socket.close();
|
||||
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());
|
||||
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);
|
||||
this.pingTimer = window.setInterval(ping, HEARTBEAT_INTERVAL_MS);
|
||||
}
|
||||
send(message) { if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(JSON.stringify(message)); }
|
||||
|
||||
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.closed = true; clearTimeout(this.timer); clearInterval(this.pingTimer); this.socket?.close(); }
|
||||
|
||||
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)}`; }
|
||||
|
||||
Reference in New Issue
Block a user