rwsdk
Version:
Build fast, server-driven webapps on Cloudflare with SSR, RSC, and realtime
104 lines (103 loc) • 4.22 kB
JavaScript
import { newWebSocketRpcSession } from "capnweb";
import { DEFAULT_SYNCED_STATE_PATH } from "./constants.mjs";
// Map of endpoint URLs to their respective clients
const clientCache = new Map();
const activeSubscriptions = new Set();
// Set up beforeunload handler to unsubscribe all active subscriptions
if (typeof window !== "undefined") {
const handleBeforeUnload = () => {
if (activeSubscriptions.size === 0) {
return;
}
// Unsubscribe all active subscriptions
// Use a synchronous approach where possible, but don't block page unload
const subscriptions = Array.from(activeSubscriptions);
activeSubscriptions.clear();
// Fire-and-forget unsubscribe calls - we can't await during beforeunload
for (const { key, handler, client } of subscriptions) {
void client.unsubscribe(key, handler).catch(() => {
// Ignore errors during page unload - the connection will be closed anyway
});
}
};
window.addEventListener("beforeunload", handleBeforeUnload);
}
/**
* Returns a cached client for the provided endpoint, creating it when necessary.
* The client is wrapped to track subscriptions for cleanup on page reload.
* @param endpoint Endpoint to connect to.
* @returns RPC client instance.
*/
export const getSyncedStateClient = (endpoint = DEFAULT_SYNCED_STATE_PATH) => {
// Return existing client if already cached for this endpoint
const existingClient = clientCache.get(endpoint);
if (existingClient) {
return existingClient;
}
const baseClient = newWebSocketRpcSession(endpoint);
// Wrap the client using a Proxy to track subscriptions
// The RPC client uses dynamic property access, so we can't use .bind()
const wrappedClient = new Proxy(baseClient, {
get(target, prop) {
if (prop === "subscribe") {
return async (key, handler) => {
const subscription = {
key,
handler,
client: wrappedClient,
};
activeSubscriptions.add(subscription);
return target[prop](key, handler);
};
}
if (prop === "unsubscribe") {
return async (key, handler) => {
// Find and remove the subscription
for (const sub of activeSubscriptions) {
if (sub.key === key &&
sub.handler === handler &&
sub.client === wrappedClient) {
activeSubscriptions.delete(sub);
break;
}
}
return target[prop](key, handler);
};
}
// Pass through all other properties/methods
return target[prop];
},
});
// Cache the client for this endpoint
clientCache.set(endpoint, wrappedClient);
return wrappedClient;
};
/**
* Initializes and caches an RPC client instance for the sync state endpoint.
* The client is wrapped to track subscriptions for cleanup on page reload.
* @param options Optional endpoint override.
* @returns Cached client instance or `null` when running without `window`.
*/
export const initSyncedStateClient = (options = {}) => {
const endpoint = options.endpoint ?? DEFAULT_SYNCED_STATE_PATH;
if (typeof window === "undefined") {
return null;
}
// Use getSyncedStateClient which now handles caching via Map
return getSyncedStateClient(endpoint);
};
/**
* Injects a client instance for tests and updates the cached endpoint.
* Also clears the subscription registry for test isolation.
* @param client Stub client instance or `null` to clear the cache.
* @param endpoint Endpoint associated with the injected client.
*/
export const setSyncedStateClientForTesting = (client, endpoint = DEFAULT_SYNCED_STATE_PATH) => {
if (client) {
clientCache.set(endpoint, client);
}
else {
clientCache.delete(endpoint);
}
activeSubscriptions.clear();
};