@pluv/io 0.0.0-canary-20261027014949

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/index.mjs ADDED
@@ -0,0 +1,3591 @@
1
+ import { hkdf } from "@panva/hkdf";
2
+ import { EncryptJWT, jwtDecrypt } from "jose";
3
+ import { noop } from "@pluv/crdt";
4
+ import colors from "kleur";
5
+ // src/utils/ArmedWait.ts
6
+ /**
7
+ * The first `arm` starts one wait. Another `arm` during that wait does nothing.
8
+ * When the wait ends, `work` runs. `keepAlive` covers the wait and that work.
9
+ * `clear` cancels a pending wait and settles `keepAlive` without running `work`.
10
+ */
11
+ var ArmedWait = class {
12
+ _intervalMs;
13
+ _keepAlive;
14
+ _done = null;
15
+ _timer = null;
16
+ constructor(_intervalMs, _keepAlive) {
17
+ this._intervalMs = _intervalMs;
18
+ this._keepAlive = _keepAlive;
19
+ }
20
+ arm(work) {
21
+ if (this._timer) return;
22
+ const pending = new Promise((resolve) => {
23
+ this._done = resolve;
24
+ this._timer = setTimeout(() => {
25
+ this._timer = null;
26
+ this._done = null;
27
+ work().finally(resolve);
28
+ }, this._intervalMs);
29
+ });
30
+ this._keepAlive(pending);
31
+ }
32
+ clear() {
33
+ if (!this._timer) return;
34
+ clearTimeout(this._timer);
35
+ this._timer = null;
36
+ const done = this._done;
37
+ this._done = null;
38
+ done?.();
39
+ }
40
+ };
41
+ // src/utils/assertExhaustive.ts
42
+ const assertExhaustive = (value) => {
43
+ throw new Error(`Unhandled value: ${value}`);
44
+ };
45
+ const JS_IDENTIFIER = /^[a-z_$][a-z0-9_$]*$/i;
46
+ const MAX_STORAGE_SIZE_BYTES = 31457280;
47
+ const PING_TIMEOUT_MS = 3e4;
48
+ const DEFAULT_MAX_CONNECTIONS = 256;
49
+ const STORAGE_EDITS_INTERVAL_MS = 1e3;
50
+ const STORAGE_SNAPSHOT_INTERVAL_MS = 1e3;
51
+ const LIST_USERS_DEFAULT_LIMIT = 50;
52
+ const LIST_USERS_MAX_LIMIT = 100;
53
+ const SERVER_ORIGIN_EVENTS = [
54
+ "$error",
55
+ "$roomStats",
56
+ "$syncStateReceived"
57
+ ];
58
+ const isServerOriginEvent = (type) => {
59
+ return SERVER_ORIGIN_EVENTS.includes(type);
60
+ };
61
+ // src/utils/oneLine.ts
62
+ const oneLine = (strings, ...values) => {
63
+ return strings.map((str) => str.replace(/\s+/g, " ")).reduce((acc, str, i) => acc + str + (i < values.length ? values[i] : ""), "").replace(/\s+/g, " ").trim();
64
+ };
65
+ // src/utils/assertPresenceFanoutBudget.ts
66
+ const resolveMaxConnections = (limits) => {
67
+ const maxConnections = limits.maxConnections ?? 256;
68
+ if (!Number.isInteger(maxConnections) || maxConnections < 1) throw new Error(`limits.maxConnections must be a positive integer. Received: ${String(limits.maxConnections)}.`);
69
+ return maxConnections;
70
+ };
71
+ const assertPresenceFanoutBudget = (limits) => {
72
+ const maxConnections = resolveMaxConnections(limits);
73
+ if (maxConnections * maxConnections <= 1e5) return;
74
+ if (limits.dangerouslyAllowHighPresenceFanout) return;
75
+ throw new Error(oneLine`
76
+ maxConnections (${maxConnections.toLocaleString()}) is too high.
77
+ Set dangerouslyAllowHighPresenceFanout: true to allow this.
78
+ `);
79
+ };
80
+ // src/PluvRouter.ts
81
+ var PluvRouter = class {
82
+ _defs = { events: {} };
83
+ /** Event names this router defines. */
84
+ eventNames = /* @__PURE__ */ new Set();
85
+ constructor(events) {
86
+ const names = Object.keys(events);
87
+ assertEventNames(names, { allowDollar: false });
88
+ this._defs = { events };
89
+ this.eventNames = new Set(names);
90
+ }
91
+ static merge(...routers) {
92
+ const seen = /* @__PURE__ */ new Set();
93
+ for (const router of routers) for (const name of Object.keys(router._defs.events)) {
94
+ if (seen.has(name)) throw new Error(`Duplicate event name "${name}" when merging routers`);
95
+ seen.add(name);
96
+ }
97
+ const events = Object.assign(Object.create(null), ...routers.map((router) => router._defs.events));
98
+ return createInternalPluvRouter(events);
99
+ }
100
+ };
101
+ // src/utils/assertEventNames.ts
102
+ const assertEventNames = (names, options = {}) => {
103
+ const allowDollar = options.allowDollar ?? false;
104
+ for (const name of names) {
105
+ if (!allowDollar && name.includes("$")) throw new Error(oneLine`
106
+ Invalid event name. Event names must not contain $: "${name}"
107
+ `);
108
+ if (!JS_IDENTIFIER.test(name)) throw new Error(`Invalid event name. Event names must be formatted as valid JavaScript variable names: "${name}"`);
109
+ }
110
+ };
111
+ // src/utils/createInternalPluvRouter.ts
112
+ /**
113
+ * Build a router that may include `$` protocol events. Not part of the public
114
+ * package API — import via `__internal` or sibling modules under `@pluv/io`.
115
+ */
116
+ const createInternalPluvRouter = (events) => {
117
+ const names = Object.keys(events);
118
+ assertEventNames(names, { allowDollar: true });
119
+ const router = Object.create(PluvRouter.prototype);
120
+ router._defs = { events };
121
+ router.eventNames = new Set(names);
122
+ return router;
123
+ };
124
+ // src/utils/getLiveSessions.ts
125
+ const getLiveSessions = (sessions) => {
126
+ return sessions.filter((session) => !session.quit);
127
+ };
128
+ // src/utils/sessionKind.ts
129
+ const getSessionKind = (session) => {
130
+ switch (session.kind) {
131
+ case "operator":
132
+ case "user": return session.kind;
133
+ default: return assertExhaustive(session.kind);
134
+ }
135
+ };
136
+ const getSessionOperator = (session) => {
137
+ const kind = getSessionKind(session);
138
+ switch (kind) {
139
+ case "operator": return session.operator ?? null;
140
+ case "user": return null;
141
+ default: return assertExhaustive(kind);
142
+ }
143
+ };
144
+ // src/utils/getMyConnectionIds.ts
145
+ const getMyConnectionIds = (sessions, requester) => {
146
+ if (!requester) return [];
147
+ const live = getLiveSessions(sessions);
148
+ const kind = getSessionKind(requester);
149
+ const userId = requester.user?.id;
150
+ if (typeof userId !== "string") return [requester.id];
151
+ return live.filter((session) => getSessionKind(session) === kind && session.user?.id === userId).map((session) => session.id);
152
+ };
153
+ // src/utils/getRoomStatsFromSessions.ts
154
+ const getRoomStatsFromSessions = (sessions) => {
155
+ const live = getLiveSessions(sessions);
156
+ const occupantIds = /* @__PURE__ */ new Set();
157
+ const operatorIds = /* @__PURE__ */ new Set();
158
+ let occupantConnections = 0;
159
+ let operatorConnections = 0;
160
+ for (const session of live) {
161
+ const kind = getSessionKind(session);
162
+ const userId = session.user?.id;
163
+ switch (kind) {
164
+ case "operator":
165
+ operatorConnections += 1;
166
+ if (typeof userId === "string") operatorIds.add(userId);
167
+ break;
168
+ case "user":
169
+ occupantConnections += 1;
170
+ if (typeof userId === "string") occupantIds.add(userId);
171
+ break;
172
+ default: assertExhaustive(kind);
173
+ }
174
+ }
175
+ return {
176
+ user: {
177
+ connectionCount: occupantConnections,
178
+ userCount: occupantIds.size
179
+ },
180
+ operator: {
181
+ connectionCount: operatorConnections,
182
+ userCount: operatorIds.size
183
+ }
184
+ };
185
+ };
186
+ // src/utils/groupLiveUsers.ts
187
+ /**
188
+ * Group live sockets by `(kind, user id)`. Extra tabs of the same kind share
189
+ * one row. The socket with the newest `seq.presence` supplies `presence`,
190
+ * `data`, and `operator`. `excludeSessionId` drops that person's whole row.
191
+ * `key` is `user.id`.
192
+ */
193
+ const groupLiveUsers = (sessions, options = {}) => {
194
+ const live = getLiveSessions(sessions);
195
+ const excluded = options.excludeSessionId ? live.find((session) => session.id === options.excludeSessionId) : null;
196
+ const excludedRef = excluded ? liveUserKey(excluded) : null;
197
+ const kinds = options.kinds ? new Set(options.kinds) : null;
198
+ const grouped = {
199
+ operator: /* @__PURE__ */ new Map(),
200
+ user: /* @__PURE__ */ new Map()
201
+ };
202
+ const presenceSeqs = {
203
+ operator: /* @__PURE__ */ new Map(),
204
+ user: /* @__PURE__ */ new Map()
205
+ };
206
+ for (const session of live) {
207
+ const ref = liveUserKey(session);
208
+ if (!ref) continue;
209
+ if (kinds && !kinds.has(ref.kind)) continue;
210
+ if (sameParticipant(ref, excludedRef)) continue;
211
+ const rows = rowsForKind(grouped, ref.kind);
212
+ const seqs = seqsForKind(presenceSeqs, ref.kind);
213
+ const row = rows.get(ref.id);
214
+ const presenceSeq = session.seq.presence;
215
+ const operator = getSessionOperator(session);
216
+ if (!row) {
217
+ rows.set(ref.id, {
218
+ connectionIds: [session.id],
219
+ data: session.user,
220
+ key: ref.id,
221
+ kind: ref.kind,
222
+ operator,
223
+ presence: session.presence
224
+ });
225
+ seqs.set(ref.id, presenceSeq);
226
+ continue;
227
+ }
228
+ row.connectionIds.push(session.id);
229
+ const previousSeq = seqs.get(ref.id) ?? null;
230
+ if (typeof presenceSeq !== "number") continue;
231
+ if (typeof previousSeq === "number" && presenceSeq <= previousSeq) continue;
232
+ row.data = session.user;
233
+ row.operator = operator;
234
+ row.presence = session.presence;
235
+ seqs.set(ref.id, presenceSeq);
236
+ }
237
+ return [...grouped.user.values(), ...grouped.operator.values()];
238
+ };
239
+ const liveUserKey = (session) => {
240
+ const kind = getSessionKind(session);
241
+ const id = session.user?.id;
242
+ if (typeof id !== "string") return null;
243
+ return {
244
+ kind,
245
+ id
246
+ };
247
+ };
248
+ const sameParticipant = (a, b) => {
249
+ return !!a && !!b && a.kind === b.kind && a.id === b.id;
250
+ };
251
+ const rowsForKind = (grouped, kind) => {
252
+ switch (kind) {
253
+ case "operator": return grouped.operator;
254
+ case "user": return grouped.user;
255
+ default: return assertExhaustive(kind);
256
+ }
257
+ };
258
+ const seqsForKind = (seqs, kind) => {
259
+ switch (kind) {
260
+ case "operator": return seqs.operator;
261
+ case "user": return seqs.user;
262
+ default: return assertExhaustive(kind);
263
+ }
264
+ };
265
+ // src/utils/invokeProcedureResolvers.ts
266
+ /**
267
+ * Runs `self` first. A synchronous throw must not start `broadcast`.
268
+ * If `broadcast` throws after `self` returned a promise, that work still runs.
269
+ * The promise stays attached so its rejection is not unhandled.
270
+ */
271
+ const invokeProcedureResolvers = (resolvers) => {
272
+ let selfResult;
273
+ try {
274
+ selfResult = resolvers.self();
275
+ } catch (error) {
276
+ return {
277
+ ok: false,
278
+ error
279
+ };
280
+ }
281
+ try {
282
+ return {
283
+ ok: true,
284
+ broadcast: resolvers.broadcast(),
285
+ self: selfResult
286
+ };
287
+ } catch (error) {
288
+ Promise.resolve(selfResult).catch(() => void 0);
289
+ return {
290
+ ok: false,
291
+ error
292
+ };
293
+ }
294
+ };
295
+ // src/utils/isThenable.ts
296
+ const isThenable = (value) => typeof value === "object" && value !== null && typeof value.then === "function";
297
+ // src/utils/listLiveUsers.ts
298
+ const participantOrder = (kind) => {
299
+ switch (kind) {
300
+ case "user": return 0;
301
+ case "operator": return 1;
302
+ default: return assertExhaustive(kind);
303
+ }
304
+ };
305
+ const compareToCursor = (row, cursor) => {
306
+ const byKind = participantOrder(row.kind) - participantOrder(cursor.kind);
307
+ if (byKind !== 0) return byKind;
308
+ if (row.key < cursor.id) return -1;
309
+ if (row.key > cursor.id) return 1;
310
+ return 0;
311
+ };
312
+ const listLiveUsers = (sessions, options) => {
313
+ const rows = groupLiveUsers(sessions, { kinds: options.kinds ?? ["user"] }).toSorted((left, right) => {
314
+ const byKind = participantOrder(left.kind) - participantOrder(right.kind);
315
+ if (byKind !== 0) return byKind;
316
+ if (left.key < right.key) return -1;
317
+ if (left.key > right.key) return 1;
318
+ return 0;
319
+ });
320
+ const cursor = options.cursor ?? null;
321
+ const startIndex = cursor == null ? 0 : rows.findIndex((row) => compareToCursor(row, cursor) > 0);
322
+ const from = startIndex < 0 ? rows.length : startIndex;
323
+ const slice = rows.slice(from, from + options.limit);
324
+ const hasNextPage = from + slice.length < rows.length;
325
+ const last = slice.at(-1);
326
+ return {
327
+ pageInfo: {
328
+ endCursor: last ? {
329
+ kind: last.kind,
330
+ id: last.key
331
+ } : null,
332
+ hasNextPage
333
+ },
334
+ users: slice.map((row) => ({
335
+ data: row.data,
336
+ kind: row.kind,
337
+ operator: row.operator
338
+ }))
339
+ };
340
+ };
341
+ // src/utils/resolveListUsersLimit.ts
342
+ const resolveListUsersLimit = (limit) => {
343
+ const resolved = limit ?? 50;
344
+ if (!Number.isInteger(resolved) || resolved < 1 || resolved > 100) return {
345
+ success: false,
346
+ error: {
347
+ code: "INVALID_LIMIT",
348
+ message: `Invalid listUsers limit. Limit must be an integer from 1 to ${100 .toLocaleString()}. Received: ${String(limit)}.`
349
+ }
350
+ };
351
+ return {
352
+ success: true,
353
+ limit: resolved
354
+ };
355
+ };
356
+ // src/utils/pageLiveUsers.ts
357
+ const pageLiveUsers = (sessions, options = {}) => {
358
+ const resolved = resolveListUsersLimit(options.limit);
359
+ if (!resolved.success) return resolved;
360
+ return {
361
+ success: true,
362
+ ...listLiveUsers(sessions, {
363
+ cursor: options.cursor ?? null,
364
+ kinds: options.kinds,
365
+ limit: resolved.limit
366
+ })
367
+ };
368
+ };
369
+ // src/utils/parseOperatorUser.ts
370
+ const parseOperatorUser = (value) => {
371
+ if (!value || typeof value !== "object") throw new Error("Invalid operator");
372
+ const record = value;
373
+ if (typeof record.id !== "string" || !record.id) throw new Error("Invalid operator id");
374
+ if (typeof record.name !== "string") throw new Error("Invalid operator name");
375
+ if (record.imageUrl !== null && typeof record.imageUrl !== "string") throw new Error("Invalid operator imageUrl");
376
+ return {
377
+ id: record.id,
378
+ name: record.name,
379
+ imageUrl: record.imageUrl
380
+ };
381
+ };
382
+ // src/utils/parsePluvSchema.ts
383
+ function parsePluvSchema(schema, data) {
384
+ const result = schema["~standard"].validate(data);
385
+ if (result instanceof Promise) throw new Error("Async schemas are not supported. Schema validate() must be synchronous.");
386
+ if (result.issues) {
387
+ const message = result.issues[0]?.message ?? "Invalid input";
388
+ throw new Error(message);
389
+ }
390
+ return result.value;
391
+ }
392
+ // src/utils/pickBy.ts
393
+ const pickBy = (obj, by) => {
394
+ const result = {};
395
+ const keys = Object.keys(obj);
396
+ for (let i = 0; i < keys.length; ++i) {
397
+ const key = keys[i];
398
+ const value = obj[key];
399
+ if (by(value)) result[key] = value;
400
+ }
401
+ return result;
402
+ };
403
+ // src/utils/resolveIOSecret.ts
404
+ /**
405
+ * Resolve a static or context-function JWT secret.
406
+ */
407
+ const resolveIOSecret = (secret, context) => {
408
+ if (typeof secret === "function") return secret(context);
409
+ return secret;
410
+ };
411
+ // src/utils/throttle.ts
412
+ const throttle = (fn, options) => {
413
+ const { wait } = options;
414
+ let lastInvokeMs = Number.NEGATIVE_INFINITY;
415
+ let pending = false;
416
+ let timer = null;
417
+ const flush = () => {
418
+ lastInvokeMs = Date.now();
419
+ pending = false;
420
+ fn();
421
+ };
422
+ const schedule = () => {
423
+ pending = true;
424
+ if (timer) return;
425
+ const remaining = wait - (Date.now() - lastInvokeMs);
426
+ timer = setTimeout(() => {
427
+ timer = null;
428
+ if (!pending) return;
429
+ flush();
430
+ }, remaining <= 0 ? 0 : remaining);
431
+ };
432
+ const cancel = () => {
433
+ if (timer) {
434
+ clearTimeout(timer);
435
+ timer = null;
436
+ }
437
+ pending = false;
438
+ };
439
+ return {
440
+ cancel,
441
+ schedule
442
+ };
443
+ };
444
+ // src/AbstractPersistence.ts
445
+ var AbstractPersistence = class {};
446
+ // src/Persistence.ts
447
+ var Persistence = class extends AbstractPersistence {
448
+ _storages = /* @__PURE__ */ new Map();
449
+ _users = /* @__PURE__ */ new Map();
450
+ addUser(room, connectionId, user) {
451
+ const users = this._users.get(room) ?? /* @__PURE__ */ new Map();
452
+ this._users.set(room, users);
453
+ users.set(connectionId, user);
454
+ return Promise.resolve();
455
+ }
456
+ deleteStorageState(room) {
457
+ this._storages.delete(room);
458
+ return Promise.resolve();
459
+ }
460
+ async deleteUser(room, connectionId) {
461
+ const users = this._users.get(room);
462
+ if (!users) return;
463
+ users.delete(connectionId);
464
+ if (!users.size) {
465
+ await this.deleteUsers(room);
466
+ await this.deleteStorageState(room);
467
+ }
468
+ return Promise.resolve();
469
+ }
470
+ deleteUsers(room) {
471
+ this._users.delete(room);
472
+ return Promise.resolve();
473
+ }
474
+ getStorageState(room) {
475
+ const storage = this._storages.get(room) ?? null;
476
+ return Promise.resolve(storage);
477
+ }
478
+ getUser(room, connectionId) {
479
+ const user = this._users.get(room)?.get(connectionId);
480
+ return Promise.resolve(user ?? null);
481
+ }
482
+ async getUsers(room) {
483
+ const users = this._users.get(room);
484
+ if (!users) return /* @__PURE__ */ new Map();
485
+ return users;
486
+ }
487
+ async getUsersSize(room) {
488
+ return this._users.get(room)?.size ?? 0;
489
+ }
490
+ initialize(roomContext) {
491
+ return this;
492
+ }
493
+ setStorageState(room, state) {
494
+ this._storages.set(room, state);
495
+ return Promise.resolve();
496
+ }
497
+ };
498
+ // src/AbstractPubSub.ts
499
+ var AbstractPubSub = class {};
500
+ // src/PubSub.ts
501
+ var PubSub = class extends AbstractPubSub {
502
+ _nextId = 0;
503
+ _rooms = /* @__PURE__ */ new Map();
504
+ async publish(roomName, payload) {
505
+ const room = this._rooms.get(roomName);
506
+ if (!room?.size) return;
507
+ await Promise.all([...room.values()].map((handler) => Promise.resolve(handler(payload))));
508
+ }
509
+ subscribe(roomName, onMessage) {
510
+ const id = this._nextId++;
511
+ const room = this._rooms.get(roomName) ?? /* @__PURE__ */ new Map();
512
+ room.set(id, onMessage);
513
+ this._rooms.set(roomName, room);
514
+ return Promise.resolve(id);
515
+ }
516
+ unsubscribe(subscriptionId) {
517
+ for (const [roomName, room] of this._rooms) {
518
+ if (!room.delete(subscriptionId)) continue;
519
+ if (room.size === 0) this._rooms.delete(roomName);
520
+ return;
521
+ }
522
+ }
523
+ };
524
+ // src/AbstractPlatform.ts
525
+ var AbstractPlatform = class {
526
+ _initContext;
527
+ _roomContext;
528
+ _initialized = false;
529
+ _keptAlive = /* @__PURE__ */ new Set();
530
+ persistence;
531
+ pubSub;
532
+ _createToken;
533
+ constructor(config = {}) {
534
+ const { roomContext, persistence, pubSub } = config;
535
+ this._meta = config._meta;
536
+ if (!!roomContext) this._roomContext = roomContext;
537
+ this.persistence = persistence ?? new Persistence();
538
+ this.pubSub = pubSub ?? new PubSub();
539
+ }
540
+ /**
541
+ * Retain `promise` until it settles. Cloudflare uses this to keep the isolate awake.
542
+ * The default holds the reference and does nothing else.
543
+ */
544
+ keepAlive(promise) {
545
+ this._keptAlive.add(promise);
546
+ promise.finally(() => {
547
+ this._keptAlive.delete(promise);
548
+ });
549
+ }
550
+ /**
551
+ * Normalize register/createToken init context before authorize runs.
552
+ * Platforms may convert runtime-specific request types (e.g. Node `IncomingMessage`).
553
+ */
554
+ normalizeInitContext(initContext) {
555
+ return initContext;
556
+ }
557
+ validateConfig(config) {}
558
+ _initialize() {
559
+ if (this._initialized) throw new Error(oneLine`
560
+ Platform is already initialized. The \`platform\` factory passed to
561
+ \`createIO().platform(...)\` must return a new platform instance each
562
+ time it is called (e.g. \`platformNode()\`), not reuse one.
563
+ `);
564
+ this._initialized = true;
565
+ return this;
566
+ }
567
+ };
568
+ // src/roomEffects.ts
569
+ const DEFERRED_RESULT = "$u";
570
+ const deferredResult = (promise) => {
571
+ return Object.defineProperty({}, DEFERRED_RESULT, { value: promise });
572
+ };
573
+ const isDeferredResult = (value) => {
574
+ if (typeof value !== "object" || value === null || !(DEFERRED_RESULT in value)) return false;
575
+ const descriptor = Object.getOwnPropertyDescriptor(value, DEFERRED_RESULT);
576
+ return descriptor?.enumerable === false && isThenable(descriptor.value);
577
+ };
578
+ const deferredResultPromise = (result) => result[DEFERRED_RESULT];
579
+ const isSuspended = (value) => typeof value === "object" && value !== null && "suspended" in value && value.suspended === true;
580
+ const isErrorMessage = (value) => {
581
+ return typeof value === "object" && value !== null && "message" in value && typeof value.message === "string";
582
+ };
583
+ const emptyRoomEffects = () => ({
584
+ attachments: [],
585
+ broadcast: [],
586
+ closes: [],
587
+ connectionId: null,
588
+ destroy: false,
589
+ error: null,
590
+ self: [],
591
+ storageDirty: false
592
+ });
593
+ // src/AbstractWebSocket.ts
594
+ const errorMessage = (error) => {
595
+ if (isErrorMessage(error)) return error.message;
596
+ return "Unexpected error";
597
+ };
598
+ var AbstractWebSocket = class {
599
+ /** The connection is not yet open. */
600
+ CONNECTING = 0;
601
+ /** The connection is open and ready to communicate. */
602
+ OPEN = 1;
603
+ /** The connection is in the process of closing. */
604
+ CLOSING = 2;
605
+ /** The connection is closed. */
606
+ CLOSED = 3;
607
+ persistence;
608
+ room;
609
+ webSocket;
610
+ _platform;
611
+ constructor(webSocket, config) {
612
+ const { persistence, platform, room } = config;
613
+ this.persistence = persistence;
614
+ this._platform = platform;
615
+ this.room = room;
616
+ this.webSocket = webSocket;
617
+ }
618
+ handleError(params) {
619
+ const { error, message: _message, room, session } = params;
620
+ const message = errorMessage(error);
621
+ const stack = error instanceof Error ? error.stack ?? null : null;
622
+ const connectionId = session?.id ?? null;
623
+ return this.sendMessage({
624
+ connectionId,
625
+ data: {
626
+ message,
627
+ stack
628
+ },
629
+ room,
630
+ type: "$error",
631
+ user: session?.user ?? null,
632
+ operator: session?.operator ?? null
633
+ });
634
+ }
635
+ sendMessage(data) {
636
+ return this.send(JSON.stringify(data));
637
+ }
638
+ };
639
+ // src/CommitGate.ts
640
+ /**
641
+ * Orders one commit after another.
642
+ * `idle` means the latest reserved turn has already finished.
643
+ * `reserve` takes the next turn. `previous` settles when the earlier turn
644
+ * finishes. `finish` runs once, when this turn's commit has ended.
645
+ */
646
+ var CommitGate = class {
647
+ #turn = {
648
+ done: true,
649
+ promise: Promise.resolve()
650
+ };
651
+ get idle() {
652
+ return this.#turn.done;
653
+ }
654
+ reserve() {
655
+ const previous = this.#turn.promise;
656
+ let settled = false;
657
+ let resolveTurn = () => void 0;
658
+ const turn = {
659
+ done: false,
660
+ promise: new Promise((resolve) => {
661
+ resolveTurn = resolve;
662
+ })
663
+ };
664
+ const finish = () => {
665
+ if (settled) return;
666
+ settled = true;
667
+ turn.done = true;
668
+ resolveTurn();
669
+ };
670
+ this.#turn = turn;
671
+ return {
672
+ previous,
673
+ finish
674
+ };
675
+ }
676
+ };
677
+ // src/authorize.ts
678
+ /** Default token lifetime: 60 seconds (passed to jose as seconds). */
679
+ const DEFAULT_MAX_AGE_MS = 6e4;
680
+ const getEncryptionKey = async (secret) => {
681
+ return await hkdf("sha256", secret, "", "Pluv.io Generated Encryption Key", 32);
682
+ };
683
+ const isOperatorToken = (params) => {
684
+ return params.kind === "operator";
685
+ };
686
+ const now = () => Date.now() / 1e3 | 0;
687
+ const maxAgeMsToSeconds = (maxAgeMs) => {
688
+ return Math.max(1, Math.ceil(maxAgeMs / 1e3));
689
+ };
690
+ const resolveJwtIdentity = (params) => {
691
+ const { room } = params;
692
+ switch (params.kind) {
693
+ case "operator": {
694
+ const { operator, user } = params;
695
+ return {
696
+ kind: "operator",
697
+ operator,
698
+ room,
699
+ sub: `${room}|operator|${operator.id}`,
700
+ user
701
+ };
702
+ }
703
+ case "user":
704
+ case void 0: {
705
+ const { user } = params;
706
+ return {
707
+ kind: "user",
708
+ operator: null,
709
+ room,
710
+ sub: `${room}|${user.id}`,
711
+ user
712
+ };
713
+ }
714
+ default: return assertExhaustive(params);
715
+ }
716
+ };
717
+ const authorize = (params) => {
718
+ const { platform, secret } = params;
719
+ const encode = async (encodeParams) => {
720
+ const { maxAge = DEFAULT_MAX_AGE_MS } = encodeParams;
721
+ const claims = resolveJwtIdentity(encodeParams);
722
+ const encryptionSecret = await getEncryptionKey(secret);
723
+ return await new EncryptJWT({ ...claims }).setProtectedHeader({
724
+ alg: "dir",
725
+ enc: "A256GCM"
726
+ }).setIssuedAt().setExpirationTime(now() + maxAgeMsToSeconds(maxAge)).setJti(platform.randomUUID()).encrypt(encryptionSecret);
727
+ };
728
+ const decode = async (jwt) => {
729
+ try {
730
+ const encryptionSecret = await getEncryptionKey(secret);
731
+ const { payload } = await jwtDecrypt(jwt, encryptionSecret, { clockTolerance: 15 });
732
+ const decoded = payload ?? null;
733
+ if (!decoded) return null;
734
+ const kind = decoded.kind;
735
+ switch (kind) {
736
+ case "operator": return {
737
+ kind: "operator",
738
+ operator: decoded.operator,
739
+ room: decoded.room,
740
+ sub: decoded.sub,
741
+ user: decoded.user
742
+ };
743
+ case "user":
744
+ case void 0: return {
745
+ kind: "user",
746
+ operator: null,
747
+ room: decoded.room,
748
+ sub: decoded.sub,
749
+ user: decoded.user
750
+ };
751
+ default: return assertExhaustive(kind);
752
+ }
753
+ } catch {
754
+ return null;
755
+ }
756
+ };
757
+ return {
758
+ decode,
759
+ encode
760
+ };
761
+ };
762
+ // src/PluvProcedure.ts
763
+ var PluvProcedure = class PluvProcedure {
764
+ _broadcast = null;
765
+ _input = null;
766
+ _self = null;
767
+ get config() {
768
+ return {
769
+ broadcast: this._broadcast?.bind(this) ?? null,
770
+ input: this._input ?? null,
771
+ self: this._self?.bind(this) ?? null
772
+ };
773
+ }
774
+ constructor(config = {}) {
775
+ const { broadcast, input, self } = config;
776
+ this._broadcast = broadcast ?? null;
777
+ this._input = input ?? null;
778
+ this._self = self ?? null;
779
+ }
780
+ broadcast(resolver) {
781
+ const { broadcast } = this.config;
782
+ if (!!broadcast) throw new Error("Broadcast was already defined for this procedure");
783
+ return new PluvProcedure({
784
+ ...this.config,
785
+ broadcast: resolver
786
+ });
787
+ }
788
+ input(input) {
789
+ return new PluvProcedure({ input });
790
+ }
791
+ self(resolver) {
792
+ const { self } = this.config;
793
+ if (!!self) throw new Error("Self was already defined for this procedure");
794
+ return new PluvProcedure({
795
+ ...this.config,
796
+ self: resolver
797
+ });
798
+ }
799
+ };
800
+ // src/createSessionRouter.ts
801
+ const baseProcedureFactory$1 = () => () => {
802
+ return new PluvProcedure();
803
+ };
804
+ const asSession = (event) => {
805
+ return event;
806
+ };
807
+ const createSessionRouter = (params) => {
808
+ const readLimits = () => typeof params.limits === "function" ? params.limits() : params.limits;
809
+ const baseProcedure = baseProcedureFactory$1();
810
+ return createInternalPluvRouter({
811
+ $getOthers: baseProcedure().self((_data, raw) => {
812
+ const { session, sessions } = asSession(raw);
813
+ const presenceSeqById = new Map(sessions.map((item) => [item.id, item.seq.presence]));
814
+ const others = groupLiveUsers(sessions, { excludeSessionId: session.id }).map(({ connectionIds, data, presence, kind, operator }) => ({
815
+ connectionIds,
816
+ data,
817
+ session: {
818
+ kind,
819
+ operator,
820
+ presence,
821
+ seq: { presence: connectionIds.reduce((max, id) => {
822
+ const seq = presenceSeqById.get(id) ?? null;
823
+ if (typeof seq !== "number") return max;
824
+ if (typeof max !== "number") return seq;
825
+ return seq > max ? seq : max;
826
+ }, null) }
827
+ }
828
+ }));
829
+ return { $othersReceived: {
830
+ myConnectionIds: getMyConnectionIds(sessions, session),
831
+ others
832
+ } };
833
+ }),
834
+ $listUsers: baseProcedure().self((data, raw) => {
835
+ const { sessions } = asSession(raw);
836
+ const result = pageLiveUsers(sessions, data);
837
+ if (!result.success) return { $usersPage: {
838
+ requestId: data.requestId,
839
+ success: false,
840
+ error: {
841
+ code: result.error.code === "INVALID_LIMIT" ? "INVALID_LIMIT" : "FAILED",
842
+ message: result.error.message
843
+ }
844
+ } };
845
+ return { $usersPage: {
846
+ requestId: data.requestId,
847
+ success: true,
848
+ pageInfo: result.pageInfo,
849
+ users: result.users
850
+ } };
851
+ }),
852
+ $initializeSession: baseProcedure().broadcast((data, raw) => {
853
+ const event = asSession(raw);
854
+ const presence = data.presence ?? null;
855
+ const { session } = event;
856
+ if (!session) return {};
857
+ const kind = getSessionKind(session);
858
+ const participantId = session.user.id;
859
+ if (typeof event.sessions.reduce((max, other) => {
860
+ if (other.quit) return max;
861
+ const otherKind = getSessionKind(other);
862
+ const otherId = other.user?.id;
863
+ if (otherKind !== kind || otherId !== participantId) return max;
864
+ const seq = other.seq.presence;
865
+ if (typeof seq !== "number") return max;
866
+ if (typeof max !== "number") return seq;
867
+ return seq > max ? seq : max;
868
+ }, null) !== "number") event.presence = presence;
869
+ return { $userJoined: {
870
+ connectionId: session.id,
871
+ user: session.user,
872
+ session: {
873
+ kind,
874
+ operator: session.operator,
875
+ presence: session.presence ?? presence ?? {},
876
+ seq: { presence: session.webSocket.state.seq.presence }
877
+ }
878
+ } };
879
+ }),
880
+ $ping: baseProcedure().self((_data, raw) => {
881
+ const { platform, session } = asSession(raw);
882
+ if (!session) return {};
883
+ const currentTime = (/* @__PURE__ */ new Date()).getTime();
884
+ const prevState = session.webSocket.state;
885
+ platform.setSerializedState(session.webSocket, {
886
+ ...prevState,
887
+ timers: {
888
+ ...prevState.timers,
889
+ ping: currentTime
890
+ }
891
+ });
892
+ return { $pong: {} };
893
+ }),
894
+ $updatePresence: baseProcedure().broadcast((data, raw) => {
895
+ const context = asSession(raw);
896
+ const presence = data.presence;
897
+ const { session } = context;
898
+ if (!session) return {};
899
+ const cleanedPatch = pickBy(presence ?? {}, (value) => typeof value !== "undefined");
900
+ const updated = Object.assign(Object.create(null), session.presence, cleanedPatch);
901
+ const bytes = new TextEncoder().encode(JSON.stringify(updated)).length;
902
+ const presenceMaxSize = readLimits()?.presenceMaxSize;
903
+ if (!!presenceMaxSize && bytes > presenceMaxSize) throw new Error(oneLine`
904
+ Large presence. Presence must be at most
905
+ ${presenceMaxSize.toLocaleString()} bytes.
906
+ Current size: ${bytes.toLocaleString()} bytes
907
+ `);
908
+ context.presence = updated;
909
+ return { $presenceUpdated: {
910
+ user: session.user,
911
+ session: {
912
+ presence: updated,
913
+ seq: { presence: session.webSocket.state.seq.presence },
914
+ kind: session.kind,
915
+ operator: session.operator
916
+ }
917
+ } };
918
+ })
919
+ });
920
+ };
921
+ // src/createStorageRouter.ts
922
+ const baseProcedureFactory = () => () => {
923
+ return new PluvProcedure();
924
+ };
925
+ const asStorage = (event) => {
926
+ return event;
927
+ };
928
+ /** Storage `$` events. These run beside the doc. */
929
+ const createStorageRouter = (params) => {
930
+ const { limits } = params;
931
+ const logDebug = params.logDebug ?? (() => void 0);
932
+ const baseProcedure = baseProcedureFactory();
933
+ return createInternalPluvRouter({
934
+ $syncStorage: baseProcedure().self((data, { doc }) => {
935
+ return { $storageDiff: { update: data.stateVector ? doc.encodeDiff(data.stateVector) : doc.getEncodedState() } };
936
+ }),
937
+ $updateStorage: baseProcedure().broadcast((data, raw) => {
938
+ const { doc } = asStorage(raw);
939
+ const origin = data.origin;
940
+ const update = data.update ?? null;
941
+ if (origin === "$initialized") return {};
942
+ if (!update) return {};
943
+ const before = doc.getStateVector();
944
+ const wasPending = doc.hasPending();
945
+ doc.applyEncodedState({ update });
946
+ const integrated = doc.getStateVector() !== before;
947
+ if (!integrated && (doc.hasPending() || !doc.canDetectPending())) {
948
+ logDebug("Storage update did not integrate");
949
+ if (data.resync) return {};
950
+ return { $storageResync: { stateVector: doc.getStateVector() } };
951
+ }
952
+ const state = integrated ? doc.encodeDiff(before) : update;
953
+ const encodedState = doc.getEncodedState();
954
+ const storageSize = new TextEncoder().encode(encodedState).length;
955
+ if (!!limits.storageMaxSize && storageSize > limits.storageMaxSize) throw new Error(oneLine`
956
+ Large Storage. Storage must be at most
957
+ ${limits.storageMaxSize.toLocaleString()} bytes.
958
+ Current size: ${storageSize.toLocaleString()} bytes
959
+ `);
960
+ if (doc.hasPending() && !wasPending) return {
961
+ $storageUpdated: { state },
962
+ $storageResync: { stateVector: doc.getStateVector() }
963
+ };
964
+ return { $storageUpdated: { state } };
965
+ })
966
+ });
967
+ };
968
+ // src/createBaseRouter.ts
969
+ /**
970
+ * Built-in `$` protocol events. Kept separate from `PluvServer` so the protocol
971
+ * can be constructed/tested without a full server instance. This merge only
972
+ * fills `_defs`. `manageSessions` dispatches the session slice. The room
973
+ * dispatches storage events plus the user's router.
974
+ */
975
+ const createBaseRouter = (params) => {
976
+ return PluvRouter.merge(createSessionRouter(params), createStorageRouter(params));
977
+ };
978
+ // src/RoomStorage.ts
979
+ var RoomStorage = class {
980
+ _docFactory;
981
+ _getContext;
982
+ _getInitialStorage;
983
+ _platform;
984
+ _room;
985
+ _doc;
986
+ _storageSeeded = false;
987
+ constructor(config) {
988
+ this._docFactory = config.docFactory;
989
+ this._getContext = config.getContext;
990
+ this._getInitialStorage = config.getInitialStorage;
991
+ this._platform = config.platform;
992
+ this._room = config.room;
993
+ this._doc = Promise.resolve(this._docFactory.getEmpty());
994
+ }
995
+ get doc() {
996
+ return this._doc;
997
+ }
998
+ getEmptyDoc() {
999
+ return this._docFactory.getEmpty();
1000
+ }
1001
+ get storageSeeded() {
1002
+ return this._storageSeeded;
1003
+ }
1004
+ set storageSeeded(value) {
1005
+ this._storageSeeded = value;
1006
+ }
1007
+ async destroy() {
1008
+ const resolvedDoc = await this._doc;
1009
+ const encodedState = resolvedDoc.getEncodedState();
1010
+ const shouldDestroyStorage = this._storageSeeded && resolvedDoc.isDirty();
1011
+ const refusedEmptyPersist = this._storageSeeded && !shouldDestroyStorage;
1012
+ this._storageSeeded = false;
1013
+ resolvedDoc.destroy();
1014
+ this._doc = Promise.resolve(this._docFactory.getEmpty());
1015
+ return {
1016
+ encodedState,
1017
+ refusedEmptyPersist,
1018
+ shouldDestroyStorage
1019
+ };
1020
+ }
1021
+ initialize() {
1022
+ this._doc = this._loadInitialDoc();
1023
+ return this._doc;
1024
+ }
1025
+ async _loadInitialDoc() {
1026
+ const doc = this._docFactory.getEmpty();
1027
+ const [encodedState, context] = await Promise.all([this._platform.persistence.getStorageState(this._room), this._getContext()]);
1028
+ if (!encodedState) {
1029
+ const loadedState = await this._getInitialStorage({
1030
+ context,
1031
+ room: this._room
1032
+ });
1033
+ if (!!loadedState && !this._docFactory.isEmpty(loadedState)) {
1034
+ doc.applyEncodedState({ update: loadedState });
1035
+ this._storageSeeded = true;
1036
+ }
1037
+ }
1038
+ if (typeof encodedState === "string") {
1039
+ doc.applyEncodedState({ update: encodedState });
1040
+ if (!doc.isEmpty()) this._storageSeeded = true;
1041
+ }
1042
+ doc.rebuildStorage();
1043
+ return doc;
1044
+ }
1045
+ };
1046
+ // src/StorageChangeLog.ts
1047
+ /**
1048
+ * Ordered document edits for `onStorageEdits`. The first edit starts the wait.
1049
+ * Edits during that wait join the same list. A room with no listener does not
1050
+ * construct this.
1051
+ */
1052
+ var StorageChangeLog = class {
1053
+ _changes = [];
1054
+ _closed = false;
1055
+ _queue = Promise.resolve();
1056
+ _getContext;
1057
+ _onEdits;
1058
+ _room;
1059
+ _wait;
1060
+ constructor(config) {
1061
+ this._getContext = config.getContext;
1062
+ this._onEdits = config.onEdits;
1063
+ this._room = config.room;
1064
+ this._wait = new ArmedWait(config.intervalMs, config.keepAlive);
1065
+ }
1066
+ push(changes) {
1067
+ if (this._closed || changes.length === 0) return;
1068
+ this._changes.push(...changes);
1069
+ this._arm();
1070
+ }
1071
+ detach() {
1072
+ this._closed = true;
1073
+ this._wait.clear();
1074
+ }
1075
+ /** Send the waiting list now, then one follow-up list if that call writes. */
1076
+ flush() {
1077
+ const run = this._queue.then(() => this._flush());
1078
+ this._queue = run.then(() => void 0, () => void 0);
1079
+ return run;
1080
+ }
1081
+ reopen() {
1082
+ this._closed = false;
1083
+ this._changes = [];
1084
+ this._wait.clear();
1085
+ }
1086
+ _arm() {
1087
+ if (this._closed) return;
1088
+ this._wait.arm(() => this.flush());
1089
+ }
1090
+ async _emit() {
1091
+ if (this._changes.length === 0) return;
1092
+ const changes = this._changes;
1093
+ this._changes = [];
1094
+ try {
1095
+ const context = await this._getContext();
1096
+ await this._onEdits({
1097
+ changes,
1098
+ context,
1099
+ room: this._room
1100
+ });
1101
+ } catch (error) {
1102
+ console.error(error);
1103
+ }
1104
+ }
1105
+ async _flush() {
1106
+ this._wait.clear();
1107
+ await this._emit();
1108
+ this._wait.clear();
1109
+ if (this._changes.length > 0) await this._emit();
1110
+ }
1111
+ };
1112
+ // src/StorageReservations.ts
1113
+ const covers = (doc, version) => {
1114
+ for (const [id, clock] of version) if ((doc.get(id) ?? 0) < clock) return false;
1115
+ return true;
1116
+ };
1117
+ /**
1118
+ * Same-client updates leave in clock order. Updates from different clients keep
1119
+ * the order their groups first arrived. Counters from different clients are not
1120
+ * comparable.
1121
+ */
1122
+ const orderCovered = (ready) => {
1123
+ const groups = /* @__PURE__ */ new Map();
1124
+ for (const item of ready) {
1125
+ const key = item.version.length === 1 ? item.version[0][0] : `\0${item.seq}`;
1126
+ const group = groups.get(key);
1127
+ if (group) group.push(item);
1128
+ else groups.set(key, [item]);
1129
+ }
1130
+ return [...groups.values()].sort((left, right) => Math.min(...left.map((item) => item.seq)) - Math.min(...right.map((item) => item.seq))).flatMap((group) => group.toSorted((left, right) => {
1131
+ const leftClock = left.version[0]?.[1] ?? 0;
1132
+ const rightClock = right.version[0]?.[1] ?? 0;
1133
+ if (left.version.length === 1 && right.version.length === 1 && leftClock !== rightClock) return leftClock - rightClock;
1134
+ return left.seq - right.seq;
1135
+ }).map(({ operator, procedure, update, user }) => ({
1136
+ operator,
1137
+ procedure,
1138
+ update,
1139
+ user
1140
+ })));
1141
+ };
1142
+ /** Bytes waiting until the document version covers them. Not an edit yet. */
1143
+ var StorageReservations = class {
1144
+ _maxBytes;
1145
+ _bytes = 0;
1146
+ _items = [];
1147
+ _seq = 0;
1148
+ /** `0` keeps every waiting update. A positive cap matches the document size limit. */
1149
+ constructor(_maxBytes = 0) {
1150
+ this._maxBytes = _maxBytes;
1151
+ }
1152
+ add(input) {
1153
+ const bytes = input.update.length;
1154
+ if (this._maxBytes > 0 && bytes > this._maxBytes) return;
1155
+ this._seq += 1;
1156
+ this._items.push({
1157
+ ...input,
1158
+ seq: this._seq
1159
+ });
1160
+ this._bytes += bytes;
1161
+ }
1162
+ clear() {
1163
+ this._bytes = 0;
1164
+ this._items = [];
1165
+ }
1166
+ holds(update) {
1167
+ return this._items.some((item) => item.update === update);
1168
+ }
1169
+ takeCovered(doc, contained) {
1170
+ const ready = [];
1171
+ const waiting = [];
1172
+ for (const item of this._items) if (covers(doc, item.version) && contained(item.update)) {
1173
+ ready.push(item);
1174
+ this._bytes -= item.update.length;
1175
+ } else waiting.push(item);
1176
+ this._items = waiting;
1177
+ return orderCovered(ready);
1178
+ }
1179
+ /**
1180
+ * Drop the oldest waiting updates until the retained bytes fit. Call this after
1181
+ * the covered ones are admitted. The apply runs between `add` and that
1182
+ * admission, so trimming inside `add` drops an update the apply is about to
1183
+ * make reportable.
1184
+ */
1185
+ trim() {
1186
+ while (this._maxBytes > 0 && this._bytes > this._maxBytes && this._items.length > 1) {
1187
+ const oldest = this._items.shift();
1188
+ if (!oldest) return;
1189
+ this._bytes -= oldest.update.length;
1190
+ }
1191
+ }
1192
+ };
1193
+ // src/StorageEdits.ts
1194
+ /**
1195
+ * Reservations become edits once the document covers them. A write that was
1196
+ * never reserved is still reported, with no author, so a replay includes it.
1197
+ */
1198
+ var StorageEdits = class {
1199
+ _paused = false;
1200
+ _unsubscribe = null;
1201
+ _log;
1202
+ _reservations;
1203
+ constructor(config) {
1204
+ this._log = new StorageChangeLog(config);
1205
+ this._reservations = new StorageReservations(config.maxBytes ?? 0);
1206
+ }
1207
+ attach(doc) {
1208
+ this.detach();
1209
+ this._log.reopen();
1210
+ this._paused = false;
1211
+ this._unsubscribe = doc.subscribe((notice) => {
1212
+ if (this._paused || !notice.update) return;
1213
+ this.note(doc, notice.update);
1214
+ });
1215
+ }
1216
+ clearReservations() {
1217
+ this._reservations.clear();
1218
+ }
1219
+ detach() {
1220
+ this._reservations.clear();
1221
+ this._unsubscribe?.();
1222
+ this._unsubscribe = null;
1223
+ this._log.detach();
1224
+ }
1225
+ flush() {
1226
+ return this._log.flush();
1227
+ }
1228
+ note(doc, notice) {
1229
+ const version = new Map(doc.readStateVersion(doc.getStateVector()));
1230
+ const admitted = this._reservations.takeCovered(version, (update) => doc.containsUpdate(update));
1231
+ const edits = [...admitted];
1232
+ if (notice && admitted.length === 0 && !this._reservations.holds(notice) && doc.containsUpdate(notice)) edits.push({
1233
+ update: notice,
1234
+ user: null,
1235
+ operator: null,
1236
+ procedure: null
1237
+ });
1238
+ this._reservations.trim();
1239
+ this._log.push(edits);
1240
+ }
1241
+ /** Skip subscription notes while a capture is still collecting its bytes. */
1242
+ pause(paused) {
1243
+ this._paused = paused;
1244
+ }
1245
+ reopen() {
1246
+ this._reservations.clear();
1247
+ this._log.reopen();
1248
+ }
1249
+ reserve(doc, input) {
1250
+ if (!input.update) return;
1251
+ this._reservations.add({
1252
+ ...input,
1253
+ version: doc.readUpdateVersion(input.update)
1254
+ });
1255
+ }
1256
+ };
1257
+ // src/StorageSnapshot.ts
1258
+ /**
1259
+ * The room's snapshot. The document stays live. This copies it when asked.
1260
+ * The session manager owns the clock.
1261
+ */
1262
+ var StorageSnapshot = class {
1263
+ _closed = false;
1264
+ _dirty = false;
1265
+ _epoch = 0;
1266
+ _generation = 0;
1267
+ _hold = 0;
1268
+ _pending = [];
1269
+ _release = null;
1270
+ _unsubscribe = null;
1271
+ _untilClean = null;
1272
+ _getContext;
1273
+ _getDoc;
1274
+ _keepAlive;
1275
+ _limits;
1276
+ _onStored;
1277
+ _room;
1278
+ _setStorageState;
1279
+ constructor(config) {
1280
+ this._getContext = config.getContext;
1281
+ this._getDoc = config.getDoc;
1282
+ this._keepAlive = config.keepAlive;
1283
+ this._limits = config.limits;
1284
+ this._onStored = config.onStored;
1285
+ this._room = config.room;
1286
+ this._setStorageState = config.setStorageState;
1287
+ }
1288
+ get closed() {
1289
+ return this._closed;
1290
+ }
1291
+ get dirty() {
1292
+ return this._dirty && !this._closed;
1293
+ }
1294
+ /** Updates still need a `$storageUpdated` frame. A closed room does not retry them. */
1295
+ get hasPending() {
1296
+ return !this._closed && this._pending.length > 0;
1297
+ }
1298
+ get epoch() {
1299
+ return this._epoch;
1300
+ }
1301
+ /** Increments in `seal`. Stays put across `reopen`. */
1302
+ get generation() {
1303
+ return this._generation;
1304
+ }
1305
+ /** The seed snapshot already stored this epoch. A later write still needs a copy. */
1306
+ acknowledge(epoch) {
1307
+ if (this._epoch === epoch) this._markClean();
1308
+ }
1309
+ assertSize(encodedState) {
1310
+ if (!this._exceedsLimit(encodedState)) return;
1311
+ const limit = this._limits.storageMaxSize ?? 0;
1312
+ const storageSize = new TextEncoder().encode(encodedState).length;
1313
+ throw new Error(oneLine`
1314
+ Large Storage. Storage must be at most
1315
+ ${limit.toLocaleString()} bytes.
1316
+ Current size: ${storageSize.toLocaleString()} bytes
1317
+ `);
1318
+ }
1319
+ attach(doc) {
1320
+ this.detach();
1321
+ this._unsubscribe = doc.subscribe((notice) => {
1322
+ if (!notice.update) return;
1323
+ this._epoch += 1;
1324
+ this._markDirty();
1325
+ if (this._hold > 0 || this._closed) return;
1326
+ this._pending.push(notice.update);
1327
+ });
1328
+ }
1329
+ detach() {
1330
+ this._unsubscribe?.();
1331
+ this._unsubscribe = null;
1332
+ }
1333
+ async flush() {
1334
+ if (this._closed) return "closed";
1335
+ if (!this._dirty) return "clean";
1336
+ const doc = await this._getDoc();
1337
+ const context = await this._getContext();
1338
+ if (this._closed) return "closed";
1339
+ if (!this._dirty) return "clean";
1340
+ const stored = await this._store({
1341
+ context,
1342
+ doc,
1343
+ notify: true
1344
+ });
1345
+ if (this._closed) return "closed";
1346
+ if (stored.oversized || !stored.duringNotify && !stored.duringSnapshot) {
1347
+ this._markClean();
1348
+ return "clean";
1349
+ }
1350
+ const follow = await this._store({
1351
+ context,
1352
+ doc,
1353
+ notify: false
1354
+ });
1355
+ if (this._closed) return "closed";
1356
+ if (follow.oversized || !follow.duringSnapshot) {
1357
+ this._markClean();
1358
+ return "clean";
1359
+ }
1360
+ this._markDirty();
1361
+ return "dirty";
1362
+ }
1363
+ /** Doc updates inside `fn` mark the snapshot stale and do not broadcast again. */
1364
+ hold(fn) {
1365
+ this._hold += 1;
1366
+ try {
1367
+ return fn();
1368
+ } finally {
1369
+ this._hold -= 1;
1370
+ }
1371
+ }
1372
+ /** An update made inside `hold` still needs a `$storageUpdated` frame. */
1373
+ queueUpdate(update) {
1374
+ if (this._closed || update.length === 0) return;
1375
+ this._pending.push(update);
1376
+ }
1377
+ /** Drop updates whose `$storageUpdated` frames were delivered. */
1378
+ dropUpdates(updates) {
1379
+ if (updates.length === 0 || this._pending.length === 0) return;
1380
+ const remaining = /* @__PURE__ */ new Map();
1381
+ for (const update of updates) remaining.set(update, (remaining.get(update) ?? 0) + 1);
1382
+ this._pending = this._pending.filter((update) => {
1383
+ const count = remaining.get(update);
1384
+ if (!count) return true;
1385
+ if (count === 1) remaining.delete(update);
1386
+ else remaining.set(update, count - 1);
1387
+ return false;
1388
+ });
1389
+ }
1390
+ /** A copy of the updates that still need a `$storageUpdated` frame. */
1391
+ pendingUpdates() {
1392
+ return [...this._pending];
1393
+ }
1394
+ reopen() {
1395
+ this._closed = false;
1396
+ this._markClean();
1397
+ this._epoch = 0;
1398
+ this._hold = 0;
1399
+ this._pending = [];
1400
+ }
1401
+ async seal() {
1402
+ this._closed = true;
1403
+ this._generation += 1;
1404
+ if (!this._dirty) return;
1405
+ const doc = await this._getDoc();
1406
+ const context = await this._getContext();
1407
+ let notify = true;
1408
+ for (;;) {
1409
+ const stored = await this._store({
1410
+ context,
1411
+ doc,
1412
+ notify
1413
+ });
1414
+ notify = false;
1415
+ if (stored.oversized || !stored.duringNotify && !stored.duringSnapshot) {
1416
+ this._markClean();
1417
+ return;
1418
+ }
1419
+ if (stored.duringSnapshot) continue;
1420
+ const follow = await this._store({
1421
+ context,
1422
+ doc,
1423
+ notify: false
1424
+ });
1425
+ if (follow.oversized || !follow.duringSnapshot) {
1426
+ this._markClean();
1427
+ return;
1428
+ }
1429
+ }
1430
+ }
1431
+ async _store(params) {
1432
+ const { context, doc, notify } = params;
1433
+ const encodedState = doc.getEncodedState();
1434
+ if (this._exceedsLimit(encodedState)) {
1435
+ this._markClean();
1436
+ return {
1437
+ duringNotify: false,
1438
+ duringSnapshot: false,
1439
+ oversized: true
1440
+ };
1441
+ }
1442
+ const epoch = this._epoch;
1443
+ await this._setStorageState(encodedState);
1444
+ const duringSnapshot = this._epoch !== epoch;
1445
+ const notifiedAt = this._epoch;
1446
+ if (notify) this._onStored({
1447
+ context,
1448
+ encodedState,
1449
+ room: this._room
1450
+ });
1451
+ return {
1452
+ duringNotify: this._epoch !== notifiedAt,
1453
+ duringSnapshot,
1454
+ oversized: false
1455
+ };
1456
+ }
1457
+ _arm() {
1458
+ if (this._untilClean) return;
1459
+ let release;
1460
+ const held = new Promise((resolve) => {
1461
+ release = resolve;
1462
+ });
1463
+ this._untilClean = held;
1464
+ this._release = release;
1465
+ this._keepAlive(held);
1466
+ }
1467
+ _exceedsLimit(encodedState) {
1468
+ const storageSize = new TextEncoder().encode(encodedState).length;
1469
+ return !!this._limits.storageMaxSize && storageSize > this._limits.storageMaxSize;
1470
+ }
1471
+ _markClean() {
1472
+ this._dirty = false;
1473
+ this._release?.();
1474
+ this._release = null;
1475
+ this._untilClean = null;
1476
+ }
1477
+ _markDirty() {
1478
+ this._dirty = true;
1479
+ if (this._closed) return;
1480
+ this._arm();
1481
+ }
1482
+ };
1483
+ // src/IORoom.ts
1484
+ /** Clients ignore `$storageUpdated` when `connectionId` is missing. A listener write has no session. */
1485
+ const SERVER_STORAGE_CONNECTION_ID = "$server";
1486
+ const effectFrame = (type, data, session) => ({
1487
+ connectionId: session.id,
1488
+ data,
1489
+ operator: session.operator,
1490
+ type,
1491
+ user: session.user
1492
+ });
1493
+ const framesFromRecord = (record, session) => {
1494
+ if (!record) return [];
1495
+ return Object.entries(record).map(([type, data]) => effectFrame(type, data, session));
1496
+ };
1497
+ var IORoom = class {
1498
+ id;
1499
+ limits;
1500
+ treaty;
1501
+ "~";
1502
+ _teardown = null;
1503
+ _uninitialize = null;
1504
+ _edits;
1505
+ _snapshot;
1506
+ _context;
1507
+ _debug;
1508
+ _listeners;
1509
+ _platform;
1510
+ _roomContext;
1511
+ _router;
1512
+ _secret;
1513
+ _storage;
1514
+ /**
1515
+ * @ignore
1516
+ * @readonly
1517
+ * @deprecated Internal use only. Changes to this will never be marked as breaking.
1518
+ */
1519
+ get _defs() {
1520
+ return {
1521
+ context: this._context,
1522
+ events: this._router._defs.events,
1523
+ platform: this._platform,
1524
+ treaty: this.treaty
1525
+ };
1526
+ }
1527
+ constructor(id, config) {
1528
+ const { _meta, context, debug, getInitialStorage, limits, onRoomDestroyed, onStorageDestroyed, onStorageEdits, onStorageSnapshot, onMessage, onUserConnected, onUserDisconnected, platform, roomContext, router, secret, treaty } = config;
1529
+ this.id = id;
1530
+ this.limits = limits;
1531
+ this._context = context;
1532
+ this._debug = debug;
1533
+ this._roomContext = roomContext;
1534
+ this._router = router;
1535
+ this._secret = secret;
1536
+ this.treaty = treaty;
1537
+ this._platform = platform.initialize({
1538
+ ...!!_meta ? { _meta } : {},
1539
+ roomContext
1540
+ });
1541
+ this._storage = new RoomStorage({
1542
+ docFactory: treaty.storage ?? noop.doc(),
1543
+ getContext: () => this._getContext(),
1544
+ getInitialStorage,
1545
+ platform: this._platform,
1546
+ room: this.id
1547
+ });
1548
+ this._edits = onStorageEdits ? new StorageEdits({
1549
+ getContext: () => this._getContext(),
1550
+ intervalMs: STORAGE_EDITS_INTERVAL_MS,
1551
+ keepAlive: (promise) => {
1552
+ this._platform.keepAlive(promise);
1553
+ },
1554
+ maxBytes: this.limits.storageMaxSize ?? 0,
1555
+ onEdits: (event) => onStorageEdits(event),
1556
+ room: this.id
1557
+ }) : null;
1558
+ this._snapshot = new StorageSnapshot({
1559
+ getContext: () => this._getContext(),
1560
+ getDoc: () => this._doc,
1561
+ keepAlive: (promise) => {
1562
+ this._platform.keepAlive(promise);
1563
+ },
1564
+ limits: this.limits,
1565
+ onStored: (event) => {
1566
+ this._listeners.onStorageSnapshot(event);
1567
+ },
1568
+ room: this.id,
1569
+ setStorageState: (encodedState) => this._platform.persistence.setStorageState(this.id, encodedState)
1570
+ });
1571
+ this._listeners = {
1572
+ onRoomDestroyed: (event) => onRoomDestroyed?.(event),
1573
+ onStorageDestroyed: (event) => onStorageDestroyed?.(event),
1574
+ onStorageSnapshot: (event) => {
1575
+ this._platform.keepAlive(Promise.resolve(onStorageSnapshot?.(event)).catch((error) => {
1576
+ console.error(error);
1577
+ }));
1578
+ },
1579
+ onMessage: (event) => {
1580
+ this._platform.keepAlive(Promise.resolve(onMessage?.(event)).catch((error) => {
1581
+ console.error(error);
1582
+ }));
1583
+ },
1584
+ onUserConnected: (event) => onUserConnected?.(event),
1585
+ onUserDisconnected: (event) => onUserDisconnected?.(event)
1586
+ };
1587
+ this["~"] = {
1588
+ applyPresencePatch: this._applyPresencePatch.bind(this),
1589
+ applyStorageProcedure: this._applyStorageProcedure.bind(this),
1590
+ commitLoadedStorage: this._commitLoadedStorage.bind(this),
1591
+ close: this._close.bind(this),
1592
+ destroy: this._destroyRoom.bind(this),
1593
+ flushSnapshot: async () => {
1594
+ const status = await this._snapshot.flush();
1595
+ return {
1596
+ broadcast: this._snapshot.pendingUpdates().map((update) => ({
1597
+ connectionId: SERVER_STORAGE_CONNECTION_ID,
1598
+ data: { state: update },
1599
+ operator: null,
1600
+ type: "$storageUpdated",
1601
+ user: null
1602
+ })),
1603
+ status
1604
+ };
1605
+ },
1606
+ initializeStorage: this._initializeStorage.bind(this),
1607
+ logDebug: this._logDebug.bind(this),
1608
+ message: this._message.bind(this),
1609
+ register: this._register.bind(this),
1610
+ dropStorageUpdates: this._dropStorageUpdates.bind(this),
1611
+ queueStorageFrames: this._queueStorageFrames.bind(this),
1612
+ storageDirty: () => this._snapshot.dirty || this._snapshot.hasPending,
1613
+ userConnected: this._userConnected.bind(this),
1614
+ whenReady: () => this._initialized
1615
+ };
1616
+ this._initialize();
1617
+ }
1618
+ /**
1619
+ * @internal Opens the stored document and applies a treaty presence procedure.
1620
+ */
1621
+ async _applyPresencePatch(params) {
1622
+ const { name, data, session } = params;
1623
+ const procedures = this.treaty._defs.procedures.presence;
1624
+ if (!procedures || !Object.hasOwn(procedures, name)) throw new Error(`Unknown presence procedure "${name}"`);
1625
+ const procedure = procedures[name];
1626
+ const doc = await this._doc;
1627
+ const applied = procedure.apply(data, {
1628
+ user: session.user,
1629
+ operator: session.operator,
1630
+ presence: session.presence,
1631
+ doc
1632
+ });
1633
+ if (isThenable(applied)) return deferredResult(Promise.resolve(applied));
1634
+ return applied;
1635
+ }
1636
+ async _applyStorageProcedure(params) {
1637
+ if (!await this._initialized || this._snapshot.closed) return this._releaseStorage(emptyRoomEffects(), params.actor);
1638
+ const generation = this._snapshot.generation;
1639
+ const { name, data, actor } = params;
1640
+ const procedures = this.treaty._defs.procedures.storage;
1641
+ if (!procedures || !Object.hasOwn(procedures, name)) throw new Error(`Unknown storage procedure "${name}"`);
1642
+ const procedure = procedures[name];
1643
+ if (procedure.prepared && procedure.load) {
1644
+ const loaded = procedure.load(data, {
1645
+ user: actor.user,
1646
+ operator: actor.operator,
1647
+ presence: actor.presence
1648
+ }).then((result) => ({
1649
+ actor,
1650
+ data: result.input,
1651
+ generation,
1652
+ name,
1653
+ prepared: result.prepared
1654
+ }));
1655
+ this._platform.keepAlive(loaded.then(() => void 0, () => void 0));
1656
+ return deferredResult(loaded);
1657
+ }
1658
+ return this._commitLoadedStorage({
1659
+ actor,
1660
+ data,
1661
+ generation,
1662
+ name,
1663
+ prepared: void 0
1664
+ });
1665
+ }
1666
+ async _commitLoadedStorage(loaded, releasePending = true) {
1667
+ const { actor, data, name, prepared } = loaded;
1668
+ const procedure = this.treaty._defs.procedures.storage?.[name];
1669
+ if (!procedure) throw new Error(`Unknown storage procedure "${name}"`);
1670
+ const doc = await this._doc;
1671
+ if (loaded.generation !== this._snapshot.generation) throw new Error("The room closed before this storage write");
1672
+ this._edits?.pause(true);
1673
+ let update;
1674
+ try {
1675
+ update = this._snapshot.hold(() => doc.captureUpdate(() => {
1676
+ doc.transact(() => {
1677
+ procedure.apply(data, {
1678
+ user: actor.user,
1679
+ operator: actor.operator,
1680
+ presence: actor.presence,
1681
+ doc
1682
+ }, prepared);
1683
+ }, actor.id);
1684
+ }));
1685
+ } finally {
1686
+ this._edits?.pause(false);
1687
+ }
1688
+ if (update) {
1689
+ this._edits?.reserve(doc, {
1690
+ operator: actor.operator,
1691
+ procedure: name,
1692
+ update,
1693
+ user: actor.user
1694
+ });
1695
+ this._edits?.note(doc);
1696
+ }
1697
+ this._snapshot.assertSize(doc.getEncodedState());
1698
+ if (releasePending && update) this._snapshot.queueUpdate(update);
1699
+ if (!update) return releasePending ? this._releaseStorage(emptyRoomEffects(), actor) : {
1700
+ ...emptyRoomEffects(),
1701
+ connectionId: actor.id,
1702
+ storageDirty: this._snapshot.dirty
1703
+ };
1704
+ if (releasePending) return this._releaseStorage(emptyRoomEffects(), actor);
1705
+ return {
1706
+ ...emptyRoomEffects(),
1707
+ broadcast: [effectFrame("$storageUpdated", { state: update }, actor)],
1708
+ connectionId: actor.id,
1709
+ storageDirty: true
1710
+ };
1711
+ }
1712
+ async _close(session) {
1713
+ if (!await this._initialized) return {
1714
+ ...emptyRoomEffects(),
1715
+ connectionId: session.id
1716
+ };
1717
+ await this._emitDisconnected(session);
1718
+ return this._releaseStorage({
1719
+ ...emptyRoomEffects(),
1720
+ broadcast: [effectFrame("$exit", {
1721
+ operator: session.operator,
1722
+ sessionId: session.id,
1723
+ user: session.user
1724
+ }, session)],
1725
+ closes: [session.id]
1726
+ }, session);
1727
+ }
1728
+ async _destroyRoom() {
1729
+ if (!this._uninitialize) return;
1730
+ await (await this._uninitialize).bind(this)();
1731
+ }
1732
+ get _doc() {
1733
+ if (!this._uninitialize) return this._storage.doc;
1734
+ return this._uninitialize.then(() => this._storage.doc);
1735
+ }
1736
+ async _emitDisconnected(session) {
1737
+ try {
1738
+ const [doc, context] = await Promise.all([this._doc, this._getContext()]);
1739
+ const encodedState = doc.getEncodedState();
1740
+ await Promise.resolve(this._listeners.onUserDisconnected({
1741
+ context,
1742
+ encodedState,
1743
+ kind: session.kind,
1744
+ operator: session.operator,
1745
+ room: this.id,
1746
+ user: session.user
1747
+ }));
1748
+ } catch (error) {
1749
+ console.error(error);
1750
+ }
1751
+ }
1752
+ async _getAuthorizedUser(token, options) {
1753
+ const secret = resolveIOSecret(this._secret, options);
1754
+ if (!token) return null;
1755
+ if (!secret) throw new Error("`secret` was not provided");
1756
+ const payload = await authorize({
1757
+ platform: this._platform,
1758
+ secret
1759
+ }).decode(token);
1760
+ if (!payload) {
1761
+ this._logDebug(colors.blue("Could not decode token:"));
1762
+ this._logDebug(token);
1763
+ return null;
1764
+ }
1765
+ if (payload.room !== this.id) {
1766
+ this._logDebug(colors.blue(`Token is not authorized for room ${this.id}:`));
1767
+ this._logDebug(colors.blue("Received:"), payload.room);
1768
+ this._logDebug(token);
1769
+ return null;
1770
+ }
1771
+ const user = parsePluvSchema(this.treaty.user, payload.user);
1772
+ switch (payload.kind) {
1773
+ case "operator": try {
1774
+ return {
1775
+ kind: "operator",
1776
+ operator: parseOperatorUser(payload.operator),
1777
+ user
1778
+ };
1779
+ } catch {
1780
+ this._logDebug(`${colors.blue("Operator token fails validation:")} ${token}`);
1781
+ return null;
1782
+ }
1783
+ case "user": try {
1784
+ return {
1785
+ kind: "user",
1786
+ operator: null,
1787
+ user
1788
+ };
1789
+ } catch {
1790
+ this._logDebug(`${colors.blue("Token fails validation:")} ${token}`);
1791
+ return null;
1792
+ }
1793
+ default: return assertExhaustive(payload.kind);
1794
+ }
1795
+ }
1796
+ /** User context for session procedures. `.config({ context })` runs here. */
1797
+ async _getContext() {
1798
+ const context = this._context;
1799
+ if (typeof context === "function") return await Promise.resolve(context(this._roomContext));
1800
+ return await Promise.resolve(context);
1801
+ }
1802
+ _getProcedure(message) {
1803
+ return this._router._defs.events[message.type] ?? null;
1804
+ }
1805
+ _initialize() {
1806
+ const promise = (async () => {
1807
+ this._logDebug(colors.blue(`Initializing room: ${this.id}`));
1808
+ if (!!this._uninitialize) {
1809
+ await (await this._uninitialize).bind(this)();
1810
+ this._uninitialize = null;
1811
+ }
1812
+ await this._storage.initialize();
1813
+ const doc = await this._storage.doc;
1814
+ this._snapshot.reopen();
1815
+ this._snapshot.attach(doc);
1816
+ this._edits?.reopen();
1817
+ this._edits?.attach(doc);
1818
+ const uninitialize = async () => {
1819
+ if (this._teardown) return this._teardown;
1820
+ this._teardown = (async () => {
1821
+ await this._edits?.flush();
1822
+ this._edits?.clearReservations();
1823
+ await this._snapshot.seal();
1824
+ await this._edits?.flush();
1825
+ this._snapshot.detach();
1826
+ this._edits?.detach();
1827
+ const context = await this._getContext();
1828
+ const { encodedState, refusedEmptyPersist, shouldDestroyStorage } = await this._storage.destroy();
1829
+ if (refusedEmptyPersist) this._logDebug(colors.blue(`Refusing to persist an unwritten document for room: ${this.id}`));
1830
+ await Promise.resolve(this._listeners.onRoomDestroyed({
1831
+ ..."_meta" in this._platform && !!this._platform._meta ? { _meta: this._platform._meta } : {},
1832
+ context,
1833
+ room: this.id
1834
+ }));
1835
+ if (shouldDestroyStorage) {
1836
+ await Promise.resolve(this._listeners.onStorageDestroyed({
1837
+ ..."_meta" in this._platform && !!this._platform._meta ? { _meta: this._platform._meta } : {},
1838
+ context,
1839
+ encodedState,
1840
+ room: this.id
1841
+ }));
1842
+ await this._platform.persistence.deleteStorageState(this.id);
1843
+ }
1844
+ this._uninitialize = null;
1845
+ })();
1846
+ try {
1847
+ await this._teardown;
1848
+ } finally {
1849
+ this._teardown = null;
1850
+ }
1851
+ };
1852
+ return { uninitialize };
1853
+ })();
1854
+ this._uninitialize = promise.then((result) => result.uninitialize);
1855
+ }
1856
+ get _initialized() {
1857
+ if (!this._uninitialize) return Promise.resolve(false);
1858
+ return this._uninitialize.then(() => true);
1859
+ }
1860
+ async _initializeStorage(params) {
1861
+ if (!await this._initialized) return {
1862
+ changeKind: "empty",
1863
+ state: null,
1864
+ storageDirty: false
1865
+ };
1866
+ const { input } = params;
1867
+ const doc = await this._doc;
1868
+ const context = await this._getContext();
1869
+ const update = input.update ?? null;
1870
+ const stateVector = input.stateVector ?? null;
1871
+ const stateForClient = (encodedState) => {
1872
+ if (!stateVector) return encodedState;
1873
+ return doc.encodeDiff(stateVector);
1874
+ };
1875
+ if (this._storage.storageSeeded) return {
1876
+ changeKind: "unchanged",
1877
+ state: stateVector ? doc.encodeDiff(stateVector) : doc.getEncodedState(),
1878
+ storageDirty: this._snapshot.dirty
1879
+ };
1880
+ if (update) {
1881
+ this._edits?.pause(true);
1882
+ let encodedState;
1883
+ try {
1884
+ this._edits?.reserve(doc, {
1885
+ operator: params.actor.operator,
1886
+ procedure: null,
1887
+ update,
1888
+ user: params.actor.user
1889
+ });
1890
+ encodedState = this._snapshot.hold(() => doc.applyEncodedState({ update }).getEncodedState());
1891
+ } finally {
1892
+ this._edits?.pause(false);
1893
+ }
1894
+ this._edits?.note(doc);
1895
+ const storageSize = new TextEncoder().encode(encodedState).length;
1896
+ if (!!this.limits.storageMaxSize && storageSize > this.limits.storageMaxSize) throw new Error("Storage has exceeded the size limit");
1897
+ this._storage.storageSeeded = true;
1898
+ const epoch = this._snapshot.epoch;
1899
+ if (await this._platform.persistence.setStorageState(this.id, encodedState).then(() => true).catch((error) => {
1900
+ this._logDebug(error);
1901
+ return false;
1902
+ })) {
1903
+ this._snapshot.acknowledge(epoch);
1904
+ this._listeners.onStorageSnapshot({
1905
+ context,
1906
+ encodedState,
1907
+ room: this.id
1908
+ });
1909
+ }
1910
+ return {
1911
+ changeKind: "initialized",
1912
+ state: stateForClient(encodedState),
1913
+ storageDirty: this._snapshot.dirty
1914
+ };
1915
+ }
1916
+ return {
1917
+ changeKind: "empty",
1918
+ state: stateForClient(doc.getEncodedState()),
1919
+ storageDirty: this._snapshot.dirty
1920
+ };
1921
+ }
1922
+ _logDebug(...data) {
1923
+ if (this._debug) console.log(...data);
1924
+ }
1925
+ async _message(session, event) {
1926
+ if (!await this._initialized) return this._releaseStorage(emptyRoomEffects(), session);
1927
+ const context = await this._getContext();
1928
+ this._listeners.onMessage({
1929
+ ..."_meta" in this._platform && !!this._platform._meta ? { _meta: this._platform._meta } : {},
1930
+ context,
1931
+ message: event,
1932
+ kind: session.kind,
1933
+ operator: session.operator,
1934
+ room: this.id,
1935
+ user: session.user
1936
+ });
1937
+ const procedure = this._getProcedure(event);
1938
+ if (!procedure) {
1939
+ if (event.type.startsWith("$")) return this._releaseStorage(emptyRoomEffects(), session);
1940
+ return this._releaseStorage({
1941
+ ...emptyRoomEffects(),
1942
+ broadcast: [effectFrame(event.type, event.data, session)]
1943
+ }, session);
1944
+ }
1945
+ let inputs;
1946
+ try {
1947
+ inputs = procedure.config.input ? parsePluvSchema(procedure.config.input, event.data) : event.data;
1948
+ } catch (error) {
1949
+ return this._releaseStorage({
1950
+ ...emptyRoomEffects(),
1951
+ error
1952
+ }, session);
1953
+ }
1954
+ const attachments = [];
1955
+ let presence = session.presence;
1956
+ const doc = await this._doc;
1957
+ const storageEvent = event.type === "$syncStorage" || event.type === "$updateStorage";
1958
+ if (storageEvent && this._snapshot.closed) return this._releaseStorage(emptyRoomEffects(), session);
1959
+ let releaseNotice = false;
1960
+ const wide = this._resolverContext({
1961
+ attachments,
1962
+ context,
1963
+ doc,
1964
+ getPresence: () => presence,
1965
+ session,
1966
+ setPresence: (value) => {
1967
+ presence = value;
1968
+ }
1969
+ });
1970
+ const resolverContext = storageEvent ? wide : this._userContext(wide);
1971
+ const finish = (broadcast, self) => this._releaseStorage({
1972
+ ...emptyRoomEffects(),
1973
+ attachments,
1974
+ broadcast: framesFromRecord(broadcast, session),
1975
+ self: framesFromRecord(self, session)
1976
+ }, session);
1977
+ try {
1978
+ if (this._edits && event.type === "$updateStorage") {
1979
+ const data = inputs;
1980
+ const incoming = data.update ?? null;
1981
+ if (incoming && data.origin !== "$initialized") {
1982
+ this._edits.reserve(doc, {
1983
+ operator: session.operator,
1984
+ procedure: typeof data.procedure === "string" ? data.procedure : null,
1985
+ update: incoming,
1986
+ user: session.user
1987
+ });
1988
+ this._edits.pause(true);
1989
+ releaseNotice = true;
1990
+ }
1991
+ }
1992
+ const invoked = invokeProcedureResolvers({
1993
+ broadcast: () => storageEvent ? this._snapshot.hold(() => procedure.config.broadcast?.(inputs, resolverContext)) : procedure.config.broadcast?.(inputs, resolverContext),
1994
+ self: () => procedure.config.self?.(inputs, resolverContext)
1995
+ });
1996
+ if (!invoked.ok) return this._releaseStorage({
1997
+ ...emptyRoomEffects(),
1998
+ attachments,
1999
+ error: invoked.error
2000
+ }, session);
2001
+ const { broadcast: broadcastResult, self: selfResult } = invoked;
2002
+ if (!storageEvent && (isThenable(broadcastResult) || isThenable(selfResult))) return deferredResult(Promise.all([broadcastResult, selfResult]).then(([broadcast, self]) => finish(broadcast, self)).catch((error) => this._releaseStorage({
2003
+ ...emptyRoomEffects(),
2004
+ attachments,
2005
+ error
2006
+ }, session)));
2007
+ const [broadcast, self] = await Promise.all([broadcastResult, selfResult]);
2008
+ return finish(broadcast, self);
2009
+ } catch (error) {
2010
+ return this._releaseStorage({
2011
+ ...emptyRoomEffects(),
2012
+ attachments,
2013
+ error
2014
+ }, session);
2015
+ } finally {
2016
+ if (releaseNotice) {
2017
+ this._edits?.pause(false);
2018
+ this._edits?.note(doc);
2019
+ }
2020
+ }
2021
+ }
2022
+ async _register(...options) {
2023
+ const { token: tokenOption, ...initRest } = options[0] ?? {};
2024
+ const token = tokenOption ?? null;
2025
+ const registerConfig = {
2026
+ ...this._platform.normalizeInitContext(initRest),
2027
+ token
2028
+ };
2029
+ if (this._teardown) await this._teardown;
2030
+ if (!await this._initialized) {
2031
+ this._initialize();
2032
+ await this._initialized;
2033
+ }
2034
+ const identity = await this._getAuthorizedUser(token, registerConfig);
2035
+ if (!identity) {
2036
+ this._logDebug(colors.blue("Authorization failed for connection"));
2037
+ return {
2038
+ encodedState: null,
2039
+ error: /* @__PURE__ */ new Error("Not authorized"),
2040
+ kind: null,
2041
+ operator: null,
2042
+ user: null
2043
+ };
2044
+ }
2045
+ const doc = await this._doc;
2046
+ return {
2047
+ encodedState: doc.isEmpty() ? null : doc.getEncodedState(),
2048
+ error: null,
2049
+ kind: identity.kind,
2050
+ operator: identity.operator,
2051
+ user: identity.user
2052
+ };
2053
+ }
2054
+ _resolverContext(params) {
2055
+ const { attachments, context, doc, getPresence, session, setPresence } = params;
2056
+ const storage = this._storage;
2057
+ const time = (/* @__PURE__ */ new Date()).getTime();
2058
+ return {
2059
+ connectionId: session.id,
2060
+ context,
2061
+ doc,
2062
+ kind: session.kind,
2063
+ operator: session.operator,
2064
+ platform: this._platform,
2065
+ get presence() {
2066
+ return getPresence();
2067
+ },
2068
+ persistence: this._platform.persistence,
2069
+ set presence(presence) {
2070
+ const next = presence ?? null;
2071
+ setPresence(next);
2072
+ const attachment = {
2073
+ presence: next,
2074
+ seq: session.seq.presence,
2075
+ sessionId: session.id
2076
+ };
2077
+ const index = attachments.findIndex((item) => item.sessionId === session.id);
2078
+ if (index === -1) attachments.push(attachment);
2079
+ else attachments[index] = attachment;
2080
+ },
2081
+ room: this.id,
2082
+ session: {
2083
+ id: session.id,
2084
+ kind: session.kind,
2085
+ operator: session.operator,
2086
+ presence: session.presence,
2087
+ quit: session.quit,
2088
+ room: this.id,
2089
+ seq: session.seq,
2090
+ timers: { ping: session.lastPing },
2091
+ user: session.user
2092
+ },
2093
+ sessions: [],
2094
+ get storageSeeded() {
2095
+ return storage.storageSeeded;
2096
+ },
2097
+ set storageSeeded(value) {
2098
+ storage.storageSeeded = value;
2099
+ },
2100
+ user: session.user,
2101
+ time
2102
+ };
2103
+ }
2104
+ async _dropStorageUpdates(frames) {
2105
+ const updates = [];
2106
+ for (const frame of frames) {
2107
+ if (frame.type !== "$storageUpdated") continue;
2108
+ const state = frame.data?.state;
2109
+ if (typeof state !== "string" || state.length === 0) continue;
2110
+ updates.push(state);
2111
+ }
2112
+ this._snapshot.dropUpdates(updates);
2113
+ }
2114
+ /** Keeps frames from a failed send on the pending list. Ones already queued stay once. */
2115
+ _queueStorageFrames(frames) {
2116
+ const pending = new Set(this._snapshot.pendingUpdates());
2117
+ for (const frame of frames) {
2118
+ if (frame.type !== "$storageUpdated") continue;
2119
+ const state = frame.data?.state;
2120
+ if (typeof state !== "string" || state.length === 0 || pending.has(state)) continue;
2121
+ this._snapshot.queueUpdate(state);
2122
+ pending.add(state);
2123
+ }
2124
+ }
2125
+ _releaseStorage(effects, session) {
2126
+ const updates = this._snapshot.pendingUpdates();
2127
+ const broadcast = updates.length ? [...effects.broadcast, ...updates.map((update) => effectFrame("$storageUpdated", { state: update }, session))] : effects.broadcast;
2128
+ return {
2129
+ ...effects,
2130
+ broadcast,
2131
+ connectionId: session.id,
2132
+ storageDirty: this._snapshot.dirty || broadcast.some((frame) => frame.type === "$storageUpdated")
2133
+ };
2134
+ }
2135
+ async _userConnected(params) {
2136
+ const { encodedState, id, kind, operator, user } = params;
2137
+ const context = await this._getContext();
2138
+ try {
2139
+ await Promise.resolve(this._listeners.onUserConnected({
2140
+ context,
2141
+ encodedState,
2142
+ kind,
2143
+ operator,
2144
+ room: this.id,
2145
+ user
2146
+ }));
2147
+ } catch (error) {
2148
+ console.error(error);
2149
+ }
2150
+ return this._releaseStorage(emptyRoomEffects(), {
2151
+ id,
2152
+ operator,
2153
+ user
2154
+ });
2155
+ }
2156
+ _userContext(event) {
2157
+ return {
2158
+ connectionId: event.connectionId,
2159
+ context: event.context,
2160
+ doc: event.doc,
2161
+ kind: event.kind,
2162
+ operator: event.operator,
2163
+ get presence() {
2164
+ return event.presence;
2165
+ },
2166
+ set presence(value) {
2167
+ event.presence = value;
2168
+ },
2169
+ room: event.room,
2170
+ time: event.time,
2171
+ user: event.user
2172
+ };
2173
+ }
2174
+ };
2175
+ // src/version.generated.ts
2176
+ const version = "0.0.0-canary-20261027014949";
2177
+ // src/PluvServer.ts
2178
+ var PluvServer = class {
2179
+ version = version;
2180
+ _config;
2181
+ _docFactory;
2182
+ _listeners;
2183
+ _onGetOperator;
2184
+ _onStorageEdits;
2185
+ /**
2186
+ * @description The `createIO()` instance this server was built from.
2187
+ */
2188
+ get io() {
2189
+ return this._config.io;
2190
+ }
2191
+ /**
2192
+ * @ignore
2193
+ * @readonly
2194
+ * @deprecated Internal use only. Changes to this will never be marked as breaking.
2195
+ */
2196
+ get _defs() {
2197
+ return {
2198
+ context: this._config.context,
2199
+ events: this._router._defs.events,
2200
+ platform: this._config.platform(),
2201
+ treaty: this._config.treaty
2202
+ };
2203
+ }
2204
+ /**
2205
+ * `_router` fills `_defs` with the session slice, the storage slice, and the
2206
+ * user's router. `createRoom` receives `_docRouter`: storage events plus the
2207
+ * user's router. `manageSessions` dispatches session events.
2208
+ */
2209
+ get _baseRouter() {
2210
+ return createBaseRouter({
2211
+ limits: this._config.limits,
2212
+ logDebug: (...data) => this._logDebug(...data),
2213
+ onStorageSnapshot: (event) => this._listeners.onStorageSnapshot(event)
2214
+ });
2215
+ }
2216
+ get _router() {
2217
+ return this._config.router ? PluvRouter.merge(this._baseRouter, this._config.router) : this._baseRouter;
2218
+ }
2219
+ get _docRouter() {
2220
+ const storage = createStorageRouter({
2221
+ limits: this._config.limits,
2222
+ logDebug: (...data) => this._logDebug(...data),
2223
+ onStorageSnapshot: (event) => this._listeners.onStorageSnapshot(event)
2224
+ });
2225
+ return this._config.router ? PluvRouter.merge(storage, this._config.router) : storage;
2226
+ }
2227
+ constructor(options) {
2228
+ this._config = {
2229
+ debug: false,
2230
+ router: new PluvRouter({}),
2231
+ ...options
2232
+ };
2233
+ const { onGetOperator } = options;
2234
+ const { onRoomDestroyed, onRoomMessage, onStorageDestroyed, onStorageEdits, onStorageSnapshot, onUserConnected, onUserDisconnected } = options;
2235
+ this._onGetOperator = onGetOperator ?? null;
2236
+ this._onStorageEdits = onStorageEdits ?? null;
2237
+ this._docFactory = this._config.treaty.storage ?? noop.doc();
2238
+ this._listeners = {
2239
+ onRoomDestroyed: (event) => onRoomDestroyed?.(event),
2240
+ onRoomMessage: (event) => onRoomMessage?.(event),
2241
+ onStorageDestroyed: (event) => onStorageDestroyed?.(event),
2242
+ onStorageEdits: (event) => onStorageEdits?.(event),
2243
+ onStorageSnapshot: (event) => onStorageSnapshot?.(event),
2244
+ onUserConnected: (event) => onUserConnected?.(event),
2245
+ onUserDisconnected: (event) => onUserDisconnected?.(event)
2246
+ };
2247
+ }
2248
+ createRoom(room, ...options) {
2249
+ const { _meta, debug, ...platformRoomContext } = options[0] ?? {};
2250
+ const platform = this._config.platform();
2251
+ platform.validateConfig(this._config);
2252
+ if (!/^[a-z0-9](?:[a-z0-9-_]*[a-z0-9])?$/i.test(room)) throw new Error("Unsupported room name");
2253
+ const roomContext = platformRoomContext;
2254
+ const listeners = this._listeners;
2255
+ const logDebug = this._logDebug.bind(this);
2256
+ const newRoom = new IORoom(room, {
2257
+ ...!!_meta ? { _meta } : {},
2258
+ context: this._config.context,
2259
+ debug: debug ?? this._config.debug,
2260
+ getInitialStorage: this._getInitialStorage,
2261
+ limits: this._config.limits,
2262
+ async onRoomDestroyed(event) {
2263
+ logDebug(`${colors.blue("Deleting empty room:")} ${room}`);
2264
+ await Promise.resolve(listeners.onRoomDestroyed(event));
2265
+ logDebug(`${colors.blue("Deleted room:")} ${room}`);
2266
+ },
2267
+ async onStorageDestroyed(event) {
2268
+ logDebug(`${colors.blue("Destroying storage for room:")} ${room}`);
2269
+ await Promise.resolve(listeners.onStorageDestroyed(event));
2270
+ },
2271
+ onStorageSnapshot(event) {
2272
+ return listeners.onStorageSnapshot(event);
2273
+ },
2274
+ onStorageEdits: this._onStorageEdits ? (event) => listeners.onStorageEdits(event) : void 0,
2275
+ async onMessage(event) {
2276
+ await Promise.resolve(listeners.onRoomMessage(event));
2277
+ },
2278
+ async onUserConnected(event) {
2279
+ await Promise.resolve(listeners.onUserConnected(event));
2280
+ },
2281
+ async onUserDisconnected(event) {
2282
+ await Promise.resolve(listeners.onUserDisconnected(event));
2283
+ },
2284
+ secret: this._config.secret,
2285
+ treaty: this._config.treaty,
2286
+ platform,
2287
+ roomContext,
2288
+ router: this._docRouter
2289
+ });
2290
+ this._logDebug(`${colors.blue("Created room:")} ${room}`);
2291
+ return newRoom;
2292
+ }
2293
+ async createToken(params) {
2294
+ return await this._config.io.createToken(params, { onGetOperator: this._onGetOperator });
2295
+ }
2296
+ _getInitialStorage = (...args) => {
2297
+ const getInitialStorage = this._config.getInitialStorage;
2298
+ if (typeof getInitialStorage !== "function") return null;
2299
+ return getInitialStorage(...args);
2300
+ };
2301
+ _logDebug(...data) {
2302
+ if (this._config.debug) console.log(...data);
2303
+ }
2304
+ };
2305
+ // src/PluvIO.ts
2306
+ var PluvIO = class {
2307
+ version = version;
2308
+ _context = {};
2309
+ _debug;
2310
+ _limits;
2311
+ _platform;
2312
+ _secret;
2313
+ _treaty;
2314
+ get procedure() {
2315
+ return new PluvProcedure();
2316
+ }
2317
+ constructor(options) {
2318
+ const { context, debug = false, limits, platform, secret, treaty } = options;
2319
+ this._debug = debug;
2320
+ this._limits = {
2321
+ dangerouslyAllowHighPresenceFanout: false,
2322
+ maxConnections: 256,
2323
+ presenceMaxSize: 512,
2324
+ storageMaxSize: MAX_STORAGE_SIZE_BYTES,
2325
+ userIdMaxLength: 128,
2326
+ userMaxSize: 512,
2327
+ ...limits
2328
+ };
2329
+ this._platform = platform;
2330
+ this._secret = secret;
2331
+ this._treaty = treaty;
2332
+ if (context) this._context = context;
2333
+ assertPresenceFanoutBudget(this._limits);
2334
+ }
2335
+ async createToken(params, options) {
2336
+ const platform = this._platform();
2337
+ const token = params;
2338
+ const { maxAge: _maxAge, room: _room, ...rest } = token;
2339
+ const initRest = isOperatorToken(token) ? (() => {
2340
+ const { operator: _operator, kind: _kind, ...init } = rest;
2341
+ return init;
2342
+ })() : (() => {
2343
+ const { user: _user, kind: _kind, ...init } = rest;
2344
+ return init;
2345
+ })();
2346
+ const initContext = platform.normalizeInitContext(initRest);
2347
+ const encodeParams = await this._resolveEncodeParams({
2348
+ initContext,
2349
+ onGetOperator: options?.onGetOperator ?? null,
2350
+ token: params
2351
+ });
2352
+ const secret = resolveIOSecret(this._secret, encodeParams);
2353
+ const ioAuthorize = {
2354
+ user: this._treaty.user,
2355
+ secret
2356
+ };
2357
+ if (platform._createToken) return await platform._createToken({
2358
+ ...encodeParams,
2359
+ authorize: ioAuthorize
2360
+ });
2361
+ if (!secret) throw new Error("`secret` was not provided");
2362
+ return await authorize({
2363
+ platform,
2364
+ secret
2365
+ }).encode(encodeParams);
2366
+ }
2367
+ async _resolveEncodeParams(params) {
2368
+ const { initContext, onGetOperator, token } = params;
2369
+ switch (token.kind) {
2370
+ case "operator": {
2371
+ if (!onGetOperator) throw new Error("`onGetOperator` must be set on `io.server()`; mint operator tokens with `ioServer.createToken`");
2372
+ const operator = parseOperatorUser(token.operator);
2373
+ if (typeof token.operator.email !== "string" || token.operator.email.length === 0) throw new Error("Invalid operator email");
2374
+ this._assertIdLength(operator.id, "operator");
2375
+ const derived = await onGetOperator({
2376
+ operator: {
2377
+ ...operator,
2378
+ email: token.operator.email
2379
+ },
2380
+ room: token.room
2381
+ });
2382
+ if (!derived) throw new Error("`onGetOperator` returned null");
2383
+ const parsed = parsePluvSchema(this._treaty.user, derived);
2384
+ this._assertUserLimits({
2385
+ operator,
2386
+ user: parsed
2387
+ });
2388
+ return {
2389
+ ...token,
2390
+ ...initContext,
2391
+ kind: "operator",
2392
+ operator,
2393
+ user: parsed
2394
+ };
2395
+ }
2396
+ case "user":
2397
+ case void 0: {
2398
+ const parsed = parsePluvSchema(this._treaty.user, token.user);
2399
+ this._assertUserLimits({ user: parsed });
2400
+ return {
2401
+ ...token,
2402
+ ...initContext,
2403
+ kind: "user",
2404
+ user: parsed
2405
+ };
2406
+ }
2407
+ default: return assertExhaustive(token);
2408
+ }
2409
+ }
2410
+ _assertIdLength(id, label) {
2411
+ if (!this._limits.userIdMaxLength || id.length <= this._limits.userIdMaxLength) return;
2412
+ throw new Error(oneLine`
2413
+ createToken was called with a long ${label} id. ID must be at
2414
+ most ${this._limits.userIdMaxLength.toLocaleString()} characters.
2415
+ Current length: ${id.length.toLocaleString()}
2416
+ `);
2417
+ }
2418
+ _assertUserLimits(params) {
2419
+ const { operator, user } = params;
2420
+ this._assertIdLength(user.id, "user");
2421
+ const bytes = new TextEncoder().encode(JSON.stringify(user)).length + (operator ? new TextEncoder().encode(JSON.stringify(operator)).length : 0);
2422
+ if (!this._limits.userMaxSize || bytes <= this._limits.userMaxSize) return;
2423
+ throw new Error(oneLine`
2424
+ createToken called with large payload. ${operator ? "User and operator together must" : "User must"} be at most
2425
+ ${this._limits.userMaxSize.toLocaleString()} bytes. Current size:
2426
+ ${bytes.toLocaleString()} bytes
2427
+ `);
2428
+ }
2429
+ mergeRouters(...routers) {
2430
+ return PluvRouter.merge(...routers);
2431
+ }
2432
+ router(events) {
2433
+ return new PluvRouter(events);
2434
+ }
2435
+ server(...config) {
2436
+ return new PluvServer({
2437
+ ...config[0] ?? {},
2438
+ context: this._context,
2439
+ debug: this._debug,
2440
+ io: this,
2441
+ limits: this._limits,
2442
+ platform: this._platform,
2443
+ secret: this._secret,
2444
+ treaty: this._treaty
2445
+ });
2446
+ }
2447
+ };
2448
+ // src/createIO.ts
2449
+ const createIO = () => ({ platform: (platform) => ({ config: (params) => {
2450
+ const { context, debug, limits, secret, treaty } = params;
2451
+ return new PluvIO({
2452
+ context,
2453
+ debug,
2454
+ limits,
2455
+ platform,
2456
+ secret,
2457
+ treaty
2458
+ });
2459
+ } }) });
2460
+ // src/internal.ts
2461
+ /**
2462
+ * Maintainer / test-only surface. Not part of the public API — shape and
2463
+ * contents may change without a semver bump. Prefer public exports for apps.
2464
+ */
2465
+ const __internal = {
2466
+ assertPresenceFanoutBudget,
2467
+ createBaseRouter,
2468
+ createInternalPluvRouter,
2469
+ getMyConnectionIds,
2470
+ getRoomStatsFromSessions,
2471
+ groupLiveUsers,
2472
+ isServerOriginEvent,
2473
+ listLiveUsers,
2474
+ pageLiveUsers,
2475
+ resolveListUsersLimit,
2476
+ resolveMaxConnections,
2477
+ ROOM_STATS_THROTTLE_MS: 150,
2478
+ SERVER_ORIGIN_EVENTS,
2479
+ throttle
2480
+ };
2481
+ // src/ExclusiveUntil.ts
2482
+ /**
2483
+ * Runs tasks one at a time while `known` is false.
2484
+ * Once `known` returns true, tasks run immediately.
2485
+ * A task already running here cannot call `run`; that waits for itself.
2486
+ */
2487
+ var ExclusiveUntil = class {
2488
+ known;
2489
+ _tail = Promise.resolve();
2490
+ constructor(known) {
2491
+ this.known = known;
2492
+ }
2493
+ async run(task) {
2494
+ if (this.known()) return task();
2495
+ const previous = this._tail;
2496
+ let release;
2497
+ this._tail = new Promise((resolve) => {
2498
+ release = resolve;
2499
+ });
2500
+ await previous;
2501
+ if (this.known()) {
2502
+ release();
2503
+ return task();
2504
+ }
2505
+ try {
2506
+ return await task();
2507
+ } finally {
2508
+ release();
2509
+ }
2510
+ }
2511
+ };
2512
+ // src/JoinQueue.ts
2513
+ /**
2514
+ * Runs async tasks one at a time.
2515
+ * A task already running here cannot wait on `join`; that waits for itself.
2516
+ * `finish` is how a task waits for a promise and then comes back.
2517
+ */
2518
+ var JoinQueue = class {
2519
+ _tail = Promise.resolve();
2520
+ /**
2521
+ * Runs `task` on this queue. `{ value }` is passed to `finish` before the
2522
+ * task releases. `{ pending }` is awaited after it releases, and `finish`
2523
+ * runs on the next join. `{ suspended: true }` releases the queue and does
2524
+ * not run `finish`.
2525
+ */
2526
+ async finish(task, finish) {
2527
+ const deferred = { pending: null };
2528
+ await this.join(async () => {
2529
+ const result = await task();
2530
+ if ("suspended" in result) return;
2531
+ if ("pending" in result) {
2532
+ deferred.pending = result.pending;
2533
+ return;
2534
+ }
2535
+ await finish(result.value);
2536
+ });
2537
+ if (!deferred.pending) return;
2538
+ const value = await deferred.pending;
2539
+ await this.join(() => finish(value));
2540
+ }
2541
+ join(task) {
2542
+ const run = this._tail.then(task, task);
2543
+ this._tail = run.then(() => void 0, () => void 0);
2544
+ return run;
2545
+ }
2546
+ };
2547
+ // src/RoomSessions.ts
2548
+ var RoomSessions = class {
2549
+ _platform;
2550
+ _participantSessions = {
2551
+ operator: /* @__PURE__ */ new Map(),
2552
+ user: /* @__PURE__ */ new Map()
2553
+ };
2554
+ _sessions = /* @__PURE__ */ new Map();
2555
+ constructor(config) {
2556
+ this._platform = config.platform;
2557
+ }
2558
+ all() {
2559
+ return this._sessions;
2560
+ }
2561
+ addUserSession(userId, sessionId) {
2562
+ return this.addParticipantSession({
2563
+ id: userId,
2564
+ kind: "user",
2565
+ sessionId
2566
+ });
2567
+ }
2568
+ addParticipantSession(params) {
2569
+ const { id, kind, sessionId } = params;
2570
+ const sessions = this._sessionsForKind(kind);
2571
+ const updated = (sessions.get(id) ?? /* @__PURE__ */ new Set()).add(sessionId);
2572
+ sessions.set(id, updated);
2573
+ return updated;
2574
+ }
2575
+ delete(sessionId) {
2576
+ return this._sessions.delete(sessionId);
2577
+ }
2578
+ deleteConnection(sessionId) {
2579
+ const webSocket = this._sessions.get(sessionId) ?? null;
2580
+ if (!webSocket) return null;
2581
+ webSocket.state = {
2582
+ ...webSocket.state,
2583
+ quit: true
2584
+ };
2585
+ this._sessions.delete(sessionId);
2586
+ return webSocket;
2587
+ }
2588
+ get(sessionId) {
2589
+ return this._sessions.get(sessionId);
2590
+ }
2591
+ getLatestPresence(id, kind = "user") {
2592
+ const sessionIds = Array.from(this._sessionsForKind(kind).get(id)?.values() ?? []);
2593
+ if (!sessionIds.length) return {
2594
+ presence: null,
2595
+ seq: null
2596
+ };
2597
+ return sessionIds.reduce((state, sessionId) => {
2598
+ const pluvWs = this._sessions.get(sessionId) ?? null;
2599
+ if (!pluvWs) return state;
2600
+ const session = pluvWs.session;
2601
+ const presence = session.presence;
2602
+ const seq = session.seq.presence;
2603
+ const sessionKind = getSessionKind(session);
2604
+ const participantId = session.user?.id;
2605
+ if (sessionKind !== kind || participantId !== id) return state;
2606
+ if (typeof state.seq !== "number") return {
2607
+ presence,
2608
+ seq
2609
+ };
2610
+ if (typeof seq !== "number") return state;
2611
+ return seq > state.seq ? {
2612
+ presence,
2613
+ seq
2614
+ } : state;
2615
+ }, {
2616
+ presence: null,
2617
+ seq: null
2618
+ });
2619
+ }
2620
+ getQuitters() {
2621
+ const currentTime = (/* @__PURE__ */ new Date()).getTime();
2622
+ return Array.from(this._sessions.values()).filter((pluvWs) => !this._isLive(pluvWs, currentTime));
2623
+ }
2624
+ getSession(webSocket) {
2625
+ const pluvWs = this.resolve(webSocket);
2626
+ if (!pluvWs) throw new Error("Session could not be found");
2627
+ return this.toSession(pluvWs);
2628
+ }
2629
+ getSessions() {
2630
+ return Array.from(this._sessions.values()).map((pluvWs) => this.toSession(pluvWs));
2631
+ }
2632
+ getLiveSessions(currentTime = Date.now()) {
2633
+ return Array.from(this._sessions.values()).filter((pluvWs) => this._isLive(pluvWs, currentTime)).map((pluvWs) => this.toSession(pluvWs));
2634
+ }
2635
+ getSize() {
2636
+ const currentTime = (/* @__PURE__ */ new Date()).getTime();
2637
+ /**
2638
+ * @description Doing this instead of .size because some sessions
2639
+ * in the map can be considered as "omitted".
2640
+ * @date December 21, 2022
2641
+ */
2642
+ return Array.from(this._sessions.values()).reduce((count, pluvWs) => {
2643
+ return this._isLive(pluvWs, currentTime) ? count + 1 : count;
2644
+ }, 0);
2645
+ }
2646
+ has(sessionId) {
2647
+ return this._sessions.has(sessionId);
2648
+ }
2649
+ removeUserSession(userId, sessionId) {
2650
+ return this.removeParticipantSession({
2651
+ id: userId,
2652
+ kind: "user",
2653
+ sessionId
2654
+ });
2655
+ }
2656
+ removeParticipantSession(params) {
2657
+ const { id, kind, sessionId } = params;
2658
+ const sessions = this._sessionsForKind(kind);
2659
+ const set = sessions.get(id);
2660
+ if (!set) return null;
2661
+ set.delete(sessionId);
2662
+ if (!!set.size) return set;
2663
+ sessions.delete(id);
2664
+ return set;
2665
+ }
2666
+ resolve(webSocket) {
2667
+ if (webSocket instanceof AbstractWebSocket) return webSocket;
2668
+ const sessionId = this._platform.getSessionId(webSocket);
2669
+ if (typeof sessionId === "string") {
2670
+ const session = this._sessions.get(sessionId) ?? null;
2671
+ if (session) return session;
2672
+ }
2673
+ return Array.from(this._sessions.values()).find((pluvWs) => pluvWs.webSocket === webSocket) ?? null;
2674
+ }
2675
+ set(sessionId, webSocket) {
2676
+ this._sessions.set(sessionId, webSocket);
2677
+ return this;
2678
+ }
2679
+ setPresence(params) {
2680
+ const { presence, seq: requested, sessionId } = params;
2681
+ const pluvWs = this._sessions.get(sessionId) ?? null;
2682
+ if (!pluvWs) return;
2683
+ if (requested !== void 0 && (pluvWs.session.seq.presence ?? null) !== requested) return;
2684
+ const wsSession = pluvWs.session;
2685
+ const kind = getSessionKind(wsSession);
2686
+ const participantId = wsSession.user?.id;
2687
+ const sessionIds = participantId ? /* @__PURE__ */ new Set([...this._sessionsForKind(kind).get(participantId) ?? [], sessionId]) : /* @__PURE__ */ new Set([sessionId]);
2688
+ const currentMax = Array.from(sessionIds).reduce((max, sId) => {
2689
+ const seq = this._sessions.get(sId)?.session.seq.presence ?? null;
2690
+ if (typeof seq !== "number") return max;
2691
+ if (typeof max !== "number") return seq;
2692
+ return seq > max ? seq : max;
2693
+ }, null);
2694
+ const next = typeof currentMax === "number" ? currentMax + 1 : 1;
2695
+ sessionIds.forEach((sId) => {
2696
+ const session = this._sessions.get(sId)?.session;
2697
+ if (!session) return;
2698
+ const prevState = session.webSocket.state;
2699
+ this._platform.setSerializedState(session.webSocket, {
2700
+ ...prevState,
2701
+ presence,
2702
+ seq: {
2703
+ ...prevState.seq,
2704
+ presence: next
2705
+ }
2706
+ });
2707
+ });
2708
+ }
2709
+ toSession(pluvWs) {
2710
+ return pluvWs.session;
2711
+ }
2712
+ values() {
2713
+ return this._sessions.values();
2714
+ }
2715
+ _sessionsForKind(kind) {
2716
+ switch (kind) {
2717
+ case "operator": return this._participantSessions.operator;
2718
+ case "user": return this._participantSessions.user;
2719
+ default: return assertExhaustive(kind);
2720
+ }
2721
+ }
2722
+ _isLive(pluvWs, currentTime) {
2723
+ if (pluvWs.state.quit) return false;
2724
+ return currentTime - (this._platform.getLastPing(pluvWs) ?? pluvWs.state.timers.ping) <= PING_TIMEOUT_MS;
2725
+ }
2726
+ };
2727
+ // src/RoomSessionManager/createHostTreatyProcedureProxy.ts
2728
+ const PROMISE_TRAP_KEYS = /* @__PURE__ */ new Set([
2729
+ "then",
2730
+ "catch",
2731
+ "finally"
2732
+ ]);
2733
+ const createHostTreatyProcedureProxy = (invoke) => {
2734
+ return new Proxy(invoke, { get(target, prop, receiver) {
2735
+ if (typeof prop !== "string" || PROMISE_TRAP_KEYS.has(prop)) return Reflect.get(target, prop, receiver);
2736
+ return (data) => target(prop, data);
2737
+ } });
2738
+ };
2739
+ // src/RoomSessionManager/readTreatySchema.ts
2740
+ const readTreatySchema = (treaty) => {
2741
+ const procedures = treaty.toProceduresJson();
2742
+ return {
2743
+ user: treaty.toUserJson(),
2744
+ presence: treaty.presence ? {
2745
+ schema: treaty.toPresenceJson(),
2746
+ procedures: procedures.presence
2747
+ } : null,
2748
+ storage: treaty.storage ? {
2749
+ schema: treaty.toStorageJson(),
2750
+ procedures: procedures.storage
2751
+ } : null
2752
+ };
2753
+ };
2754
+ // src/RoomSessionManager/resolveHostTreatyProcedureSenderId.ts
2755
+ const resolveHostTreatyProcedureSenderId = (actor) => {
2756
+ const senderId = actor != null && typeof actor === "object" ? actor.senderId : void 0;
2757
+ if (typeof senderId !== "string") throw new Error("Host treaty invoke expected { senderId: string }");
2758
+ return senderId;
2759
+ };
2760
+ // src/RoomSessionManager/RoomSessionManager.ts
2761
+ const isLoadedStorage = (value) => !("broadcast" in value);
2762
+ /**
2763
+ * Session manager for one room. `createRoom` returns the room.
2764
+ * `manageSessions` attaches the sockets.
2765
+ */
2766
+ var RoomSessionManager = class {
2767
+ _room;
2768
+ broadcast;
2769
+ __experimental_presence;
2770
+ __experimental_storage;
2771
+ _platform;
2772
+ _sessions;
2773
+ _sessionRouter;
2774
+ _roomStats;
2775
+ _queue = new JoinQueue();
2776
+ _limits = new ExclusiveUntil(() => this._room.limits !== null);
2777
+ _closing = 0;
2778
+ _connecting = 0;
2779
+ _registering = 0;
2780
+ _storageSnapshotRun = null;
2781
+ _commitGate = new CommitGate();
2782
+ _retry = null;
2783
+ _lastGarbageCollectMs = -60001;
2784
+ /** Procedure frames whose delivery failed. This isolate outlives the facet. */
2785
+ _unsent = [];
2786
+ _unsentId = 0;
2787
+ _treatySchema;
2788
+ constructor(_room, platform) {
2789
+ this._room = _room;
2790
+ this._platform = platform;
2791
+ this._sessions = new RoomSessions({ platform: this._platform });
2792
+ this._sessionRouter = createSessionRouter({ limits: () => this._room.limits });
2793
+ this._roomStats = throttle(() => {
2794
+ this._emitRoomStats();
2795
+ }, { wait: 150 });
2796
+ this.broadcast = this._createBroadcast();
2797
+ this.__experimental_presence = this._createPresence();
2798
+ this.__experimental_storage = this._createStorage();
2799
+ this._hydrate();
2800
+ this._platform.pubSub.subscribe(this._room.id, async (payload) => {
2801
+ await this._onPublished(payload);
2802
+ });
2803
+ }
2804
+ get id() {
2805
+ return this._room.id;
2806
+ }
2807
+ getTreatySchema() {
2808
+ this._treatySchema ??= readTreatySchema(this._room.treaty);
2809
+ return structuredClone(this._treatySchema);
2810
+ }
2811
+ async register(webSocket, ...options) {
2812
+ const sessionId = this._platform.getSessionId(webSocket);
2813
+ if (typeof sessionId === "string" && this._sessions.has(sessionId)) return;
2814
+ await this._limits.run(async () => {
2815
+ const limits = this._room.limits;
2816
+ const maxConnections = limits ? resolveMaxConnections(limits) : null;
2817
+ const rejectMaxConnections = () => {
2818
+ const rejected = this._platform.convertWebSocket(webSocket, { room: this._room.id });
2819
+ const limit = maxConnections ?? 0;
2820
+ rejected.handleError({
2821
+ error: /* @__PURE__ */ new Error(`Room is at maxConnections (${limit.toLocaleString()}).`),
2822
+ room: this._room.id
2823
+ });
2824
+ rejected.close(4e3, "Room is at maxConnections");
2825
+ };
2826
+ if (maxConnections !== null && this._sessions.getSize() + this._registering >= maxConnections) {
2827
+ rejectMaxConnections();
2828
+ return;
2829
+ }
2830
+ this._registering += 1;
2831
+ let reserved = true;
2832
+ const releaseReservation = () => {
2833
+ if (!reserved) return;
2834
+ reserved = false;
2835
+ this._registering -= 1;
2836
+ };
2837
+ await this._queue.finish(async () => {
2838
+ try {
2839
+ const result = await this._room["~"].register(...options);
2840
+ if (result.error || !result.user || !result.kind) {
2841
+ const rejected = this._platform.convertWebSocket(webSocket, { room: this._room.id });
2842
+ rejected.handleError({
2843
+ error: result.error ?? /* @__PURE__ */ new Error("Not authorized"),
2844
+ room: this._room.id
2845
+ });
2846
+ rejected.close(3e3, "WebSocket unauthorized.");
2847
+ return { value: null };
2848
+ }
2849
+ const pluvWs = this._platform.convertWebSocket(webSocket, { room: this._room.id });
2850
+ const participantId = result.user.id;
2851
+ const latest = this._sessions.getLatestPresence(participantId, result.kind);
2852
+ const prevState = pluvWs.state;
2853
+ pluvWs.user = result.user;
2854
+ this._platform.setSerializedState(pluvWs, {
2855
+ ...prevState,
2856
+ kind: result.kind,
2857
+ operator: result.operator,
2858
+ presence: latest.presence,
2859
+ seq: {
2860
+ ...prevState.seq,
2861
+ presence: latest.seq
2862
+ }
2863
+ });
2864
+ this._sessions.addParticipantSession({
2865
+ id: participantId,
2866
+ kind: result.kind,
2867
+ sessionId: pluvWs.sessionId
2868
+ });
2869
+ this._room["~"].logDebug(`${colors.blue(`Registering connection for room ${this._room.id}:`)} ${pluvWs.sessionId}`);
2870
+ await this._platform.acceptWebSocket(pluvWs);
2871
+ this._sessions.set(pluvWs.sessionId, pluvWs);
2872
+ releaseReservation();
2873
+ await this._platform.persistence.addUser(this._room.id, pluvWs.sessionId, result.user);
2874
+ const registered = this._sessions.toSession(pluvWs);
2875
+ const stats = getRoomStatsFromSessions(this._sessions.getLiveSessions());
2876
+ await this._writeFrames([{
2877
+ connectionId: registered.id,
2878
+ data: {
2879
+ session: {
2880
+ kind: result.kind,
2881
+ operator: result.operator,
2882
+ presence: registered.presence,
2883
+ seq: { presence: registered.seq.presence }
2884
+ },
2885
+ sessionId: registered.id,
2886
+ state: result.encodedState,
2887
+ stats
2888
+ },
2889
+ operator: result.operator,
2890
+ type: "$registered",
2891
+ user: result.user
2892
+ }], "self");
2893
+ const { encodedState, kind, operator, user } = result;
2894
+ if (!kind || !user) return { value: null };
2895
+ this._connecting += 1;
2896
+ return { pending: this._room["~"].userConnected({
2897
+ encodedState,
2898
+ id: pluvWs.sessionId,
2899
+ kind,
2900
+ operator,
2901
+ user
2902
+ }).then((effects) => ({
2903
+ effects,
2904
+ socket: pluvWs
2905
+ }), (error) => {
2906
+ console.error(error);
2907
+ return {
2908
+ effects: null,
2909
+ socket: pluvWs
2910
+ };
2911
+ }) };
2912
+ } catch (error) {
2913
+ releaseReservation();
2914
+ throw error;
2915
+ }
2916
+ }, async (connected) => {
2917
+ try {
2918
+ if (!connected?.effects) return;
2919
+ await this._applyEffects(connected.effects, connected.socket);
2920
+ this._roomStats.schedule();
2921
+ this._room["~"].logDebug(oneLine`
2922
+ ${colors.blue(`Registered connection for room ${this._room.id}:`)}
2923
+ ${connected.socket.sessionId}
2924
+ `);
2925
+ this._room["~"].logDebug(`${colors.blue(`Room ${this._room.id} size:`)} ${this._sessions.getSize()}`);
2926
+ } finally {
2927
+ releaseReservation();
2928
+ if (connected) {
2929
+ this._connecting -= 1;
2930
+ await this._destroyEmptyRoom();
2931
+ }
2932
+ }
2933
+ });
2934
+ });
2935
+ }
2936
+ applyFrames(effects, senderId) {
2937
+ return this._queue.join(async () => {
2938
+ await this._applyEffects(effects, this._socket(senderId));
2939
+ });
2940
+ }
2941
+ /** Writes sockets only. The facet drops storage after `deliverFrames` returns. */
2942
+ writeFrames(effects, senderId) {
2943
+ return this._queue.join(async () => {
2944
+ await this._applyEffects(effects, this._socket(senderId), false);
2945
+ });
2946
+ }
2947
+ applyPresence(params) {
2948
+ const socket = this._sessions.get(params.senderId) ?? null;
2949
+ if (!socket) return Promise.resolve();
2950
+ return this._queue.join(async () => {
2951
+ const context = this._sessionContext(socket);
2952
+ const broadcastResult = await this._sessionRouter._defs.events.$updatePresence.config.broadcast?.({
2953
+ presence: params.patch,
2954
+ procedure: params.name
2955
+ }, context);
2956
+ await this._deliverRecord(broadcastResult, socket, "broadcast");
2957
+ });
2958
+ }
2959
+ onMessage(webSocket) {
2960
+ if (!this._sessions.resolve(webSocket)) return async () => void 0;
2961
+ return this._onSocketMessage(webSocket);
2962
+ }
2963
+ onClose(webSocket) {
2964
+ return async (event) => {
2965
+ const socket = this._sessions.resolve(webSocket);
2966
+ if (!socket) return;
2967
+ if (!this._sessions.has(socket.sessionId)) return;
2968
+ await this._disconnect(socket, event.code, event.reason, false);
2969
+ };
2970
+ }
2971
+ onError(webSocket) {
2972
+ return async () => {
2973
+ const socket = this._sessions.resolve(webSocket);
2974
+ if (!socket) return;
2975
+ if (!this._sessions.has(socket.sessionId)) return;
2976
+ await this._disconnect(socket, 1011, "", false);
2977
+ };
2978
+ }
2979
+ async garbageCollect() {
2980
+ this._lastGarbageCollectMs = Date.now();
2981
+ const quitting = await this._queue.join(async () => Array.from(this._sessions.values()).filter((socket) => !this._isLiveSocket(socket)));
2982
+ if (!quitting.length) {
2983
+ if (this._sessions.getSize() === 0) this._roomStats.cancel();
2984
+ else this._roomStats.schedule();
2985
+ return;
2986
+ }
2987
+ await this._unregisterSockets({
2988
+ code: 1e3,
2989
+ reason: "Ping timed out.",
2990
+ sockets: quitting,
2991
+ closeSocket: true,
2992
+ sync: true
2993
+ });
2994
+ }
2995
+ async evict(sessionId) {
2996
+ const socket = this._sessions.get(sessionId);
2997
+ if (!socket) return;
2998
+ socket.state.quit = true;
2999
+ await this._evictSockets({
3000
+ code: 1e3,
3001
+ reason: "",
3002
+ sockets: [socket]
3003
+ });
3004
+ }
3005
+ async evictAll() {
3006
+ const sockets = Array.from(this._sessions.values());
3007
+ sockets.forEach((socket) => {
3008
+ socket.state.quit = true;
3009
+ });
3010
+ await this._evictSockets({
3011
+ code: 1e3,
3012
+ reason: "",
3013
+ sockets
3014
+ });
3015
+ }
3016
+ getSize() {
3017
+ return this._sessions.getSize();
3018
+ }
3019
+ noteStorageDirty() {
3020
+ if (this._storageSnapshotRun) return;
3021
+ const run = this._snapshotUntilClean();
3022
+ this._storageSnapshotRun = run;
3023
+ this._platform.keepAlive(run);
3024
+ run.finally(() => {
3025
+ if (this._storageSnapshotRun === run) this._storageSnapshotRun = null;
3026
+ });
3027
+ }
3028
+ retryUnsent(effects) {
3029
+ this._unsentId += 1;
3030
+ this._unsent.push({
3031
+ effects,
3032
+ id: this._unsentId
3033
+ });
3034
+ if (this._retry) return;
3035
+ const run = this._deliverUnsent();
3036
+ this._retry = run;
3037
+ this._platform.keepAlive(run);
3038
+ run.finally(() => {
3039
+ if (this._retry === run) this._retry = null;
3040
+ });
3041
+ }
3042
+ listUsers(options = {}) {
3043
+ return pageLiveUsers(this._sessions.getLiveSessions(), options);
3044
+ }
3045
+ _socket(senderId) {
3046
+ return this._sessions.get(senderId) ?? null;
3047
+ }
3048
+ async _applyEffects(effects, sender, dropStorage = true) {
3049
+ this._noteStorage(effects);
3050
+ for (const attachment of effects.attachments) this._sessions.setPresence(attachment);
3051
+ if (effects.error) {
3052
+ const storageFrames = effects.broadcast.filter((frame) => frame.type === "$storageUpdated");
3053
+ if (storageFrames.length) {
3054
+ await this._writeFrames(storageFrames, "broadcast");
3055
+ if (dropStorage) await this._room["~"].dropStorageUpdates(storageFrames);
3056
+ }
3057
+ sender?.handleError({
3058
+ error: effects.error,
3059
+ room: this._room.id,
3060
+ ...sender ? { session: this._sessions.toSession(sender) } : {}
3061
+ });
3062
+ return;
3063
+ }
3064
+ await this._writeFrames(effects.broadcast, "broadcast");
3065
+ if (dropStorage) {
3066
+ const storageFrames = effects.broadcast.filter((frame) => frame.type === "$storageUpdated");
3067
+ if (storageFrames.length) await this._room["~"].dropStorageUpdates(storageFrames);
3068
+ }
3069
+ await this._writeFrames(effects.self, "self");
3070
+ }
3071
+ async _collectPong() {
3072
+ if (Date.now() - this._lastGarbageCollectMs > 6e4) await this.garbageCollect();
3073
+ }
3074
+ _createBroadcast() {
3075
+ const send = (event, data, senderId) => {
3076
+ if (typeof senderId !== "string") throw new Error("room.broadcast of a user event requires a senderId");
3077
+ const socket = this._sessions.get(senderId) ?? null;
3078
+ const session = socket ? this._sessions.toSession(socket) : null;
3079
+ return this._publish({
3080
+ connectionId: senderId,
3081
+ data,
3082
+ operator: session ? getSessionOperator(session) : null,
3083
+ type: event,
3084
+ user: session?.user ?? null
3085
+ });
3086
+ };
3087
+ return new Proxy(send, { get(fn, prop) {
3088
+ return (data, senderId) => fn(prop, data, senderId);
3089
+ } });
3090
+ }
3091
+ _createPresence() {
3092
+ return (actor) => {
3093
+ const senderId = resolveHostTreatyProcedureSenderId(actor);
3094
+ const invoke = async (name, data) => {
3095
+ const socket = this._sessions.get(senderId) ?? null;
3096
+ if (!socket) throw new Error(`Unknown senderId: ${senderId}`);
3097
+ await this._queue.finish(async () => {
3098
+ const patch = await this._room["~"].applyPresencePatch({
3099
+ name,
3100
+ data,
3101
+ session: this._toSnapshot(socket)
3102
+ });
3103
+ if (isDeferredResult(patch)) return { pending: deferredResultPromise(patch) };
3104
+ if (isSuspended(patch)) {
3105
+ if ("error" in patch && isErrorMessage(patch.error)) socket.handleError({
3106
+ error: patch.error,
3107
+ room: this._room.id,
3108
+ session: this._sessions.toSession(socket)
3109
+ });
3110
+ return { suspended: true };
3111
+ }
3112
+ return { value: patch };
3113
+ }, async (patch) => {
3114
+ const context = this._sessionContext(socket);
3115
+ const broadcastResult = await this._sessionRouter._defs.events.$updatePresence.config.broadcast?.({
3116
+ presence: patch,
3117
+ procedure: name
3118
+ }, context);
3119
+ await this._deliverRecord(broadcastResult, socket, "broadcast");
3120
+ });
3121
+ };
3122
+ return createHostTreatyProcedureProxy(invoke);
3123
+ };
3124
+ }
3125
+ _createStorage() {
3126
+ return (actor) => {
3127
+ const senderId = resolveHostTreatyProcedureSenderId(actor);
3128
+ const invoke = async (name, data) => {
3129
+ const socket = this._sessions.get(senderId) ?? null;
3130
+ if (!socket) throw new Error(`Unknown senderId: ${senderId}`);
3131
+ let finishCommit = () => void 0;
3132
+ await this._queue.finish(async () => {
3133
+ try {
3134
+ const effects = await this._room["~"].applyStorageProcedure({
3135
+ name,
3136
+ data,
3137
+ actor: this._toSnapshot(socket)
3138
+ });
3139
+ if (!isDeferredResult(effects)) return { value: effects };
3140
+ const loaded = deferredResultPromise(effects);
3141
+ const turn = this._commitGate.reserve();
3142
+ finishCommit = turn.finish;
3143
+ return { pending: turn.previous.then(() => loaded, () => loaded).then((value) => value, (error) => {
3144
+ turn.finish();
3145
+ throw error;
3146
+ }) };
3147
+ } catch (error) {
3148
+ finishCommit();
3149
+ await this._retainStorage();
3150
+ throw error;
3151
+ }
3152
+ }, async (loaded) => {
3153
+ try {
3154
+ const effects = isLoadedStorage(loaded) ? await this._room["~"].commitLoadedStorage(loaded) : loaded;
3155
+ this._noteStorage(effects);
3156
+ await this._writeFrames(effects.broadcast, "broadcast");
3157
+ await this._room["~"].dropStorageUpdates(effects.broadcast);
3158
+ } catch (error) {
3159
+ await this._retainStorage();
3160
+ throw error;
3161
+ } finally {
3162
+ finishCommit();
3163
+ }
3164
+ });
3165
+ };
3166
+ return createHostTreatyProcedureProxy(invoke);
3167
+ };
3168
+ }
3169
+ async _deliverRecord(record, socket, mode) {
3170
+ if (!record) return;
3171
+ const session = this._sessions.toSession(socket);
3172
+ const frames = Object.entries(record).map(([type, data]) => ({
3173
+ connectionId: session.id,
3174
+ data,
3175
+ operator: getSessionOperator(session),
3176
+ type,
3177
+ user: session.user
3178
+ }));
3179
+ await this._writeFrames(frames, mode);
3180
+ }
3181
+ async _destroyEmptyRoom() {
3182
+ if (this._sessions.all().size !== 0) return;
3183
+ if (this._connecting !== 0 || this._registering !== 0) {
3184
+ this._queue.join(() => this._destroyIdleRoom());
3185
+ return;
3186
+ }
3187
+ await this._destroyIdleRoom();
3188
+ }
3189
+ async _destroyIdleRoom() {
3190
+ if (this._sessions.all().size !== 0) return;
3191
+ if (this._connecting !== 0 || this._registering !== 0) return;
3192
+ if (this._closing !== 0) return;
3193
+ try {
3194
+ await this._room["~"].destroy();
3195
+ } catch (error) {
3196
+ console.error(error);
3197
+ }
3198
+ }
3199
+ async _disconnect(socket, code, reason, closeSocket) {
3200
+ await this._unregisterSockets({
3201
+ code,
3202
+ reason,
3203
+ sockets: [socket],
3204
+ closeSocket,
3205
+ sync: false
3206
+ });
3207
+ }
3208
+ async _emitRoomStats() {
3209
+ const stats = getRoomStatsFromSessions(this._sessions.getLiveSessions());
3210
+ await this._publish({
3211
+ connectionId: null,
3212
+ data: stats,
3213
+ operator: null,
3214
+ type: "$roomStats",
3215
+ user: null
3216
+ });
3217
+ }
3218
+ async _emitSyncState() {
3219
+ const connectionIds = await this._platform.persistence.getUsers(this._room.id).then((map) => Object.keys(map));
3220
+ await this._publish({
3221
+ connectionId: null,
3222
+ data: { connectionIds },
3223
+ operator: null,
3224
+ type: "$syncStateReceived",
3225
+ user: null
3226
+ });
3227
+ }
3228
+ async _evictSockets(params) {
3229
+ const { code, reason, sockets } = params;
3230
+ const closing = sockets.filter((socket) => this._sessions.has(socket.sessionId));
3231
+ if (!closing.length) return;
3232
+ await this._unregisterSockets({
3233
+ code,
3234
+ reason,
3235
+ sockets: closing,
3236
+ closeSocket: true,
3237
+ sync: false
3238
+ });
3239
+ }
3240
+ _hydrate() {
3241
+ this._platform.getWebSockets().forEach((webSocket) => {
3242
+ const deserialized = this._platform.getSerializedState(webSocket);
3243
+ const sessionId = this._platform.getSessionId(webSocket);
3244
+ if (!deserialized) return;
3245
+ if (typeof sessionId !== "string") return;
3246
+ const pluvWs = this._platform.convertWebSocket(webSocket, { room: this._room.id });
3247
+ if (!pluvWs.user) return;
3248
+ this._sessions.set(sessionId, pluvWs);
3249
+ const session = pluvWs.session;
3250
+ this._sessions.addParticipantSession({
3251
+ id: session.user.id,
3252
+ kind: getSessionKind(session),
3253
+ sessionId
3254
+ });
3255
+ });
3256
+ }
3257
+ _isLiveSocket(socket, now = Date.now()) {
3258
+ if (socket.state.quit) return false;
3259
+ return now - (this._platform.getLastPing(socket) ?? socket.state.timers.ping) <= PING_TIMEOUT_MS;
3260
+ }
3261
+ _noteStorage(effects) {
3262
+ if (!effects.storageDirty) return;
3263
+ this.noteStorageDirty();
3264
+ }
3265
+ /** A failed send leaves the queued update for the snapshot loop. */
3266
+ async _retainStorage() {
3267
+ if (await this._room["~"].storageDirty()) this.noteStorageDirty();
3268
+ }
3269
+ async _onPublished(payload) {
3270
+ const { options = {}, ...message } = payload;
3271
+ const frame = {
3272
+ connectionId: message.connectionId,
3273
+ data: message.data,
3274
+ operator: message.operator,
3275
+ type: message.type,
3276
+ user: message.user
3277
+ };
3278
+ switch (options.type) {
3279
+ case "self":
3280
+ await this._writeFrames([frame], "self");
3281
+ return;
3282
+ case "broadcast": {
3283
+ const targets = options.sessionIds ? options.sessionIds.map((sessionId) => this._sessions.get(sessionId) ?? null).filter((socket) => !!socket) : Array.from(this._sessions.all().values());
3284
+ await Promise.allSettled(targets.map(async (socket) => {
3285
+ await Promise.resolve(socket.sendMessage({
3286
+ ...frame,
3287
+ room: this._room.id
3288
+ }));
3289
+ }));
3290
+ return;
3291
+ }
3292
+ case void 0: {
3293
+ const targets = Array.from(this._sessions.all().values());
3294
+ await Promise.allSettled(targets.map(async (socket) => {
3295
+ await Promise.resolve(socket.sendMessage({
3296
+ ...frame,
3297
+ room: this._room.id
3298
+ }));
3299
+ }));
3300
+ return;
3301
+ }
3302
+ default: assertExhaustive(options);
3303
+ }
3304
+ }
3305
+ _onSocketMessage(webSocket) {
3306
+ return async (event) => {
3307
+ const socket = this._sessions.resolve(webSocket);
3308
+ if (!socket) throw new Error("Could not get session");
3309
+ if (socket.state.quit) {
3310
+ await this._disconnect(socket, 1011, "WebSocket broken.", true);
3311
+ return;
3312
+ }
3313
+ const message = this._parseMessage(event);
3314
+ if (!message) return;
3315
+ const sessionEvent = this._sessionRouter.eventNames.has(message.type);
3316
+ if (!sessionEvent && !await this._room["~"].whenReady()) return;
3317
+ if (sessionEvent) {
3318
+ if (message.type === "$initializeSession") {
3319
+ await this._openSession(socket, message);
3320
+ return;
3321
+ }
3322
+ await this._runSessionProcedure(socket, message);
3323
+ return;
3324
+ }
3325
+ await this._queue.finish(async () => {
3326
+ const effects = await this._room["~"].message(this._toSnapshot(socket), message);
3327
+ if (isDeferredResult(effects)) return { pending: deferredResultPromise(effects) };
3328
+ if (isSuspended(effects)) {
3329
+ if ("error" in effects && isErrorMessage(effects.error)) socket.handleError({
3330
+ error: new Error(effects.error.message),
3331
+ room: this._room.id,
3332
+ session: this._sessions.toSession(socket)
3333
+ });
3334
+ return { suspended: true };
3335
+ }
3336
+ return { value: effects };
3337
+ }, (effects) => this._applyEffects(effects, socket));
3338
+ };
3339
+ }
3340
+ async _openSession(socket, message) {
3341
+ const procedure = this._sessionRouter._defs.events.$initializeSession;
3342
+ const session = this._sessions.toSession(socket);
3343
+ try {
3344
+ const inputs = this._readInputs(procedure, message);
3345
+ const context = this._sessionContext(socket);
3346
+ const broadcast = await procedure.config.broadcast?.(inputs, context);
3347
+ await this._queue.join(async () => {
3348
+ const { storageDirty, ...received } = await this._room["~"].initializeStorage({
3349
+ input: inputs ?? {},
3350
+ actor: this._toSnapshot(socket)
3351
+ });
3352
+ if (storageDirty) this.noteStorageDirty();
3353
+ await this._deliverRecord(broadcast, socket, "broadcast");
3354
+ await this._deliverRecord({ $storageReceived: received }, socket, "self");
3355
+ });
3356
+ } catch (error) {
3357
+ socket.handleError({
3358
+ error,
3359
+ room: this._room.id,
3360
+ session
3361
+ });
3362
+ }
3363
+ }
3364
+ _parseMessage(message) {
3365
+ try {
3366
+ const parsed = this._platform.parseData(message.data);
3367
+ if (typeof parsed !== "object") return null;
3368
+ if (typeof parsed.type !== "string") return null;
3369
+ if (typeof parsed.data !== "object") return null;
3370
+ return parsed;
3371
+ } catch {
3372
+ return null;
3373
+ }
3374
+ }
3375
+ async _publish(message) {
3376
+ if (typeof message.connectionId !== "string" && !isServerOriginEvent(message.type)) return;
3377
+ await this._platform.pubSub.publish(this._room.id, {
3378
+ ...message,
3379
+ room: this._room.id
3380
+ });
3381
+ }
3382
+ _readInputs(procedure, message) {
3383
+ if (!procedure?.config.input) return message.data;
3384
+ return parsePluvSchema(procedure.config.input, message.data);
3385
+ }
3386
+ async _removeSocket(socket) {
3387
+ const session = this._sessions.toSession(socket);
3388
+ const kind = getSessionKind(session);
3389
+ const participantId = session.user?.id;
3390
+ this._sessions.deleteConnection(socket.sessionId);
3391
+ if (participantId) this._sessions.removeParticipantSession({
3392
+ id: participantId,
3393
+ kind,
3394
+ sessionId: socket.sessionId
3395
+ });
3396
+ await this._platform.persistence.deleteUser(this._room.id, socket.sessionId).catch(() => null);
3397
+ }
3398
+ async _runSessionProcedure(socket, message) {
3399
+ const procedure = this._sessionRouter._defs.events[message.type];
3400
+ if (!procedure) return;
3401
+ const session = this._sessions.toSession(socket);
3402
+ let inputs;
3403
+ try {
3404
+ inputs = this._readInputs(procedure, message);
3405
+ } catch (error) {
3406
+ socket.handleError({
3407
+ error,
3408
+ room: this._room.id,
3409
+ session
3410
+ });
3411
+ return;
3412
+ }
3413
+ try {
3414
+ const context = this._sessionContext(socket);
3415
+ const invoked = invokeProcedureResolvers({
3416
+ broadcast: () => procedure.config.broadcast?.(inputs, context),
3417
+ self: () => procedure.config.self?.(inputs, context)
3418
+ });
3419
+ if (!invoked.ok) {
3420
+ socket.handleError({
3421
+ error: invoked.error,
3422
+ room: this._room.id,
3423
+ session
3424
+ });
3425
+ return;
3426
+ }
3427
+ const [broadcast, self] = await Promise.all([invoked.broadcast, invoked.self]);
3428
+ await this._deliverRecord(broadcast, socket, "broadcast");
3429
+ await this._deliverRecord(self, socket, "self");
3430
+ } catch (error) {
3431
+ socket.handleError({
3432
+ error,
3433
+ room: this._room.id,
3434
+ session
3435
+ });
3436
+ }
3437
+ }
3438
+ /** Sends procedure frames that `deliverFrames` did not. A failed send stays in this promise and retries after the snapshot interval. */
3439
+ async _deliverUnsent() {
3440
+ while (this._unsent.length) try {
3441
+ await this._queue.join(async () => {
3442
+ const pending = this._unsent.slice();
3443
+ for (const item of pending) {
3444
+ const sender = item.effects.connectionId ? this._sessions.get(item.effects.connectionId) ?? null : null;
3445
+ await this._applyEffects(item.effects, sender);
3446
+ this._unsent = this._unsent.filter((held) => held.id !== item.id);
3447
+ }
3448
+ });
3449
+ } catch (error) {
3450
+ console.error(error);
3451
+ await new Promise((resolve) => {
3452
+ setTimeout(resolve, STORAGE_SNAPSHOT_INTERVAL_MS);
3453
+ });
3454
+ }
3455
+ }
3456
+ /** One promise. Wait, ask the room for a snapshot, and repeat until that snapshot is current. */
3457
+ async _snapshotUntilClean() {
3458
+ for (;;) {
3459
+ await new Promise((resolve) => {
3460
+ setTimeout(resolve, STORAGE_SNAPSHOT_INTERVAL_MS);
3461
+ });
3462
+ try {
3463
+ const status = await this._queue.join(async () => {
3464
+ const flushed = await this._room["~"].flushSnapshot();
3465
+ if (flushed.broadcast.length) {
3466
+ await this._writeFrames(flushed.broadcast, "broadcast");
3467
+ await this._room["~"].dropStorageUpdates(flushed.broadcast);
3468
+ }
3469
+ return flushed.status;
3470
+ });
3471
+ if (status === "closed") return;
3472
+ if (status === "dirty" || await this._room["~"].storageDirty()) continue;
3473
+ } catch (error) {
3474
+ console.error(error);
3475
+ if (await this._room["~"].storageDirty()) continue;
3476
+ return;
3477
+ }
3478
+ await Promise.resolve();
3479
+ if (await this._room["~"].storageDirty()) continue;
3480
+ return;
3481
+ }
3482
+ }
3483
+ _sessionContext(socket) {
3484
+ const sessions = this._sessions;
3485
+ const session = sessions.toSession(socket);
3486
+ const time = (/* @__PURE__ */ new Date()).getTime();
3487
+ return {
3488
+ connectionId: session.id,
3489
+ kind: getSessionKind(session),
3490
+ operator: getSessionOperator(session),
3491
+ platform: this._platform,
3492
+ get presence() {
3493
+ return session.webSocket.state.presence ?? session.presence ?? null;
3494
+ },
3495
+ set presence(presence) {
3496
+ sessions.setPresence({
3497
+ presence: presence ?? null,
3498
+ sessionId: session.id
3499
+ });
3500
+ },
3501
+ room: this._room.id,
3502
+ session,
3503
+ sessions: this._sessions.getLiveSessions(),
3504
+ time,
3505
+ user: session.user
3506
+ };
3507
+ }
3508
+ _toSnapshot(socket) {
3509
+ const session = this._sessions.toSession(socket);
3510
+ return {
3511
+ id: session.id,
3512
+ kind: getSessionKind(session),
3513
+ lastPing: this._platform.getLastPing(socket) ?? socket.state.timers.ping,
3514
+ operator: getSessionOperator(session),
3515
+ presence: session.presence,
3516
+ quit: socket.state.quit,
3517
+ seq: { presence: session.seq.presence },
3518
+ user: session.user
3519
+ };
3520
+ }
3521
+ /**
3522
+ * Removes each socket on the queue, then runs `onUserDisconnected` after that
3523
+ * join so the listener can await the session manager. `$exit` is written on
3524
+ * a later join. Destroy waits until every unregister in progress has sent `$exit`.
3525
+ */
3526
+ async _unregisterSockets(params) {
3527
+ const { code, reason, sockets, closeSocket, sync } = params;
3528
+ if (!sockets.length) return;
3529
+ this._closing += 1;
3530
+ try {
3531
+ for (const socket of sockets) {
3532
+ const snapshot = await this._queue.join(async () => {
3533
+ if (!this._sessions.has(socket.sessionId)) return null;
3534
+ const next = this._toSnapshot(socket);
3535
+ this._room["~"].logDebug(`${colors.blue(`Unregistering connection for room ${this._room.id}:`)} ${next.id}`);
3536
+ await this._removeSocket(socket);
3537
+ return next;
3538
+ });
3539
+ if (!snapshot) continue;
3540
+ let effects = null;
3541
+ try {
3542
+ effects = await this._room["~"].close(snapshot);
3543
+ } finally {
3544
+ if (closeSocket) socket.close(code, reason);
3545
+ }
3546
+ if (!effects) continue;
3547
+ const produced = effects;
3548
+ this._noteStorage(produced);
3549
+ await this._queue.join(async () => {
3550
+ await this._writeFrames(produced.broadcast, "broadcast");
3551
+ await this._room["~"].dropStorageUpdates(produced.broadcast);
3552
+ this._room["~"].logDebug(`${colors.blue(`Unregistered connection for room ${this._room.id}:`)} ${snapshot.id}`);
3553
+ });
3554
+ }
3555
+ } finally {
3556
+ this._closing -= 1;
3557
+ await this._queue.join(async () => {
3558
+ if (sync) await this._emitSyncState();
3559
+ await this._destroyEmptyRoom();
3560
+ if (this._sessions.getSize() === 0) this._roomStats.cancel();
3561
+ else this._roomStats.schedule();
3562
+ });
3563
+ }
3564
+ }
3565
+ async _writeFrames(frames, mode) {
3566
+ if (mode === "self") {
3567
+ await Promise.all(frames.map(async (frame) => {
3568
+ const socket = frame.connectionId ? this._sessions.get(frame.connectionId) ?? null : null;
3569
+ if (!socket) return;
3570
+ await Promise.resolve(socket.sendMessage({
3571
+ ...frame,
3572
+ room: this._room.id
3573
+ }));
3574
+ if (frame.type === "$pong") await this._collectPong();
3575
+ }));
3576
+ return;
3577
+ }
3578
+ await Promise.allSettled(Array.from(this._sessions.all().values()).flatMap((socket) => frames.map((frame) => Promise.resolve(socket.sendMessage({
3579
+ ...frame,
3580
+ room: this._room.id
3581
+ })))));
3582
+ }
3583
+ };
3584
+ // src/manageSessions.ts
3585
+ function manageSessions(room, platform) {
3586
+ const full = room;
3587
+ return new RoomSessionManager(full, platform ?? full._defs.platform);
3588
+ }
3589
+ export { AbstractPersistence, AbstractPlatform, AbstractPubSub, AbstractWebSocket, CommitGate, DEFAULT_MAX_CONNECTIONS, LIST_USERS_DEFAULT_LIMIT, LIST_USERS_MAX_LIMIT, PluvIO, PluvProcedure, PluvRouter, PluvServer, __internal, authorize, createIO, deferredResult, deferredResultPromise, isDeferredResult, isErrorMessage, isSuspended, manageSessions, parsePluvSchema };
3590
+
3591
+ //# sourceMappingURL=index.mjs.map