rwsdk 1.1.0 → 1.2.0
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.
- package/dist/runtime/lib/router.d.ts +1 -1
- package/dist/runtime/lib/router.test.js +43 -1
- package/dist/use-synced-state/__tests__/client-core.test.d.ts +1 -0
- package/dist/use-synced-state/__tests__/client-core.test.js +284 -0
- package/dist/use-synced-state/client-core.d.ts +27 -0
- package/dist/use-synced-state/client-core.js +127 -4
- package/dist/use-synced-state/client.d.ts +2 -2
- package/dist/use-synced-state/client.js +1 -1
- package/dist/use-synced-state/useSyncedState.d.ts +2 -0
- package/dist/use-synced-state/useSyncedState.js +5 -1
- package/package.json +1 -1
|
@@ -146,7 +146,7 @@ export declare const wrapHandlerToThrowResponses: <T extends RequestInfo = Reque
|
|
|
146
146
|
* route("/post/:id", ({ params }) => <BlogPost id={params.id} />),
|
|
147
147
|
* ])
|
|
148
148
|
*/
|
|
149
|
-
export declare function layout<
|
|
149
|
+
export declare function layout<TLayout extends RequestInfo = RequestInfo, Routes extends readonly Route<any>[] = readonly Route<any>[]>(LayoutComponent: React.FC<LayoutProps<TLayout>>, routes: Routes): Routes;
|
|
150
150
|
/**
|
|
151
151
|
* Wraps routes with a Document component and configures rendering options.
|
|
152
152
|
*
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { describe, expect, it } from "vitest";
|
|
3
|
-
import { defineRoutes, except, layout, matchPath, prefix, render, route, } from "./router";
|
|
3
|
+
import { defineRoutes, except, index, layout, matchPath, prefix, render, route, } from "./router";
|
|
4
4
|
describe("matchPath", () => {
|
|
5
5
|
// Test case 1: Static paths
|
|
6
6
|
it("should match static paths", () => {
|
|
@@ -793,6 +793,48 @@ describe("defineRoutes - Request Handling Behavior", () => {
|
|
|
793
793
|
});
|
|
794
794
|
expect(await response.text()).toBe("Rendered with layouts");
|
|
795
795
|
});
|
|
796
|
+
it("should accept child routes whose params are a superset of the layout's params", async () => {
|
|
797
|
+
let capturedLayoutProps = null;
|
|
798
|
+
let capturedRouteParams = null;
|
|
799
|
+
// Layout only needs { orgId }
|
|
800
|
+
const AppLayout = (props) => {
|
|
801
|
+
capturedLayoutProps = props;
|
|
802
|
+
return React.createElement("div", { className: "app-layout" }, props.children);
|
|
803
|
+
};
|
|
804
|
+
const OrgIndex = () => {
|
|
805
|
+
return React.createElement("div", {}, "Org Index");
|
|
806
|
+
};
|
|
807
|
+
// Schedule needs { orgId, date } — a superset
|
|
808
|
+
const Schedule = (requestInfo) => {
|
|
809
|
+
capturedRouteParams = requestInfo.params;
|
|
810
|
+
return React.createElement("div", {}, `${requestInfo.params.orgId} / ${requestInfo.params.date}`);
|
|
811
|
+
};
|
|
812
|
+
const routes = prefix("/:orgId", [
|
|
813
|
+
layout(AppLayout, [
|
|
814
|
+
index(OrgIndex),
|
|
815
|
+
prefix("/schedule", [
|
|
816
|
+
route("/:date", Schedule),
|
|
817
|
+
]),
|
|
818
|
+
]),
|
|
819
|
+
]);
|
|
820
|
+
const router = defineRoutes([...routes]);
|
|
821
|
+
const deps = createMockDependencies();
|
|
822
|
+
deps.mockRequestInfo.request = new Request("http://localhost:3000/acme/schedule/2025-01-15/");
|
|
823
|
+
const request = new Request("http://localhost:3000/acme/schedule/2025-01-15/");
|
|
824
|
+
const response = await router.handle({
|
|
825
|
+
request,
|
|
826
|
+
renderPage: deps.mockRenderPage,
|
|
827
|
+
getRequestInfo: deps.getRequestInfo,
|
|
828
|
+
onError: deps.onError,
|
|
829
|
+
runWithRequestInfoOverrides: deps.mockRunWithRequestInfoOverrides,
|
|
830
|
+
rscActionHandler: deps.mockRscActionHandler,
|
|
831
|
+
});
|
|
832
|
+
expect(response.status).toBe(200);
|
|
833
|
+
expect(deps.mockRequestInfo.params).toEqual({
|
|
834
|
+
orgId: "acme",
|
|
835
|
+
date: "2025-01-15",
|
|
836
|
+
});
|
|
837
|
+
});
|
|
796
838
|
});
|
|
797
839
|
describe("Parameter Extraction", () => {
|
|
798
840
|
it("should extract path parameters and make them available in request info", async () => {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,284 @@
|
|
|
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
|
+
describe("client-core reconnection", () => {
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
vi.useFakeTimers();
|
|
33
|
+
mockClients.length = 0;
|
|
34
|
+
newWebSocketRpcSession.mockReset();
|
|
35
|
+
newWebSocketRpcSession.mockImplementation(() => makeMockClient());
|
|
36
|
+
// Clear all internal state between tests
|
|
37
|
+
__testing.clientCache.clear();
|
|
38
|
+
__testing.activeSubscriptions.clear();
|
|
39
|
+
for (const [, state] of __testing.backoffState) {
|
|
40
|
+
if (state.timer !== null)
|
|
41
|
+
clearTimeout(state.timer);
|
|
42
|
+
}
|
|
43
|
+
__testing.backoffState.clear();
|
|
44
|
+
});
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
__testing.clientCache.clear();
|
|
47
|
+
__testing.activeSubscriptions.clear();
|
|
48
|
+
__testing.backoffState.clear();
|
|
49
|
+
__testing.statusListeners.clear();
|
|
50
|
+
vi.useRealTimers();
|
|
51
|
+
});
|
|
52
|
+
it("registers onRpcBroken callback when creating a client", () => {
|
|
53
|
+
getSyncedStateClient("wss://test.example.com/__synced-state");
|
|
54
|
+
expect(mockClients).toHaveLength(1);
|
|
55
|
+
expect(mockClients[0].onRpcBroken).toHaveBeenCalledOnce();
|
|
56
|
+
});
|
|
57
|
+
it("creates a new client after connection breaks", () => {
|
|
58
|
+
getSyncedStateClient("wss://test.example.com/__synced-state");
|
|
59
|
+
expect(mockClients).toHaveLength(1);
|
|
60
|
+
// Simulate connection break
|
|
61
|
+
mockClients[0].simulateBreak();
|
|
62
|
+
// Reconnect happens after backoff timer fires
|
|
63
|
+
vi.runOnlyPendingTimers();
|
|
64
|
+
expect(mockClients).toHaveLength(2);
|
|
65
|
+
});
|
|
66
|
+
it("does not reconnect immediately — waits for backoff", () => {
|
|
67
|
+
getSyncedStateClient("wss://test.example.com/__synced-state");
|
|
68
|
+
mockClients[0].simulateBreak();
|
|
69
|
+
// Before the timer fires, no new session yet
|
|
70
|
+
expect(mockClients).toHaveLength(1);
|
|
71
|
+
// After timer fires, reconnect happens
|
|
72
|
+
vi.runOnlyPendingTimers();
|
|
73
|
+
expect(mockClients).toHaveLength(2);
|
|
74
|
+
});
|
|
75
|
+
it("re-subscribes active subscriptions after reconnect", async () => {
|
|
76
|
+
const client = getSyncedStateClient("wss://test.example.com/__synced-state");
|
|
77
|
+
const handler = vi.fn();
|
|
78
|
+
await client.subscribe("counter", handler);
|
|
79
|
+
// Simulate connection break
|
|
80
|
+
mockClients[0].simulateBreak();
|
|
81
|
+
vi.runOnlyPendingTimers();
|
|
82
|
+
// The new client should have subscribe called with the same key and handler
|
|
83
|
+
const newClient = mockClients[1];
|
|
84
|
+
expect(newClient.subscribe).toHaveBeenCalledWith("counter", handler);
|
|
85
|
+
});
|
|
86
|
+
it("fetches latest state for each subscription after reconnect", async () => {
|
|
87
|
+
const client = getSyncedStateClient("wss://test.example.com/__synced-state");
|
|
88
|
+
const handler = vi.fn();
|
|
89
|
+
await client.subscribe("counter", handler);
|
|
90
|
+
mockClients[0].simulateBreak();
|
|
91
|
+
vi.runOnlyPendingTimers();
|
|
92
|
+
const newClient = mockClients[1];
|
|
93
|
+
expect(newClient.getState).toHaveBeenCalledWith("counter");
|
|
94
|
+
});
|
|
95
|
+
it("calls handler with fetched state when value is not undefined", async () => {
|
|
96
|
+
const client = getSyncedStateClient("wss://test.example.com/__synced-state");
|
|
97
|
+
const handler = vi.fn();
|
|
98
|
+
await client.subscribe("counter", handler);
|
|
99
|
+
// Next client will return a value for getState
|
|
100
|
+
newWebSocketRpcSession.mockImplementationOnce(() => {
|
|
101
|
+
const c = makeMockClient();
|
|
102
|
+
c.getState.mockResolvedValue(42);
|
|
103
|
+
return c;
|
|
104
|
+
});
|
|
105
|
+
mockClients[0].simulateBreak();
|
|
106
|
+
vi.runOnlyPendingTimers();
|
|
107
|
+
// Allow the getState promise to resolve
|
|
108
|
+
await vi.runAllTimersAsync();
|
|
109
|
+
expect(handler).toHaveBeenCalledWith(42);
|
|
110
|
+
});
|
|
111
|
+
it("does not call handler when fetched state is undefined", async () => {
|
|
112
|
+
const client = getSyncedStateClient("wss://test.example.com/__synced-state");
|
|
113
|
+
const handler = vi.fn();
|
|
114
|
+
await client.subscribe("counter", handler);
|
|
115
|
+
handler.mockClear();
|
|
116
|
+
mockClients[0].simulateBreak();
|
|
117
|
+
vi.runOnlyPendingTimers();
|
|
118
|
+
// Default mock returns undefined for getState
|
|
119
|
+
await vi.runAllTimersAsync();
|
|
120
|
+
expect(handler).not.toHaveBeenCalled();
|
|
121
|
+
});
|
|
122
|
+
it("does not re-subscribe keys that were unsubscribed before reconnect", async () => {
|
|
123
|
+
const client = getSyncedStateClient("wss://test.example.com/__synced-state");
|
|
124
|
+
const handler = vi.fn();
|
|
125
|
+
await client.subscribe("counter", handler);
|
|
126
|
+
await client.unsubscribe("counter", handler);
|
|
127
|
+
mockClients[0].simulateBreak();
|
|
128
|
+
vi.runOnlyPendingTimers();
|
|
129
|
+
const newClient = mockClients[1];
|
|
130
|
+
expect(newClient.subscribe).not.toHaveBeenCalled();
|
|
131
|
+
});
|
|
132
|
+
it("does not schedule multiple reconnects for the same endpoint", () => {
|
|
133
|
+
getSyncedStateClient("wss://test.example.com/__synced-state");
|
|
134
|
+
// Fire broken twice rapidly
|
|
135
|
+
mockClients[0].simulateBreak();
|
|
136
|
+
mockClients[0].simulateBreak();
|
|
137
|
+
vi.runOnlyPendingTimers();
|
|
138
|
+
// Should only have created one new session
|
|
139
|
+
expect(mockClients).toHaveLength(2);
|
|
140
|
+
});
|
|
141
|
+
it("uses exponential backoff with jitter and a 30s cap", () => {
|
|
142
|
+
// Backoff is base * (0.75..1.25) due to ±25% jitter, so we check ranges
|
|
143
|
+
const inRange = (val, min, max) => val >= min && val <= max;
|
|
144
|
+
expect(inRange(__testing.getBackoffMs(0), 750, 1250)).toBe(true); // base 1000
|
|
145
|
+
expect(inRange(__testing.getBackoffMs(1), 1500, 2500)).toBe(true); // base 2000
|
|
146
|
+
expect(inRange(__testing.getBackoffMs(2), 3000, 5000)).toBe(true); // base 4000
|
|
147
|
+
expect(inRange(__testing.getBackoffMs(3), 6000, 10000)).toBe(true); // base 8000
|
|
148
|
+
expect(inRange(__testing.getBackoffMs(5), 22500, 30000)).toBe(true); // capped at 30000
|
|
149
|
+
expect(inRange(__testing.getBackoffMs(10), 22500, 30000)).toBe(true); // still capped
|
|
150
|
+
});
|
|
151
|
+
it("returns cached client on second call for same endpoint", () => {
|
|
152
|
+
const client1 = getSyncedStateClient("wss://test.example.com/__synced-state");
|
|
153
|
+
const client2 = getSyncedStateClient("wss://test.example.com/__synced-state");
|
|
154
|
+
expect(client1).toBe(client2);
|
|
155
|
+
expect(mockClients).toHaveLength(1);
|
|
156
|
+
});
|
|
157
|
+
it("re-subscribes multiple subscriptions after reconnect", async () => {
|
|
158
|
+
const client = getSyncedStateClient("wss://test.example.com/__synced-state");
|
|
159
|
+
const handler1 = vi.fn();
|
|
160
|
+
const handler2 = vi.fn();
|
|
161
|
+
await client.subscribe("counter", handler1);
|
|
162
|
+
await client.subscribe("score", handler2);
|
|
163
|
+
mockClients[0].simulateBreak();
|
|
164
|
+
vi.runOnlyPendingTimers();
|
|
165
|
+
const newClient = mockClients[1];
|
|
166
|
+
expect(newClient.subscribe).toHaveBeenCalledWith("counter", handler1);
|
|
167
|
+
expect(newClient.subscribe).toHaveBeenCalledWith("score", handler2);
|
|
168
|
+
expect(newClient.getState).toHaveBeenCalledWith("counter");
|
|
169
|
+
expect(newClient.getState).toHaveBeenCalledWith("score");
|
|
170
|
+
});
|
|
171
|
+
describe("onStatusChange", () => {
|
|
172
|
+
const ENDPOINT = "wss://test.example.com/__synced-state";
|
|
173
|
+
it("fires 'disconnected' immediately when connection breaks", () => {
|
|
174
|
+
getSyncedStateClient(ENDPOINT);
|
|
175
|
+
const statusCb = vi.fn();
|
|
176
|
+
onStatusChange(ENDPOINT, statusCb);
|
|
177
|
+
mockClients[0].simulateBreak();
|
|
178
|
+
expect(statusCb).toHaveBeenCalledWith("disconnected");
|
|
179
|
+
});
|
|
180
|
+
it("fires 'reconnecting' then 'connected' when reconnect completes", async () => {
|
|
181
|
+
getSyncedStateClient(ENDPOINT);
|
|
182
|
+
const statusCb = vi.fn();
|
|
183
|
+
onStatusChange(ENDPOINT, statusCb);
|
|
184
|
+
mockClients[0].simulateBreak();
|
|
185
|
+
statusCb.mockClear();
|
|
186
|
+
vi.runOnlyPendingTimers();
|
|
187
|
+
await vi.runAllTimersAsync();
|
|
188
|
+
expect(statusCb).toHaveBeenCalledTimes(2);
|
|
189
|
+
expect(statusCb).toHaveBeenNthCalledWith(1, "reconnecting");
|
|
190
|
+
expect(statusCb).toHaveBeenNthCalledWith(2, "connected");
|
|
191
|
+
});
|
|
192
|
+
it("fires full lifecycle: disconnected → reconnecting → connected", async () => {
|
|
193
|
+
getSyncedStateClient(ENDPOINT);
|
|
194
|
+
const statuses = [];
|
|
195
|
+
onStatusChange(ENDPOINT, (s) => statuses.push(s));
|
|
196
|
+
mockClients[0].simulateBreak();
|
|
197
|
+
vi.runOnlyPendingTimers();
|
|
198
|
+
await vi.runAllTimersAsync();
|
|
199
|
+
expect(statuses).toEqual(["disconnected", "reconnecting", "connected"]);
|
|
200
|
+
});
|
|
201
|
+
it("returns an unsubscribe function that stops notifications", () => {
|
|
202
|
+
getSyncedStateClient(ENDPOINT);
|
|
203
|
+
const statusCb = vi.fn();
|
|
204
|
+
const unsub = onStatusChange(ENDPOINT, statusCb);
|
|
205
|
+
unsub();
|
|
206
|
+
mockClients[0].simulateBreak();
|
|
207
|
+
expect(statusCb).not.toHaveBeenCalled();
|
|
208
|
+
});
|
|
209
|
+
it("supports multiple listeners on the same endpoint", () => {
|
|
210
|
+
getSyncedStateClient(ENDPOINT);
|
|
211
|
+
const cb1 = vi.fn();
|
|
212
|
+
const cb2 = vi.fn();
|
|
213
|
+
onStatusChange(ENDPOINT, cb1);
|
|
214
|
+
onStatusChange(ENDPOINT, cb2);
|
|
215
|
+
mockClients[0].simulateBreak();
|
|
216
|
+
expect(cb1).toHaveBeenCalledWith("disconnected");
|
|
217
|
+
expect(cb2).toHaveBeenCalledWith("disconnected");
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
// ============================================================
|
|
221
|
+
// REPRODUCTIONS: Failing tests demonstrating Copilot-flagged bugs
|
|
222
|
+
// ============================================================
|
|
223
|
+
describe("REPRO: bug reproductions", () => {
|
|
224
|
+
it("BUG: status listener registered with relative URL never fires because reconnect uses the normalized absolute URL", () => {
|
|
225
|
+
// Stub window so relative URLs get normalized inside getSyncedStateClient
|
|
226
|
+
vi.stubGlobal("window", {
|
|
227
|
+
location: { protocol: "https:", host: "example.com" },
|
|
228
|
+
addEventListener: () => { },
|
|
229
|
+
});
|
|
230
|
+
const RELATIVE = "/__synced-state";
|
|
231
|
+
const statusCb = vi.fn();
|
|
232
|
+
// This mirrors what useSyncedState.ts does: register listener with
|
|
233
|
+
// the same (relative) string it passes to getSyncedStateClient.
|
|
234
|
+
onStatusChange(RELATIVE, statusCb);
|
|
235
|
+
getSyncedStateClient(RELATIVE);
|
|
236
|
+
mockClients[0].simulateBreak();
|
|
237
|
+
vi.runOnlyPendingTimers();
|
|
238
|
+
// Expected: full lifecycle fires. Actual: nothing fires because
|
|
239
|
+
// reconnect notifies using "wss://example.com/__synced-state"
|
|
240
|
+
// but the listener is stored under "/__synced-state".
|
|
241
|
+
expect(statusCb).toHaveBeenCalledWith("disconnected");
|
|
242
|
+
vi.unstubAllGlobals();
|
|
243
|
+
});
|
|
244
|
+
it("BUG: unsubscribing one of two instances of the same callback removes it for all", () => {
|
|
245
|
+
const ENDPOINT = "wss://test.example.com/__synced-state";
|
|
246
|
+
getSyncedStateClient(ENDPOINT);
|
|
247
|
+
// Simulate two React components sharing the same onStatusChange
|
|
248
|
+
// callback (the case when createSyncedStateHook({ onStatusChange })
|
|
249
|
+
// is used by multiple component instances).
|
|
250
|
+
const sharedCallback = vi.fn();
|
|
251
|
+
const unsubA = onStatusChange(ENDPOINT, sharedCallback);
|
|
252
|
+
const unsubB = onStatusChange(ENDPOINT, sharedCallback);
|
|
253
|
+
// Component A unmounts
|
|
254
|
+
unsubA();
|
|
255
|
+
// Component B is still mounted and should still receive updates
|
|
256
|
+
mockClients[0].simulateBreak();
|
|
257
|
+
// Expected: callback fires once (for B). Actual: fires zero times
|
|
258
|
+
// because Set.delete removed the single shared entry.
|
|
259
|
+
expect(sharedCallback).toHaveBeenCalledWith("disconnected");
|
|
260
|
+
unsubB();
|
|
261
|
+
});
|
|
262
|
+
it("BUG: reconnect emits 'connected' and resets backoff even when subscribe() rejects", async () => {
|
|
263
|
+
const ENDPOINT = "wss://test.example.com/__synced-state";
|
|
264
|
+
const client = getSyncedStateClient(ENDPOINT);
|
|
265
|
+
const handler = vi.fn();
|
|
266
|
+
await client.subscribe("counter", handler);
|
|
267
|
+
// Next client's subscribe will reject
|
|
268
|
+
newWebSocketRpcSession.mockImplementationOnce(() => {
|
|
269
|
+
const c = makeMockClient();
|
|
270
|
+
c.subscribe.mockRejectedValue(new Error("subscribe failed"));
|
|
271
|
+
return c;
|
|
272
|
+
});
|
|
273
|
+
const statuses = [];
|
|
274
|
+
onStatusChange(ENDPOINT, (s) => statuses.push(s));
|
|
275
|
+
mockClients[0].simulateBreak();
|
|
276
|
+
vi.runOnlyPendingTimers();
|
|
277
|
+
await vi.runAllTimersAsync();
|
|
278
|
+
// Expected: on failure, we should NOT claim connected and NOT reset backoff.
|
|
279
|
+
// Actual: "connected" fires and backoff resets to 0 despite the failure.
|
|
280
|
+
expect(statuses).not.toContain("connected");
|
|
281
|
+
expect(__testing.backoffState.get(ENDPOINT)?.attempt ?? 0).toBeGreaterThan(0);
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
});
|
|
@@ -1,12 +1,27 @@
|
|
|
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
23
|
* The client is wrapped to track subscriptions for cleanup on page reload.
|
|
24
|
+
* On connection failure, automatically reconnects and re-subscribes.
|
|
10
25
|
* @param endpoint Endpoint to connect to.
|
|
11
26
|
* @returns RPC client instance.
|
|
12
27
|
*/
|
|
@@ -27,3 +42,15 @@ export declare const initSyncedStateClient: (options?: {
|
|
|
27
42
|
* @param endpoint Endpoint associated with the injected client.
|
|
28
43
|
*/
|
|
29
44
|
export declare const setSyncedStateClientForTesting: (client: SyncedStateClient | null, endpoint?: string) => void;
|
|
45
|
+
export declare const __testing: {
|
|
46
|
+
activeSubscriptions: Set<Subscription>;
|
|
47
|
+
clientCache: Map<string, SyncedStateClient>;
|
|
48
|
+
backoffState: Map<string, {
|
|
49
|
+
attempt: number;
|
|
50
|
+
timer: ReturnType<typeof setTimeout> | null;
|
|
51
|
+
}>;
|
|
52
|
+
statusListeners: Map<string, StatusChangeCallback[]>;
|
|
53
|
+
reconnect: typeof reconnect;
|
|
54
|
+
getBackoffMs: typeof getBackoffMs;
|
|
55
|
+
};
|
|
56
|
+
export {};
|
|
@@ -1,8 +1,64 @@
|
|
|
1
1
|
import { newWebSocketRpcSession } from "capnweb";
|
|
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();
|
|
5
15
|
const activeSubscriptions = new Set();
|
|
16
|
+
// Status change listeners per endpoint. Uses an array rather than a Set so
|
|
17
|
+
// that two components passing the same callback reference (e.g. via
|
|
18
|
+
// createSyncedStateHook({ onStatusChange })) are tracked as two separate
|
|
19
|
+
// registrations — unsubscribing one must not cancel the other.
|
|
20
|
+
const statusListeners = new Map();
|
|
21
|
+
function notifyStatusChange(endpoint, status) {
|
|
22
|
+
const listeners = statusListeners.get(endpoint);
|
|
23
|
+
if (listeners) {
|
|
24
|
+
// Snapshot so unsubscribes fired by callbacks don't skip entries.
|
|
25
|
+
for (const cb of [...listeners]) {
|
|
26
|
+
cb(status);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Registers a callback that fires when the connection status changes for an endpoint.
|
|
32
|
+
* Returns an unsubscribe function.
|
|
33
|
+
*/
|
|
34
|
+
export const onStatusChange = (endpoint, callback) => {
|
|
35
|
+
const normalized = normalizeEndpoint(endpoint);
|
|
36
|
+
let listeners = statusListeners.get(normalized);
|
|
37
|
+
if (!listeners) {
|
|
38
|
+
listeners = [];
|
|
39
|
+
statusListeners.set(normalized, listeners);
|
|
40
|
+
}
|
|
41
|
+
listeners.push(callback);
|
|
42
|
+
return () => {
|
|
43
|
+
const idx = listeners.indexOf(callback);
|
|
44
|
+
if (idx !== -1) {
|
|
45
|
+
listeners.splice(idx, 1);
|
|
46
|
+
}
|
|
47
|
+
if (listeners.length === 0) {
|
|
48
|
+
statusListeners.delete(normalized);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
// Tracks per-endpoint reconnection backoff state
|
|
53
|
+
const backoffState = new Map();
|
|
54
|
+
const BASE_BACKOFF_MS = 1000;
|
|
55
|
+
const MAX_BACKOFF_MS = 30000;
|
|
56
|
+
function getBackoffMs(attempt) {
|
|
57
|
+
const base = Math.min(BASE_BACKOFF_MS * 2 ** attempt, MAX_BACKOFF_MS);
|
|
58
|
+
// Add ±25% jitter to avoid thundering herd on server restart
|
|
59
|
+
const jittered = base * (0.75 + Math.random() * 0.5);
|
|
60
|
+
return Math.round(Math.min(jittered, MAX_BACKOFF_MS));
|
|
61
|
+
}
|
|
6
62
|
// Set up beforeunload handler to unsubscribe all active subscriptions
|
|
7
63
|
if (typeof window !== "undefined") {
|
|
8
64
|
const handleBeforeUnload = () => {
|
|
@@ -22,18 +78,62 @@ if (typeof window !== "undefined") {
|
|
|
22
78
|
};
|
|
23
79
|
window.addEventListener("beforeunload", handleBeforeUnload);
|
|
24
80
|
}
|
|
81
|
+
function reconnect(endpoint, deadClient) {
|
|
82
|
+
// Don't schedule multiple reconnects for the same endpoint
|
|
83
|
+
const state = backoffState.get(endpoint) ?? { attempt: 0, timer: null };
|
|
84
|
+
if (state.timer !== null) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
notifyStatusChange(endpoint, "disconnected");
|
|
88
|
+
const delayMs = getBackoffMs(state.attempt);
|
|
89
|
+
state.timer = setTimeout(() => {
|
|
90
|
+
state.timer = null;
|
|
91
|
+
state.attempt++;
|
|
92
|
+
backoffState.set(endpoint, state);
|
|
93
|
+
notifyStatusChange(endpoint, "reconnecting");
|
|
94
|
+
// Evict the dead client so getSyncedStateClient creates a fresh one
|
|
95
|
+
clientCache.delete(endpoint);
|
|
96
|
+
const newClient = getSyncedStateClient(endpoint);
|
|
97
|
+
// Re-subscribe everything that was on the dead client. Kick off both
|
|
98
|
+
// subscribe() and getState() synchronously so callers see the calls
|
|
99
|
+
// happen inside the timer tick, but only confirm "connected" once the
|
|
100
|
+
// subscribe promises resolve — otherwise a rejected resubscription
|
|
101
|
+
// would be masked as a successful reconnect.
|
|
102
|
+
const subscribePromises = [];
|
|
103
|
+
for (const sub of activeSubscriptions) {
|
|
104
|
+
if (sub.client === deadClient) {
|
|
105
|
+
sub.client = newClient;
|
|
106
|
+
subscribePromises.push(newClient.subscribe(sub.key, sub.handler));
|
|
107
|
+
void newClient.getState(sub.key).then((val) => {
|
|
108
|
+
if (val !== undefined) {
|
|
109
|
+
sub.handler(val);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
Promise.all(subscribePromises).then(() => {
|
|
115
|
+
backoffState.set(endpoint, { attempt: 0, timer: null });
|
|
116
|
+
notifyStatusChange(endpoint, "connected");
|
|
117
|
+
}, () => {
|
|
118
|
+
// Resubscription failed — leave the attempt counter elevated so
|
|
119
|
+
// the next reconnect uses a longer backoff, and emit disconnected
|
|
120
|
+
// again. A subsequent onRpcBroken from the new (likely dead)
|
|
121
|
+
// client will drive the next retry.
|
|
122
|
+
notifyStatusChange(endpoint, "disconnected");
|
|
123
|
+
});
|
|
124
|
+
}, delayMs);
|
|
125
|
+
backoffState.set(endpoint, state);
|
|
126
|
+
}
|
|
25
127
|
/**
|
|
26
128
|
* Returns a cached client for the provided endpoint, creating it when necessary.
|
|
27
129
|
* The client is wrapped to track subscriptions for cleanup on page reload.
|
|
130
|
+
* On connection failure, automatically reconnects and re-subscribes.
|
|
28
131
|
* @param endpoint Endpoint to connect to.
|
|
29
132
|
* @returns RPC client instance.
|
|
30
133
|
*/
|
|
31
134
|
export const getSyncedStateClient = (endpoint = DEFAULT_SYNCED_STATE_PATH) => {
|
|
32
135
|
// Convert relative endpoint to absolute URL for environments like WKWebView
|
|
33
|
-
|
|
34
|
-
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
35
|
-
endpoint = `${protocol}//${window.location.host}${endpoint}`;
|
|
36
|
-
}
|
|
136
|
+
endpoint = normalizeEndpoint(endpoint);
|
|
37
137
|
// Return existing client if already cached for this endpoint
|
|
38
138
|
const existingClient = clientCache.get(endpoint);
|
|
39
139
|
if (existingClient) {
|
|
@@ -73,6 +173,12 @@ export const getSyncedStateClient = (endpoint = DEFAULT_SYNCED_STATE_PATH) => {
|
|
|
73
173
|
return target[prop];
|
|
74
174
|
},
|
|
75
175
|
});
|
|
176
|
+
// Listen for connection failure and trigger reconnection
|
|
177
|
+
if (typeof baseClient.onRpcBroken === "function") {
|
|
178
|
+
baseClient.onRpcBroken(() => {
|
|
179
|
+
reconnect(endpoint, wrappedClient);
|
|
180
|
+
});
|
|
181
|
+
}
|
|
76
182
|
// Cache the client for this endpoint
|
|
77
183
|
clientCache.set(endpoint, wrappedClient);
|
|
78
184
|
return wrappedClient;
|
|
@@ -105,4 +211,21 @@ export const setSyncedStateClientForTesting = (client, endpoint = DEFAULT_SYNCED
|
|
|
105
211
|
clientCache.delete(endpoint);
|
|
106
212
|
}
|
|
107
213
|
activeSubscriptions.clear();
|
|
214
|
+
statusListeners.clear();
|
|
215
|
+
// Clear any pending reconnection timers
|
|
216
|
+
for (const [, state] of backoffState) {
|
|
217
|
+
if (state.timer !== null) {
|
|
218
|
+
clearTimeout(state.timer);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
backoffState.clear();
|
|
222
|
+
};
|
|
223
|
+
// Exported for testing only
|
|
224
|
+
export const __testing = {
|
|
225
|
+
activeSubscriptions,
|
|
226
|
+
clientCache,
|
|
227
|
+
backoffState,
|
|
228
|
+
statusListeners,
|
|
229
|
+
reconnect,
|
|
230
|
+
getBackoffMs,
|
|
108
231
|
};
|
|
@@ -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) => {
|