@pluv/client 0.0.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.
@@ -0,0 +1,993 @@
1
+ import { doc, InferYjsSharedTypeJson, YjsDoc } from "@pluv/crdt-yjs";
2
+ import type {
3
+ BaseIOEventRecord,
4
+ EventMessage,
5
+ Id,
6
+ InferEventMessage,
7
+ InferIOAuthorize,
8
+ InferIOAuthorizeRequired,
9
+ InferIOAuthorizeUser,
10
+ InferIOInput,
11
+ InferIOOutput,
12
+ IOEventMessage,
13
+ IOLike,
14
+ JsonObject,
15
+ } from "@pluv/types";
16
+ import type { AbstractType } from "yjs";
17
+ import type { CrdtManagerOptions } from "./CrdtManager";
18
+ import { CrdtManager } from "./CrdtManager";
19
+ import { CrdtNotifier } from "./CrdtNotifier";
20
+ import type { EventNotifierSubscriptionCallback } from "./EventNotifier";
21
+ import { EventNotifier } from "./EventNotifier";
22
+ import type { OtherNotifierSubscriptionCallback } from "./OtherNotifier";
23
+ import { OtherNotifier } from "./OtherNotifier";
24
+ import type {
25
+ StateNotifierSubjects,
26
+ SubscriptionCallback,
27
+ } from "./StateNotifier";
28
+ import { StateNotifier } from "./StateNotifier";
29
+ import type {
30
+ AuthorizationState,
31
+ UserInfo,
32
+ WebSocketConnection,
33
+ WebSocketState,
34
+ } from "./types";
35
+ import { ConnectionState } from "./types";
36
+ import type { UsersManagerConfig } from "./UsersManager";
37
+ import { UsersManager } from "./UsersManager";
38
+
39
+ const HEARTBEAT_INTERVAL_MS = 10_000;
40
+ const PONG_TIMEOUT_MS = 2_000;
41
+ const RECONNECT_TIMEOUT_MS = 30_000;
42
+
43
+ declare global {
44
+ var process: {
45
+ env: {
46
+ [key: string]: string | undefined;
47
+ };
48
+ };
49
+ }
50
+
51
+ interface WindowListeners {
52
+ onNavigatorOnline: () => void;
53
+ onVisibilityChange: () => void;
54
+ }
55
+
56
+ interface WebSocketListeners {
57
+ onClose: (event: CloseEvent) => void;
58
+ onError: () => void;
59
+ onMessage: (message: MessageEvent<string>) => void;
60
+ onOpen: () => void;
61
+ }
62
+
63
+ interface IntervalIds {
64
+ heartbeat: number | null;
65
+ }
66
+
67
+ interface TimeoutIds {
68
+ pong: number | null;
69
+ reconnect: number | null;
70
+ }
71
+
72
+ export type AuthEndpoint = string | ((room: string) => FetchOptions);
73
+ export type WsEndpoint = string | ((room: string) => string);
74
+
75
+ export interface RoomConfigOptions {
76
+ authEndpoint?: AuthEndpoint;
77
+ wsEndpoint: WsEndpoint;
78
+ }
79
+
80
+ type FetchOptions = string | { url: string; options?: RequestInit };
81
+
82
+ export type RoomEndpoints<TIO extends IOLike> = {
83
+ wsEndpoint: WsEndpoint;
84
+ } & (InferIOAuthorizeRequired<InferIOAuthorize<TIO>> extends true
85
+ ? { authEndpoint: AuthEndpoint }
86
+ : { authEndpoint?: AuthEndpoint });
87
+
88
+ interface InternalListeners {
89
+ onAuthorizationFail: (error: Error) => void;
90
+ }
91
+
92
+ interface InternalSubscriptions {
93
+ observeCrdt: (() => void) | null;
94
+ }
95
+
96
+ export type PluvRoomDebug<TIO extends IOLike> = Id<{
97
+ output: readonly (keyof InferIOOutput<TIO>)[];
98
+ input: readonly (keyof InferIOInput<TIO>)[];
99
+ }>;
100
+
101
+ export type PluvRoomOptions<
102
+ TIO extends IOLike,
103
+ TPresence extends JsonObject = {},
104
+ TStorage extends Record<string, AbstractType<any>> = {}
105
+ > = {
106
+ debug?: boolean | PluvRoomDebug<TIO>;
107
+ onAuthorizationFail?: (error: Error) => void;
108
+ } & CrdtManagerOptions<TStorage> &
109
+ UsersManagerConfig<TPresence>;
110
+
111
+ export type RoomConfig<
112
+ TIO extends IOLike,
113
+ TPresence extends JsonObject = {},
114
+ TStorage extends Record<string, AbstractType<any>> = {}
115
+ > = RoomEndpoints<TIO> & PluvRoomOptions<TIO, TPresence, TStorage>;
116
+
117
+ export class PluvRoom<
118
+ TIO extends IOLike,
119
+ TPresence extends JsonObject = {},
120
+ TStorage extends Record<string, AbstractType<any>> = {}
121
+ > {
122
+ readonly _endpoints: RoomEndpoints<TIO>;
123
+
124
+ public readonly room: string;
125
+
126
+ private _crdtManager: CrdtManager<TStorage> | null = null;
127
+ private _crdtNotifier = new CrdtNotifier<TStorage>();
128
+ private _debug: boolean | PluvRoomDebug<TIO>;
129
+ private _eventNotifier = new EventNotifier<TIO>();
130
+ private _initialStorage: (() => TStorage) | null = null;
131
+ private _intervals: IntervalIds = {
132
+ heartbeat: null,
133
+ };
134
+ private _listeners: InternalListeners;
135
+ private _otherNotifier = new OtherNotifier<TIO>();
136
+ private _state: WebSocketState<TIO> = {
137
+ authorization: {
138
+ token: null,
139
+ user: null,
140
+ },
141
+ connection: {
142
+ id: null,
143
+ state: ConnectionState.Untouched,
144
+ },
145
+ webSocket: null,
146
+ };
147
+ private _stateNotifier = new StateNotifier<TIO, TPresence>();
148
+ private _subscriptions: InternalSubscriptions = {
149
+ observeCrdt: null,
150
+ };
151
+ private _timeouts: TimeoutIds = {
152
+ pong: null,
153
+ reconnect: null,
154
+ };
155
+ private _windowListeners: WindowListeners | null = null;
156
+ private _wsListeners: WebSocketListeners | null = null;
157
+ private _usersManager: UsersManager<TIO, TPresence>;
158
+
159
+ constructor(room: string, options: RoomConfig<TIO, TPresence, TStorage>) {
160
+ const {
161
+ authEndpoint,
162
+ debug = false,
163
+ initialPresence,
164
+ initialStorage,
165
+ onAuthorizationFail,
166
+ presence,
167
+ wsEndpoint,
168
+ } = options;
169
+
170
+ this.room = room;
171
+ this._debug = debug;
172
+
173
+ this._endpoints = {
174
+ authEndpoint: authEndpoint as
175
+ | string
176
+ | ((room: string) => FetchOptions),
177
+ wsEndpoint,
178
+ };
179
+
180
+ this._listeners = {
181
+ onAuthorizationFail: (error) => {
182
+ this._clearTimeout(this._timeouts.reconnect);
183
+ this._timeouts.reconnect = setTimeout(
184
+ this._reconnect.bind(this),
185
+ RECONNECT_TIMEOUT_MS
186
+ );
187
+
188
+ onAuthorizationFail?.(error);
189
+ },
190
+ };
191
+
192
+ this._usersManager = new UsersManager<TIO, TPresence>({
193
+ initialPresence,
194
+ presence,
195
+ });
196
+
197
+ if (initialStorage) this._initialStorage = initialStorage;
198
+ }
199
+
200
+ public get webSocket(): WebSocket | null {
201
+ return this._state.webSocket;
202
+ }
203
+
204
+ public broadcast(message: InferEventMessage<InferIOInput<TIO>>): void {
205
+ if (!this._state.webSocket) return;
206
+ if (this._state.connection.state !== ConnectionState.Open) return;
207
+
208
+ this._sendMessage(message);
209
+ }
210
+
211
+ public async connect(): Promise<void> {
212
+ if (!this._endpoints.wsEndpoint) {
213
+ throw new Error("Must provide an wsEndpoint.");
214
+ }
215
+
216
+ const canConnect = [
217
+ ConnectionState.Closed,
218
+ ConnectionState.Untouched,
219
+ ].some((state) => this._state.connection.state === state);
220
+
221
+ if (!canConnect) return;
222
+
223
+ this._clearTimeout(this._timeouts.reconnect);
224
+
225
+ this._updateState((oldState) => {
226
+ oldState.connection.state = ConnectionState.Connecting;
227
+
228
+ return oldState;
229
+ });
230
+
231
+ const url = new URL(this._getWsEndpoint(this.room));
232
+
233
+ let authToken: string | null = null;
234
+
235
+ try {
236
+ authToken = await this._getAuthorization(this.room);
237
+ } catch (err) {
238
+ this._onAuthorizationFail(err);
239
+
240
+ return;
241
+ }
242
+
243
+ authToken &&
244
+ url.searchParams.set("token", encodeURIComponent(authToken));
245
+
246
+ let webSocket: WebSocket;
247
+
248
+ try {
249
+ webSocket = new WebSocket(url.toString());
250
+ } catch (err) {
251
+ this._onAuthorizationFail(err);
252
+
253
+ return;
254
+ }
255
+
256
+ this._updateState((oldState) => {
257
+ oldState.authorization.token = authToken;
258
+ oldState.webSocket = webSocket;
259
+
260
+ return oldState;
261
+ });
262
+
263
+ this._attachWindowListeners();
264
+ this._attachWsListeners();
265
+ }
266
+
267
+ public disconnect(): void {
268
+ if (!this._endpoints.wsEndpoint) {
269
+ throw new Error("Must provide an wsEndpoint.");
270
+ }
271
+
272
+ if (!this._state.webSocket) return;
273
+
274
+ const canDisconnect = [
275
+ ConnectionState.Connecting,
276
+ ConnectionState.Open,
277
+ ].some((state) => this._state.connection.state === state);
278
+
279
+ if (!canDisconnect) return;
280
+
281
+ this._closeWs();
282
+ this._crdtManager?.destroy();
283
+
284
+ this._updateState((oldState) => {
285
+ oldState.authorization.token = null;
286
+ oldState.connection.state = ConnectionState.Closed;
287
+ oldState.webSocket = null;
288
+
289
+ return oldState;
290
+ });
291
+ }
292
+
293
+ public event = <TEvent extends keyof InferIOOutput<TIO>>(
294
+ name: TEvent,
295
+ callback: EventNotifierSubscriptionCallback<TIO, TEvent>
296
+ ): (() => void) => {
297
+ return this._eventNotifier.subscribe(name, callback);
298
+ };
299
+
300
+ public getConnection(): WebSocketConnection {
301
+ // Create a read-only clone of the connection state
302
+ return Object.freeze(
303
+ JSON.parse(JSON.stringify(this._state.connection))
304
+ );
305
+ }
306
+
307
+ public getMyPresence = (): TPresence | null => {
308
+ return this._usersManager.myself?.presence ?? null;
309
+ };
310
+
311
+ public getMyself = (): Id<UserInfo<TIO, TPresence>> | null => {
312
+ return this._usersManager.myself;
313
+ };
314
+
315
+ public getOther = (
316
+ connectionId: string
317
+ ): Id<UserInfo<TIO, TPresence>> | null => {
318
+ return this._usersManager.getOther(connectionId);
319
+ };
320
+
321
+ public getOthers = (): readonly Id<UserInfo<TIO, TPresence>>[] => {
322
+ return this._usersManager.getOthers();
323
+ };
324
+
325
+ public getStorage = <TKey extends keyof TStorage>(
326
+ key: TKey
327
+ ): TStorage[TKey] | null => {
328
+ return this._crdtManager?.get(key) ?? null;
329
+ };
330
+
331
+ public other = (
332
+ connectionId: string,
333
+ callback: OtherNotifierSubscriptionCallback<TIO>
334
+ ): (() => void) => {
335
+ return this._otherNotifier.subscribe(connectionId, callback);
336
+ };
337
+
338
+ public storage = <TKey extends keyof TStorage>(
339
+ key: TKey,
340
+ fn: (value: InferYjsSharedTypeJson<TStorage[TKey]>) => void
341
+ ): (() => void) => {
342
+ return this._crdtNotifier.subscribe(key, fn);
343
+ };
344
+
345
+ public subscribe = <
346
+ TSubject extends keyof StateNotifierSubjects<TIO, TPresence>
347
+ >(
348
+ name: TSubject,
349
+ callback: SubscriptionCallback<TIO, TPresence, TSubject>
350
+ ): (() => void) => {
351
+ return this._stateNotifier.subscribe(name, callback);
352
+ };
353
+
354
+ private _attachWindowListeners(): void {
355
+ if (typeof window === "undefined") return;
356
+ if (typeof document === "undefined") return;
357
+
358
+ this._windowListeners = {
359
+ onNavigatorOnline: this._onNavigatorOnline.bind(this),
360
+ onVisibilityChange: this._onVisibilityChange.bind(this),
361
+ };
362
+
363
+ window.addEventListener(
364
+ "online",
365
+ this._windowListeners.onNavigatorOnline
366
+ );
367
+ document.addEventListener(
368
+ "visibilitychange",
369
+ this._windowListeners.onVisibilityChange
370
+ );
371
+ }
372
+
373
+ private _attachWsListeners(): void {
374
+ if (!this._state.webSocket) return;
375
+
376
+ this._wsListeners = {
377
+ onClose: this._onClose.bind(this),
378
+ onError: this._onError.bind(this),
379
+ onMessage: this._onMessage.bind(this),
380
+ onOpen: this._onOpen.bind(this),
381
+ };
382
+
383
+ this._state.webSocket.addEventListener(
384
+ "close",
385
+ this._wsListeners.onClose
386
+ );
387
+ this._state.webSocket.addEventListener(
388
+ "error",
389
+ this._wsListeners.onError
390
+ );
391
+ this._state.webSocket.addEventListener(
392
+ "message",
393
+ this._wsListeners.onMessage
394
+ );
395
+ this._state.webSocket.addEventListener(
396
+ "open",
397
+ this._wsListeners.onOpen
398
+ );
399
+ }
400
+
401
+ private _clearInterval(interval: number | null) {
402
+ if (Number.isNaN(interval)) return;
403
+
404
+ clearInterval(interval as number);
405
+ }
406
+
407
+ private _clearTimeout(timeout: number | null) {
408
+ if (Number.isNaN(timeout)) return;
409
+
410
+ clearTimeout(timeout as number);
411
+ }
412
+
413
+ private _closeWs(): void {
414
+ if (!this._state.webSocket) return;
415
+
416
+ this._subscriptions.observeCrdt?.();
417
+
418
+ this._clearInterval(this._intervals.heartbeat);
419
+ this._clearTimeout(this._timeouts.pong);
420
+ this._clearTimeout(this._timeouts.reconnect);
421
+
422
+ this._detachWindowListeners();
423
+ this._usersManager.removeMyself();
424
+
425
+ const canClose = [WebSocket.CONNECTING, WebSocket.OPEN].some(
426
+ (readyState) => readyState === this._state.webSocket?.readyState
427
+ );
428
+
429
+ if (canClose) this._state.webSocket.close();
430
+
431
+ this._detachWsListeners();
432
+
433
+ this._stateNotifier.subjects["my-presence"].next(null);
434
+ this._stateNotifier.subjects["myself"].next(null);
435
+ }
436
+
437
+ private _detachWindowListeners(): void {
438
+ if (!this._windowListeners) return;
439
+ if (typeof window === "undefined") return;
440
+ if (typeof document === "undefined") return;
441
+
442
+ window.removeEventListener(
443
+ "online",
444
+ this._windowListeners.onNavigatorOnline
445
+ );
446
+ document.removeEventListener(
447
+ "visibilitychange",
448
+ this._windowListeners.onVisibilityChange
449
+ );
450
+
451
+ this._windowListeners = null;
452
+ }
453
+
454
+ private _detachWsListeners(): void {
455
+ if (!this._state.webSocket || !this._wsListeners) return;
456
+
457
+ this._state.webSocket.removeEventListener(
458
+ "close",
459
+ this._wsListeners.onClose
460
+ );
461
+ this._state.webSocket.removeEventListener(
462
+ "error",
463
+ this._wsListeners.onError
464
+ );
465
+ this._state.webSocket.removeEventListener(
466
+ "message",
467
+ this._wsListeners.onMessage
468
+ );
469
+ this._state.webSocket.removeEventListener(
470
+ "open",
471
+ this._wsListeners.onOpen
472
+ );
473
+
474
+ this._wsListeners = null;
475
+ }
476
+
477
+ private async _getAuthorization(room: string): Promise<string | null> {
478
+ if (!this._endpoints.authEndpoint) return null;
479
+
480
+ if (typeof this._endpoints.authEndpoint === "string") {
481
+ const token = await fetch(this._endpoints.authEndpoint)
482
+ .then((res) => res.text())
483
+ .then((text) => text.trim());
484
+
485
+ return token;
486
+ }
487
+
488
+ const config = this._endpoints.authEndpoint(room);
489
+ const endpoint = typeof config === "string" ? config : config.url;
490
+ const options = typeof config === "string" ? undefined : config.options;
491
+
492
+ const token = await fetch(endpoint, options)
493
+ .then((res) => res.text())
494
+ .then((text) => text.trim());
495
+
496
+ return token;
497
+ }
498
+
499
+ private _getWsEndpoint(room: string): string {
500
+ if (typeof this._endpoints.wsEndpoint === "string") {
501
+ return this._endpoints.wsEndpoint;
502
+ }
503
+
504
+ return this._endpoints.wsEndpoint(room);
505
+ }
506
+
507
+ private _handleExit(message: IOEventMessage<TIO>): void {
508
+ const { connectionId } = message;
509
+
510
+ if (!connectionId) return;
511
+ // Should not reach here
512
+ if (!this._state.webSocket) throw new Error("Could not find WebSocket");
513
+
514
+ this._usersManager.removeUser(connectionId);
515
+
516
+ const others = this._usersManager.getOthers();
517
+
518
+ this._stateNotifier.subjects.others.next(others);
519
+ this._otherNotifier.subject(connectionId).next(null);
520
+ this._otherNotifier.subjects.delete(connectionId);
521
+ }
522
+
523
+ private _handlePresenceUpdatedMessage(message: IOEventMessage<TIO>): void {
524
+ const { connectionId } = message;
525
+
526
+ if (!connectionId) return;
527
+ // Should not reach here
528
+ if (!this._state.webSocket) throw new Error("Could not find WebSocket");
529
+
530
+ const data = message.data as BaseIOEventRecord<
531
+ InferIOAuthorize<TIO>
532
+ >["$PRESENCE_UPDATED"];
533
+
534
+ this._usersManager.patchPresence(
535
+ connectionId,
536
+ data.presence as TPresence
537
+ );
538
+
539
+ const myPresence = this._usersManager.myself?.presence ?? null;
540
+ const myself = this._usersManager.myself ?? null;
541
+
542
+ if (myself?.connectionId === connectionId) {
543
+ this._stateNotifier.subjects["my-presence"].next(myPresence);
544
+ this._stateNotifier.subjects["myself"].next(myself);
545
+
546
+ return;
547
+ }
548
+
549
+ const other = this._usersManager.getOther(connectionId);
550
+ const others = this._usersManager.getOthers();
551
+
552
+ this._otherNotifier.subject(connectionId).next(other);
553
+ this._stateNotifier.subjects["others"].next(others);
554
+ }
555
+
556
+ private _handleReceiveOthers(message: IOEventMessage<TIO>): void {
557
+ if (!message.connectionId) return;
558
+ // Should not reach here
559
+ if (!this._state.webSocket) throw new Error("Could not find WebSocket");
560
+
561
+ const data = message.data as BaseIOEventRecord<
562
+ InferIOAuthorize<TIO>
563
+ >["$OTHERS_RECEIVED"];
564
+
565
+ Object.keys(data.others).forEach((connectionId) => {
566
+ const { presence, user } = data.others[connectionId];
567
+
568
+ if (!user) return;
569
+
570
+ this._usersManager.setUser(connectionId, user);
571
+
572
+ const other = this._usersManager.getOther(connectionId);
573
+
574
+ !!presence &&
575
+ this._usersManager.patchPresence(
576
+ connectionId,
577
+ presence as TPresence
578
+ );
579
+
580
+ this._otherNotifier.subject(connectionId).next(other);
581
+ });
582
+
583
+ const others = this._usersManager.getOthers();
584
+
585
+ this._stateNotifier.subjects["others"].next(others);
586
+ }
587
+
588
+ private _handleRegisteredMessage(message: IOEventMessage<TIO>): void {
589
+ const { connectionId, user } = message;
590
+
591
+ if (!connectionId) return;
592
+ // Should not reach here
593
+ if (!this._state.webSocket) throw new Error("Could not find WebSocket");
594
+
595
+ this._updateState((oldState) => {
596
+ oldState.connection.id = connectionId;
597
+ oldState.authorization.user = user;
598
+
599
+ return oldState;
600
+ });
601
+
602
+ this._usersManager.setMyself(
603
+ connectionId,
604
+ user as Id<InferIOAuthorizeUser<InferIOAuthorize<TIO>>>
605
+ );
606
+
607
+ const myPresence = this._usersManager.myself?.presence ?? null;
608
+ const myself = this._usersManager.myself ?? null;
609
+
610
+ this._stateNotifier.subjects["my-presence"].next(myPresence);
611
+ this._stateNotifier.subjects["myself"].next(myself);
612
+
613
+ this._sendMessage({ type: "$GET_OTHERS", data: {} });
614
+ }
615
+
616
+ private _handleStorageReceivedMessage(message: IOEventMessage<TIO>): void {
617
+ const { connectionId } = message;
618
+
619
+ if (!connectionId) return;
620
+ // Should not reach here
621
+ if (!this._state.webSocket) throw new Error("Could not find WebSocket");
622
+
623
+ const data = message.data as BaseIOEventRecord<
624
+ InferIOAuthorize<TIO>
625
+ >["$STORAGE_RECEIVED"];
626
+
627
+ this._crdtManager = new CrdtManager<TStorage>({
628
+ encodedState: data.state,
629
+ initialStorage: this._initialStorage ?? undefined,
630
+ });
631
+
632
+ this._observeCrdt();
633
+
634
+ const sharedTypes = this._crdtManager.doc.getSharedTypes();
635
+
636
+ Object.keys(sharedTypes).forEach((key) => {
637
+ const sharedType = sharedTypes[key];
638
+ const serialized = sharedType.toJSON();
639
+
640
+ this._crdtNotifier.subject(key).next(serialized);
641
+ });
642
+
643
+ const update = this._crdtManager.doc.encodeStateAsUpdate();
644
+
645
+ this._sendMessage({
646
+ type: "$UPDATE_STORAGE",
647
+ data: { origin: "$STORAGE_RECEIVED", update },
648
+ });
649
+ }
650
+
651
+ private _handleStorageUpdatedMessage(message: IOEventMessage<TIO>): void {
652
+ const { connectionId } = message;
653
+
654
+ if (!connectionId) return;
655
+ // Should not reach here
656
+ if (!this._state.webSocket) throw new Error("Could not find WebSocket");
657
+
658
+ const data = message.data as BaseIOEventRecord<
659
+ InferIOAuthorize<TIO>
660
+ >["$STORAGE_UPDATED"];
661
+
662
+ if (!this._crdtManager) return;
663
+
664
+ const test = doc({});
665
+ test.applyUpdate(data.state);
666
+
667
+ this._crdtManager.doc.applyUpdate(data.state, "$STORAGE_UPDATED");
668
+
669
+ const sharedTypes = this._crdtManager.doc.getSharedTypes();
670
+
671
+ Object.keys(sharedTypes).forEach((key) => {
672
+ if (!this._crdtManager) return;
673
+
674
+ const sharedType = sharedTypes[key];
675
+
676
+ if (!sharedType) return;
677
+
678
+ const serialized = sharedType.toJSON();
679
+
680
+ this._crdtNotifier.subject(key).next(serialized);
681
+ });
682
+ }
683
+
684
+ private _handleUserJoinedMessage(message: IOEventMessage<TIO>): void {
685
+ const { connectionId } = message;
686
+
687
+ if (!connectionId) return;
688
+ // Should not reach here
689
+ if (!this._state.webSocket) throw new Error("Could not find WebSocket");
690
+ if (!this._usersManager.myself) return;
691
+
692
+ const data = message.data as BaseIOEventRecord<
693
+ InferIOAuthorize<TIO>
694
+ >["$USER_JOINED"];
695
+
696
+ this._usersManager.setUser(connectionId, data.user);
697
+
698
+ const other = this._usersManager.getOther(connectionId);
699
+ const others = this._usersManager.getOthers();
700
+
701
+ this._otherNotifier.subject(connectionId).next(other);
702
+ this._stateNotifier.subjects["others"].next(others);
703
+ }
704
+
705
+ private _heartbeat(): void {
706
+ if (!this._state.webSocket) return;
707
+ if (!this._state.connection.id) return;
708
+ if (this._state.webSocket.readyState !== this._state.webSocket.OPEN) {
709
+ return;
710
+ }
711
+
712
+ this._clearTimeout(this._timeouts.pong);
713
+ this._timeouts.pong = setTimeout(
714
+ this._reconnect.bind(this),
715
+ PONG_TIMEOUT_MS
716
+ );
717
+
718
+ this._sendMessage({ type: "$PING", data: {} });
719
+ }
720
+
721
+ private _logDebug(...data: any[]): void {
722
+ // eslint-disable-next-line turbo/no-undeclared-env-vars
723
+ if (process?.env?.NODE_ENV === "production") return;
724
+
725
+ this._debug && console.log(...data);
726
+ }
727
+
728
+ private _observeCrdt(): void {
729
+ this._subscriptions.observeCrdt?.();
730
+
731
+ if (!this._crdtManager) return;
732
+
733
+ const unsubscribe = this._crdtManager.doc.subscribe(
734
+ (update, _origin) => {
735
+ const origin = _origin ?? null;
736
+
737
+ if (!this._crdtManager) return;
738
+ if (!this._state.webSocket) return;
739
+ if (!this._state.connection.id) return;
740
+ if (this._state.webSocket.readyState !== WebSocket.OPEN) return;
741
+
742
+ if (origin === "$STORAGE_UPDATED") return;
743
+
744
+ this._sendMessage({
745
+ type: "$UPDATE_STORAGE",
746
+ data: { origin, update },
747
+ });
748
+ }
749
+ );
750
+
751
+ this._subscriptions.observeCrdt = unsubscribe;
752
+ }
753
+
754
+ private _onAuthorizationFail(error: unknown): void {
755
+ const authError =
756
+ error instanceof Error ? error : new Error("Authorization failed");
757
+
758
+ this._listeners.onAuthorizationFail(authError);
759
+
760
+ this._updateState((oldState) => {
761
+ oldState.connection.state = ConnectionState.Unavailable;
762
+
763
+ return oldState;
764
+ });
765
+ }
766
+
767
+ /**
768
+ * TODO
769
+ * @description Determine if ws was closed with a code for closing without retries.
770
+ * Otherwise, attempt to retry again with incremental backoff
771
+ * @author leedavidcs
772
+ * @date August 19, 2022
773
+ */
774
+ private _onClose(event: CloseEvent): void {
775
+ this._logDebug("WebSocket closed");
776
+ !!event.reason && this._logDebug(event.reason);
777
+
778
+ this._clearInterval(this._intervals.heartbeat);
779
+ this._clearTimeout(this._timeouts.pong);
780
+
781
+ this._updateState((oldState) => {
782
+ oldState.connection.state = ConnectionState.Closed;
783
+ oldState.webSocket = null;
784
+
785
+ return oldState;
786
+ });
787
+ }
788
+
789
+ /**
790
+ * TODO
791
+ * @description Handle websocket errors in a meaningful way, should they occur
792
+ * @author leedavidcs
793
+ * @date August 19, 2022
794
+ */
795
+ private _onError(): void {}
796
+
797
+ private _onMessage(event: MessageEvent<string>): void {
798
+ const message = this._parseMessage(event);
799
+
800
+ if (!message) return;
801
+
802
+ const shouldLog =
803
+ typeof this._debug === "boolean"
804
+ ? this._debug
805
+ : this._debug.output.find((value) => value === message.type);
806
+
807
+ if (shouldLog) {
808
+ this._logDebug("WebSocket event received: ", message.type, message);
809
+ }
810
+
811
+ this._eventNotifier
812
+ .subject(message.type as keyof InferIOOutput<TIO>)
813
+ .next(message as any);
814
+
815
+ const { connectionId } = message;
816
+
817
+ if (!connectionId) return;
818
+
819
+ switch (message.type) {
820
+ case "$EXIT": {
821
+ this._handleExit(message);
822
+ return;
823
+ }
824
+ case "$OTHERS_RECEIVED": {
825
+ this._handleReceiveOthers(message);
826
+ return;
827
+ }
828
+ case "$PONG": {
829
+ this._clearTimeout(this._timeouts.pong);
830
+ return;
831
+ }
832
+ case "$PRESENCE_UPDATED": {
833
+ this._handlePresenceUpdatedMessage(message);
834
+ return;
835
+ }
836
+ case "$REGISTERED": {
837
+ this._handleRegisteredMessage(message);
838
+ return;
839
+ }
840
+ case "$STORAGE_RECEIVED": {
841
+ this._handleStorageReceivedMessage(message);
842
+ return;
843
+ }
844
+ case "$STORAGE_UPDATED": {
845
+ this._handleStorageUpdatedMessage(message);
846
+ return;
847
+ }
848
+ case "$USER_JOINED": {
849
+ this._handleUserJoinedMessage(message);
850
+ return;
851
+ }
852
+ default:
853
+ }
854
+ }
855
+
856
+ private _onOpen(): void {
857
+ if (this._state.connection.state !== ConnectionState.Connecting) return;
858
+
859
+ this._logDebug("WebSocket connected");
860
+
861
+ this._updateState((oldState) => {
862
+ oldState.connection.state = ConnectionState.Open;
863
+
864
+ return oldState;
865
+ });
866
+
867
+ this._clearInterval(this._intervals.heartbeat);
868
+ this._intervals.heartbeat = setInterval(
869
+ this._heartbeat.bind(this),
870
+ HEARTBEAT_INTERVAL_MS
871
+ );
872
+ }
873
+
874
+ private async _onNavigatorOnline(): Promise<void> {
875
+ if (this._state.connection.state !== ConnectionState.Unavailable) {
876
+ return;
877
+ }
878
+
879
+ await this._reconnect();
880
+ }
881
+
882
+ private async _onVisibilityChange(): Promise<void> {
883
+ if (typeof document === "undefined") return;
884
+ if (document.visibilityState !== "visible") return;
885
+
886
+ switch (this._state.connection.state) {
887
+ case ConnectionState.Open:
888
+ this._clearInterval(this._intervals.heartbeat);
889
+ setInterval(this._heartbeat.bind(this), HEARTBEAT_INTERVAL_MS);
890
+
891
+ return;
892
+ case ConnectionState.Unavailable:
893
+ await this._reconnect();
894
+
895
+ return;
896
+ default:
897
+ }
898
+ }
899
+
900
+ private _parseMessage(message: {
901
+ data: string | ArrayBuffer;
902
+ }): IOEventMessage<TIO> | null {
903
+ /**
904
+ * !HACK
905
+ * @description We'll only handle stringified JSONs for now
906
+ * @author leedavidcs
907
+ * @date August 9, 2022
908
+ */
909
+ if (typeof message.data !== "string") return null;
910
+
911
+ try {
912
+ const config = JSON.parse(message.data);
913
+
914
+ if (typeof config !== "object") return null;
915
+ if (typeof config.type !== "string") return null;
916
+ if (typeof config.data !== "object") return null;
917
+
918
+ return config as IOEventMessage<TIO>;
919
+ } catch {
920
+ return null;
921
+ }
922
+ }
923
+
924
+ private async _reconnect(): Promise<void> {
925
+ if (!this._endpoints.wsEndpoint) {
926
+ throw new Error("Must provide an wsEndpoint.");
927
+ }
928
+
929
+ if (!this._state.webSocket) return;
930
+
931
+ this._closeWs();
932
+
933
+ this._updateState((oldState) => {
934
+ oldState.authorization.token = null;
935
+ oldState.connection.state = ConnectionState.Unavailable;
936
+ oldState.webSocket = null;
937
+
938
+ return oldState;
939
+ });
940
+
941
+ await this.connect();
942
+ }
943
+
944
+ private _sendMessage<
945
+ TMessage extends EventMessage<string, any> = EventMessage<string, any>
946
+ >(data: TMessage): void {
947
+ const webSocket = this._state.webSocket;
948
+
949
+ if (webSocket?.readyState !== WebSocket.OPEN) return;
950
+
951
+ const shouldLog =
952
+ typeof this._debug === "boolean"
953
+ ? this._debug
954
+ : this._debug.input.find((value) => value === data.type);
955
+
956
+ if (shouldLog) {
957
+ this._logDebug("WebSocket event sent: ", data.type, data);
958
+ }
959
+
960
+ webSocket.send(JSON.stringify(data));
961
+ }
962
+
963
+ private _updateState(
964
+ updater: (oldState: WebSocketState<TIO>) => WebSocketState<TIO>
965
+ ): WebSocketState<TIO> {
966
+ let authorization: AuthorizationState<TIO>;
967
+
968
+ try {
969
+ authorization = JSON.parse(
970
+ JSON.stringify(this._state.authorization)
971
+ );
972
+ } catch {
973
+ throw new Error("User is not JSON serializable");
974
+ }
975
+
976
+ const oldState: WebSocketState<TIO> = {
977
+ ...this._state,
978
+ authorization,
979
+ connection: JSON.parse(JSON.stringify(this._state.connection)),
980
+ webSocket: this._state.webSocket,
981
+ };
982
+
983
+ const newState = updater(oldState);
984
+
985
+ this._logDebug("WebSocket new state: ", newState);
986
+
987
+ this._state = newState;
988
+
989
+ this._stateNotifier.subjects.connection.next(newState);
990
+
991
+ return newState;
992
+ }
993
+ }