@pluv/client 0.0.0-experimental-20250527040415

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,1627 @@
1
+ import type {
2
+ AbstractCrdtDocFactory,
3
+ InferCrdtJson,
4
+ InferDoc,
5
+ InferDocLike,
6
+ InferStorage,
7
+ NoopCrdtDocFactory,
8
+ } from "@pluv/crdt";
9
+ import type {
10
+ BaseIOEventRecord,
11
+ BaseUser,
12
+ BroadcastProxy,
13
+ CrdtDocLike,
14
+ EventMessage,
15
+ EventNotifierSubscriptionCallback,
16
+ EventProxy,
17
+ IOEventMessage,
18
+ IOLike,
19
+ Id,
20
+ InferIOAuthorize,
21
+ InferIOAuthorizeUser,
22
+ InferIOInput,
23
+ InferIOOutput,
24
+ InputZodLike,
25
+ JsonObject,
26
+ MergeEvents,
27
+ OptionalProps,
28
+ OtherSubscriptionCallback,
29
+ OthersSubscriptionCallback,
30
+ RoomEventListenerMap,
31
+ RoomLike,
32
+ StateNotifierSubjects,
33
+ StorageProxy,
34
+ StorageRootSubscriptionCallback,
35
+ StorageSubscriptionCallback,
36
+ SubscribeProxy,
37
+ SubscriptionCallback,
38
+ UpdateMyPresenceAction,
39
+ UserInfo,
40
+ WebSocketConnection,
41
+ WebSocketState,
42
+ } from "@pluv/types";
43
+ import { ConnectionState, StorageState } from "@pluv/types";
44
+ import type { AbstractStorageStore } from "./AbstractStorageStore";
45
+ import type { CrdtManagerOptions } from "./CrdtManager";
46
+ import { CrdtManager } from "./CrdtManager";
47
+ import { CrdtNotifier } from "./CrdtNotifier";
48
+ import { EventNotifier } from "./EventNotifier";
49
+ import { ListenerManager } from "./ListenerManager";
50
+ import { PluvProcedure } from "./PluvProcedure";
51
+ import type { PluvRouterEventConfig } from "./PluvRouter";
52
+ import { PluvRouter } from "./PluvRouter";
53
+ import { StateNotifier } from "./StateNotifier";
54
+ import { StorageStore } from "./StorageStore";
55
+ import type {
56
+ AuthorizationState,
57
+ EventResolver,
58
+ EventResolverContext,
59
+ InternalSubscriptions,
60
+ PluvClientLimits,
61
+ PublicKey,
62
+ WithMetadata,
63
+ } from "./types";
64
+ import type { UsersManagerConfig } from "./UsersManager";
65
+ import { UsersManager } from "./UsersManager";
66
+ import { UsersNotifier } from "./UsersNotifier";
67
+ import { debounce } from "./utils";
68
+
69
+ const ADD_TO_STORAGE_STATE_DEBOUNCE_MS = 1_000;
70
+ const HEARTBEAT_INTERVAL_MS = 10_000;
71
+ const PONG_TIMEOUT_MS = 2_000;
72
+
73
+ const RECONNECT_TIMEOUT_MS = 5_000;
74
+ const MIN_RECONNECT_TIMEOUT_MS = 1_000;
75
+ const MAX_RECONNECT_TIMEOUT_MS = 60_000;
76
+
77
+ const ORIGIN_INITIALIZED = "$initialized";
78
+ const ORIGIN_STORAGE_UPDATED = "$storageUpdated";
79
+
80
+ declare global {
81
+ // eslint-disable-next-line no-var
82
+ var process: {
83
+ env: {
84
+ [key: string]: string | undefined;
85
+ };
86
+ };
87
+ }
88
+
89
+ export const DEFAULT_PLUV_CLIENT_ADDON = <
90
+ TIO extends IOLike,
91
+ TMetadata extends JsonObject,
92
+ TPresence extends Record<string, any>,
93
+ TCrdt extends AbstractCrdtDocFactory<any, any>,
94
+ >(
95
+ input: PluvRoomAddonInput<TIO, TMetadata, TPresence, TCrdt>,
96
+ ): PluvRoomAddonResult => ({
97
+ storage: new StorageStore(input.room.id),
98
+ });
99
+
100
+ interface WindowListeners {
101
+ onNavigatorOffline: () => void;
102
+ onNavigatorOnline: () => void;
103
+ onVisibilityChange: () => void;
104
+ onWindowFocus: () => void;
105
+ }
106
+
107
+ interface WebSocketListeners {
108
+ onClose: (event: CloseEvent) => void;
109
+ onError: (event: Event) => void;
110
+ onMessage: (message: MessageEvent<string>) => void;
111
+ onOpen: (event: Event) => void;
112
+ }
113
+
114
+ interface IntervalIds {
115
+ heartbeat: number | null;
116
+ }
117
+
118
+ interface TimeoutIds {
119
+ pong: number | null;
120
+ reconnect: number | null;
121
+ }
122
+
123
+ export interface EndpointParams<TMetadata extends JsonObject> {
124
+ metadata: TMetadata;
125
+ room: string;
126
+ }
127
+ export type AuthEndpoint<TMetadata extends JsonObject> =
128
+ | string
129
+ | ((params: EndpointParams<TMetadata>) => string | FetchOptions)
130
+ | true;
131
+ export type WsEndpoint<TMetadata extends JsonObject> =
132
+ | string
133
+ | ((params: EndpointParams<TMetadata>) => string);
134
+
135
+ type FetchOptions = { url: string; options?: RequestInit };
136
+
137
+ export type RoomEndpoints<TIO extends IOLike<any, any>, TMetadata extends JsonObject> = {
138
+ wsEndpoint?: WsEndpoint<TMetadata>;
139
+ } & (InferIOAuthorizeUser<InferIOAuthorize<TIO>> extends BaseUser
140
+ ? { authEndpoint: AuthEndpoint<TMetadata> }
141
+ : { authEndpoint?: undefined });
142
+
143
+ interface InternalListeners {
144
+ onAuthorizationFail: (error: Error) => void;
145
+ }
146
+
147
+ export type PluvRoomAddon<
148
+ TIO extends IOLike,
149
+ TMetadata extends JsonObject,
150
+ TPresence extends Record<string, any>,
151
+ TCrdt extends AbstractCrdtDocFactory<any, any>,
152
+ > = (input: PluvRoomAddonInput<TIO, TMetadata, TPresence, TCrdt>) => Partial<PluvRoomAddonResult>;
153
+
154
+ export interface PluvRoomAddonInput<
155
+ TIO extends IOLike,
156
+ TMetadata extends JsonObject,
157
+ TPresence extends Record<string, any>,
158
+ TCrdt extends AbstractCrdtDocFactory<any, any>,
159
+ > {
160
+ room: PluvRoom<TIO, TMetadata, TPresence, TCrdt>;
161
+ }
162
+
163
+ export interface PluvRoomAddonResult {
164
+ storage: AbstractStorageStore;
165
+ }
166
+
167
+ export type PluvRoomDebug<TIO extends IOLike> = Id<{
168
+ output: readonly (keyof InferIOOutput<TIO>)[];
169
+ input: readonly (keyof InferIOInput<TIO>)[];
170
+ }>;
171
+
172
+ type GetPublickKeyParams<TMetadata extends JsonObject = {}> = WithMetadata<TMetadata>;
173
+ type GetAuthEndpointParams<TMetadata extends JsonObject = {}> = WithMetadata<TMetadata>;
174
+ type GetWsEndpointParams<TMetadata extends JsonObject = {}> = WithMetadata<TMetadata>;
175
+
176
+ export type RoomConnectParams<TMetadata extends JsonObject = {}> = keyof TMetadata extends never
177
+ ? []
178
+ : [WithMetadata<TMetadata>];
179
+
180
+ export interface ReconnectTimeoutMsParams {
181
+ attempts: number;
182
+ }
183
+ export type ReconnectTimeoutMs = number | ((params: ReconnectTimeoutMsParams) => number);
184
+
185
+ export type RoomConfig<
186
+ TIO extends IOLike,
187
+ TMetadata extends JsonObject,
188
+ TPresence extends Record<string, any>,
189
+ TCrdt extends AbstractCrdtDocFactory<any, any>,
190
+ TEvents extends PluvRouterEventConfig<TIO, TPresence, InferStorage<TCrdt>>,
191
+ > = Id<
192
+ {
193
+ addons?: readonly PluvRoomAddon<TIO, TMetadata, TPresence, TCrdt>[];
194
+ debug?: boolean | PluvRoomDebug<TIO>;
195
+ limits: PluvClientLimits;
196
+ onAuthorizationFail?: (error: Error) => void;
197
+ metadata?: InputZodLike<TMetadata>;
198
+ publicKey?: PublicKey<TMetadata>;
199
+ reconnectTimeoutMs?: ReconnectTimeoutMs;
200
+ router?: PluvRouter<TIO, TPresence, InferStorage<TCrdt>, TEvents>;
201
+ } & RoomEndpoints<TIO, TMetadata> &
202
+ Pick<CrdtManagerOptions<TCrdt>, "initialStorage"> &
203
+ UsersManagerConfig<TPresence>
204
+ >;
205
+
206
+ export class PluvRoom<
207
+ TIO extends IOLike<any, any>,
208
+ TMetadata extends JsonObject = {},
209
+ TPresence extends Record<string, any> = {},
210
+ TCrdt extends AbstractCrdtDocFactory<any, any> = NoopCrdtDocFactory,
211
+ TEvents extends PluvRouterEventConfig<TIO, TPresence, InferStorage<TCrdt>> = {},
212
+ > implements RoomLike<TIO, InferDoc<TCrdt>, TPresence, InferStorage<TCrdt>>
213
+ {
214
+ readonly _endpoints: RoomEndpoints<TIO, TMetadata>;
215
+
216
+ public readonly id: string;
217
+ public readonly metadata?: InputZodLike<TMetadata>;
218
+
219
+ private readonly _crdtManager: CrdtManager<TCrdt>;
220
+ private readonly _crdtNotifier = new CrdtNotifier<InferStorage<TCrdt>>();
221
+ private readonly _debug: boolean | PluvRoomDebug<TIO>;
222
+ private readonly _eventNotifier = new EventNotifier<MergeEvents<TEvents, TIO>>();
223
+ private readonly _intervals: IntervalIds = {
224
+ heartbeat: null,
225
+ };
226
+ private readonly _limits: PluvClientLimits;
227
+ private readonly _listenerManager = new ListenerManager();
228
+ private readonly _listeners: InternalListeners;
229
+ private readonly _publicKey: PublicKey<TMetadata> | null = null;
230
+ private readonly _reconnectTimeoutMs: ReconnectTimeoutMs;
231
+ private readonly _router: PluvRouter<TIO, TPresence, InferStorage<TCrdt>, TEvents>;
232
+ private readonly _stateNotifier = new StateNotifier<TIO, TPresence>();
233
+ private readonly _storageStore: AbstractStorageStore;
234
+ private readonly _subscriptions: InternalSubscriptions = {
235
+ observeCrdt: null,
236
+ };
237
+ private readonly _timeouts: TimeoutIds = {
238
+ pong: null,
239
+ reconnect: null,
240
+ };
241
+ private readonly _usersManager: UsersManager<TIO, TPresence>;
242
+ private readonly _usersNotifier = new UsersNotifier<TIO, TPresence>();
243
+
244
+ private _lastMetadata: TMetadata | null = null;
245
+ private _state: WebSocketState<TIO> = {
246
+ authorization: {
247
+ token: null,
248
+ user: null,
249
+ },
250
+ connection: {
251
+ attempts: 0,
252
+ id: null,
253
+ state: ConnectionState.Untouched,
254
+ },
255
+ storage: {
256
+ state: StorageState.Unavailable,
257
+ },
258
+ webSocket: null,
259
+ };
260
+ private _windowListeners: WindowListeners | null = null;
261
+ private _wsListeners: WebSocketListeners | null = null;
262
+
263
+ constructor(room: string, options: RoomConfig<TIO, TMetadata, TPresence, TCrdt, TEvents>) {
264
+ const {
265
+ addons = [],
266
+ authEndpoint,
267
+ debug = false,
268
+ initialPresence,
269
+ initialStorage,
270
+ limits,
271
+ metadata,
272
+ onAuthorizationFail,
273
+ presence,
274
+ publicKey,
275
+ reconnectTimeoutMs = RECONNECT_TIMEOUT_MS,
276
+ router,
277
+ wsEndpoint,
278
+ } = options;
279
+
280
+ this.id = room;
281
+ this.metadata = metadata;
282
+
283
+ const addon = this._getAddon(addons);
284
+ const { storage } = {
285
+ ...DEFAULT_PLUV_CLIENT_ADDON({ room: this }),
286
+ ...addon({ room: this }),
287
+ };
288
+ this._storageStore = storage;
289
+
290
+ this._debug = debug;
291
+ this._endpoints = { authEndpoint, wsEndpoint } as RoomEndpoints<TIO, TMetadata>;
292
+ this._limits = limits;
293
+ this._reconnectTimeoutMs = reconnectTimeoutMs;
294
+
295
+ if (!!publicKey) this._publicKey = publicKey;
296
+
297
+ this._listeners = {
298
+ onAuthorizationFail: (error) => {
299
+ onAuthorizationFail?.(error);
300
+ },
301
+ };
302
+
303
+ this._router =
304
+ router ??
305
+ (new PluvRouter({}) as PluvRouter<TIO, TPresence, InferStorage<TCrdt>, TEvents>);
306
+ this._usersManager = new UsersManager<TIO, TPresence>({
307
+ initialPresence,
308
+ limits: this._limits,
309
+ presence,
310
+ });
311
+ this._crdtManager = new CrdtManager<TCrdt>({ initialStorage });
312
+ }
313
+
314
+ /**
315
+ * @deprecated To be removed in v3. Use `getStorageLoaded` instead.
316
+ */
317
+ public get storageLoaded(): boolean {
318
+ return this.getStorageLoaded();
319
+ }
320
+
321
+ public get webSocket(): WebSocket | null {
322
+ return this._state.webSocket;
323
+ }
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
+
332
+ public broadcast = new Proxy(
333
+ async <TEvent extends keyof InferIOInput<MergeEvents<TEvents, TIO>>>(
334
+ event: TEvent,
335
+ data: Id<InferIOInput<MergeEvents<TEvents, TIO>>[TEvent]>,
336
+ ): Promise<void> => {
337
+ if (!this._state.webSocket) return;
338
+ if (this._state.connection.state !== ConnectionState.Open) return;
339
+
340
+ const type = event.toString();
341
+
342
+ const procedure = this._router._defs.events[type] as PluvProcedure<
343
+ TIO,
344
+ any,
345
+ any,
346
+ TPresence,
347
+ TCrdt,
348
+ ""
349
+ > | null;
350
+
351
+ if (!procedure?.config.broadcast) {
352
+ this._sendMessage({ data, type });
353
+
354
+ return;
355
+ }
356
+
357
+ const myself = this._usersManager.myself;
358
+
359
+ if (!myself) return;
360
+
361
+ const parsed = procedure.config.input ? procedure.config.input.parse(data) : data;
362
+ const context: EventResolverContext<TIO, TPresence, InferDocLike<TCrdt>> = {
363
+ doc: this._crdtManager.doc,
364
+ others: this._usersManager.getOthers(),
365
+ room: this.id,
366
+ user: myself,
367
+ };
368
+
369
+ const output = await (
370
+ procedure.config.broadcast as EventResolver<
371
+ TIO,
372
+ any,
373
+ any,
374
+ TPresence,
375
+ InferStorage<TCrdt>
376
+ >
377
+ )(parsed, context);
378
+
379
+ Object.entries(output).forEach(([_type, _data]) => {
380
+ this._sendMessage({ data: _data, type: _type });
381
+ });
382
+ },
383
+ {
384
+ get(fn, prop) {
385
+ return async (
386
+ data: Id<InferIOInput<MergeEvents<TEvents, TIO>>[any]>,
387
+ ): Promise<void> => {
388
+ return await fn(prop, data);
389
+ };
390
+ },
391
+ },
392
+ ) as BroadcastProxy<TIO, TEvents>;
393
+
394
+ public canRedo = (): boolean => {
395
+ return !!this._crdtManager.doc.canRedo();
396
+ };
397
+
398
+ public canUndo = (): boolean => {
399
+ return !!this._crdtManager.doc.canUndo();
400
+ };
401
+
402
+ public async connect(...args: RoomConnectParams<TMetadata>): Promise<void> {
403
+ const params = (args[0] ?? {}) as WithMetadata<TMetadata>;
404
+ const metadata = params.metadata as TMetadata;
405
+
406
+ this._setMetadata(metadata);
407
+
408
+ const canConnect = [
409
+ ConnectionState.Closed,
410
+ ConnectionState.Unavailable,
411
+ ConnectionState.Untouched,
412
+ ].some((state) => this._state.connection.state === state);
413
+
414
+ if (!canConnect) return;
415
+
416
+ this._clearTimeout(this._timeouts.reconnect);
417
+ this._updateState((oldState) => {
418
+ oldState.connection.state = ConnectionState.Connecting;
419
+
420
+ return oldState;
421
+ });
422
+
423
+ await this._storageStore.initialize();
424
+
425
+ const wsEndpoint = this._getWsEndpoint(this.id, params);
426
+ const url = new URL(wsEndpoint);
427
+
428
+ let authToken: string | null = null;
429
+ let webSocket: WebSocket;
430
+
431
+ try {
432
+ const publicKey = this._getPublicKey(params);
433
+
434
+ authToken = await this._getAuthorization(this.id, params);
435
+
436
+ if (authToken) url.searchParams.set("token", encodeURIComponent(authToken));
437
+ if (publicKey) url.searchParams.set("public_key", encodeURIComponent(publicKey));
438
+
439
+ webSocket = new WebSocket(url.toString());
440
+ } catch (err) {
441
+ this._updateState((oldState) => {
442
+ oldState.connection.attempts += 1;
443
+
444
+ return oldState;
445
+ });
446
+
447
+ await this._storageStore.destroy();
448
+ this._pollReconnect();
449
+ await Promise.resolve(this._onAuthorizationFail(err));
450
+
451
+ return;
452
+ }
453
+
454
+ this._updateState((oldState) => {
455
+ oldState.connection.attempts = 0;
456
+ oldState.authorization.token = authToken;
457
+ oldState.storage.state = StorageState.Loading;
458
+ oldState.webSocket = webSocket;
459
+
460
+ return oldState;
461
+ });
462
+
463
+ this._attachWindowListeners();
464
+ this._attachWsListeners();
465
+ }
466
+
467
+ public async disconnect(): Promise<void> {
468
+ if (!this._state.webSocket) return;
469
+
470
+ const canDisconnect = [ConnectionState.Connecting, ConnectionState.Open].some(
471
+ (state) => this._state.connection.state === state,
472
+ );
473
+
474
+ if (!canDisconnect) return;
475
+
476
+ this._closeWs();
477
+ this._clearInterval(this._intervals.heartbeat);
478
+ this._clearTimeout(this._timeouts.reconnect);
479
+ this._updateState((oldState) => {
480
+ oldState.authorization.token = null;
481
+ oldState.connection.state = ConnectionState.Closed;
482
+ oldState.storage.state =
483
+ oldState.storage.state === StorageState.Synced
484
+ ? StorageState.Offline
485
+ : StorageState.Unavailable;
486
+ oldState.webSocket = null;
487
+
488
+ return oldState;
489
+ });
490
+
491
+ await this._storageStore.destroy();
492
+ this._crdtManager.destroy();
493
+ this._stateNotifier.subjects["my-presence"].next(null);
494
+ }
495
+
496
+ /**
497
+ * @deprecated To be removed in v3. Use PluvRoom.subscribe.event instead
498
+ */
499
+ public event = new Proxy(
500
+ <TEvent extends keyof InferIOOutput<MergeEvents<TEvents, TIO>>>(
501
+ event: TEvent,
502
+ callback: EventNotifierSubscriptionCallback<MergeEvents<TEvents, TIO>, any>,
503
+ ): (() => void) => this._eventNotifier.subscribe(event, callback),
504
+ {
505
+ get(fn, prop) {
506
+ return (
507
+ callback: EventNotifierSubscriptionCallback<MergeEvents<TEvents, TIO>, any>,
508
+ ): (() => void) => fn(prop as any, callback);
509
+ },
510
+ },
511
+ ) as EventProxy<TIO, TEvents>;
512
+
513
+ public getConnection = (): WebSocketConnection => {
514
+ // Create a read-only clone of the connection state
515
+ return Object.freeze(JSON.parse(JSON.stringify(this._state.connection)));
516
+ };
517
+
518
+ public getDoc = (): CrdtDocLike<InferDoc<TCrdt>, InferStorage<TCrdt>> => {
519
+ return this._crdtManager.doc;
520
+ };
521
+
522
+ public getMyPresence = (): TPresence => {
523
+ return this._usersManager.myPresence;
524
+ };
525
+
526
+ public getMyself = (): Id<UserInfo<TIO, TPresence>> | null => {
527
+ return this._usersManager.myself;
528
+ };
529
+
530
+ public getOther = (connectionId: string): Id<UserInfo<TIO, TPresence>> | null => {
531
+ return this._usersManager.getOther(connectionId);
532
+ };
533
+
534
+ public getOthers = (): readonly Id<UserInfo<TIO, TPresence>>[] => {
535
+ return this._usersManager.getOthers();
536
+ };
537
+
538
+ public getStorage = <TKey extends keyof InferStorage<TCrdt>>(
539
+ type: TKey,
540
+ ): InferStorage<TCrdt>[TKey] | null => {
541
+ const sharedType = this._crdtManager.get(type);
542
+
543
+ if (typeof sharedType === "undefined") return null;
544
+
545
+ return sharedType;
546
+ };
547
+
548
+ public getStorageJson(): InferCrdtJson<InferStorage<TCrdt>> | null;
549
+ public getStorageJson<TKey extends keyof InferStorage<TCrdt>>(
550
+ type: TKey,
551
+ ): InferCrdtJson<InferStorage<TCrdt>[TKey]> | null;
552
+ public getStorageJson<TKey extends keyof InferStorage<TCrdt>>(type?: TKey) {
553
+ if (this._state.connection.id === null) return null;
554
+ if (typeof type === "undefined") return this._crdtManager.doc.toJson();
555
+
556
+ const sharedType = this._crdtManager.get(type);
557
+
558
+ if (typeof sharedType === "undefined") return null;
559
+
560
+ return this._crdtManager.doc.toJson(type);
561
+ }
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
+ */
573
+ public other = (
574
+ connectionId: string,
575
+ callback: OtherSubscriptionCallback<TIO, TPresence>,
576
+ ): (() => void) => {
577
+ const clientId = this._usersManager.getClientId(connectionId);
578
+
579
+ if (!clientId) return () => undefined;
580
+
581
+ return this._usersNotifier.subscribeOther(clientId, callback);
582
+ };
583
+
584
+ public redo = (): void => {
585
+ this._crdtManager.doc.redo();
586
+ };
587
+
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>>;
612
+
613
+ /**
614
+ * @deprecated To be removed in v3. Use PluvRoom.subscribe.storage instead
615
+ * @description Subscribes to the root storage object (serialized)
616
+ */
617
+ public storageRoot = (
618
+ callback: (value: {
619
+ [P in keyof InferStorage<TCrdt>]: InferCrdtJson<InferStorage<TCrdt>[P]>;
620
+ }) => void,
621
+ ): (() => void) => {
622
+ return this._crdtNotifier.subcribeRoot(callback);
623
+ };
624
+
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>;
668
+
669
+ public transact = (fn: (storage: InferStorage<TCrdt>) => void, origin?: string): void => {
670
+ const _origin = origin ?? this._state.connection.id;
671
+
672
+ /**
673
+ * !HACK
674
+ * @description Don't transact anything if there is no origin, because
675
+ * that means the user isn't connected yet. This will mean events are
676
+ * lost unfortunately.
677
+ * @date September 23, 2023
678
+ */
679
+ if (typeof _origin !== "string") return;
680
+
681
+ this._crdtManager.doc.transact(() => {
682
+ const storage = this._crdtManager.doc.get();
683
+
684
+ fn(storage);
685
+ }, _origin);
686
+ };
687
+
688
+ public undo = (): void => {
689
+ this._crdtManager.doc.undo();
690
+ };
691
+
692
+ public updateMyPresence = (presence: UpdateMyPresenceAction<TPresence>): void => {
693
+ const newPresence =
694
+ typeof presence === "function" ? presence(this.getMyPresence()) : presence;
695
+
696
+ this._usersManager.updateMyPresence(newPresence);
697
+
698
+ const myPresence = this._usersManager.myPresence;
699
+ const myself = this._usersManager.myself ?? null;
700
+
701
+ this._stateNotifier.subjects["my-presence"].next(myPresence);
702
+ if (!!myself) this._stateNotifier.subjects.myself.next(myself);
703
+
704
+ this.broadcast(
705
+ "$updatePresence" as keyof InferIOInput<MergeEvents<TEvents, TIO>>,
706
+ { presence: newPresence } as any,
707
+ );
708
+ };
709
+
710
+ private async _addToStorageStore(update: string): Promise<void> {
711
+ await this._storageStore.addUpdate(update);
712
+
713
+ this._flattenStorageStore();
714
+ }
715
+
716
+ private _attachWindowListeners(): void {
717
+ if (typeof window === "undefined") return;
718
+ if (typeof document === "undefined") return;
719
+
720
+ this._windowListeners = this._windowListeners || {
721
+ onNavigatorOffline: this._onNavigatorOffline.bind(this),
722
+ onNavigatorOnline: this._onNavigatorOnline.bind(this),
723
+ onVisibilityChange: this._onVisibilityChange.bind(this),
724
+ onWindowFocus: this._onWindowFocus.bind(this),
725
+ };
726
+
727
+ window.addEventListener("offline", this._windowListeners.onNavigatorOffline);
728
+ window.addEventListener("online", this._windowListeners.onNavigatorOnline);
729
+ document.addEventListener("visibilitychange", this._windowListeners.onVisibilityChange);
730
+ window.addEventListener("focus", this._windowListeners.onWindowFocus);
731
+ }
732
+
733
+ private _attachWsListeners(): void {
734
+ if (!this._state.webSocket) return;
735
+
736
+ this._wsListeners = this._wsListeners || {
737
+ onClose: this._onClose.bind(this),
738
+ onError: this._onError.bind(this),
739
+ onMessage: this._onMessage.bind(this),
740
+ onOpen: this._onOpen.bind(this),
741
+ };
742
+
743
+ this._state.webSocket.addEventListener("close", this._wsListeners.onClose);
744
+ this._state.webSocket.addEventListener("error", this._wsListeners.onError);
745
+ this._state.webSocket.addEventListener("message", this._wsListeners.onMessage);
746
+ this._state.webSocket.addEventListener("open", this._wsListeners.onOpen);
747
+ }
748
+
749
+ private _clearInterval(interval: number | null) {
750
+ if (Number.isNaN(interval)) return;
751
+
752
+ clearInterval(interval as number);
753
+ }
754
+
755
+ private _clearTimeout(timeout: number | null) {
756
+ if (Number.isNaN(timeout)) return;
757
+
758
+ clearTimeout(timeout as number);
759
+ }
760
+
761
+ private _closeWs(): void {
762
+ this._subscriptions.observeCrdt?.();
763
+
764
+ this._clearInterval(this._intervals.heartbeat);
765
+ this._clearTimeout(this._timeouts.pong);
766
+ this._detachWindowListeners();
767
+ this._usersManager.removeMyself();
768
+ this._usersManager.clearConnections();
769
+ this._usersNotifier.clear();
770
+ this._stateNotifier.subjects.myself.next(null);
771
+ this._stateNotifier.subjects.others.next([]);
772
+ this._usersNotifier.others.next({ others: [], event: { kind: "clear" } });
773
+ this._state.webSocket?.close();
774
+
775
+ this._detachWsListeners();
776
+ }
777
+
778
+ private _detachWindowListeners(): void {
779
+ if (!this._windowListeners) return;
780
+ if (typeof window === "undefined") return;
781
+ if (typeof document === "undefined") return;
782
+
783
+ window.removeEventListener("offline", this._windowListeners.onNavigatorOffline);
784
+ window.removeEventListener("online", this._windowListeners.onNavigatorOnline);
785
+ document.removeEventListener("visibilitychange", this._windowListeners.onVisibilityChange);
786
+ window.removeEventListener("focus", this._windowListeners.onWindowFocus);
787
+
788
+ this._windowListeners = null;
789
+ }
790
+
791
+ private _detachWsListeners(): void {
792
+ if (!this._state.webSocket || !this._wsListeners) return;
793
+
794
+ this._state.webSocket.removeEventListener("close", this._wsListeners.onClose);
795
+ this._state.webSocket.removeEventListener("error", this._wsListeners.onError);
796
+ this._state.webSocket.removeEventListener("message", this._wsListeners.onMessage);
797
+ this._state.webSocket.removeEventListener("open", this._wsListeners.onOpen);
798
+
799
+ this._wsListeners = null;
800
+ }
801
+
802
+ private _emitSharedTypes(): void {
803
+ const sharedTypes = this._crdtManager.doc.get();
804
+
805
+ const storageRoot = Object.keys(sharedTypes).reduce(
806
+ (acc, prop) => {
807
+ const serialized = this._crdtManager.doc.toJson(prop);
808
+
809
+ this._crdtNotifier.subject(prop).next(serialized);
810
+
811
+ return { ...acc, [prop]: serialized };
812
+ },
813
+ {} as { [P in keyof InferStorage<TCrdt>]: InferCrdtJson<InferStorage<TCrdt>[P]> },
814
+ );
815
+
816
+ this._crdtNotifier.rootSubject.next(storageRoot);
817
+ }
818
+
819
+ private _flattenStorageStore = debounce(
820
+ async (): Promise<void> => {
821
+ const shouldFlatten = await this._storageStore.getShouldFlatten();
822
+
823
+ if (!shouldFlatten) return;
824
+
825
+ const updates = await this._storageStore.getUpdates();
826
+
827
+ this._crdtManager.applyUpdate({ update: updates, origin: this });
828
+
829
+ const encodedState = this._crdtManager.doc.getEncodedState();
830
+
831
+ await this._storageStore.flatten(encodedState);
832
+ },
833
+ { wait: ADD_TO_STORAGE_STATE_DEBOUNCE_MS },
834
+ );
835
+
836
+ private _getAddon = (
837
+ addons: readonly PluvRoomAddon<TIO, TMetadata, TPresence, TCrdt>[],
838
+ ): PluvRoomAddon<TIO, TMetadata, TPresence, TCrdt> => {
839
+ return addons.reduce<PluvRoomAddon<TIO, TMetadata, TPresence, TCrdt>>(
840
+ (acc, addon) => () => ({
841
+ ...acc({ room: this }),
842
+ ...addon({ room: this }),
843
+ }),
844
+ DEFAULT_PLUV_CLIENT_ADDON,
845
+ );
846
+ };
847
+
848
+ private _getAuthFetchOptions(
849
+ room: string,
850
+ params: GetAuthEndpointParams<TMetadata>,
851
+ ): FetchOptions | null {
852
+ const metadata = params.metadata as TMetadata;
853
+
854
+ if (typeof this._endpoints.authEndpoint === "undefined") return null;
855
+
856
+ if (this._endpoints.authEndpoint === true) {
857
+ return {
858
+ url: `/api/pluv/authorize?room=${room}`,
859
+ options: {},
860
+ };
861
+ }
862
+
863
+ if (typeof this._endpoints.authEndpoint === "string") {
864
+ return {
865
+ url: this._endpoints.authEndpoint,
866
+ options: {},
867
+ };
868
+ }
869
+
870
+ const result = this._endpoints.authEndpoint({ metadata, room });
871
+
872
+ return typeof result === "string" ? { url: result, options: {} } : result;
873
+ }
874
+
875
+ private async _getAuthorization(
876
+ room: string,
877
+ params: GetAuthEndpointParams<TMetadata>,
878
+ ): Promise<string | null> {
879
+ const fetchOptions = this._getAuthFetchOptions(room, params);
880
+
881
+ if (!fetchOptions) return null;
882
+
883
+ const { url, options } = fetchOptions;
884
+ const res = await fetch(url, options);
885
+
886
+ if (!res.ok || res.status !== 200) throw new Error("Room is unauthorized");
887
+
888
+ try {
889
+ return await res.text().then((text) => text.trim());
890
+ } catch (err) {
891
+ throw new Error(err instanceof Error ? err.message : "Room is unauthorized");
892
+ }
893
+ }
894
+
895
+ private _getPublicKey(params: GetPublickKeyParams<TMetadata>): string | null {
896
+ const metadata = params.metadata as TMetadata;
897
+
898
+ if (!this._publicKey) return null;
899
+ if (typeof this._publicKey === "string") return this._publicKey;
900
+
901
+ return this._publicKey({ metadata });
902
+ }
903
+
904
+ private _getWsEndpoint(room: string, params: GetWsEndpointParams<TMetadata>): string {
905
+ const metadata = params.metadata as TMetadata;
906
+
907
+ switch (typeof this._endpoints.wsEndpoint) {
908
+ case "undefined":
909
+ return !!this._getPublicKey(params)
910
+ ? `wss://rooms.pluv.io/api/room/${room}`
911
+ : `/api/pluv/room/${room}`;
912
+ case "string":
913
+ return this._endpoints.wsEndpoint;
914
+ default:
915
+ return this._endpoints.wsEndpoint({ metadata, room });
916
+ }
917
+ }
918
+
919
+ private _handleExit(message: IOEventMessage<TIO>): void {
920
+ const { connectionId } = message;
921
+
922
+ if (!connectionId) return;
923
+ // Should not reach here
924
+ if (!this._state.webSocket) throw new Error("Could not find WebSocket");
925
+
926
+ const clientId = this._usersManager.getClientId(connectionId);
927
+ const deleted = this._usersManager.deleteConnection(connectionId);
928
+ const others = this._usersManager.getOthers();
929
+
930
+ this._stateNotifier.subjects.others.next(others);
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
+
945
+ /**
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.
949
+ * @date April 16, 2025
950
+ */
951
+ if (!remaining && !!clientId) this._usersNotifier.delete(clientId);
952
+ }
953
+
954
+ private _handlePresenceUpdatedMessage(message: IOEventMessage<TIO>): void {
955
+ const { connectionId } = message;
956
+
957
+ if (!connectionId) return;
958
+ // Should not reach here
959
+ if (!this._state.webSocket) throw new Error("Could not find WebSocket");
960
+
961
+ const data = message.data as BaseIOEventRecord<InferIOAuthorize<TIO>>["$presenceUpdated"];
962
+ const myself = this._usersManager.myself ?? null;
963
+
964
+ /**
965
+ * !HACK
966
+ * @description We're going to have the user's own presence be patched only via local calls
967
+ * to this.updateMyPresence. So we'll not update the user's presence in this handler to
968
+ * avoid weird update delays to the user's own presence.
969
+ *
970
+ * However, we will patch the user's own presence if the user has updated their own
971
+ * presence via another connection that is not this one (e.g. if the user has opened
972
+ * another connection in another browser tab/window somewhere).
973
+ * @date April 2, 2025
974
+ */
975
+ if (myself?.connectionId === connectionId) return;
976
+
977
+ const updated = this._usersManager.patchPresence(connectionId, data.presence as TPresence);
978
+ const myClientId = !!myself ? this._usersManager.getClientId(myself) : null;
979
+ const clientId = this._usersManager.getClientId(connectionId);
980
+
981
+ if (!!clientId && myClientId === clientId) {
982
+ this._stateNotifier.subjects["my-presence"].next(updated);
983
+ this._stateNotifier.subjects.myself.next(this._usersManager.myself);
984
+
985
+ return;
986
+ }
987
+
988
+ if (!!clientId) {
989
+ const other = this._usersManager.getOther(connectionId);
990
+ const others = this._usersManager.getOthers();
991
+
992
+ this._usersNotifier.other(clientId).next(other);
993
+ this._stateNotifier.subjects.others.next(others);
994
+
995
+ if (!!other) {
996
+ this._usersNotifier.others.next({
997
+ others,
998
+ event: { kind: "update", user: other },
999
+ });
1000
+ }
1001
+
1002
+ return;
1003
+ }
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);
1016
+ const others = this._usersManager.getOthers();
1017
+
1018
+ this._usersNotifier.other(added.clientId).next(other);
1019
+ this._stateNotifier.subjects.others.next(others);
1020
+
1021
+ this._usersNotifier.others.next({
1022
+ others,
1023
+ event: { kind: "update", user: added.data },
1024
+ });
1025
+ }
1026
+
1027
+ private _handleReceiveOthers(message: IOEventMessage<TIO>): void {
1028
+ if (!message.connectionId) return;
1029
+ // Should not reach here
1030
+ if (!this._state.webSocket) throw new Error("Could not find WebSocket");
1031
+
1032
+ const data = message.data as BaseIOEventRecord<InferIOAuthorize<TIO>>["$othersReceived"];
1033
+
1034
+ Object.keys(data.others).forEach((connectionId) => {
1035
+ const { presence, user } = data.others[connectionId];
1036
+
1037
+ const result = this._usersManager.addConnection({
1038
+ connectionId,
1039
+ presence: presence as TPresence,
1040
+ user,
1041
+ });
1042
+
1043
+ if (!!presence) this._usersManager.patchPresence(connectionId, presence as TPresence);
1044
+ if (result.isMyself) return;
1045
+
1046
+ const clientId = result.clientId;
1047
+ const other = this._usersManager.getOther(connectionId);
1048
+
1049
+ this._usersNotifier.other(clientId).next(other);
1050
+ });
1051
+
1052
+ const others = this._usersManager.getOthers();
1053
+
1054
+ this._stateNotifier.subjects.others.next(others);
1055
+ this._usersNotifier.others.next({
1056
+ others,
1057
+ event: { kind: "sync", users: others },
1058
+ });
1059
+ }
1060
+
1061
+ private async _handleRegisteredMessage(message: IOEventMessage<TIO>): Promise<void> {
1062
+ const { connectionId } = message;
1063
+ const user = message.user as Id<InferIOAuthorizeUser<InferIOAuthorize<TIO>>>;
1064
+
1065
+ if (!connectionId) return;
1066
+ // Should not reach here
1067
+ if (!this._state.webSocket) throw new Error("Could not find WebSocket");
1068
+
1069
+ const data = message.data as BaseIOEventRecord<InferIOAuthorize<TIO>>["$registered"];
1070
+ const state = data.state;
1071
+
1072
+ this._updateState((oldState) => {
1073
+ oldState.connection.id = connectionId;
1074
+ oldState.authorization.user = user ?? null;
1075
+
1076
+ return oldState;
1077
+ });
1078
+
1079
+ const userInfo: OptionalProps<UserInfo<TIO, TPresence>, "presence"> = {
1080
+ connectionId,
1081
+ presence: (data.presence as TPresence | null) ?? undefined,
1082
+ user,
1083
+ };
1084
+ this._usersManager.setMyself(userInfo);
1085
+
1086
+ const presence = this._usersManager.myPresence;
1087
+ const myself = this._usersManager.myself ?? null;
1088
+
1089
+ this._stateNotifier.subjects["my-presence"].next(presence);
1090
+ this._stateNotifier.subjects.myself.next(myself);
1091
+
1092
+ const update = !state
1093
+ ? (this._crdtManager.resolveEncodedState(await this._storageStore.getUpdates()) ??
1094
+ this._crdtManager.getInitialState())
1095
+ : this._crdtManager.getInitialState();
1096
+
1097
+ this._sendMessage({
1098
+ type: "$initializeSession",
1099
+ data: { presence, update },
1100
+ });
1101
+ }
1102
+
1103
+ private async _handleStorageReceivedMessage(message: IOEventMessage<TIO>): Promise<void> {
1104
+ const { connectionId } = message;
1105
+
1106
+ if (!connectionId) return;
1107
+ // Should not reach here
1108
+ if (!this._state.webSocket) throw new Error("Could not find WebSocket");
1109
+
1110
+ const data = message.data as BaseIOEventRecord<InferIOAuthorize<TIO>>["$storageReceived"];
1111
+ const changeKind = data.changeKind;
1112
+ const state = data.state;
1113
+
1114
+ const updates = await this._storageStore.getUpdates();
1115
+ const origin = ORIGIN_INITIALIZED;
1116
+
1117
+ const updateStorage = (update: string | readonly string[]) => {
1118
+ return this._crdtManager.initialized
1119
+ ? this._crdtManager.applyUpdate({ update, origin })
1120
+ : this._crdtManager.initialize({ update, origin });
1121
+ };
1122
+
1123
+ if (changeKind === "empty") {
1124
+ this._crdtManager.destroy();
1125
+ this._crdtManager.initialize({ origin, update: updates });
1126
+ } else if (changeKind === "initialized") {
1127
+ if (!!updates.length) updateStorage(updates);
1128
+ else {
1129
+ const encodedState = updateStorage(state).doc.getEncodedState();
1130
+
1131
+ this._addToStorageStore(encodedState);
1132
+ }
1133
+ } else {
1134
+ const encodedState = updateStorage(state).doc.getEncodedState();
1135
+
1136
+ if (!!updates.length) this._crdtManager.applyUpdate({ update: updates, origin });
1137
+
1138
+ await this._addToStorageStore(encodedState);
1139
+ }
1140
+
1141
+ const encodedState = this._crdtManager.doc.getEncodedState();
1142
+
1143
+ this._emitSharedTypes();
1144
+ this._observeCrdt();
1145
+ this._updateState((oldState) => {
1146
+ oldState.storage.state = StorageState.Synced;
1147
+
1148
+ return oldState;
1149
+ });
1150
+ this._stateNotifier.subjects["storage-loaded"].next(true);
1151
+
1152
+ this._sendMessage({
1153
+ type: "$updateStorage",
1154
+ data: { origin, update: encodedState },
1155
+ });
1156
+ }
1157
+
1158
+ private _handleStorageUpdatedMessage(message: IOEventMessage<TIO>): void {
1159
+ const { connectionId } = message;
1160
+
1161
+ if (!connectionId) return;
1162
+ // Should not reach here
1163
+ if (!this._state.webSocket) throw new Error("Could not find WebSocket");
1164
+
1165
+ const data = message.data as BaseIOEventRecord<
1166
+ InferIOAuthorize<TIO>
1167
+ >[typeof ORIGIN_STORAGE_UPDATED];
1168
+
1169
+ this._crdtManager.doc.applyEncodedState({
1170
+ origin: ORIGIN_STORAGE_UPDATED,
1171
+ update: data.state,
1172
+ });
1173
+
1174
+ const sharedTypes = this._crdtManager.doc.get();
1175
+
1176
+ const storageRoot = Object.keys(sharedTypes).reduce(
1177
+ (acc, prop) => {
1178
+ const serialized = this._crdtManager.doc.toJson(prop);
1179
+
1180
+ this._crdtNotifier.subject(prop).next(serialized);
1181
+
1182
+ return { ...acc, [prop]: serialized };
1183
+ },
1184
+ {} as {
1185
+ [P in keyof InferStorage<TCrdt>]: InferCrdtJson<InferStorage<TCrdt>[P]>;
1186
+ },
1187
+ );
1188
+
1189
+ this._crdtNotifier.rootSubject.next(storageRoot);
1190
+ }
1191
+
1192
+ private _handleSyncStateReceived(message: IOEventMessage<TIO>): void {
1193
+ const { connectionId } = message;
1194
+
1195
+ if (!connectionId) return;
1196
+ // Should not reach here
1197
+ if (!this._state.webSocket) throw new Error("Could not find WebSocket");
1198
+ if (!this._usersManager.myself) return;
1199
+
1200
+ const data = message.data as BaseIOEventRecord<InferIOAuthorize<TIO>>["$syncStateReceived"];
1201
+ const active = new Set(data?.connectionIds ?? []);
1202
+
1203
+ const quitters = this._usersManager
1204
+ .getOthers()
1205
+ .filter((other) => !active.has(other.connectionId));
1206
+
1207
+ quitters.forEach((quitter) => {
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
+ });
1216
+ });
1217
+
1218
+ const remaining = this._usersManager.getOthers();
1219
+
1220
+ this._stateNotifier.subjects.others.next(remaining);
1221
+ }
1222
+
1223
+ private _handleUserJoinedMessage(message: IOEventMessage<TIO>): void {
1224
+ const { connectionId } = message;
1225
+
1226
+ if (!connectionId) return;
1227
+ // Should not reach here
1228
+ if (!this._state.webSocket) throw new Error("Could not find WebSocket");
1229
+ if (!this._usersManager.myself) return;
1230
+
1231
+ const data = message.data as BaseIOEventRecord<InferIOAuthorize<TIO>>["$userJoined"];
1232
+ const myself = this._usersManager.myself;
1233
+
1234
+ if (myself.connectionId === connectionId) {
1235
+ this._sendMessage({ type: "$getOthers", data: {} });
1236
+ return;
1237
+ }
1238
+
1239
+ const added = this._usersManager.addConnection({
1240
+ connectionId,
1241
+ presence: data.presence as TPresence,
1242
+ user: data.user,
1243
+ });
1244
+
1245
+ const other = this._usersManager.getOther(connectionId);
1246
+ const others = this._usersManager.getOthers();
1247
+
1248
+ this._usersNotifier.other(added.clientId).next(other);
1249
+ this._stateNotifier.subjects.others.next(others);
1250
+
1251
+ this._usersNotifier.others.next({
1252
+ others,
1253
+ event: { kind: "enter", user: added.data },
1254
+ });
1255
+ }
1256
+
1257
+ private _heartbeat(): void {
1258
+ this._clearTimeout(this._timeouts.pong);
1259
+ this._timeouts.pong = setTimeout(
1260
+ this._reconnect.bind(this),
1261
+ PONG_TIMEOUT_MS,
1262
+ ) as unknown as number;
1263
+
1264
+ /**
1265
+ * !HACK
1266
+ * @description Send data as a stable string so that the server can react to this in a
1267
+ * consistent way.
1268
+ * @date July 8, 2024
1269
+ */
1270
+ this._sendMessage('{"type":"$ping","data":{}}');
1271
+ }
1272
+
1273
+ private _logDebug(...data: any[]): void {
1274
+ if (typeof process === "undefined") return;
1275
+ if (process.env?.NODE_ENV === "production") return;
1276
+ if (this._debug) console.log(...data);
1277
+ }
1278
+
1279
+ private _observeCrdt(): void {
1280
+ this._subscriptions.observeCrdt?.();
1281
+
1282
+ const unsubscribe = this._crdtManager.doc.subscribe((event) => {
1283
+ const origin = event.origin ?? null;
1284
+
1285
+ this._emitSharedTypes();
1286
+
1287
+ if (origin === ORIGIN_INITIALIZED || origin === ORIGIN_STORAGE_UPDATED) {
1288
+ return;
1289
+ }
1290
+
1291
+ this._addToStorageStore(event.update);
1292
+
1293
+ if (!this._state.webSocket) return;
1294
+ if (!this._state.connection.id) return;
1295
+ if (this._state.webSocket.readyState !== WebSocket.OPEN) return;
1296
+
1297
+ this._sendMessage({
1298
+ type: "$updateStorage",
1299
+ data: { origin, update: event.update },
1300
+ });
1301
+ });
1302
+
1303
+ this._subscriptions.observeCrdt = unsubscribe;
1304
+ }
1305
+
1306
+ private _onAuthorizationFail(error: unknown): void {
1307
+ const authError = error instanceof Error ? error : new Error("Authorization failed");
1308
+
1309
+ this._listeners.onAuthorizationFail(authError);
1310
+
1311
+ this._updateState((oldState) => {
1312
+ oldState.connection.state = ConnectionState.Unavailable;
1313
+ oldState.storage.state = StorageState.Unavailable;
1314
+
1315
+ return oldState;
1316
+ });
1317
+ }
1318
+
1319
+ private async _onClose(event: CloseEvent): Promise<void> {
1320
+ this._listenerManager.subjects.close.next(event);
1321
+
1322
+ this._logDebug("WebSocket closed");
1323
+ if (!!event.reason) this._logDebug(event.reason);
1324
+
1325
+ const shouldRetry = [
1326
+ // Going away: Client/server is shutting down or navigating
1327
+ 1001,
1328
+ // Abnormal closure: Usually network error
1329
+ 1006,
1330
+ // Internal error: may be a larger problem, but try reconnecting for now
1331
+ 1011,
1332
+ // Service restart: Server restarted
1333
+ 1012,
1334
+ // Try again later: server overloaded
1335
+ 1013,
1336
+ // Bad gateway
1337
+ 1014,
1338
+ ].some((okToRetryCode) => event.code === okToRetryCode);
1339
+
1340
+ this._updateState((oldState) => {
1341
+ oldState.authorization.token = null;
1342
+ oldState.connection.state = shouldRetry
1343
+ ? ConnectionState.Unavailable
1344
+ : /**
1345
+ * !HACK
1346
+ * @description Assume these are not recoverable. We're just going to mark this
1347
+ * as Closed to avoid reconnect attempts.
1348
+ * @date April 28, 2025
1349
+ */
1350
+ ConnectionState.Closed;
1351
+ oldState.storage.state =
1352
+ oldState.storage.state === StorageState.Synced
1353
+ ? StorageState.Offline
1354
+ : StorageState.Unavailable;
1355
+ oldState.webSocket = null;
1356
+
1357
+ return oldState;
1358
+ });
1359
+
1360
+ if (shouldRetry) {
1361
+ await this._reconnect();
1362
+ return;
1363
+ }
1364
+
1365
+ this._closeWs();
1366
+ this._clearTimeout(this._timeouts.reconnect);
1367
+ }
1368
+
1369
+ /**
1370
+ * TODO
1371
+ * @description Handle websocket errors in a meaningful way, should they occur
1372
+ * @date August 19, 2022
1373
+ */
1374
+ private _onError(event: Event): void {
1375
+ this._listenerManager.subjects.error.next(event);
1376
+ }
1377
+
1378
+ private _onMessage(event: MessageEvent<string>): void {
1379
+ this._listenerManager.subjects.message.next(event);
1380
+
1381
+ const message = this._parseMessage(event);
1382
+
1383
+ if (!message) return;
1384
+
1385
+ const shouldLog =
1386
+ typeof this._debug === "boolean"
1387
+ ? this._debug
1388
+ : this._debug.output.find((value) => value === message.type);
1389
+
1390
+ if (shouldLog) {
1391
+ this._logDebug("WebSocket event received: ", message.type, message);
1392
+ }
1393
+
1394
+ this._eventNotifier.subject(message.type as keyof InferIOOutput<TIO>).next(message as any);
1395
+
1396
+ switch (message.type) {
1397
+ case "$exit": {
1398
+ this._handleExit(message);
1399
+ return;
1400
+ }
1401
+ case "$othersReceived": {
1402
+ this._handleReceiveOthers(message);
1403
+ return;
1404
+ }
1405
+ case "$pong": {
1406
+ this._clearTimeout(this._timeouts.pong);
1407
+ return;
1408
+ }
1409
+ case "$presenceUpdated": {
1410
+ this._handlePresenceUpdatedMessage(message);
1411
+ return;
1412
+ }
1413
+ case "$registered": {
1414
+ this._handleRegisteredMessage(message);
1415
+ return;
1416
+ }
1417
+ case "$storageReceived": {
1418
+ this._handleStorageReceivedMessage(message);
1419
+ return;
1420
+ }
1421
+ case "$storageUpdated": {
1422
+ this._handleStorageUpdatedMessage(message);
1423
+ return;
1424
+ }
1425
+ case "$syncStateReceived": {
1426
+ this._handleSyncStateReceived(message);
1427
+ return;
1428
+ }
1429
+ case "$userJoined": {
1430
+ this._handleUserJoinedMessage(message);
1431
+ return;
1432
+ }
1433
+ default:
1434
+ }
1435
+ }
1436
+
1437
+ private _onOpen(event: Event): void {
1438
+ this._listenerManager.subjects.open.next(event);
1439
+
1440
+ if (this._state.connection.state !== ConnectionState.Connecting) return;
1441
+
1442
+ this._logDebug("WebSocket connected");
1443
+ this._updateState((oldState) => {
1444
+ oldState.connection.state = ConnectionState.Open;
1445
+
1446
+ return oldState;
1447
+ });
1448
+
1449
+ this._clearInterval(this._intervals.heartbeat);
1450
+ this._intervals.heartbeat = setInterval(
1451
+ this._heartbeat.bind(this),
1452
+ HEARTBEAT_INTERVAL_MS,
1453
+ ) as unknown as number;
1454
+ }
1455
+
1456
+ private async _onNavigatorOffline(): Promise<void> {
1457
+ if (this._state.connection.state === ConnectionState.Closed) return;
1458
+
1459
+ this._updateState((oldState) => {
1460
+ oldState.connection.state = ConnectionState.Unavailable;
1461
+ oldState.storage.state =
1462
+ oldState.storage.state === StorageState.Synced
1463
+ ? StorageState.Offline
1464
+ : StorageState.Unavailable;
1465
+
1466
+ return oldState;
1467
+ });
1468
+ }
1469
+
1470
+ private async _onNavigatorOnline(): Promise<void> {
1471
+ if (this._state.connection.state !== ConnectionState.Unavailable) return;
1472
+
1473
+ await this._reconnect();
1474
+ }
1475
+
1476
+ private async _onVisibilityChange(): Promise<void> {
1477
+ if (typeof document === "undefined") return;
1478
+ if (document.visibilityState !== "visible") return;
1479
+
1480
+ switch (this._state.connection.state) {
1481
+ case ConnectionState.Open: {
1482
+ this._clearInterval(this._intervals.heartbeat);
1483
+ setInterval(this._heartbeat.bind(this), HEARTBEAT_INTERVAL_MS);
1484
+
1485
+ return;
1486
+ }
1487
+ case ConnectionState.Unavailable: {
1488
+ await this._reconnect();
1489
+
1490
+ return;
1491
+ }
1492
+ default:
1493
+ }
1494
+ }
1495
+
1496
+ private async _onWindowFocus(): Promise<void> {
1497
+ return await this._onVisibilityChange();
1498
+ }
1499
+
1500
+ private _parseMessage(message: { data: string | ArrayBuffer }): IOEventMessage<TIO> | null {
1501
+ /**
1502
+ * !HACK
1503
+ * @description We'll only handle stringified JSONs for now
1504
+ * @date August 9, 2022
1505
+ */
1506
+ if (typeof message.data !== "string") return null;
1507
+
1508
+ try {
1509
+ const config = JSON.parse(message.data);
1510
+
1511
+ if (typeof config !== "object") return null;
1512
+ if (typeof config.type !== "string") return null;
1513
+ if (typeof config.data !== "object") return null;
1514
+
1515
+ return config as IOEventMessage<TIO>;
1516
+ } catch {
1517
+ return null;
1518
+ }
1519
+ }
1520
+
1521
+ private _pollReconnect(): void {
1522
+ this._clearTimeout(this._timeouts.reconnect);
1523
+
1524
+ const timeoutMs =
1525
+ typeof this._reconnectTimeoutMs === "number"
1526
+ ? this._reconnectTimeoutMs
1527
+ : this._reconnectTimeoutMs({ attempts: this._state.connection.attempts });
1528
+ const clampedMs = Math.max(
1529
+ Math.min(MIN_RECONNECT_TIMEOUT_MS, timeoutMs),
1530
+ MAX_RECONNECT_TIMEOUT_MS,
1531
+ );
1532
+
1533
+ this._timeouts.reconnect = setTimeout(
1534
+ this._reconnect.bind(this),
1535
+ clampedMs,
1536
+ ) as unknown as number;
1537
+ }
1538
+
1539
+ private async _reconnect(): Promise<void> {
1540
+ this._closeWs();
1541
+
1542
+ /**
1543
+ * @description User has manually closed this connection. We're not going to retry, since
1544
+ * the user probably intends for this to stay closed. They will need to invoke connect
1545
+ * themselves.
1546
+ * @date April 28, 2025
1547
+ */
1548
+ if (this._state.connection.state === ConnectionState.Closed) return;
1549
+
1550
+ const metadata = this._lastMetadata;
1551
+ const params = (
1552
+ typeof metadata === "undefined" ? [] : [{ metadata }]
1553
+ ) as RoomConnectParams<TMetadata>;
1554
+
1555
+ await this.connect(...params);
1556
+ }
1557
+
1558
+ private _sendMessage(data: string): void;
1559
+ private _sendMessage<TMessage extends EventMessage<string, any> = EventMessage<string, any>>(
1560
+ data: TMessage,
1561
+ ): void;
1562
+ private _sendMessage(data: any): void {
1563
+ const webSocket = this._state.webSocket;
1564
+
1565
+ if (webSocket?.readyState !== WebSocket.OPEN) return;
1566
+
1567
+ const dataType: string | null = ((): string | null => {
1568
+ if (typeof data === "object") return data.type ?? null;
1569
+ if (typeof data !== "string") return null;
1570
+
1571
+ try {
1572
+ return JSON.parse(data)?.type ?? null;
1573
+ } catch {
1574
+ return null;
1575
+ }
1576
+ })();
1577
+
1578
+ const shouldLog: boolean =
1579
+ typeof this._debug === "boolean"
1580
+ ? this._debug
1581
+ : !dataType
1582
+ ? false
1583
+ : this._debug.input.some((value) => value === data.type);
1584
+
1585
+ if (shouldLog) this._logDebug("WebSocket event sent: ", data.type, data);
1586
+
1587
+ const message = typeof data === "string" ? data : JSON.stringify(data);
1588
+
1589
+ webSocket.send(message);
1590
+ }
1591
+
1592
+ private _setMetadata(metadata: TMetadata): TMetadata {
1593
+ const parsed = this.metadata ? this.metadata.parse(metadata) : metadata;
1594
+
1595
+ this._lastMetadata = parsed;
1596
+
1597
+ return parsed;
1598
+ }
1599
+
1600
+ private _updateState(
1601
+ updater: (oldState: WebSocketState<TIO>) => WebSocketState<TIO>,
1602
+ ): WebSocketState<TIO> {
1603
+ let authorization: AuthorizationState<TIO>;
1604
+
1605
+ try {
1606
+ authorization = JSON.parse(JSON.stringify(this._state.authorization));
1607
+ } catch {
1608
+ throw new Error("User is not JSON serializable");
1609
+ }
1610
+
1611
+ const oldState: WebSocketState<TIO> = {
1612
+ ...this._state,
1613
+ authorization,
1614
+ connection: JSON.parse(JSON.stringify(this._state.connection)),
1615
+ storage: JSON.parse(JSON.stringify(this._state.storage)),
1616
+ webSocket: this._state.webSocket,
1617
+ };
1618
+
1619
+ const newState = updater(oldState);
1620
+ this._state = newState;
1621
+
1622
+ this._logDebug("WebSocket new state: ", newState);
1623
+ this._stateNotifier.subjects.connection.next(newState);
1624
+
1625
+ return newState;
1626
+ }
1627
+ }