@pluv/io 0.0.0-experimental-20250527040415

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