@pluv/client 2.0.1 → 2.1.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/PluvRoom.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import type {
2
2
  AbstractCrdtDocFactory,
3
- CrdtType,
4
3
  InferCrdtJson,
5
4
  InferStorage,
6
5
  NoopCrdtDocFactory,
@@ -8,9 +7,11 @@ import type {
8
7
  import type {
9
8
  BaseIOEventRecord,
10
9
  BaseUser,
10
+ BroadcastProxy,
11
11
  CrdtDocLike,
12
12
  EventMessage,
13
13
  EventNotifierSubscriptionCallback,
14
+ EventProxy,
14
15
  IOEventMessage,
15
16
  IOLike,
16
17
  Id,
@@ -22,8 +23,15 @@ import type {
22
23
  JsonObject,
23
24
  MergeEvents,
24
25
  OptionalProps,
25
- OtherNotifierSubscriptionCallback,
26
+ OtherSubscriptionCallback,
27
+ OthersSubscriptionCallback,
26
28
  RoomLike,
29
+ StateNotifierSubjects,
30
+ StorageProxy,
31
+ StorageRootSubscriptionCallback,
32
+ StorageSubscriptionCallback,
33
+ SubscribeProxy,
34
+ SubscriptionCallback,
27
35
  UpdateMyPresenceAction,
28
36
  UserInfo,
29
37
  WebSocketState,
@@ -34,11 +42,9 @@ import type { CrdtManagerOptions } from "./CrdtManager";
34
42
  import { CrdtManager } from "./CrdtManager";
35
43
  import { CrdtNotifier } from "./CrdtNotifier";
36
44
  import { EventNotifier } from "./EventNotifier";
37
- import { OtherNotifier } from "./OtherNotifier";
38
45
  import { PluvProcedure } from "./PluvProcedure";
39
46
  import type { PluvRouterEventConfig } from "./PluvRouter";
40
47
  import { PluvRouter } from "./PluvRouter";
41
- import type { StateNotifierSubjects, SubscriptionCallback } from "./StateNotifier";
42
48
  import { StateNotifier } from "./StateNotifier";
43
49
  import { StorageStore } from "./StorageStore";
44
50
  import type {
@@ -53,6 +59,7 @@ import type {
53
59
  } from "./types";
54
60
  import type { UsersManagerConfig } from "./UsersManager";
55
61
  import { UsersManager } from "./UsersManager";
62
+ import { UsersNotifier } from "./UsersNotifier";
56
63
  import { debounce } from "./utils";
57
64
 
58
65
  const ADD_TO_STORAGE_STATE_DEBOUNCE_MS = 1_000;
@@ -214,7 +221,6 @@ export class PluvRoom<
214
221
  };
215
222
  private readonly _limits: PluvClientLimits;
216
223
  private readonly _listeners: InternalListeners;
217
- private readonly _otherNotifier = new OtherNotifier<TIO, TPresence>();
218
224
  private readonly _publicKey: PublicKey<TMetadata> | null = null;
219
225
  private readonly _reconnectTimeoutMs: ReconnectTimeoutMs;
220
226
  private readonly _router: PluvRouter<TIO, TPresence, InferStorage<TCrdt>, TEvents>;
@@ -228,6 +234,7 @@ export class PluvRoom<
228
234
  reconnect: null,
229
235
  };
230
236
  private readonly _usersManager: UsersManager<TIO, TPresence>;
237
+ private readonly _usersNotifier = new UsersNotifier<TIO, TPresence>();
231
238
 
232
239
  private _lastMetadata: TMetadata | null = null;
233
240
  private _state: WebSocketState<TIO> = {
@@ -364,14 +371,7 @@ export class PluvRoom<
364
371
  };
365
372
  },
366
373
  },
367
- ) as (<TEvent extends keyof InferIOInput<MergeEvents<TEvents, TIO>>>(
368
- event: TEvent,
369
- data: Id<InferIOInput<MergeEvents<TEvents, TIO>>[TEvent]>,
370
- ) => Promise<void>) & {
371
- [PEvent in keyof InferIOInput<MergeEvents<TEvents, TIO>>]: (
372
- data: Id<InferIOInput<MergeEvents<TEvents, TIO>>[PEvent]>,
373
- ) => Promise<void>;
374
- };
374
+ ) as BroadcastProxy<TIO, TEvents>;
375
375
 
376
376
  public canRedo = (): boolean => {
377
377
  return !!this._crdtManager.doc.canRedo();
@@ -470,6 +470,9 @@ export class PluvRoom<
470
470
  this._stateNotifier.subjects["my-presence"].next(null);
471
471
  }
472
472
 
473
+ /**
474
+ * @deprecated To be removed in v3. Use PluvRoom.subscribe.event instead
475
+ */
473
476
  public event = new Proxy(
474
477
  <TEvent extends keyof InferIOOutput<MergeEvents<TEvents, TIO>>>(
475
478
  event: TEvent,
@@ -482,14 +485,7 @@ export class PluvRoom<
482
485
  ): (() => void) => fn(prop as any, callback);
483
486
  },
484
487
  },
485
- ) as (<TEvent extends keyof InferIOOutput<MergeEvents<TEvents, TIO>>>(
486
- event: TEvent,
487
- callback: EventNotifierSubscriptionCallback<MergeEvents<TEvents, TIO>, TEvent>,
488
- ) => () => void) & {
489
- [PEvent in keyof InferIOOutput<MergeEvents<TEvents, TIO>>]: (
490
- callback: EventNotifierSubscriptionCallback<MergeEvents<TEvents, TIO>, PEvent>,
491
- ) => () => void;
492
- };
488
+ ) as EventProxy<TIO, TEvents>;
493
489
 
494
490
  public getConnection = (): WebSocketConnection => {
495
491
  // Create a read-only clone of the connection state
@@ -542,42 +538,104 @@ export class PluvRoom<
542
538
  return this._crdtManager.doc.toJson(type);
543
539
  }
544
540
 
541
+ /**
542
+ * @deprecated To be removed in v3. Use PluvRoom.subscribe.other instead
543
+ */
545
544
  public other = (
546
545
  connectionId: string,
547
- callback: OtherNotifierSubscriptionCallback<TIO, TPresence>,
546
+ callback: OtherSubscriptionCallback<TIO, TPresence>,
548
547
  ): (() => void) => {
549
548
  const clientId = this._usersManager.getClientId(connectionId);
550
549
 
551
550
  if (!clientId) return () => undefined;
552
551
 
553
- return this._otherNotifier.subscribe(clientId, callback);
552
+ return this._usersNotifier.subscribeOther(clientId, callback);
554
553
  };
555
554
 
556
555
  public redo = (): void => {
557
556
  this._crdtManager.doc.redo();
558
557
  };
559
558
 
560
- public storage = <TKey extends keyof InferStorage<TCrdt>>(
561
- key: TKey,
562
- fn: (value: InferCrdtJson<InferStorage<TCrdt>[TKey]>) => void,
563
- ): (() => void) => {
564
- return this._crdtNotifier.subscribe(key, fn);
565
- };
559
+ /**
560
+ * @deprecated To be removed in v3. To be removed in v3. Use PluvRoom.subscribe.storage instead
561
+ */
562
+ public storage = new Proxy(
563
+ <TKey extends keyof InferStorage<TCrdt>>(
564
+ key: TKey,
565
+ callback: StorageSubscriptionCallback<InferStorage<TCrdt>, TKey>,
566
+ ): (() => void) => this._crdtNotifier.subscribe(key, callback),
567
+ {
568
+ get: (fn, prop) => {
569
+ type _Storage = InferStorage<TCrdt>;
566
570
 
571
+ if (!!prop) {
572
+ return (callback: StorageRootSubscriptionCallback<_Storage>) => {
573
+ return this._crdtNotifier.subcribeRoot(callback);
574
+ };
575
+ }
576
+
577
+ return (callback: StorageSubscriptionCallback<_Storage, keyof _Storage>) => {
578
+ return fn(prop as keyof InferStorage<TCrdt>, callback);
579
+ };
580
+ },
581
+ },
582
+ ) as StorageProxy<InferStorage<TCrdt>>;
583
+
584
+ /**
585
+ * @deprecated To be removed in v3. Use PluvRoom.subscribe.storage instead
586
+ * @description Subscribes to the root storage object (serialized)
587
+ */
567
588
  public storageRoot = (
568
- fn: (value: {
589
+ callback: (value: {
569
590
  [P in keyof InferStorage<TCrdt>]: InferCrdtJson<InferStorage<TCrdt>[P]>;
570
591
  }) => void,
571
592
  ): (() => void) => {
572
- return this._crdtNotifier.subcribeRoot(fn);
593
+ return this._crdtNotifier.subcribeRoot(callback);
573
594
  };
574
595
 
575
- public subscribe = <TSubject extends keyof StateNotifierSubjects<TIO, TPresence>>(
576
- name: TSubject,
577
- callback: SubscriptionCallback<TIO, TPresence, TSubject>,
578
- ): (() => void) => {
579
- return this._stateNotifier.subscribe(name, callback);
580
- };
596
+ public subscribe = new Proxy(
597
+ <TSubject extends keyof StateNotifierSubjects<TIO, TPresence>>(
598
+ name: TSubject,
599
+ callback: SubscriptionCallback<TIO, TPresence, TSubject>,
600
+ ): (() => void) => this._stateNotifier.subscribe(name, callback),
601
+ {
602
+ get: (fn, prop) => {
603
+ if (prop === "connection") {
604
+ return (callback: SubscriptionCallback<TIO, TPresence, "connection">) => {
605
+ return fn("connection", callback);
606
+ };
607
+ }
608
+
609
+ if (prop === "myPresence") {
610
+ return (callback: SubscriptionCallback<TIO, TPresence, "my-presence">) => {
611
+ return fn("my-presence", callback);
612
+ };
613
+ }
614
+
615
+ if (prop === "myself") {
616
+ return (callback: SubscriptionCallback<TIO, TPresence, "myself">) => {
617
+ return fn("myself", callback);
618
+ };
619
+ }
620
+
621
+ if (prop === "others") {
622
+ return (callback: OthersSubscriptionCallback<TIO, TPresence>) => {
623
+ return this._usersNotifier.subscribeOthers(callback);
624
+ };
625
+ }
626
+
627
+ if (prop === "storageLoaded") {
628
+ return (callback: SubscriptionCallback<TIO, TPresence, "storage-loaded">) => {
629
+ return fn("storage-loaded", callback);
630
+ };
631
+ }
632
+
633
+ if (prop === "event") return this.event;
634
+ if (prop === "other") return this.other;
635
+ if (prop === "storage") return this.storage;
636
+ },
637
+ },
638
+ ) as SubscribeProxy<TIO, TPresence, InferStorage<TCrdt>, TEvents>;
581
639
 
582
640
  public transact = (fn: (storage: InferStorage<TCrdt>) => void, origin?: string): void => {
583
641
  const _origin = origin ?? this._state.connection.id;
@@ -679,9 +737,10 @@ export class PluvRoom<
679
737
  this._detachWindowListeners();
680
738
  this._usersManager.removeMyself();
681
739
  this._usersManager.clearConnections();
682
- this._otherNotifier.clear();
740
+ this._usersNotifier.clear();
683
741
  this._stateNotifier.subjects.myself.next(null);
684
742
  this._stateNotifier.subjects.others.next([]);
743
+ this._usersNotifier.others.next({ others: [], event: { kind: "clear" } });
685
744
  this._state.webSocket?.close();
686
745
 
687
746
  this._detachWsListeners();
@@ -841,13 +900,26 @@ export class PluvRoom<
841
900
 
842
901
  this._stateNotifier.subjects.others.next(others);
843
902
 
903
+ // Should not reach here
904
+ if (!deleted) {
905
+ console.warn("Could not identify exited connection");
906
+ return;
907
+ }
908
+
909
+ const { data: user, remaining } = deleted;
910
+
911
+ this._usersNotifier.others.next({
912
+ others,
913
+ event: { kind: "leave", user },
914
+ });
915
+
844
916
  /**
845
- * @description A single user can have multiple connections. So we're checking that there
846
- * isn't a remaining connection before choosing to delete that user for other connected
847
- * participants.
917
+ * @description A single user can have multiple connections. So we're checking that
918
+ * there isn't a remaining connection before choosing to delete that user for other
919
+ * connected participants.
848
920
  * @date April 16, 2025
849
921
  */
850
- if (!deleted?.remaining && !!clientId) this._otherNotifier.delete(clientId);
922
+ if (!remaining && !!clientId) this._usersNotifier.delete(clientId);
851
923
  }
852
924
 
853
925
  private _handlePresenceUpdatedMessage(message: IOEventMessage<TIO>): void {
@@ -886,27 +958,41 @@ export class PluvRoom<
886
958
 
887
959
  if (!!clientId) {
888
960
  const other = this._usersManager.getOther(connectionId);
961
+ const others = this._usersManager.getOthers();
889
962
 
890
- this._otherNotifier.subject(clientId).next(other);
891
- } else {
892
- /**
893
- * !HACK
894
- * @description User could not be found. Add the connection to keep others up-to-date
895
- * @date April 19, 2025
896
- */
897
- const added = this._usersManager.addConnection({
898
- connectionId,
899
- presence: data.presence as TPresence,
900
- user: message.user as Id<InferIOAuthorizeUser<InferIOAuthorize<TIO>>>,
901
- });
902
- const other = this._usersManager.getOther(connectionId);
963
+ this._usersNotifier.other(clientId).next(other);
964
+ this._stateNotifier.subjects.others.next(others);
903
965
 
904
- this._otherNotifier.subject(added.clientId).next(other);
966
+ if (!!other) {
967
+ this._usersNotifier.others.next({
968
+ others,
969
+ event: { kind: "update", user: other },
970
+ });
971
+ }
972
+
973
+ return;
905
974
  }
906
975
 
976
+ /**
977
+ * !HACK
978
+ * @description User could not be found. Add the connection to keep others up-to-date
979
+ * @date April 19, 2025
980
+ */
981
+ const added = this._usersManager.addConnection({
982
+ connectionId,
983
+ presence: data.presence as TPresence,
984
+ user: message.user as Id<InferIOAuthorizeUser<InferIOAuthorize<TIO>>>,
985
+ });
986
+ const other = this._usersManager.getOther(connectionId);
907
987
  const others = this._usersManager.getOthers();
908
988
 
989
+ this._usersNotifier.other(added.clientId).next(other);
909
990
  this._stateNotifier.subjects.others.next(others);
991
+
992
+ this._usersNotifier.others.next({
993
+ others,
994
+ event: { kind: "update", user: added.data },
995
+ });
910
996
  }
911
997
 
912
998
  private _handleReceiveOthers(message: IOEventMessage<TIO>): void {
@@ -931,12 +1017,16 @@ export class PluvRoom<
931
1017
  const clientId = result.clientId;
932
1018
  const other = this._usersManager.getOther(connectionId);
933
1019
 
934
- this._otherNotifier.subject(clientId).next(other);
1020
+ this._usersNotifier.other(clientId).next(other);
935
1021
  });
936
1022
 
937
1023
  const others = this._usersManager.getOthers();
938
1024
 
939
1025
  this._stateNotifier.subjects.others.next(others);
1026
+ this._usersNotifier.others.next({
1027
+ others,
1028
+ event: { kind: "sync", users: others },
1029
+ });
940
1030
  }
941
1031
 
942
1032
  private async _handleRegisteredMessage(message: IOEventMessage<TIO>): Promise<void> {
@@ -1083,6 +1173,13 @@ export class PluvRoom<
1083
1173
 
1084
1174
  quitters.forEach((quitter) => {
1085
1175
  this._usersManager.deleteConnection(quitter.connectionId);
1176
+
1177
+ const remaining = this._usersManager.getOthers();
1178
+
1179
+ this._usersNotifier.others.next({
1180
+ others: remaining,
1181
+ event: { kind: "leave", user: quitter },
1182
+ });
1086
1183
  });
1087
1184
 
1088
1185
  const remaining = this._usersManager.getOthers();
@@ -1115,8 +1212,13 @@ export class PluvRoom<
1115
1212
  const other = this._usersManager.getOther(connectionId);
1116
1213
  const others = this._usersManager.getOthers();
1117
1214
 
1118
- this._otherNotifier.subject(added.clientId).next(other);
1215
+ this._usersNotifier.other(added.clientId).next(other);
1119
1216
  this._stateNotifier.subjects.others.next(others);
1217
+
1218
+ this._usersNotifier.others.next({
1219
+ others,
1220
+ event: { kind: "enter", user: added.data },
1221
+ });
1120
1222
  }
1121
1223
 
1122
1224
  private _heartbeat(): void {
@@ -1,15 +1,15 @@
1
- import type { Id, IOLike, JsonObject, UserInfo, WebSocketState } from "@pluv/types";
1
+ import type {
2
+ Id,
3
+ IOLike,
4
+ JsonObject,
5
+ StateNotifierSubjects,
6
+ SubscriptionCallback,
7
+ UserInfo,
8
+ WebSocketState,
9
+ } from "@pluv/types";
2
10
  import type { Subject } from "wonka";
3
11
  import { makeSubject, subscribe } from "wonka";
4
12
 
5
- export interface StateNotifierSubjects<TIO extends IOLike, TPresence extends JsonObject> {
6
- connection: Subject<Id<WebSocketState<TIO>>>;
7
- "my-presence": Subject<TPresence | null>;
8
- myself: Subject<Readonly<Id<UserInfo<TIO>>> | null>;
9
- others: Subject<readonly Id<UserInfo<TIO>>[]>;
10
- "storage-loaded": Subject<boolean>;
11
- }
12
-
13
13
  type InferSubjectValue<
14
14
  TIO extends IOLike,
15
15
  TPresence extends JsonObject,
@@ -19,18 +19,12 @@ type InferSubjectValue<
19
19
  ? Id<IValue>
20
20
  : never;
21
21
 
22
- export type SubscriptionCallback<
23
- TIO extends IOLike,
24
- TPresence extends JsonObject,
25
- TSubject extends keyof StateNotifierSubjects<TIO, TPresence>,
26
- > = (value: InferSubjectValue<TIO, TPresence, TSubject>) => void;
27
-
28
22
  export class StateNotifier<TIO extends IOLike, TPresence extends JsonObject = {}> {
29
23
  public subjects: StateNotifierSubjects<TIO, TPresence> = {
30
24
  connection: makeSubject<Id<WebSocketState<TIO>>>(),
31
25
  "my-presence": makeSubject<TPresence>(),
32
- myself: makeSubject<Readonly<Id<UserInfo<TIO>>> | null>(),
33
- others: makeSubject<readonly Id<UserInfo<TIO>>[]>(),
26
+ myself: makeSubject<Readonly<Id<UserInfo<TIO, TPresence>>> | null>(),
27
+ others: makeSubject<readonly Id<UserInfo<TIO, TPresence>>[]>(),
34
28
  "storage-loaded": makeSubject<true>(),
35
29
  };
36
30
 
@@ -0,0 +1,66 @@
1
+ import type {
2
+ Id,
3
+ IOLike,
4
+ JsonObject,
5
+ OtherSubscriptionCallback,
6
+ OthersSubscriptionCallback,
7
+ OthersSubscriptionEvent,
8
+ UserInfo,
9
+ } from "@pluv/types";
10
+ import type { Subject } from "wonka";
11
+ import { makeSubject, subscribe, TypeOfSource } from "wonka";
12
+
13
+ export class UsersNotifier<TIO extends IOLike, TPresence extends JsonObject = {}> {
14
+ public readonly others = makeSubject<{
15
+ others: readonly Id<UserInfo<TIO, TPresence>>[];
16
+ event: OthersSubscriptionEvent<TIO, TPresence>;
17
+ }>();
18
+
19
+ private _otherSubjects = new Map<
20
+ [clientId: string][0],
21
+ Subject<Id<UserInfo<TIO, TPresence>> | null>
22
+ >();
23
+
24
+ public clear(): void {
25
+ this._otherSubjects.forEach((subject) => {
26
+ subject.next(null);
27
+ });
28
+
29
+ this._otherSubjects.clear();
30
+ }
31
+
32
+ public delete(clientId: string): void {
33
+ this.other(clientId).next(null);
34
+ this._otherSubjects.delete(clientId);
35
+ }
36
+
37
+ public other(clientId: string): Subject<Id<UserInfo<TIO, TPresence>> | null> {
38
+ const subject = this._otherSubjects.get(clientId);
39
+
40
+ if (subject) return subject;
41
+
42
+ const newSubject = makeSubject<Id<UserInfo<TIO, TPresence>>>();
43
+
44
+ this._otherSubjects.set(clientId, newSubject);
45
+
46
+ return newSubject;
47
+ }
48
+
49
+ public subscribeOther(
50
+ clientId: string,
51
+ callback: OtherSubscriptionCallback<TIO, TPresence>,
52
+ ): () => void {
53
+ const source = this.other(clientId).source;
54
+ const subscription = subscribe(callback)(source);
55
+
56
+ return subscription.unsubscribe;
57
+ }
58
+
59
+ public subscribeOthers(callback: OthersSubscriptionCallback<TIO, TPresence>): () => void {
60
+ const subscription = subscribe<TypeOfSource<typeof this.others.source>>(
61
+ ({ others, event }) => callback(others, event),
62
+ )(this.others.source);
63
+
64
+ return subscription.unsubscribe;
65
+ }
66
+ }
@@ -1,50 +0,0 @@
1
- import type {
2
- Id,
3
- IOLike,
4
- JsonObject,
5
- OtherNotifierSubscriptionCallback,
6
- UserInfo,
7
- } from "@pluv/types";
8
- import type { Subject } from "wonka";
9
- import { makeSubject, subscribe } from "wonka";
10
-
11
- export class OtherNotifier<TIO extends IOLike, TPresence extends JsonObject = {}> {
12
- private _subjects = new Map<
13
- [clientId: string][0],
14
- Subject<Id<UserInfo<TIO, TPresence>> | null>
15
- >();
16
-
17
- public clear(): void {
18
- this._subjects.forEach((subject) => {
19
- subject.next(null);
20
- });
21
-
22
- this._subjects.clear();
23
- }
24
-
25
- public delete(clientId: string): void {
26
- this.subject(clientId).next(null);
27
- this._subjects.delete(clientId);
28
- }
29
-
30
- public subject(clientId: string): Subject<Id<UserInfo<TIO, TPresence>> | null> {
31
- const subject = this._subjects.get(clientId);
32
-
33
- if (subject) return subject;
34
-
35
- const newSubject = makeSubject<Id<UserInfo<TIO, TPresence>>>();
36
-
37
- this._subjects.set(clientId, newSubject);
38
-
39
- return newSubject;
40
- }
41
-
42
- public subscribe(
43
- clientId: string,
44
- callback: OtherNotifierSubscriptionCallback<TIO, TPresence>,
45
- ): () => void {
46
- const source = this.subject(clientId).source;
47
-
48
- return subscribe(callback)(source).unsubscribe;
49
- }
50
- }