@pluv/client 2.0.2 → 2.2.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,16 +1,19 @@
1
1
  import type {
2
2
  AbstractCrdtDocFactory,
3
- CrdtType,
4
3
  InferCrdtJson,
4
+ InferDoc,
5
+ InferDocLike,
5
6
  InferStorage,
6
7
  NoopCrdtDocFactory,
7
8
  } from "@pluv/crdt";
8
9
  import type {
9
10
  BaseIOEventRecord,
10
11
  BaseUser,
12
+ BroadcastProxy,
11
13
  CrdtDocLike,
12
14
  EventMessage,
13
15
  EventNotifierSubscriptionCallback,
16
+ EventProxy,
14
17
  IOEventMessage,
15
18
  IOLike,
16
19
  Id,
@@ -22,23 +25,31 @@ import type {
22
25
  JsonObject,
23
26
  MergeEvents,
24
27
  OptionalProps,
25
- OtherNotifierSubscriptionCallback,
28
+ OtherSubscriptionCallback,
29
+ OthersSubscriptionCallback,
30
+ RoomEventListenerMap,
26
31
  RoomLike,
32
+ StateNotifierSubjects,
33
+ StorageProxy,
34
+ StorageRootSubscriptionCallback,
35
+ StorageSubscriptionCallback,
36
+ SubscribeProxy,
37
+ SubscriptionCallback,
27
38
  UpdateMyPresenceAction,
28
39
  UserInfo,
40
+ WebSocketConnection,
29
41
  WebSocketState,
30
42
  } from "@pluv/types";
31
- import { ConnectionState } from "@pluv/types";
43
+ import { ConnectionState, StorageState } from "@pluv/types";
32
44
  import type { AbstractStorageStore } from "./AbstractStorageStore";
33
45
  import type { CrdtManagerOptions } from "./CrdtManager";
34
46
  import { CrdtManager } from "./CrdtManager";
35
47
  import { CrdtNotifier } from "./CrdtNotifier";
36
48
  import { EventNotifier } from "./EventNotifier";
37
- import { OtherNotifier } from "./OtherNotifier";
49
+ import { ListenerManager } from "./ListenerManager";
38
50
  import { PluvProcedure } from "./PluvProcedure";
39
51
  import type { PluvRouterEventConfig } from "./PluvRouter";
40
52
  import { PluvRouter } from "./PluvRouter";
41
- import type { StateNotifierSubjects, SubscriptionCallback } from "./StateNotifier";
42
53
  import { StateNotifier } from "./StateNotifier";
43
54
  import { StorageStore } from "./StorageStore";
44
55
  import type {
@@ -48,11 +59,11 @@ import type {
48
59
  InternalSubscriptions,
49
60
  PluvClientLimits,
50
61
  PublicKey,
51
- WebSocketConnection,
52
62
  WithMetadata,
53
63
  } from "./types";
54
64
  import type { UsersManagerConfig } from "./UsersManager";
55
65
  import { UsersManager } from "./UsersManager";
66
+ import { UsersNotifier } from "./UsersNotifier";
56
67
  import { debounce } from "./utils";
57
68
 
58
69
  const ADD_TO_STORAGE_STATE_DEBOUNCE_MS = 1_000;
@@ -78,8 +89,8 @@ declare global {
78
89
  export const DEFAULT_PLUV_CLIENT_ADDON = <
79
90
  TIO extends IOLike,
80
91
  TMetadata extends JsonObject,
81
- TPresence extends JsonObject,
82
- TCrdt extends AbstractCrdtDocFactory<any>,
92
+ TPresence extends Record<string, any>,
93
+ TCrdt extends AbstractCrdtDocFactory<any, any>,
83
94
  >(
84
95
  input: PluvRoomAddonInput<TIO, TMetadata, TPresence, TCrdt>,
85
96
  ): PluvRoomAddonResult => ({
@@ -95,9 +106,9 @@ interface WindowListeners {
95
106
 
96
107
  interface WebSocketListeners {
97
108
  onClose: (event: CloseEvent) => void;
98
- onError: () => void;
109
+ onError: (event: Event) => void;
99
110
  onMessage: (message: MessageEvent<string>) => void;
100
- onOpen: () => void;
111
+ onOpen: (event: Event) => void;
101
112
  }
102
113
 
103
114
  interface IntervalIds {
@@ -136,15 +147,15 @@ interface InternalListeners {
136
147
  export type PluvRoomAddon<
137
148
  TIO extends IOLike,
138
149
  TMetadata extends JsonObject,
139
- TPresence extends JsonObject,
140
- TCrdt extends AbstractCrdtDocFactory<any>,
150
+ TPresence extends Record<string, any>,
151
+ TCrdt extends AbstractCrdtDocFactory<any, any>,
141
152
  > = (input: PluvRoomAddonInput<TIO, TMetadata, TPresence, TCrdt>) => Partial<PluvRoomAddonResult>;
142
153
 
143
154
  export interface PluvRoomAddonInput<
144
155
  TIO extends IOLike,
145
156
  TMetadata extends JsonObject,
146
- TPresence extends JsonObject,
147
- TCrdt extends AbstractCrdtDocFactory<any>,
157
+ TPresence extends Record<string, any>,
158
+ TCrdt extends AbstractCrdtDocFactory<any, any>,
148
159
  > {
149
160
  room: PluvRoom<TIO, TMetadata, TPresence, TCrdt>;
150
161
  }
@@ -174,8 +185,8 @@ export type ReconnectTimeoutMs = number | ((params: ReconnectTimeoutMsParams) =>
174
185
  export type RoomConfig<
175
186
  TIO extends IOLike,
176
187
  TMetadata extends JsonObject,
177
- TPresence extends JsonObject,
178
- TCrdt extends AbstractCrdtDocFactory<any>,
188
+ TPresence extends Record<string, any>,
189
+ TCrdt extends AbstractCrdtDocFactory<any, any>,
179
190
  TEvents extends PluvRouterEventConfig<TIO, TPresence, InferStorage<TCrdt>>,
180
191
  > = Id<
181
192
  {
@@ -195,10 +206,10 @@ export type RoomConfig<
195
206
  export class PluvRoom<
196
207
  TIO extends IOLike<any, any>,
197
208
  TMetadata extends JsonObject = {},
198
- TPresence extends JsonObject = {},
199
- TCrdt extends AbstractCrdtDocFactory<any> = NoopCrdtDocFactory,
209
+ TPresence extends Record<string, any> = {},
210
+ TCrdt extends AbstractCrdtDocFactory<any, any> = NoopCrdtDocFactory,
200
211
  TEvents extends PluvRouterEventConfig<TIO, TPresence, InferStorage<TCrdt>> = {},
201
- > implements RoomLike<TIO, TPresence, InferStorage<TCrdt>>
212
+ > implements RoomLike<TIO, InferDoc<TCrdt>, TPresence, InferStorage<TCrdt>>
202
213
  {
203
214
  readonly _endpoints: RoomEndpoints<TIO, TMetadata>;
204
215
 
@@ -213,8 +224,8 @@ export class PluvRoom<
213
224
  heartbeat: null,
214
225
  };
215
226
  private readonly _limits: PluvClientLimits;
227
+ private readonly _listenerManager = new ListenerManager();
216
228
  private readonly _listeners: InternalListeners;
217
- private readonly _otherNotifier = new OtherNotifier<TIO, TPresence>();
218
229
  private readonly _publicKey: PublicKey<TMetadata> | null = null;
219
230
  private readonly _reconnectTimeoutMs: ReconnectTimeoutMs;
220
231
  private readonly _router: PluvRouter<TIO, TPresence, InferStorage<TCrdt>, TEvents>;
@@ -228,6 +239,7 @@ export class PluvRoom<
228
239
  reconnect: null,
229
240
  };
230
241
  private readonly _usersManager: UsersManager<TIO, TPresence>;
242
+ private readonly _usersNotifier = new UsersNotifier<TIO, TPresence>();
231
243
 
232
244
  private _lastMetadata: TMetadata | null = null;
233
245
  private _state: WebSocketState<TIO> = {
@@ -237,10 +249,12 @@ export class PluvRoom<
237
249
  },
238
250
  connection: {
239
251
  attempts: 0,
240
- count: 0,
241
252
  id: null,
242
253
  state: ConnectionState.Untouched,
243
254
  },
255
+ storage: {
256
+ state: StorageState.Unavailable,
257
+ },
244
258
  webSocket: null,
245
259
  };
246
260
  private _windowListeners: WindowListeners | null = null;
@@ -297,14 +311,24 @@ export class PluvRoom<
297
311
  this._crdtManager = new CrdtManager<TCrdt>({ initialStorage });
298
312
  }
299
313
 
314
+ /**
315
+ * @deprecated To be removed in v3. Use `getStorageLoaded` instead.
316
+ */
300
317
  public get storageLoaded(): boolean {
301
- return this._crdtManager.initialized;
318
+ return this.getStorageLoaded();
302
319
  }
303
320
 
304
321
  public get webSocket(): WebSocket | null {
305
322
  return this._state.webSocket;
306
323
  }
307
324
 
325
+ public addEventListener<TKind extends keyof RoomEventListenerMap>(
326
+ kind: TKind,
327
+ handler: RoomEventListenerMap[TKind],
328
+ ): () => void {
329
+ return this._listenerManager.subscribe(kind, handler as any);
330
+ }
331
+
308
332
  public broadcast = new Proxy(
309
333
  async <TEvent extends keyof InferIOInput<MergeEvents<TEvents, TIO>>>(
310
334
  event: TEvent,
@@ -320,7 +344,8 @@ export class PluvRoom<
320
344
  any,
321
345
  any,
322
346
  TPresence,
323
- InferStorage<TCrdt>
347
+ TCrdt,
348
+ ""
324
349
  > | null;
325
350
 
326
351
  if (!procedure?.config.broadcast) {
@@ -334,7 +359,7 @@ export class PluvRoom<
334
359
  if (!myself) return;
335
360
 
336
361
  const parsed = procedure.config.input ? procedure.config.input.parse(data) : data;
337
- const context: EventResolverContext<TIO, TPresence, InferStorage<TCrdt>> = {
362
+ const context: EventResolverContext<TIO, TPresence, InferDocLike<TCrdt>> = {
338
363
  doc: this._crdtManager.doc,
339
364
  others: this._usersManager.getOthers(),
340
365
  room: this.id,
@@ -364,14 +389,7 @@ export class PluvRoom<
364
389
  };
365
390
  },
366
391
  },
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
- };
392
+ ) as BroadcastProxy<TIO, TEvents>;
375
393
 
376
394
  public canRedo = (): boolean => {
377
395
  return !!this._crdtManager.doc.canRedo();
@@ -436,6 +454,7 @@ export class PluvRoom<
436
454
  this._updateState((oldState) => {
437
455
  oldState.connection.attempts = 0;
438
456
  oldState.authorization.token = authToken;
457
+ oldState.storage.state = StorageState.Loading;
439
458
  oldState.webSocket = webSocket;
440
459
 
441
460
  return oldState;
@@ -460,6 +479,10 @@ export class PluvRoom<
460
479
  this._updateState((oldState) => {
461
480
  oldState.authorization.token = null;
462
481
  oldState.connection.state = ConnectionState.Closed;
482
+ oldState.storage.state =
483
+ oldState.storage.state === StorageState.Synced
484
+ ? StorageState.Offline
485
+ : StorageState.Unavailable;
463
486
  oldState.webSocket = null;
464
487
 
465
488
  return oldState;
@@ -470,6 +493,9 @@ export class PluvRoom<
470
493
  this._stateNotifier.subjects["my-presence"].next(null);
471
494
  }
472
495
 
496
+ /**
497
+ * @deprecated To be removed in v3. Use PluvRoom.subscribe.event instead
498
+ */
473
499
  public event = new Proxy(
474
500
  <TEvent extends keyof InferIOOutput<MergeEvents<TEvents, TIO>>>(
475
501
  event: TEvent,
@@ -482,21 +508,14 @@ export class PluvRoom<
482
508
  ): (() => void) => fn(prop as any, callback);
483
509
  },
484
510
  },
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
- };
511
+ ) as EventProxy<TIO, TEvents>;
493
512
 
494
513
  public getConnection = (): WebSocketConnection => {
495
514
  // Create a read-only clone of the connection state
496
515
  return Object.freeze(JSON.parse(JSON.stringify(this._state.connection)));
497
516
  };
498
517
 
499
- public getDoc = (): CrdtDocLike<InferStorage<TCrdt>> => {
518
+ public getDoc = (): CrdtDocLike<InferDoc<TCrdt>, InferStorage<TCrdt>> => {
500
519
  return this._crdtManager.doc;
501
520
  };
502
521
 
@@ -532,7 +551,6 @@ export class PluvRoom<
532
551
  ): InferCrdtJson<InferStorage<TCrdt>[TKey]> | null;
533
552
  public getStorageJson<TKey extends keyof InferStorage<TCrdt>>(type?: TKey) {
534
553
  if (this._state.connection.id === null) return null;
535
-
536
554
  if (typeof type === "undefined") return this._crdtManager.doc.toJson();
537
555
 
538
556
  const sharedType = this._crdtManager.get(type);
@@ -542,42 +560,111 @@ export class PluvRoom<
542
560
  return this._crdtManager.doc.toJson(type);
543
561
  }
544
562
 
563
+ public getStorageLoaded = (): boolean => {
564
+ return (
565
+ this._state.storage.state === StorageState.Offline ||
566
+ this._state.storage.state === StorageState.Synced
567
+ );
568
+ };
569
+
570
+ /**
571
+ * @deprecated To be removed in v3. Use PluvRoom.subscribe.other instead
572
+ */
545
573
  public other = (
546
574
  connectionId: string,
547
- callback: OtherNotifierSubscriptionCallback<TIO, TPresence>,
575
+ callback: OtherSubscriptionCallback<TIO, TPresence>,
548
576
  ): (() => void) => {
549
577
  const clientId = this._usersManager.getClientId(connectionId);
550
578
 
551
579
  if (!clientId) return () => undefined;
552
580
 
553
- return this._otherNotifier.subscribe(clientId, callback);
581
+ return this._usersNotifier.subscribeOther(clientId, callback);
554
582
  };
555
583
 
556
584
  public redo = (): void => {
557
585
  this._crdtManager.doc.redo();
558
586
  };
559
587
 
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
- };
588
+ /**
589
+ * @deprecated To be removed in v3. Use PluvRoom.subscribe.storage instead
590
+ */
591
+ public storage = new Proxy(
592
+ <TKey extends keyof InferStorage<TCrdt>>(
593
+ key: TKey,
594
+ callback: StorageSubscriptionCallback<InferStorage<TCrdt>, TKey>,
595
+ ): (() => void) => this._crdtNotifier.subscribe(key, callback),
596
+ {
597
+ get: (fn, prop) => {
598
+ type _Storage = InferStorage<TCrdt>;
599
+
600
+ if (!!prop) {
601
+ return (callback: StorageRootSubscriptionCallback<_Storage>) => {
602
+ return this._crdtNotifier.subcribeRoot(callback);
603
+ };
604
+ }
605
+
606
+ return (callback: StorageSubscriptionCallback<_Storage, keyof _Storage>) => {
607
+ return fn(prop as keyof InferStorage<TCrdt>, callback);
608
+ };
609
+ },
610
+ },
611
+ ) as StorageProxy<InferStorage<TCrdt>>;
566
612
 
613
+ /**
614
+ * @deprecated To be removed in v3. Use PluvRoom.subscribe.storage instead
615
+ * @description Subscribes to the root storage object (serialized)
616
+ */
567
617
  public storageRoot = (
568
- fn: (value: {
618
+ callback: (value: {
569
619
  [P in keyof InferStorage<TCrdt>]: InferCrdtJson<InferStorage<TCrdt>[P]>;
570
620
  }) => void,
571
621
  ): (() => void) => {
572
- return this._crdtNotifier.subcribeRoot(fn);
622
+ return this._crdtNotifier.subcribeRoot(callback);
573
623
  };
574
624
 
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
- };
625
+ public subscribe = new Proxy(
626
+ <TSubject extends keyof StateNotifierSubjects<TIO, TPresence>>(
627
+ name: TSubject,
628
+ callback: SubscriptionCallback<TIO, TPresence, TSubject>,
629
+ ): (() => void) => this._stateNotifier.subscribe(name, callback),
630
+ {
631
+ get: (fn, prop) => {
632
+ if (prop === "connection") {
633
+ return (callback: SubscriptionCallback<TIO, TPresence, "connection">) => {
634
+ return fn("connection", callback);
635
+ };
636
+ }
637
+
638
+ if (prop === "myPresence") {
639
+ return (callback: SubscriptionCallback<TIO, TPresence, "my-presence">) => {
640
+ return fn("my-presence", callback);
641
+ };
642
+ }
643
+
644
+ if (prop === "myself") {
645
+ return (callback: SubscriptionCallback<TIO, TPresence, "myself">) => {
646
+ return fn("myself", callback);
647
+ };
648
+ }
649
+
650
+ if (prop === "others") {
651
+ return (callback: OthersSubscriptionCallback<TIO, TPresence>) => {
652
+ return this._usersNotifier.subscribeOthers(callback);
653
+ };
654
+ }
655
+
656
+ if (prop === "storageLoaded") {
657
+ return (callback: SubscriptionCallback<TIO, TPresence, "storage-loaded">) => {
658
+ return fn("storage-loaded", callback);
659
+ };
660
+ }
661
+
662
+ if (prop === "event") return this.event;
663
+ if (prop === "other") return this.other;
664
+ if (prop === "storage") return this.storage;
665
+ },
666
+ },
667
+ ) as SubscribeProxy<TIO, TPresence, InferStorage<TCrdt>, TEvents>;
581
668
 
582
669
  public transact = (fn: (storage: InferStorage<TCrdt>) => void, origin?: string): void => {
583
670
  const _origin = origin ?? this._state.connection.id;
@@ -679,9 +766,10 @@ export class PluvRoom<
679
766
  this._detachWindowListeners();
680
767
  this._usersManager.removeMyself();
681
768
  this._usersManager.clearConnections();
682
- this._otherNotifier.clear();
769
+ this._usersNotifier.clear();
683
770
  this._stateNotifier.subjects.myself.next(null);
684
771
  this._stateNotifier.subjects.others.next([]);
772
+ this._usersNotifier.others.next({ others: [], event: { kind: "clear" } });
685
773
  this._state.webSocket?.close();
686
774
 
687
775
  this._detachWsListeners();
@@ -841,13 +929,26 @@ export class PluvRoom<
841
929
 
842
930
  this._stateNotifier.subjects.others.next(others);
843
931
 
932
+ // Should not reach here
933
+ if (!deleted) {
934
+ console.warn("Could not identify exited connection");
935
+ return;
936
+ }
937
+
938
+ const { data: user, remaining } = deleted;
939
+
940
+ this._usersNotifier.others.next({
941
+ others,
942
+ event: { kind: "leave", user },
943
+ });
944
+
844
945
  /**
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.
946
+ * @description A single user can have multiple connections. So we're checking that
947
+ * there isn't a remaining connection before choosing to delete that user for other
948
+ * connected participants.
848
949
  * @date April 16, 2025
849
950
  */
850
- if (!deleted?.remaining && !!clientId) this._otherNotifier.delete(clientId);
951
+ if (!remaining && !!clientId) this._usersNotifier.delete(clientId);
851
952
  }
852
953
 
853
954
  private _handlePresenceUpdatedMessage(message: IOEventMessage<TIO>): void {
@@ -886,27 +987,41 @@ export class PluvRoom<
886
987
 
887
988
  if (!!clientId) {
888
989
  const other = this._usersManager.getOther(connectionId);
990
+ const others = this._usersManager.getOthers();
889
991
 
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);
992
+ this._usersNotifier.other(clientId).next(other);
993
+ this._stateNotifier.subjects.others.next(others);
903
994
 
904
- this._otherNotifier.subject(added.clientId).next(other);
995
+ if (!!other) {
996
+ this._usersNotifier.others.next({
997
+ others,
998
+ event: { kind: "update", user: other },
999
+ });
1000
+ }
1001
+
1002
+ return;
905
1003
  }
906
1004
 
1005
+ /**
1006
+ * !HACK
1007
+ * @description User could not be found. Add the connection to keep others up-to-date
1008
+ * @date April 19, 2025
1009
+ */
1010
+ const added = this._usersManager.addConnection({
1011
+ connectionId,
1012
+ presence: data.presence as TPresence,
1013
+ user: message.user as Id<InferIOAuthorizeUser<InferIOAuthorize<TIO>>>,
1014
+ });
1015
+ const other = this._usersManager.getOther(connectionId);
907
1016
  const others = this._usersManager.getOthers();
908
1017
 
1018
+ this._usersNotifier.other(added.clientId).next(other);
909
1019
  this._stateNotifier.subjects.others.next(others);
1020
+
1021
+ this._usersNotifier.others.next({
1022
+ others,
1023
+ event: { kind: "update", user: added.data },
1024
+ });
910
1025
  }
911
1026
 
912
1027
  private _handleReceiveOthers(message: IOEventMessage<TIO>): void {
@@ -931,12 +1046,16 @@ export class PluvRoom<
931
1046
  const clientId = result.clientId;
932
1047
  const other = this._usersManager.getOther(connectionId);
933
1048
 
934
- this._otherNotifier.subject(clientId).next(other);
1049
+ this._usersNotifier.other(clientId).next(other);
935
1050
  });
936
1051
 
937
1052
  const others = this._usersManager.getOthers();
938
1053
 
939
1054
  this._stateNotifier.subjects.others.next(others);
1055
+ this._usersNotifier.others.next({
1056
+ others,
1057
+ event: { kind: "sync", users: others },
1058
+ });
940
1059
  }
941
1060
 
942
1061
  private async _handleRegisteredMessage(message: IOEventMessage<TIO>): Promise<void> {
@@ -951,7 +1070,6 @@ export class PluvRoom<
951
1070
  const state = data.state;
952
1071
 
953
1072
  this._updateState((oldState) => {
954
- oldState.connection.count += 1;
955
1073
  oldState.connection.id = connectionId;
956
1074
  oldState.authorization.user = user ?? null;
957
1075
 
@@ -1024,6 +1142,11 @@ export class PluvRoom<
1024
1142
 
1025
1143
  this._emitSharedTypes();
1026
1144
  this._observeCrdt();
1145
+ this._updateState((oldState) => {
1146
+ oldState.storage.state = StorageState.Synced;
1147
+
1148
+ return oldState;
1149
+ });
1027
1150
  this._stateNotifier.subjects["storage-loaded"].next(true);
1028
1151
 
1029
1152
  this._sendMessage({
@@ -1083,6 +1206,13 @@ export class PluvRoom<
1083
1206
 
1084
1207
  quitters.forEach((quitter) => {
1085
1208
  this._usersManager.deleteConnection(quitter.connectionId);
1209
+
1210
+ const remaining = this._usersManager.getOthers();
1211
+
1212
+ this._usersNotifier.others.next({
1213
+ others: remaining,
1214
+ event: { kind: "leave", user: quitter },
1215
+ });
1086
1216
  });
1087
1217
 
1088
1218
  const remaining = this._usersManager.getOthers();
@@ -1115,8 +1245,13 @@ export class PluvRoom<
1115
1245
  const other = this._usersManager.getOther(connectionId);
1116
1246
  const others = this._usersManager.getOthers();
1117
1247
 
1118
- this._otherNotifier.subject(added.clientId).next(other);
1248
+ this._usersNotifier.other(added.clientId).next(other);
1119
1249
  this._stateNotifier.subjects.others.next(others);
1250
+
1251
+ this._usersNotifier.others.next({
1252
+ others,
1253
+ event: { kind: "enter", user: added.data },
1254
+ });
1120
1255
  }
1121
1256
 
1122
1257
  private _heartbeat(): void {
@@ -1175,12 +1310,15 @@ export class PluvRoom<
1175
1310
 
1176
1311
  this._updateState((oldState) => {
1177
1312
  oldState.connection.state = ConnectionState.Unavailable;
1313
+ oldState.storage.state = StorageState.Unavailable;
1178
1314
 
1179
1315
  return oldState;
1180
1316
  });
1181
1317
  }
1182
1318
 
1183
1319
  private async _onClose(event: CloseEvent): Promise<void> {
1320
+ this._listenerManager.subjects.close.next(event);
1321
+
1184
1322
  this._logDebug("WebSocket closed");
1185
1323
  if (!!event.reason) this._logDebug(event.reason);
1186
1324
 
@@ -1210,6 +1348,10 @@ export class PluvRoom<
1210
1348
  * @date April 28, 2025
1211
1349
  */
1212
1350
  ConnectionState.Closed;
1351
+ oldState.storage.state =
1352
+ oldState.storage.state === StorageState.Synced
1353
+ ? StorageState.Offline
1354
+ : StorageState.Unavailable;
1213
1355
  oldState.webSocket = null;
1214
1356
 
1215
1357
  return oldState;
@@ -1229,9 +1371,13 @@ export class PluvRoom<
1229
1371
  * @description Handle websocket errors in a meaningful way, should they occur
1230
1372
  * @date August 19, 2022
1231
1373
  */
1232
- private _onError(): void {}
1374
+ private _onError(event: Event): void {
1375
+ this._listenerManager.subjects.error.next(event);
1376
+ }
1233
1377
 
1234
1378
  private _onMessage(event: MessageEvent<string>): void {
1379
+ this._listenerManager.subjects.message.next(event);
1380
+
1235
1381
  const message = this._parseMessage(event);
1236
1382
 
1237
1383
  if (!message) return;
@@ -1288,7 +1434,9 @@ export class PluvRoom<
1288
1434
  }
1289
1435
  }
1290
1436
 
1291
- private _onOpen(): void {
1437
+ private _onOpen(event: Event): void {
1438
+ this._listenerManager.subjects.open.next(event);
1439
+
1292
1440
  if (this._state.connection.state !== ConnectionState.Connecting) return;
1293
1441
 
1294
1442
  this._logDebug("WebSocket connected");
@@ -1310,6 +1458,10 @@ export class PluvRoom<
1310
1458
 
1311
1459
  this._updateState((oldState) => {
1312
1460
  oldState.connection.state = ConnectionState.Unavailable;
1461
+ oldState.storage.state =
1462
+ oldState.storage.state === StorageState.Synced
1463
+ ? StorageState.Offline
1464
+ : StorageState.Unavailable;
1313
1465
 
1314
1466
  return oldState;
1315
1467
  });
@@ -1460,6 +1612,7 @@ export class PluvRoom<
1460
1612
  ...this._state,
1461
1613
  authorization,
1462
1614
  connection: JSON.parse(JSON.stringify(this._state.connection)),
1615
+ storage: JSON.parse(JSON.stringify(this._state.storage)),
1463
1616
  webSocket: this._state.webSocket,
1464
1617
  };
1465
1618