rwsdk 1.1.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,303 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi, } from "vitest";
2
+ const mockClients = [];
3
+ const { newWebSocketRpcSession } = vi.hoisted(() => {
4
+ return {
5
+ newWebSocketRpcSession: vi.fn(),
6
+ };
7
+ });
8
+ vi.mock("capnweb", () => ({
9
+ newWebSocketRpcSession,
10
+ }));
11
+ function makeMockClient() {
12
+ let brokenCb = null;
13
+ const client = {
14
+ getState: vi.fn().mockResolvedValue(undefined),
15
+ setState: vi.fn().mockResolvedValue(undefined),
16
+ subscribe: vi.fn().mockResolvedValue(undefined),
17
+ unsubscribe: vi.fn().mockResolvedValue(undefined),
18
+ onRpcBroken: vi.fn((cb) => {
19
+ brokenCb = cb;
20
+ }),
21
+ // Helper to simulate a connection break from tests
22
+ simulateBreak(error = new Error("connection lost")) {
23
+ brokenCb?.(error);
24
+ },
25
+ };
26
+ mockClients.push(client);
27
+ return client;
28
+ }
29
+ import { getSyncedStateClient, onStatusChange, __testing, } from "../client-core";
30
+ const ENDPOINT = "wss://test.example.com/__synced-state";
31
+ describe("client-core reconnection", () => {
32
+ beforeEach(() => {
33
+ vi.useFakeTimers();
34
+ mockClients.length = 0;
35
+ newWebSocketRpcSession.mockReset();
36
+ newWebSocketRpcSession.mockImplementation(() => makeMockClient());
37
+ // Clear all internal state between tests
38
+ __testing.clientCache.clear();
39
+ __testing.activeSubscriptions.clear();
40
+ for (const [, state] of __testing.backoffState) {
41
+ if (state.timer !== null)
42
+ clearTimeout(state.timer);
43
+ }
44
+ __testing.backoffState.clear();
45
+ });
46
+ afterEach(() => {
47
+ __testing.clientCache.clear();
48
+ __testing.activeSubscriptions.clear();
49
+ __testing.backoffState.clear();
50
+ __testing.statusListeners.clear();
51
+ vi.useRealTimers();
52
+ });
53
+ it("registers onRpcBroken callback when creating a client", async () => {
54
+ getSyncedStateClient(ENDPOINT);
55
+ await __testing.warmUp(ENDPOINT);
56
+ expect(mockClients).toHaveLength(1);
57
+ expect(mockClients[0].onRpcBroken).toHaveBeenCalledOnce();
58
+ });
59
+ it("creates a new client after connection breaks", async () => {
60
+ getSyncedStateClient(ENDPOINT);
61
+ await __testing.warmUp(ENDPOINT);
62
+ expect(mockClients).toHaveLength(1);
63
+ // Simulate connection break
64
+ mockClients[0].simulateBreak();
65
+ // Reconnect happens after backoff timer fires
66
+ vi.runOnlyPendingTimers();
67
+ await __testing.warmUp(ENDPOINT);
68
+ expect(mockClients).toHaveLength(2);
69
+ });
70
+ it("does not reconnect immediately — waits for backoff", async () => {
71
+ getSyncedStateClient(ENDPOINT);
72
+ await __testing.warmUp(ENDPOINT);
73
+ mockClients[0].simulateBreak();
74
+ // Before the timer fires, no new session yet
75
+ expect(mockClients).toHaveLength(1);
76
+ // After timer fires, reconnect happens
77
+ vi.runOnlyPendingTimers();
78
+ await __testing.warmUp(ENDPOINT);
79
+ expect(mockClients).toHaveLength(2);
80
+ });
81
+ it("re-subscribes active subscriptions after reconnect", async () => {
82
+ const client = getSyncedStateClient(ENDPOINT);
83
+ const handler = vi.fn();
84
+ await client.subscribe("counter", handler);
85
+ // Simulate connection break
86
+ mockClients[0].simulateBreak();
87
+ vi.runOnlyPendingTimers();
88
+ await __testing.warmUp(ENDPOINT);
89
+ // The new client should have subscribe called with the same key and handler
90
+ const newClient = mockClients[1];
91
+ expect(newClient.subscribe).toHaveBeenCalledWith("counter", handler);
92
+ });
93
+ it("fetches latest state for each subscription after reconnect", async () => {
94
+ const client = getSyncedStateClient(ENDPOINT);
95
+ const handler = vi.fn();
96
+ await client.subscribe("counter", handler);
97
+ mockClients[0].simulateBreak();
98
+ vi.runOnlyPendingTimers();
99
+ await __testing.warmUp(ENDPOINT);
100
+ const newClient = mockClients[1];
101
+ expect(newClient.getState).toHaveBeenCalledWith("counter");
102
+ });
103
+ it("calls handler with fetched state when value is not undefined", async () => {
104
+ const client = getSyncedStateClient(ENDPOINT);
105
+ const handler = vi.fn();
106
+ await client.subscribe("counter", handler);
107
+ // Next client will return a value for getState
108
+ newWebSocketRpcSession.mockImplementationOnce(() => {
109
+ const c = makeMockClient();
110
+ c.getState.mockResolvedValue(42);
111
+ return c;
112
+ });
113
+ mockClients[0].simulateBreak();
114
+ vi.runOnlyPendingTimers();
115
+ await __testing.warmUp(ENDPOINT);
116
+ // Allow the getState promise to resolve
117
+ await vi.runAllTimersAsync();
118
+ expect(handler).toHaveBeenCalledWith(42);
119
+ });
120
+ it("does not call handler when fetched state is undefined", async () => {
121
+ const client = getSyncedStateClient(ENDPOINT);
122
+ const handler = vi.fn();
123
+ await client.subscribe("counter", handler);
124
+ handler.mockClear();
125
+ mockClients[0].simulateBreak();
126
+ vi.runOnlyPendingTimers();
127
+ await __testing.warmUp(ENDPOINT);
128
+ // Default mock returns undefined for getState
129
+ await vi.runAllTimersAsync();
130
+ expect(handler).not.toHaveBeenCalled();
131
+ });
132
+ it("does not re-subscribe keys that were unsubscribed before reconnect", async () => {
133
+ const client = getSyncedStateClient(ENDPOINT);
134
+ const handler = vi.fn();
135
+ await client.subscribe("counter", handler);
136
+ await client.unsubscribe("counter", handler);
137
+ mockClients[0].simulateBreak();
138
+ vi.runOnlyPendingTimers();
139
+ await __testing.warmUp(ENDPOINT);
140
+ const newClient = mockClients[1];
141
+ expect(newClient.subscribe).not.toHaveBeenCalled();
142
+ });
143
+ it("does not schedule multiple reconnects for the same endpoint", async () => {
144
+ getSyncedStateClient(ENDPOINT);
145
+ await __testing.warmUp(ENDPOINT);
146
+ // Fire broken twice rapidly
147
+ mockClients[0].simulateBreak();
148
+ mockClients[0].simulateBreak();
149
+ vi.runOnlyPendingTimers();
150
+ await __testing.warmUp(ENDPOINT);
151
+ // Should only have created one new session
152
+ expect(mockClients).toHaveLength(2);
153
+ });
154
+ it("uses exponential backoff with jitter and a 30s cap", () => {
155
+ // Backoff is base * (0.75..1.25) due to ±25% jitter, so we check ranges
156
+ const inRange = (val, min, max) => val >= min && val <= max;
157
+ expect(inRange(__testing.getBackoffMs(0), 750, 1250)).toBe(true); // base 1000
158
+ expect(inRange(__testing.getBackoffMs(1), 1500, 2500)).toBe(true); // base 2000
159
+ expect(inRange(__testing.getBackoffMs(2), 3000, 5000)).toBe(true); // base 4000
160
+ expect(inRange(__testing.getBackoffMs(3), 6000, 10000)).toBe(true); // base 8000
161
+ expect(inRange(__testing.getBackoffMs(5), 22500, 30000)).toBe(true); // capped at 30000
162
+ expect(inRange(__testing.getBackoffMs(10), 22500, 30000)).toBe(true); // still capped
163
+ });
164
+ it("returns cached client on second call for same endpoint", async () => {
165
+ const client1 = getSyncedStateClient(ENDPOINT);
166
+ const client2 = getSyncedStateClient(ENDPOINT);
167
+ expect(client1).toBe(client2);
168
+ await __testing.warmUp(ENDPOINT);
169
+ expect(mockClients).toHaveLength(1);
170
+ });
171
+ it("re-subscribes multiple subscriptions after reconnect", async () => {
172
+ const client = getSyncedStateClient(ENDPOINT);
173
+ const handler1 = vi.fn();
174
+ const handler2 = vi.fn();
175
+ await client.subscribe("counter", handler1);
176
+ await client.subscribe("score", handler2);
177
+ mockClients[0].simulateBreak();
178
+ vi.runOnlyPendingTimers();
179
+ await __testing.warmUp(ENDPOINT);
180
+ const newClient = mockClients[1];
181
+ expect(newClient.subscribe).toHaveBeenCalledWith("counter", handler1);
182
+ expect(newClient.subscribe).toHaveBeenCalledWith("score", handler2);
183
+ expect(newClient.getState).toHaveBeenCalledWith("counter");
184
+ expect(newClient.getState).toHaveBeenCalledWith("score");
185
+ });
186
+ describe("onStatusChange", () => {
187
+ it("fires 'disconnected' immediately when connection breaks", async () => {
188
+ getSyncedStateClient(ENDPOINT);
189
+ await __testing.warmUp(ENDPOINT);
190
+ const statusCb = vi.fn();
191
+ onStatusChange(ENDPOINT, statusCb);
192
+ mockClients[0].simulateBreak();
193
+ expect(statusCb).toHaveBeenCalledWith("disconnected");
194
+ });
195
+ it("fires 'reconnecting' then 'connected' when reconnect completes", async () => {
196
+ getSyncedStateClient(ENDPOINT);
197
+ await __testing.warmUp(ENDPOINT);
198
+ const statusCb = vi.fn();
199
+ onStatusChange(ENDPOINT, statusCb);
200
+ mockClients[0].simulateBreak();
201
+ statusCb.mockClear();
202
+ vi.runOnlyPendingTimers();
203
+ await vi.runAllTimersAsync();
204
+ expect(statusCb).toHaveBeenCalledTimes(2);
205
+ expect(statusCb).toHaveBeenNthCalledWith(1, "reconnecting");
206
+ expect(statusCb).toHaveBeenNthCalledWith(2, "connected");
207
+ });
208
+ it("fires full lifecycle: disconnected → reconnecting → connected", async () => {
209
+ getSyncedStateClient(ENDPOINT);
210
+ await __testing.warmUp(ENDPOINT);
211
+ const statuses = [];
212
+ onStatusChange(ENDPOINT, (s) => statuses.push(s));
213
+ mockClients[0].simulateBreak();
214
+ vi.runOnlyPendingTimers();
215
+ await vi.runAllTimersAsync();
216
+ expect(statuses).toEqual(["disconnected", "reconnecting", "connected"]);
217
+ });
218
+ it("returns an unsubscribe function that stops notifications", async () => {
219
+ getSyncedStateClient(ENDPOINT);
220
+ await __testing.warmUp(ENDPOINT);
221
+ const statusCb = vi.fn();
222
+ const unsub = onStatusChange(ENDPOINT, statusCb);
223
+ unsub();
224
+ mockClients[0].simulateBreak();
225
+ expect(statusCb).not.toHaveBeenCalled();
226
+ });
227
+ it("supports multiple listeners on the same endpoint", async () => {
228
+ getSyncedStateClient(ENDPOINT);
229
+ await __testing.warmUp(ENDPOINT);
230
+ const cb1 = vi.fn();
231
+ const cb2 = vi.fn();
232
+ onStatusChange(ENDPOINT, cb1);
233
+ onStatusChange(ENDPOINT, cb2);
234
+ mockClients[0].simulateBreak();
235
+ expect(cb1).toHaveBeenCalledWith("disconnected");
236
+ expect(cb2).toHaveBeenCalledWith("disconnected");
237
+ });
238
+ });
239
+ // ============================================================
240
+ // REPRODUCTIONS: Failing tests demonstrating Copilot-flagged bugs
241
+ // ============================================================
242
+ describe("REPRO: bug reproductions", () => {
243
+ it("BUG: status listener registered with relative URL never fires because reconnect uses the normalized absolute URL", async () => {
244
+ // Stub window so relative URLs get normalized inside getSyncedStateClient
245
+ vi.stubGlobal("window", {
246
+ location: { protocol: "https:", host: "example.com" },
247
+ addEventListener: () => { },
248
+ });
249
+ const RELATIVE = "/__synced-state";
250
+ const statusCb = vi.fn();
251
+ // This mirrors what useSyncedState.ts does: register listener with
252
+ // the same (relative) string it passes to getSyncedStateClient.
253
+ onStatusChange(RELATIVE, statusCb);
254
+ getSyncedStateClient(RELATIVE);
255
+ await __testing.warmUp(RELATIVE);
256
+ mockClients[0].simulateBreak();
257
+ vi.runOnlyPendingTimers();
258
+ // Expected: full lifecycle fires. Actual: nothing fires because
259
+ // reconnect notifies using "wss://example.com/__synced-state"
260
+ // but the listener is stored under "/__synced-state".
261
+ expect(statusCb).toHaveBeenCalledWith("disconnected");
262
+ vi.unstubAllGlobals();
263
+ });
264
+ it("BUG: unsubscribing one of two instances of the same callback removes it for all", async () => {
265
+ getSyncedStateClient(ENDPOINT);
266
+ await __testing.warmUp(ENDPOINT);
267
+ // Simulate two React components sharing the same onStatusChange
268
+ // callback (the case when createSyncedStateHook({ onStatusChange })
269
+ // is used by multiple component instances).
270
+ const sharedCallback = vi.fn();
271
+ const unsubA = onStatusChange(ENDPOINT, sharedCallback);
272
+ const unsubB = onStatusChange(ENDPOINT, sharedCallback);
273
+ // Component A unmounts
274
+ unsubA();
275
+ // Component B is still mounted and should still receive updates
276
+ mockClients[0].simulateBreak();
277
+ // Expected: callback fires once (for B). Actual: fires zero times
278
+ // because Set.delete removed the single shared entry.
279
+ expect(sharedCallback).toHaveBeenCalledWith("disconnected");
280
+ unsubB();
281
+ });
282
+ it("BUG: reconnect emits 'connected' and resets backoff even when subscribe() rejects", async () => {
283
+ const client = getSyncedStateClient(ENDPOINT);
284
+ const handler = vi.fn();
285
+ await client.subscribe("counter", handler);
286
+ // Next client's subscribe will reject
287
+ newWebSocketRpcSession.mockImplementationOnce(() => {
288
+ const c = makeMockClient();
289
+ c.subscribe.mockRejectedValue(new Error("subscribe failed"));
290
+ return c;
291
+ });
292
+ const statuses = [];
293
+ onStatusChange(ENDPOINT, (s) => statuses.push(s));
294
+ mockClients[0].simulateBreak();
295
+ vi.runOnlyPendingTimers();
296
+ await vi.runAllTimersAsync();
297
+ // Expected: on failure, we should NOT claim connected and NOT reset backoff.
298
+ // Actual: "connected" fires and backoff resets to 0 despite the failure.
299
+ expect(statuses).not.toContain("connected");
300
+ expect(__testing.backoffState.get(ENDPOINT)?.attempt ?? 0).toBeGreaterThan(0);
301
+ });
302
+ });
303
+ });
@@ -0,0 +1 @@
1
+ export declare function loadCapnweb(): Promise<typeof import("capnweb")>;
@@ -0,0 +1,11 @@
1
+ let capnwebPromise = null;
2
+ export function loadCapnweb() {
3
+ if (!capnwebPromise) {
4
+ capnwebPromise = import("capnweb").catch(() => {
5
+ throw new Error('The "use-synced-state" feature requires the "capnweb" package, ' +
6
+ 'which is not installed. Install it with your package manager ' +
7
+ '(e.g. `npm install capnweb` or `pnpm add capnweb`).');
8
+ });
9
+ }
10
+ return capnwebPromise;
11
+ }
@@ -1,12 +1,28 @@
1
+ export type SyncedStateStatus = "connected" | "disconnected" | "reconnecting";
2
+ export type StatusChangeCallback = (status: SyncedStateStatus) => void;
1
3
  export type SyncedStateClient = {
2
4
  getState(key: string): Promise<unknown>;
3
5
  setState(value: unknown, key: string): Promise<void>;
4
6
  subscribe(key: string, handler: (value: unknown) => void): Promise<void>;
5
7
  unsubscribe(key: string, handler: (value: unknown) => void): Promise<void>;
6
8
  };
9
+ type Subscription = {
10
+ key: string;
11
+ handler: (value: unknown) => void;
12
+ client: SyncedStateClient;
13
+ };
14
+ /**
15
+ * Registers a callback that fires when the connection status changes for an endpoint.
16
+ * Returns an unsubscribe function.
17
+ */
18
+ export declare const onStatusChange: (endpoint: string, callback: StatusChangeCallback) => (() => void);
19
+ declare function getBackoffMs(attempt: number): number;
20
+ declare function reconnect(endpoint: string, deadClient: SyncedStateClient): void;
7
21
  /**
8
22
  * Returns a cached client for the provided endpoint, creating it when necessary.
9
- * The client is wrapped to track subscriptions for cleanup on page reload.
23
+ * The returned client is a proxy that loads `capnweb` lazily on first method
24
+ * call — consumers that never hit `use-synced-state` pay no import cost and
25
+ * don't need `capnweb` installed.
10
26
  * @param endpoint Endpoint to connect to.
11
27
  * @returns RPC client instance.
12
28
  */
@@ -27,3 +43,16 @@ export declare const initSyncedStateClient: (options?: {
27
43
  * @param endpoint Endpoint associated with the injected client.
28
44
  */
29
45
  export declare const setSyncedStateClientForTesting: (client: SyncedStateClient | null, endpoint?: string) => void;
46
+ export declare const __testing: {
47
+ activeSubscriptions: Set<Subscription>;
48
+ clientCache: Map<string, SyncedStateClient>;
49
+ backoffState: Map<string, {
50
+ attempt: number;
51
+ timer: ReturnType<typeof setTimeout> | null;
52
+ }>;
53
+ statusListeners: Map<string, StatusChangeCallback[]>;
54
+ reconnect: typeof reconnect;
55
+ getBackoffMs: typeof getBackoffMs;
56
+ warmUp(endpoint?: string): Promise<void>;
57
+ };
58
+ export {};
@@ -1,8 +1,67 @@
1
- import { newWebSocketRpcSession } from "capnweb";
1
+ import { loadCapnweb } from "./capnweb-loader.mjs";
2
2
  import { DEFAULT_SYNCED_STATE_PATH } from "./constants.mjs";
3
+ // Converts a relative endpoint like "/__synced-state" to an absolute
4
+ // ws:// or wss:// URL so the same key is used by getSyncedStateClient,
5
+ // onStatusChange, and reconnect notifications.
6
+ function normalizeEndpoint(endpoint) {
7
+ if (endpoint.startsWith("/") && typeof window !== "undefined") {
8
+ const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
9
+ return `${protocol}//${window.location.host}${endpoint}`;
10
+ }
11
+ return endpoint;
12
+ }
3
13
  // Map of endpoint URLs to their respective clients
4
14
  const clientCache = new Map();
15
+ // Tracks the promise of the underlying capnweb session per endpoint, exposed
16
+ // for tests so they can `await` the lazy load before making assertions.
17
+ const baseClientPromiseByEndpoint = new Map();
5
18
  const activeSubscriptions = new Set();
19
+ // Status change listeners per endpoint. Uses an array rather than a Set so
20
+ // that two components passing the same callback reference (e.g. via
21
+ // createSyncedStateHook({ onStatusChange })) are tracked as two separate
22
+ // registrations — unsubscribing one must not cancel the other.
23
+ const statusListeners = new Map();
24
+ function notifyStatusChange(endpoint, status) {
25
+ const listeners = statusListeners.get(endpoint);
26
+ if (listeners) {
27
+ // Snapshot so unsubscribes fired by callbacks don't skip entries.
28
+ for (const cb of [...listeners]) {
29
+ cb(status);
30
+ }
31
+ }
32
+ }
33
+ /**
34
+ * Registers a callback that fires when the connection status changes for an endpoint.
35
+ * Returns an unsubscribe function.
36
+ */
37
+ export const onStatusChange = (endpoint, callback) => {
38
+ const normalized = normalizeEndpoint(endpoint);
39
+ let listeners = statusListeners.get(normalized);
40
+ if (!listeners) {
41
+ listeners = [];
42
+ statusListeners.set(normalized, listeners);
43
+ }
44
+ listeners.push(callback);
45
+ return () => {
46
+ const idx = listeners.indexOf(callback);
47
+ if (idx !== -1) {
48
+ listeners.splice(idx, 1);
49
+ }
50
+ if (listeners.length === 0) {
51
+ statusListeners.delete(normalized);
52
+ }
53
+ };
54
+ };
55
+ // Tracks per-endpoint reconnection backoff state
56
+ const backoffState = new Map();
57
+ const BASE_BACKOFF_MS = 1000;
58
+ const MAX_BACKOFF_MS = 30000;
59
+ function getBackoffMs(attempt) {
60
+ const base = Math.min(BASE_BACKOFF_MS * 2 ** attempt, MAX_BACKOFF_MS);
61
+ // Add ±25% jitter to avoid thundering herd on server restart
62
+ const jittered = base * (0.75 + Math.random() * 0.5);
63
+ return Math.round(Math.min(jittered, MAX_BACKOFF_MS));
64
+ }
6
65
  // Set up beforeunload handler to unsubscribe all active subscriptions
7
66
  if (typeof window !== "undefined") {
8
67
  const handleBeforeUnload = () => {
@@ -22,28 +81,87 @@ if (typeof window !== "undefined") {
22
81
  };
23
82
  window.addEventListener("beforeunload", handleBeforeUnload);
24
83
  }
84
+ function reconnect(endpoint, deadClient) {
85
+ // Don't schedule multiple reconnects for the same endpoint
86
+ const state = backoffState.get(endpoint) ?? { attempt: 0, timer: null };
87
+ if (state.timer !== null) {
88
+ return;
89
+ }
90
+ notifyStatusChange(endpoint, "disconnected");
91
+ const delayMs = getBackoffMs(state.attempt);
92
+ state.timer = setTimeout(() => {
93
+ state.timer = null;
94
+ state.attempt++;
95
+ backoffState.set(endpoint, state);
96
+ notifyStatusChange(endpoint, "reconnecting");
97
+ // Evict the dead client so getSyncedStateClient creates a fresh one
98
+ clientCache.delete(endpoint);
99
+ const newClient = getSyncedStateClient(endpoint);
100
+ // Re-subscribe everything that was on the dead client. Kick off both
101
+ // subscribe() and getState() synchronously so callers see the calls
102
+ // happen inside the timer tick, but only confirm "connected" once the
103
+ // subscribe promises resolve — otherwise a rejected resubscription
104
+ // would be masked as a successful reconnect.
105
+ const subscribePromises = [];
106
+ for (const sub of activeSubscriptions) {
107
+ if (sub.client === deadClient) {
108
+ sub.client = newClient;
109
+ subscribePromises.push(newClient.subscribe(sub.key, sub.handler));
110
+ void newClient.getState(sub.key).then((val) => {
111
+ if (val !== undefined) {
112
+ sub.handler(val);
113
+ }
114
+ });
115
+ }
116
+ }
117
+ Promise.all(subscribePromises).then(() => {
118
+ backoffState.set(endpoint, { attempt: 0, timer: null });
119
+ notifyStatusChange(endpoint, "connected");
120
+ }, () => {
121
+ // Resubscription failed — leave the attempt counter elevated so
122
+ // the next reconnect uses a longer backoff, and emit disconnected
123
+ // again. A subsequent onRpcBroken from the new (likely dead)
124
+ // client will drive the next retry.
125
+ notifyStatusChange(endpoint, "disconnected");
126
+ });
127
+ }, delayMs);
128
+ backoffState.set(endpoint, state);
129
+ }
25
130
  /**
26
131
  * Returns a cached client for the provided endpoint, creating it when necessary.
27
- * The client is wrapped to track subscriptions for cleanup on page reload.
132
+ * The returned client is a proxy that loads `capnweb` lazily on first method
133
+ * call — consumers that never hit `use-synced-state` pay no import cost and
134
+ * don't need `capnweb` installed.
28
135
  * @param endpoint Endpoint to connect to.
29
136
  * @returns RPC client instance.
30
137
  */
31
138
  export const getSyncedStateClient = (endpoint = DEFAULT_SYNCED_STATE_PATH) => {
32
139
  // Convert relative endpoint to absolute URL for environments like WKWebView
33
- if (endpoint.startsWith("/") && typeof window !== "undefined") {
34
- const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
35
- endpoint = `${protocol}//${window.location.host}${endpoint}`;
36
- }
140
+ endpoint = normalizeEndpoint(endpoint);
37
141
  // Return existing client if already cached for this endpoint
38
142
  const existingClient = clientCache.get(endpoint);
39
143
  if (existingClient) {
40
144
  return existingClient;
41
145
  }
42
- const baseClient = newWebSocketRpcSession(endpoint);
43
- // Wrap the client using a Proxy to track subscriptions
44
- // The RPC client uses dynamic property access, so we can't use .bind()
45
- const wrappedClient = new Proxy(baseClient, {
46
- get(target, prop) {
146
+ let baseClientPromise = null;
147
+ let wrappedClient;
148
+ const getBaseClient = () => {
149
+ if (!baseClientPromise) {
150
+ baseClientPromise = loadCapnweb().then((mod) => {
151
+ const session = mod.newWebSocketRpcSession(endpoint);
152
+ if (typeof session.onRpcBroken === "function") {
153
+ session.onRpcBroken(() => {
154
+ reconnect(endpoint, wrappedClient);
155
+ });
156
+ }
157
+ return session;
158
+ });
159
+ baseClientPromiseByEndpoint.set(endpoint, baseClientPromise);
160
+ }
161
+ return baseClientPromise;
162
+ };
163
+ wrappedClient = new Proxy({}, {
164
+ get(_target, prop) {
47
165
  if (prop === "subscribe") {
48
166
  return async (key, handler) => {
49
167
  const subscription = {
@@ -52,7 +170,8 @@ export const getSyncedStateClient = (endpoint = DEFAULT_SYNCED_STATE_PATH) => {
52
170
  client: wrappedClient,
53
171
  };
54
172
  activeSubscriptions.add(subscription);
55
- return target[prop](key, handler);
173
+ const base = await getBaseClient();
174
+ return base[prop](key, handler);
56
175
  };
57
176
  }
58
177
  if (prop === "unsubscribe") {
@@ -66,15 +185,26 @@ export const getSyncedStateClient = (endpoint = DEFAULT_SYNCED_STATE_PATH) => {
66
185
  break;
67
186
  }
68
187
  }
69
- return target[prop](key, handler);
188
+ const base = await getBaseClient();
189
+ return base[prop](key, handler);
70
190
  };
71
191
  }
72
192
  // Pass through all other properties/methods
73
- return target[prop];
193
+ return async (...args) => {
194
+ const base = await getBaseClient();
195
+ return base[prop](...args);
196
+ };
74
197
  },
75
198
  });
76
199
  // Cache the client for this endpoint
77
200
  clientCache.set(endpoint, wrappedClient);
201
+ // Eagerly kick off the capnweb load so the underlying session (and its
202
+ // onRpcBroken handler) is ready as soon as possible, and reconnect flows
203
+ // that don't call methods on the new client still create the replacement
204
+ // session. Errors are swallowed here to avoid unhandled rejections — they
205
+ // still surface through subsequent method calls because the rejected
206
+ // promise remains cached.
207
+ void getBaseClient().catch(() => { });
78
208
  return wrappedClient;
79
209
  };
80
210
  /**
@@ -104,5 +234,34 @@ export const setSyncedStateClientForTesting = (client, endpoint = DEFAULT_SYNCED
104
234
  else {
105
235
  clientCache.delete(endpoint);
106
236
  }
237
+ baseClientPromiseByEndpoint.delete(endpoint);
107
238
  activeSubscriptions.clear();
239
+ statusListeners.clear();
240
+ // Clear any pending reconnection timers
241
+ for (const [, state] of backoffState) {
242
+ if (state.timer !== null) {
243
+ clearTimeout(state.timer);
244
+ }
245
+ }
246
+ backoffState.clear();
247
+ };
248
+ // Exported for testing only
249
+ export const __testing = {
250
+ activeSubscriptions,
251
+ clientCache,
252
+ backoffState,
253
+ statusListeners,
254
+ reconnect,
255
+ getBackoffMs,
256
+ // Awaits the eagerly-kicked-off capnweb load for a cached client. Tests
257
+ // should `await __testing.warmUp(endpoint)` after `getSyncedStateClient`
258
+ // (or after a reconnect) when they need the underlying session to exist
259
+ // before asserting on it.
260
+ async warmUp(endpoint = DEFAULT_SYNCED_STATE_PATH) {
261
+ const normalized = normalizeEndpoint(endpoint);
262
+ const promise = baseClientPromiseByEndpoint.get(normalized);
263
+ if (promise) {
264
+ await promise.catch(() => { });
265
+ }
266
+ },
108
267
  };
@@ -1,3 +1,3 @@
1
1
  export { getSyncedStateClient, initSyncedStateClient, setSyncedStateClientForTesting, } from "./client-core.js";
2
- export type { SyncedStateClient } from "./client-core.js";
3
- export { useSyncedState } from "./useSyncedState.js";
2
+ export type { SyncedStateClient, SyncedStateStatus, StatusChangeCallback, } from "./client-core.js";
3
+ export { useSyncedState, createSyncedStateHook } from "./useSyncedState.js";
@@ -1,4 +1,4 @@
1
1
  // Re-export everything from client-core to maintain the public API
2
2
  export { getSyncedStateClient, initSyncedStateClient, setSyncedStateClientForTesting, } from "./client-core.js";
3
3
  // Re-export useSyncedState (no circular dependency since useSyncedState imports from client-core, not client)
4
- export { useSyncedState } from "./useSyncedState.js";
4
+ export { useSyncedState, createSyncedStateHook } from "./useSyncedState.js";
@@ -1,4 +1,5 @@
1
1
  import React from "react";
2
+ import { type StatusChangeCallback } from "./client-core.js";
2
3
  type HookDeps = {
3
4
  useState: typeof React.useState;
4
5
  useEffect: typeof React.useEffect;
@@ -10,6 +11,7 @@ export type CreateSyncedStateHookOptions = {
10
11
  url?: string;
11
12
  roomId?: string;
12
13
  hooks?: HookDeps;
14
+ onStatusChange?: StatusChangeCallback;
13
15
  };
14
16
  /**
15
17
  * Builds a `useSyncedState` hook configured with optional endpoint and hook overrides.
@@ -1,5 +1,5 @@
1
1
  import React from "react";
2
- import { getSyncedStateClient } from "./client-core.js";
2
+ import { getSyncedStateClient, onStatusChange, } from "./client-core.js";
3
3
  import { DEFAULT_SYNCED_STATE_PATH } from "./constants.mjs";
4
4
  const defaultDeps = {
5
5
  useState: React.useState,
@@ -48,8 +48,12 @@ export const createSyncedStateHook = (options = {}) => {
48
48
  }
49
49
  });
50
50
  void client.subscribe(key, handleUpdate);
51
+ const unsubscribeStatus = options.onStatusChange
52
+ ? onStatusChange(resolvedUrl, options.onStatusChange)
53
+ : undefined;
51
54
  return () => {
52
55
  isActive = false;
56
+ unsubscribeStatus?.();
53
57
  // Call unsubscribe when component unmounts
54
58
  // Page reloads are handled by the beforeunload event listener in client-core.ts
55
59
  void client.unsubscribe(key, handleUpdate).catch((error) => {