@pluv/io 0.40.1 → 0.41.0

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 CHANGED
@@ -2,6 +2,7 @@ import type { AbstractCrdtDoc, AbstractCrdtDocFactory } from "@pluv/crdt";
2
2
  import { noop } from "@pluv/crdt";
3
3
  import type {
4
4
  BaseIOEventRecord,
5
+ BaseUser,
5
6
  EventMessage,
6
7
  IOEventMessage,
7
8
  IOLike,
@@ -25,7 +26,7 @@ import type { AbstractCloseEvent, AbstractErrorEvent, AbstractMessageEvent } fro
25
26
  import { AbstractWebSocket } from "./AbstractWebSocket";
26
27
  import type { PluvRouter, PluvRouterEventConfig } from "./PluvRouter";
27
28
  import { authorize } from "./authorize";
28
- import { PING_TIMEOUT_MS } from "./constants";
29
+ import { GARBAGE_COLLECT_INTERVAL_MS, PING_TIMEOUT_MS } from "./constants";
29
30
  import type {
30
31
  EventResolverContext,
31
32
  EventResolverKind,
@@ -87,6 +88,12 @@ interface SendMessageSender {
87
88
  user: JsonObject | null;
88
89
  }
89
90
 
91
+ interface PatchPresenceParams {
92
+ presence: JsonObject | null;
93
+ sessionId: string;
94
+ timer?: number | null;
95
+ }
96
+
90
97
  export type WebSocketRegisterConfig<TPlatform extends AbstractPlatform<any> = AbstractPlatform<any>> = {
91
98
  token?: string | null;
92
99
  } & InferInitContextType<TPlatform>;
@@ -101,6 +108,7 @@ export class IORoom<
101
108
  public readonly id: string;
102
109
 
103
110
  private _doc: Promise<AbstractCrdtDoc<any>>;
111
+ private _lastGarbageCollectMs: number = -1 * (GARBAGE_COLLECT_INTERVAL_MS + 1);
104
112
  private _uninitialize: Promise<() => Promise<void>> | null = null;
105
113
 
106
114
  private readonly _authorize: TAuthorize = null as TAuthorize;
@@ -110,7 +118,8 @@ export class IORoom<
110
118
  private readonly _listeners: IORoomListeners<TPlatform, TAuthorize, TContext, TEvents>;
111
119
  private readonly _platform: TPlatform;
112
120
  private readonly _router: PluvRouter<TPlatform, TAuthorize, TContext, TEvents>;
113
- private readonly _sessions = new Map<string, AbstractWebSocket>();
121
+ private readonly _sessions = new Map<[sessionId: string][0], AbstractWebSocket<any, TAuthorize>>();
122
+ private readonly _userSessionss = new Map<[userId: string][0], Set<[sessionId: string][0]>>();
114
123
 
115
124
  /**
116
125
  * @ignore
@@ -185,6 +194,11 @@ export class IORoom<
185
194
 
186
195
  if (authorize) this._authorize = authorize;
187
196
 
197
+ /**
198
+ * @description Everything below this line relates to waking up from hibernation.
199
+ * @see https://developers.cloudflare.com/durable-objects/best-practices/websockets/#websocket-hibernation-api
200
+ * @date April 17, 2025
201
+ */
188
202
  const webSockets = this._platform.getWebSockets() as readonly InferPlatformWebSocketSource<TPlatform>[];
189
203
 
190
204
  webSockets.forEach((webSocket) => {
@@ -197,6 +211,10 @@ export class IORoom<
197
211
  const pluvWs = this._platform.convertWebSocket(webSocket, { room: this.id });
198
212
 
199
213
  this._sessions.set(sessionId, pluvWs);
214
+
215
+ const userId = pluvWs.session.user?.id;
216
+
217
+ if (!!userId) this._addUserSession(userId, sessionId);
200
218
  });
201
219
 
202
220
  const { doc, uninitialize } = this._initialize();
@@ -231,6 +249,15 @@ export class IORoom<
231
249
  await this._emitQuitters();
232
250
  }
233
251
 
252
+ /**
253
+ * @description The IORoom will garbage collect occasionally as connections ping/pong the
254
+ * server
255
+ */
256
+ public async garbageCollect(): Promise<void> {
257
+ this._lastGarbageCollectMs = Date.now();
258
+ this._emitQuitters();
259
+ }
260
+
234
261
  public getSize(): number {
235
262
  const currentTime = new Date().getTime();
236
263
 
@@ -286,42 +313,54 @@ export class IORoom<
286
313
  ): Promise<void> {
287
314
  const _options = (options[0] ?? {}) as WebSocketRegisterConfig<TPlatform>;
288
315
  const token = _options.token ?? null;
316
+
317
+ /**
318
+ * TODO
319
+ * @description Update the sessionId strategy so that the userId can be used to retrieve
320
+ * the session in O(1) time if authorized.
321
+ * @date April 15, 2025
322
+ */
289
323
  const sessionId = this._platform.getSessionId(webSocket);
290
324
  const sessionExists = typeof sessionId === "string" && this._sessions.has(sessionId);
291
325
 
292
326
  if (sessionExists) return;
327
+
293
328
  if (!(await this._initialized)) {
294
329
  this._initialize();
295
-
296
330
  await this._initialized;
297
331
  }
298
332
 
299
- const user = await this._getAuthorizedUser(token, _options);
333
+ const user: BaseUser | null = await this._getAuthorizedUser(token, _options);
300
334
  const ioAuthorize = this._getIOAuthorize(_options);
301
335
  const isUnauthorized = !!ioAuthorize && !user;
302
-
303
- // There is an attempt for multiple of the same identities. Probably malicious.
304
- const isTokenInUse: boolean =
305
- !!user &&
306
- (await this._getSessions().then((sessions) => sessions.some((session) => session.user?.id === user.id)));
307
-
308
336
  const pluvWs = this._platform.convertWebSocket(webSocket, { room: this.id });
309
337
 
310
- this._logDebug(`${colors.blue(`Registering connection for room ${this.id}:`)} ${pluvWs.sessionId}`);
311
-
312
- await this._platform.acceptWebSocket(pluvWs);
313
-
314
- if (isUnauthorized || isTokenInUse) {
315
- this._logDebug(`${colors.blue("Authorization failed for connection:")} ${pluvWs.sessionId}`);
316
-
338
+ if (isUnauthorized) {
339
+ this._logDebug(`${colors.blue("Authorization failed for connection")}`);
317
340
  pluvWs.handleError({ error: new Error("Not authorized"), room: this.id });
318
341
  pluvWs.close(3000, "WebSocket unauthorized.");
319
342
 
320
343
  return;
321
344
  }
322
345
 
323
- this._sessions.set(pluvWs.sessionId, pluvWs);
346
+ if (!!user) {
347
+ const latest = this._getLatestPresence(user.id);
348
+ const prevState = pluvWs.state;
349
+
350
+ pluvWs.user = user;
351
+
352
+ this._platform.setSerializedState(pluvWs, {
353
+ ...prevState,
354
+ presence: latest.presence,
355
+ timers: { ...prevState.timers, presence: latest.timer },
356
+ });
357
+ this._addUserSession(user.id, pluvWs.sessionId);
358
+ }
359
+
360
+ this._logDebug(`${colors.blue(`Registering connection for room ${this.id}:`)} ${pluvWs.sessionId}`);
324
361
 
362
+ await this._platform.acceptWebSocket(pluvWs);
363
+ this._sessions.set(pluvWs.sessionId, pluvWs);
325
364
  await this._platform.persistence.addUser(this.id, pluvWs.sessionId, user ?? {});
326
365
 
327
366
  if (this._platform._config.registrationMode === "attached") {
@@ -335,18 +374,26 @@ export class IORoom<
335
374
 
336
375
  await this._emitRegistered(pluvWs);
337
376
 
338
- this._logDebug(`${colors.blue(`Registered connection for room ${this.id}:`)} ${pluvWs.sessionId}`);
339
-
340
377
  const size = this.getSize();
341
378
 
379
+ this._logDebug(`${colors.blue(`Registered connection for room ${this.id}:`)} ${pluvWs.sessionId}`);
342
380
  this._logDebug(`${colors.blue(`Room ${this.id} size:`)} ${size}`);
343
381
  }
344
382
 
383
+ private _addUserSession(userId: string, sessionId: string): Set<[sessionId: string][0]> {
384
+ const set = this._userSessionss.get(userId) ?? new Set<string>();
385
+ const updated = set.add(sessionId);
386
+
387
+ this._userSessionss.set(userId, updated);
388
+
389
+ return updated;
390
+ }
391
+
345
392
  private async _broadcast(params: BroadcastParams<this>): Promise<void> {
346
393
  const { message, senderId } = params;
347
394
 
348
395
  const sender = senderId ? (this._sessions.get(senderId) ?? null) : null;
349
- const session = (await sender?.getSession()) ?? null;
396
+ const session = sender?.session ?? null;
350
397
  const user = session?.user ?? null;
351
398
 
352
399
  if (typeof senderId !== "string") return;
@@ -359,8 +406,8 @@ export class IORoom<
359
406
  });
360
407
  }
361
408
 
362
- private async _closeWebSockets(webSockets: readonly AbstractWebSocket[]): Promise<void> {
363
- const closeWebSocket = async (webSocket: AbstractWebSocket): Promise<void> => {
409
+ private async _closeWebSockets(webSockets: readonly AbstractWebSocket<any, TAuthorize>[]): Promise<void> {
410
+ const closeWebSocket = async (webSocket: AbstractWebSocket<any, TAuthorize>): Promise<void> => {
364
411
  webSocket.state = { ...webSocket.state, quit: true };
365
412
 
366
413
  const sessionId = webSocket.sessionId;
@@ -374,8 +421,13 @@ export class IORoom<
374
421
  senderId: sessionId,
375
422
  });
376
423
 
424
+ const session = webSocket.session;
425
+ const user = session.user;
426
+
427
+ if (!!user) this._removeUserSession(user.id, sessionId);
428
+
377
429
  try {
378
- const [session, doc] = await Promise.all([webSocket.getSession(), this._doc]);
430
+ const doc = await this._doc;
379
431
  const encodedState = doc.getEncodedState();
380
432
 
381
433
  await Promise.resolve(
@@ -383,7 +435,7 @@ export class IORoom<
383
435
  context: this._context,
384
436
  encodedState,
385
437
  room: this.id,
386
- user: session.user,
438
+ user,
387
439
  }),
388
440
  );
389
441
  } catch (error) {
@@ -419,12 +471,23 @@ export class IORoom<
419
471
  this._closeWebSockets(quitters);
420
472
  }
421
473
 
422
- private async _emitRegistered(pluvWs: AbstractWebSocket): Promise<void> {
423
- const session = await pluvWs.getSession();
474
+ private async _emitRegistered(pluvWs: AbstractWebSocket<any, TAuthorize>): Promise<void> {
475
+ const session = pluvWs.session;
424
476
  const sessionId = session.id;
477
+ const presence = session.presence;
425
478
  const user = session.user;
426
479
 
427
- await this._sendSelfMessage({ type: "$registered", data: { sessionId } }, { sessionId, user });
480
+ await this._sendSelfMessage(
481
+ {
482
+ type: "$registered",
483
+ data: {
484
+ presence,
485
+ sessionId,
486
+ timers: { presence: session.timers.presence },
487
+ },
488
+ },
489
+ { sessionId, user },
490
+ );
428
491
 
429
492
  try {
430
493
  const doc = await this._doc;
@@ -450,7 +513,7 @@ export class IORoom<
450
513
  throw new Error("Platform must use detached mode");
451
514
  }
452
515
 
453
- private _getAbstractWs(webSocket: WebSocketType<TPlatform>): AbstractWebSocket | null {
516
+ private _getAbstractWs(webSocket: WebSocketType<TPlatform>): AbstractWebSocket<any, TAuthorize> | null {
454
517
  if ((webSocket as unknown as any) instanceof AbstractWebSocket) return webSocket;
455
518
 
456
519
  const sessionId = this._platform.getSessionId(webSocket);
@@ -469,7 +532,7 @@ export class IORoom<
469
532
  private async _getAuthorizedUser(
470
533
  token: Maybe<string>,
471
534
  options: WebSocketRegisterConfig<TPlatform>,
472
- ): Promise<InferIOAuthorizeUser<InferIOAuthorize<this>>> {
535
+ ): Promise<InferIOAuthorizeUser<TAuthorize>> {
473
536
  const ioAuthorize = this._getIOAuthorize(options);
474
537
 
475
538
  if (!ioAuthorize) return null as InferIOAuthorizeUser<InferIOAuthorize<this>>;
@@ -521,6 +584,36 @@ export class IORoom<
521
584
  return this._authorize as ResolvedPluvIOAuthorize<any, any> | null;
522
585
  }
523
586
 
587
+ private _getLatestPresence(userId: string): { timer: number | null; presence: JsonObject | null } {
588
+ if (!this._authorize) return { timer: null, presence: null };
589
+
590
+ const sessionIds = Array.from(this._userSessionss.get(userId)?.values() ?? []);
591
+
592
+ if (!sessionIds.length) return { timer: null, presence: null };
593
+
594
+ return sessionIds.reduce(
595
+ (state, sessionId) => {
596
+ const pluvWs = this._sessions.get(sessionId) ?? null;
597
+
598
+ if (!pluvWs) return state;
599
+
600
+ const session = pluvWs.session;
601
+ const presence = session.presence;
602
+ const timer = session.timers.presence;
603
+
604
+ if (session.user?.id !== userId) return state;
605
+ if (typeof state.timer !== "number") return { presence, timer };
606
+ if (typeof timer !== "number") return state;
607
+
608
+ return timer > state.timer ? { presence, timer } : state;
609
+ },
610
+ { presence: null, timer: null } as {
611
+ presence: JsonObject | null;
612
+ timer: number | null;
613
+ },
614
+ );
615
+ }
616
+
524
617
  private _getProcedure(
525
618
  message: EventMessage<string, any>,
526
619
  ): (typeof this._router)["_defs"]["events"][keyof (typeof this._router)["_defs"]["events"]] | null {
@@ -535,18 +628,18 @@ export class IORoom<
535
628
  return procedure.config.input ? procedure.config.input.parse(message.data) : message.data;
536
629
  }
537
630
 
538
- private async _getSession(webSocket: WebSocketType<TPlatform>): Promise<WebSocketSession<TAuthorize>> {
631
+ private _getSession(webSocket: WebSocketType<TPlatform>): WebSocketSession<TAuthorize> {
539
632
  const pluvWs = this._getAbstractWs(webSocket);
540
633
 
541
634
  if (!pluvWs) throw new Error("Session could not be found");
542
635
 
543
- return await pluvWs.getSession<TAuthorize>();
636
+ return pluvWs.session;
544
637
  }
545
638
 
546
- private async _getSessions() {
547
- const promises = Array.from(this._sessions.values()).map(async (pluvWs) => await pluvWs.getSession());
639
+ private _getSessions() {
640
+ const sessions = Array.from(this._sessions.values()).map((pluvWs) => pluvWs.session);
548
641
 
549
- return await Promise.all(promises);
642
+ return sessions;
550
643
  }
551
644
 
552
645
  private _initialize() {
@@ -624,14 +717,14 @@ export class IORoom<
624
717
  if (this._debug) console.log(...data);
625
718
  }
626
719
 
627
- private _onClose(webSocket: AbstractWebSocket): () => Promise<void> {
720
+ private _onClose(webSocket: AbstractWebSocket<any, TAuthorize>): () => Promise<void> {
628
721
  return async (): Promise<void> => {
629
722
  if (!(await this._initialized)) return;
630
723
  await this._closeWebSockets([webSocket]);
631
724
  };
632
725
  }
633
726
 
634
- private _onMessage(webSocket: AbstractWebSocket): (event: AbstractMessageEvent) => Promise<void> {
727
+ private _onMessage(webSocket: AbstractWebSocket<any, TAuthorize>): (event: AbstractMessageEvent) => Promise<void> {
635
728
  return async (event: AbstractMessageEvent): Promise<void> => {
636
729
  if (!(await this._initialized)) return;
637
730
 
@@ -639,18 +732,34 @@ export class IORoom<
639
732
 
640
733
  if (!pluvWs) throw new Error("Could not get session");
641
734
 
642
- const [session, sessions] = await Promise.all([
643
- this._getSession(pluvWs as WebSocketType<TPlatform>),
644
- this._getSessions(),
645
- ]);
735
+ const session = this._getSession(pluvWs as WebSocketType<TPlatform>);
736
+ const sessions = this._getSessions();
737
+
738
+ const setPresence = (params: PatchPresenceParams): void => {
739
+ this._setPresence.bind(this)(params);
740
+ };
646
741
 
647
742
  const doc = await this._doc;
648
743
  const eventContext: EventResolverContext<EventResolverKind, TPlatform, TAuthorize, TContext> = {
649
744
  context: this._context,
650
745
  doc,
746
+ garbageCollect: async () => {
747
+ await this.garbageCollect();
748
+ },
749
+ get presence() {
750
+ return session.presence as JsonObject | null;
751
+ },
752
+ set presence(presence: JsonObject | null) {
753
+ const sessionId = this.session?.id;
754
+
755
+ if (!sessionId) return;
756
+
757
+ setPresence({ presence, sessionId, timer: this.time });
758
+ },
651
759
  room: this.id,
652
760
  session,
653
761
  sessions,
762
+ time: new Date().getTime(),
654
763
  };
655
764
 
656
765
  if (pluvWs.state.quit) {
@@ -711,24 +820,24 @@ export class IORoom<
711
820
  const handleBroadcast = async () => {
712
821
  if (!broadcast) return;
713
822
 
714
- await Promise.all(
715
- Object.entries(broadcast).map(async ([type, data]: any) => {
716
- await this._broadcast({
717
- message: { data, type },
718
- senderId: sessionId,
719
- });
720
- }),
721
- );
823
+ const messages = Object.entries(broadcast).map(async ([type, data]: any) => {
824
+ await this._broadcast({
825
+ message: { data, type },
826
+ senderId: sessionId,
827
+ });
828
+ });
829
+
830
+ await Promise.all(messages);
722
831
  };
723
832
 
724
833
  const handleSelf = async () => {
725
834
  if (!self) return;
726
835
 
727
- await Promise.all(
728
- Object.entries(self).map(async ([type, data]) => {
729
- await this._sendSelfMessage({ data, type }, { sessionId, user });
730
- }),
731
- );
836
+ const messages = Object.entries(self).map(async ([type, data]) => {
837
+ await this._sendSelfMessage({ data, type }, { sessionId, user });
838
+ });
839
+
840
+ await Promise.all(messages);
732
841
  };
733
842
 
734
843
  const handleSync = async () => {
@@ -742,11 +851,11 @@ export class IORoom<
742
851
  ...message,
743
852
  });
744
853
 
745
- await Promise.all(
746
- Object.entries(sync).map(async ([type, data]) => {
747
- await this._sendSelfMessage({ data, type }, { sessionId, user });
748
- }),
749
- );
854
+ const messages = Object.entries(sync).map(async ([type, data]) => {
855
+ await this._sendSelfMessage({ data, type }, { sessionId, user });
856
+ });
857
+
858
+ await Promise.all(messages);
750
859
  };
751
860
 
752
861
  await Promise.all([handleBroadcast(), handleSelf(), handleSync()]);
@@ -768,7 +877,58 @@ export class IORoom<
768
877
  }
769
878
  }
770
879
 
771
- private async _sendMessage(pluvWs: AbstractWebSocket<any>, message: IOEventMessage<any>): Promise<void> {
880
+ private _setPresence(params: PatchPresenceParams): void {
881
+ const { presence, sessionId, timer: _timer } = params;
882
+
883
+ const timer = _timer ?? new Date().getTime();
884
+ const pluvWs = this._sessions.get(sessionId) ?? null;
885
+
886
+ if (!pluvWs) return;
887
+
888
+ const wsSession = pluvWs.session;
889
+ const user = wsSession.user as BaseUser | null;
890
+
891
+ const sessionIds = user
892
+ ? new Set<string>([...(this._userSessionss.get(user.id) ?? []), sessionId])
893
+ : new Set([sessionId]);
894
+
895
+ sessionIds.forEach((sId) => {
896
+ const pWs = this._sessions.get(sId);
897
+ const session = pWs?.session;
898
+
899
+ if (!session) return;
900
+
901
+ const prevState = session.webSocket.state;
902
+
903
+ this._platform.setSerializedState(session.webSocket, {
904
+ ...prevState,
905
+ presence,
906
+ timers: {
907
+ ...prevState.timers,
908
+ presence: timer,
909
+ },
910
+ });
911
+ });
912
+ }
913
+
914
+ private _removeUserSession(userId: string, sessionId: string): Set<[sessionId: string][0]> | null {
915
+ const set = this._userSessionss.get(userId);
916
+
917
+ if (!set) return null;
918
+
919
+ set.delete(sessionId);
920
+
921
+ if (!!set.size) return set;
922
+
923
+ this._userSessionss.delete(userId);
924
+
925
+ return set;
926
+ }
927
+
928
+ private async _sendMessage(
929
+ pluvWs: AbstractWebSocket<any, TAuthorize>,
930
+ message: IOEventMessage<any>,
931
+ ): Promise<void> {
772
932
  if (!(await this._initialized)) return;
773
933
 
774
934
  await Promise.resolve(pluvWs.sendMessage(message));
@@ -790,10 +950,10 @@ export class IORoom<
790
950
  const pluvWs = this._sessions.get(id);
791
951
 
792
952
  return pluvWs ? dict.set(id, pluvWs) : dict;
793
- }, new Map<string, AbstractWebSocket>()) ?? this._sessions;
953
+ }, new Map<string, AbstractWebSocket<any, TAuthorize>>()) ?? this._sessions;
794
954
 
795
955
  const promises = Array.from(webSockets.values()).map(async (pluvWs) => {
796
- const session = await pluvWs.getSession();
956
+ const session = pluvWs.session;
797
957
  const user = session.user ?? null;
798
958
 
799
959
  this._sendMessage(pluvWs, { connectionId, data, room, type, user });
@@ -813,9 +973,22 @@ export class IORoom<
813
973
  const pluvWs = this._sessions.get(senderId);
814
974
 
815
975
  if (!pluvWs) return;
816
- if (message.type === "$pong") await this._emitQuitters();
817
976
 
818
- const session = await pluvWs.getSession();
977
+ /**
978
+ * @description Note that this will not fire for Cloudflare's hibernatable websockets
979
+ * because the server will auto-respond with the $pong event without calling any
980
+ * functions.
981
+ * @date April 18, 2025
982
+ */
983
+ if (message.type === "$pong") {
984
+ const elapsedMs = Date.now() - this._lastGarbageCollectMs;
985
+
986
+ if (elapsedMs > GARBAGE_COLLECT_INTERVAL_MS) {
987
+ await this.garbageCollect();
988
+ }
989
+ }
990
+
991
+ const session = pluvWs.session;
819
992
  const user = session.user ?? null;
820
993
 
821
994
  this._sendMessage(pluvWs, {
@@ -840,9 +1013,19 @@ export class IORoom<
840
1013
  const context: EventResolverContext<"sync", TPlatform, TAuthorize, TContext> = {
841
1014
  context: this._context,
842
1015
  doc,
1016
+ garbageCollect: async () => {
1017
+ await this.garbageCollect();
1018
+ },
1019
+ get presence() {
1020
+ return null;
1021
+ },
1022
+ set presence(presence: JsonObject | null) {
1023
+ return;
1024
+ },
843
1025
  room: this.id,
844
1026
  session: null,
845
- sessions: await this._getSessions(),
1027
+ sessions: this._getSessions(),
1028
+ time: new Date().getTime(),
846
1029
  };
847
1030
 
848
1031
  const resolver = this._getProcedure(message)?.config.sync;