rwsdk
Version:
Build fast, server-driven webapps on Cloudflare with SSR, RSC, and realtime
154 lines (153 loc) • 6.1 kB
JavaScript
/**
* 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);
}