@pluv/io 5.0.1 → 5.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/IORoom.ts DELETED
@@ -1,1245 +0,0 @@
1
- import type { AbstractCrdtDocFactory } from "@pluv/crdt";
2
- import { noop } from "@pluv/crdt";
3
- import type {
4
- BaseIOEventRecord,
5
- BaseUser,
6
- CrdtDocLike,
7
- CrdtLibraryType,
8
- EventMessage,
9
- IOEventMessage,
10
- IOLike,
11
- Id,
12
- InferEventMessage,
13
- InferEventsOutput,
14
- InferIOAuthorize,
15
- InferIOAuthorizeUser,
16
- InferIOInput,
17
- JsonObject,
18
- Maybe,
19
- } from "@pluv/types";
20
- import colors from "kleur";
21
- import type {
22
- AbstractPlatform,
23
- InferInitContextType,
24
- InferPlatformWebSocketSource,
25
- InferRoomContextType,
26
- } from "./AbstractPlatform";
27
- import type {
28
- AbstractCloseEvent,
29
- AbstractErrorEvent,
30
- AbstractMessageEvent,
31
- } from "./AbstractWebSocket";
32
- import { AbstractWebSocket } from "./AbstractWebSocket";
33
- import type { PluvRouter, PluvRouterEventConfig } from "./PluvRouter";
34
- import { authorize } from "./authorize";
35
- import { GARBAGE_COLLECT_INTERVAL_MS, PING_TIMEOUT_MS } from "./constants";
36
- import type {
37
- EventResolverContext,
38
- EventResolverKind,
39
- GetInitialStorageFn,
40
- IORoomDestroyedEvent,
41
- IORoomListenerEvent,
42
- IORoomMessageEvent,
43
- IOUserConnectedEvent,
44
- IOUserDisconnectedEvent,
45
- PluvContext,
46
- PluvIOAuthorize,
47
- ResolvedPluvIOAuthorize,
48
- WebSocketSession,
49
- WebSocketType,
50
- } from "./types";
51
- import { oneLine } from "./utils";
52
-
53
- type BroadcastMessage<TIO extends IORoom<any, any, any, any, any>> =
54
- | InferEventMessage<InferIOInput<TIO>>
55
- | InferEventMessage<BaseIOEventRecord<InferIOAuthorize<TIO>>>;
56
-
57
- interface BroadcastParams<TIO extends IORoom<any, any, any, any, any>> {
58
- message: BroadcastMessage<TIO>;
59
- senderId?: string;
60
- }
61
-
62
- export interface IORoomListeners<
63
- TPlatform extends AbstractPlatform<any>,
64
- TAuthorize extends PluvIOAuthorize<TPlatform, any, InferInitContextType<TPlatform>> | null,
65
- TContext extends Record<string, any>,
66
- TEvents extends PluvRouterEventConfig<TPlatform, TAuthorize, TContext>,
67
- > {
68
- onRoomDestroyed: (event: IORoomDestroyedEvent<TPlatform, TContext>) => void;
69
- onStorageDestroyed: (event: IORoomListenerEvent<TPlatform, TContext>) => void;
70
- onMessage: (event: IORoomMessageEvent<TPlatform, TAuthorize, TContext, TEvents>) => void;
71
- onUserConnected: (event: IOUserConnectedEvent<TPlatform, TAuthorize, TContext>) => void;
72
- onUserDisconnected: (event: IOUserDisconnectedEvent<TPlatform, TAuthorize, TContext>) => void;
73
- }
74
-
75
- export type BroadcastProxy<TIO extends IORoom<any, any, any, any, any>> = (<
76
- TEvent extends keyof InferIOInput<TIO>,
77
- >(
78
- event: TEvent,
79
- data: Id<InferIOInput<TIO>[TEvent]>,
80
- ) => Promise<void>) & {
81
- [event in keyof InferIOInput<TIO>]: (data: Id<InferIOInput<TIO>>[event]) => Promise<void>;
82
- };
83
-
84
- export type IORoomConfig<
85
- TPlatform extends AbstractPlatform<any> = AbstractPlatform<any>,
86
- TAuthorize extends PluvIOAuthorize<TPlatform, any, InferInitContextType<TPlatform>> | null =
87
- any,
88
- TContext extends Record<string, any> = {},
89
- TEvents extends PluvRouterEventConfig<TPlatform, TAuthorize, TContext> = {},
90
- > = Partial<IORoomListeners<TPlatform, TAuthorize, TContext, TEvents>> & {
91
- authorize?: TAuthorize;
92
- context: PluvContext<TPlatform, TContext>;
93
- crdt?: { doc: (value: any) => AbstractCrdtDocFactory<any, any> };
94
- debug: boolean;
95
- getInitialStorage: GetInitialStorageFn<TContext>;
96
- platform: TPlatform;
97
- roomContext: InferRoomContextType<TPlatform>;
98
- router: PluvRouter<TPlatform, TAuthorize, TContext, TEvents>;
99
- };
100
-
101
- interface SendMessageSender {
102
- sessionId: string | null;
103
- user: JsonObject | null;
104
- }
105
-
106
- interface PatchPresenceParams {
107
- presence: JsonObject | null;
108
- sessionId: string;
109
- timer?: number | null;
110
- }
111
-
112
- export type WebSocketRegisterConfig<
113
- TPlatform extends AbstractPlatform<any> = AbstractPlatform<any>,
114
- > = {
115
- token?: string | null;
116
- } & InferInitContextType<TPlatform>;
117
-
118
- export class IORoom<
119
- TPlatform extends AbstractPlatform<any> = AbstractPlatform<any>,
120
- TAuthorize extends PluvIOAuthorize<TPlatform, any, InferInitContextType<TPlatform>> | null =
121
- null,
122
- TContext extends Record<string, any> = {},
123
- TCrdt extends CrdtLibraryType<any> = CrdtLibraryType<any>,
124
- TEvents extends PluvRouterEventConfig<TPlatform, TAuthorize, TContext> = {},
125
- > implements IOLike<TAuthorize, TCrdt, TEvents> {
126
- public readonly id: string;
127
-
128
- private _doc: Promise<CrdtDocLike<any, any>>;
129
- private _lastGarbageCollectMs: number = -1 * (GARBAGE_COLLECT_INTERVAL_MS + 1);
130
- private _uninitialize: Promise<() => Promise<void>> | null = null;
131
-
132
- private readonly _authorize: TAuthorize = null as TAuthorize;
133
- private readonly _context: PluvContext<TPlatform, TContext>;
134
- private readonly _crdt: TCrdt;
135
- private readonly _debug: boolean;
136
- private readonly _docFactory: AbstractCrdtDocFactory<any, any>;
137
- private readonly _getInitialStorage: GetInitialStorageFn<TContext>;
138
- private readonly _listeners: IORoomListeners<TPlatform, TAuthorize, TContext, TEvents>;
139
- private readonly _platform: TPlatform;
140
- private readonly _roomContext: InferRoomContextType<TPlatform>;
141
- private readonly _router: PluvRouter<TPlatform, TAuthorize, TContext, TEvents>;
142
- private readonly _sessions = new Map<
143
- [sessionId: string][0],
144
- AbstractWebSocket<any, TAuthorize>
145
- >();
146
- private readonly _userSessionss = new Map<[userId: string][0], Set<[sessionId: string][0]>>();
147
-
148
- private _wasDocEmptyOnInit: boolean = true;
149
- private _storageInitializedViaSession: boolean = false;
150
-
151
- /**
152
- * @ignore
153
- * @readonly
154
- * @deprecated Internal use only. Changes to this will never be marked as breaking.
155
- */
156
- public get _defs() {
157
- return {
158
- authorize: this._authorize,
159
- context: this._context,
160
- crdt: this._crdt,
161
- events: this._router._defs.events,
162
- platform: this._platform,
163
- } as {
164
- authorize: TAuthorize;
165
- context: TContext;
166
- crdt: TCrdt;
167
- events: TEvents;
168
- platform: TPlatform;
169
- };
170
- }
171
-
172
- public get broadcast(): BroadcastProxy<this> {
173
- const _broadcast = <TEvent extends keyof InferIOInput<this>>(
174
- event: TEvent,
175
- data: Id<InferIOInput<this>[TEvent]>,
176
- ): Promise<void> => {
177
- const message = { type: event, data } as BroadcastMessage<this>;
178
-
179
- return Promise.resolve(this._broadcast({ message }));
180
- };
181
-
182
- return new Proxy(_broadcast, {
183
- get(fn, prop) {
184
- return (data: any) => fn(prop as any, data);
185
- },
186
- }) as BroadcastProxy<this>;
187
- }
188
-
189
- private get _initialized(): Promise<boolean> {
190
- return Promise.resolve(!!this._uninitialize?.then((result) => !!result));
191
- }
192
-
193
- constructor(id: string, config: IORoomConfig<TPlatform, TAuthorize, TContext, TEvents>) {
194
- const {
195
- _meta,
196
- authorize,
197
- context,
198
- crdt = noop,
199
- debug,
200
- getInitialStorage,
201
- onRoomDestroyed,
202
- onStorageDestroyed,
203
- onMessage,
204
- onUserConnected,
205
- onUserDisconnected,
206
- platform,
207
- roomContext,
208
- router,
209
- } = config as IORoomConfig<TPlatform, TAuthorize, TContext, TEvents> & { _meta?: any };
210
-
211
- this.id = id;
212
-
213
- this._context = context;
214
- this._crdt = crdt as TCrdt;
215
- this._debug = debug;
216
- this._docFactory = crdt.doc(() => ({}));
217
- this._getInitialStorage = getInitialStorage;
218
- this._roomContext = roomContext;
219
- this._router = router;
220
- this._platform = platform.initialize({ ...(!!_meta ? { _meta } : {}), roomContext });
221
-
222
- // Listeners are provided from server-level configuration via createRoom
223
- this._listeners = {
224
- onRoomDestroyed: (event) => onRoomDestroyed?.(event),
225
- onStorageDestroyed: (event) => onStorageDestroyed?.(event),
226
- onMessage: (event) => onMessage?.(event),
227
- onUserConnected: (event) => onUserConnected?.(event),
228
- onUserDisconnected: (event) => onUserDisconnected?.(event),
229
- };
230
-
231
- if (authorize) this._authorize = authorize;
232
-
233
- /**
234
- * @description Everything below this line relates to waking up from hibernation.
235
- * @see https://developers.cloudflare.com/durable-objects/best-practices/websockets/#websocket-hibernation-api
236
- * @date April 17, 2025
237
- */
238
- const webSockets =
239
- this._platform.getWebSockets() as readonly InferPlatformWebSocketSource<TPlatform>[];
240
-
241
- webSockets.forEach((webSocket) => {
242
- const deserialized = this._platform.getSerializedState(webSocket);
243
- const sessionId = this._platform.getSessionId(webSocket);
244
-
245
- if (!deserialized) return;
246
- if (typeof sessionId !== "string") return;
247
-
248
- const pluvWs = this._platform.convertWebSocket(webSocket, { room: this.id });
249
-
250
- this._sessions.set(sessionId, pluvWs);
251
-
252
- const userId = pluvWs.session.user?.id;
253
-
254
- if (!!userId) this._addUserSession(userId, sessionId);
255
- });
256
-
257
- const { doc, uninitialize } = this._initialize();
258
-
259
- this._doc = doc;
260
- this._uninitialize = uninitialize;
261
- }
262
-
263
- /**
264
- * @description Closes a connection with the specified sessionId
265
- * @param sessionId The session id of the connection to close
266
- */
267
- public async evict(sessionId: string): Promise<void> {
268
- const session = this._sessions.get(sessionId);
269
-
270
- if (session) session.state.quit = true;
271
-
272
- await this._emitQuitters();
273
- }
274
-
275
- /**
276
- * @description Closes all connections to this room, effectively destroying
277
- * the room
278
- */
279
- public async evictAll(): Promise<void> {
280
- const sessions = Array.from(this._sessions.values());
281
-
282
- sessions.forEach((session) => {
283
- session.state.quit = true;
284
- });
285
-
286
- await this._emitQuitters();
287
- }
288
-
289
- /**
290
- * @description The IORoom will garbage collect occasionally as connections ping/pong the
291
- * server
292
- */
293
- public async garbageCollect(): Promise<void> {
294
- this._lastGarbageCollectMs = Date.now();
295
- this._emitQuitters();
296
- this._emitSyncState();
297
- }
298
-
299
- public getSize(): number {
300
- const currentTime = new Date().getTime();
301
-
302
- /**
303
- * @description Doing this instead of .size because some sessions
304
- * in the map can be considered as "omitted".
305
- * @date December 21, 2022
306
- */
307
- return Array.from(this._sessions.values()).reduce((count, pluvWs) => {
308
- if (pluvWs.state.quit) return count;
309
-
310
- const pingTime = this._platform.getLastPing(pluvWs) ?? pluvWs.state.timers.ping;
311
-
312
- return currentTime - pingTime > PING_TIMEOUT_MS ? count : count + 1;
313
- }, 0);
314
- }
315
-
316
- public onClose(
317
- webSocket: WebSocketType<TPlatform>,
318
- ): (event: AbstractCloseEvent) => Promise<void> {
319
- this._ensureDetached();
320
-
321
- const wsSession = this._getAbstractWs(webSocket);
322
-
323
- if (!wsSession) return async () => undefined;
324
-
325
- return this._onClose(wsSession);
326
- }
327
-
328
- public onError(
329
- webSocket: WebSocketType<TPlatform>,
330
- ): (event: AbstractErrorEvent) => Promise<void> {
331
- this._ensureDetached();
332
-
333
- const wsSession = this._getAbstractWs(webSocket);
334
-
335
- if (!wsSession) return async () => undefined;
336
-
337
- return this._onClose(wsSession);
338
- }
339
-
340
- public onMessage(
341
- webSocket: WebSocketType<TPlatform>,
342
- ): (event: AbstractMessageEvent) => Promise<void> {
343
- this._ensureDetached();
344
-
345
- const wsSession = this._getAbstractWs(webSocket);
346
-
347
- if (!wsSession) return async () => undefined;
348
-
349
- return this._onMessage(wsSession);
350
- }
351
-
352
- public async register(
353
- webSocket: InferPlatformWebSocketSource<TPlatform>,
354
- ...options: keyof InferInitContextType<TPlatform> extends never
355
- ? [{ token?: string }?]
356
- : [WebSocketRegisterConfig<TPlatform>]
357
- ): Promise<void> {
358
- const _options = (options[0] ?? {}) as WebSocketRegisterConfig<TPlatform>;
359
- const token = _options.token ?? null;
360
-
361
- /**
362
- * TODO
363
- * @description Update the sessionId strategy so that the userId can be used to retrieve
364
- * the session in O(1) time if authorized.
365
- * @date April 15, 2025
366
- */
367
- const sessionId = this._platform.getSessionId(webSocket);
368
- const sessionExists = typeof sessionId === "string" && this._sessions.has(sessionId);
369
-
370
- if (sessionExists) return;
371
-
372
- if (!(await this._initialized)) {
373
- this._initialize();
374
- await this._initialized;
375
- }
376
-
377
- const user: BaseUser | null = await this._getAuthorizedUser(token, _options);
378
- const ioAuthorize = this._getIOAuthorize(_options);
379
- const isUnauthorized = !!ioAuthorize && !user;
380
- const pluvWs = this._platform.convertWebSocket(webSocket, { room: this.id });
381
-
382
- if (isUnauthorized) {
383
- this._logDebug(`${colors.blue("Authorization failed for connection")}`);
384
- pluvWs.handleError({ error: new Error("Not authorized"), room: this.id });
385
- pluvWs.close(3000, "WebSocket unauthorized.");
386
-
387
- return;
388
- }
389
-
390
- if (!!user) {
391
- const latest = this._getLatestPresence(user.id);
392
- const prevState = pluvWs.state;
393
-
394
- pluvWs.user = user;
395
-
396
- this._platform.setSerializedState(pluvWs, {
397
- ...prevState,
398
- presence: latest.presence,
399
- timers: { ...prevState.timers, presence: latest.timer },
400
- });
401
- this._addUserSession(user.id, pluvWs.sessionId);
402
- }
403
-
404
- this._logDebug(
405
- `${colors.blue(`Registering connection for room ${this.id}:`)} ${pluvWs.sessionId}`,
406
- );
407
-
408
- await this._platform.acceptWebSocket(pluvWs);
409
- this._sessions.set(pluvWs.sessionId, pluvWs);
410
- await this._platform.persistence.addUser(this.id, pluvWs.sessionId, user ?? {});
411
-
412
- if (this._platform._config.registrationMode === "attached") {
413
- const onClose = this._onClose(pluvWs).bind(this);
414
- const onMessage = this._onMessage(pluvWs).bind(this);
415
-
416
- pluvWs.addEventListener("close", onClose);
417
- pluvWs.addEventListener("error", onClose);
418
- pluvWs.addEventListener("message", onMessage);
419
- }
420
-
421
- await this._emitRegistered(pluvWs);
422
-
423
- const size = this.getSize();
424
-
425
- this._logDebug(oneLine`
426
- ${colors.blue(`Registered connection for room ${this.id}:`)}
427
- ${pluvWs.sessionId}
428
- `);
429
- this._logDebug(`${colors.blue(`Room ${this.id} size:`)} ${size}`);
430
- }
431
-
432
- private _addUserSession(userId: string, sessionId: string): Set<[sessionId: string][0]> {
433
- const set = this._userSessionss.get(userId) ?? new Set<string>();
434
- const updated = set.add(sessionId);
435
-
436
- this._userSessionss.set(userId, updated);
437
-
438
- return updated;
439
- }
440
-
441
- private async _broadcast(params: BroadcastParams<this>): Promise<void> {
442
- const { message, senderId } = params;
443
-
444
- const sender = senderId ? (this._sessions.get(senderId) ?? null) : null;
445
- const session = sender?.session ?? null;
446
- const user = session?.user ?? null;
447
-
448
- if (typeof senderId !== "string") return;
449
-
450
- this._platform.pubSub.publish(this.id, {
451
- connectionId: senderId ?? null,
452
- room: this.id,
453
- user,
454
- ...message,
455
- });
456
- }
457
-
458
- private async _closeWebSockets(
459
- webSockets: readonly AbstractWebSocket<any, TAuthorize>[],
460
- ): Promise<void> {
461
- const closeWebSocket = async (
462
- webSocket: AbstractWebSocket<any, TAuthorize>,
463
- ): Promise<void> => {
464
- webSocket.state = { ...webSocket.state, quit: true };
465
-
466
- const sessionId = webSocket.sessionId;
467
-
468
- this._logDebug(
469
- `${colors.blue(`Unregistering connection for room ${this.id}:`)} ${sessionId}`,
470
- );
471
- this._sessions.delete(sessionId);
472
-
473
- await this._platform.persistence.deleteUser(this.id, sessionId).catch(() => null);
474
- await this._broadcast({
475
- message: { type: "$exit", data: { sessionId } },
476
- senderId: sessionId,
477
- });
478
-
479
- const session = webSocket.session;
480
- const user = session.user;
481
-
482
- if (!!user) this._removeUserSession(user.id, sessionId);
483
-
484
- try {
485
- const [doc, context] = await Promise.all([this._doc, this._getContext()]);
486
- const encodedState = doc.getEncodedState();
487
-
488
- await Promise.resolve(
489
- this._listeners.onUserDisconnected({
490
- context,
491
- encodedState,
492
- platform: this._platform,
493
- room: this.id,
494
- user,
495
- }),
496
- );
497
- } catch (error) {
498
- console.error(error);
499
- }
500
-
501
- this._logDebug(
502
- `${colors.blue(`Unregistered connection for room ${this.id}:`)} ${webSocket.sessionId}`,
503
- );
504
- };
505
-
506
- const promises = webSockets.map(async (webSocket) => {
507
- await closeWebSocket(webSocket).catch(() => null);
508
- });
509
-
510
- await Promise.all(promises).catch(() => null);
511
-
512
- const size = this.getSize();
513
-
514
- this._logDebug(`${colors.blue(`Room ${this.id} size:`)} ${size}`);
515
-
516
- if (!!size) return;
517
- if (!this._uninitialize) return;
518
-
519
- const uninitialize = (await this._uninitialize).bind(this);
520
- await uninitialize();
521
- }
522
-
523
- private async _emitQuitters(): Promise<void> {
524
- const currentTime = new Date().getTime();
525
- const quitters = Array.from(this._sessions.values()).filter((pluvWs) => {
526
- return pluvWs.state.quit || currentTime - pluvWs.state.timers.ping > PING_TIMEOUT_MS;
527
- });
528
-
529
- this._closeWebSockets(quitters);
530
- }
531
-
532
- private async _emitRegistered(pluvWs: AbstractWebSocket<any, TAuthorize>): Promise<void> {
533
- const session = pluvWs.session;
534
- const sessionId = session.id;
535
- const presence = session.presence;
536
- const user = session.user;
537
-
538
- const [doc, context] = await Promise.all([this._doc, this._getContext()]);
539
- const encodedState = doc.isEmpty() ? null : doc.getEncodedState();
540
-
541
- await this._sendSelfMessage(
542
- {
543
- type: "$registered",
544
- data: {
545
- presence,
546
- sessionId,
547
- state: encodedState,
548
- timers: { presence: session.timers.presence },
549
- },
550
- },
551
- { sessionId, user },
552
- );
553
-
554
- try {
555
- await Promise.resolve(
556
- this._listeners.onUserConnected({
557
- context,
558
- encodedState,
559
- platform: this._platform,
560
- room: this.id,
561
- user,
562
- webSocket: pluvWs.webSocket,
563
- }),
564
- );
565
- } catch (err) {
566
- console.error(err);
567
- }
568
- }
569
-
570
- private async _emitSyncState(): Promise<void> {
571
- const connectionIds = await this._platform.persistence
572
- .getUsers(this.id)
573
- .then((map) => Object.keys(map));
574
-
575
- await this._broadcast({
576
- message: {
577
- type: "$syncStateReceived",
578
- data: { connectionIds },
579
- },
580
- });
581
- }
582
-
583
- private _ensureDetached(): void {
584
- if (this._platform._config.registrationMode === "detached") return;
585
-
586
- throw new Error("Platform must use detached mode");
587
- }
588
-
589
- private _getAbstractWs(
590
- webSocket: WebSocketType<TPlatform>,
591
- ): AbstractWebSocket<any, TAuthorize> | null {
592
- if ((webSocket as unknown as any) instanceof AbstractWebSocket) return webSocket;
593
-
594
- const sessionId = this._platform.getSessionId(webSocket);
595
-
596
- if (typeof sessionId === "string") {
597
- const session = this._sessions.get(sessionId) ?? null;
598
-
599
- if (session) return session;
600
- }
601
-
602
- const sessions = Array.from(this._sessions.values());
603
-
604
- return sessions.find((pluvWs) => pluvWs.webSocket === webSocket) ?? null;
605
- }
606
-
607
- /**
608
- * TODO
609
- * @description Revisit any cast types
610
- * @date July 17, 2025
611
- */
612
- private async _getAuthorizedUser(
613
- token: Maybe<string>,
614
- options: WebSocketRegisterConfig<TPlatform>,
615
- ): Promise<InferIOAuthorizeUser<TAuthorize>> {
616
- const ioAuthorize = this._getIOAuthorize(options);
617
-
618
- if (!ioAuthorize) return null as any;
619
- if (!token) return null as any;
620
-
621
- if (!ioAuthorize.secret)
622
- throw new Error("`authorize` was specified without a valid secret");
623
-
624
- const payload = await authorize({
625
- platform: this._platform,
626
- secret: ioAuthorize.secret,
627
- }).decode(token);
628
-
629
- if (!payload) {
630
- this._logDebug(colors.blue("Could not decode token:"));
631
- this._logDebug(token);
632
-
633
- return null as any;
634
- }
635
-
636
- if (payload.room !== this.id) {
637
- this._logDebug(colors.blue(`Token is not authorized for room ${this.id}:`));
638
- this._logDebug(colors.blue("Received:"), payload.room);
639
- this._logDebug(token);
640
-
641
- return null as any;
642
- }
643
-
644
- try {
645
- return ioAuthorize.user.parse(payload.user) ?? null;
646
- } catch {
647
- this._logDebug(`${colors.blue("Token fails validation:")} ${token}`);
648
-
649
- return null as any;
650
- }
651
- }
652
-
653
- private async _getContext(): Promise<TContext> {
654
- return typeof this._context === "function"
655
- ? await Promise.resolve(this._context(this._roomContext))
656
- : await Promise.resolve(this._context);
657
- }
658
-
659
- private async _getInitialDoc(): Promise<CrdtDocLike<any, any>> {
660
- const doc = this._docFactory.getEmpty();
661
-
662
- const [encodedState, context] = await Promise.all([
663
- this._platform.persistence.getStorageState(this.id),
664
- this._getContext(),
665
- ]);
666
-
667
- if (!encodedState) {
668
- const loadedState = await this._getInitialStorage({
669
- context,
670
- room: this.id,
671
- });
672
-
673
- if (!!loadedState && !this._docFactory.isEmpty(loadedState)) {
674
- doc.applyEncodedState({ update: loadedState });
675
- }
676
- }
677
-
678
- if (typeof encodedState === "string") {
679
- doc.applyEncodedState({ update: encodedState });
680
-
681
- // If we loaded storage from persistence and it's non-empty, storage was previously initialized.
682
- // This handles hibernation wake-up: restore the initialization state so that onStorageDestroyed
683
- // will fire correctly when the room is destroyed.
684
- if (!doc.isEmpty()) {
685
- this._storageInitializedViaSession = true;
686
- }
687
- }
688
-
689
- return doc;
690
- }
691
-
692
- private _getIOAuthorize(
693
- options: WebSocketRegisterConfig<TPlatform>,
694
- ): ResolvedPluvIOAuthorize<any, any> | null {
695
- if (typeof this._authorize === "function") return this._authorize(options);
696
-
697
- return this._authorize as ResolvedPluvIOAuthorize<any, any> | null;
698
- }
699
-
700
- private _getLatestPresence(userId: string): {
701
- timer: number | null;
702
- presence: JsonObject | null;
703
- } {
704
- if (!this._authorize) return { timer: null, presence: null };
705
-
706
- const sessionIds = Array.from(this._userSessionss.get(userId)?.values() ?? []);
707
-
708
- if (!sessionIds.length) return { timer: null, presence: null };
709
-
710
- return sessionIds.reduce(
711
- (state, sessionId) => {
712
- const pluvWs = this._sessions.get(sessionId) ?? null;
713
-
714
- if (!pluvWs) return state;
715
-
716
- const session = pluvWs.session;
717
- const presence = session.presence;
718
- const timer = session.timers.presence;
719
-
720
- if (session.user?.id !== userId) return state;
721
- if (typeof state.timer !== "number") return { presence, timer };
722
- if (typeof timer !== "number") return state;
723
-
724
- return timer > state.timer ? { presence, timer } : state;
725
- },
726
- { presence: null, timer: null } as {
727
- presence: JsonObject | null;
728
- timer: number | null;
729
- },
730
- );
731
- }
732
-
733
- private _getProcedure(
734
- message: EventMessage<string, any>,
735
- ):
736
- | (typeof this._router)["_defs"]["events"][keyof (typeof this._router)["_defs"]["events"]]
737
- | null {
738
- return (
739
- this._router._defs.events[
740
- message.type as keyof (typeof this._router)["_defs"]["events"]
741
- ] ?? null
742
- );
743
- }
744
-
745
- private _getProcedureInputs(
746
- message: EventMessage<string, any>,
747
- ): InferIOInput<this>[keyof TEvents] {
748
- const procedure = this._getProcedure(message);
749
-
750
- if (!procedure) return message.data;
751
-
752
- return procedure.config.input ? procedure.config.input.parse(message.data) : message.data;
753
- }
754
-
755
- private _getSession(webSocket: WebSocketType<TPlatform>): WebSocketSession<TAuthorize> {
756
- const pluvWs = this._getAbstractWs(webSocket);
757
-
758
- if (!pluvWs) throw new Error("Session could not be found");
759
-
760
- return pluvWs.session;
761
- }
762
-
763
- private _getSessions() {
764
- const sessions = Array.from(this._sessions.values()).map((pluvWs) => pluvWs.session);
765
-
766
- return sessions;
767
- }
768
-
769
- private _initialize() {
770
- const promise = (async () => {
771
- this._logDebug(`${colors.blue(`Initializing room: ${this.id}`)}`);
772
-
773
- if (!!this._uninitialize) {
774
- const uninitialize = (await this._uninitialize).bind(this);
775
- await uninitialize();
776
-
777
- this._uninitialize = null;
778
- }
779
-
780
- const pubSubId = await this._platform.pubSub.subscribe(
781
- this.id,
782
- async ({ options = {}, ...message }): Promise<void> => {
783
- await promise;
784
-
785
- const sender: SendMessageSender = {
786
- sessionId: message.connectionId,
787
- user: message.user,
788
- };
789
-
790
- switch (options.type) {
791
- case "self": {
792
- await this._sendSelfMessage(message, sender);
793
- return;
794
- }
795
- case "sync": {
796
- await this._sendSyncMessage(message, sender);
797
- return;
798
- }
799
- case "broadcast":
800
- default: {
801
- const sessionIds = options.sessionIds;
802
-
803
- await this._sendBroadcastMessage(message, sender, sessionIds);
804
- }
805
- }
806
- },
807
- );
808
-
809
- const doc = await this._getInitialDoc();
810
-
811
- // Track if doc was empty when room was first initialized
812
- this._wasDocEmptyOnInit = doc.isEmpty();
813
-
814
- const uninitialize = async () => {
815
- this._platform.pubSub.unsubscribe(pubSubId);
816
-
817
- const [doc, context] = await Promise.all([this._doc, this._getContext()]);
818
- const encodedState = doc.getEncodedState();
819
-
820
- doc.destroy();
821
- this._doc = Promise.resolve(this._docFactory.getEmpty());
822
-
823
- // Always emit onRoomDestroyed
824
- await Promise.resolve(
825
- this._listeners.onRoomDestroyed({
826
- ...("_meta" in this._platform && !!this._platform._meta
827
- ? { _meta: this._platform._meta }
828
- : {}),
829
- context,
830
- platform: this._platform,
831
- room: this.id,
832
- }),
833
- );
834
-
835
- // Only emit onStorageDestroyed if storage was initialized via initializeSession
836
- if (this._storageInitializedViaSession) {
837
- await Promise.resolve(
838
- this._listeners.onStorageDestroyed({
839
- ...("_meta" in this._platform && !!this._platform._meta
840
- ? { _meta: this._platform._meta }
841
- : {}),
842
- context,
843
- encodedState,
844
- platform: this._platform,
845
- room: this.id,
846
- }),
847
- );
848
-
849
- this._storageInitializedViaSession = false;
850
- }
851
-
852
- this._uninitialize = null;
853
- };
854
-
855
- return { doc, uninitialize };
856
- })();
857
-
858
- this._doc = promise.then((result) => result.doc);
859
- this._uninitialize = promise.then((result) => result.uninitialize);
860
-
861
- return { doc: this._doc, uninitialize: this._uninitialize };
862
- }
863
-
864
- private _logDebug(...data: any[]): void {
865
- if (this._debug) console.log(...data);
866
- }
867
-
868
- private _onClose(webSocket: AbstractWebSocket<any, TAuthorize>): () => Promise<void> {
869
- return async (): Promise<void> => {
870
- if (!(await this._initialized)) return;
871
- await this._closeWebSockets([webSocket]);
872
- };
873
- }
874
-
875
- private _onMessage(
876
- webSocket: AbstractWebSocket<any, TAuthorize>,
877
- ): (event: AbstractMessageEvent) => Promise<void> {
878
- return async (event: AbstractMessageEvent): Promise<void> => {
879
- if (!(await this._initialized)) return;
880
-
881
- const pluvWs = this._getAbstractWs(webSocket as WebSocketType<TPlatform>);
882
-
883
- if (!pluvWs) throw new Error("Could not get session");
884
-
885
- const session = this._getSession(pluvWs as WebSocketType<TPlatform>);
886
- const sessions = this._getSessions();
887
-
888
- const setPresence = (params: PatchPresenceParams): void => {
889
- this._setPresence.bind(this)(params);
890
- };
891
-
892
- const [doc, context] = await Promise.all([this._doc, this._getContext()]);
893
- const eventContext: EventResolverContext<
894
- EventResolverKind,
895
- TPlatform,
896
- TAuthorize,
897
- TContext
898
- > = {
899
- context,
900
- doc,
901
- garbageCollect: async () => {
902
- await this.garbageCollect();
903
- },
904
- platform: this._platform,
905
- get presence() {
906
- return session.presence as JsonObject | null;
907
- },
908
- set presence(presence: JsonObject | null) {
909
- const sessionId = this.session?.id;
910
-
911
- if (!sessionId) return;
912
-
913
- setPresence({ presence, sessionId, timer: this.time });
914
- },
915
- room: this.id,
916
- session,
917
- sessions,
918
- time: new Date().getTime(),
919
- };
920
-
921
- if (pluvWs.state.quit) {
922
- await this._closeWebSockets([pluvWs]).catch(() => null);
923
- pluvWs.close(1011, "WebSocket broken.");
924
-
925
- return;
926
- }
927
-
928
- const message = this._parseMessage(event);
929
-
930
- if (!message) return;
931
-
932
- const procedure = this._getProcedure(message);
933
-
934
- this._listeners.onMessage({
935
- ...("_meta" in this._platform && !!this._platform._meta
936
- ? { _meta: this._platform._meta }
937
- : {}),
938
- context,
939
- encodedState: doc.getEncodedState(),
940
- message: message as InferEventMessage<
941
- InferEventsOutput<TEvents>,
942
- keyof InferEventsOutput<TEvents>
943
- >,
944
- platform: this._platform,
945
- room: this.id,
946
- user: session.user,
947
- webSocket: session.webSocket.webSocket,
948
- });
949
-
950
- const sessionId = session.id;
951
- const user = session.user;
952
-
953
- if (!procedure) {
954
- await this._broadcast({
955
- message: message as any,
956
- senderId: sessionId,
957
- });
958
-
959
- return;
960
- }
961
-
962
- let inputs: InferIOInput<this>[keyof TEvents];
963
-
964
- try {
965
- inputs = this._getProcedureInputs(message);
966
- } catch (error) {
967
- pluvWs.handleError({
968
- error,
969
- message: "Invalid input",
970
- room: this.id,
971
- session,
972
- });
973
-
974
- return;
975
- }
976
-
977
- await Promise.all([
978
- procedure.config.broadcast?.(inputs, eventContext),
979
- procedure.config.self?.(inputs, eventContext),
980
- procedure.config.sync?.(inputs, eventContext),
981
- ]).then(async ([broadcast, self, sync]) => {
982
- const handleBroadcast = async () => {
983
- if (!broadcast) return;
984
-
985
- const messages = Object.entries(broadcast).map(async ([type, data]: any) => {
986
- await this._broadcast({
987
- message: { data, type },
988
- senderId: sessionId,
989
- });
990
- });
991
-
992
- await Promise.all(messages);
993
- };
994
-
995
- const handleSelf = async () => {
996
- if (!self) return;
997
-
998
- const messages = Object.entries(self).map(async ([type, data]) => {
999
- await this._sendSelfMessage({ data, type }, { sessionId, user });
1000
- });
1001
-
1002
- await Promise.all(messages);
1003
- };
1004
-
1005
- const handleSync = async () => {
1006
- if (!sync) return;
1007
-
1008
- await this._platform.pubSub.publish(this.id, {
1009
- connectionId: session.id,
1010
- options: { type: "sync" },
1011
- room: this.id,
1012
- user,
1013
- ...message,
1014
- });
1015
-
1016
- const messages = Object.entries(sync).map(async ([type, data]) => {
1017
- await this._sendSelfMessage({ data, type }, { sessionId, user });
1018
- });
1019
-
1020
- await Promise.all(messages);
1021
- };
1022
-
1023
- await Promise.all([handleBroadcast(), handleSelf(), handleSync()]);
1024
- });
1025
-
1026
- // After processing messages that might update storage (like $initializeSession),
1027
- // check if storage was initialized via initializeSession
1028
- await this._checkStorageInitializedViaSession();
1029
- };
1030
- }
1031
-
1032
- private _parseMessage(message: {
1033
- data: string | ArrayBuffer;
1034
- }): EventMessage<string, any> | null {
1035
- try {
1036
- const parsed = this._platform.parseData(message.data);
1037
-
1038
- if (typeof parsed !== "object") return null;
1039
- if (typeof parsed.type !== "string") return null;
1040
- if (typeof parsed.data !== "object") return null;
1041
-
1042
- return parsed as EventMessage<string, any>;
1043
- } catch {
1044
- return null;
1045
- }
1046
- }
1047
-
1048
- private _setPresence(params: PatchPresenceParams): void {
1049
- const { presence, sessionId, timer: _timer } = params;
1050
-
1051
- const timer = _timer ?? new Date().getTime();
1052
- const pluvWs = this._sessions.get(sessionId) ?? null;
1053
-
1054
- if (!pluvWs) return;
1055
-
1056
- const wsSession = pluvWs.session;
1057
- const user = wsSession.user as BaseUser | null;
1058
-
1059
- const sessionIds = user
1060
- ? new Set<string>([...(this._userSessionss.get(user.id) ?? []), sessionId])
1061
- : new Set([sessionId]);
1062
-
1063
- sessionIds.forEach((sId) => {
1064
- const pWs = this._sessions.get(sId);
1065
- const session = pWs?.session;
1066
-
1067
- if (!session) return;
1068
-
1069
- const prevState = session.webSocket.state;
1070
-
1071
- this._platform.setSerializedState(session.webSocket, {
1072
- ...prevState,
1073
- presence,
1074
- timers: {
1075
- ...prevState.timers,
1076
- presence: timer,
1077
- },
1078
- });
1079
- });
1080
- }
1081
-
1082
- private _removeUserSession(
1083
- userId: string,
1084
- sessionId: string,
1085
- ): Set<[sessionId: string][0]> | null {
1086
- const set = this._userSessionss.get(userId);
1087
-
1088
- if (!set) return null;
1089
-
1090
- set.delete(sessionId);
1091
-
1092
- if (!!set.size) return set;
1093
-
1094
- this._userSessionss.delete(userId);
1095
-
1096
- return set;
1097
- }
1098
-
1099
- private async _checkStorageInitializedViaSession(): Promise<void> {
1100
- if (this._storageInitializedViaSession) return;
1101
-
1102
- // Only mark as initialized if:
1103
- // 1. Doc was empty when room was first initialized
1104
- // 2. Doc is now non-empty
1105
- // 3. At least one session has been registered
1106
- const doc = await this._doc;
1107
- if (this._wasDocEmptyOnInit && !doc.isEmpty() && this._sessions.size > 0) {
1108
- this._storageInitializedViaSession = true;
1109
- }
1110
- }
1111
-
1112
- private async _sendMessage(
1113
- pluvWs: AbstractWebSocket<any, TAuthorize>,
1114
- message: IOEventMessage<any>,
1115
- ): Promise<void> {
1116
- if (!(await this._initialized)) return;
1117
-
1118
- await Promise.resolve(pluvWs.sendMessage(message));
1119
- }
1120
-
1121
- private async _sendBroadcastMessage(
1122
- message: EventMessage<string, any>,
1123
- sender: SendMessageSender | null,
1124
- sessionIds?: readonly string[],
1125
- ): Promise<void> {
1126
- const { data, type } = message;
1127
- const connectionId = sender?.sessionId ?? null;
1128
- const room = this.id;
1129
-
1130
- if (typeof connectionId !== "string") return;
1131
-
1132
- const webSockets =
1133
- sessionIds?.reduce((dict, id) => {
1134
- const pluvWs = this._sessions.get(id);
1135
-
1136
- return pluvWs ? dict.set(id, pluvWs) : dict;
1137
- }, new Map<string, AbstractWebSocket<any, TAuthorize>>()) ?? this._sessions;
1138
-
1139
- const promises = Array.from(webSockets.values()).map(async (pluvWs) => {
1140
- const session = pluvWs.session;
1141
- const user = session.user ?? null;
1142
-
1143
- this._sendMessage(pluvWs, { connectionId, data, room, type, user });
1144
- });
1145
-
1146
- await Promise.all(promises);
1147
- }
1148
-
1149
- private async _sendSelfMessage(
1150
- message: EventMessage<string, any>,
1151
- sender: SendMessageSender | null,
1152
- ): Promise<void> {
1153
- const senderId = sender?.sessionId ?? null;
1154
-
1155
- if (typeof senderId !== "string") return;
1156
-
1157
- const pluvWs = this._sessions.get(senderId);
1158
-
1159
- if (!pluvWs) return;
1160
-
1161
- /**
1162
- * @description Note that this will not fire for Cloudflare's hibernatable websockets
1163
- * because the server will auto-respond with the $pong event without calling any
1164
- * functions.
1165
- * @date April 18, 2025
1166
- */
1167
- if (message.type === "$pong") {
1168
- const elapsedMs = Date.now() - this._lastGarbageCollectMs;
1169
-
1170
- if (elapsedMs > GARBAGE_COLLECT_INTERVAL_MS) {
1171
- await this.garbageCollect();
1172
- }
1173
- }
1174
-
1175
- const session = pluvWs.session;
1176
- const user = session.user ?? null;
1177
-
1178
- this._sendMessage(pluvWs, {
1179
- connectionId: senderId,
1180
- room: this.id,
1181
- user,
1182
- ...message,
1183
- });
1184
- }
1185
-
1186
- private async _sendSyncMessage(
1187
- message: EventMessage<string, any>,
1188
- sender: SendMessageSender | null,
1189
- ): Promise<void> {
1190
- if (!sender) return;
1191
-
1192
- const connectionId = sender.sessionId;
1193
-
1194
- if (typeof connectionId !== "string") return;
1195
-
1196
- const [doc, context] = await Promise.all([this._doc, this._getContext()]);
1197
- const resolverCtx: EventResolverContext<"sync", TPlatform, TAuthorize, TContext> = {
1198
- context,
1199
- doc,
1200
- garbageCollect: async () => {
1201
- await this.garbageCollect();
1202
- },
1203
- platform: this._platform,
1204
- get presence() {
1205
- return null;
1206
- },
1207
- set presence(presence: JsonObject | null) {
1208
- return;
1209
- },
1210
- room: this.id,
1211
- session: null,
1212
- sessions: this._getSessions(),
1213
- time: new Date().getTime(),
1214
- };
1215
-
1216
- const resolver = this._getProcedure(message)?.config.sync;
1217
-
1218
- if (!resolver) return;
1219
-
1220
- let inputs: InferIOInput<this>[keyof TEvents];
1221
-
1222
- try {
1223
- inputs = this._getProcedureInputs(message);
1224
- } catch {
1225
- return;
1226
- }
1227
-
1228
- Promise.resolve(resolver(inputs, resolverCtx)).then((output) => {
1229
- if (!output) return;
1230
-
1231
- Object.keys(output).forEach((type: string) => {
1232
- const data = output[type] ?? {};
1233
-
1234
- this._platform.pubSub.publish(this.id, {
1235
- connectionId,
1236
- data,
1237
- options: { type: "self" },
1238
- room: this.id,
1239
- type,
1240
- user: (sender.user ?? null) as BaseUser | null,
1241
- });
1242
- });
1243
- });
1244
- }
1245
- }