rwsdk 1.5.0 → 1.5.2

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.
@@ -1,4 +1,5 @@
1
1
  import { packMessage, unpackServerMessage, } from "../protocol.mjs";
2
+ import { startPendingRequestTimer } from "./timer.js";
2
3
  export function makeMessageId(connection) {
3
4
  return `${connection.nextId++}`;
4
5
  }
@@ -7,6 +8,7 @@ export async function sendMessage(connection, message) {
7
8
  if (isOpen) {
8
9
  return new Promise((resolve, reject) => {
9
10
  pending.set(message.id, { resolve, reject });
11
+ startPendingRequestTimer(connection);
10
12
  ws.send(packMessage(message));
11
13
  });
12
14
  }
@@ -14,6 +16,7 @@ export async function sendMessage(connection, message) {
14
16
  const onOpen = () => {
15
17
  cleanup();
16
18
  connection.pending.set(message.id, { resolve, reject });
19
+ startPendingRequestTimer(connection);
17
20
  connection.ws.send(packMessage(message));
18
21
  };
19
22
  const onClose = () => {
@@ -44,6 +47,7 @@ export function handleServerMessage(connection, message) {
44
47
  pending.reject(new Error(message.message));
45
48
  }
46
49
  }
50
+ startPendingRequestTimer(connection);
47
51
  return;
48
52
  }
49
53
  const pending = connection.pending.get(message.id);
@@ -51,6 +55,7 @@ export function handleServerMessage(connection, message) {
51
55
  return;
52
56
  connection.pending.delete(message.id);
53
57
  pending.resolve(message.kind === "getState" ? message.value : undefined);
58
+ startPendingRequestTimer(connection);
54
59
  }
55
60
  export function unpackMessage(data) {
56
61
  if (typeof data !== "string")
@@ -1,5 +1,5 @@
1
1
  import { type Connection } from "./types.js";
2
- export declare const DEAD_CONNECTION_TIMEOUT_MS = 90000;
3
- export declare function cleanupConnectionTimers(connection: Connection): void;
2
+ export declare const PENDING_REQUEST_TIMEOUT_MS = 30000;
3
+ export declare function startPendingRequestTimer(connection: Connection): void;
4
+ export declare function stopPendingRequestTimer(connection: Connection): void;
4
5
  export declare function rejectPending(connection: Connection, reason: string): void;
5
- export declare function resetDeadConnectionTimer(connection: Connection, endpoint: string): void;
@@ -1,10 +1,27 @@
1
- import { manager } from "../state/clientManager.js";
2
- import { reconnect } from "../reconnect/reconnect.js";
3
- export const DEAD_CONNECTION_TIMEOUT_MS = 90_000;
4
- export function cleanupConnectionTimers(connection) {
5
- if (connection.deadConnectionTimer) {
6
- clearTimeout(connection.deadConnectionTimer);
7
- connection.deadConnectionTimer = null;
1
+ // context(justinvdm, 29 Jun 2026): This timeout applies only to requests that
2
+ // are waiting for a server response, not to idle connections. Hibernation
3
+ // relies on idle sockets being allowed to sleep, so we must not close a socket
4
+ // just because no traffic has arrived.
5
+ export const PENDING_REQUEST_TIMEOUT_MS = 30_000;
6
+ export function startPendingRequestTimer(connection) {
7
+ stopPendingRequestTimer(connection);
8
+ if (connection.pending.size === 0) {
9
+ return;
10
+ }
11
+ connection.pendingRequestTimer = setTimeout(() => {
12
+ // We intentionally do not close the socket here. In Cloudflare's
13
+ // environment a half-open WebSocket is unlikely; the server should
14
+ // already have sent an error frame for any throw (see server.mts).
15
+ // Closing would trigger a visible reconnect that looks like the old
16
+ // idle-timeout symptom. We only reject the pending promises so the
17
+ // caller is not left hanging forever.
18
+ rejectPending(connection, "useSyncedState request timed out");
19
+ }, PENDING_REQUEST_TIMEOUT_MS);
20
+ }
21
+ export function stopPendingRequestTimer(connection) {
22
+ if (connection.pendingRequestTimer) {
23
+ clearTimeout(connection.pendingRequestTimer);
24
+ connection.pendingRequestTimer = null;
8
25
  }
9
26
  }
10
27
  export function rejectPending(connection, reason) {
@@ -12,22 +29,5 @@ export function rejectPending(connection, reason) {
12
29
  pending.reject(new Error(reason));
13
30
  }
14
31
  connection.pending.clear();
15
- }
16
- export function resetDeadConnectionTimer(connection, endpoint) {
17
- if (connection.deadConnectionTimer) {
18
- clearTimeout(connection.deadConnectionTimer);
19
- }
20
- connection.deadConnectionTimer = setTimeout(() => {
21
- try {
22
- connection.ws.close();
23
- }
24
- catch { }
25
- connection.isOpen = false;
26
- rejectPending(connection, "WebSocket timed out");
27
- cleanupConnectionTimers(connection);
28
- if (manager.getConnection(endpoint) === connection) {
29
- manager.deleteConnection(endpoint);
30
- reconnect(endpoint);
31
- }
32
- }, DEAD_CONNECTION_TIMEOUT_MS);
32
+ stopPendingRequestTimer(connection);
33
33
  }
@@ -17,6 +17,6 @@ export type Connection = {
17
17
  pending: Map<string, PendingRequest>;
18
18
  isOpen: boolean;
19
19
  messageHandlers: Map<string, Set<(value: unknown) => void>>;
20
- deadConnectionTimer: ReturnType<typeof setTimeout> | null;
20
+ pendingRequestTimer: ReturnType<typeof setTimeout> | null;
21
21
  webSocketFactory: WebSocketFactory;
22
22
  };
@@ -1,4 +1,7 @@
1
1
  export type SyncedStateIdentity = unknown;
2
+ export declare class SyncedStateIdentityError extends Error {
3
+ constructor(message: string);
4
+ }
2
5
  export declare function setIdentityInUrl(identity: SyncedStateIdentity, url: URL): URL;
3
6
  export declare function getIdentityFromUrl(url: URL): SyncedStateIdentity;
4
7
  export declare function removeIdentityFromUrl(url: URL): URL;
@@ -1,10 +1,29 @@
1
1
  const IDENTITY_QUERY_PARAM = "__ssi";
2
- // context(justinvdm, 19 Jun 2026): Serialize the captured identity into a URL
2
+ const MAX_IDENTITY_SIZE_BYTES = 4096;
3
+ export class SyncedStateIdentityError extends Error {
4
+ constructor(message) {
5
+ super(message);
6
+ this.name = "SyncedStateIdentityError";
7
+ }
8
+ }
9
+ // context(justinvdm, 29 Jun 2026): Serialize the captured identity into a URL
3
10
  // query parameter so the worker can pass it to the DO during the WebSocket
4
11
  // upgrade. The identity is extracted from requestInfo in the worker and is not
5
12
  // user-controlled, so passing it in the internal worker->DO request is safe.
13
+ // We validate serializability and size here so a bad extractor fails the
14
+ // handshake with a clear error instead of an opaque internal failure.
6
15
  export function setIdentityInUrl(identity, url) {
7
- url.searchParams.set(IDENTITY_QUERY_PARAM, JSON.stringify(identity));
16
+ let serialized;
17
+ try {
18
+ serialized = JSON.stringify(identity);
19
+ }
20
+ catch (error) {
21
+ throw new SyncedStateIdentityError(`useSyncedState identity must be JSON-serializable: ${error instanceof Error ? error.message : String(error)}`);
22
+ }
23
+ if (serialized.length > MAX_IDENTITY_SIZE_BYTES) {
24
+ throw new SyncedStateIdentityError(`useSyncedState identity exceeds maximum size of ${MAX_IDENTITY_SIZE_BYTES} bytes`);
25
+ }
26
+ url.searchParams.set(IDENTITY_QUERY_PARAM, serialized);
8
27
  return url;
9
28
  }
10
29
  export function getIdentityFromUrl(url) {
@@ -55,7 +55,7 @@ export declare class SyncedStateServer extends DurableObject {
55
55
  setStub(stub: DurableObjectStub<SyncedStateServer>): void;
56
56
  fetch(request: Request): Promise<Response>;
57
57
  webSocketMessage(ws: WebSocket, data: string | ArrayBuffer): Promise<void>;
58
- webSocketClose(ws: WebSocket): Promise<void>;
58
+ webSocketClose(_ws: WebSocket): Promise<void>;
59
59
  getState(key: string): Promise<SyncedStateValue | undefined>;
60
60
  setState(value: SyncedStateValue, key: string): Promise<void>;
61
61
  }
@@ -9,7 +9,7 @@ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (
9
9
  if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
10
10
  return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
11
11
  };
12
- var _SyncedStateServer_instances, _a, _SyncedStateServer_keyHandler, _SyncedStateServer_roomHandler, _SyncedStateServer_setStateHandler, _SyncedStateServer_getStateHandler, _SyncedStateServer_subscribeHandler, _SyncedStateServer_unsubscribeHandler, _SyncedStateServer_identityExtractor, _SyncedStateServer_namespace, _SyncedStateServer_durableObjectName, _SyncedStateServer_stub, _SyncedStateServer_stateStore, _SyncedStateServer_stateStoreLoaded, _SyncedStateServer_loadStateStore, _SyncedStateServer_stateStorageKey, _SyncedStateServer_getState, _SyncedStateServer_setState, _SyncedStateServer_subscriptions, _SyncedStateServer_subscribe, _SyncedStateServer_unsubscribe, _SyncedStateServer_broadcastUpdate, _SyncedStateServer_getAttachment, _SyncedStateServer_getIdentity, _SyncedStateServer_getSubscriptionsFromAttachment, _SyncedStateServer_setSubscriptionsInAttachment, _SyncedStateServer_ensureSubscriptionsLoaded, _SyncedStateServer_resolveStorageKey, _SyncedStateServer_getStubForHandlers, _SyncedStateServer_send, _SyncedStateServer_sendError;
12
+ var _SyncedStateServer_instances, _a, _SyncedStateServer_keyHandler, _SyncedStateServer_roomHandler, _SyncedStateServer_setStateHandler, _SyncedStateServer_getStateHandler, _SyncedStateServer_subscribeHandler, _SyncedStateServer_unsubscribeHandler, _SyncedStateServer_identityExtractor, _SyncedStateServer_namespace, _SyncedStateServer_durableObjectName, _SyncedStateServer_stub, _SyncedStateServer_handleClientMessage, _SyncedStateServer_stateStore, _SyncedStateServer_stateStoreLoaded, _SyncedStateServer_loadStateStore, _SyncedStateServer_stateStorageKey, _SyncedStateServer_getState, _SyncedStateServer_setState, _SyncedStateServer_subscribe, _SyncedStateServer_unsubscribe, _SyncedStateServer_broadcastUpdate, _SyncedStateServer_getAttachment, _SyncedStateServer_getIdentity, _SyncedStateServer_getSubscriptionsFromAttachment, _SyncedStateServer_setSubscriptionsInAttachment, _SyncedStateServer_resolveStorageKey, _SyncedStateServer_getStubForHandlers, _SyncedStateServer_send, _SyncedStateServer_sendError;
13
13
  import { DurableObject } from "cloudflare:workers";
14
14
  import { getIdentityFromUrl, } from "./identity.mjs";
15
15
  import { unpackClientMessage, packMessage, } from "./protocol.mjs";
@@ -87,13 +87,6 @@ export class SyncedStateServer extends DurableObject {
87
87
  // the DO, so every write is persisted and the cache is warmed on first read.
88
88
  _SyncedStateServer_stateStore.set(this, new Map());
89
89
  _SyncedStateServer_stateStoreLoaded.set(this, false);
90
- // ---------------------------------------------------------------------------
91
- // Subscriptions
92
- // ---------------------------------------------------------------------------
93
- // Map from storage key to the subscribers for that key. We store the
94
- // user-facing key per subscriber so broadcasts can send each socket the key
95
- // it originally subscribed to.
96
- _SyncedStateServer_subscriptions.set(this, new Map());
97
90
  this.state = state;
98
91
  this.env = env;
99
92
  this.storage = state.storage;
@@ -128,73 +121,20 @@ export class SyncedStateServer extends DurableObject {
128
121
  __classPrivateFieldGet(this, _SyncedStateServer_instances, "m", _SyncedStateServer_sendError).call(this, ws, error instanceof Error ? error.message : "Invalid protocol message");
129
122
  return;
130
123
  }
131
- // After DO eviction the in-memory subscription map is empty. Rehydrate it
132
- // from the socket attachment before handling any message that depends on
133
- // knowing this socket's subscriptions (especially broadcasts on setState).
134
- __classPrivateFieldGet(this, _SyncedStateServer_instances, "m", _SyncedStateServer_ensureSubscriptionsLoaded).call(this, ws);
135
- const identity = __classPrivateFieldGet(this, _SyncedStateServer_instances, "m", _SyncedStateServer_getIdentity).call(this, ws);
136
- const storageKey = await __classPrivateFieldGet(this, _SyncedStateServer_instances, "m", _SyncedStateServer_resolveStorageKey).call(this, message.key, identity);
137
- switch (message.kind) {
138
- case "getState": {
139
- const value = await __classPrivateFieldGet(this, _SyncedStateServer_instances, "m", _SyncedStateServer_getState).call(this, storageKey, identity);
140
- __classPrivateFieldGet(this, _SyncedStateServer_instances, "m", _SyncedStateServer_send).call(this, ws, {
141
- kind: "getState",
142
- key: message.key,
143
- value,
144
- id: message.id,
145
- });
146
- break;
147
- }
148
- case "setState": {
149
- await __classPrivateFieldGet(this, _SyncedStateServer_instances, "m", _SyncedStateServer_setState).call(this, storageKey, message.value, identity);
150
- __classPrivateFieldGet(this, _SyncedStateServer_instances, "m", _SyncedStateServer_send).call(this, ws, {
151
- kind: "setState",
152
- key: message.key,
153
- id: message.id,
154
- });
155
- break;
156
- }
157
- case "subscribe": {
158
- __classPrivateFieldGet(this, _SyncedStateServer_instances, "m", _SyncedStateServer_subscribe).call(this, ws, storageKey, message.key);
159
- __classPrivateFieldGet(this, _SyncedStateServer_instances, "m", _SyncedStateServer_send).call(this, ws, {
160
- kind: "subscribe",
161
- key: message.key,
162
- id: message.id,
163
- });
164
- break;
165
- }
166
- case "unsubscribe": {
167
- __classPrivateFieldGet(this, _SyncedStateServer_instances, "m", _SyncedStateServer_unsubscribe).call(this, ws, storageKey, message.key);
168
- __classPrivateFieldGet(this, _SyncedStateServer_instances, "m", _SyncedStateServer_send).call(this, ws, {
169
- kind: "unsubscribe",
170
- key: message.key,
171
- id: message.id,
172
- });
173
- break;
174
- }
175
- default: {
176
- __classPrivateFieldGet(this, _SyncedStateServer_instances, "m", _SyncedStateServer_sendError).call(this, ws, "Unknown message kind", message.id);
177
- }
178
- }
179
- }
180
- async webSocketClose(ws) {
181
- // context(justinvdm, 18 Jun 2026): Remove this socket from all in-memory
182
- // subscription sets. The attachment is dropped by the runtime, so no
183
- // persistent cleanup is required.
184
- for (const subscribers of __classPrivateFieldGet(this, _SyncedStateServer_subscriptions, "f").values()) {
185
- for (const entry of subscribers) {
186
- if (entry.ws === ws) {
187
- subscribers.delete(entry);
188
- break;
189
- }
190
- }
124
+ // Once a request id is parsed, the client is waiting for a matching
125
+ // response. Every dispatch path must resolve or reject that request id.
126
+ try {
127
+ await __classPrivateFieldGet(this, _SyncedStateServer_instances, "m", _SyncedStateServer_handleClientMessage).call(this, ws, message);
191
128
  }
192
- for (const [key, subscribers] of __classPrivateFieldGet(this, _SyncedStateServer_subscriptions, "f")) {
193
- if (subscribers.size === 0) {
194
- __classPrivateFieldGet(this, _SyncedStateServer_subscriptions, "f").delete(key);
195
- }
129
+ catch (error) {
130
+ __classPrivateFieldGet(this, _SyncedStateServer_instances, "m", _SyncedStateServer_sendError).call(this, ws, error instanceof Error ? error.message : String(error), message.id);
196
131
  }
197
132
  }
133
+ async webSocketClose(_ws) {
134
+ // context(justinvdm, 29 Jun 2026): Subscriptions are persisted on the
135
+ // WebSocket attachment. When the runtime removes the socket, the
136
+ // attachment disappears with it, so no persistent cleanup is required.
137
+ }
198
138
  // Public RPC surface exposed to handler callbacks and other Workers RPC callers.
199
139
  async getState(key) {
200
140
  return __classPrivateFieldGet(this, _SyncedStateServer_instances, "m", _SyncedStateServer_getState).call(this, key, undefined);
@@ -203,7 +143,52 @@ export class SyncedStateServer extends DurableObject {
203
143
  await __classPrivateFieldGet(this, _SyncedStateServer_instances, "m", _SyncedStateServer_setState).call(this, key, value, undefined);
204
144
  }
205
145
  }
206
- _a = SyncedStateServer, _SyncedStateServer_stub = new WeakMap(), _SyncedStateServer_stateStore = new WeakMap(), _SyncedStateServer_stateStoreLoaded = new WeakMap(), _SyncedStateServer_subscriptions = new WeakMap(), _SyncedStateServer_instances = new WeakSet(), _SyncedStateServer_loadStateStore = async function _SyncedStateServer_loadStateStore() {
146
+ _a = SyncedStateServer, _SyncedStateServer_stub = new WeakMap(), _SyncedStateServer_stateStore = new WeakMap(), _SyncedStateServer_stateStoreLoaded = new WeakMap(), _SyncedStateServer_instances = new WeakSet(), _SyncedStateServer_handleClientMessage = async function _SyncedStateServer_handleClientMessage(ws, message) {
147
+ const identity = __classPrivateFieldGet(this, _SyncedStateServer_instances, "m", _SyncedStateServer_getIdentity).call(this, ws);
148
+ const storageKey = await __classPrivateFieldGet(this, _SyncedStateServer_instances, "m", _SyncedStateServer_resolveStorageKey).call(this, message.key, identity);
149
+ switch (message.kind) {
150
+ case "getState": {
151
+ const value = await __classPrivateFieldGet(this, _SyncedStateServer_instances, "m", _SyncedStateServer_getState).call(this, storageKey, identity);
152
+ __classPrivateFieldGet(this, _SyncedStateServer_instances, "m", _SyncedStateServer_send).call(this, ws, {
153
+ kind: "getState",
154
+ key: message.key,
155
+ value,
156
+ id: message.id,
157
+ });
158
+ break;
159
+ }
160
+ case "setState": {
161
+ await __classPrivateFieldGet(this, _SyncedStateServer_instances, "m", _SyncedStateServer_setState).call(this, storageKey, message.value, identity);
162
+ __classPrivateFieldGet(this, _SyncedStateServer_instances, "m", _SyncedStateServer_send).call(this, ws, {
163
+ kind: "setState",
164
+ key: message.key,
165
+ id: message.id,
166
+ });
167
+ break;
168
+ }
169
+ case "subscribe": {
170
+ __classPrivateFieldGet(this, _SyncedStateServer_instances, "m", _SyncedStateServer_subscribe).call(this, ws, storageKey, message.key);
171
+ __classPrivateFieldGet(this, _SyncedStateServer_instances, "m", _SyncedStateServer_send).call(this, ws, {
172
+ kind: "subscribe",
173
+ key: message.key,
174
+ id: message.id,
175
+ });
176
+ break;
177
+ }
178
+ case "unsubscribe": {
179
+ __classPrivateFieldGet(this, _SyncedStateServer_instances, "m", _SyncedStateServer_unsubscribe).call(this, ws, storageKey, message.key);
180
+ __classPrivateFieldGet(this, _SyncedStateServer_instances, "m", _SyncedStateServer_send).call(this, ws, {
181
+ kind: "unsubscribe",
182
+ key: message.key,
183
+ id: message.id,
184
+ });
185
+ break;
186
+ }
187
+ default: {
188
+ __classPrivateFieldGet(this, _SyncedStateServer_instances, "m", _SyncedStateServer_sendError).call(this, ws, "Unknown message kind", message.id);
189
+ }
190
+ }
191
+ }, _SyncedStateServer_loadStateStore = async function _SyncedStateServer_loadStateStore() {
207
192
  if (__classPrivateFieldGet(this, _SyncedStateServer_stateStoreLoaded, "f")) {
208
193
  return;
209
194
  }
@@ -239,20 +224,14 @@ _a = SyncedStateServer, _SyncedStateServer_stub = new WeakMap(), _SyncedStateSer
239
224
  }
240
225
  __classPrivateFieldGet(this, _SyncedStateServer_instances, "m", _SyncedStateServer_broadcastUpdate).call(this, key, value);
241
226
  }, _SyncedStateServer_subscribe = function _SyncedStateServer_subscribe(ws, storageKey, userKey) {
242
- if (!__classPrivateFieldGet(this, _SyncedStateServer_subscriptions, "f").has(storageKey)) {
243
- __classPrivateFieldGet(this, _SyncedStateServer_subscriptions, "f").set(storageKey, new Set());
244
- }
245
- const subscribers = __classPrivateFieldGet(this, _SyncedStateServer_subscriptions, "f").get(storageKey);
246
227
  // Defensive deduplication: a stateful client may send subscribe more than
247
- // once for the same key (e.g. across reconnects), and DO eviction can
248
- // rehydrate the same subscription from the attachment. Keep only one entry
249
- // per (socket, userKey) pair.
250
- for (const entry of subscribers) {
251
- if (entry.ws === ws && entry.userKey === userKey) {
252
- return;
253
- }
228
+ // once for the same key (e.g. across reconnects). Keep only one entry per
229
+ // (socket, userKey) pair in the attachment.
230
+ const subs = __classPrivateFieldGet(this, _SyncedStateServer_instances, "m", _SyncedStateServer_getSubscriptionsFromAttachment).call(this, ws);
231
+ if (!subs.some((s) => s.userKey === userKey && s.storageKey === storageKey)) {
232
+ subs.push({ userKey, storageKey });
233
+ __classPrivateFieldGet(this, _SyncedStateServer_instances, "m", _SyncedStateServer_setSubscriptionsInAttachment).call(this, ws, subs);
254
234
  }
255
- subscribers.add({ ws, userKey });
256
235
  const identity = __classPrivateFieldGet(this, _SyncedStateServer_instances, "m", _SyncedStateServer_getIdentity).call(this, ws);
257
236
  const subscribeHandler = __classPrivateFieldGet(_a, _a, "f", _SyncedStateServer_subscribeHandler);
258
237
  if (subscribeHandler) {
@@ -261,26 +240,9 @@ _a = SyncedStateServer, _SyncedStateServer_stub = new WeakMap(), _SyncedStateSer
261
240
  subscribeHandler(storageKey, identity, stub);
262
241
  }
263
242
  }
264
- // Persist the subscription in the socket attachment so it survives
265
- // DO eviction.
266
- const subs = __classPrivateFieldGet(this, _SyncedStateServer_instances, "m", _SyncedStateServer_getSubscriptionsFromAttachment).call(this, ws);
267
- if (!subs.some((s) => s.userKey === userKey && s.storageKey === storageKey)) {
268
- subs.push({ userKey, storageKey });
269
- __classPrivateFieldGet(this, _SyncedStateServer_instances, "m", _SyncedStateServer_setSubscriptionsInAttachment).call(this, ws, subs);
270
- }
271
243
  }, _SyncedStateServer_unsubscribe = function _SyncedStateServer_unsubscribe(ws, storageKey, userKey) {
272
- const subscribers = __classPrivateFieldGet(this, _SyncedStateServer_subscriptions, "f").get(storageKey);
273
- if (subscribers) {
274
- for (const entry of subscribers) {
275
- if (entry.ws === ws && entry.userKey === userKey) {
276
- subscribers.delete(entry);
277
- break;
278
- }
279
- }
280
- if (subscribers.size === 0) {
281
- __classPrivateFieldGet(this, _SyncedStateServer_subscriptions, "f").delete(storageKey);
282
- }
283
- }
244
+ const subs = __classPrivateFieldGet(this, _SyncedStateServer_instances, "m", _SyncedStateServer_getSubscriptionsFromAttachment).call(this, ws).filter((s) => !(s.userKey === userKey && s.storageKey === storageKey));
245
+ __classPrivateFieldGet(this, _SyncedStateServer_instances, "m", _SyncedStateServer_setSubscriptionsInAttachment).call(this, ws, subs);
284
246
  const identity = __classPrivateFieldGet(this, _SyncedStateServer_instances, "m", _SyncedStateServer_getIdentity).call(this, ws);
285
247
  const unsubscribeHandler = __classPrivateFieldGet(_a, _a, "f", _SyncedStateServer_unsubscribeHandler);
286
248
  if (unsubscribeHandler) {
@@ -289,24 +251,27 @@ _a = SyncedStateServer, _SyncedStateServer_stub = new WeakMap(), _SyncedStateSer
289
251
  unsubscribeHandler(storageKey, identity, stub);
290
252
  }
291
253
  }
292
- const subs = __classPrivateFieldGet(this, _SyncedStateServer_instances, "m", _SyncedStateServer_getSubscriptionsFromAttachment).call(this, ws).filter((s) => !(s.userKey === userKey && s.storageKey === storageKey));
293
- __classPrivateFieldGet(this, _SyncedStateServer_instances, "m", _SyncedStateServer_setSubscriptionsInAttachment).call(this, ws, subs);
294
- }, _SyncedStateServer_broadcastUpdate = function _SyncedStateServer_broadcastUpdate(key, value) {
295
- const subscribers = __classPrivateFieldGet(this, _SyncedStateServer_subscriptions, "f").get(key);
296
- if (!subscribers || subscribers.size === 0) {
297
- return;
298
- }
299
- for (const { ws, userKey } of subscribers) {
300
- const message = {
301
- kind: "update",
302
- key: userKey,
303
- value,
304
- };
305
- try {
306
- ws.send(packMessage(message));
307
- }
308
- catch {
309
- // Socket is already closed; it will be cleaned up via webSocketClose.
254
+ }, _SyncedStateServer_broadcastUpdate = function _SyncedStateServer_broadcastUpdate(storageKey, value) {
255
+ // Cloudflare's live socket list is the source of truth for currently
256
+ // connected sockets. Each socket's attachment holds the user-facing keys it
257
+ // subscribed to, so we send each socket its original key even after the DO
258
+ // has hibernated.
259
+ for (const ws of this.ctx.getWebSockets()) {
260
+ const subs = __classPrivateFieldGet(this, _SyncedStateServer_instances, "m", _SyncedStateServer_getSubscriptionsFromAttachment).call(this, ws);
261
+ for (const sub of subs) {
262
+ if (sub.storageKey !== storageKey)
263
+ continue;
264
+ const message = {
265
+ kind: "update",
266
+ key: sub.userKey,
267
+ value,
268
+ };
269
+ try {
270
+ ws.send(packMessage(message));
271
+ }
272
+ catch {
273
+ // Socket is already closed; it will be cleaned up via webSocketClose.
274
+ }
310
275
  }
311
276
  }
312
277
  }, _SyncedStateServer_getAttachment = function _SyncedStateServer_getAttachment(ws) {
@@ -326,24 +291,6 @@ _a = SyncedStateServer, _SyncedStateServer_stub = new WeakMap(), _SyncedStateSer
326
291
  const attachment = __classPrivateFieldGet(this, _SyncedStateServer_instances, "m", _SyncedStateServer_getAttachment).call(this, ws);
327
292
  attachment.subscriptions = subscriptions;
328
293
  ws.serializeAttachment(attachment);
329
- }, _SyncedStateServer_ensureSubscriptionsLoaded = function _SyncedStateServer_ensureSubscriptionsLoaded(ws) {
330
- const subs = __classPrivateFieldGet(this, _SyncedStateServer_instances, "m", _SyncedStateServer_getSubscriptionsFromAttachment).call(this, ws);
331
- for (const { userKey, storageKey } of subs) {
332
- if (!__classPrivateFieldGet(this, _SyncedStateServer_subscriptions, "f").has(storageKey)) {
333
- __classPrivateFieldGet(this, _SyncedStateServer_subscriptions, "f").set(storageKey, new Set());
334
- }
335
- const subscribers = __classPrivateFieldGet(this, _SyncedStateServer_subscriptions, "f").get(storageKey);
336
- let exists = false;
337
- for (const entry of subscribers) {
338
- if (entry.ws === ws && entry.userKey === userKey) {
339
- exists = true;
340
- break;
341
- }
342
- }
343
- if (!exists) {
344
- subscribers.add({ ws, userKey });
345
- }
346
- }
347
294
  }, _SyncedStateServer_resolveStorageKey =
348
295
  // ---------------------------------------------------------------------------
349
296
  // Handlers
@@ -30,12 +30,22 @@ export function createSyncedStateClient(endpoint, webSocketFactory) {
30
30
  }
31
31
  handlers.add(handler);
32
32
  if (connection.isOpen) {
33
- connection.ws.send(JSON.stringify({
34
- v: 1,
35
- kind: "subscribe",
36
- key,
37
- id: makeMessageId(connection),
38
- }));
33
+ try {
34
+ await sendMessage(connection, {
35
+ kind: "subscribe",
36
+ key,
37
+ id: makeMessageId(connection),
38
+ });
39
+ }
40
+ catch (error) {
41
+ // Roll back local subscription state so we don't pretend to be
42
+ // subscribed when the server rejected or never processed it.
43
+ handlers.delete(handler);
44
+ if (handlers.size === 0)
45
+ connection.messageHandlers.delete(key);
46
+ manager.removeSubscription(key, handler, client);
47
+ throw error;
48
+ }
39
49
  }
40
50
  },
41
51
  async unsubscribe(key, handler) {
@@ -49,13 +59,18 @@ export function createSyncedStateClient(endpoint, webSocketFactory) {
49
59
  if (handlers.size === 0)
50
60
  connection.messageHandlers.delete(key);
51
61
  }
52
- if (connection.isOpen) {
53
- connection.ws.send(JSON.stringify({
54
- v: 1,
55
- kind: "unsubscribe",
56
- key,
57
- id: makeMessageId(connection),
58
- }));
62
+ try {
63
+ if (connection.isOpen) {
64
+ await sendMessage(connection, {
65
+ kind: "unsubscribe",
66
+ key,
67
+ id: makeMessageId(connection),
68
+ });
69
+ }
70
+ }
71
+ catch {
72
+ // Unsubscribe is often called during cleanup. Swallow errors from a
73
+ // closing socket; the server attachment will be dropped anyway.
59
74
  }
60
75
  },
61
76
  };
@@ -53,7 +53,9 @@ export class SyncedStateClientManager {
53
53
  normalizeEndpoint(endpoint) {
54
54
  if (endpoint.startsWith("/") && typeof window !== "undefined") {
55
55
  const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
56
- return `${protocol}//${window.location.host}${endpoint}`;
56
+ // Strip a trailing DNS root dot so the WebSocket URL matches routing.
57
+ const host = window.location.host.replace(/\.$/, "");
58
+ return `${protocol}//${host}${endpoint}`;
57
59
  }
58
60
  return endpoint;
59
61
  }
@@ -125,9 +127,9 @@ export class SyncedStateClientManager {
125
127
  const normalized = this.normalizeEndpoint(endpoint);
126
128
  const connection = this.getConnection(normalized);
127
129
  if (connection) {
128
- if (connection.deadConnectionTimer) {
129
- clearTimeout(connection.deadConnectionTimer);
130
- connection.deadConnectionTimer = null;
130
+ if (connection.pendingRequestTimer) {
131
+ clearTimeout(connection.pendingRequestTimer);
132
+ connection.pendingRequestTimer = null;
131
133
  }
132
134
  try {
133
135
  connection.ws.close();
@@ -23,21 +23,39 @@ export const syncedStateRoutes = (getNamespace, options = {}) => {
23
23
  const roomHandler = SyncedStateServer.getRoomHandler();
24
24
  const idParam = requestInfo.params?.id;
25
25
  let resolvedRoomName;
26
- if (roomHandler) {
27
- resolvedRoomName = await runWithRequestInfo(requestInfo, async () => await roomHandler(idParam, requestInfo));
26
+ try {
27
+ if (roomHandler) {
28
+ resolvedRoomName = await runWithRequestInfo(requestInfo, async () => await roomHandler(idParam, requestInfo));
29
+ }
30
+ else {
31
+ resolvedRoomName = idParam ?? durableObjectName;
32
+ }
28
33
  }
29
- else {
30
- resolvedRoomName = idParam ?? durableObjectName;
34
+ catch (error) {
35
+ const message = error instanceof Error ? error.message : "Room resolution failed";
36
+ return new Response(message, { status: 500 });
31
37
  }
32
38
  const id = namespace.idFromName(resolvedRoomName);
33
39
  const stub = namespace.get(id);
34
40
  let identity = undefined;
35
- if (identityExtractor) {
36
- identity = await runWithRequestInfo(requestInfo, async () => await identityExtractor(requestInfo));
41
+ try {
42
+ if (identityExtractor) {
43
+ identity = await runWithRequestInfo(requestInfo, async () => await identityExtractor(requestInfo));
44
+ }
45
+ }
46
+ catch (error) {
47
+ const message = error instanceof Error ? error.message : "Identity extraction failed";
48
+ return new Response(message, { status: 500 });
37
49
  }
38
50
  const doUrl = new URL(request.url);
39
51
  doUrl.searchParams.set("clientId", crypto.randomUUID());
40
- setIdentityInUrl(identity, doUrl);
52
+ try {
53
+ setIdentityInUrl(identity, doUrl);
54
+ }
55
+ catch (error) {
56
+ const message = error instanceof Error ? error.message : "Invalid identity";
57
+ return new Response(message, { status: 500 });
58
+ }
41
59
  const doRequest = new Request(doUrl.toString(), {
42
60
  headers: request.headers,
43
61
  });