@pluv/client 0.8.1 → 0.9.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
@@ -14,6 +14,7 @@ import type {
14
14
  } from "@pluv/types";
15
15
  import type { AbstractType } from "yjs";
16
16
  import { AbstractRoom } from "./AbstractRoom";
17
+ import { AbstractStorageStore } from "./AbstractStorageStore";
17
18
  import type { CrdtManagerOptions } from "./CrdtManager";
18
19
  import { CrdtManager } from "./CrdtManager";
19
20
  import { CrdtNotifier } from "./CrdtNotifier";
@@ -26,6 +27,7 @@ import type {
26
27
  SubscriptionCallback,
27
28
  } from "./StateNotifier";
28
29
  import { StateNotifier } from "./StateNotifier";
30
+ import { StorageStore } from "./StorageStore";
29
31
  import type {
30
32
  AuthorizationState,
31
33
  InternalSubscriptions,
@@ -36,10 +38,23 @@ import type {
36
38
  import { ConnectionState } from "./types";
37
39
  import type { UsersManagerConfig } from "./UsersManager";
38
40
  import { UsersManager } from "./UsersManager";
41
+ import { debounce } from "./utils";
39
42
 
43
+ const ADD_TO_STORAGE_STATE_DEBOUNCE_MS = 1_000;
40
44
  const HEARTBEAT_INTERVAL_MS = 10_000;
41
45
  const PONG_TIMEOUT_MS = 2_000;
42
46
  const RECONNECT_TIMEOUT_MS = 30_000;
47
+ const Y_ORIGIN_INITIALIZED = "$INITIALIZED";
48
+
49
+ export const DEFAULT_PLUV_CLIENT_ADDON = <
50
+ TIO extends IOLike = IOLike,
51
+ TPresence extends JsonObject = {},
52
+ TStorage extends Record<string, AbstractType<any>> = {}
53
+ >(
54
+ input: PluvRoomAddonInput<TIO, TPresence, TStorage>
55
+ ): PluvRoomAddonResult => ({
56
+ storage: new StorageStore(input.room.id),
57
+ });
43
58
 
44
59
  declare global {
45
60
  var process: {
@@ -88,6 +103,26 @@ interface InternalListeners {
88
103
  onAuthorizationFail: (error: Error) => void;
89
104
  }
90
105
 
106
+ export type PluvRoomAddon<
107
+ TIO extends IOLike = IOLike,
108
+ TPresence extends JsonObject = {},
109
+ TStorage extends Record<string, AbstractType<any>> = {}
110
+ > = (
111
+ input: PluvRoomAddonInput<TIO, TPresence, TStorage>
112
+ ) => Partial<PluvRoomAddonResult>;
113
+
114
+ export interface PluvRoomAddonInput<
115
+ TIO extends IOLike = IOLike,
116
+ TPresence extends JsonObject = {},
117
+ TStorage extends Record<string, AbstractType<any>> = {}
118
+ > {
119
+ room: PluvRoom<TIO, TPresence, TStorage>;
120
+ }
121
+
122
+ export interface PluvRoomAddonResult {
123
+ storage: AbstractStorageStore;
124
+ }
125
+
91
126
  export type PluvRoomDebug<TIO extends IOLike> = Id<{
92
127
  output: readonly (keyof InferIOOutput<TIO>)[];
93
128
  input: readonly (keyof InferIOInput<TIO>)[];
@@ -98,6 +133,7 @@ export type PluvRoomOptions<
98
133
  TPresence extends JsonObject = {},
99
134
  TStorage extends Record<string, AbstractType<any>> = {}
100
135
  > = {
136
+ addons?: readonly PluvRoomAddon<TIO, TPresence, TStorage>[];
101
137
  debug?: boolean | PluvRoomDebug<TIO>;
102
138
  onAuthorizationFail?: (error: Error) => void;
103
139
  } & Omit<CrdtManagerOptions<TStorage>, "encodedState"> &
@@ -116,11 +152,10 @@ export class PluvRoom<
116
152
  > extends AbstractRoom<TIO, TPresence, TStorage> {
117
153
  readonly _endpoints: RoomEndpoints<TIO>;
118
154
 
119
- private _crdtManager: CrdtManager<TStorage> | null = null;
155
+ private _crdtManager: CrdtManager<TStorage>;
120
156
  private _crdtNotifier = new CrdtNotifier<TStorage>();
121
157
  private _debug: boolean | PluvRoomDebug<TIO>;
122
158
  private _eventNotifier = new EventNotifier<TIO>();
123
- private _initialStorage: (() => TStorage) | null = null;
124
159
  private _intervals: IntervalIds = {
125
160
  heartbeat: null,
126
161
  };
@@ -139,6 +174,7 @@ export class PluvRoom<
139
174
  webSocket: null,
140
175
  };
141
176
  private _stateNotifier = new StateNotifier<TIO, TPresence>();
177
+ private _storageStore: AbstractStorageStore;
142
178
  private _subscriptions: InternalSubscriptions = {
143
179
  observeCrdt: null,
144
180
  };
@@ -152,6 +188,7 @@ export class PluvRoom<
152
188
 
153
189
  constructor(room: string, options: RoomConfig<TIO, TPresence, TStorage>) {
154
190
  const {
191
+ addons = [],
155
192
  authEndpoint,
156
193
  debug = false,
157
194
  initialPresence,
@@ -163,6 +200,15 @@ export class PluvRoom<
163
200
 
164
201
  super(room);
165
202
 
203
+ const addon = this._getAddon(addons);
204
+
205
+ const { storage } = {
206
+ ...DEFAULT_PLUV_CLIENT_ADDON({ room: this }),
207
+ ...addon({ room: this }),
208
+ };
209
+
210
+ this._storageStore = storage;
211
+
166
212
  this._debug = debug;
167
213
 
168
214
  this._endpoints = { authEndpoint, wsEndpoint } as RoomEndpoints<TIO>;
@@ -184,7 +230,7 @@ export class PluvRoom<
184
230
  presence,
185
231
  });
186
232
 
187
- if (initialStorage) this._initialStorage = initialStorage;
233
+ this._crdtManager = new CrdtManager<TStorage>({ initialStorage });
188
234
  }
189
235
 
190
236
  public get webSocket(): WebSocket | null {
@@ -223,26 +269,24 @@ export class PluvRoom<
223
269
  return oldState;
224
270
  });
225
271
 
272
+ await this._storageStore.initialize();
273
+ await this._applyStorageStore();
274
+
226
275
  const url = new URL(this._getWsEndpoint(this.id));
227
276
 
228
277
  let authToken: string | null = null;
278
+ let webSocket: WebSocket;
229
279
 
230
280
  try {
231
281
  authToken = await this._getAuthorization(this.id);
232
- } catch (err) {
233
- this._onAuthorizationFail(err);
234
-
235
- return;
236
- }
237
-
238
- authToken &&
239
- url.searchParams.set("token", encodeURIComponent(authToken));
240
282
 
241
- let webSocket: WebSocket;
283
+ authToken &&
284
+ url.searchParams.set("token", encodeURIComponent(authToken));
242
285
 
243
- try {
244
286
  webSocket = new WebSocket(url.toString());
245
287
  } catch (err) {
288
+ await this._storageStore.destroy();
289
+
246
290
  this._onAuthorizationFail(err);
247
291
 
248
292
  return;
@@ -259,7 +303,7 @@ export class PluvRoom<
259
303
  this._attachWsListeners();
260
304
  }
261
305
 
262
- public disconnect(): void {
306
+ public async disconnect(): Promise<void> {
263
307
  if (!this._endpoints.wsEndpoint) {
264
308
  throw new Error("Must provide an wsEndpoint.");
265
309
  }
@@ -276,17 +320,12 @@ export class PluvRoom<
276
320
  this._clearInterval(this._intervals.heartbeat);
277
321
 
278
322
  this._closeWs();
279
- this._crdtManager?.destroy();
280
323
 
281
- this._stateNotifier.subjects["my-presence"].next(null);
324
+ await this._storageStore.destroy();
282
325
 
283
- this._updateState((oldState) => {
284
- oldState.authorization.token = null;
285
- oldState.connection.state = ConnectionState.Closed;
286
- oldState.webSocket = null;
326
+ this._crdtManager?.destroy();
287
327
 
288
- return oldState;
289
- });
328
+ this._stateNotifier.subjects["my-presence"].next(null);
290
329
  }
291
330
 
292
331
  public event = <TEvent extends keyof InferIOOutput<TIO>>(
@@ -365,6 +404,26 @@ export class PluvRoom<
365
404
  );
366
405
  };
367
406
 
407
+ private async _addToStorageStore(update: string): Promise<void> {
408
+ await this._storageStore.addUpdate(update);
409
+
410
+ this._flattenStorageStore();
411
+ }
412
+
413
+ private async _applyStorageStore(): Promise<void> {
414
+ const updates = await this._storageStore.getUpdates();
415
+
416
+ this._crdtManager.initialize({
417
+ onInitialized: () => {
418
+ this._observeCrdt();
419
+ },
420
+ origin: Y_ORIGIN_INITIALIZED,
421
+ update: updates,
422
+ });
423
+
424
+ this._emitSharedTypes();
425
+ }
426
+
368
427
  private _attachWindowListeners(): void {
369
428
  if (typeof window === "undefined") return;
370
429
  if (typeof document === "undefined") return;
@@ -442,8 +501,6 @@ export class PluvRoom<
442
501
  if (canClose) this._state.webSocket.close();
443
502
 
444
503
  this._detachWsListeners();
445
-
446
- this._stateNotifier.subjects["myself"].next(null);
447
504
  }
448
505
 
449
506
  private _detachWindowListeners(): void {
@@ -486,27 +543,44 @@ export class PluvRoom<
486
543
  this._wsListeners = null;
487
544
  }
488
545
 
489
- private async _getAuthorization(room: string): Promise<string | null> {
490
- const fetchOptions = this._getAuthFetchOptions(room);
546
+ private _emitSharedTypes(): void {
547
+ const sharedTypes = this._crdtManager.doc.getSharedTypes();
491
548
 
492
- if (!fetchOptions) return null;
549
+ Object.entries(sharedTypes).forEach(([prop, sharedType]) => {
550
+ const serialized = sharedType.toJSON();
493
551
 
494
- const { url, options } = fetchOptions;
552
+ this._crdtNotifier.subject(prop).next(serialized);
553
+ });
554
+ }
495
555
 
496
- const res = await fetch(url, options);
556
+ private _flattenStorageStore = debounce(
557
+ async (): Promise<void> => {
558
+ const shouldFlatten = await this._storageStore.getShouldFlatten();
497
559
 
498
- if (!res.ok || res.status !== 200) {
499
- throw new Error("Room is unauthorized");
500
- }
560
+ if (!shouldFlatten) return;
501
561
 
502
- try {
503
- return await res.text().then((text) => text.trim());
504
- } catch (err) {
505
- throw new Error(
506
- err instanceof Error ? err.message : "Room is unauthorized"
507
- );
508
- }
509
- }
562
+ const updates = await this._storageStore.getUpdates();
563
+
564
+ this._crdtManager.applyUpdate(updates, this);
565
+
566
+ const encodedState = this._crdtManager.doc.encodeStateAsUpdate();
567
+
568
+ await this._storageStore.flatten(encodedState);
569
+ },
570
+ { wait: ADD_TO_STORAGE_STATE_DEBOUNCE_MS }
571
+ );
572
+
573
+ private _getAddon = (
574
+ addons: readonly PluvRoomAddon<TIO, TPresence, TStorage>[]
575
+ ): PluvRoomAddon<TIO, TPresence, TStorage> => {
576
+ return addons.reduce<PluvRoomAddon<TIO, TPresence, TStorage>>(
577
+ (acc, addon) => () => ({
578
+ ...acc({ room: this }),
579
+ ...addon({ room: this }),
580
+ }),
581
+ DEFAULT_PLUV_CLIENT_ADDON
582
+ );
583
+ };
510
584
 
511
585
  private _getAuthFetchOptions(room: string): FetchOptions | null {
512
586
  if (typeof this._endpoints.authEndpoint === "undefined") return null;
@@ -532,6 +606,28 @@ export class PluvRoom<
532
606
  : result;
533
607
  }
534
608
 
609
+ private async _getAuthorization(room: string): Promise<string | null> {
610
+ const fetchOptions = this._getAuthFetchOptions(room);
611
+
612
+ if (!fetchOptions) return null;
613
+
614
+ const { url, options } = fetchOptions;
615
+
616
+ const res = await fetch(url, options);
617
+
618
+ if (!res.ok || res.status !== 200) {
619
+ throw new Error("Room is unauthorized");
620
+ }
621
+
622
+ try {
623
+ return await res.text().then((text) => text.trim());
624
+ } catch (err) {
625
+ throw new Error(
626
+ err instanceof Error ? err.message : "Room is unauthorized"
627
+ );
628
+ }
629
+ }
630
+
535
631
  private _getWsEndpoint(room: string): string {
536
632
  switch (typeof this._endpoints.wsEndpoint) {
537
633
  case "undefined":
@@ -661,7 +757,9 @@ export class PluvRoom<
661
757
  });
662
758
  }
663
759
 
664
- private _handleStorageReceivedMessage(message: IOEventMessage<TIO>): void {
760
+ private async _handleStorageReceivedMessage(
761
+ message: IOEventMessage<TIO>
762
+ ): Promise<void> {
665
763
  const { connectionId } = message;
666
764
 
667
765
  if (!connectionId) return;
@@ -672,27 +770,25 @@ export class PluvRoom<
672
770
  InferIOAuthorize<TIO>
673
771
  >["$STORAGE_RECEIVED"];
674
772
 
675
- this._crdtManager = new CrdtManager<TStorage>({
676
- encodedState: data.state,
677
- initialStorage: this._initialStorage ?? undefined,
773
+ this._crdtManager.initialize({
774
+ onInitialized: async () => {
775
+ this._observeCrdt();
776
+ },
777
+ origin: Y_ORIGIN_INITIALIZED,
778
+ update: data.state,
678
779
  });
679
780
 
680
- this._observeCrdt();
681
-
682
- const sharedTypes = this._crdtManager.doc.getSharedTypes();
781
+ const encodedState = this._crdtManager.doc.encodeStateAsUpdate();
683
782
 
684
- Object.keys(sharedTypes).forEach((key) => {
685
- const sharedType = sharedTypes[key];
686
- const serialized = sharedType.toJSON();
783
+ await this._storageStore.addUpdate(encodedState);
687
784
 
688
- this._crdtNotifier.subject(key).next(serialized);
689
- });
785
+ this._emitSharedTypes();
690
786
 
691
787
  const update = this._crdtManager.doc.encodeStateAsUpdate();
692
788
 
693
789
  this._sendMessage({
694
790
  type: "$UPDATE_STORAGE",
695
- data: { origin: "$STORAGE_RECEIVED", update },
791
+ data: { origin: Y_ORIGIN_INITIALIZED, update },
696
792
  });
697
793
  }
698
794
 
@@ -713,16 +809,12 @@ export class PluvRoom<
713
809
 
714
810
  const sharedTypes = this._crdtManager.doc.getSharedTypes();
715
811
 
716
- Object.keys(sharedTypes).forEach((key) => {
812
+ Object.entries(sharedTypes).forEach(([prop, sharedType]) => {
717
813
  if (!this._crdtManager) return;
718
814
 
719
- const sharedType = sharedTypes[key];
720
-
721
- if (!sharedType) return;
722
-
723
815
  const serialized = sharedType.toJSON();
724
816
 
725
- this._crdtNotifier.subject(key).next(serialized);
817
+ this._crdtNotifier.subject(prop).next(serialized);
726
818
  });
727
819
  }
728
820
 
@@ -783,23 +875,17 @@ export class PluvRoom<
783
875
  (update, _origin) => {
784
876
  const origin = _origin ?? null;
785
877
 
786
- if (!this._crdtManager) return;
878
+ this._addToStorageStore(update);
879
+ this._emitSharedTypes();
880
+
881
+ if (origin === Y_ORIGIN_INITIALIZED || origin === this) return;
882
+
787
883
  if (!this._state.webSocket) return;
788
884
  if (!this._state.connection.id) return;
789
885
  if (this._state.webSocket.readyState !== WebSocket.OPEN) return;
790
886
 
791
887
  if (origin === "$STORAGE_UPDATED") return;
792
888
 
793
- const sharedTypes = this._crdtManager.doc.getSharedTypes();
794
-
795
- Object.entries(sharedTypes).forEach(([prop, sharedType]) => {
796
- if (!this._crdtManager) return;
797
-
798
- const serialized = sharedType.toJSON();
799
-
800
- this._crdtNotifier.subject(prop).next(serialized);
801
- });
802
-
803
889
  this._sendMessage({
804
890
  type: "$UPDATE_STORAGE",
805
891
  data: { origin, update },
@@ -830,14 +916,17 @@ export class PluvRoom<
830
916
  * @author leedavidcs
831
917
  * @date August 19, 2022
832
918
  */
833
- private _onClose(event: CloseEvent): void {
919
+ private async _onClose(event: CloseEvent): Promise<void> {
834
920
  this._logDebug("WebSocket closed");
835
921
  !!event.reason && this._logDebug(event.reason);
836
922
 
837
923
  this._clearInterval(this._intervals.heartbeat);
838
924
  this._clearTimeout(this._timeouts.pong);
839
925
 
926
+ this._stateNotifier.subjects["myself"].next(null);
927
+
840
928
  this._updateState((oldState) => {
929
+ oldState.authorization.token = null;
841
930
  oldState.connection.state = ConnectionState.Closed;
842
931
  oldState.webSocket = null;
843
932
 
@@ -0,0 +1,31 @@
1
+ import { AbstractStorageStore } from "./AbstractStorageStore";
2
+
3
+ export class StorageStore extends AbstractStorageStore {
4
+ public addUpdate() {
5
+ return Promise.resolve();
6
+ }
7
+
8
+ public applyUpdates() {
9
+ return Promise.resolve();
10
+ }
11
+
12
+ public destroy() {
13
+ return Promise.resolve();
14
+ }
15
+
16
+ public flatten() {
17
+ return Promise.resolve();
18
+ }
19
+
20
+ public getShouldFlatten() {
21
+ return Promise.resolve(false);
22
+ }
23
+
24
+ public getUpdates() {
25
+ return Promise.resolve([]);
26
+ }
27
+
28
+ public initialize() {
29
+ return Promise.resolve();
30
+ }
31
+ }
package/src/index.ts CHANGED
@@ -7,12 +7,18 @@ export type {
7
7
  } from "@pluv/types";
8
8
  export type { AbstractType } from "yjs";
9
9
  export { AbstractRoom } from "./AbstractRoom";
10
+ export { AbstractStorageStore } from "./AbstractStorageStore";
10
11
  export { createClient } from "./createClient";
11
12
  export type { MockedRoomConfig, MockedRoomEvents } from "./MockedRoom";
12
13
  export { MockedRoom } from "./MockedRoom";
13
14
  export type { PluvClientOptions } from "./PluvClient";
14
15
  export { PluvClient } from "./PluvClient";
15
- export type { PluvRoomDebug } from "./PluvRoom";
16
+ export type {
17
+ PluvRoomAddon,
18
+ PluvRoomAddonInput,
19
+ PluvRoomAddonResult,
20
+ PluvRoomDebug,
21
+ } from "./PluvRoom";
16
22
  export { PluvRoom } from "./PluvRoom";
17
23
  export type { WebSocketConnection, UserInfo, WebSocketState } from "./types";
18
24
  export { ConnectionState } from "./types";
@@ -0,0 +1,27 @@
1
+ export const debounce = <TFunc extends (...args: any[]) => any>(
2
+ fn: TFunc,
3
+ options?: { wait: number; immediate?: boolean }
4
+ ): ((...args: Parameters<TFunc>) => void) => {
5
+ const { wait, immediate = false } = options ?? {};
6
+
7
+ let prevResult: ReturnType<TFunc> | undefined;
8
+ let timeout: number | null = null;
9
+
10
+ return (...args: Parameters<TFunc>): void => {
11
+ if (typeof timeout === "number") clearTimeout(timeout);
12
+
13
+ timeout = setTimeout(() => {
14
+ timeout = null;
15
+
16
+ if (!immediate) prevResult = fn(args);
17
+ }, wait) as number;
18
+
19
+ if (immediate && !timeout) {
20
+ fn(args);
21
+
22
+ return;
23
+ }
24
+
25
+ return prevResult;
26
+ };
27
+ };
@@ -0,0 +1 @@
1
+ export { debounce } from "./debounce";