UNPKG

rwsdk

Version:

Build fast, server-driven webapps on Cloudflare with SSR, RSC, and realtime

154 lines (153 loc) 6.1 kB
/** * Owns all cross-request state for the hibernation sync-state client: * cached clients, open connections, active subscriptions, status listeners, * and per-endpoint reconnection backoff. * * A single module-level instance is used because the client cache and * subscription registry must survive across React renders and component * unmount/mount cycles on the same page. */ export class SyncedStateClientManager { constructor() { this.clients = new Map(); this.connections = new Map(); this.subscriptions = new Set(); this.statusListeners = new Map(); this.backoff = new Map(); // ------------------------------------------------------------------------- // Status listeners // ------------------------------------------------------------------------- this.notifyStatusChange = (endpoint, status) => { const listeners = this.statusListeners.get(endpoint); if (!listeners) return; for (const cb of [...listeners]) cb(status); }; this.onStatusChange = (endpoint, callback) => { const normalized = this.normalizeEndpoint(endpoint); let listeners = this.statusListeners.get(normalized); if (!listeners) { listeners = []; this.statusListeners.set(normalized, listeners); } listeners.push(callback); return () => { const idx = listeners.indexOf(callback); if (idx !== -1) listeners.splice(idx, 1); if (listeners.length === 0) this.statusListeners.delete(normalized); }; }; this.beforeUnload = () => { if (this.subscriptions.size === 0) return; const subscriptions = Array.from(this.subscriptions); this.subscriptions.clear(); for (const { key, handler, client } of subscriptions) { void client.unsubscribe(key, handler).catch(() => { }); } }; } normalizeEndpoint(endpoint) { if (endpoint.startsWith("/") && typeof window !== "undefined") { const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; // Strip a trailing DNS root dot so the WebSocket URL matches routing. const host = window.location.host.replace(/\.$/, ""); return `${protocol}//${host}${endpoint}`; } return endpoint; } // ------------------------------------------------------------------------- // Clients // ------------------------------------------------------------------------- getClient(endpoint) { return this.clients.get(endpoint); } setClient(endpoint, client) { this.clients.set(endpoint, client); } deleteClient(endpoint) { this.clients.delete(endpoint); } // ------------------------------------------------------------------------- // Connections // ------------------------------------------------------------------------- getConnection(endpoint) { return this.connections.get(endpoint); } setConnection(endpoint, connection) { this.connections.set(endpoint, connection); } deleteConnection(endpoint) { this.connections.delete(endpoint); } // ------------------------------------------------------------------------- // Backoff // ------------------------------------------------------------------------- getBackoff(endpoint) { return this.backoff.get(endpoint) ?? { attempt: 0, timer: null }; } setBackoff(endpoint, state) { this.backoff.set(endpoint, state); } resetBackoff(endpoint) { const state = this.getBackoff(endpoint); if (state.timer !== null) { clearTimeout(state.timer); } this.backoff.set(endpoint, { attempt: 0, timer: null }); } // ------------------------------------------------------------------------- // Subscriptions // ------------------------------------------------------------------------- addSubscription(key, handler, client) { const exists = [...this.subscriptions].some((s) => s.key === key && s.handler === handler && s.client === client); if (!exists) this.subscriptions.add({ key, handler, client }); } removeSubscription(key, handler, client) { for (const sub of [...this.subscriptions]) { if (sub.key === key && sub.handler === handler && sub.client === client) { this.subscriptions.delete(sub); } } } subscriptionsForClient(client) { return [...this.subscriptions].filter((s) => s.client === client); } clearSubscriptions() { this.subscriptions.clear(); } // ------------------------------------------------------------------------- // Global reset (used by tests and beforeunload) // ------------------------------------------------------------------------- clearForTesting(endpoint) { const normalized = this.normalizeEndpoint(endpoint); const connection = this.getConnection(normalized); if (connection) { if (connection.pendingRequestTimer) { clearTimeout(connection.pendingRequestTimer); connection.pendingRequestTimer = null; } try { connection.ws.close(); } catch { } this.deleteConnection(normalized); } for (const state of this.backoff.values()) { if (state.timer !== null) clearTimeout(state.timer); } this.clients.delete(normalized); this.backoff.clear(); this.statusListeners.clear(); this.subscriptions.clear(); } } export const manager = new SyncedStateClientManager(); if (typeof window !== "undefined") { window.addEventListener("beforeunload", manager.beforeUnload); }