rwsdk 1.2.12 → 1.2.13-test.20260618142113
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/SyncedStateServerHibernation.d.mts +57 -0
- package/dist/use-synced-state/SyncedStateServerHibernation.mjs +329 -0
- package/dist/use-synced-state/__tests__/SyncedStateServerHibernation.test.d.mts +1 -0
- package/dist/use-synced-state/__tests__/SyncedStateServerHibernation.test.mjs +97 -0
- package/dist/use-synced-state/__tests__/client-core.test.js +24 -0
- package/dist/use-synced-state/client-core-hibernation.d.ts +61 -0
- package/dist/use-synced-state/client-core-hibernation.js +352 -0
- package/dist/use-synced-state/client-core.js +16 -9
- package/dist/use-synced-state/client-hibernation.d.ts +3 -0
- package/dist/use-synced-state/client-hibernation.js +4 -0
- package/dist/use-synced-state/protocol-hibernation.d.mts +57 -0
- package/dist/use-synced-state/protocol-hibernation.mjs +55 -0
- package/dist/use-synced-state/useSyncedStateHibernation.d.ts +22 -0
- package/dist/use-synced-state/useSyncedStateHibernation.js +64 -0
- package/dist/use-synced-state/worker-hibernation.d.mts +8 -0
- package/dist/use-synced-state/worker-hibernation.mjs +109 -0
- package/package.json +10 -2
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { DurableObject } from "cloudflare:workers";
|
|
2
|
+
import type { RequestInfo } from "../runtime/requestInfo/types";
|
|
3
|
+
import { type SyncedStateValue } from "./protocol-hibernation.mjs";
|
|
4
|
+
export type SyncedStateServerHibernationAttachment = {
|
|
5
|
+
clientId: string;
|
|
6
|
+
subscriptions: Array<{
|
|
7
|
+
userKey: string;
|
|
8
|
+
storageKey: string;
|
|
9
|
+
}>;
|
|
10
|
+
};
|
|
11
|
+
type OnSetHandler = (key: string, value: SyncedStateValue, stub: DurableObjectStub<SyncedStateServerHibernation>) => void;
|
|
12
|
+
type OnGetHandler = (key: string, value: SyncedStateValue | undefined, stub: DurableObjectStub<SyncedStateServerHibernation>) => void;
|
|
13
|
+
type OnKeyHandler = (key: string, stub: DurableObjectStub<SyncedStateServerHibernation>) => Promise<string>;
|
|
14
|
+
type OnRoomHandler = (roomId: string | undefined, requestInfo: RequestInfo | null) => Promise<string>;
|
|
15
|
+
type OnSubscribeHandler = (key: string, stub: DurableObjectStub<SyncedStateServerHibernation>) => void;
|
|
16
|
+
type OnUnsubscribeHandler = (key: string, stub: DurableObjectStub<SyncedStateServerHibernation>) => void;
|
|
17
|
+
/**
|
|
18
|
+
* Durable Object that keeps shared state for multiple clients and notifies
|
|
19
|
+
* subscribers, using the Cloudflare Hibernation WebSocket API so idle
|
|
20
|
+
* connections do not keep the object active.
|
|
21
|
+
*
|
|
22
|
+
* The implementation copies the hibernation lifecycle pattern from the older
|
|
23
|
+
* RealtimeDurableObject but replaces its RSC/action protocol with a small
|
|
24
|
+
* JSON state-sync protocol.
|
|
25
|
+
*
|
|
26
|
+
* Keys are expected to arrive already transformed by the worker proxy (via
|
|
27
|
+
* registerKeyHandler). The DO stores and broadcasts using the `storageKey`
|
|
28
|
+
* provided in each message, but sends user-facing `key` values back to the
|
|
29
|
+
* client.
|
|
30
|
+
*/
|
|
31
|
+
export declare class SyncedStateServerHibernation extends DurableObject {
|
|
32
|
+
#private;
|
|
33
|
+
static registerKeyHandler(handler: OnKeyHandler | null): void;
|
|
34
|
+
static getKeyHandler(): OnKeyHandler | null;
|
|
35
|
+
static registerRoomHandler(handler: OnRoomHandler | null): void;
|
|
36
|
+
static getRoomHandler(): OnRoomHandler | null;
|
|
37
|
+
static registerNamespace(namespace: DurableObjectNamespace<SyncedStateServerHibernation>, durableObjectName?: string): void;
|
|
38
|
+
static getNamespace(): DurableObjectNamespace<SyncedStateServerHibernation> | null;
|
|
39
|
+
static getDurableObjectName(): string;
|
|
40
|
+
static registerSetStateHandler(handler: OnSetHandler | null): void;
|
|
41
|
+
static registerGetStateHandler(handler: OnGetHandler | null): void;
|
|
42
|
+
static registerSubscribeHandler(handler: OnSubscribeHandler | null): void;
|
|
43
|
+
static registerUnsubscribeHandler(handler: OnUnsubscribeHandler | null): void;
|
|
44
|
+
static getSubscribeHandler(): OnSubscribeHandler | null;
|
|
45
|
+
static getUnsubscribeHandler(): OnUnsubscribeHandler | null;
|
|
46
|
+
state: DurableObjectState;
|
|
47
|
+
env: Env;
|
|
48
|
+
storage: DurableObjectStorage;
|
|
49
|
+
constructor(state: DurableObjectState, env: Env);
|
|
50
|
+
setStub(stub: DurableObjectStub<SyncedStateServerHibernation>): void;
|
|
51
|
+
fetch(request: Request): Promise<Response>;
|
|
52
|
+
webSocketMessage(ws: WebSocket, data: string | ArrayBuffer): Promise<void>;
|
|
53
|
+
webSocketClose(ws: WebSocket): Promise<void>;
|
|
54
|
+
getState(key: string): Promise<SyncedStateValue | undefined>;
|
|
55
|
+
setState(value: SyncedStateValue, key: string): Promise<void>;
|
|
56
|
+
}
|
|
57
|
+
export {};
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
|
|
2
|
+
if (kind === "m") throw new TypeError("Private method is not writable");
|
|
3
|
+
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
|
|
4
|
+
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
|
|
5
|
+
return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
|
|
6
|
+
};
|
|
7
|
+
var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
|
|
8
|
+
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
|
|
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
|
+
return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
|
|
11
|
+
};
|
|
12
|
+
var _SyncedStateServerHibernation_instances, _a, _SyncedStateServerHibernation_keyHandler, _SyncedStateServerHibernation_roomHandler, _SyncedStateServerHibernation_setStateHandler, _SyncedStateServerHibernation_getStateHandler, _SyncedStateServerHibernation_subscribeHandler, _SyncedStateServerHibernation_unsubscribeHandler, _SyncedStateServerHibernation_namespace, _SyncedStateServerHibernation_durableObjectName, _SyncedStateServerHibernation_stub, _SyncedStateServerHibernation_stateStore, _SyncedStateServerHibernation_getState, _SyncedStateServerHibernation_setState, _SyncedStateServerHibernation_subscriptions, _SyncedStateServerHibernation_subscribe, _SyncedStateServerHibernation_unsubscribe, _SyncedStateServerHibernation_broadcastUpdate, _SyncedStateServerHibernation_getAttachment, _SyncedStateServerHibernation_getSubscriptionsFromAttachment, _SyncedStateServerHibernation_setSubscriptionsInAttachment, _SyncedStateServerHibernation_ensureSubscriptionsLoaded, _SyncedStateServerHibernation_getStubForHandlers, _SyncedStateServerHibernation_send, _SyncedStateServerHibernation_sendError;
|
|
13
|
+
import { DurableObject } from "cloudflare:workers";
|
|
14
|
+
import { unpackClientMessage, packMessage, } from "./protocol-hibernation.mjs";
|
|
15
|
+
/**
|
|
16
|
+
* Durable Object that keeps shared state for multiple clients and notifies
|
|
17
|
+
* subscribers, using the Cloudflare Hibernation WebSocket API so idle
|
|
18
|
+
* connections do not keep the object active.
|
|
19
|
+
*
|
|
20
|
+
* The implementation copies the hibernation lifecycle pattern from the older
|
|
21
|
+
* RealtimeDurableObject but replaces its RSC/action protocol with a small
|
|
22
|
+
* JSON state-sync protocol.
|
|
23
|
+
*
|
|
24
|
+
* Keys are expected to arrive already transformed by the worker proxy (via
|
|
25
|
+
* registerKeyHandler). The DO stores and broadcasts using the `storageKey`
|
|
26
|
+
* provided in each message, but sends user-facing `key` values back to the
|
|
27
|
+
* client.
|
|
28
|
+
*/
|
|
29
|
+
export class SyncedStateServerHibernation extends DurableObject {
|
|
30
|
+
static registerKeyHandler(handler) {
|
|
31
|
+
__classPrivateFieldSet(_a, _a, handler, "f", _SyncedStateServerHibernation_keyHandler);
|
|
32
|
+
}
|
|
33
|
+
static getKeyHandler() {
|
|
34
|
+
return __classPrivateFieldGet(_a, _a, "f", _SyncedStateServerHibernation_keyHandler);
|
|
35
|
+
}
|
|
36
|
+
static registerRoomHandler(handler) {
|
|
37
|
+
__classPrivateFieldSet(_a, _a, handler, "f", _SyncedStateServerHibernation_roomHandler);
|
|
38
|
+
}
|
|
39
|
+
static getRoomHandler() {
|
|
40
|
+
return __classPrivateFieldGet(_a, _a, "f", _SyncedStateServerHibernation_roomHandler);
|
|
41
|
+
}
|
|
42
|
+
static registerNamespace(namespace, durableObjectName) {
|
|
43
|
+
__classPrivateFieldSet(_a, _a, namespace, "f", _SyncedStateServerHibernation_namespace);
|
|
44
|
+
if (durableObjectName) {
|
|
45
|
+
__classPrivateFieldSet(_a, _a, durableObjectName, "f", _SyncedStateServerHibernation_durableObjectName);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
static getNamespace() {
|
|
49
|
+
return __classPrivateFieldGet(_a, _a, "f", _SyncedStateServerHibernation_namespace);
|
|
50
|
+
}
|
|
51
|
+
static getDurableObjectName() {
|
|
52
|
+
return __classPrivateFieldGet(_a, _a, "f", _SyncedStateServerHibernation_durableObjectName);
|
|
53
|
+
}
|
|
54
|
+
static registerSetStateHandler(handler) {
|
|
55
|
+
__classPrivateFieldSet(_a, _a, handler, "f", _SyncedStateServerHibernation_setStateHandler);
|
|
56
|
+
}
|
|
57
|
+
static registerGetStateHandler(handler) {
|
|
58
|
+
__classPrivateFieldSet(_a, _a, handler, "f", _SyncedStateServerHibernation_getStateHandler);
|
|
59
|
+
}
|
|
60
|
+
static registerSubscribeHandler(handler) {
|
|
61
|
+
__classPrivateFieldSet(_a, _a, handler, "f", _SyncedStateServerHibernation_subscribeHandler);
|
|
62
|
+
}
|
|
63
|
+
static registerUnsubscribeHandler(handler) {
|
|
64
|
+
__classPrivateFieldSet(_a, _a, handler, "f", _SyncedStateServerHibernation_unsubscribeHandler);
|
|
65
|
+
}
|
|
66
|
+
static getSubscribeHandler() {
|
|
67
|
+
return __classPrivateFieldGet(_a, _a, "f", _SyncedStateServerHibernation_subscribeHandler);
|
|
68
|
+
}
|
|
69
|
+
static getUnsubscribeHandler() {
|
|
70
|
+
return __classPrivateFieldGet(_a, _a, "f", _SyncedStateServerHibernation_unsubscribeHandler);
|
|
71
|
+
}
|
|
72
|
+
constructor(state, env) {
|
|
73
|
+
super(state, env);
|
|
74
|
+
_SyncedStateServerHibernation_instances.add(this);
|
|
75
|
+
_SyncedStateServerHibernation_stub.set(this, null);
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// State storage
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
_SyncedStateServerHibernation_stateStore.set(this, new Map());
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Subscriptions
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// Map from storage key to the subscribers for that key. We store the
|
|
84
|
+
// user-facing key per subscriber so broadcasts can send each socket the key
|
|
85
|
+
// it originally subscribed to.
|
|
86
|
+
_SyncedStateServerHibernation_subscriptions.set(this, new Map());
|
|
87
|
+
this.state = state;
|
|
88
|
+
this.env = env;
|
|
89
|
+
this.storage = state.storage;
|
|
90
|
+
}
|
|
91
|
+
setStub(stub) {
|
|
92
|
+
__classPrivateFieldSet(this, _SyncedStateServerHibernation_stub, stub, "f");
|
|
93
|
+
}
|
|
94
|
+
async fetch(request) {
|
|
95
|
+
const url = new URL(request.url);
|
|
96
|
+
const clientId = url.searchParams.get("clientId") ?? crypto.randomUUID();
|
|
97
|
+
const { 0: client, 1: server } = new WebSocketPair();
|
|
98
|
+
const attachment = {
|
|
99
|
+
clientId,
|
|
100
|
+
subscriptions: [],
|
|
101
|
+
};
|
|
102
|
+
server.serializeAttachment(attachment);
|
|
103
|
+
this.state.acceptWebSocket(server);
|
|
104
|
+
return new Response(null, { status: 101, webSocket: client });
|
|
105
|
+
}
|
|
106
|
+
async webSocketMessage(ws, data) {
|
|
107
|
+
if (typeof data !== "string") {
|
|
108
|
+
__classPrivateFieldGet(this, _SyncedStateServerHibernation_instances, "m", _SyncedStateServerHibernation_sendError).call(this, ws, "Expected text WebSocket message");
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
let message;
|
|
112
|
+
try {
|
|
113
|
+
message = unpackClientMessage(data);
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
__classPrivateFieldGet(this, _SyncedStateServerHibernation_instances, "m", _SyncedStateServerHibernation_sendError).call(this, ws, error instanceof Error ? error.message : "Invalid protocol message");
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const storageKey = message.storageKey ?? message.key;
|
|
120
|
+
// After hibernation the in-memory subscription map is empty. Rehydrate it
|
|
121
|
+
// from the socket attachment before handling any message that depends on
|
|
122
|
+
// knowing this socket's subscriptions (especially broadcasts on setState).
|
|
123
|
+
__classPrivateFieldGet(this, _SyncedStateServerHibernation_instances, "m", _SyncedStateServerHibernation_ensureSubscriptionsLoaded).call(this, ws);
|
|
124
|
+
switch (message.kind) {
|
|
125
|
+
case "getState": {
|
|
126
|
+
const value = __classPrivateFieldGet(this, _SyncedStateServerHibernation_instances, "m", _SyncedStateServerHibernation_getState).call(this, storageKey);
|
|
127
|
+
__classPrivateFieldGet(this, _SyncedStateServerHibernation_instances, "m", _SyncedStateServerHibernation_send).call(this, ws, {
|
|
128
|
+
kind: "getState",
|
|
129
|
+
key: message.key,
|
|
130
|
+
value,
|
|
131
|
+
id: message.id,
|
|
132
|
+
});
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
case "setState": {
|
|
136
|
+
__classPrivateFieldGet(this, _SyncedStateServerHibernation_instances, "m", _SyncedStateServerHibernation_setState).call(this, storageKey, message.value);
|
|
137
|
+
__classPrivateFieldGet(this, _SyncedStateServerHibernation_instances, "m", _SyncedStateServerHibernation_send).call(this, ws, {
|
|
138
|
+
kind: "setState",
|
|
139
|
+
key: message.key,
|
|
140
|
+
id: message.id,
|
|
141
|
+
});
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
case "subscribe": {
|
|
145
|
+
__classPrivateFieldGet(this, _SyncedStateServerHibernation_instances, "m", _SyncedStateServerHibernation_subscribe).call(this, ws, storageKey, message.key);
|
|
146
|
+
__classPrivateFieldGet(this, _SyncedStateServerHibernation_instances, "m", _SyncedStateServerHibernation_send).call(this, ws, {
|
|
147
|
+
kind: "subscribe",
|
|
148
|
+
key: message.key,
|
|
149
|
+
id: message.id,
|
|
150
|
+
});
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
case "unsubscribe": {
|
|
154
|
+
__classPrivateFieldGet(this, _SyncedStateServerHibernation_instances, "m", _SyncedStateServerHibernation_unsubscribe).call(this, ws, storageKey, message.key);
|
|
155
|
+
__classPrivateFieldGet(this, _SyncedStateServerHibernation_instances, "m", _SyncedStateServerHibernation_send).call(this, ws, {
|
|
156
|
+
kind: "unsubscribe",
|
|
157
|
+
key: message.key,
|
|
158
|
+
id: message.id,
|
|
159
|
+
});
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
default: {
|
|
163
|
+
__classPrivateFieldGet(this, _SyncedStateServerHibernation_instances, "m", _SyncedStateServerHibernation_sendError).call(this, ws, "Unknown message kind", message.id);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
async webSocketClose(ws) {
|
|
168
|
+
// context(justinvdm, 18 Jun 2026): Remove this socket from all in-memory
|
|
169
|
+
// subscription sets. The attachment is dropped by the runtime, so no
|
|
170
|
+
// persistent cleanup is required.
|
|
171
|
+
for (const subscribers of __classPrivateFieldGet(this, _SyncedStateServerHibernation_subscriptions, "f").values()) {
|
|
172
|
+
for (const entry of subscribers) {
|
|
173
|
+
if (entry.ws === ws) {
|
|
174
|
+
subscribers.delete(entry);
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
for (const [key, subscribers] of __classPrivateFieldGet(this, _SyncedStateServerHibernation_subscriptions, "f")) {
|
|
180
|
+
if (subscribers.size === 0) {
|
|
181
|
+
__classPrivateFieldGet(this, _SyncedStateServerHibernation_subscriptions, "f").delete(key);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// Public RPC surface exposed to handler callbacks and other Workers RPC callers.
|
|
186
|
+
async getState(key) {
|
|
187
|
+
return __classPrivateFieldGet(this, _SyncedStateServerHibernation_instances, "m", _SyncedStateServerHibernation_getState).call(this, key);
|
|
188
|
+
}
|
|
189
|
+
async setState(value, key) {
|
|
190
|
+
__classPrivateFieldGet(this, _SyncedStateServerHibernation_instances, "m", _SyncedStateServerHibernation_setState).call(this, key, value);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
_a = SyncedStateServerHibernation, _SyncedStateServerHibernation_stub = new WeakMap(), _SyncedStateServerHibernation_stateStore = new WeakMap(), _SyncedStateServerHibernation_subscriptions = new WeakMap(), _SyncedStateServerHibernation_instances = new WeakSet(), _SyncedStateServerHibernation_getState = function _SyncedStateServerHibernation_getState(key) {
|
|
194
|
+
const value = __classPrivateFieldGet(this, _SyncedStateServerHibernation_stateStore, "f").get(key);
|
|
195
|
+
if (__classPrivateFieldGet(_a, _a, "f", _SyncedStateServerHibernation_getStateHandler)) {
|
|
196
|
+
const stub = __classPrivateFieldGet(this, _SyncedStateServerHibernation_instances, "m", _SyncedStateServerHibernation_getStubForHandlers).call(this);
|
|
197
|
+
if (stub) {
|
|
198
|
+
__classPrivateFieldGet(_a, _a, "f", _SyncedStateServerHibernation_getStateHandler).call(_a, key, value, stub);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return value;
|
|
202
|
+
}, _SyncedStateServerHibernation_setState = function _SyncedStateServerHibernation_setState(key, value) {
|
|
203
|
+
__classPrivateFieldGet(this, _SyncedStateServerHibernation_stateStore, "f").set(key, value);
|
|
204
|
+
if (__classPrivateFieldGet(_a, _a, "f", _SyncedStateServerHibernation_setStateHandler)) {
|
|
205
|
+
const stub = __classPrivateFieldGet(this, _SyncedStateServerHibernation_instances, "m", _SyncedStateServerHibernation_getStubForHandlers).call(this);
|
|
206
|
+
if (stub) {
|
|
207
|
+
__classPrivateFieldGet(_a, _a, "f", _SyncedStateServerHibernation_setStateHandler).call(_a, key, value, stub);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
__classPrivateFieldGet(this, _SyncedStateServerHibernation_instances, "m", _SyncedStateServerHibernation_broadcastUpdate).call(this, key, value);
|
|
211
|
+
}, _SyncedStateServerHibernation_subscribe = function _SyncedStateServerHibernation_subscribe(ws, storageKey, userKey) {
|
|
212
|
+
if (!__classPrivateFieldGet(this, _SyncedStateServerHibernation_subscriptions, "f").has(storageKey)) {
|
|
213
|
+
__classPrivateFieldGet(this, _SyncedStateServerHibernation_subscriptions, "f").set(storageKey, new Set());
|
|
214
|
+
}
|
|
215
|
+
__classPrivateFieldGet(this, _SyncedStateServerHibernation_subscriptions, "f").get(storageKey).add({ ws, userKey });
|
|
216
|
+
const subscribeHandler = __classPrivateFieldGet(_a, _a, "f", _SyncedStateServerHibernation_subscribeHandler);
|
|
217
|
+
if (subscribeHandler) {
|
|
218
|
+
const stub = __classPrivateFieldGet(this, _SyncedStateServerHibernation_instances, "m", _SyncedStateServerHibernation_getStubForHandlers).call(this);
|
|
219
|
+
if (stub) {
|
|
220
|
+
subscribeHandler(storageKey, stub);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// Persist the subscription in the socket attachment so it survives
|
|
224
|
+
// hibernation.
|
|
225
|
+
const subs = __classPrivateFieldGet(this, _SyncedStateServerHibernation_instances, "m", _SyncedStateServerHibernation_getSubscriptionsFromAttachment).call(this, ws);
|
|
226
|
+
if (!subs.some((s) => s.userKey === userKey && s.storageKey === storageKey)) {
|
|
227
|
+
subs.push({ userKey, storageKey });
|
|
228
|
+
__classPrivateFieldGet(this, _SyncedStateServerHibernation_instances, "m", _SyncedStateServerHibernation_setSubscriptionsInAttachment).call(this, ws, subs);
|
|
229
|
+
}
|
|
230
|
+
}, _SyncedStateServerHibernation_unsubscribe = function _SyncedStateServerHibernation_unsubscribe(ws, storageKey, userKey) {
|
|
231
|
+
const subscribers = __classPrivateFieldGet(this, _SyncedStateServerHibernation_subscriptions, "f").get(storageKey);
|
|
232
|
+
if (subscribers) {
|
|
233
|
+
for (const entry of subscribers) {
|
|
234
|
+
if (entry.ws === ws && entry.userKey === userKey) {
|
|
235
|
+
subscribers.delete(entry);
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (subscribers.size === 0) {
|
|
240
|
+
__classPrivateFieldGet(this, _SyncedStateServerHibernation_subscriptions, "f").delete(storageKey);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
const unsubscribeHandler = __classPrivateFieldGet(_a, _a, "f", _SyncedStateServerHibernation_unsubscribeHandler);
|
|
244
|
+
if (unsubscribeHandler) {
|
|
245
|
+
const stub = __classPrivateFieldGet(this, _SyncedStateServerHibernation_instances, "m", _SyncedStateServerHibernation_getStubForHandlers).call(this);
|
|
246
|
+
if (stub) {
|
|
247
|
+
unsubscribeHandler(storageKey, stub);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
const subs = __classPrivateFieldGet(this, _SyncedStateServerHibernation_instances, "m", _SyncedStateServerHibernation_getSubscriptionsFromAttachment).call(this, ws).filter((s) => !(s.userKey === userKey && s.storageKey === storageKey));
|
|
251
|
+
__classPrivateFieldGet(this, _SyncedStateServerHibernation_instances, "m", _SyncedStateServerHibernation_setSubscriptionsInAttachment).call(this, ws, subs);
|
|
252
|
+
}, _SyncedStateServerHibernation_broadcastUpdate = function _SyncedStateServerHibernation_broadcastUpdate(key, value) {
|
|
253
|
+
const subscribers = __classPrivateFieldGet(this, _SyncedStateServerHibernation_subscriptions, "f").get(key);
|
|
254
|
+
if (!subscribers || subscribers.size === 0) {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
for (const { ws, userKey } of subscribers) {
|
|
258
|
+
const message = {
|
|
259
|
+
kind: "update",
|
|
260
|
+
key: userKey,
|
|
261
|
+
value,
|
|
262
|
+
};
|
|
263
|
+
try {
|
|
264
|
+
ws.send(packMessage(message));
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
// Socket is already closed; it will be cleaned up via webSocketClose.
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}, _SyncedStateServerHibernation_getAttachment = function _SyncedStateServerHibernation_getAttachment(ws) {
|
|
271
|
+
const raw = ws.deserializeAttachment();
|
|
272
|
+
if (raw &&
|
|
273
|
+
typeof raw === "object" &&
|
|
274
|
+
"subscriptions" in raw &&
|
|
275
|
+
Array.isArray(raw.subscriptions)) {
|
|
276
|
+
return raw;
|
|
277
|
+
}
|
|
278
|
+
return { clientId: "", subscriptions: [] };
|
|
279
|
+
}, _SyncedStateServerHibernation_getSubscriptionsFromAttachment = function _SyncedStateServerHibernation_getSubscriptionsFromAttachment(ws) {
|
|
280
|
+
return __classPrivateFieldGet(this, _SyncedStateServerHibernation_instances, "m", _SyncedStateServerHibernation_getAttachment).call(this, ws).subscriptions;
|
|
281
|
+
}, _SyncedStateServerHibernation_setSubscriptionsInAttachment = function _SyncedStateServerHibernation_setSubscriptionsInAttachment(ws, subscriptions) {
|
|
282
|
+
const attachment = __classPrivateFieldGet(this, _SyncedStateServerHibernation_instances, "m", _SyncedStateServerHibernation_getAttachment).call(this, ws);
|
|
283
|
+
attachment.subscriptions = subscriptions;
|
|
284
|
+
ws.serializeAttachment(attachment);
|
|
285
|
+
}, _SyncedStateServerHibernation_ensureSubscriptionsLoaded = function _SyncedStateServerHibernation_ensureSubscriptionsLoaded(ws) {
|
|
286
|
+
const subs = __classPrivateFieldGet(this, _SyncedStateServerHibernation_instances, "m", _SyncedStateServerHibernation_getSubscriptionsFromAttachment).call(this, ws);
|
|
287
|
+
for (const { userKey, storageKey } of subs) {
|
|
288
|
+
if (!__classPrivateFieldGet(this, _SyncedStateServerHibernation_subscriptions, "f").has(storageKey)) {
|
|
289
|
+
__classPrivateFieldGet(this, _SyncedStateServerHibernation_subscriptions, "f").set(storageKey, new Set());
|
|
290
|
+
}
|
|
291
|
+
const subscribers = __classPrivateFieldGet(this, _SyncedStateServerHibernation_subscriptions, "f").get(storageKey);
|
|
292
|
+
let exists = false;
|
|
293
|
+
for (const entry of subscribers) {
|
|
294
|
+
if (entry.ws === ws && entry.userKey === userKey) {
|
|
295
|
+
exists = true;
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (!exists) {
|
|
300
|
+
subscribers.add({ ws, userKey });
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}, _SyncedStateServerHibernation_getStubForHandlers = function _SyncedStateServerHibernation_getStubForHandlers() {
|
|
304
|
+
if (__classPrivateFieldGet(this, _SyncedStateServerHibernation_stub, "f")) {
|
|
305
|
+
return __classPrivateFieldGet(this, _SyncedStateServerHibernation_stub, "f");
|
|
306
|
+
}
|
|
307
|
+
const namespace = __classPrivateFieldGet(_a, _a, "f", _SyncedStateServerHibernation_namespace);
|
|
308
|
+
if (namespace) {
|
|
309
|
+
return namespace.get(this.ctx.id);
|
|
310
|
+
}
|
|
311
|
+
return null;
|
|
312
|
+
}, _SyncedStateServerHibernation_send = function _SyncedStateServerHibernation_send(ws, message) {
|
|
313
|
+
try {
|
|
314
|
+
ws.send(packMessage(message));
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
// Ignore send failures on closed sockets.
|
|
318
|
+
}
|
|
319
|
+
}, _SyncedStateServerHibernation_sendError = function _SyncedStateServerHibernation_sendError(ws, message, id) {
|
|
320
|
+
__classPrivateFieldGet(this, _SyncedStateServerHibernation_instances, "m", _SyncedStateServerHibernation_send).call(this, ws, { kind: "error", message, id });
|
|
321
|
+
};
|
|
322
|
+
_SyncedStateServerHibernation_keyHandler = { value: null };
|
|
323
|
+
_SyncedStateServerHibernation_roomHandler = { value: null };
|
|
324
|
+
_SyncedStateServerHibernation_setStateHandler = { value: null };
|
|
325
|
+
_SyncedStateServerHibernation_getStateHandler = { value: null };
|
|
326
|
+
_SyncedStateServerHibernation_subscribeHandler = { value: null };
|
|
327
|
+
_SyncedStateServerHibernation_unsubscribeHandler = { value: null };
|
|
328
|
+
_SyncedStateServerHibernation_namespace = { value: null };
|
|
329
|
+
_SyncedStateServerHibernation_durableObjectName = { value: "syncedStateHibernation" };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
vi.mock("cloudflare:workers", () => {
|
|
3
|
+
class DurableObject {
|
|
4
|
+
}
|
|
5
|
+
return { DurableObject };
|
|
6
|
+
});
|
|
7
|
+
import { SyncedStateServerHibernation } from "../SyncedStateServerHibernation.mjs";
|
|
8
|
+
import { packMessage } from "../protocol-hibernation.mjs";
|
|
9
|
+
// Minimal WebSocket stub that supports the methods we use.
|
|
10
|
+
function createWebSocketStub() {
|
|
11
|
+
const sent = [];
|
|
12
|
+
const ws = {
|
|
13
|
+
attachment: null,
|
|
14
|
+
send(data) {
|
|
15
|
+
sent.push(data);
|
|
16
|
+
},
|
|
17
|
+
addEventListener(_event, _handler) {
|
|
18
|
+
// not used in DO tests
|
|
19
|
+
},
|
|
20
|
+
serializeAttachment(value) {
|
|
21
|
+
this.attachment = value;
|
|
22
|
+
},
|
|
23
|
+
deserializeAttachment() {
|
|
24
|
+
return this.attachment;
|
|
25
|
+
},
|
|
26
|
+
close() { },
|
|
27
|
+
_sent: sent,
|
|
28
|
+
};
|
|
29
|
+
return ws;
|
|
30
|
+
}
|
|
31
|
+
describe("SyncedStateServerHibernation", () => {
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
SyncedStateServerHibernation.registerKeyHandler(null);
|
|
34
|
+
SyncedStateServerHibernation.registerRoomHandler(null);
|
|
35
|
+
SyncedStateServerHibernation.registerSetStateHandler(null);
|
|
36
|
+
SyncedStateServerHibernation.registerGetStateHandler(null);
|
|
37
|
+
SyncedStateServerHibernation.registerSubscribeHandler(null);
|
|
38
|
+
SyncedStateServerHibernation.registerUnsubscribeHandler(null);
|
|
39
|
+
});
|
|
40
|
+
it("stores and retrieves state by storageKey", () => {
|
|
41
|
+
const coordinator = new SyncedStateServerHibernation({}, {});
|
|
42
|
+
const ws = createWebSocketStub();
|
|
43
|
+
coordinator.webSocketMessage(ws, packMessage({ kind: "setState", key: "counter", storageKey: "user:123:counter", value: 5, id: "1" }));
|
|
44
|
+
coordinator.webSocketMessage(ws, packMessage({ kind: "getState", key: "counter", storageKey: "user:123:counter", id: "2" }));
|
|
45
|
+
expect(ws._sent).toHaveLength(2);
|
|
46
|
+
const response = JSON.parse(ws._sent[1]);
|
|
47
|
+
expect(response).toMatchObject({ v: 1, kind: "getState", key: "counter", value: 5, id: "2" });
|
|
48
|
+
});
|
|
49
|
+
it("notifies subscribers when state changes", () => {
|
|
50
|
+
const coordinator = new SyncedStateServerHibernation({}, {});
|
|
51
|
+
const ws = createWebSocketStub();
|
|
52
|
+
coordinator.webSocketMessage(ws, packMessage({ kind: "subscribe", key: "counter", storageKey: "counter", id: "1" }));
|
|
53
|
+
coordinator.webSocketMessage(ws, packMessage({ kind: "setState", key: "counter", storageKey: "counter", value: 7, id: "2" }));
|
|
54
|
+
const messages = ws._sent.map((m) => JSON.parse(m));
|
|
55
|
+
expect(messages).toContainEqual({ v: 1, kind: "update", key: "counter", value: 7 });
|
|
56
|
+
});
|
|
57
|
+
it("keeps transformed and user-facing keys isolated", () => {
|
|
58
|
+
const coordinator = new SyncedStateServerHibernation({}, {});
|
|
59
|
+
const ws = createWebSocketStub();
|
|
60
|
+
coordinator.webSocketMessage(ws, packMessage({ kind: "setState", key: "counter", storageKey: "user:123:counter", value: 9, id: "1" }));
|
|
61
|
+
coordinator.webSocketMessage(ws, packMessage({ kind: "getState", key: "counter", id: "2" }));
|
|
62
|
+
const response = JSON.parse(ws._sent[1]);
|
|
63
|
+
expect(response.value).toBeUndefined();
|
|
64
|
+
});
|
|
65
|
+
it("invokes registered setState handler", () => {
|
|
66
|
+
const coordinator = new SyncedStateServerHibernation({}, {});
|
|
67
|
+
coordinator.setStub({});
|
|
68
|
+
const calls = [];
|
|
69
|
+
SyncedStateServerHibernation.registerSetStateHandler((key, value) => {
|
|
70
|
+
calls.push({ key, value });
|
|
71
|
+
});
|
|
72
|
+
const ws = createWebSocketStub();
|
|
73
|
+
coordinator.webSocketMessage(ws, packMessage({ kind: "setState", key: "x", storageKey: "transformed:x", value: 1, id: "1" }));
|
|
74
|
+
expect(calls).toEqual([{ key: "transformed:x", value: 1 }]);
|
|
75
|
+
});
|
|
76
|
+
it("rehydrates subscriptions after simulated hibernation", () => {
|
|
77
|
+
const coordinator = new SyncedStateServerHibernation({}, {});
|
|
78
|
+
const ws = createWebSocketStub();
|
|
79
|
+
coordinator.webSocketMessage(ws, packMessage({ kind: "subscribe", key: "counter", storageKey: "user:123:counter", id: "1" }));
|
|
80
|
+
// Simulate hibernation by clearing the in-memory subscription map.
|
|
81
|
+
coordinator["#subscriptions"]?.clear?.();
|
|
82
|
+
// A setState message should still trigger an update because the attachment
|
|
83
|
+
// is rehydrated when the message handler runs.
|
|
84
|
+
coordinator.webSocketMessage(ws, packMessage({ kind: "setState", key: "counter", storageKey: "user:123:counter", value: 42, id: "2" }));
|
|
85
|
+
const messages = ws._sent.map((m) => JSON.parse(m));
|
|
86
|
+
expect(messages).toContainEqual({ v: 1, kind: "update", key: "counter", value: 42 });
|
|
87
|
+
});
|
|
88
|
+
it("rejects unsupported protocol versions", () => {
|
|
89
|
+
const coordinator = new SyncedStateServerHibernation({}, {});
|
|
90
|
+
const ws = createWebSocketStub();
|
|
91
|
+
coordinator.webSocketMessage(ws, JSON.stringify({ v: 99, kind: "getState", key: "counter", id: "1" }));
|
|
92
|
+
const messages = ws._sent.map((m) => JSON.parse(m));
|
|
93
|
+
expect(messages).toHaveLength(1);
|
|
94
|
+
expect(messages[0]).toMatchObject({ v: 1, kind: "error" });
|
|
95
|
+
expect(messages[0].message).toContain("Unsupported hibernation protocol version");
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -279,6 +279,30 @@ describe("client-core reconnection", () => {
|
|
|
279
279
|
expect(sharedCallback).toHaveBeenCalledWith("disconnected");
|
|
280
280
|
unsubB();
|
|
281
281
|
});
|
|
282
|
+
it("duplicates active subscriptions on every reconnect", async () => {
|
|
283
|
+
const client = getSyncedStateClient(ENDPOINT);
|
|
284
|
+
const handler = vi.fn();
|
|
285
|
+
await client.subscribe("counter", handler);
|
|
286
|
+
expect(__testing.activeSubscriptions.size).toBe(1);
|
|
287
|
+
// First reconnect
|
|
288
|
+
mockClients[0].simulateBreak();
|
|
289
|
+
vi.runOnlyPendingTimers();
|
|
290
|
+
await __testing.warmUp(ENDPOINT);
|
|
291
|
+
await vi.runAllTimersAsync();
|
|
292
|
+
// Second reconnect
|
|
293
|
+
mockClients[1].simulateBreak();
|
|
294
|
+
vi.runOnlyPendingTimers();
|
|
295
|
+
await __testing.warmUp(ENDPOINT);
|
|
296
|
+
await vi.runAllTimersAsync();
|
|
297
|
+
// Third reconnect
|
|
298
|
+
mockClients[2].simulateBreak();
|
|
299
|
+
vi.runOnlyPendingTimers();
|
|
300
|
+
await __testing.warmUp(ENDPOINT);
|
|
301
|
+
await vi.runAllTimersAsync();
|
|
302
|
+
// Should remain exactly one subscription per logical subscription.
|
|
303
|
+
// Buggy implementation doubles the set on every reconnect.
|
|
304
|
+
expect(__testing.activeSubscriptions.size).toBe(1);
|
|
305
|
+
});
|
|
282
306
|
it("BUG: reconnect emits 'connected' and resets backoff even when subscribe() rejects", async () => {
|
|
283
307
|
const client = getSyncedStateClient(ENDPOINT);
|
|
284
308
|
const handler = vi.fn();
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export type SyncedStateStatus = "connected" | "disconnected" | "reconnecting";
|
|
2
|
+
export type StatusChangeCallback = (status: SyncedStateStatus) => void;
|
|
3
|
+
export type SyncedStateClient = {
|
|
4
|
+
getState(key: string): Promise<unknown>;
|
|
5
|
+
setState(value: unknown, key: string): Promise<void>;
|
|
6
|
+
subscribe(key: string, handler: (value: unknown) => void): Promise<void>;
|
|
7
|
+
unsubscribe(key: string, handler: (value: unknown) => void): Promise<void>;
|
|
8
|
+
};
|
|
9
|
+
type PendingRequest = {
|
|
10
|
+
resolve: (value: unknown) => void;
|
|
11
|
+
reject: (reason: unknown) => void;
|
|
12
|
+
};
|
|
13
|
+
type Connection = {
|
|
14
|
+
ws: WebSocket;
|
|
15
|
+
nextId: number;
|
|
16
|
+
pending: Map<string, PendingRequest>;
|
|
17
|
+
isOpen: boolean;
|
|
18
|
+
subscribedKeys: Set<string>;
|
|
19
|
+
messageHandlers: Map<string, Set<(value: unknown) => void>>;
|
|
20
|
+
};
|
|
21
|
+
type Subscription = {
|
|
22
|
+
key: string;
|
|
23
|
+
handler: (value: unknown) => void;
|
|
24
|
+
client: SyncedStateClient;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Registers a callback that fires when the connection status changes for an endpoint.
|
|
28
|
+
* Returns an unsubscribe function.
|
|
29
|
+
*/
|
|
30
|
+
export declare const onStatusChange: (endpoint: string, callback: StatusChangeCallback) => (() => void);
|
|
31
|
+
declare function getBackoffMs(attempt: number): number;
|
|
32
|
+
/**
|
|
33
|
+
* Returns a cached client for the provided endpoint, creating it when necessary.
|
|
34
|
+
* The returned client is a thin wrapper around a raw WebSocket that speaks the
|
|
35
|
+
* hibernation JSON protocol.
|
|
36
|
+
*/
|
|
37
|
+
export declare const getSyncedStateClient: (endpoint?: string) => SyncedStateClient;
|
|
38
|
+
/**
|
|
39
|
+
* Initializes and caches an RPC client instance for the sync state endpoint.
|
|
40
|
+
* The client is wrapped to track subscriptions for cleanup on page unload.
|
|
41
|
+
*/
|
|
42
|
+
export declare const initSyncedStateClient: (options?: {
|
|
43
|
+
endpoint?: string;
|
|
44
|
+
}) => SyncedStateClient | null;
|
|
45
|
+
/**
|
|
46
|
+
* Injects a client instance for tests and updates the cached endpoint.
|
|
47
|
+
* Also clears the subscription registry for test isolation.
|
|
48
|
+
*/
|
|
49
|
+
export declare const setSyncedStateClientForTesting: (client: SyncedStateClient | null, endpoint?: string) => void;
|
|
50
|
+
export declare const __testing: {
|
|
51
|
+
activeSubscriptions: Set<Subscription>;
|
|
52
|
+
clientCache: Map<string, SyncedStateClient>;
|
|
53
|
+
backoffState: Map<string, {
|
|
54
|
+
attempt: number;
|
|
55
|
+
timer: ReturnType<typeof setTimeout> | null;
|
|
56
|
+
}>;
|
|
57
|
+
statusListeners: Map<string, StatusChangeCallback[]>;
|
|
58
|
+
connectionByEndpoint: Map<string, Connection>;
|
|
59
|
+
getBackoffMs: typeof getBackoffMs;
|
|
60
|
+
};
|
|
61
|
+
export {};
|