@pluv/client 5.0.1 → 5.0.2

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/dist/index.mjs ADDED
@@ -0,0 +1,1770 @@
1
+ import { ConnectionState, ConnectionState as ConnectionState$1, StorageState } from "@pluv/types";
2
+ import { noop } from "@pluv/crdt";
3
+ import { makeSubject, subscribe } from "wonka";
4
+ //#region src/AbstractStorageStore.ts
5
+ var AbstractStorageStore = class {
6
+ constructor(room) {
7
+ this.room = room;
8
+ }
9
+ };
10
+ //#endregion
11
+ //#region src/constants.ts
12
+ const PLUV_PRESENCE_META_KEY = "__pluv";
13
+ //#endregion
14
+ //#region src/PluvProcedure.ts
15
+ var PluvProcedure = class PluvProcedure {
16
+ get config() {
17
+ return {
18
+ broadcast: this._broadcast?.bind(this) ?? null,
19
+ input: this._input ?? null,
20
+ resolver: this._resolver()
21
+ };
22
+ }
23
+ constructor(config = {}) {
24
+ this._broadcast = null;
25
+ this._input = null;
26
+ const { broadcast, input } = config;
27
+ this._broadcast = broadcast ?? null;
28
+ this._input = input ?? null;
29
+ }
30
+ broadcast(resolver) {
31
+ const { broadcast } = this.config;
32
+ if (!!broadcast) throw new Error("Broadcast was already defined for this procedure");
33
+ return new PluvProcedure({
34
+ ...this.config,
35
+ broadcast: resolver
36
+ });
37
+ }
38
+ input(input) {
39
+ return new PluvProcedure({ input });
40
+ }
41
+ _resolver() {
42
+ return (data, context) => this._broadcast?.(data, context);
43
+ }
44
+ };
45
+ //#endregion
46
+ //#region src/CrdtManager.ts
47
+ var CrdtManager = class {
48
+ constructor(options) {
49
+ this.initialized = false;
50
+ const { initialStorage = noop.doc({}) } = options;
51
+ this._docFactory = initialStorage;
52
+ this.doc = this._docFactory.getEmpty();
53
+ }
54
+ /**
55
+ * !HACK
56
+ * @description If this is called before the doc is initialized. Omit the
57
+ * update. This unfortunately means updates can be dropped entirely
58
+ * @date June 13, 2023
59
+ */
60
+ applyUpdate(params) {
61
+ const { origin, update } = params;
62
+ if (!this.initialized) return this;
63
+ const updates = typeof update === "string" ? [update] : update;
64
+ this._applyDocUpdates(this.doc, updates, origin);
65
+ return this;
66
+ }
67
+ destroy() {
68
+ this.initialized = false;
69
+ this.doc.destroy();
70
+ this.doc = this._docFactory.getEmpty();
71
+ }
72
+ get(key) {
73
+ return this.doc.get(key);
74
+ }
75
+ getInitialState() {
76
+ return this._docFactory.getInitialized().getEncodedState();
77
+ }
78
+ initialize(params) {
79
+ const { origin, update } = params;
80
+ /**
81
+ * !HACK
82
+ * @description If the doc was already initialized, we don't want to re-initialize
83
+ * the doc and overwrite what's already there. Skip the initialization and log a
84
+ * warning.
85
+ * @date May 7, 2025
86
+ */
87
+ if (this.initialized) {
88
+ console.warn("Attempted to initialize storage multiple times");
89
+ return this;
90
+ }
91
+ /**
92
+ * !HACK
93
+ * @description If the doc is already populated, somehow we're attempting to overwrite
94
+ * a doc that was presumably initialized but not identified as initialized. Skip the
95
+ * initialization and log a warning.
96
+ * @date May 7, 2025
97
+ */
98
+ if (!this.doc.isEmpty()) {
99
+ console.warn("Attempted to initialize over a non-empty storage");
100
+ return this;
101
+ }
102
+ const updates = typeof update === "string" ? [update] : update;
103
+ if (!updates.length) return this;
104
+ const initialized = this._docFactory.getInitialized();
105
+ const reference = initialized.get();
106
+ this.doc = this._applyDocUpdates(this.doc, updates, origin).rebuildStorage(reference);
107
+ this.initialized = true;
108
+ initialized.destroy();
109
+ return this;
110
+ }
111
+ resolveEncodedState(updates) {
112
+ return this._docFactory.resolveEncodedState(updates);
113
+ }
114
+ _applyDocUpdates(doc, updates, origin) {
115
+ doc.transact(() => {
116
+ doc.batchApplyEncodedState({
117
+ origin,
118
+ updates
119
+ });
120
+ }, origin);
121
+ return doc;
122
+ }
123
+ };
124
+ //#endregion
125
+ //#region src/CrdtNotifier.ts
126
+ var CrdtNotifier = class {
127
+ constructor() {
128
+ this.rootSubject = makeSubject();
129
+ this.subjects = /* @__PURE__ */ new Map();
130
+ }
131
+ subject(key) {
132
+ const subject = this.subjects.get(key);
133
+ if (subject) return subject;
134
+ const newSubject = makeSubject();
135
+ this.subjects.set(key, newSubject);
136
+ return newSubject;
137
+ }
138
+ subcribeRoot(callback) {
139
+ return subscribe(callback)(this.rootSubject.source).unsubscribe;
140
+ }
141
+ subscribe(key, callback) {
142
+ const subject = this.subject(key);
143
+ return subscribe(callback)(subject.source).unsubscribe;
144
+ }
145
+ };
146
+ //#endregion
147
+ //#region src/EventNotifier.ts
148
+ var EventNotifier = class {
149
+ constructor() {
150
+ this.subjects = /* @__PURE__ */ new Map();
151
+ }
152
+ subject(name) {
153
+ const subject = this.subjects.get(name);
154
+ if (subject) return subject;
155
+ const newSubject = makeSubject();
156
+ this.subjects.set(name, newSubject);
157
+ return newSubject;
158
+ }
159
+ subscribe(name, callback) {
160
+ const subject = this.subject(name);
161
+ return subscribe(callback)(subject.source).unsubscribe;
162
+ }
163
+ };
164
+ //#endregion
165
+ //#region src/ListenerManager.ts
166
+ var ListenerManager = class {
167
+ constructor() {
168
+ this.subjects = {
169
+ close: makeSubject(),
170
+ error: makeSubject(),
171
+ message: makeSubject(),
172
+ open: makeSubject()
173
+ };
174
+ }
175
+ subscribe(kind, callback) {
176
+ const source = this.subjects[kind]?.source;
177
+ if (!source) return () => void 0;
178
+ return subscribe(callback)(source).unsubscribe;
179
+ }
180
+ };
181
+ //#endregion
182
+ //#region src/PluvRouter.ts
183
+ var PluvRouter = class PluvRouter {
184
+ constructor(events) {
185
+ this._defs = { events: {} };
186
+ const invalidName = Object.keys(events).find((name) => !this._isValidEventName(name));
187
+ if (typeof invalidName === "string") throw new Error(`Invalid event name. Event names must be formatted as valid JavaScript variable names: "${invalidName}"`);
188
+ this._defs = { events };
189
+ }
190
+ static merge(...routers) {
191
+ const events = Object.assign(Object.create(null), ...routers.map((router) => router._defs.events));
192
+ if (Object.keys(events).some((key) => key.startsWith("$"))) throw new Error("Procedures may not start with \"$\"");
193
+ return new PluvRouter(events);
194
+ }
195
+ _isValidEventName(name) {
196
+ return /^[a-z_$][a-z0-9_$]*$/gi.test(name);
197
+ }
198
+ };
199
+ //#endregion
200
+ //#region src/StateNotifier.ts
201
+ var StateNotifier = class {
202
+ constructor() {
203
+ this.subjects = {
204
+ connection: makeSubject(),
205
+ "my-presence": makeSubject(),
206
+ myself: makeSubject(),
207
+ others: makeSubject(),
208
+ "storage-loaded": makeSubject()
209
+ };
210
+ }
211
+ subscribe(name, callback) {
212
+ const source = this.subjects[name]?.source;
213
+ if (!source) throw new Error(`Could not subscribe to source: ${name}`);
214
+ return subscribe(callback)(source).unsubscribe;
215
+ }
216
+ };
217
+ //#endregion
218
+ //#region src/StorageStore.ts
219
+ var StorageStore = class extends AbstractStorageStore {
220
+ addUpdate() {
221
+ return Promise.resolve();
222
+ }
223
+ applyUpdates() {
224
+ return Promise.resolve();
225
+ }
226
+ destroy() {
227
+ return Promise.resolve();
228
+ }
229
+ flatten() {
230
+ return Promise.resolve();
231
+ }
232
+ getShouldFlatten() {
233
+ return Promise.resolve(false);
234
+ }
235
+ getUpdates() {
236
+ return Promise.resolve([]);
237
+ }
238
+ initialize() {
239
+ return Promise.resolve();
240
+ }
241
+ };
242
+ //#endregion
243
+ //#region src/utils/debounce.ts
244
+ const debounce = (fn, options) => {
245
+ const { wait, immediate = false } = options ?? {};
246
+ let prevResult;
247
+ let timeout = null;
248
+ return (...args) => {
249
+ if (typeof timeout === "number") clearTimeout(timeout);
250
+ timeout = setTimeout(() => {
251
+ timeout = null;
252
+ if (!immediate) prevResult = fn(args);
253
+ }, wait);
254
+ if (immediate && !timeout) {
255
+ fn(args);
256
+ return;
257
+ }
258
+ return prevResult;
259
+ };
260
+ };
261
+ //#endregion
262
+ //#region src/utils/pickBy.ts
263
+ const pickBy = (obj, by) => {
264
+ const result = {};
265
+ const keys = Object.keys(obj);
266
+ for (let i = 0; i < keys.length; ++i) {
267
+ const key = keys[i];
268
+ const value = obj[key];
269
+ if (by(value)) result[key] = value;
270
+ }
271
+ return result;
272
+ };
273
+ //#endregion
274
+ //#region src/UsersManager.ts
275
+ var UsersManager = class {
276
+ constructor(config) {
277
+ this._idMap = {
278
+ fromConnectionId: /* @__PURE__ */ new Map(),
279
+ fromClientId: /* @__PURE__ */ new Map()
280
+ };
281
+ this._myself = null;
282
+ this._others = /* @__PURE__ */ new Map();
283
+ this._presence = null;
284
+ const { initialPresence, limits, presence = null } = config;
285
+ this.initialPresence = initialPresence;
286
+ this._limits = limits;
287
+ this._myPresence = initialPresence;
288
+ this._presence = presence;
289
+ }
290
+ get myPresence() {
291
+ return this._myPresence;
292
+ }
293
+ get myself() {
294
+ return this._myself;
295
+ }
296
+ addConnection(userInfo) {
297
+ /**
298
+ * @description Ensure that the presence is complete, so that it does not fail schema
299
+ * validation
300
+ * @date April 20, 2025
301
+ */
302
+ const cleaned = pickBy(userInfo.presence ?? {}, (value) => typeof value !== "undefined");
303
+ const presence = {
304
+ ...this.initialPresence,
305
+ ...cleaned
306
+ };
307
+ const data = {
308
+ ...userInfo,
309
+ presence
310
+ };
311
+ const myClientId = !!this._myself ? this.getClientId(this._myself) : null;
312
+ const clientId = this.getClientId(data);
313
+ const remaining = this._setConnectionId(userInfo.connectionId, clientId).size;
314
+ if (myClientId === clientId) return {
315
+ clientId,
316
+ data,
317
+ isMyself: true,
318
+ remaining
319
+ };
320
+ /**
321
+ * !HACK
322
+ * @description Don't override the other user, so that we ensure the user's presence isn't
323
+ * overwritten by whatever incoming connection.
324
+ * @date April 16, 2025
325
+ */
326
+ if (!this._others.get(clientId)) this._others.set(clientId, data);
327
+ return {
328
+ clientId,
329
+ data,
330
+ isMyself: false,
331
+ remaining
332
+ };
333
+ }
334
+ clearConnections() {
335
+ this.removeMyself();
336
+ this._others.clear();
337
+ this._idMap.fromClientId.clear();
338
+ this._idMap.fromConnectionId.clear();
339
+ }
340
+ deleteConnection(connectionId) {
341
+ const clientId = this.getClientId(connectionId);
342
+ this._deleteConnectionId(connectionId);
343
+ if (!clientId) return null;
344
+ const userInfo = this._others.get(clientId) ?? null;
345
+ if (!userInfo) return null;
346
+ const remaining = this._getConnectionIds(clientId)?.size ?? 0;
347
+ const result = {
348
+ clientId,
349
+ remaining,
350
+ data: userInfo
351
+ };
352
+ /**
353
+ * @description A user may have multiple websocket connections open. If one connection is
354
+ * dropped, the user may still be connected via another websocket (e.g. on anther browser
355
+ * tab). In this case, we just want to remove the connection mapping for the connection
356
+ * that is dropped, but keep the remaining connections and the user.
357
+ * @date April 16, 2025
358
+ */
359
+ if (!remaining) this._others.delete(clientId);
360
+ return result;
361
+ }
362
+ getClientId(input) {
363
+ if (typeof input !== "string") {
364
+ const userInfo = input;
365
+ const connectionId = userInfo.connectionId;
366
+ return userInfo.user?.id ?? null ?? connectionId;
367
+ }
368
+ const connectionId = input;
369
+ if (this._myself?.connectionId === connectionId) return this.getClientId(this._myself);
370
+ return this._idMap.fromConnectionId.get(connectionId) ?? null;
371
+ }
372
+ getOther(connectionId) {
373
+ const clientId = this.getClientId(connectionId);
374
+ if (!clientId) return null;
375
+ return this._others.get(clientId) ?? null;
376
+ }
377
+ getOthers() {
378
+ return Array.from(this._others.values());
379
+ }
380
+ patchPresence(connectionId, patch) {
381
+ const clientId = this.getClientId(connectionId);
382
+ const myClientId = !!this._myself ? this.getClientId(this._myself) : null;
383
+ if (!clientId) return null;
384
+ if (myClientId === clientId) return this.updateMyPresence(patch);
385
+ const other = this._others.get(clientId);
386
+ if (!other) return null;
387
+ const cleanedPatch = pickBy(patch ?? {}, (value) => typeof value !== "undefined");
388
+ const cleanedPresence = pickBy(other.presence ?? {}, (value) => typeof value !== "undefined");
389
+ const presence = {
390
+ ...this.initialPresence,
391
+ ...cleanedPresence,
392
+ ...cleanedPatch
393
+ };
394
+ const validated = this._presence ? this._presence.parse(presence) : presence;
395
+ /**
396
+ * !HACK
397
+ * @description We're patching internal metadata back into the presence field so it doesn't
398
+ * get stripped from validation
399
+ * @date May 13, 2025
400
+ */
401
+ if (!!presence["__pluv"]) validated[PLUV_PRESENCE_META_KEY] = presence[PLUV_PRESENCE_META_KEY];
402
+ this._others.set(clientId, {
403
+ ...other,
404
+ presence: validated
405
+ });
406
+ return validated;
407
+ }
408
+ removeMyself() {
409
+ if (!this._myself) return;
410
+ const connectionId = this._myself.connectionId;
411
+ this._deleteConnectionId(connectionId);
412
+ this._myself = null;
413
+ }
414
+ setMyself(userInfo) {
415
+ const connectionId = userInfo.connectionId;
416
+ const presence = userInfo.presence ?? this.initialPresence;
417
+ this._myself = {
418
+ ...userInfo,
419
+ presence
420
+ };
421
+ this._myPresence = presence;
422
+ const clientId = this.getClientId(this._myself);
423
+ this._setConnectionId(connectionId, clientId);
424
+ }
425
+ setPresence(connectionId, presence) {
426
+ const clientId = this.getClientId(connectionId);
427
+ const myClientId = !!this._myself ? this.getClientId(this._myself) : null;
428
+ if (!clientId) return;
429
+ if (myClientId === clientId) {
430
+ if (!this._myself) return;
431
+ this._myself.presence = presence;
432
+ this._myPresence = presence;
433
+ return;
434
+ }
435
+ const other = this._others.get(clientId);
436
+ if (!other) return;
437
+ this._others.set(clientId, {
438
+ ...other,
439
+ presence
440
+ });
441
+ }
442
+ /**
443
+ * @description This method need not care about being connected. This is because a user should
444
+ * be able to view and update their own presence without being online
445
+ * @date April 20, 2025
446
+ */
447
+ updateMyPresence(patch) {
448
+ const cleanedPatch = pickBy(patch, (value) => typeof value !== "undefined");
449
+ const cleanedPresence = pickBy(this._myPresence, (value) => typeof value !== "undefined");
450
+ const updated = {
451
+ ...this.initialPresence,
452
+ ...cleanedPresence,
453
+ ...cleanedPatch
454
+ };
455
+ const bytes = new TextEncoder().encode(JSON.stringify(updated)).length;
456
+ if (!!this._limits.presenceMaxSize && bytes > this._limits.presenceMaxSize) throw new Error(`Large presence. Presence must be at most 512 bytes. Current size: ${bytes.toLocaleString()}`);
457
+ this._myPresence = updated;
458
+ if (!this._myself) return updated;
459
+ this._myself.presence = updated;
460
+ return updated;
461
+ }
462
+ _deleteConnectionId(connectionId) {
463
+ const clientId = this.getClientId(connectionId);
464
+ this._idMap.fromConnectionId.delete(connectionId);
465
+ if (!clientId) return;
466
+ const set = this._idMap.fromClientId.get(clientId) ?? null;
467
+ if (!set) return;
468
+ set.delete(connectionId);
469
+ if (!!set.size) return;
470
+ this._idMap.fromClientId.delete(clientId);
471
+ }
472
+ _getConnectionIds(clientId) {
473
+ return this._idMap.fromClientId.get(clientId) ?? null;
474
+ }
475
+ _setConnectionId(connectionId, clientId) {
476
+ const updated = (this._idMap.fromClientId.get(clientId) ?? /* @__PURE__ */ new Set()).add(connectionId);
477
+ this._idMap.fromConnectionId.set(connectionId, clientId);
478
+ this._idMap.fromClientId.set(clientId, updated);
479
+ return updated;
480
+ }
481
+ };
482
+ //#endregion
483
+ //#region src/UsersNotifier.ts
484
+ var UsersNotifier = class {
485
+ constructor() {
486
+ this.others = makeSubject();
487
+ this._otherSubjects = /* @__PURE__ */ new Map();
488
+ }
489
+ clear() {
490
+ this._otherSubjects.forEach((subject) => {
491
+ subject.next(null);
492
+ });
493
+ this._otherSubjects.clear();
494
+ }
495
+ delete(clientId) {
496
+ this.other(clientId).next(null);
497
+ this._otherSubjects.delete(clientId);
498
+ }
499
+ other(clientId) {
500
+ const subject = this._otherSubjects.get(clientId);
501
+ if (subject) return subject;
502
+ const newSubject = makeSubject();
503
+ this._otherSubjects.set(clientId, newSubject);
504
+ return newSubject;
505
+ }
506
+ subscribeOther(clientId, callback) {
507
+ const source = this.other(clientId).source;
508
+ return subscribe(callback)(source).unsubscribe;
509
+ }
510
+ subscribeOthers(callback) {
511
+ return subscribe(({ others, event }) => callback(others, event))(this.others.source).unsubscribe;
512
+ }
513
+ };
514
+ //#endregion
515
+ //#region src/PluvRoom.ts
516
+ const ADD_TO_STORAGE_STATE_DEBOUNCE_MS = 1e3;
517
+ const HEARTBEAT_INTERVAL_MS = 1e4;
518
+ const PONG_TIMEOUT_MS = 2e3;
519
+ const RECONNECT_TIMEOUT_MS = 5e3;
520
+ const MIN_RECONNECT_TIMEOUT_MS = 1e3;
521
+ const MAX_RECONNECT_TIMEOUT_MS = 6e4;
522
+ const ORIGIN_INITIALIZED = "$initialized";
523
+ const ORIGIN_STORAGE_UPDATED = "$storageUpdated";
524
+ const DEFAULT_PLUV_CLIENT_ADDON = (input) => ({ storage: new StorageStore(input.room.id) });
525
+ var PluvRoom = class {
526
+ constructor(room, options) {
527
+ this._crdtNotifier = new CrdtNotifier();
528
+ this._eventNotifier = new EventNotifier();
529
+ this._intervals = { heartbeat: null };
530
+ this._listenerManager = new ListenerManager();
531
+ this._publicKey = null;
532
+ this._stateNotifier = new StateNotifier();
533
+ this._subscriptions = { observeCrdt: null };
534
+ this._timeouts = {
535
+ pong: null,
536
+ reconnect: null
537
+ };
538
+ this._usersNotifier = new UsersNotifier();
539
+ this._lastMetadata = null;
540
+ this._state = {
541
+ authorization: {
542
+ token: null,
543
+ user: null
544
+ },
545
+ connection: {
546
+ attempts: 0,
547
+ id: null,
548
+ state: ConnectionState$1.Untouched
549
+ },
550
+ storage: { state: StorageState.Unavailable },
551
+ webSocket: null
552
+ };
553
+ this._windowListeners = null;
554
+ this._wsListeners = null;
555
+ this.broadcast = new Proxy(async (event, data) => {
556
+ if (!this._state.webSocket) return;
557
+ if (this._state.connection.state !== ConnectionState$1.Open) return;
558
+ const type = event.toString();
559
+ const procedure = this._router._defs.events[type];
560
+ if (!procedure?.config.broadcast) {
561
+ this._sendMessage({
562
+ data,
563
+ type
564
+ });
565
+ return;
566
+ }
567
+ const myself = this._usersManager.myself;
568
+ if (!myself) return;
569
+ const parsed = procedure.config.input ? procedure.config.input.parse(data) : data;
570
+ const context = {
571
+ doc: this._crdtManager.doc,
572
+ others: this._usersManager.getOthers(),
573
+ room: this.id,
574
+ user: myself
575
+ };
576
+ const output = await procedure.config.broadcast(parsed, context);
577
+ Object.entries(output).forEach(([_type, _data]) => {
578
+ this._sendMessage({
579
+ data: _data,
580
+ type: _type
581
+ });
582
+ });
583
+ }, { get(fn, prop) {
584
+ return async (data) => {
585
+ return await fn(prop, data);
586
+ };
587
+ } });
588
+ this.canRedo = () => {
589
+ return !!this._crdtManager.doc.canRedo();
590
+ };
591
+ this.canUndo = () => {
592
+ return !!this._crdtManager.doc.canUndo();
593
+ };
594
+ this.getConnection = () => {
595
+ return Object.freeze(JSON.parse(JSON.stringify(this._state.connection)));
596
+ };
597
+ this.getDoc = () => {
598
+ return this._crdtManager.doc;
599
+ };
600
+ this.getMyPresence = () => {
601
+ return this._usersManager.myPresence;
602
+ };
603
+ this.getMyself = () => {
604
+ return this._usersManager.myself;
605
+ };
606
+ this.getOther = (connectionId) => {
607
+ return this._usersManager.getOther(connectionId);
608
+ };
609
+ this.getOthers = () => {
610
+ return this._usersManager.getOthers();
611
+ };
612
+ this.getStorage = (type) => {
613
+ const sharedType = this._crdtManager.get(type);
614
+ if (typeof sharedType === "undefined") return null;
615
+ return sharedType;
616
+ };
617
+ this.getStorageLoaded = () => {
618
+ return this._state.storage.state === StorageState.Offline || this._state.storage.state === StorageState.Synced;
619
+ };
620
+ this.redo = () => {
621
+ this._crdtManager.doc.redo();
622
+ };
623
+ this.subscribe = new Proxy((name, callback) => this._stateNotifier.subscribe(name, callback), { get: (fn, prop) => {
624
+ if (prop === "connection") return (callback) => {
625
+ return fn("connection", callback);
626
+ };
627
+ if (prop === "myPresence") return (callback) => {
628
+ return fn("my-presence", callback);
629
+ };
630
+ if (prop === "myself") return (callback) => {
631
+ return fn("myself", callback);
632
+ };
633
+ if (prop === "others") return (callback) => {
634
+ return this._usersNotifier.subscribeOthers(callback);
635
+ };
636
+ if (prop === "storageLoaded") return (callback) => {
637
+ return fn("storage-loaded", callback);
638
+ };
639
+ if (prop === "event") return this._event;
640
+ if (prop === "other") return this._other;
641
+ if (prop === "storage") return this.#_storage;
642
+ } });
643
+ this.transact = (fn, origin) => {
644
+ const _origin = origin ?? this._state.connection.id;
645
+ /**
646
+ * !HACK
647
+ * @description Don't transact anything if there is no origin, because
648
+ * that means the user isn't connected yet. This will mean events are
649
+ * lost unfortunately.
650
+ * @date September 23, 2023
651
+ */
652
+ if (typeof _origin !== "string") return;
653
+ this._crdtManager.doc.transact(() => {
654
+ fn(this._crdtManager.doc.get());
655
+ }, _origin);
656
+ };
657
+ this.undo = () => {
658
+ this._crdtManager.doc.undo();
659
+ };
660
+ this.updateMyPresence = (presence) => {
661
+ const newPresence = typeof presence === "function" ? presence(this.getMyPresence()) : presence;
662
+ this._usersManager.updateMyPresence(newPresence);
663
+ const myPresence = this._usersManager.myPresence;
664
+ const myself = this._usersManager.myself ?? null;
665
+ this._stateNotifier.subjects["my-presence"].next(myPresence);
666
+ if (!!myself) this._stateNotifier.subjects.myself.next(myself);
667
+ this.broadcast("$updatePresence", { presence: newPresence });
668
+ };
669
+ this._event = new Proxy((event, callback) => this._eventNotifier.subscribe(event, callback), { get(fn, prop) {
670
+ return (callback) => fn(prop, callback);
671
+ } });
672
+ this._flattenStorageStore = debounce(async () => {
673
+ if (!await this._storageStore.getShouldFlatten()) return;
674
+ const updates = await this._storageStore.getUpdates();
675
+ this._crdtManager.applyUpdate({
676
+ update: updates,
677
+ origin: this
678
+ });
679
+ const encodedState = this._crdtManager.doc.getEncodedState();
680
+ await this._storageStore.flatten(encodedState);
681
+ }, { wait: ADD_TO_STORAGE_STATE_DEBOUNCE_MS });
682
+ this._getAddon = (addons) => {
683
+ return addons.reduce((acc, addon) => () => ({
684
+ ...acc({ room: this }),
685
+ ...addon({ room: this })
686
+ }), DEFAULT_PLUV_CLIENT_ADDON);
687
+ };
688
+ this._other = (connectionId, callback) => {
689
+ const clientId = this._usersManager.getClientId(connectionId);
690
+ if (!clientId) return () => void 0;
691
+ return this._usersNotifier.subscribeOther(clientId, callback);
692
+ };
693
+ this.#_storage = new Proxy((key, callback) => this._crdtNotifier.subscribe(key, callback), { get: (fn, prop) => {
694
+ if (!!prop) return (callback) => {
695
+ return this._crdtNotifier.subcribeRoot(callback);
696
+ };
697
+ return (callback) => {
698
+ return fn(prop, callback);
699
+ };
700
+ } });
701
+ const { addons = [], authEndpoint, debug = false, initialPresence, initialStorage, limits, metadata, onAuthorizationFail, presence, publicKey, reconnectTimeoutMs = RECONNECT_TIMEOUT_MS, router, wsEndpoint } = options;
702
+ this.id = room;
703
+ this.metadata = metadata;
704
+ const addon = this._getAddon(addons);
705
+ const { storage } = {
706
+ ...DEFAULT_PLUV_CLIENT_ADDON({ room: this }),
707
+ ...addon({ room: this })
708
+ };
709
+ this._storageStore = storage;
710
+ this._debug = debug;
711
+ this._endpoints = {
712
+ authEndpoint,
713
+ wsEndpoint
714
+ };
715
+ this._limits = limits;
716
+ this._reconnectTimeoutMs = reconnectTimeoutMs;
717
+ if (!!publicKey) this._publicKey = publicKey;
718
+ this._listeners = { onAuthorizationFail: (error) => {
719
+ onAuthorizationFail?.(error);
720
+ } };
721
+ this._router = router ?? new PluvRouter({});
722
+ this._usersManager = new UsersManager({
723
+ initialPresence,
724
+ limits: this._limits,
725
+ presence
726
+ });
727
+ this._crdtManager = new CrdtManager({ initialStorage });
728
+ }
729
+ get webSocket() {
730
+ return this._state.webSocket;
731
+ }
732
+ addEventListener(kind, handler) {
733
+ return this._listenerManager.subscribe(kind, handler);
734
+ }
735
+ async connect(...args) {
736
+ const params = args[0] ?? {};
737
+ const metadata = params.metadata;
738
+ this._setMetadata(metadata);
739
+ if (![
740
+ ConnectionState$1.Closed,
741
+ ConnectionState$1.Unavailable,
742
+ ConnectionState$1.Untouched
743
+ ].some((state) => this._state.connection.state === state)) return;
744
+ this._clearTimeout(this._timeouts.reconnect);
745
+ this._updateState((oldState) => {
746
+ oldState.connection.state = ConnectionState$1.Connecting;
747
+ return oldState;
748
+ });
749
+ await this._storageStore.initialize();
750
+ const wsEndpoint = this._getWsEndpoint(this.id, params);
751
+ const url = new URL(wsEndpoint);
752
+ let authToken = null;
753
+ let webSocket;
754
+ try {
755
+ const publicKey = this._getPublicKey(params);
756
+ authToken = await this._getAuthorization(this.id, params);
757
+ if (authToken) url.searchParams.set("token", encodeURIComponent(authToken));
758
+ if (publicKey) url.searchParams.set("public_key", encodeURIComponent(publicKey));
759
+ webSocket = new WebSocket(url.toString());
760
+ } catch (err) {
761
+ this._updateState((oldState) => {
762
+ oldState.connection.attempts += 1;
763
+ return oldState;
764
+ });
765
+ await this._storageStore.destroy();
766
+ this._pollReconnect();
767
+ await Promise.resolve(this._onAuthorizationFail(err));
768
+ return;
769
+ }
770
+ this._updateState((oldState) => {
771
+ oldState.connection.attempts = 0;
772
+ oldState.authorization.token = authToken;
773
+ oldState.storage.state = StorageState.Loading;
774
+ oldState.webSocket = webSocket;
775
+ return oldState;
776
+ });
777
+ this._attachWindowListeners();
778
+ this._attachWsListeners();
779
+ }
780
+ async disconnect() {
781
+ if (!this._state.webSocket) return;
782
+ if (![ConnectionState$1.Connecting, ConnectionState$1.Open].some((state) => this._state.connection.state === state)) return;
783
+ this._closeWs();
784
+ this._clearInterval(this._intervals.heartbeat);
785
+ this._clearTimeout(this._timeouts.reconnect);
786
+ this._updateState((oldState) => {
787
+ oldState.authorization.token = null;
788
+ oldState.connection.state = ConnectionState$1.Closed;
789
+ oldState.storage.state = oldState.storage.state === StorageState.Synced ? StorageState.Offline : StorageState.Unavailable;
790
+ oldState.webSocket = null;
791
+ return oldState;
792
+ });
793
+ await this._storageStore.destroy();
794
+ this._crdtManager.destroy();
795
+ this._stateNotifier.subjects["my-presence"].next(null);
796
+ }
797
+ getStorageJson(type) {
798
+ if (this._state.connection.id === null) return null;
799
+ if (typeof type === "undefined") return this._crdtManager.doc.toJson();
800
+ if (typeof this._crdtManager.get(type) === "undefined") return null;
801
+ return this._crdtManager.doc.toJson(type);
802
+ }
803
+ async _addToStorageStore(update) {
804
+ await this._storageStore.addUpdate(update);
805
+ this._flattenStorageStore();
806
+ }
807
+ _attachWindowListeners() {
808
+ if (typeof window === "undefined") return;
809
+ if (typeof document === "undefined") return;
810
+ this._windowListeners = this._windowListeners || {
811
+ onNavigatorOffline: this._onNavigatorOffline.bind(this),
812
+ onNavigatorOnline: this._onNavigatorOnline.bind(this),
813
+ onVisibilityChange: this._onVisibilityChange.bind(this),
814
+ onWindowFocus: this._onWindowFocus.bind(this)
815
+ };
816
+ window.addEventListener("offline", this._windowListeners.onNavigatorOffline);
817
+ window.addEventListener("online", this._windowListeners.onNavigatorOnline);
818
+ document.addEventListener("visibilitychange", this._windowListeners.onVisibilityChange);
819
+ window.addEventListener("focus", this._windowListeners.onWindowFocus);
820
+ }
821
+ _attachWsListeners() {
822
+ if (!this._state.webSocket) return;
823
+ this._wsListeners = this._wsListeners || {
824
+ onClose: this._onClose.bind(this),
825
+ onError: this._onError.bind(this),
826
+ onMessage: this._onMessage.bind(this),
827
+ onOpen: this._onOpen.bind(this)
828
+ };
829
+ this._state.webSocket.addEventListener("close", this._wsListeners.onClose);
830
+ this._state.webSocket.addEventListener("error", this._wsListeners.onError);
831
+ this._state.webSocket.addEventListener("message", this._wsListeners.onMessage);
832
+ this._state.webSocket.addEventListener("open", this._wsListeners.onOpen);
833
+ }
834
+ _clearInterval(interval) {
835
+ if (Number.isNaN(interval)) return;
836
+ clearInterval(interval);
837
+ }
838
+ _clearTimeout(timeout) {
839
+ if (Number.isNaN(timeout)) return;
840
+ clearTimeout(timeout);
841
+ }
842
+ _closeWs() {
843
+ this._subscriptions.observeCrdt?.();
844
+ this._clearInterval(this._intervals.heartbeat);
845
+ this._clearTimeout(this._timeouts.pong);
846
+ this._detachWindowListeners();
847
+ this._usersManager.removeMyself();
848
+ this._usersManager.clearConnections();
849
+ this._usersNotifier.clear();
850
+ this._stateNotifier.subjects.myself.next(null);
851
+ this._stateNotifier.subjects.others.next([]);
852
+ this._usersNotifier.others.next({
853
+ others: [],
854
+ event: { kind: "clear" }
855
+ });
856
+ this._state.webSocket?.close();
857
+ this._detachWsListeners();
858
+ }
859
+ _detachWindowListeners() {
860
+ if (!this._windowListeners) return;
861
+ if (typeof window === "undefined") return;
862
+ if (typeof document === "undefined") return;
863
+ window.removeEventListener("offline", this._windowListeners.onNavigatorOffline);
864
+ window.removeEventListener("online", this._windowListeners.onNavigatorOnline);
865
+ document.removeEventListener("visibilitychange", this._windowListeners.onVisibilityChange);
866
+ window.removeEventListener("focus", this._windowListeners.onWindowFocus);
867
+ this._windowListeners = null;
868
+ }
869
+ _detachWsListeners() {
870
+ if (!this._state.webSocket || !this._wsListeners) return;
871
+ this._state.webSocket.removeEventListener("close", this._wsListeners.onClose);
872
+ this._state.webSocket.removeEventListener("error", this._wsListeners.onError);
873
+ this._state.webSocket.removeEventListener("message", this._wsListeners.onMessage);
874
+ this._state.webSocket.removeEventListener("open", this._wsListeners.onOpen);
875
+ this._wsListeners = null;
876
+ }
877
+ _emitSharedTypes() {
878
+ const sharedTypes = this._crdtManager.doc.get();
879
+ const storageRoot = Object.keys(sharedTypes).reduce((acc, prop) => {
880
+ const serialized = this._crdtManager.doc.toJson(prop);
881
+ this._crdtNotifier.subject(prop).next(serialized);
882
+ return {
883
+ ...acc,
884
+ [prop]: serialized
885
+ };
886
+ }, {});
887
+ this._crdtNotifier.rootSubject.next(storageRoot);
888
+ }
889
+ _getAuthFetchOptions(room, params) {
890
+ const metadata = params.metadata;
891
+ if (typeof this._endpoints.authEndpoint === "undefined") return null;
892
+ if (this._endpoints.authEndpoint === true) return {
893
+ url: `/api/pluv/authorize?room=${room}`,
894
+ options: {}
895
+ };
896
+ if (typeof this._endpoints.authEndpoint === "string") return {
897
+ url: this._endpoints.authEndpoint,
898
+ options: {}
899
+ };
900
+ const result = this._endpoints.authEndpoint({
901
+ metadata,
902
+ room
903
+ });
904
+ return typeof result === "string" ? {
905
+ url: result,
906
+ options: {}
907
+ } : result;
908
+ }
909
+ async _getAuthorization(room, params) {
910
+ const fetchOptions = this._getAuthFetchOptions(room, params);
911
+ if (!fetchOptions) return null;
912
+ const { url, options } = fetchOptions;
913
+ const res = await fetch(url, options);
914
+ if (!res.ok || res.status !== 200) throw new Error("Room is unauthorized");
915
+ try {
916
+ return await res.text().then((text) => text.trim());
917
+ } catch (err) {
918
+ throw new Error(err instanceof Error ? err.message : "Room is unauthorized", { cause: err });
919
+ }
920
+ }
921
+ _getPublicKey(params) {
922
+ const metadata = params.metadata;
923
+ if (!this._publicKey) return null;
924
+ if (typeof this._publicKey === "string") return this._publicKey;
925
+ return this._publicKey({ metadata });
926
+ }
927
+ _getWsEndpoint(room, params) {
928
+ const metadata = params.metadata;
929
+ switch (typeof this._endpoints.wsEndpoint) {
930
+ case "undefined": return !!this._getPublicKey(params) ? `wss://rooms.pluv.io/api/room/${room}` : `/api/pluv/room/${room}`;
931
+ case "string": return this._endpoints.wsEndpoint;
932
+ default: return this._endpoints.wsEndpoint({
933
+ metadata,
934
+ room
935
+ });
936
+ }
937
+ }
938
+ _handleExit(message) {
939
+ const { connectionId } = message;
940
+ if (!connectionId) return;
941
+ if (!this._state.webSocket) throw new Error("Could not find WebSocket");
942
+ const clientId = this._usersManager.getClientId(connectionId);
943
+ const deleted = this._usersManager.deleteConnection(connectionId);
944
+ const others = this._usersManager.getOthers();
945
+ this._stateNotifier.subjects.others.next(others);
946
+ if (!deleted) {
947
+ console.warn("Could not identify exited connection");
948
+ return;
949
+ }
950
+ const { data: user, remaining } = deleted;
951
+ this._usersNotifier.others.next({
952
+ others,
953
+ event: {
954
+ kind: "leave",
955
+ user
956
+ }
957
+ });
958
+ /**
959
+ * @description A single user can have multiple connections. So we're checking that
960
+ * there isn't a remaining connection before choosing to delete that user for other
961
+ * connected participants.
962
+ * @date April 16, 2025
963
+ */
964
+ if (!remaining && !!clientId) this._usersNotifier.delete(clientId);
965
+ }
966
+ _handlePresenceUpdatedMessage(message) {
967
+ const { connectionId } = message;
968
+ if (!connectionId) return;
969
+ if (!this._state.webSocket) throw new Error("Could not find WebSocket");
970
+ const data = message.data;
971
+ const myself = this._usersManager.myself ?? null;
972
+ /**
973
+ * !HACK
974
+ * @description We're going to have the user's own presence be patched only via local calls
975
+ * to this.updateMyPresence. So we'll not update the user's presence in this handler to
976
+ * avoid weird update delays to the user's own presence.
977
+ *
978
+ * However, we will patch the user's own presence if the user has updated their own
979
+ * presence via another connection that is not this one (e.g. if the user has opened
980
+ * another connection in another browser tab/window somewhere).
981
+ * @date April 2, 2025
982
+ */
983
+ if (myself?.connectionId === connectionId) return;
984
+ const updated = this._usersManager.patchPresence(connectionId, data.presence);
985
+ const myClientId = !!myself ? this._usersManager.getClientId(myself) : null;
986
+ const clientId = this._usersManager.getClientId(connectionId);
987
+ if (!!clientId && myClientId === clientId) {
988
+ this._stateNotifier.subjects["my-presence"].next(updated);
989
+ this._stateNotifier.subjects.myself.next(this._usersManager.myself);
990
+ return;
991
+ }
992
+ if (!!clientId) {
993
+ const other = this._usersManager.getOther(connectionId);
994
+ const others = this._usersManager.getOthers();
995
+ this._usersNotifier.other(clientId).next(other);
996
+ this._stateNotifier.subjects.others.next(others);
997
+ if (!!other) this._usersNotifier.others.next({
998
+ others,
999
+ event: {
1000
+ kind: "update",
1001
+ user: other
1002
+ }
1003
+ });
1004
+ return;
1005
+ }
1006
+ /**
1007
+ * !HACK
1008
+ * @description User could not be found. Add the connection to keep others up-to-date
1009
+ * @date April 19, 2025
1010
+ */
1011
+ const added = this._usersManager.addConnection({
1012
+ connectionId,
1013
+ presence: data.presence,
1014
+ user: message.user
1015
+ });
1016
+ const other = this._usersManager.getOther(connectionId);
1017
+ const others = this._usersManager.getOthers();
1018
+ this._usersNotifier.other(added.clientId).next(other);
1019
+ this._stateNotifier.subjects.others.next(others);
1020
+ this._usersNotifier.others.next({
1021
+ others,
1022
+ event: {
1023
+ kind: "update",
1024
+ user: added.data
1025
+ }
1026
+ });
1027
+ }
1028
+ _handleReceiveOthers(message) {
1029
+ if (!message.connectionId) return;
1030
+ if (!this._state.webSocket) throw new Error("Could not find WebSocket");
1031
+ const data = message.data;
1032
+ Object.keys(data.others).forEach((connectionId) => {
1033
+ const { presence, user } = data.others[connectionId];
1034
+ const result = this._usersManager.addConnection({
1035
+ connectionId,
1036
+ presence,
1037
+ user
1038
+ });
1039
+ if (!!presence) this._usersManager.patchPresence(connectionId, presence);
1040
+ if (result.isMyself) return;
1041
+ const clientId = result.clientId;
1042
+ const other = this._usersManager.getOther(connectionId);
1043
+ this._usersNotifier.other(clientId).next(other);
1044
+ });
1045
+ const others = this._usersManager.getOthers();
1046
+ this._stateNotifier.subjects.others.next(others);
1047
+ this._usersNotifier.others.next({
1048
+ others,
1049
+ event: {
1050
+ kind: "sync",
1051
+ users: others
1052
+ }
1053
+ });
1054
+ }
1055
+ async _handleRegisteredMessage(message) {
1056
+ const { connectionId } = message;
1057
+ const user = message.user;
1058
+ if (!connectionId) return;
1059
+ if (!this._state.webSocket) throw new Error("Could not find WebSocket");
1060
+ const data = message.data;
1061
+ const state = data.state;
1062
+ this._updateState((oldState) => {
1063
+ oldState.connection.id = connectionId;
1064
+ oldState.authorization.user = user ?? null;
1065
+ return oldState;
1066
+ });
1067
+ const userInfo = {
1068
+ connectionId,
1069
+ presence: data.presence ?? void 0,
1070
+ user
1071
+ };
1072
+ this._usersManager.setMyself(userInfo);
1073
+ const presence = this._usersManager.myPresence;
1074
+ const myself = this._usersManager.myself ?? null;
1075
+ this._stateNotifier.subjects["my-presence"].next(presence);
1076
+ this._stateNotifier.subjects.myself.next(myself);
1077
+ const update = await (async () => {
1078
+ if (!!state) {
1079
+ this._logDebug("Retrieving initial state");
1080
+ return this._crdtManager.getInitialState();
1081
+ }
1082
+ const resolved = this._crdtManager.resolveEncodedState(await this._storageStore.getUpdates());
1083
+ if (!!resolved) {
1084
+ this._logDebug("Retrieving storage store state");
1085
+ return resolved;
1086
+ }
1087
+ this._logDebug("Retrieving initial state");
1088
+ return this._crdtManager.getInitialState();
1089
+ })();
1090
+ this._sendMessage({
1091
+ type: "$initializeSession",
1092
+ data: {
1093
+ presence,
1094
+ update
1095
+ }
1096
+ });
1097
+ }
1098
+ async _handleStorageReceivedMessage(message) {
1099
+ const { connectionId } = message;
1100
+ if (!connectionId) return;
1101
+ if (!this._state.webSocket) throw new Error("Could not find WebSocket");
1102
+ const data = message.data;
1103
+ const changeKind = data.changeKind;
1104
+ const state = data.state;
1105
+ const updates = await this._storageStore.getUpdates();
1106
+ const origin = ORIGIN_INITIALIZED;
1107
+ const updateStorage = (update) => {
1108
+ return this._crdtManager.initialized ? this._crdtManager.applyUpdate({
1109
+ update,
1110
+ origin
1111
+ }) : this._crdtManager.initialize({
1112
+ update,
1113
+ origin
1114
+ });
1115
+ };
1116
+ if (changeKind === "empty") {
1117
+ this._crdtManager.destroy();
1118
+ this._crdtManager.initialize({
1119
+ origin,
1120
+ update: updates
1121
+ });
1122
+ } else if (changeKind === "initialized") {
1123
+ if (!!updates.length) updateStorage(updates);
1124
+ else {
1125
+ const encodedState = updateStorage(state).doc.getEncodedState();
1126
+ this._addToStorageStore(encodedState);
1127
+ }
1128
+ } else {
1129
+ const encodedState = updateStorage(state).doc.getEncodedState();
1130
+ if (!!updates.length) this._crdtManager.applyUpdate({
1131
+ update: updates,
1132
+ origin
1133
+ });
1134
+ await this._addToStorageStore(encodedState);
1135
+ }
1136
+ const encodedState = this._crdtManager.doc.getEncodedState();
1137
+ this._emitSharedTypes();
1138
+ this._observeCrdt();
1139
+ this._updateState((oldState) => {
1140
+ oldState.storage.state = StorageState.Synced;
1141
+ return oldState;
1142
+ });
1143
+ this._stateNotifier.subjects["storage-loaded"].next(true);
1144
+ this._sendMessage({
1145
+ type: "$updateStorage",
1146
+ data: {
1147
+ origin,
1148
+ update: encodedState
1149
+ }
1150
+ });
1151
+ }
1152
+ _handleStorageUpdatedMessage(message) {
1153
+ const { connectionId } = message;
1154
+ if (!connectionId) return;
1155
+ if (!this._state.webSocket) throw new Error("Could not find WebSocket");
1156
+ const data = message.data;
1157
+ this._crdtManager.doc.applyEncodedState({
1158
+ origin: ORIGIN_STORAGE_UPDATED,
1159
+ update: data.state
1160
+ });
1161
+ const sharedTypes = this._crdtManager.doc.get();
1162
+ const storageRoot = Object.keys(sharedTypes).reduce((acc, prop) => {
1163
+ const serialized = this._crdtManager.doc.toJson(prop);
1164
+ this._crdtNotifier.subject(prop).next(serialized);
1165
+ return {
1166
+ ...acc,
1167
+ [prop]: serialized
1168
+ };
1169
+ }, {});
1170
+ this._crdtNotifier.rootSubject.next(storageRoot);
1171
+ }
1172
+ _handleSyncStateReceived(message) {
1173
+ const { connectionId } = message;
1174
+ if (!connectionId) return;
1175
+ if (!this._state.webSocket) throw new Error("Could not find WebSocket");
1176
+ if (!this._usersManager.myself) return;
1177
+ const data = message.data;
1178
+ const active = new Set(data?.connectionIds ?? []);
1179
+ this._usersManager.getOthers().filter((other) => !active.has(other.connectionId)).forEach((quitter) => {
1180
+ this._usersManager.deleteConnection(quitter.connectionId);
1181
+ const remaining = this._usersManager.getOthers();
1182
+ this._usersNotifier.others.next({
1183
+ others: remaining,
1184
+ event: {
1185
+ kind: "leave",
1186
+ user: quitter
1187
+ }
1188
+ });
1189
+ });
1190
+ const remaining = this._usersManager.getOthers();
1191
+ this._stateNotifier.subjects.others.next(remaining);
1192
+ }
1193
+ _handleUserJoinedMessage(message) {
1194
+ const { connectionId } = message;
1195
+ if (!connectionId) return;
1196
+ if (!this._state.webSocket) throw new Error("Could not find WebSocket");
1197
+ if (!this._usersManager.myself) return;
1198
+ const data = message.data;
1199
+ if (this._usersManager.myself.connectionId === connectionId) {
1200
+ this._sendMessage({
1201
+ type: "$getOthers",
1202
+ data: {}
1203
+ });
1204
+ return;
1205
+ }
1206
+ const added = this._usersManager.addConnection({
1207
+ connectionId,
1208
+ presence: data.presence,
1209
+ user: data.user
1210
+ });
1211
+ const other = this._usersManager.getOther(connectionId);
1212
+ const others = this._usersManager.getOthers();
1213
+ this._usersNotifier.other(added.clientId).next(other);
1214
+ this._stateNotifier.subjects.others.next(others);
1215
+ this._usersNotifier.others.next({
1216
+ others,
1217
+ event: {
1218
+ kind: "enter",
1219
+ user: added.data
1220
+ }
1221
+ });
1222
+ }
1223
+ _heartbeat() {
1224
+ this._clearTimeout(this._timeouts.pong);
1225
+ this._timeouts.pong = setTimeout(this._reconnect.bind(this), PONG_TIMEOUT_MS);
1226
+ /**
1227
+ * !HACK
1228
+ * @description Send data as a stable string so that the server can react to this in a
1229
+ * consistent way.
1230
+ * @date July 8, 2024
1231
+ */
1232
+ this._sendMessage("{\"type\":\"$ping\",\"data\":{}}");
1233
+ }
1234
+ _logDebug(...data) {
1235
+ if (typeof process === "undefined") return;
1236
+ if (process.env?.NODE_ENV === "production") return;
1237
+ if (this._debug) console.log(...data);
1238
+ }
1239
+ _observeCrdt() {
1240
+ this._subscriptions.observeCrdt?.();
1241
+ const unsubscribe = this._crdtManager.doc.subscribe((event) => {
1242
+ const origin = event.origin ?? null;
1243
+ this._emitSharedTypes();
1244
+ if (origin === ORIGIN_INITIALIZED || origin === ORIGIN_STORAGE_UPDATED) return;
1245
+ this._addToStorageStore(event.update);
1246
+ if (!this._state.webSocket) return;
1247
+ if (!this._state.connection.id) return;
1248
+ if (this._state.webSocket.readyState !== WebSocket.OPEN) return;
1249
+ this._sendMessage({
1250
+ type: "$updateStorage",
1251
+ data: {
1252
+ origin,
1253
+ update: event.update
1254
+ }
1255
+ });
1256
+ });
1257
+ this._subscriptions.observeCrdt = unsubscribe;
1258
+ }
1259
+ _onAuthorizationFail(error) {
1260
+ const authError = error instanceof Error ? error : /* @__PURE__ */ new Error("Authorization failed");
1261
+ this._listeners.onAuthorizationFail(authError);
1262
+ this._updateState((oldState) => {
1263
+ oldState.connection.state = ConnectionState$1.Unavailable;
1264
+ oldState.storage.state = StorageState.Unavailable;
1265
+ return oldState;
1266
+ });
1267
+ }
1268
+ async _onClose(event) {
1269
+ this._listenerManager.subjects.close.next(event);
1270
+ this._logDebug("WebSocket closed");
1271
+ if (!!event.reason) this._logDebug(event.reason);
1272
+ const shouldRetry = [
1273
+ 1001,
1274
+ 1006,
1275
+ 1011,
1276
+ 1012,
1277
+ 1013,
1278
+ 1014
1279
+ ].some((okToRetryCode) => event.code === okToRetryCode);
1280
+ this._updateState((oldState) => {
1281
+ oldState.authorization.token = null;
1282
+ oldState.connection.state = shouldRetry ? ConnectionState$1.Unavailable : ConnectionState$1.Closed;
1283
+ oldState.storage.state = oldState.storage.state === StorageState.Synced ? StorageState.Offline : StorageState.Unavailable;
1284
+ oldState.webSocket = null;
1285
+ return oldState;
1286
+ });
1287
+ if (shouldRetry) {
1288
+ await this._reconnect();
1289
+ return;
1290
+ }
1291
+ this._closeWs();
1292
+ this._clearTimeout(this._timeouts.reconnect);
1293
+ }
1294
+ /**
1295
+ * TODO
1296
+ * @description Handle websocket errors in a meaningful way, should they occur
1297
+ * @date August 19, 2022
1298
+ */
1299
+ _onError(event) {
1300
+ this._listenerManager.subjects.error.next(event);
1301
+ }
1302
+ _onMessage(event) {
1303
+ this._listenerManager.subjects.message.next(event);
1304
+ const message = this._parseMessage(event);
1305
+ if (!message) return;
1306
+ if (typeof this._debug === "boolean" ? this._debug : this._debug.output.find((value) => value === message.type)) this._logDebug("WebSocket event received: ", message.type, message);
1307
+ this._eventNotifier.subject(message.type).next(message);
1308
+ switch (message.type) {
1309
+ case "$exit":
1310
+ this._handleExit(message);
1311
+ return;
1312
+ case "$othersReceived":
1313
+ this._handleReceiveOthers(message);
1314
+ return;
1315
+ case "$pong":
1316
+ this._clearTimeout(this._timeouts.pong);
1317
+ return;
1318
+ case "$presenceUpdated":
1319
+ this._handlePresenceUpdatedMessage(message);
1320
+ return;
1321
+ case "$registered":
1322
+ this._handleRegisteredMessage(message);
1323
+ return;
1324
+ case "$storageReceived":
1325
+ this._handleStorageReceivedMessage(message);
1326
+ return;
1327
+ case "$storageUpdated":
1328
+ this._handleStorageUpdatedMessage(message);
1329
+ return;
1330
+ case "$syncStateReceived":
1331
+ this._handleSyncStateReceived(message);
1332
+ return;
1333
+ case "$userJoined":
1334
+ this._handleUserJoinedMessage(message);
1335
+ return;
1336
+ }
1337
+ }
1338
+ _onOpen(event) {
1339
+ this._listenerManager.subjects.open.next(event);
1340
+ if (this._state.connection.state !== ConnectionState$1.Connecting) return;
1341
+ this._logDebug("WebSocket connected");
1342
+ this._updateState((oldState) => {
1343
+ oldState.connection.state = ConnectionState$1.Open;
1344
+ return oldState;
1345
+ });
1346
+ this._clearInterval(this._intervals.heartbeat);
1347
+ this._intervals.heartbeat = setInterval(this._heartbeat.bind(this), HEARTBEAT_INTERVAL_MS);
1348
+ }
1349
+ async _onNavigatorOffline() {
1350
+ if (this._state.connection.state === ConnectionState$1.Closed) return;
1351
+ this._updateState((oldState) => {
1352
+ oldState.connection.state = ConnectionState$1.Unavailable;
1353
+ oldState.storage.state = oldState.storage.state === StorageState.Synced ? StorageState.Offline : StorageState.Unavailable;
1354
+ return oldState;
1355
+ });
1356
+ }
1357
+ async _onNavigatorOnline() {
1358
+ if (this._state.connection.state !== ConnectionState$1.Unavailable) return;
1359
+ await this._reconnect();
1360
+ }
1361
+ async _onVisibilityChange() {
1362
+ if (typeof document === "undefined") return;
1363
+ if (document.visibilityState !== "visible") return;
1364
+ switch (this._state.connection.state) {
1365
+ case ConnectionState$1.Open:
1366
+ this._clearInterval(this._intervals.heartbeat);
1367
+ setInterval(this._heartbeat.bind(this), HEARTBEAT_INTERVAL_MS);
1368
+ return;
1369
+ case ConnectionState$1.Unavailable:
1370
+ await this._reconnect();
1371
+ return;
1372
+ }
1373
+ }
1374
+ async _onWindowFocus() {
1375
+ return await this._onVisibilityChange();
1376
+ }
1377
+ _parseMessage(message) {
1378
+ /**
1379
+ * !HACK
1380
+ * @description We'll only handle stringified JSONs for now
1381
+ * @date August 9, 2022
1382
+ */
1383
+ if (typeof message.data !== "string") return null;
1384
+ try {
1385
+ const config = JSON.parse(message.data);
1386
+ if (typeof config !== "object") return null;
1387
+ if (typeof config.type !== "string") return null;
1388
+ if (typeof config.data !== "object") return null;
1389
+ return config;
1390
+ } catch {
1391
+ return null;
1392
+ }
1393
+ }
1394
+ _pollReconnect() {
1395
+ this._clearTimeout(this._timeouts.reconnect);
1396
+ const timeoutMs = typeof this._reconnectTimeoutMs === "number" ? this._reconnectTimeoutMs : this._reconnectTimeoutMs({ attempts: this._state.connection.attempts });
1397
+ const clampedMs = Math.max(Math.min(MIN_RECONNECT_TIMEOUT_MS, timeoutMs), MAX_RECONNECT_TIMEOUT_MS);
1398
+ this._timeouts.reconnect = setTimeout(this._reconnect.bind(this), clampedMs);
1399
+ }
1400
+ async _reconnect() {
1401
+ this._closeWs();
1402
+ /**
1403
+ * @description User has manually closed this connection. We're not going to retry, since
1404
+ * the user probably intends for this to stay closed. They will need to invoke connect
1405
+ * themselves.
1406
+ * @date April 28, 2025
1407
+ */
1408
+ if (this._state.connection.state === ConnectionState$1.Closed) return;
1409
+ const metadata = this._lastMetadata;
1410
+ const params = typeof metadata === "undefined" ? [] : [{ metadata }];
1411
+ await this.connect(...params);
1412
+ }
1413
+ _sendMessage(data) {
1414
+ const webSocket = this._state.webSocket;
1415
+ if (webSocket?.readyState !== WebSocket.OPEN) return;
1416
+ const dataType = (() => {
1417
+ if (typeof data === "object") return data.type ?? null;
1418
+ if (typeof data !== "string") return null;
1419
+ try {
1420
+ return JSON.parse(data)?.type ?? null;
1421
+ } catch {
1422
+ return null;
1423
+ }
1424
+ })();
1425
+ if (typeof this._debug === "boolean" ? this._debug : !dataType ? false : this._debug.input.some((value) => value === data.type)) this._logDebug("WebSocket event sent: ", data.type, data);
1426
+ const message = typeof data === "string" ? data : JSON.stringify(data);
1427
+ webSocket.send(message);
1428
+ }
1429
+ _setMetadata(metadata) {
1430
+ const parsed = this.metadata ? this.metadata.parse(metadata) : metadata;
1431
+ this._lastMetadata = parsed;
1432
+ return parsed;
1433
+ }
1434
+ #_storage;
1435
+ _updateState(updater) {
1436
+ let authorization;
1437
+ try {
1438
+ authorization = JSON.parse(JSON.stringify(this._state.authorization));
1439
+ } catch {
1440
+ throw new Error("User is not JSON serializable");
1441
+ }
1442
+ const newState = updater({
1443
+ ...this._state,
1444
+ authorization,
1445
+ connection: JSON.parse(JSON.stringify(this._state.connection)),
1446
+ storage: JSON.parse(JSON.stringify(this._state.storage)),
1447
+ webSocket: this._state.webSocket
1448
+ });
1449
+ this._state = newState;
1450
+ this._logDebug("WebSocket new state: ", newState);
1451
+ this._stateNotifier.subjects.connection.next(newState);
1452
+ return newState;
1453
+ }
1454
+ };
1455
+ //#endregion
1456
+ //#region src/PluvClient.ts
1457
+ var PluvClient = class {
1458
+ get _defs() {
1459
+ return { initialStorage: this._initialStorage };
1460
+ }
1461
+ get procedure() {
1462
+ return new PluvProcedure();
1463
+ }
1464
+ constructor(options) {
1465
+ this._publicKey = null;
1466
+ this._rooms = /* @__PURE__ */ new Map();
1467
+ this.createRoom = (room, options = {}) => {
1468
+ const oldRoom = this.getRoom(room);
1469
+ if (oldRoom) return oldRoom;
1470
+ const newRoom = new PluvRoom(room, {
1471
+ addons: options.addons,
1472
+ authEndpoint: this._authEndpoint,
1473
+ debug: options.debug,
1474
+ initialPresence: options.initialPresence,
1475
+ initialStorage: options.initialStorage ?? this._initialStorage,
1476
+ limits: this._limits,
1477
+ metadata: this.metadata,
1478
+ onAuthorizationFail: options.onAuthorizationFail,
1479
+ presence: this._presence,
1480
+ publicKey: this._publicKey ?? void 0,
1481
+ reconnectTimeoutMs: options.reconnectTimeoutMs,
1482
+ router: options.router,
1483
+ wsEndpoint: this._wsEndpoint
1484
+ });
1485
+ this._rooms.set(room, newRoom);
1486
+ this._logDebug(`New room was created: ${room}`);
1487
+ return newRoom;
1488
+ };
1489
+ this.enter = async (room, ...args) => {
1490
+ const toEnter = typeof room === "string" ? this.getRoom(room) : room;
1491
+ if (!toEnter) throw new Error(`Could not find room: ${room}.`);
1492
+ this._rooms.set(toEnter.id, toEnter);
1493
+ await toEnter.connect(...args);
1494
+ this._logDebug(`Entered room: ${room}`);
1495
+ return toEnter;
1496
+ };
1497
+ this.getRoom = (room) => {
1498
+ return this._rooms.get(room) ?? null;
1499
+ };
1500
+ this.getRooms = () => {
1501
+ return Array.from(this._rooms.values());
1502
+ };
1503
+ this.leave = async (room) => {
1504
+ const toLeave = typeof room === "string" ? this.getRoom(room) : room;
1505
+ if (!toLeave) return;
1506
+ await toLeave.disconnect();
1507
+ this._rooms.delete(toLeave.id);
1508
+ this._logDebug(`Left and deleted room: ${room}`);
1509
+ };
1510
+ const { authEndpoint, debug = false, initialStorage, limits, metadata, presence, publicKey, wsEndpoint } = options;
1511
+ this.metadata = metadata;
1512
+ this._authEndpoint = authEndpoint;
1513
+ this._debug = debug;
1514
+ this._initialStorage = initialStorage;
1515
+ this._limits = {
1516
+ presenceMaxSize: 512,
1517
+ ...limits
1518
+ };
1519
+ this._presence = presence;
1520
+ this._wsEndpoint = wsEndpoint;
1521
+ if (!!publicKey) this._publicKey = publicKey;
1522
+ }
1523
+ router(events) {
1524
+ const invalidName = Object.keys(events).find((name) => name.includes("$"));
1525
+ if (typeof invalidName === "string") throw new Error(`Invalid event name. Event names must not contain $: "${invalidName}"`);
1526
+ return new PluvRouter(events);
1527
+ }
1528
+ _logDebug(...data) {
1529
+ if (typeof process === "undefined") return;
1530
+ if (process.env?.NODE_ENV === "production") return;
1531
+ if (this._debug) console.log(...data);
1532
+ }
1533
+ };
1534
+ //#endregion
1535
+ //#region src/createClient.ts
1536
+ const createClient = (options) => {
1537
+ return new PluvClient(options);
1538
+ };
1539
+ //#endregion
1540
+ //#region src/infer.ts
1541
+ const infer = (callback) => {
1542
+ return callback;
1543
+ };
1544
+ //#endregion
1545
+ //#region src/MockedRoom.ts
1546
+ var MockedRoom = class {
1547
+ constructor(room, options) {
1548
+ this._crdtNotifier = new CrdtNotifier();
1549
+ this._eventNotifier = new EventNotifier();
1550
+ this._usersNotifier = new UsersNotifier();
1551
+ this._state = {
1552
+ authorization: {
1553
+ token: null,
1554
+ user: null
1555
+ },
1556
+ connection: {
1557
+ attempts: 0,
1558
+ id: null,
1559
+ state: ConnectionState$1.Untouched
1560
+ },
1561
+ storage: { state: StorageState.Unavailable },
1562
+ webSocket: null
1563
+ };
1564
+ this._stateNotifier = new StateNotifier();
1565
+ this._subscriptions = { observeCrdt: null };
1566
+ this.broadcast = new Proxy(async (event, data) => {
1567
+ if (!this._state.webSocket) return;
1568
+ if (this._state.connection.state !== ConnectionState$1.Open) return;
1569
+ const type = event.toString();
1570
+ const procedure = this._router._defs.events[type];
1571
+ if (!procedure?.config.broadcast) {
1572
+ this._simulateEvent(type, data);
1573
+ return;
1574
+ }
1575
+ const myself = this._usersManager.myself;
1576
+ if (!myself) return;
1577
+ const parsed = procedure.config.input ? procedure.config.input.parse(data) : data;
1578
+ const context = {
1579
+ doc: this._crdtManager.doc,
1580
+ others: this._usersManager.getOthers(),
1581
+ room: this.id,
1582
+ user: myself
1583
+ };
1584
+ const output = await procedure.config.broadcast(parsed, context);
1585
+ Object.entries(output).forEach(([_type, _data]) => {
1586
+ this._simulateEvent(_type, _data);
1587
+ });
1588
+ }, { get(fn, prop) {
1589
+ return async (data) => {
1590
+ return await fn(prop, data);
1591
+ };
1592
+ } });
1593
+ this.canRedo = () => {
1594
+ return !!this._crdtManager.doc.canRedo();
1595
+ };
1596
+ this.canUndo = () => {
1597
+ return !!this._crdtManager.doc.canUndo();
1598
+ };
1599
+ this.getConnection = () => {
1600
+ return Object.freeze(JSON.parse(JSON.stringify(this._state.connection)));
1601
+ };
1602
+ this.getMyPresence = () => {
1603
+ return this._usersManager.myPresence;
1604
+ };
1605
+ this.getMyself = () => {
1606
+ return this._usersManager.myself;
1607
+ };
1608
+ this.getOther = (connectionId) => {
1609
+ return this._usersManager.getOther(connectionId);
1610
+ };
1611
+ this.getOthers = () => {
1612
+ return this._usersManager.getOthers();
1613
+ };
1614
+ this.getStorage = (type) => {
1615
+ const sharedType = this._crdtManager.get(type);
1616
+ if (typeof sharedType === "undefined") return null;
1617
+ return sharedType;
1618
+ };
1619
+ this.redo = () => {
1620
+ this._crdtManager.doc.redo();
1621
+ };
1622
+ this.storageRoot = (fn) => {
1623
+ return this._crdtNotifier.subcribeRoot(fn);
1624
+ };
1625
+ this.subscribe = new Proxy((name, callback) => {
1626
+ return this._stateNotifier.subscribe(name, callback);
1627
+ }, { get: (fn, prop) => {
1628
+ if (prop === "connection") return (callback) => {
1629
+ return fn("connection", callback);
1630
+ };
1631
+ if (prop === "myPresence") return (callback) => {
1632
+ return fn("my-presence", callback);
1633
+ };
1634
+ if (prop === "myself") return (callback) => {
1635
+ return fn("myself", callback);
1636
+ };
1637
+ if (prop === "others") return (callback) => {
1638
+ return this._usersNotifier.subscribeOthers(callback);
1639
+ };
1640
+ if (prop === "storageLoaded") return (callback) => {
1641
+ return fn("storage-loaded", callback);
1642
+ };
1643
+ if (prop === "event") return this._event;
1644
+ if (prop === "other") return this._other;
1645
+ if (prop === "storage") return this.#_storage;
1646
+ } });
1647
+ this.transact = (fn, origin) => {
1648
+ const _origin = origin ?? this._state.connection.id;
1649
+ const crdtManager = this._crdtManager;
1650
+ /**
1651
+ * !HACK
1652
+ * @description Don't transact anything if there is no origin, because
1653
+ * that means the user isn't connected yet. This will mean events are
1654
+ * lost unfortunately.
1655
+ * @date September 23, 2023
1656
+ */
1657
+ if (typeof _origin !== "string") return;
1658
+ if (!crdtManager) return;
1659
+ crdtManager.doc.transact(() => {
1660
+ fn(crdtManager.doc.get());
1661
+ }, _origin);
1662
+ };
1663
+ this.undo = () => {
1664
+ this._crdtManager.doc.undo();
1665
+ };
1666
+ this.updateMyPresence = (presence) => {
1667
+ const newPresence = typeof presence === "function" ? presence(this.getMyPresence()) : presence;
1668
+ this._usersManager.updateMyPresence(newPresence);
1669
+ const myPresence = this._usersManager.myPresence;
1670
+ const myself = this._usersManager.myself ?? null;
1671
+ this._stateNotifier.subjects["my-presence"].next(myPresence);
1672
+ if (!!myself) this._stateNotifier.subjects["myself"].next(myself);
1673
+ };
1674
+ this._event = new Proxy((event, callback) => this._eventNotifier.subscribe(event, callback), { get(fn, prop) {
1675
+ return (callback) => fn(prop, callback);
1676
+ } });
1677
+ this._other = (connectionId, callback) => {
1678
+ const clientId = this._usersManager.getClientId(connectionId);
1679
+ if (!clientId) return () => void 0;
1680
+ return this._usersNotifier.subscribeOther(clientId, callback);
1681
+ };
1682
+ this.#_storage = new Proxy((key, callback) => this._crdtNotifier.subscribe(key, callback), { get: (fn, prop) => {
1683
+ if (!!prop) return (callback) => {
1684
+ return this._crdtNotifier.subcribeRoot(callback);
1685
+ };
1686
+ return (callback) => {
1687
+ return fn(prop, callback);
1688
+ };
1689
+ } });
1690
+ const { events, initialPresence, initialStorage, limits, presence, router } = options;
1691
+ this.id = room;
1692
+ this._events = events;
1693
+ this._limits = {
1694
+ presenceMaxSize: 512,
1695
+ ...limits
1696
+ };
1697
+ this._router = router ?? new PluvRouter({});
1698
+ this._usersManager = new UsersManager({
1699
+ initialPresence,
1700
+ limits: this._limits,
1701
+ presence
1702
+ });
1703
+ this._crdtManager = new CrdtManager({ initialStorage });
1704
+ this._observeCrdt();
1705
+ }
1706
+ addEventListener(kind, handler) {
1707
+ return () => void 0;
1708
+ }
1709
+ getDoc() {
1710
+ return this._crdtManager.doc;
1711
+ }
1712
+ getStorageJson(type) {
1713
+ if (this._state.connection.id === null) return null;
1714
+ if (typeof type === "undefined") return this._crdtManager.doc.toJson();
1715
+ return this._crdtManager.doc.toJson(type);
1716
+ }
1717
+ getStorageLoaded() {
1718
+ return true;
1719
+ }
1720
+ _observeCrdt() {
1721
+ this._subscriptions.observeCrdt?.();
1722
+ if (!this._crdtManager) return;
1723
+ const unsubscribe = this._crdtManager.doc.subscribe((event) => {
1724
+ const origin = event.origin ?? null;
1725
+ if (!this._crdtManager) return;
1726
+ if (origin === "$storageUpdated") return;
1727
+ const sharedTypes = this._crdtManager.doc.get();
1728
+ const storageRoot = Object.keys(sharedTypes).reduce((acc, prop) => {
1729
+ if (!this._crdtManager) return acc;
1730
+ const serialized = this._crdtManager.doc.toJson(prop);
1731
+ this._crdtNotifier.subject(prop).next(serialized);
1732
+ return {
1733
+ ...acc,
1734
+ [prop]: serialized
1735
+ };
1736
+ }, {});
1737
+ this._crdtNotifier.rootSubject.next(storageRoot);
1738
+ });
1739
+ this._subscriptions.observeCrdt = unsubscribe;
1740
+ }
1741
+ _simulateEvent(event, data) {
1742
+ if (!this._events) return;
1743
+ const type = event.toString();
1744
+ const resolver = this._events[type];
1745
+ if (!resolver) return;
1746
+ const result = resolver(data);
1747
+ Object.keys(result).forEach((type) => {
1748
+ const _type = type.toString();
1749
+ const data = result[_type];
1750
+ if (!data) return;
1751
+ this._eventNotifier.subject(_type).next(data);
1752
+ });
1753
+ }
1754
+ #_storage;
1755
+ };
1756
+ //#endregion
1757
+ //#region src/register.ts
1758
+ const register = (params) => {
1759
+ const { authEndpoint, publicKey } = params;
1760
+ const wsEndpoint = ({ room }) => `wss://connect.pluv.io/api/room?room=${room}`;
1761
+ return {
1762
+ authEndpoint,
1763
+ publicKey,
1764
+ wsEndpoint
1765
+ };
1766
+ };
1767
+ //#endregion
1768
+ export { AbstractStorageStore, ConnectionState, MockedRoom, PluvClient, PluvRoom, createClient, infer, register };
1769
+
1770
+ //# sourceMappingURL=index.mjs.map