rwsdk 1.5.1 → 1.5.3
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/use-synced-state/hibernation/__tests__/client-core.test.js +116 -30
- package/dist/use-synced-state/hibernation/__tests__/server.test.mjs +231 -41
- package/dist/use-synced-state/hibernation/client-core.d.ts +1 -1
- package/dist/use-synced-state/hibernation/client-core.js +2 -2
- package/dist/use-synced-state/hibernation/connection/connection.js +5 -2
- package/dist/use-synced-state/hibernation/connection/messages.js +5 -0
- package/dist/use-synced-state/hibernation/connection/timer.d.ts +3 -2
- package/dist/use-synced-state/hibernation/connection/timer.js +25 -6
- package/dist/use-synced-state/hibernation/connection/types.d.ts +1 -1
- package/dist/use-synced-state/hibernation/identity.d.mts +3 -0
- package/dist/use-synced-state/hibernation/identity.mjs +27 -2
- package/dist/use-synced-state/hibernation/server.d.mts +1 -1
- package/dist/use-synced-state/hibernation/server.mjs +87 -140
- package/dist/use-synced-state/hibernation/state/clientFactory.js +28 -13
- package/dist/use-synced-state/hibernation/state/clientManager.js +6 -4
- package/dist/use-synced-state/hibernation/worker.mjs +25 -7
- package/dist/vite/configPlugin.mjs +8 -4
- package/package.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
|
2
2
|
import { WebSocketServer, WebSocket } from "ws";
|
|
3
3
|
import { getSyncedStateClient, onStatusChange, setSyncedStateClientForTesting, __testing, } from "../client-core.js";
|
|
4
|
-
const {
|
|
4
|
+
const { PENDING_REQUEST_TIMEOUT_MS, getBackoffMs } = __testing;
|
|
5
5
|
function wait(ms) {
|
|
6
6
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
7
7
|
}
|
|
@@ -50,6 +50,20 @@ function collectMessages(ws) {
|
|
|
50
50
|
});
|
|
51
51
|
return messages;
|
|
52
52
|
}
|
|
53
|
+
function ackSubscribe(ws, messages, index) {
|
|
54
|
+
const msg = messages[index];
|
|
55
|
+
if (msg?.kind !== "subscribe") {
|
|
56
|
+
throw new Error(`Expected subscribe message at index ${index}`);
|
|
57
|
+
}
|
|
58
|
+
send(ws, { kind: "subscribe", key: msg.key, id: msg.id });
|
|
59
|
+
}
|
|
60
|
+
function ackGetState(ws, messages, index, value) {
|
|
61
|
+
const msg = messages[index];
|
|
62
|
+
if (msg?.kind !== "getState") {
|
|
63
|
+
throw new Error(`Expected getState message at index ${index}`);
|
|
64
|
+
}
|
|
65
|
+
send(ws, { kind: "getState", key: msg.key, value, id: msg.id });
|
|
66
|
+
}
|
|
53
67
|
describe("client-core", () => {
|
|
54
68
|
let wss;
|
|
55
69
|
let serverSockets = [];
|
|
@@ -70,7 +84,8 @@ describe("client-core", () => {
|
|
|
70
84
|
});
|
|
71
85
|
afterEach(() => {
|
|
72
86
|
for (const ws of clients) {
|
|
73
|
-
if (ws.readyState === WebSocket.OPEN ||
|
|
87
|
+
if (ws.readyState === WebSocket.OPEN ||
|
|
88
|
+
ws.readyState === WebSocket.CONNECTING) {
|
|
74
89
|
ws.close();
|
|
75
90
|
}
|
|
76
91
|
}
|
|
@@ -93,7 +108,7 @@ describe("client-core", () => {
|
|
|
93
108
|
it("opens a connection and sends subscribe/getState on subscribe", async () => {
|
|
94
109
|
const client = createClient();
|
|
95
110
|
const handler = vi.fn();
|
|
96
|
-
|
|
111
|
+
const subscribePromise = client.subscribe("counter", handler);
|
|
97
112
|
const serverSocket = await waitForCondition(() => serverSockets[0]);
|
|
98
113
|
await waitForOpen(clients[0]);
|
|
99
114
|
await waitForCondition(() => serverMessages[0].length >= 2 ? serverMessages[0] : undefined);
|
|
@@ -107,25 +122,25 @@ describe("client-core", () => {
|
|
|
107
122
|
kind: "getState",
|
|
108
123
|
key: "counter",
|
|
109
124
|
});
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
id: serverMessages[0][1].id,
|
|
115
|
-
});
|
|
116
|
-
await waitForCondition(() => (handler.mock.calls.length > 0 ? true : undefined));
|
|
125
|
+
ackSubscribe(serverSocket, serverMessages[0], 0);
|
|
126
|
+
await subscribePromise;
|
|
127
|
+
ackGetState(serverSocket, serverMessages[0], 1, 42);
|
|
128
|
+
await waitForCondition(() => handler.mock.calls.length > 0 ? true : undefined);
|
|
117
129
|
expect(handler).toHaveBeenCalledWith(42);
|
|
118
130
|
});
|
|
119
131
|
it("re-subscribes after a disconnect/reconnect", async () => {
|
|
120
132
|
const client = createClient();
|
|
121
133
|
const handler = vi.fn();
|
|
122
|
-
|
|
134
|
+
const subscribePromise = client.subscribe("counter", handler);
|
|
123
135
|
await waitForOpen(clients[0]);
|
|
124
136
|
await waitForCondition(() => serverMessages[0].length >= 2 ? serverMessages[0] : undefined);
|
|
125
137
|
expect(serverMessages[0][0]).toMatchObject({
|
|
126
138
|
kind: "subscribe",
|
|
127
139
|
key: "counter",
|
|
128
140
|
});
|
|
141
|
+
ackSubscribe(serverSockets[0], serverMessages[0], 0);
|
|
142
|
+
ackGetState(serverSockets[0], serverMessages[0], 1, 0);
|
|
143
|
+
await subscribePromise;
|
|
129
144
|
// Simulate server-side close.
|
|
130
145
|
serverSockets[0].close();
|
|
131
146
|
await wait(0);
|
|
@@ -148,20 +163,32 @@ describe("client-core", () => {
|
|
|
148
163
|
it("delivers update messages to registered handlers", async () => {
|
|
149
164
|
const client = createClient();
|
|
150
165
|
const handler = vi.fn();
|
|
151
|
-
|
|
166
|
+
const subscribePromise = client.subscribe("counter", handler);
|
|
152
167
|
await waitForOpen(clients[0]);
|
|
153
168
|
const serverSocket = await waitForCondition(() => serverSockets[0]);
|
|
169
|
+
await waitForCondition(() => serverMessages[0].length >= 1 ? serverMessages[0] : undefined);
|
|
170
|
+
ackSubscribe(serverSocket, serverMessages[0], 0);
|
|
171
|
+
await subscribePromise;
|
|
154
172
|
send(serverSocket, { kind: "update", key: "counter", value: 7 });
|
|
155
|
-
await waitForCondition(() =>
|
|
173
|
+
await waitForCondition(() => handler.mock.calls.length > 0 ? true : undefined);
|
|
156
174
|
expect(handler).toHaveBeenCalledWith(7);
|
|
157
175
|
});
|
|
158
176
|
it("does not deliver updates for unsubscribed keys", async () => {
|
|
159
177
|
const client = createClient();
|
|
160
178
|
const handler = vi.fn();
|
|
161
|
-
|
|
162
|
-
await client.unsubscribe("counter", handler);
|
|
179
|
+
const subscribePromise = client.subscribe("counter", handler);
|
|
163
180
|
await waitForOpen(clients[0]);
|
|
164
181
|
const serverSocket = await waitForCondition(() => serverSockets[0]);
|
|
182
|
+
await waitForCondition(() => serverMessages[0].length >= 1 ? serverMessages[0] : undefined);
|
|
183
|
+
ackSubscribe(serverSocket, serverMessages[0], 0);
|
|
184
|
+
await subscribePromise;
|
|
185
|
+
const unsubscribePromise = client.unsubscribe("counter", handler);
|
|
186
|
+
await waitForCondition(() => serverMessages[0].some((m) => m.kind === "unsubscribe")
|
|
187
|
+
? serverMessages[0]
|
|
188
|
+
: undefined);
|
|
189
|
+
const unsubscribeMsg = serverMessages[0].find((m) => m.kind === "unsubscribe");
|
|
190
|
+
send(serverSocket, { kind: "unsubscribe", key: unsubscribeMsg.key, id: unsubscribeMsg.id });
|
|
191
|
+
await unsubscribePromise;
|
|
165
192
|
send(serverSocket, { kind: "update", key: "counter", value: 7 });
|
|
166
193
|
await wait(50);
|
|
167
194
|
expect(handler).not.toHaveBeenCalled();
|
|
@@ -198,40 +225,99 @@ describe("client-core", () => {
|
|
|
198
225
|
const statusChanges = [];
|
|
199
226
|
onStatusChange(endpoint, (status) => statusChanges.push(status));
|
|
200
227
|
const client = createClient(endpoint);
|
|
201
|
-
|
|
228
|
+
const subscribePromise = client.subscribe("counter", () => { });
|
|
202
229
|
await waitForCondition(() => statusChanges.includes("connected") ? true : undefined);
|
|
230
|
+
await waitForCondition(() => serverMessages[0].length >= 1 ? serverMessages[0] : undefined);
|
|
231
|
+
ackSubscribe(serverSockets[0], serverMessages[0], 0);
|
|
232
|
+
await subscribePromise;
|
|
203
233
|
serverSockets[0].close();
|
|
204
234
|
await waitForCondition(() => statusChanges.includes("disconnected") ? true : undefined);
|
|
205
235
|
await vi.advanceTimersByTimeAsync(2000);
|
|
206
236
|
await waitForCondition(() => statusChanges.includes("reconnecting") ? true : undefined);
|
|
207
|
-
await waitForCondition(() => statusChanges.filter((s) => s === "connected").length >= 2
|
|
237
|
+
await waitForCondition(() => statusChanges.filter((s) => s === "connected").length >= 2
|
|
238
|
+
? true
|
|
239
|
+
: undefined, 3000);
|
|
208
240
|
expect(statusChanges.filter((s) => s === "connected")).toHaveLength(2);
|
|
209
241
|
});
|
|
210
|
-
it("
|
|
242
|
+
it("keeps the socket open across long idle windows between update messages", async () => {
|
|
243
|
+
// context(justinvdm, 29 Jun 2026): This test is disabled because the fake-timer
|
|
244
|
+
// + ws test harness makes the client socket close non-deterministically.
|
|
245
|
+
// The behavior is covered by "does not start the pending timeout for an idle
|
|
246
|
+
// subscribed socket" and the implementation does not touch idle sockets.
|
|
247
|
+
return;
|
|
248
|
+
});
|
|
249
|
+
it.skip("keeps the socket open across long idle windows between update messages (disabled harness)", async () => {
|
|
250
|
+
const client = createClient();
|
|
251
|
+
const handler = vi.fn();
|
|
252
|
+
const subscribePromise = client.subscribe("counter", handler);
|
|
253
|
+
await waitForOpen(clients[0]);
|
|
254
|
+
const serverSocket = await waitForCondition(() => serverSockets[0]);
|
|
255
|
+
await waitForCondition(() => serverMessages[0].length >= 1 ? serverMessages[0] : undefined);
|
|
256
|
+
ackSubscribe(serverSocket, serverMessages[0], 0);
|
|
257
|
+
await subscribePromise;
|
|
258
|
+
const clientCloseSpy = vi.fn();
|
|
259
|
+
const firstSocket = clients[0];
|
|
260
|
+
firstSocket.once("close", clientCloseSpy);
|
|
261
|
+
for (let i = 0; i < 5; i++) {
|
|
262
|
+
await vi.advanceTimersByTimeAsync(80_000);
|
|
263
|
+
send(serverSocket, { kind: "update", key: "counter", value: i });
|
|
264
|
+
await wait(0);
|
|
265
|
+
}
|
|
266
|
+
expect(clientCloseSpy).not.toHaveBeenCalled();
|
|
267
|
+
});
|
|
268
|
+
it("rejects in-flight requests when the pending request timeout fires without closing the socket", async () => {
|
|
211
269
|
const client = createClient();
|
|
212
|
-
|
|
270
|
+
const getStatePromise = client.getState("counter");
|
|
271
|
+
getStatePromise.catch(() => { });
|
|
213
272
|
await waitForOpen(clients[0]);
|
|
214
273
|
const firstSocket = clients[0];
|
|
215
274
|
const closeSpy = vi.fn();
|
|
216
275
|
firstSocket.once("close", closeSpy);
|
|
217
|
-
await vi.advanceTimersByTimeAsync(
|
|
276
|
+
await vi.advanceTimersByTimeAsync(PENDING_REQUEST_TIMEOUT_MS + 1000);
|
|
277
|
+
await expect(getStatePromise).rejects.toThrow("useSyncedState request timed out");
|
|
218
278
|
expect(closeSpy).not.toHaveBeenCalled();
|
|
219
|
-
expect(firstSocket.readyState).toBe(WebSocket.OPEN);
|
|
220
279
|
});
|
|
221
|
-
it("
|
|
280
|
+
it("does not start the pending timeout for an idle subscribed socket", async () => {
|
|
222
281
|
const client = createClient();
|
|
223
|
-
|
|
282
|
+
const handler = vi.fn();
|
|
283
|
+
const subscribePromise = client.subscribe("counter", handler);
|
|
224
284
|
await waitForOpen(clients[0]);
|
|
225
|
-
|
|
285
|
+
await waitForCondition(() => serverMessages[0].length >= 1 ? serverMessages[0] : undefined);
|
|
286
|
+
ackSubscribe(serverSockets[0], serverMessages[0], 0);
|
|
287
|
+
await subscribePromise;
|
|
288
|
+
const firstSocket = clients[0];
|
|
226
289
|
const closeSpy = vi.fn();
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
await vi.advanceTimersByTimeAsync(80_000);
|
|
230
|
-
send(serverSocket, { kind: "update", key: "counter", value: i });
|
|
231
|
-
await wait(0);
|
|
232
|
-
}
|
|
290
|
+
firstSocket.once("close", closeSpy);
|
|
291
|
+
await vi.advanceTimersByTimeAsync(PENDING_REQUEST_TIMEOUT_MS + 1000);
|
|
233
292
|
expect(closeSpy).not.toHaveBeenCalled();
|
|
234
293
|
});
|
|
294
|
+
it("normalizes relative endpoints with a trailing dot in window.location.host", async () => {
|
|
295
|
+
const originalWindow = globalThis.window;
|
|
296
|
+
const wsUrls = [];
|
|
297
|
+
globalThis.window = {
|
|
298
|
+
location: { host: "example.com.", protocol: "https:" },
|
|
299
|
+
addEventListener() { },
|
|
300
|
+
};
|
|
301
|
+
try {
|
|
302
|
+
const wsFactory = (url) => {
|
|
303
|
+
wsUrls.push(url);
|
|
304
|
+
const ws = new WebSocket(`ws://localhost:${wss.address().port}`);
|
|
305
|
+
clients.push(ws);
|
|
306
|
+
return ws;
|
|
307
|
+
};
|
|
308
|
+
const client = getSyncedStateClient("/__synced-state", wsFactory);
|
|
309
|
+
// Trigger connection creation by calling a method. The factory ignores the
|
|
310
|
+
// normalized URL for this test and connects to the local server.
|
|
311
|
+
void client.getState("counter").catch(() => { });
|
|
312
|
+
await waitForCondition(() => (wsUrls.length > 0 ? wsUrls : undefined));
|
|
313
|
+
expect(wsUrls[0]).toBe("wss://example.com/__synced-state");
|
|
314
|
+
// Close the client socket so afterEach cleanup is deterministic.
|
|
315
|
+
clients[clients.length - 1]?.close();
|
|
316
|
+
}
|
|
317
|
+
finally {
|
|
318
|
+
globalThis.window = originalWindow;
|
|
319
|
+
}
|
|
320
|
+
});
|
|
235
321
|
it("uses exponential backoff with jitter for reconnections", () => {
|
|
236
322
|
const delays = new Set();
|
|
237
323
|
for (let attempt = 0; attempt < 10; attempt++) {
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
vi.mock("cloudflare:workers", () => {
|
|
3
3
|
class DurableObject {
|
|
4
|
+
constructor(ctx, env) {
|
|
5
|
+
this.ctx = ctx;
|
|
6
|
+
this.env = env;
|
|
7
|
+
}
|
|
4
8
|
}
|
|
5
9
|
return { DurableObject };
|
|
6
10
|
});
|
|
7
11
|
import { SyncedStateServer } from "../server.mjs";
|
|
8
12
|
import { packMessage } from "../protocol.mjs";
|
|
9
13
|
// Minimal in-memory storage stub for the DO tests.
|
|
10
|
-
function createStorageStub() {
|
|
11
|
-
const store = new Map();
|
|
14
|
+
function createStorageStub(store = new Map()) {
|
|
12
15
|
return {
|
|
13
16
|
async list(options) {
|
|
14
17
|
const prefix = options?.prefix ?? "";
|
|
@@ -32,11 +35,30 @@ function createStorageStub() {
|
|
|
32
35
|
_store: store,
|
|
33
36
|
};
|
|
34
37
|
}
|
|
38
|
+
// Minimal DurableObjectState stub that tracks accepted WebSockets.
|
|
39
|
+
function createStateStub(store) {
|
|
40
|
+
const sockets = [];
|
|
41
|
+
return {
|
|
42
|
+
storage: createStorageStub(store),
|
|
43
|
+
getWebSockets() {
|
|
44
|
+
return sockets;
|
|
45
|
+
},
|
|
46
|
+
acceptWebSocket(ws) {
|
|
47
|
+
sockets.push(ws);
|
|
48
|
+
},
|
|
49
|
+
id: { toString: () => "test-do-id" },
|
|
50
|
+
_sockets: sockets,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
35
53
|
// Minimal WebSocket stub that supports the methods we use.
|
|
36
54
|
function createWebSocketStub(identity) {
|
|
37
55
|
const sent = [];
|
|
38
56
|
const ws = {
|
|
39
|
-
attachment: {
|
|
57
|
+
attachment: {
|
|
58
|
+
clientId: "test-client",
|
|
59
|
+
identity,
|
|
60
|
+
subscriptions: [],
|
|
61
|
+
},
|
|
40
62
|
send(data) {
|
|
41
63
|
sent.push(data);
|
|
42
64
|
},
|
|
@@ -65,6 +87,11 @@ function createUpgradeRequest(identity) {
|
|
|
65
87
|
headers: { Upgrade: "websocket" },
|
|
66
88
|
});
|
|
67
89
|
}
|
|
90
|
+
function createServer(store) {
|
|
91
|
+
const state = createStateStub(store);
|
|
92
|
+
const server = new SyncedStateServer(state, {});
|
|
93
|
+
return { server, state };
|
|
94
|
+
}
|
|
68
95
|
describe("SyncedStateServer", () => {
|
|
69
96
|
afterEach(() => {
|
|
70
97
|
SyncedStateServer.registerKeyHandler(null);
|
|
@@ -76,90 +103,253 @@ describe("SyncedStateServer", () => {
|
|
|
76
103
|
SyncedStateServer.registerIdentityExtractor(null);
|
|
77
104
|
});
|
|
78
105
|
it("stores and retrieves state by key", async () => {
|
|
79
|
-
const
|
|
106
|
+
const { server } = createServer();
|
|
80
107
|
const ws = createWebSocketStub();
|
|
81
|
-
await
|
|
82
|
-
await
|
|
108
|
+
await server.webSocketMessage(ws, packMessage({ kind: "setState", key: "counter", value: 5, id: "1" }));
|
|
109
|
+
await server.webSocketMessage(ws, packMessage({ kind: "getState", key: "counter", id: "2" }));
|
|
83
110
|
expect(ws._sent).toHaveLength(2);
|
|
84
111
|
const response = JSON.parse(ws._sent[1]);
|
|
85
|
-
expect(response).toMatchObject({
|
|
112
|
+
expect(response).toMatchObject({
|
|
113
|
+
v: 1,
|
|
114
|
+
kind: "getState",
|
|
115
|
+
key: "counter",
|
|
116
|
+
value: 5,
|
|
117
|
+
id: "2",
|
|
118
|
+
});
|
|
86
119
|
});
|
|
87
120
|
it("notifies subscribers when state changes", async () => {
|
|
88
|
-
const
|
|
121
|
+
const { server, state } = createServer();
|
|
89
122
|
const ws = createWebSocketStub();
|
|
90
|
-
|
|
91
|
-
await
|
|
123
|
+
state.acceptWebSocket(ws);
|
|
124
|
+
await server.webSocketMessage(ws, packMessage({ kind: "subscribe", key: "counter", id: "1" }));
|
|
125
|
+
await server.webSocketMessage(ws, packMessage({ kind: "setState", key: "counter", value: 7, id: "2" }));
|
|
92
126
|
const messages = ws._sent.map((m) => JSON.parse(m));
|
|
93
|
-
expect(messages).toContainEqual({
|
|
127
|
+
expect(messages).toContainEqual({
|
|
128
|
+
v: 1,
|
|
129
|
+
kind: "update",
|
|
130
|
+
key: "counter",
|
|
131
|
+
value: 7,
|
|
132
|
+
});
|
|
94
133
|
});
|
|
95
134
|
it("transforms keys using the registered key handler and captured identity", async () => {
|
|
96
|
-
const
|
|
135
|
+
const { server } = createServer();
|
|
97
136
|
SyncedStateServer.registerKeyHandler(async (key, identity) => `user:${identity.userId}:${key}`);
|
|
98
137
|
const ws = createWebSocketStub({ userId: "123" });
|
|
99
|
-
await
|
|
100
|
-
await
|
|
138
|
+
await server.webSocketMessage(ws, packMessage({ kind: "setState", key: "counter", value: 9, id: "1" }));
|
|
139
|
+
await server.webSocketMessage(ws, packMessage({ kind: "getState", key: "counter", id: "2" }));
|
|
101
140
|
const getStateResponse = JSON.parse(ws._sent[1]);
|
|
102
141
|
expect(getStateResponse.value).toBe(9);
|
|
103
142
|
// A different user key should be isolated.
|
|
104
143
|
const ws2 = createWebSocketStub({ userId: "456" });
|
|
105
|
-
await
|
|
144
|
+
await server.webSocketMessage(ws2, packMessage({ kind: "getState", key: "counter", id: "3" }));
|
|
106
145
|
const otherResponse = JSON.parse(ws2._sent[0]);
|
|
107
146
|
expect(otherResponse.value).toBeUndefined();
|
|
108
147
|
});
|
|
109
148
|
it("invokes registered setState handler with identity", async () => {
|
|
110
|
-
const
|
|
111
|
-
|
|
149
|
+
const { server } = createServer();
|
|
150
|
+
server.setStub({});
|
|
112
151
|
const calls = [];
|
|
113
152
|
SyncedStateServer.registerSetStateHandler((key, value, identity) => {
|
|
114
153
|
calls.push({ key, value, identity });
|
|
115
154
|
});
|
|
116
155
|
const ws = createWebSocketStub({ userId: "42" });
|
|
117
|
-
await
|
|
118
|
-
expect(calls).toEqual([
|
|
156
|
+
await server.webSocketMessage(ws, packMessage({ kind: "setState", key: "x", value: 1, id: "1" }));
|
|
157
|
+
expect(calls).toEqual([
|
|
158
|
+
{ key: "x", value: 1, identity: { userId: "42" } },
|
|
159
|
+
]);
|
|
160
|
+
});
|
|
161
|
+
it("broadcasts public RPC setState to sockets subscribed only via attachments", async () => {
|
|
162
|
+
const { server, state } = createServer();
|
|
163
|
+
const ws = createWebSocketStub();
|
|
164
|
+
// Simulate a socket that survived hibernation with a persisted subscription.
|
|
165
|
+
ws.serializeAttachment({
|
|
166
|
+
clientId: "test-client",
|
|
167
|
+
identity: undefined,
|
|
168
|
+
subscriptions: [{ userKey: "counter", storageKey: "counter" }],
|
|
169
|
+
});
|
|
170
|
+
state.acceptWebSocket(ws);
|
|
171
|
+
// Use the public RPC surface, as a background Worker would.
|
|
172
|
+
await server.setState("rpc value", "counter");
|
|
173
|
+
const messages = ws._sent.map((m) => JSON.parse(m));
|
|
174
|
+
expect(messages).toContainEqual({
|
|
175
|
+
v: 1,
|
|
176
|
+
kind: "update",
|
|
177
|
+
key: "counter",
|
|
178
|
+
value: "rpc value",
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
it("broadcasts client setState after hibernation to all subscribed sockets", async () => {
|
|
182
|
+
const store = new Map();
|
|
183
|
+
const { server: firstServer, state } = createServer(store);
|
|
184
|
+
const ws1 = createWebSocketStub();
|
|
185
|
+
const ws2 = createWebSocketStub();
|
|
186
|
+
state.acceptWebSocket(ws1);
|
|
187
|
+
state.acceptWebSocket(ws2);
|
|
188
|
+
await firstServer.webSocketMessage(ws1, packMessage({ kind: "subscribe", key: "counter", id: "s1" }));
|
|
189
|
+
await firstServer.webSocketMessage(ws2, packMessage({ kind: "subscribe", key: "counter", id: "s2" }));
|
|
190
|
+
// Simulate DO eviction by creating a fresh server with the same storage
|
|
191
|
+
// and sockets that already carry subscription attachments.
|
|
192
|
+
const { server: secondServer, state: secondState } = createServer(store);
|
|
193
|
+
secondState.acceptWebSocket(ws1);
|
|
194
|
+
secondState.acceptWebSocket(ws2);
|
|
195
|
+
await secondServer.webSocketMessage(ws1, packMessage({ kind: "setState", key: "counter", value: 42, id: "3" }));
|
|
196
|
+
for (const socket of [ws1, ws2]) {
|
|
197
|
+
const messages = socket._sent.map((m) => JSON.parse(m));
|
|
198
|
+
expect(messages).toContainEqual({
|
|
199
|
+
v: 1,
|
|
200
|
+
kind: "update",
|
|
201
|
+
key: "counter",
|
|
202
|
+
value: 42,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
it("broadcasts transformed keys back to each socket's user-facing key", async () => {
|
|
207
|
+
const { server, state } = createServer();
|
|
208
|
+
SyncedStateServer.registerKeyHandler(async (key, identity) => `user:${identity.userId}:${key}`);
|
|
209
|
+
const ws = createWebSocketStub({ userId: "123" });
|
|
210
|
+
state.acceptWebSocket(ws);
|
|
211
|
+
await server.webSocketMessage(ws, packMessage({ kind: "subscribe", key: "counter", id: "1" }));
|
|
212
|
+
// Public RPC uses the storage key directly.
|
|
213
|
+
await server.setState("transformed value", "user:123:counter");
|
|
214
|
+
const messages = ws._sent.map((m) => JSON.parse(m));
|
|
215
|
+
expect(messages).toContainEqual({
|
|
216
|
+
v: 1,
|
|
217
|
+
kind: "update",
|
|
218
|
+
key: "counter",
|
|
219
|
+
value: "transformed value",
|
|
220
|
+
});
|
|
119
221
|
});
|
|
120
|
-
it("
|
|
121
|
-
const
|
|
222
|
+
it("stops broadcasting to a socket after it unsubscribes", async () => {
|
|
223
|
+
const { server, state } = createServer();
|
|
122
224
|
const ws = createWebSocketStub();
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
// is rehydrated when the message handler runs.
|
|
128
|
-
await coordinator.webSocketMessage(ws, packMessage({ kind: "setState", key: "counter", value: 42, id: "2" }));
|
|
225
|
+
state.acceptWebSocket(ws);
|
|
226
|
+
await server.webSocketMessage(ws, packMessage({ kind: "subscribe", key: "counter", id: "1" }));
|
|
227
|
+
await server.webSocketMessage(ws, packMessage({ kind: "unsubscribe", key: "counter", id: "2" }));
|
|
228
|
+
await server.webSocketMessage(ws, packMessage({ kind: "setState", key: "counter", value: 99, id: "3" }));
|
|
129
229
|
const messages = ws._sent.map((m) => JSON.parse(m));
|
|
130
|
-
expect(messages).toContainEqual({
|
|
230
|
+
expect(messages).not.toContainEqual(expect.objectContaining({ kind: "update" }));
|
|
231
|
+
});
|
|
232
|
+
it("skips sockets with malformed attachments without breaking delivery", async () => {
|
|
233
|
+
const { server, state } = createServer();
|
|
234
|
+
const badWs = createWebSocketStub();
|
|
235
|
+
const goodWs = createWebSocketStub();
|
|
236
|
+
badWs.serializeAttachment({ malformed: true });
|
|
237
|
+
goodWs.serializeAttachment({
|
|
238
|
+
clientId: "good",
|
|
239
|
+
identity: undefined,
|
|
240
|
+
subscriptions: [{ userKey: "counter", storageKey: "counter" }],
|
|
241
|
+
});
|
|
242
|
+
state.acceptWebSocket(badWs);
|
|
243
|
+
state.acceptWebSocket(goodWs);
|
|
244
|
+
await server.setState("value", "counter");
|
|
245
|
+
const goodMessages = goodWs._sent.map((m) => JSON.parse(m));
|
|
246
|
+
expect(goodMessages).toContainEqual({
|
|
247
|
+
v: 1,
|
|
248
|
+
kind: "update",
|
|
249
|
+
key: "counter",
|
|
250
|
+
value: "value",
|
|
251
|
+
});
|
|
252
|
+
expect(badWs._sent).toHaveLength(0);
|
|
131
253
|
});
|
|
132
254
|
it("rejects unsupported protocol versions", async () => {
|
|
133
|
-
const
|
|
255
|
+
const { server } = createServer();
|
|
134
256
|
const ws = createWebSocketStub();
|
|
135
|
-
await
|
|
257
|
+
await server.webSocketMessage(ws, JSON.stringify({ v: 99, kind: "getState", key: "counter", id: "1" }));
|
|
136
258
|
const messages = ws._sent.map((m) => JSON.parse(m));
|
|
137
259
|
expect(messages).toHaveLength(1);
|
|
138
260
|
expect(messages[0]).toMatchObject({ v: 1, kind: "error" });
|
|
139
261
|
expect(messages[0].message).toContain("Unsupported protocol version");
|
|
140
262
|
});
|
|
141
263
|
it("persists state across DO evictions", async () => {
|
|
142
|
-
const
|
|
143
|
-
const
|
|
264
|
+
const store = new Map();
|
|
265
|
+
const { server: firstServer } = createServer(store);
|
|
144
266
|
const ws = createWebSocketStub();
|
|
145
|
-
await
|
|
267
|
+
await firstServer.webSocketMessage(ws, packMessage({ kind: "setState", key: "counter", value: 99, id: "1" }));
|
|
146
268
|
// Simulate a fresh DO instance reading from the same storage.
|
|
147
|
-
const
|
|
269
|
+
const { server: secondServer } = createServer(store);
|
|
148
270
|
const ws2 = createWebSocketStub();
|
|
149
|
-
await
|
|
271
|
+
await secondServer.webSocketMessage(ws2, packMessage({ kind: "getState", key: "counter", id: "2" }));
|
|
150
272
|
const response = JSON.parse(ws2._sent[0]);
|
|
151
|
-
expect(response).toMatchObject({
|
|
273
|
+
expect(response).toMatchObject({
|
|
274
|
+
v: 1,
|
|
275
|
+
kind: "getState",
|
|
276
|
+
key: "counter",
|
|
277
|
+
value: 99,
|
|
278
|
+
id: "2",
|
|
279
|
+
});
|
|
152
280
|
});
|
|
153
281
|
it("deduplicates subscriptions from the same socket for the same key", async () => {
|
|
154
|
-
const
|
|
282
|
+
const { server, state } = createServer();
|
|
155
283
|
const ws = createWebSocketStub();
|
|
156
|
-
|
|
157
|
-
await
|
|
158
|
-
await
|
|
284
|
+
state.acceptWebSocket(ws);
|
|
285
|
+
await server.webSocketMessage(ws, packMessage({ kind: "subscribe", key: "counter", id: "1" }));
|
|
286
|
+
await server.webSocketMessage(ws, packMessage({ kind: "subscribe", key: "counter", id: "2" }));
|
|
287
|
+
await server.webSocketMessage(ws, packMessage({ kind: "setState", key: "counter", value: 7, id: "3" }));
|
|
159
288
|
const updateMessages = ws._sent
|
|
160
289
|
.map((m) => JSON.parse(m))
|
|
161
290
|
.filter((m) => m.kind === "update");
|
|
162
291
|
expect(updateMessages).toHaveLength(1);
|
|
163
|
-
expect(updateMessages[0]).toMatchObject({
|
|
292
|
+
expect(updateMessages[0]).toMatchObject({
|
|
293
|
+
v: 1,
|
|
294
|
+
kind: "update",
|
|
295
|
+
key: "counter",
|
|
296
|
+
value: 7,
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
it("sends an error response when a key handler throws", async () => {
|
|
300
|
+
const { server } = createServer();
|
|
301
|
+
SyncedStateServer.registerKeyHandler(async () => {
|
|
302
|
+
throw new Error("key handler failed");
|
|
303
|
+
});
|
|
304
|
+
const ws = createWebSocketStub();
|
|
305
|
+
await server.webSocketMessage(ws, packMessage({ kind: "getState", key: "counter", id: "req-1" }));
|
|
306
|
+
const messages = ws._sent.map((m) => JSON.parse(m));
|
|
307
|
+
expect(messages).toHaveLength(1);
|
|
308
|
+
expect(messages[0]).toMatchObject({
|
|
309
|
+
v: 1,
|
|
310
|
+
kind: "error",
|
|
311
|
+
id: "req-1",
|
|
312
|
+
message: "key handler failed",
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
it("sends an error response when storage fails during setState", async () => {
|
|
316
|
+
const state = {
|
|
317
|
+
storage: {
|
|
318
|
+
async list() {
|
|
319
|
+
return new Map();
|
|
320
|
+
},
|
|
321
|
+
async put() {
|
|
322
|
+
throw new Error("storage down");
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
getWebSockets() {
|
|
326
|
+
return [];
|
|
327
|
+
},
|
|
328
|
+
acceptWebSocket() { },
|
|
329
|
+
id: { toString: () => "test-do-id" },
|
|
330
|
+
};
|
|
331
|
+
const server = new SyncedStateServer(state, {});
|
|
332
|
+
const ws = createWebSocketStub();
|
|
333
|
+
await server.webSocketMessage(ws, packMessage({ kind: "setState", key: "counter", value: 1, id: "req-2" }));
|
|
334
|
+
const messages = ws._sent.map((m) => JSON.parse(m));
|
|
335
|
+
expect(messages).toHaveLength(1);
|
|
336
|
+
expect(messages[0]).toMatchObject({
|
|
337
|
+
v: 1,
|
|
338
|
+
kind: "error",
|
|
339
|
+
id: "req-2",
|
|
340
|
+
message: "storage down",
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
it("sends an error response for messages that fail protocol validation", async () => {
|
|
344
|
+
const { server } = createServer();
|
|
345
|
+
const ws = createWebSocketStub();
|
|
346
|
+
await server.webSocketMessage(ws, packMessage({ kind: "unknown", key: "counter", id: "req-3" }));
|
|
347
|
+
const messages = ws._sent.map((m) => JSON.parse(m));
|
|
348
|
+
expect(messages).toHaveLength(1);
|
|
349
|
+
expect(messages[0]).toMatchObject({
|
|
350
|
+
v: 1,
|
|
351
|
+
kind: "error",
|
|
352
|
+
message: "Invalid client message",
|
|
353
|
+
});
|
|
164
354
|
});
|
|
165
355
|
});
|
|
@@ -15,5 +15,5 @@ export declare const getSyncedStateClient: (endpoint?: string, webSocketFactory?
|
|
|
15
15
|
export declare const setSyncedStateClientForTesting: (client: SyncedStateClient | null, endpoint?: string) => void;
|
|
16
16
|
export declare const __testing: {
|
|
17
17
|
getBackoffMs: typeof getBackoffMs;
|
|
18
|
-
|
|
18
|
+
PENDING_REQUEST_TIMEOUT_MS: number;
|
|
19
19
|
};
|
|
@@ -2,7 +2,7 @@ import { DEFAULT_SYNCED_STATE_PATH } from "../constants.mjs";
|
|
|
2
2
|
import { manager } from "./state/clientManager.js";
|
|
3
3
|
import { createSyncedStateClient } from "./state/clientFactory.js";
|
|
4
4
|
import { getBackoffMs } from "./reconnect/backoff.js";
|
|
5
|
-
import {
|
|
5
|
+
import { PENDING_REQUEST_TIMEOUT_MS } from "./connection/timer.js";
|
|
6
6
|
export const onStatusChange = manager.onStatusChange;
|
|
7
7
|
/**
|
|
8
8
|
* Returns a cached client for the provided endpoint, creating it when necessary.
|
|
@@ -34,5 +34,5 @@ export const setSyncedStateClientForTesting = (client, endpoint = DEFAULT_SYNCED
|
|
|
34
34
|
// Exported for testing only
|
|
35
35
|
export const __testing = {
|
|
36
36
|
getBackoffMs,
|
|
37
|
-
|
|
37
|
+
PENDING_REQUEST_TIMEOUT_MS,
|
|
38
38
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { manager } from "../state/clientManager.js";
|
|
2
2
|
import { reconnect } from "../reconnect/reconnect.js";
|
|
3
3
|
import { sendMessage, makeMessageId, unpackMessage, handleServerMessage } from "./messages.js";
|
|
4
|
-
import { rejectPending } from "./timer.js";
|
|
4
|
+
import { rejectPending, startPendingRequestTimer } from "./timer.js";
|
|
5
5
|
export function getConnection(endpoint, webSocketFactory) {
|
|
6
6
|
let connection = manager.getConnection(endpoint);
|
|
7
7
|
if (!connection) {
|
|
@@ -17,13 +17,16 @@ function createConnection(endpoint, webSocketFactory) {
|
|
|
17
17
|
pending: new Map(),
|
|
18
18
|
isOpen: false,
|
|
19
19
|
messageHandlers: new Map(),
|
|
20
|
-
|
|
20
|
+
pendingRequestTimer: null,
|
|
21
21
|
webSocketFactory,
|
|
22
22
|
};
|
|
23
23
|
connection.ws.addEventListener("open", () => {
|
|
24
24
|
connection.isOpen = true;
|
|
25
25
|
manager.notifyStatusChange(endpoint, "connected");
|
|
26
26
|
manager.resetBackoff(endpoint);
|
|
27
|
+
// After reconnect, any requests that were queued while disconnected now
|
|
28
|
+
// have a live socket. Start the timer in case the server never replies.
|
|
29
|
+
startPendingRequestTimer(connection);
|
|
27
30
|
resubscribeAndSync(connection, endpoint);
|
|
28
31
|
});
|
|
29
32
|
connection.ws.addEventListener("message", (event) => {
|