@pluv/client 0.0.0-canary-20261027014949
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/LICENSE +21 -0
- package/README.md +18 -0
- package/dist/index.d.mts +357 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +2625 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +45 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,2625 @@
|
|
|
1
|
+
import { ConnectionState, ConnectionState as ConnectionState$1, StorageState } from "@pluv/types";
|
|
2
|
+
import { noop } from "@pluv/crdt";
|
|
3
|
+
import { makeSubject, subscribe } from "wonka";
|
|
4
|
+
// src/AbstractStorageStore.ts
|
|
5
|
+
var AbstractStorageStore = class {
|
|
6
|
+
constructor(room) {
|
|
7
|
+
this.room = room;
|
|
8
|
+
}
|
|
9
|
+
};
|
|
10
|
+
// src/constants.ts
|
|
11
|
+
const PLUV_PRESENCE_META_KEY = "__pluv";
|
|
12
|
+
const LIST_USERS_TIMEOUT_MS = 1e4;
|
|
13
|
+
// src/utils/assertSerializingSchema.ts
|
|
14
|
+
const getJsonSchemaProducer = (schema) => {
|
|
15
|
+
const jsonSchema = schema?.["~standard"]?.jsonSchema;
|
|
16
|
+
if (!jsonSchema || typeof jsonSchema.input !== "function") return null;
|
|
17
|
+
return jsonSchema;
|
|
18
|
+
};
|
|
19
|
+
const assertSerializingSchema = (schema) => {
|
|
20
|
+
if (!getJsonSchemaProducer(schema)) throw new Error("Schema must implement Standard Schema and Standard JSON Schema on the same object (e.g. Zod 4.2+ or ArkType).");
|
|
21
|
+
};
|
|
22
|
+
// src/CrdtManager.ts
|
|
23
|
+
var CrdtManager = class {
|
|
24
|
+
constructor(options) {
|
|
25
|
+
this.initialized = false;
|
|
26
|
+
const { initialStorage, storage = noop.doc() } = options;
|
|
27
|
+
this._docFactory = storage.getFactory(initialStorage);
|
|
28
|
+
this.doc = this._docFactory.getEmpty();
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* !HACK
|
|
32
|
+
* @description If this is called before the doc is initialized. Omit the
|
|
33
|
+
* update. This unfortunately means updates can be dropped entirely
|
|
34
|
+
* @date June 13, 2023
|
|
35
|
+
*/
|
|
36
|
+
applyUpdate(params) {
|
|
37
|
+
const { origin, update } = params;
|
|
38
|
+
if (!this.initialized) return this;
|
|
39
|
+
const updates = typeof update === "string" ? [update] : update;
|
|
40
|
+
this._applyDocUpdates(this.doc, updates, origin);
|
|
41
|
+
return this;
|
|
42
|
+
}
|
|
43
|
+
destroy() {
|
|
44
|
+
this.initialized = false;
|
|
45
|
+
this.doc.destroy();
|
|
46
|
+
this.doc = this._docFactory.getEmpty();
|
|
47
|
+
}
|
|
48
|
+
get(key) {
|
|
49
|
+
return this.doc.get(key);
|
|
50
|
+
}
|
|
51
|
+
getInitialState() {
|
|
52
|
+
return this._docFactory.getInitialized().getEncodedState();
|
|
53
|
+
}
|
|
54
|
+
initialize(params) {
|
|
55
|
+
const { origin, update } = params;
|
|
56
|
+
/**
|
|
57
|
+
* !HACK
|
|
58
|
+
* @description If the doc was already initialized, we don't want to re-initialize
|
|
59
|
+
* the doc and overwrite what's already there. Skip the initialization and log a
|
|
60
|
+
* warning.
|
|
61
|
+
* @date May 7, 2025
|
|
62
|
+
*/
|
|
63
|
+
if (this.initialized) {
|
|
64
|
+
console.warn("Attempted to initialize storage multiple times");
|
|
65
|
+
return this;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* !HACK
|
|
69
|
+
* @description If the doc is already populated, somehow we're attempting to overwrite
|
|
70
|
+
* a doc that was presumably initialized but not identified as initialized. Skip the
|
|
71
|
+
* initialization and log a warning.
|
|
72
|
+
* @date May 7, 2025
|
|
73
|
+
*/
|
|
74
|
+
if (!this.doc.isEmpty()) {
|
|
75
|
+
console.warn("Attempted to initialize over a non-empty storage");
|
|
76
|
+
return this;
|
|
77
|
+
}
|
|
78
|
+
const updates = typeof update === "string" ? [update] : update;
|
|
79
|
+
if (!updates.length) return this;
|
|
80
|
+
this.doc = this._applyDocUpdates(this.doc, updates, origin).rebuildStorage();
|
|
81
|
+
this.initialized = true;
|
|
82
|
+
return this;
|
|
83
|
+
}
|
|
84
|
+
resolveEncodedState(updates) {
|
|
85
|
+
return this._docFactory.resolveEncodedState(updates);
|
|
86
|
+
}
|
|
87
|
+
_applyDocUpdates(doc, updates, origin) {
|
|
88
|
+
doc.transact(() => {
|
|
89
|
+
doc.batchApplyEncodedState({
|
|
90
|
+
origin,
|
|
91
|
+
updates
|
|
92
|
+
});
|
|
93
|
+
}, origin);
|
|
94
|
+
return doc;
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
// src/CrdtNotifier.ts
|
|
98
|
+
var CrdtNotifier = class {
|
|
99
|
+
constructor() {
|
|
100
|
+
this.rootSubject = makeSubject();
|
|
101
|
+
this.subjects = /* @__PURE__ */ new Map();
|
|
102
|
+
}
|
|
103
|
+
subject(key) {
|
|
104
|
+
const subject = this.subjects.get(key);
|
|
105
|
+
if (subject) return subject;
|
|
106
|
+
const newSubject = makeSubject();
|
|
107
|
+
this.subjects.set(key, newSubject);
|
|
108
|
+
return newSubject;
|
|
109
|
+
}
|
|
110
|
+
subcribeRoot(callback) {
|
|
111
|
+
const subscription = subscribe(callback)(this.rootSubject.source);
|
|
112
|
+
return () => {
|
|
113
|
+
subscription.unsubscribe();
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
subscribe(key, callback) {
|
|
117
|
+
const subject = this.subject(key);
|
|
118
|
+
const subscription = subscribe(callback)(subject.source);
|
|
119
|
+
return () => {
|
|
120
|
+
subscription.unsubscribe();
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
// src/EventNotifier.ts
|
|
125
|
+
var EventNotifier = class {
|
|
126
|
+
constructor() {
|
|
127
|
+
this.subjects = /* @__PURE__ */ new Map();
|
|
128
|
+
}
|
|
129
|
+
subject(name) {
|
|
130
|
+
const subject = this.subjects.get(name);
|
|
131
|
+
if (subject) return subject;
|
|
132
|
+
const newSubject = makeSubject();
|
|
133
|
+
this.subjects.set(name, newSubject);
|
|
134
|
+
return newSubject;
|
|
135
|
+
}
|
|
136
|
+
subscribe(name, callback) {
|
|
137
|
+
const subject = this.subject(name);
|
|
138
|
+
const subscription = subscribe(callback)(subject.source);
|
|
139
|
+
return () => {
|
|
140
|
+
subscription.unsubscribe();
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
// src/ListenerManager.ts
|
|
145
|
+
var ListenerManager = class {
|
|
146
|
+
constructor() {
|
|
147
|
+
this.subjects = {
|
|
148
|
+
close: makeSubject(),
|
|
149
|
+
error: makeSubject(),
|
|
150
|
+
message: makeSubject(),
|
|
151
|
+
open: makeSubject()
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
subscribe(kind, callback) {
|
|
155
|
+
const source = this.subjects[kind]?.source;
|
|
156
|
+
if (!source) return () => void 0;
|
|
157
|
+
const subscription = subscribe(callback)(source);
|
|
158
|
+
return () => {
|
|
159
|
+
subscription.unsubscribe();
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
// src/PendingRequestManager.ts
|
|
164
|
+
/**
|
|
165
|
+
* Correlate in-flight request ids to replies on a multiplexed socket.
|
|
166
|
+
*/
|
|
167
|
+
var PendingRequestManager = class {
|
|
168
|
+
constructor() {
|
|
169
|
+
this._waiters = /* @__PURE__ */ new Map();
|
|
170
|
+
}
|
|
171
|
+
get size() {
|
|
172
|
+
return this._waiters.size;
|
|
173
|
+
}
|
|
174
|
+
request(params) {
|
|
175
|
+
const requestId = globalThis.crypto.randomUUID();
|
|
176
|
+
return {
|
|
177
|
+
promise: new Promise((resolve) => {
|
|
178
|
+
const timeoutId = setTimeout(() => {
|
|
179
|
+
this.complete(requestId, params.onTimeout());
|
|
180
|
+
}, params.timeoutMs);
|
|
181
|
+
this._waiters.set(requestId, {
|
|
182
|
+
onAbort: params.onAbort,
|
|
183
|
+
resolve,
|
|
184
|
+
timeoutId
|
|
185
|
+
});
|
|
186
|
+
}),
|
|
187
|
+
requestId
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
complete(requestId, result) {
|
|
191
|
+
const pending = this._waiters.get(requestId) ?? null;
|
|
192
|
+
if (!pending) return false;
|
|
193
|
+
this._waiters.delete(requestId);
|
|
194
|
+
clearTimeout(pending.timeoutId);
|
|
195
|
+
pending.resolve(result);
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
failAll() {
|
|
199
|
+
Array.from(this._waiters.entries()).forEach(([requestId, pending]) => {
|
|
200
|
+
this.complete(requestId, pending.onAbort());
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
// src/StateNotifier.ts
|
|
205
|
+
var StateNotifier = class {
|
|
206
|
+
constructor() {
|
|
207
|
+
this.subjects = {
|
|
208
|
+
connection: makeSubject(),
|
|
209
|
+
"my-presence": makeSubject(),
|
|
210
|
+
myself: makeSubject(),
|
|
211
|
+
others: makeSubject(),
|
|
212
|
+
roomStats: makeSubject(),
|
|
213
|
+
"storage-loaded": makeSubject()
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
subscribe(name, callback) {
|
|
217
|
+
const source = this.subjects[name]?.source;
|
|
218
|
+
if (!source) throw new Error(`Could not subscribe to source: ${name}`);
|
|
219
|
+
const subscription = subscribe(callback)(source);
|
|
220
|
+
return () => {
|
|
221
|
+
subscription.unsubscribe();
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
// src/StorageStore.ts
|
|
226
|
+
var StorageStore = class extends AbstractStorageStore {
|
|
227
|
+
addUpdate() {
|
|
228
|
+
return Promise.resolve();
|
|
229
|
+
}
|
|
230
|
+
applyUpdates() {
|
|
231
|
+
return Promise.resolve();
|
|
232
|
+
}
|
|
233
|
+
destroy() {
|
|
234
|
+
return Promise.resolve();
|
|
235
|
+
}
|
|
236
|
+
flatten() {
|
|
237
|
+
return Promise.resolve();
|
|
238
|
+
}
|
|
239
|
+
getShouldFlatten() {
|
|
240
|
+
return Promise.resolve(false);
|
|
241
|
+
}
|
|
242
|
+
getUpdates() {
|
|
243
|
+
return Promise.resolve([]);
|
|
244
|
+
}
|
|
245
|
+
initialize() {
|
|
246
|
+
return Promise.resolve();
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
// src/StorageSync.ts
|
|
250
|
+
/**
|
|
251
|
+
* Holds remote storage updates until the document has loaded, and remembers when a catch-up
|
|
252
|
+
* reply did not move the document.
|
|
253
|
+
*/
|
|
254
|
+
var StorageSync = class {
|
|
255
|
+
constructor() {
|
|
256
|
+
this._queue = [];
|
|
257
|
+
this._ready = false;
|
|
258
|
+
this._stalled = false;
|
|
259
|
+
}
|
|
260
|
+
get isReady() {
|
|
261
|
+
return this._ready;
|
|
262
|
+
}
|
|
263
|
+
hold(update) {
|
|
264
|
+
this._queue.push(update);
|
|
265
|
+
}
|
|
266
|
+
markReady() {
|
|
267
|
+
this._ready = true;
|
|
268
|
+
return this._takeQueue();
|
|
269
|
+
}
|
|
270
|
+
clearStall() {
|
|
271
|
+
this._stalled = false;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* @description The first time a catch-up reply does not move the document. Later calls
|
|
275
|
+
* stay quiet until `clearStall()` or `reset()`.
|
|
276
|
+
*/
|
|
277
|
+
noteStall() {
|
|
278
|
+
if (this._stalled) return false;
|
|
279
|
+
this._stalled = true;
|
|
280
|
+
return true;
|
|
281
|
+
}
|
|
282
|
+
reset() {
|
|
283
|
+
this._queue = [];
|
|
284
|
+
this._ready = false;
|
|
285
|
+
this._stalled = false;
|
|
286
|
+
}
|
|
287
|
+
_takeQueue() {
|
|
288
|
+
const queued = this._queue.splice(0);
|
|
289
|
+
if (queued.length) this.clearStall();
|
|
290
|
+
return queued;
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
// src/utils/assertExhaustive.ts
|
|
294
|
+
const assertExhaustive = (value) => {
|
|
295
|
+
throw new Error(`Unhandled value: ${value}`);
|
|
296
|
+
};
|
|
297
|
+
// src/utils/parsePluvSchema.ts
|
|
298
|
+
function parsePluvSchema(schema, data) {
|
|
299
|
+
const result = schema["~standard"].validate(data);
|
|
300
|
+
if (result instanceof Promise) throw new Error("Async schemas are not supported. Schema validate() must be synchronous.");
|
|
301
|
+
if (result.issues) {
|
|
302
|
+
const message = result.issues[0]?.message ?? "Invalid input";
|
|
303
|
+
throw new Error(message);
|
|
304
|
+
}
|
|
305
|
+
return result.value;
|
|
306
|
+
}
|
|
307
|
+
// src/utils/pickBy.ts
|
|
308
|
+
const pickBy = (obj, by) => {
|
|
309
|
+
const result = {};
|
|
310
|
+
const keys = Object.keys(obj);
|
|
311
|
+
for (let i = 0; i < keys.length; ++i) {
|
|
312
|
+
const key = keys[i];
|
|
313
|
+
const value = obj[key];
|
|
314
|
+
if (by(value)) result[key] = value;
|
|
315
|
+
}
|
|
316
|
+
return result;
|
|
317
|
+
};
|
|
318
|
+
// src/UsersManager.ts
|
|
319
|
+
var UsersManager = class {
|
|
320
|
+
constructor(config) {
|
|
321
|
+
this._idMap = {
|
|
322
|
+
fromConnectionId: /* @__PURE__ */ new Map(),
|
|
323
|
+
fromClientKey: {
|
|
324
|
+
operator: /* @__PURE__ */ new Map(),
|
|
325
|
+
user: /* @__PURE__ */ new Map()
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
this._myself = null;
|
|
329
|
+
this._operators = /* @__PURE__ */ new Map();
|
|
330
|
+
this._others = /* @__PURE__ */ new Map();
|
|
331
|
+
this._presence = null;
|
|
332
|
+
this._presenceSeqs = {
|
|
333
|
+
operator: /* @__PURE__ */ new Map(),
|
|
334
|
+
user: /* @__PURE__ */ new Map()
|
|
335
|
+
};
|
|
336
|
+
this._localPresenceInFlight = 0;
|
|
337
|
+
const { initialPresence, limits, presence = null } = config;
|
|
338
|
+
this._presence = presence;
|
|
339
|
+
this._limits = limits;
|
|
340
|
+
const resolved = presence ? parsePluvSchema(presence, initialPresence ?? {}) : initialPresence ?? {};
|
|
341
|
+
this.initialPresence = resolved;
|
|
342
|
+
this._myPresence = resolved;
|
|
343
|
+
}
|
|
344
|
+
get myPresence() {
|
|
345
|
+
return this._myPresence;
|
|
346
|
+
}
|
|
347
|
+
get myself() {
|
|
348
|
+
return this._myself;
|
|
349
|
+
}
|
|
350
|
+
addConnection(params) {
|
|
351
|
+
/**
|
|
352
|
+
* @description Ensure that the presence is complete, so that it does not fail schema
|
|
353
|
+
* validation
|
|
354
|
+
* @date April 20, 2025
|
|
355
|
+
*/
|
|
356
|
+
const cleaned = pickBy(params.presence ?? {}, (value) => typeof value !== "undefined");
|
|
357
|
+
const presence = {
|
|
358
|
+
...this.initialPresence,
|
|
359
|
+
...cleaned
|
|
360
|
+
};
|
|
361
|
+
const info = this._toUserInfo(params, presence);
|
|
362
|
+
const clientKey = this._clientKeyFromInfo(info);
|
|
363
|
+
const myClientKey = this._myself ? this._clientKeyFromInfo(this._myself) : null;
|
|
364
|
+
const remaining = this._setConnectionId(params.connectionId, clientKey).size;
|
|
365
|
+
if (this._sameClientKey(myClientKey, clientKey)) return {
|
|
366
|
+
data: this._myself ?? info,
|
|
367
|
+
isMyself: true,
|
|
368
|
+
presenceChanged: false,
|
|
369
|
+
remaining
|
|
370
|
+
};
|
|
371
|
+
const others = this._mapForKind(clientKey.kind);
|
|
372
|
+
if (!others.get(clientKey.id)) {
|
|
373
|
+
others.set(clientKey.id, info);
|
|
374
|
+
this._setPresenceSeq(clientKey, params.presenceSeq ?? null);
|
|
375
|
+
return {
|
|
376
|
+
data: info,
|
|
377
|
+
isMyself: false,
|
|
378
|
+
presenceChanged: true,
|
|
379
|
+
remaining
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
const presenceChanged = this._isNewerPresenceSeq(clientKey, params.presenceSeq);
|
|
383
|
+
if (presenceChanged) {
|
|
384
|
+
others.set(clientKey.id, info);
|
|
385
|
+
this._setPresenceSeq(clientKey, params.presenceSeq);
|
|
386
|
+
}
|
|
387
|
+
return {
|
|
388
|
+
data: others.get(clientKey.id) ?? info,
|
|
389
|
+
isMyself: false,
|
|
390
|
+
presenceChanged,
|
|
391
|
+
remaining
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
clearConnections() {
|
|
395
|
+
this.removeMyself();
|
|
396
|
+
this._operators.clear();
|
|
397
|
+
this._others.clear();
|
|
398
|
+
this._presenceSeqs.operator.clear();
|
|
399
|
+
this._presenceSeqs.user.clear();
|
|
400
|
+
this._localPresenceInFlight = 0;
|
|
401
|
+
this._idMap.fromClientKey.operator.clear();
|
|
402
|
+
this._idMap.fromClientKey.user.clear();
|
|
403
|
+
this._idMap.fromConnectionId.clear();
|
|
404
|
+
}
|
|
405
|
+
deleteConnection(connectionId) {
|
|
406
|
+
const clientKey = this._clientKeyForConnection(connectionId);
|
|
407
|
+
this._deleteConnectionId(connectionId);
|
|
408
|
+
if (!clientKey) return null;
|
|
409
|
+
const userInfo = this._getByClientKey(clientKey);
|
|
410
|
+
if (!userInfo) return null;
|
|
411
|
+
const remaining = this._getConnectionIds(clientKey)?.size ?? 0;
|
|
412
|
+
const result = {
|
|
413
|
+
remaining,
|
|
414
|
+
data: userInfo
|
|
415
|
+
};
|
|
416
|
+
/**
|
|
417
|
+
* @description A user may have multiple websocket connections open. If one connection is
|
|
418
|
+
* dropped, the user may still be connected via another websocket (e.g. on anther browser
|
|
419
|
+
* tab). In this case, we just want to remove the connection mapping for the connection
|
|
420
|
+
* that is dropped, but keep the remaining connections and the user.
|
|
421
|
+
* @date April 16, 2025
|
|
422
|
+
*/
|
|
423
|
+
if (!remaining) {
|
|
424
|
+
this._mapForKind(clientKey.kind).delete(clientKey.id);
|
|
425
|
+
this._presenceSeqsForKind(clientKey.kind).delete(clientKey.id);
|
|
426
|
+
}
|
|
427
|
+
return result;
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Public participant id. Both kinds use `data.id`.
|
|
431
|
+
*/
|
|
432
|
+
getParticipantId(info) {
|
|
433
|
+
return this._clientKeyFromInfo(info).id;
|
|
434
|
+
}
|
|
435
|
+
isMyselfConnection(connectionId) {
|
|
436
|
+
if (!this._myself) return false;
|
|
437
|
+
return this._sameClientKey(this._clientKeyForConnection(connectionId), this._clientKeyFromInfo(this._myself));
|
|
438
|
+
}
|
|
439
|
+
getOther(userId, options) {
|
|
440
|
+
const kind = options?.kind ?? "user";
|
|
441
|
+
switch (kind) {
|
|
442
|
+
case "user": return this._others.get(userId) ?? null;
|
|
443
|
+
case "operator": return this._operators.get(userId) ?? null;
|
|
444
|
+
default: return assertExhaustive(kind);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
getOtherByConnectionId(connectionId, options) {
|
|
448
|
+
const clientKey = this._clientKeyForConnection(connectionId);
|
|
449
|
+
if (!clientKey) return null;
|
|
450
|
+
const info = this._getByClientKey(clientKey);
|
|
451
|
+
if (!info) return null;
|
|
452
|
+
return (options?.kinds ?? ["user"]).includes(info.kind) ? info : null;
|
|
453
|
+
}
|
|
454
|
+
getOthers(options) {
|
|
455
|
+
const kinds = options?.kinds ?? ["user"];
|
|
456
|
+
const others = [];
|
|
457
|
+
if (kinds.includes("user")) others.push(...this._others.values());
|
|
458
|
+
if (kinds.includes("operator")) others.push(...this._operators.values());
|
|
459
|
+
return others;
|
|
460
|
+
}
|
|
461
|
+
getOccupancy(options) {
|
|
462
|
+
const kinds = new Set(options?.kinds ?? ["user"]);
|
|
463
|
+
const myselfKind = this._myself?.kind ?? "user";
|
|
464
|
+
const includeMyself = !!this._myself && kinds.has(myselfKind);
|
|
465
|
+
const myClientKey = this._myself ? this._clientKeyFromInfo(this._myself) : null;
|
|
466
|
+
let connectionCount = 0;
|
|
467
|
+
this._idMap.fromConnectionId.forEach((clientKey) => {
|
|
468
|
+
const info = this._sameClientKey(clientKey, myClientKey) ? this._myself : this._getByClientKey(clientKey);
|
|
469
|
+
if (info && kinds.has(info.kind)) connectionCount += 1;
|
|
470
|
+
});
|
|
471
|
+
const userCount = (kinds.has("user") ? this._others.size : 0) + (kinds.has("operator") ? this._operators.size : 0) + (includeMyself ? 1 : 0);
|
|
472
|
+
return {
|
|
473
|
+
connectionCount,
|
|
474
|
+
userCount
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
beginLocalPresenceWrite() {
|
|
478
|
+
this._localPresenceInFlight += 1;
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* @returns Whether this own-connection echo should apply. Stale echoes
|
|
482
|
+
* during a burst of local writes return false so they cannot rewind.
|
|
483
|
+
*/
|
|
484
|
+
ackOwnPresenceEcho() {
|
|
485
|
+
if (this._localPresenceInFlight > 0) this._localPresenceInFlight -= 1;
|
|
486
|
+
return this._localPresenceInFlight === 0;
|
|
487
|
+
}
|
|
488
|
+
patchPresence(params) {
|
|
489
|
+
const { connectionId, session, user } = params;
|
|
490
|
+
const { operator, presence: patch, seq } = session;
|
|
491
|
+
const presenceSeq = seq.presence;
|
|
492
|
+
const clientKey = this._clientKeyForConnection(connectionId);
|
|
493
|
+
const myClientKey = this._myself ? this._clientKeyFromInfo(this._myself) : null;
|
|
494
|
+
if (!clientKey) return null;
|
|
495
|
+
if (this._sameClientKey(myClientKey, clientKey)) {
|
|
496
|
+
if (!this._shouldApplyPresenceSeq(clientKey, presenceSeq)) return {
|
|
497
|
+
applied: false,
|
|
498
|
+
presence: this._myself?.presence ?? this._myPresence
|
|
499
|
+
};
|
|
500
|
+
return {
|
|
501
|
+
applied: true,
|
|
502
|
+
presence: this.updateMyPresence(patch, presenceSeq)
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
const other = this._getByClientKey(clientKey);
|
|
506
|
+
if (!other) return null;
|
|
507
|
+
if (!this._shouldApplyPresenceSeq(clientKey, presenceSeq)) return {
|
|
508
|
+
applied: false,
|
|
509
|
+
presence: other.presence
|
|
510
|
+
};
|
|
511
|
+
const cleanedPatch = pickBy(patch ?? {}, (value) => typeof value !== "undefined");
|
|
512
|
+
const cleanedPresence = pickBy(other.presence ?? {}, (value) => typeof value !== "undefined");
|
|
513
|
+
const presence = {
|
|
514
|
+
...this.initialPresence,
|
|
515
|
+
...cleanedPresence,
|
|
516
|
+
...cleanedPatch
|
|
517
|
+
};
|
|
518
|
+
const validated = this._presence ? parsePluvSchema(this._presence, presence) : presence;
|
|
519
|
+
/**
|
|
520
|
+
* !HACK
|
|
521
|
+
* @description We're patching internal metadata back into the presence field so it doesn't
|
|
522
|
+
* get stripped from validation
|
|
523
|
+
* @date May 13, 2025
|
|
524
|
+
*/
|
|
525
|
+
if (!!presence["__pluv"]) validated[PLUV_PRESENCE_META_KEY] = presence[PLUV_PRESENCE_META_KEY];
|
|
526
|
+
this._mapForKind(clientKey.kind).set(clientKey.id, {
|
|
527
|
+
...other,
|
|
528
|
+
data: user,
|
|
529
|
+
operator,
|
|
530
|
+
presence: validated
|
|
531
|
+
});
|
|
532
|
+
this._setPresenceSeq(clientKey, presenceSeq);
|
|
533
|
+
return {
|
|
534
|
+
applied: true,
|
|
535
|
+
presence: validated
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
pruneConnections(activeConnectionIds) {
|
|
539
|
+
const myClientKey = this._myself ? this._clientKeyFromInfo(this._myself) : null;
|
|
540
|
+
const left = [];
|
|
541
|
+
Array.from(this._idMap.fromConnectionId.keys()).forEach((connectionId) => {
|
|
542
|
+
if (activeConnectionIds.has(connectionId)) return;
|
|
543
|
+
const clientKey = this._clientKeyForConnection(connectionId);
|
|
544
|
+
if (!clientKey || this._sameClientKey(clientKey, myClientKey)) return;
|
|
545
|
+
const deleted = this.deleteConnection(connectionId);
|
|
546
|
+
if (deleted && !deleted.remaining) left.push(deleted.data);
|
|
547
|
+
});
|
|
548
|
+
return left;
|
|
549
|
+
}
|
|
550
|
+
removeMyself() {
|
|
551
|
+
if (!this._myself) return;
|
|
552
|
+
const clientKey = this._clientKeyFromInfo(this._myself);
|
|
553
|
+
this._getConnectionIds(clientKey)?.forEach((connectionId) => {
|
|
554
|
+
this._idMap.fromConnectionId.delete(connectionId);
|
|
555
|
+
});
|
|
556
|
+
this._connectionsForKind(clientKey.kind).delete(clientKey.id);
|
|
557
|
+
this._presenceSeqsForKind(clientKey.kind).delete(clientKey.id);
|
|
558
|
+
this._localPresenceInFlight = 0;
|
|
559
|
+
this._myself = null;
|
|
560
|
+
}
|
|
561
|
+
replaceOthers(rows) {
|
|
562
|
+
const myClientKey = this._myself ? this._clientKeyFromInfo(this._myself) : null;
|
|
563
|
+
const previousUsers = new Map(this._others);
|
|
564
|
+
const previousOperators = new Map(this._operators);
|
|
565
|
+
const previousUserSeqs = new Map(this._presenceSeqs.user);
|
|
566
|
+
const previousOperatorSeqs = new Map(this._presenceSeqs.operator);
|
|
567
|
+
Array.from(this._idMap.fromConnectionId.entries()).forEach(([connectionId, clientKey]) => {
|
|
568
|
+
if (this._sameClientKey(clientKey, myClientKey)) return;
|
|
569
|
+
this._idMap.fromConnectionId.delete(connectionId);
|
|
570
|
+
});
|
|
571
|
+
Array.from(this._idMap.fromClientKey.user.keys()).forEach((id) => {
|
|
572
|
+
if (myClientKey?.kind === "user" && myClientKey.id === id) return;
|
|
573
|
+
this._idMap.fromClientKey.user.delete(id);
|
|
574
|
+
});
|
|
575
|
+
Array.from(this._idMap.fromClientKey.operator.keys()).forEach((id) => {
|
|
576
|
+
if (myClientKey?.kind === "operator" && myClientKey.id === id) return;
|
|
577
|
+
this._idMap.fromClientKey.operator.delete(id);
|
|
578
|
+
});
|
|
579
|
+
this._others.clear();
|
|
580
|
+
this._operators.clear();
|
|
581
|
+
this._presenceSeqs.user.clear();
|
|
582
|
+
this._presenceSeqs.operator.clear();
|
|
583
|
+
if (myClientKey) {
|
|
584
|
+
const mySeq = (() => {
|
|
585
|
+
switch (myClientKey.kind) {
|
|
586
|
+
case "user": return previousUserSeqs.get(myClientKey.id);
|
|
587
|
+
case "operator": return previousOperatorSeqs.get(myClientKey.id);
|
|
588
|
+
default: return assertExhaustive(myClientKey.kind);
|
|
589
|
+
}
|
|
590
|
+
})();
|
|
591
|
+
if (mySeq !== void 0) this._setPresenceSeq(myClientKey, mySeq);
|
|
592
|
+
}
|
|
593
|
+
rows.forEach((row) => {
|
|
594
|
+
const cleaned = pickBy(row.presence ?? {}, (value) => typeof value !== "undefined");
|
|
595
|
+
const snapshotPresence = {
|
|
596
|
+
...this.initialPresence,
|
|
597
|
+
...cleaned
|
|
598
|
+
};
|
|
599
|
+
const info = this._toUserInfo(row, snapshotPresence);
|
|
600
|
+
const clientKey = this._clientKeyFromInfo(info);
|
|
601
|
+
const previous = (() => {
|
|
602
|
+
switch (clientKey.kind) {
|
|
603
|
+
case "user": return previousUsers.get(clientKey.id);
|
|
604
|
+
case "operator": return previousOperators.get(clientKey.id);
|
|
605
|
+
default: return assertExhaustive(clientKey.kind);
|
|
606
|
+
}
|
|
607
|
+
})();
|
|
608
|
+
const previousSeq = (() => {
|
|
609
|
+
switch (clientKey.kind) {
|
|
610
|
+
case "user": return previousUserSeqs.get(clientKey.id);
|
|
611
|
+
case "operator": return previousOperatorSeqs.get(clientKey.id);
|
|
612
|
+
default: return assertExhaustive(clientKey.kind);
|
|
613
|
+
}
|
|
614
|
+
})();
|
|
615
|
+
const snapshotNewer = this._isNewerPresenceSeqValue(previousSeq, row.presenceSeq);
|
|
616
|
+
if (previous && !snapshotNewer) {
|
|
617
|
+
this._mapForKind(previous.kind).set(clientKey.id, previous);
|
|
618
|
+
this._setPresenceSeq(clientKey, previousSeq ?? null);
|
|
619
|
+
} else {
|
|
620
|
+
this._mapForKind(info.kind).set(clientKey.id, info);
|
|
621
|
+
this._setPresenceSeq(clientKey, row.presenceSeq ?? null);
|
|
622
|
+
}
|
|
623
|
+
row.connectionIds.forEach((connectionId) => {
|
|
624
|
+
this._setConnectionId(connectionId, clientKey);
|
|
625
|
+
});
|
|
626
|
+
});
|
|
627
|
+
const left = [];
|
|
628
|
+
previousUsers.forEach((_, id) => {
|
|
629
|
+
if (!this._others.has(id)) left.push({
|
|
630
|
+
id,
|
|
631
|
+
kind: "user"
|
|
632
|
+
});
|
|
633
|
+
});
|
|
634
|
+
previousOperators.forEach((_, id) => {
|
|
635
|
+
if (!this._operators.has(id)) left.push({
|
|
636
|
+
id,
|
|
637
|
+
kind: "operator"
|
|
638
|
+
});
|
|
639
|
+
});
|
|
640
|
+
return left;
|
|
641
|
+
}
|
|
642
|
+
setMyself(params) {
|
|
643
|
+
const presence = params.presence ?? this.initialPresence;
|
|
644
|
+
const info = this._toUserInfo(params, presence);
|
|
645
|
+
const clientKey = this._clientKeyFromInfo(info);
|
|
646
|
+
this._myself = info;
|
|
647
|
+
this._myPresence = presence;
|
|
648
|
+
this._setPresenceSeq(clientKey, params.presenceSeq ?? null);
|
|
649
|
+
this._setConnectionId(params.connectionId, clientKey);
|
|
650
|
+
}
|
|
651
|
+
setMyConnectionIds(connectionIds) {
|
|
652
|
+
if (!this._myself) return;
|
|
653
|
+
const clientKey = this._clientKeyFromInfo(this._myself);
|
|
654
|
+
this._getConnectionIds(clientKey)?.forEach((connectionId) => {
|
|
655
|
+
this._idMap.fromConnectionId.delete(connectionId);
|
|
656
|
+
});
|
|
657
|
+
this._connectionsForKind(clientKey.kind).delete(clientKey.id);
|
|
658
|
+
connectionIds.forEach((connectionId) => {
|
|
659
|
+
this._setConnectionId(connectionId, clientKey);
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
setPresence(connectionId, presence, presenceSeq) {
|
|
663
|
+
const clientKey = this._clientKeyForConnection(connectionId);
|
|
664
|
+
const myClientKey = this._myself ? this._clientKeyFromInfo(this._myself) : null;
|
|
665
|
+
if (!clientKey) return;
|
|
666
|
+
if (!this._shouldApplyPresenceSeq(clientKey, presenceSeq)) return;
|
|
667
|
+
if (this._sameClientKey(myClientKey, clientKey)) {
|
|
668
|
+
if (!this._myself) return;
|
|
669
|
+
this._myself.presence = presence;
|
|
670
|
+
this._myPresence = presence;
|
|
671
|
+
this._setPresenceSeq(clientKey, presenceSeq);
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
const other = this._getByClientKey(clientKey);
|
|
675
|
+
if (!other) return;
|
|
676
|
+
this._mapForKind(clientKey.kind).set(clientKey.id, {
|
|
677
|
+
...other,
|
|
678
|
+
presence
|
|
679
|
+
});
|
|
680
|
+
this._setPresenceSeq(clientKey, presenceSeq);
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* @description This method need not care about being connected. This is because a user should
|
|
684
|
+
* be able to view and update their own presence without being online
|
|
685
|
+
* @date April 20, 2025
|
|
686
|
+
*/
|
|
687
|
+
updateMyPresence(patch, presenceSeq) {
|
|
688
|
+
const cleanedPatch = pickBy(patch, (value) => typeof value !== "undefined");
|
|
689
|
+
const cleanedPresence = pickBy(this._myPresence, (value) => typeof value !== "undefined");
|
|
690
|
+
const updated = {
|
|
691
|
+
...this.initialPresence,
|
|
692
|
+
...cleanedPresence,
|
|
693
|
+
...cleanedPatch
|
|
694
|
+
};
|
|
695
|
+
const bytes = new TextEncoder().encode(JSON.stringify(updated)).length;
|
|
696
|
+
if (!!this._limits.presenceMaxSize && bytes > this._limits.presenceMaxSize) throw new Error(`Large presence. Presence must be at most 512 bytes. Current size: ${bytes.toLocaleString()}`);
|
|
697
|
+
this._myPresence = updated;
|
|
698
|
+
if (!this._myself) return updated;
|
|
699
|
+
this._myself.presence = updated;
|
|
700
|
+
this._setPresenceSeq(this._clientKeyFromInfo(this._myself), presenceSeq);
|
|
701
|
+
return updated;
|
|
702
|
+
}
|
|
703
|
+
_toUserInfo(params, presence) {
|
|
704
|
+
switch (params.kind) {
|
|
705
|
+
case "operator": return {
|
|
706
|
+
data: params.data,
|
|
707
|
+
kind: params.kind,
|
|
708
|
+
operator: params.operator ?? null,
|
|
709
|
+
presence
|
|
710
|
+
};
|
|
711
|
+
case "user": return {
|
|
712
|
+
data: params.data,
|
|
713
|
+
kind: params.kind,
|
|
714
|
+
operator: null,
|
|
715
|
+
presence
|
|
716
|
+
};
|
|
717
|
+
default: return assertExhaustive(params.kind);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
_clientKeyForConnection(connectionId) {
|
|
721
|
+
return this._idMap.fromConnectionId.get(connectionId) ?? null;
|
|
722
|
+
}
|
|
723
|
+
_clientKeyFromInfo(info) {
|
|
724
|
+
const id = info.data && typeof info.data.id === "string" ? info.data.id : null;
|
|
725
|
+
if (!id) throw new Error("Could not resolve user id");
|
|
726
|
+
switch (info.kind) {
|
|
727
|
+
case "operator":
|
|
728
|
+
case "user": return {
|
|
729
|
+
kind: info.kind,
|
|
730
|
+
id
|
|
731
|
+
};
|
|
732
|
+
default: return assertExhaustive(info.kind);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
_sameClientKey(a, b) {
|
|
736
|
+
return !!a && !!b && a.kind === b.kind && a.id === b.id;
|
|
737
|
+
}
|
|
738
|
+
_mapForKind(kind) {
|
|
739
|
+
switch (kind) {
|
|
740
|
+
case "operator": return this._operators;
|
|
741
|
+
case "user": return this._others;
|
|
742
|
+
default: return assertExhaustive(kind);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
_presenceSeqsForKind(kind) {
|
|
746
|
+
switch (kind) {
|
|
747
|
+
case "operator": return this._presenceSeqs.operator;
|
|
748
|
+
case "user": return this._presenceSeqs.user;
|
|
749
|
+
default: return assertExhaustive(kind);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
_connectionsForKind(kind) {
|
|
753
|
+
switch (kind) {
|
|
754
|
+
case "operator": return this._idMap.fromClientKey.operator;
|
|
755
|
+
case "user": return this._idMap.fromClientKey.user;
|
|
756
|
+
default: return assertExhaustive(kind);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
_getByClientKey(clientKey) {
|
|
760
|
+
return this._mapForKind(clientKey.kind).get(clientKey.id) ?? null;
|
|
761
|
+
}
|
|
762
|
+
/**
|
|
763
|
+
* Extra-tab joins and occupancy snapshots only replace presence when the
|
|
764
|
+
* incoming seq is a newer number. Connecting with no seq is not an
|
|
765
|
+
* interact. Equal seqs keep the value already applied.
|
|
766
|
+
*/
|
|
767
|
+
_isNewerPresenceSeq(clientKey, incoming) {
|
|
768
|
+
return this._isNewerPresenceSeqValue(this._presenceSeqsForKind(clientKey.kind).get(clientKey.id), incoming);
|
|
769
|
+
}
|
|
770
|
+
_isNewerPresenceSeqValue(previous, incoming) {
|
|
771
|
+
if (typeof incoming !== "number") return false;
|
|
772
|
+
if (typeof previous !== "number") return true;
|
|
773
|
+
return incoming > previous;
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* Local writes omit a seq and always apply. Remote writes apply when the
|
|
777
|
+
* seq is newer or equal (last-applied wins among equal seqs).
|
|
778
|
+
* A null seq applies only when we do not already have one.
|
|
779
|
+
*/
|
|
780
|
+
_shouldApplyPresenceSeq(clientKey, incoming) {
|
|
781
|
+
if (incoming === void 0) return true;
|
|
782
|
+
if (typeof incoming !== "number") return typeof this._presenceSeqsForKind(clientKey.kind).get(clientKey.id) !== "number";
|
|
783
|
+
const previous = this._presenceSeqsForKind(clientKey.kind).get(clientKey.id);
|
|
784
|
+
if (typeof previous !== "number") return true;
|
|
785
|
+
return incoming >= previous;
|
|
786
|
+
}
|
|
787
|
+
_setPresenceSeq(clientKey, incoming) {
|
|
788
|
+
if (typeof incoming === "number") {
|
|
789
|
+
this._presenceSeqsForKind(clientKey.kind).set(clientKey.id, incoming);
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
if (incoming === void 0) return;
|
|
793
|
+
this._presenceSeqsForKind(clientKey.kind).set(clientKey.id, null);
|
|
794
|
+
}
|
|
795
|
+
_deleteConnectionId(connectionId) {
|
|
796
|
+
const clientKey = this._clientKeyForConnection(connectionId);
|
|
797
|
+
this._idMap.fromConnectionId.delete(connectionId);
|
|
798
|
+
if (!clientKey) return;
|
|
799
|
+
const connections = this._connectionsForKind(clientKey.kind);
|
|
800
|
+
const set = connections.get(clientKey.id) ?? null;
|
|
801
|
+
if (!set) return;
|
|
802
|
+
set.delete(connectionId);
|
|
803
|
+
if (!!set.size) return;
|
|
804
|
+
connections.delete(clientKey.id);
|
|
805
|
+
}
|
|
806
|
+
_getConnectionIds(clientKey) {
|
|
807
|
+
return this._connectionsForKind(clientKey.kind).get(clientKey.id) ?? null;
|
|
808
|
+
}
|
|
809
|
+
_setConnectionId(connectionId, clientKey) {
|
|
810
|
+
const connections = this._connectionsForKind(clientKey.kind);
|
|
811
|
+
const updated = (connections.get(clientKey.id) ?? /* @__PURE__ */ new Set()).add(connectionId);
|
|
812
|
+
this._idMap.fromConnectionId.set(connectionId, clientKey);
|
|
813
|
+
connections.set(clientKey.id, updated);
|
|
814
|
+
return updated;
|
|
815
|
+
}
|
|
816
|
+
};
|
|
817
|
+
// src/UsersNotifier.ts
|
|
818
|
+
var UsersNotifier = class {
|
|
819
|
+
constructor() {
|
|
820
|
+
this.others = makeSubject();
|
|
821
|
+
this._otherSubjects = {
|
|
822
|
+
operator: /* @__PURE__ */ new Map(),
|
|
823
|
+
user: /* @__PURE__ */ new Map()
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
clear() {
|
|
827
|
+
this._subjectsForKind("user").forEach((subject) => {
|
|
828
|
+
subject.next(null);
|
|
829
|
+
});
|
|
830
|
+
this._subjectsForKind("operator").forEach((subject) => {
|
|
831
|
+
subject.next(null);
|
|
832
|
+
});
|
|
833
|
+
this._subjectsForKind("user").clear();
|
|
834
|
+
this._subjectsForKind("operator").clear();
|
|
835
|
+
}
|
|
836
|
+
delete(params) {
|
|
837
|
+
const { id, kind } = params;
|
|
838
|
+
const subjects = this._subjectsForKind(kind);
|
|
839
|
+
const subject = subjects.get(id);
|
|
840
|
+
if (!subject) return;
|
|
841
|
+
subject.next(null);
|
|
842
|
+
subjects.delete(id);
|
|
843
|
+
}
|
|
844
|
+
other(params) {
|
|
845
|
+
const { id, kind } = params;
|
|
846
|
+
const subjects = this._subjectsForKind(kind);
|
|
847
|
+
const subject = subjects.get(id);
|
|
848
|
+
if (subject) return subject;
|
|
849
|
+
const created = makeSubject();
|
|
850
|
+
subjects.set(id, created);
|
|
851
|
+
return created;
|
|
852
|
+
}
|
|
853
|
+
subscribeOther(id, callback, options) {
|
|
854
|
+
const kind = options?.kind ?? "user";
|
|
855
|
+
const subscription = subscribe(callback)(this.other({
|
|
856
|
+
id,
|
|
857
|
+
kind
|
|
858
|
+
}).source);
|
|
859
|
+
return () => {
|
|
860
|
+
subscription.unsubscribe();
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
subscribeOthers(callback, options) {
|
|
864
|
+
const kinds = options?.kinds ?? ["user"];
|
|
865
|
+
const subscription = subscribe(({ others, event }) => {
|
|
866
|
+
switch (event.kind) {
|
|
867
|
+
case "clear":
|
|
868
|
+
case "sync": break;
|
|
869
|
+
case "enter":
|
|
870
|
+
case "leave":
|
|
871
|
+
case "update":
|
|
872
|
+
if (!kinds.includes(event.user.kind)) return;
|
|
873
|
+
break;
|
|
874
|
+
default: assertExhaustive(event);
|
|
875
|
+
}
|
|
876
|
+
callback(others, event);
|
|
877
|
+
})(this.others.source);
|
|
878
|
+
return () => {
|
|
879
|
+
subscription.unsubscribe();
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
_subjectsForKind(kind) {
|
|
883
|
+
switch (kind) {
|
|
884
|
+
case "operator": return this._otherSubjects.operator;
|
|
885
|
+
case "user": return this._otherSubjects.user;
|
|
886
|
+
default: return assertExhaustive(kind);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
};
|
|
890
|
+
// src/utils/sequenceCommit.ts
|
|
891
|
+
/**
|
|
892
|
+
* Runs `commit` after earlier commits. `ready` should already be running.
|
|
893
|
+
* A rejection, or a throw from `commit`, is logged, and the next call still runs.
|
|
894
|
+
* The storage caller returns before the write. This promise only orders the next commit.
|
|
895
|
+
*/
|
|
896
|
+
const sequenceCommit = (params) => {
|
|
897
|
+
return params.tail.then(() => params.ready).then((value) => {
|
|
898
|
+
try {
|
|
899
|
+
params.commit(value);
|
|
900
|
+
} catch (error) {
|
|
901
|
+
console.error(error);
|
|
902
|
+
}
|
|
903
|
+
}, (error) => {
|
|
904
|
+
console.error(error);
|
|
905
|
+
});
|
|
906
|
+
};
|
|
907
|
+
// src/utils/debounce.ts
|
|
908
|
+
const debounce = (fn, options) => {
|
|
909
|
+
const { wait, immediate = false } = options ?? {};
|
|
910
|
+
let prevResult;
|
|
911
|
+
let timeout = null;
|
|
912
|
+
return (...args) => {
|
|
913
|
+
if (typeof timeout === "number") clearTimeout(timeout);
|
|
914
|
+
timeout = setTimeout(() => {
|
|
915
|
+
timeout = null;
|
|
916
|
+
if (!immediate) prevResult = fn(args);
|
|
917
|
+
}, wait);
|
|
918
|
+
if (immediate && !timeout) {
|
|
919
|
+
fn(args);
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
return prevResult;
|
|
923
|
+
};
|
|
924
|
+
};
|
|
925
|
+
// src/utils/inRange.ts
|
|
926
|
+
const inRange = (value, range) => {
|
|
927
|
+
const { max, min } = range;
|
|
928
|
+
return Number.isInteger(value) && value >= min && value <= max;
|
|
929
|
+
};
|
|
930
|
+
// src/PluvRoom.ts
|
|
931
|
+
const ADD_TO_STORAGE_STATE_DEBOUNCE_MS = 1e3;
|
|
932
|
+
const HEARTBEAT_INTERVAL_MS = 1e4;
|
|
933
|
+
const PONG_TIMEOUT_MS = 2e3;
|
|
934
|
+
const RECONNECT_TIMEOUT_MS = 5e3;
|
|
935
|
+
const MIN_RECONNECT_TIMEOUT_MS = 1e3;
|
|
936
|
+
const MAX_RECONNECT_TIMEOUT_MS = 6e4;
|
|
937
|
+
const ORIGIN_INITIALIZED = "$initialized";
|
|
938
|
+
const ORIGIN_STORAGE_UPDATED = "$storageUpdated";
|
|
939
|
+
const DEFAULT_PLUV_CLIENT_ADDON = (input) => ({ storage: new StorageStore(input.room.id) });
|
|
940
|
+
var PluvRoom = class {
|
|
941
|
+
constructor(room, options) {
|
|
942
|
+
this._crdtNotifier = new CrdtNotifier();
|
|
943
|
+
this._eventNotifier = new EventNotifier();
|
|
944
|
+
this._errorSubject = makeSubject();
|
|
945
|
+
this._intervals = { heartbeat: null };
|
|
946
|
+
this._listenerManager = new ListenerManager();
|
|
947
|
+
this._requests = new PendingRequestManager();
|
|
948
|
+
this._stateNotifier = new StateNotifier();
|
|
949
|
+
this._subscriptions = { observeCrdt: null };
|
|
950
|
+
this._timeouts = {
|
|
951
|
+
pong: null,
|
|
952
|
+
reconnect: null
|
|
953
|
+
};
|
|
954
|
+
this._usersNotifier = new UsersNotifier();
|
|
955
|
+
this._roomStats = {
|
|
956
|
+
connectionCount: 0,
|
|
957
|
+
userCount: 0
|
|
958
|
+
};
|
|
959
|
+
this._operatorStats = {
|
|
960
|
+
connectionCount: 0,
|
|
961
|
+
userCount: 0
|
|
962
|
+
};
|
|
963
|
+
this._lastMetadata = null;
|
|
964
|
+
this._seq = { presence: 0 };
|
|
965
|
+
this._storageSync = new StorageSync();
|
|
966
|
+
this._state = {
|
|
967
|
+
authorization: {
|
|
968
|
+
token: null,
|
|
969
|
+
user: null,
|
|
970
|
+
kind: "user",
|
|
971
|
+
operator: null
|
|
972
|
+
},
|
|
973
|
+
connection: {
|
|
974
|
+
attempts: 0,
|
|
975
|
+
id: null,
|
|
976
|
+
state: ConnectionState$1.Untouched
|
|
977
|
+
},
|
|
978
|
+
storage: { state: StorageState.Unavailable },
|
|
979
|
+
webSocket: null
|
|
980
|
+
};
|
|
981
|
+
this._windowListeners = null;
|
|
982
|
+
this._wsListeners = null;
|
|
983
|
+
this._storageProcedureNames = [];
|
|
984
|
+
this._storageCommits = Promise.resolve();
|
|
985
|
+
this.broadcast = new Proxy(async (event, data) => {
|
|
986
|
+
if (!this._state.webSocket) return;
|
|
987
|
+
if (this._state.connection.state !== ConnectionState$1.Open) return;
|
|
988
|
+
this._sendMessage({
|
|
989
|
+
data,
|
|
990
|
+
type: event.toString()
|
|
991
|
+
});
|
|
992
|
+
}, { get(fn, prop) {
|
|
993
|
+
return async (data) => {
|
|
994
|
+
return await fn(prop, data);
|
|
995
|
+
};
|
|
996
|
+
} });
|
|
997
|
+
this.canRedo = () => {
|
|
998
|
+
return this._crdtManager.doc.canRedo();
|
|
999
|
+
};
|
|
1000
|
+
this.canUndo = () => {
|
|
1001
|
+
return this._crdtManager.doc.canUndo();
|
|
1002
|
+
};
|
|
1003
|
+
this.getConnection = () => {
|
|
1004
|
+
return Object.freeze(JSON.parse(JSON.stringify(this._state.connection)));
|
|
1005
|
+
};
|
|
1006
|
+
this.getDoc = () => {
|
|
1007
|
+
return this._crdtManager.doc;
|
|
1008
|
+
};
|
|
1009
|
+
this.getMyPresence = () => {
|
|
1010
|
+
return this._usersManager.myPresence;
|
|
1011
|
+
};
|
|
1012
|
+
this.getMyself = () => {
|
|
1013
|
+
return this._usersManager.myself;
|
|
1014
|
+
};
|
|
1015
|
+
this.getOther = (userId, options) => {
|
|
1016
|
+
return this._usersManager.getOther(userId, options);
|
|
1017
|
+
};
|
|
1018
|
+
this.getOtherByConnectionId = (connectionId, options) => {
|
|
1019
|
+
return this._usersManager.getOtherByConnectionId(connectionId, options);
|
|
1020
|
+
};
|
|
1021
|
+
this.getOthers = (options) => {
|
|
1022
|
+
return this._usersManager.getOthers(options);
|
|
1023
|
+
};
|
|
1024
|
+
this.getRoomStats = (options) => {
|
|
1025
|
+
const kinds = options?.kinds;
|
|
1026
|
+
if (kinds === void 0) return this._roomStats;
|
|
1027
|
+
if (kinds.length === 0) return {
|
|
1028
|
+
connectionCount: 0,
|
|
1029
|
+
userCount: 0
|
|
1030
|
+
};
|
|
1031
|
+
const includeUsers = kinds.includes("user");
|
|
1032
|
+
const includeOperators = kinds.includes("operator");
|
|
1033
|
+
return {
|
|
1034
|
+
connectionCount: (includeUsers ? this._roomStats.connectionCount : 0) + (includeOperators ? this._operatorStats.connectionCount : 0),
|
|
1035
|
+
userCount: (includeUsers ? this._roomStats.userCount : 0) + (includeOperators ? this._operatorStats.userCount : 0)
|
|
1036
|
+
};
|
|
1037
|
+
};
|
|
1038
|
+
this.getStorage = (type) => {
|
|
1039
|
+
if (!this.getStorageLoaded()) return null;
|
|
1040
|
+
const sharedType = this._crdtManager.get(type);
|
|
1041
|
+
if (typeof sharedType === "undefined") return null;
|
|
1042
|
+
return sharedType;
|
|
1043
|
+
};
|
|
1044
|
+
this.getStorageLoaded = () => {
|
|
1045
|
+
return this._state.storage.state === StorageState.Offline || this._state.storage.state === StorageState.Synced;
|
|
1046
|
+
};
|
|
1047
|
+
this.listUsers = (options = {}) => {
|
|
1048
|
+
const resolved = options.limit ?? 50;
|
|
1049
|
+
if (!inRange(resolved, {
|
|
1050
|
+
min: 1,
|
|
1051
|
+
max: 100
|
|
1052
|
+
})) return Promise.resolve({
|
|
1053
|
+
success: false,
|
|
1054
|
+
error: {
|
|
1055
|
+
code: "INVALID_LIMIT",
|
|
1056
|
+
message: `Invalid listUsers limit. Limit must be an integer from 1 to ${100 .toLocaleString()}. Received: ${String(options.limit)}.`
|
|
1057
|
+
}
|
|
1058
|
+
});
|
|
1059
|
+
if (!this._state.webSocket || this._state.connection.state !== ConnectionState$1.Open) return Promise.resolve({
|
|
1060
|
+
success: false,
|
|
1061
|
+
error: {
|
|
1062
|
+
code: "NOT_CONNECTED",
|
|
1063
|
+
message: "Room is not connected"
|
|
1064
|
+
}
|
|
1065
|
+
});
|
|
1066
|
+
const { promise, requestId } = this._requests.request({
|
|
1067
|
+
onAbort: () => ({
|
|
1068
|
+
success: false,
|
|
1069
|
+
error: {
|
|
1070
|
+
code: "NOT_CONNECTED",
|
|
1071
|
+
message: "Room is not connected"
|
|
1072
|
+
}
|
|
1073
|
+
}),
|
|
1074
|
+
onTimeout: () => ({
|
|
1075
|
+
success: false,
|
|
1076
|
+
error: {
|
|
1077
|
+
code: "FAILED",
|
|
1078
|
+
message: "listUsers timed out"
|
|
1079
|
+
}
|
|
1080
|
+
}),
|
|
1081
|
+
timeoutMs: LIST_USERS_TIMEOUT_MS
|
|
1082
|
+
});
|
|
1083
|
+
this._sendMessage({
|
|
1084
|
+
type: "$listUsers",
|
|
1085
|
+
data: {
|
|
1086
|
+
cursor: options.cursor ?? null,
|
|
1087
|
+
kinds: options.kinds,
|
|
1088
|
+
limit: resolved,
|
|
1089
|
+
requestId
|
|
1090
|
+
}
|
|
1091
|
+
});
|
|
1092
|
+
return promise;
|
|
1093
|
+
};
|
|
1094
|
+
this.presence = new Proxy(async (name, data) => {
|
|
1095
|
+
await this._runPresenceProcedure(name, data);
|
|
1096
|
+
}, { get(fn, prop) {
|
|
1097
|
+
return (data) => fn(prop, data);
|
|
1098
|
+
} });
|
|
1099
|
+
this.redo = () => {
|
|
1100
|
+
this._crdtManager.doc.redo();
|
|
1101
|
+
};
|
|
1102
|
+
this.storage = new Proxy((name, data) => {
|
|
1103
|
+
this._runStorageProcedure(name, data);
|
|
1104
|
+
}, { get(fn, prop) {
|
|
1105
|
+
return (data) => fn(prop, data);
|
|
1106
|
+
} });
|
|
1107
|
+
this.subscribe = new Proxy((name, callback) => this._stateNotifier.subscribe(name, callback), { get: (fn, prop) => {
|
|
1108
|
+
if (prop === "connection") return (callback) => {
|
|
1109
|
+
return fn("connection", callback);
|
|
1110
|
+
};
|
|
1111
|
+
if (prop === "error") return (callback) => {
|
|
1112
|
+
const subscription = subscribe(callback)(this._errorSubject.source);
|
|
1113
|
+
return () => {
|
|
1114
|
+
subscription.unsubscribe();
|
|
1115
|
+
};
|
|
1116
|
+
};
|
|
1117
|
+
if (prop === "myPresence") return (callback) => {
|
|
1118
|
+
return fn("my-presence", callback);
|
|
1119
|
+
};
|
|
1120
|
+
if (prop === "myself") return (callback) => {
|
|
1121
|
+
return fn("myself", callback);
|
|
1122
|
+
};
|
|
1123
|
+
if (prop === "others") return (callback, options) => {
|
|
1124
|
+
return this._usersNotifier.subscribeOthers((_listed, event) => {
|
|
1125
|
+
const others = this._usersManager.getOthers(options);
|
|
1126
|
+
if (event.kind === "sync") {
|
|
1127
|
+
callback(others, {
|
|
1128
|
+
kind: "sync",
|
|
1129
|
+
users: others
|
|
1130
|
+
});
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
1133
|
+
callback(others, event);
|
|
1134
|
+
}, options);
|
|
1135
|
+
};
|
|
1136
|
+
if (prop === "roomStats") return (callback) => {
|
|
1137
|
+
return fn("roomStats", callback);
|
|
1138
|
+
};
|
|
1139
|
+
if (prop === "storageLoaded") return (callback) => {
|
|
1140
|
+
return fn("storage-loaded", callback);
|
|
1141
|
+
};
|
|
1142
|
+
if (prop === "event") return this._event;
|
|
1143
|
+
if (prop === "other") return this._other;
|
|
1144
|
+
if (prop === "storage") return this.#_storage;
|
|
1145
|
+
throw new Error(`Unknown subject: ${prop.toString()}`);
|
|
1146
|
+
} });
|
|
1147
|
+
this.transact = (fn, origin) => {
|
|
1148
|
+
const _origin = origin ?? this._state.connection.id;
|
|
1149
|
+
/**
|
|
1150
|
+
* !HACK
|
|
1151
|
+
* @description Don't transact anything if there is no origin, because
|
|
1152
|
+
* that means the user isn't connected yet. This will mean events are
|
|
1153
|
+
* lost unfortunately.
|
|
1154
|
+
* @date September 23, 2023
|
|
1155
|
+
*/
|
|
1156
|
+
if (typeof _origin !== "string") return;
|
|
1157
|
+
this._crdtManager.doc.transact(() => {
|
|
1158
|
+
fn(this._crdtManager.doc.get());
|
|
1159
|
+
}, _origin);
|
|
1160
|
+
};
|
|
1161
|
+
this.undo = () => {
|
|
1162
|
+
this._crdtManager.doc.undo();
|
|
1163
|
+
};
|
|
1164
|
+
this.updateMyPresence = async (presence) => {
|
|
1165
|
+
await this._applyMyPresence(presence);
|
|
1166
|
+
};
|
|
1167
|
+
this._event = new Proxy((event, callback) => this._eventNotifier.subscribe(event, callback), { get(fn, prop) {
|
|
1168
|
+
return (callback) => fn(prop, callback);
|
|
1169
|
+
} });
|
|
1170
|
+
this._flattenStorageStore = debounce(async () => {
|
|
1171
|
+
if (!await this._storageStore.getShouldFlatten()) return;
|
|
1172
|
+
const updates = await this._storageStore.getUpdates();
|
|
1173
|
+
this._crdtManager.applyUpdate({
|
|
1174
|
+
update: updates,
|
|
1175
|
+
origin: this
|
|
1176
|
+
});
|
|
1177
|
+
const encodedState = this._crdtManager.doc.getEncodedState();
|
|
1178
|
+
await this._storageStore.flatten(encodedState);
|
|
1179
|
+
}, { wait: ADD_TO_STORAGE_STATE_DEBOUNCE_MS });
|
|
1180
|
+
this._getAddon = (addons) => {
|
|
1181
|
+
return addons.reduce((acc, addon) => () => ({
|
|
1182
|
+
...acc({ room: this }),
|
|
1183
|
+
...addon({ room: this })
|
|
1184
|
+
}), DEFAULT_PLUV_CLIENT_ADDON);
|
|
1185
|
+
};
|
|
1186
|
+
this._other = (userId, callback, options) => {
|
|
1187
|
+
return this._usersNotifier.subscribeOther(userId, callback, options);
|
|
1188
|
+
};
|
|
1189
|
+
this.#_storage = new Proxy((key, callback) => this._crdtNotifier.subscribe(key, callback), { get: (fn, prop) => {
|
|
1190
|
+
if (prop) return (callback) => {
|
|
1191
|
+
return this._crdtNotifier.subcribeRoot(callback);
|
|
1192
|
+
};
|
|
1193
|
+
return (callback) => {
|
|
1194
|
+
return fn(prop, callback);
|
|
1195
|
+
};
|
|
1196
|
+
} });
|
|
1197
|
+
const { addons = [], authEndpoint, debug = false, initialPresence, initialStorage, limits, metadata, onAuthorizationFail, presence, reconnectTimeoutMs = RECONNECT_TIMEOUT_MS, storage: crdtStorage, treaty, wsEndpoint } = options;
|
|
1198
|
+
this.id = room;
|
|
1199
|
+
this.metadata = metadata;
|
|
1200
|
+
const addon = this._getAddon(addons);
|
|
1201
|
+
const { storage } = {
|
|
1202
|
+
...DEFAULT_PLUV_CLIENT_ADDON({ room: this }),
|
|
1203
|
+
...addon({ room: this })
|
|
1204
|
+
};
|
|
1205
|
+
this._storageStore = storage;
|
|
1206
|
+
this._debug = debug;
|
|
1207
|
+
this._endpoints = {
|
|
1208
|
+
authEndpoint,
|
|
1209
|
+
wsEndpoint
|
|
1210
|
+
};
|
|
1211
|
+
this._limits = limits;
|
|
1212
|
+
this._reconnectTimeoutMs = reconnectTimeoutMs;
|
|
1213
|
+
this._listeners = { onAuthorizationFail: (error) => {
|
|
1214
|
+
onAuthorizationFail?.(error);
|
|
1215
|
+
} };
|
|
1216
|
+
this._treaty = treaty;
|
|
1217
|
+
this._usersManager = new UsersManager({
|
|
1218
|
+
initialPresence,
|
|
1219
|
+
limits: this._limits,
|
|
1220
|
+
presence
|
|
1221
|
+
});
|
|
1222
|
+
this._crdtManager = new CrdtManager({
|
|
1223
|
+
initialStorage,
|
|
1224
|
+
storage: crdtStorage
|
|
1225
|
+
});
|
|
1226
|
+
}
|
|
1227
|
+
get webSocket() {
|
|
1228
|
+
return this._state.webSocket;
|
|
1229
|
+
}
|
|
1230
|
+
addEventListener(kind, handler) {
|
|
1231
|
+
return this._listenerManager.subscribe(kind, handler);
|
|
1232
|
+
}
|
|
1233
|
+
async connect(...args) {
|
|
1234
|
+
const params = args[0] ?? {};
|
|
1235
|
+
const metadata = params.metadata;
|
|
1236
|
+
this._setMetadata(metadata);
|
|
1237
|
+
if (![
|
|
1238
|
+
ConnectionState$1.Closed,
|
|
1239
|
+
ConnectionState$1.Unavailable,
|
|
1240
|
+
ConnectionState$1.Untouched
|
|
1241
|
+
].some((state) => this._state.connection.state === state)) return;
|
|
1242
|
+
this._clearTimeout(this._timeouts.reconnect);
|
|
1243
|
+
this._updateState((oldState) => {
|
|
1244
|
+
oldState.connection.state = ConnectionState$1.Connecting;
|
|
1245
|
+
return oldState;
|
|
1246
|
+
});
|
|
1247
|
+
await this._storageStore.initialize();
|
|
1248
|
+
const wsEndpoint = this._getWsEndpoint(this.id, params);
|
|
1249
|
+
const url = new URL(wsEndpoint);
|
|
1250
|
+
let authToken = null;
|
|
1251
|
+
let webSocket;
|
|
1252
|
+
try {
|
|
1253
|
+
authToken = await this._getAuthorization(this.id, params);
|
|
1254
|
+
if (authToken) url.searchParams.set("token", encodeURIComponent(authToken));
|
|
1255
|
+
webSocket = new WebSocket(url.toString());
|
|
1256
|
+
} catch (err) {
|
|
1257
|
+
this._updateState((oldState) => {
|
|
1258
|
+
oldState.connection.attempts += 1;
|
|
1259
|
+
return oldState;
|
|
1260
|
+
});
|
|
1261
|
+
await this._storageStore.destroy();
|
|
1262
|
+
this._pollReconnect();
|
|
1263
|
+
await Promise.resolve(this._onAuthorizationFail(err));
|
|
1264
|
+
return;
|
|
1265
|
+
}
|
|
1266
|
+
this._updateState((oldState) => {
|
|
1267
|
+
oldState.connection.attempts = 0;
|
|
1268
|
+
oldState.authorization.token = authToken;
|
|
1269
|
+
oldState.storage.state = StorageState.Loading;
|
|
1270
|
+
oldState.webSocket = webSocket;
|
|
1271
|
+
return oldState;
|
|
1272
|
+
});
|
|
1273
|
+
this._attachWindowListeners();
|
|
1274
|
+
this._attachWsListeners();
|
|
1275
|
+
}
|
|
1276
|
+
async disconnect() {
|
|
1277
|
+
if (!this._state.webSocket) return;
|
|
1278
|
+
if (![ConnectionState$1.Connecting, ConnectionState$1.Open].some((state) => this._state.connection.state === state)) return;
|
|
1279
|
+
this._closeWs();
|
|
1280
|
+
this._clearInterval(this._intervals.heartbeat);
|
|
1281
|
+
this._clearTimeout(this._timeouts.reconnect);
|
|
1282
|
+
this._updateState((oldState) => {
|
|
1283
|
+
oldState.authorization.token = null;
|
|
1284
|
+
oldState.connection.state = ConnectionState$1.Closed;
|
|
1285
|
+
oldState.storage.state = StorageState.Unavailable;
|
|
1286
|
+
oldState.webSocket = null;
|
|
1287
|
+
return oldState;
|
|
1288
|
+
});
|
|
1289
|
+
await this._storageStore.destroy();
|
|
1290
|
+
this._crdtManager.destroy();
|
|
1291
|
+
this._observeCrdt();
|
|
1292
|
+
this._stateNotifier.subjects["my-presence"].next(null);
|
|
1293
|
+
}
|
|
1294
|
+
getStorageJson(type) {
|
|
1295
|
+
if (this._state.connection.id === null) return null;
|
|
1296
|
+
if (typeof type === "undefined") return this._crdtManager.doc.toJson();
|
|
1297
|
+
if (typeof this._crdtManager.get(type) === "undefined") return null;
|
|
1298
|
+
return this._crdtManager.doc.toJson(type);
|
|
1299
|
+
}
|
|
1300
|
+
async _applyMyPresence(presence, procedure) {
|
|
1301
|
+
const newPresence = typeof presence === "function" ? presence(this.getMyPresence()) : presence;
|
|
1302
|
+
this._usersManager.updateMyPresence(newPresence);
|
|
1303
|
+
const myPresence = this._usersManager.myPresence;
|
|
1304
|
+
const myself = this._usersManager.myself ?? null;
|
|
1305
|
+
this._stateNotifier.subjects["my-presence"].next(myPresence);
|
|
1306
|
+
if (myself) this._stateNotifier.subjects.myself.next(myself);
|
|
1307
|
+
if (!!this._state.webSocket && this._state.connection.state === ConnectionState$1.Open) this._usersManager.beginLocalPresenceWrite();
|
|
1308
|
+
await this.broadcast("$updatePresence", {
|
|
1309
|
+
presence: newPresence,
|
|
1310
|
+
...procedure ? { procedure } : {}
|
|
1311
|
+
});
|
|
1312
|
+
}
|
|
1313
|
+
async _addToStorageStore(update) {
|
|
1314
|
+
await this._storageStore.addUpdate(update);
|
|
1315
|
+
this._flattenStorageStore();
|
|
1316
|
+
}
|
|
1317
|
+
_attachWindowListeners() {
|
|
1318
|
+
if (typeof window === "undefined") return;
|
|
1319
|
+
if (typeof document === "undefined") return;
|
|
1320
|
+
this._windowListeners = this._windowListeners || {
|
|
1321
|
+
onNavigatorOffline: this._onNavigatorOffline.bind(this),
|
|
1322
|
+
onNavigatorOnline: this._onNavigatorOnline.bind(this),
|
|
1323
|
+
onVisibilityChange: this._onVisibilityChange.bind(this),
|
|
1324
|
+
onWindowFocus: this._onWindowFocus.bind(this)
|
|
1325
|
+
};
|
|
1326
|
+
window.addEventListener("offline", this._windowListeners.onNavigatorOffline);
|
|
1327
|
+
window.addEventListener("online", this._windowListeners.onNavigatorOnline);
|
|
1328
|
+
document.addEventListener("visibilitychange", this._windowListeners.onVisibilityChange);
|
|
1329
|
+
window.addEventListener("focus", this._windowListeners.onWindowFocus);
|
|
1330
|
+
}
|
|
1331
|
+
_attachWsListeners() {
|
|
1332
|
+
if (!this._state.webSocket) return;
|
|
1333
|
+
this._wsListeners = this._wsListeners || {
|
|
1334
|
+
onClose: this._onClose.bind(this),
|
|
1335
|
+
onError: this._onError.bind(this),
|
|
1336
|
+
onMessage: this._onMessage.bind(this),
|
|
1337
|
+
onOpen: this._onOpen.bind(this)
|
|
1338
|
+
};
|
|
1339
|
+
this._state.webSocket.addEventListener("close", this._wsListeners.onClose);
|
|
1340
|
+
this._state.webSocket.addEventListener("error", this._wsListeners.onError);
|
|
1341
|
+
this._state.webSocket.addEventListener("message", this._wsListeners.onMessage);
|
|
1342
|
+
this._state.webSocket.addEventListener("open", this._wsListeners.onOpen);
|
|
1343
|
+
}
|
|
1344
|
+
_clearInterval(interval) {
|
|
1345
|
+
if (Number.isNaN(interval)) return;
|
|
1346
|
+
clearInterval(interval);
|
|
1347
|
+
}
|
|
1348
|
+
_clearTimeout(timeout) {
|
|
1349
|
+
if (Number.isNaN(timeout)) return;
|
|
1350
|
+
clearTimeout(timeout);
|
|
1351
|
+
}
|
|
1352
|
+
_closeWs() {
|
|
1353
|
+
this._storageSync.reset();
|
|
1354
|
+
this._subscriptions.observeCrdt?.();
|
|
1355
|
+
this._clearInterval(this._intervals.heartbeat);
|
|
1356
|
+
this._clearTimeout(this._timeouts.pong);
|
|
1357
|
+
this._detachWindowListeners();
|
|
1358
|
+
this._usersManager.removeMyself();
|
|
1359
|
+
this._usersManager.clearConnections();
|
|
1360
|
+
this._usersNotifier.clear();
|
|
1361
|
+
this._stateNotifier.subjects.myself.next(null);
|
|
1362
|
+
this._stateNotifier.subjects.others.next([]);
|
|
1363
|
+
this._usersNotifier.others.next({
|
|
1364
|
+
others: [],
|
|
1365
|
+
event: { kind: "clear" }
|
|
1366
|
+
});
|
|
1367
|
+
this._roomStats = {
|
|
1368
|
+
connectionCount: 0,
|
|
1369
|
+
userCount: 0
|
|
1370
|
+
};
|
|
1371
|
+
this._operatorStats = {
|
|
1372
|
+
connectionCount: 0,
|
|
1373
|
+
userCount: 0
|
|
1374
|
+
};
|
|
1375
|
+
this._stateNotifier.subjects.roomStats.next(this._roomStats);
|
|
1376
|
+
this._requests.failAll();
|
|
1377
|
+
this._state.webSocket?.close();
|
|
1378
|
+
this._detachWsListeners();
|
|
1379
|
+
}
|
|
1380
|
+
_detachWindowListeners() {
|
|
1381
|
+
if (!this._windowListeners) return;
|
|
1382
|
+
if (typeof window === "undefined") return;
|
|
1383
|
+
if (typeof document === "undefined") return;
|
|
1384
|
+
window.removeEventListener("offline", this._windowListeners.onNavigatorOffline);
|
|
1385
|
+
window.removeEventListener("online", this._windowListeners.onNavigatorOnline);
|
|
1386
|
+
document.removeEventListener("visibilitychange", this._windowListeners.onVisibilityChange);
|
|
1387
|
+
window.removeEventListener("focus", this._windowListeners.onWindowFocus);
|
|
1388
|
+
this._windowListeners = null;
|
|
1389
|
+
}
|
|
1390
|
+
_detachWsListeners() {
|
|
1391
|
+
if (!this._state.webSocket || !this._wsListeners) return;
|
|
1392
|
+
this._state.webSocket.removeEventListener("close", this._wsListeners.onClose);
|
|
1393
|
+
this._state.webSocket.removeEventListener("error", this._wsListeners.onError);
|
|
1394
|
+
this._state.webSocket.removeEventListener("message", this._wsListeners.onMessage);
|
|
1395
|
+
this._state.webSocket.removeEventListener("open", this._wsListeners.onOpen);
|
|
1396
|
+
this._wsListeners = null;
|
|
1397
|
+
}
|
|
1398
|
+
_emitSharedTypes() {
|
|
1399
|
+
const sharedTypes = this._crdtManager.doc.get();
|
|
1400
|
+
const storageRoot = Object.keys(sharedTypes).reduce((acc, prop) => {
|
|
1401
|
+
const serialized = this._crdtManager.doc.toJson(prop);
|
|
1402
|
+
this._crdtNotifier.subject(prop).next(serialized);
|
|
1403
|
+
acc[prop] = serialized;
|
|
1404
|
+
return acc;
|
|
1405
|
+
}, {});
|
|
1406
|
+
this._crdtNotifier.rootSubject.next(storageRoot);
|
|
1407
|
+
}
|
|
1408
|
+
_getAuthFetchOptions(room, params) {
|
|
1409
|
+
const metadata = params.metadata;
|
|
1410
|
+
if (typeof this._endpoints.authEndpoint === "undefined") return null;
|
|
1411
|
+
if (this._endpoints.authEndpoint === true) return {
|
|
1412
|
+
url: `/api/pluv/authorize?room=${room}`,
|
|
1413
|
+
options: {}
|
|
1414
|
+
};
|
|
1415
|
+
if (typeof this._endpoints.authEndpoint === "string") return {
|
|
1416
|
+
url: this._endpoints.authEndpoint,
|
|
1417
|
+
options: {}
|
|
1418
|
+
};
|
|
1419
|
+
const result = this._endpoints.authEndpoint({
|
|
1420
|
+
metadata,
|
|
1421
|
+
room
|
|
1422
|
+
});
|
|
1423
|
+
return typeof result === "string" ? {
|
|
1424
|
+
url: result,
|
|
1425
|
+
options: {}
|
|
1426
|
+
} : result;
|
|
1427
|
+
}
|
|
1428
|
+
async _getAuthorization(room, params) {
|
|
1429
|
+
const fetchOptions = this._getAuthFetchOptions(room, params);
|
|
1430
|
+
if (!fetchOptions) return null;
|
|
1431
|
+
const { url, options } = fetchOptions;
|
|
1432
|
+
const res = await fetch(url, options);
|
|
1433
|
+
if (!res.ok || res.status !== 200) throw new Error("Room is unauthorized");
|
|
1434
|
+
try {
|
|
1435
|
+
return await res.text().then((text) => text.trim());
|
|
1436
|
+
} catch (err) {
|
|
1437
|
+
throw new Error(err instanceof Error ? err.message : "Room is unauthorized", { cause: err });
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
_getWsEndpoint(room, params) {
|
|
1441
|
+
const metadata = params.metadata;
|
|
1442
|
+
switch (typeof this._endpoints.wsEndpoint) {
|
|
1443
|
+
case "undefined": return `/api/pluv/room/${room}`;
|
|
1444
|
+
case "string": return this._endpoints.wsEndpoint;
|
|
1445
|
+
default: return this._endpoints.wsEndpoint({
|
|
1446
|
+
metadata,
|
|
1447
|
+
room
|
|
1448
|
+
});
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
_handleExit(message) {
|
|
1452
|
+
const { connectionId } = message;
|
|
1453
|
+
if (!connectionId) return;
|
|
1454
|
+
if (!this._state.webSocket) throw new Error("Could not find WebSocket");
|
|
1455
|
+
const isMyself = this._usersManager.isMyselfConnection(connectionId);
|
|
1456
|
+
const deleted = this._usersManager.deleteConnection(connectionId);
|
|
1457
|
+
const others = this._usersManager.getOthers();
|
|
1458
|
+
this._stateNotifier.subjects.others.next(others);
|
|
1459
|
+
if (!deleted) {
|
|
1460
|
+
if (isMyself) return;
|
|
1461
|
+
console.warn("Could not identify exited connection");
|
|
1462
|
+
return;
|
|
1463
|
+
}
|
|
1464
|
+
const { data: user, remaining } = deleted;
|
|
1465
|
+
if (remaining) return;
|
|
1466
|
+
this._usersNotifier.others.next({
|
|
1467
|
+
others,
|
|
1468
|
+
event: {
|
|
1469
|
+
kind: "leave",
|
|
1470
|
+
user
|
|
1471
|
+
}
|
|
1472
|
+
});
|
|
1473
|
+
this._deleteOtherNotifier({
|
|
1474
|
+
id: this._usersManager.getParticipantId(user),
|
|
1475
|
+
kind: user.kind
|
|
1476
|
+
});
|
|
1477
|
+
}
|
|
1478
|
+
_handlePresenceUpdatedMessage(message) {
|
|
1479
|
+
const { connectionId } = message;
|
|
1480
|
+
if (!connectionId) return;
|
|
1481
|
+
if (!this._state.webSocket) throw new Error("Could not find WebSocket");
|
|
1482
|
+
const data = message.data;
|
|
1483
|
+
/**
|
|
1484
|
+
* This is our own presence coming back from the server. Ignore it if we
|
|
1485
|
+
* have already sent a newer local update, so a burst of writes (for
|
|
1486
|
+
* example dragging) cannot snap backward. Apply the last reply so we
|
|
1487
|
+
* can still pick up a newer value from another tab.
|
|
1488
|
+
*/
|
|
1489
|
+
if (connectionId === this._state.connection.id && !this._usersManager.ackOwnPresenceEcho()) return;
|
|
1490
|
+
const patched = this._usersManager.patchPresence({
|
|
1491
|
+
connectionId,
|
|
1492
|
+
...data
|
|
1493
|
+
});
|
|
1494
|
+
if (!patched) {
|
|
1495
|
+
/**
|
|
1496
|
+
* !HACK
|
|
1497
|
+
* @description User could not be found. Add the connection to keep others up-to-date
|
|
1498
|
+
* @date April 19, 2025
|
|
1499
|
+
*/
|
|
1500
|
+
const added = this._usersManager.addConnection({
|
|
1501
|
+
connectionId,
|
|
1502
|
+
data: message.user,
|
|
1503
|
+
kind: data.session.kind,
|
|
1504
|
+
operator: data.session.operator,
|
|
1505
|
+
presence: data.session.presence,
|
|
1506
|
+
presenceSeq: data.session.seq.presence
|
|
1507
|
+
});
|
|
1508
|
+
if (!added.presenceChanged) return;
|
|
1509
|
+
const other = this._usersManager.getOtherByConnectionId(connectionId, { kinds: ["user", "operator"] });
|
|
1510
|
+
const others = this._usersManager.getOthers();
|
|
1511
|
+
if (other) this._notifyOther(other);
|
|
1512
|
+
this._stateNotifier.subjects.others.next(others);
|
|
1513
|
+
this._usersNotifier.others.next({
|
|
1514
|
+
others,
|
|
1515
|
+
event: {
|
|
1516
|
+
kind: "update",
|
|
1517
|
+
user: added.data
|
|
1518
|
+
}
|
|
1519
|
+
});
|
|
1520
|
+
return;
|
|
1521
|
+
}
|
|
1522
|
+
if (!patched.applied) return;
|
|
1523
|
+
const updated = patched.presence;
|
|
1524
|
+
if (this._usersManager.isMyselfConnection(connectionId)) {
|
|
1525
|
+
this._stateNotifier.subjects["my-presence"].next(updated);
|
|
1526
|
+
this._stateNotifier.subjects.myself.next(this._usersManager.myself);
|
|
1527
|
+
return;
|
|
1528
|
+
}
|
|
1529
|
+
const other = this._usersManager.getOtherByConnectionId(connectionId, { kinds: ["user", "operator"] });
|
|
1530
|
+
const others = this._usersManager.getOthers();
|
|
1531
|
+
if (other) this._notifyOther(other);
|
|
1532
|
+
this._stateNotifier.subjects.others.next(others);
|
|
1533
|
+
if (other) this._usersNotifier.others.next({
|
|
1534
|
+
others,
|
|
1535
|
+
event: {
|
|
1536
|
+
kind: "update",
|
|
1537
|
+
user: other
|
|
1538
|
+
}
|
|
1539
|
+
});
|
|
1540
|
+
}
|
|
1541
|
+
_handleReceiveOthers(message) {
|
|
1542
|
+
if (!this._state.webSocket) throw new Error("Could not find WebSocket");
|
|
1543
|
+
const data = message.data;
|
|
1544
|
+
const leftIds = this._usersManager.replaceOthers(data.others.map((other) => ({
|
|
1545
|
+
connectionIds: other.connectionIds,
|
|
1546
|
+
data: other.data,
|
|
1547
|
+
kind: other.session.kind,
|
|
1548
|
+
operator: other.session.operator,
|
|
1549
|
+
presence: other.session.presence,
|
|
1550
|
+
presenceSeq: other.session.seq.presence
|
|
1551
|
+
})));
|
|
1552
|
+
this._usersManager.setMyConnectionIds(data.myConnectionIds);
|
|
1553
|
+
leftIds.forEach((subject) => {
|
|
1554
|
+
this._deleteOtherNotifier(subject);
|
|
1555
|
+
});
|
|
1556
|
+
data.others.forEach((row) => {
|
|
1557
|
+
const connectionId = row.connectionIds[0];
|
|
1558
|
+
if (!connectionId) return;
|
|
1559
|
+
const other = this._usersManager.getOtherByConnectionId(connectionId, { kinds: ["user", "operator"] });
|
|
1560
|
+
if (other) this._notifyOther(other);
|
|
1561
|
+
});
|
|
1562
|
+
const others = this._usersManager.getOthers();
|
|
1563
|
+
this._stateNotifier.subjects.others.next(others);
|
|
1564
|
+
this._usersNotifier.others.next({
|
|
1565
|
+
others,
|
|
1566
|
+
event: {
|
|
1567
|
+
kind: "sync",
|
|
1568
|
+
users: others
|
|
1569
|
+
}
|
|
1570
|
+
});
|
|
1571
|
+
}
|
|
1572
|
+
async _handleRegisteredMessage(message) {
|
|
1573
|
+
const { connectionId } = message;
|
|
1574
|
+
const user = message.user;
|
|
1575
|
+
if (!connectionId) return;
|
|
1576
|
+
if (!this._state.webSocket) throw new Error("Could not find WebSocket");
|
|
1577
|
+
const data = message.data;
|
|
1578
|
+
const state = data.state;
|
|
1579
|
+
this._setRoomStats(data.stats);
|
|
1580
|
+
this._updateState((oldState) => {
|
|
1581
|
+
oldState.connection.id = connectionId;
|
|
1582
|
+
oldState.authorization.user = user;
|
|
1583
|
+
oldState.authorization.kind = data.session.kind;
|
|
1584
|
+
oldState.authorization.operator = data.session.operator;
|
|
1585
|
+
return oldState;
|
|
1586
|
+
});
|
|
1587
|
+
this._usersManager.setMyself({
|
|
1588
|
+
connectionId,
|
|
1589
|
+
data: user,
|
|
1590
|
+
kind: data.session.kind,
|
|
1591
|
+
operator: data.session.operator,
|
|
1592
|
+
presence: data.session.presence ?? void 0,
|
|
1593
|
+
presenceSeq: data.session.seq.presence
|
|
1594
|
+
});
|
|
1595
|
+
const presence = this._usersManager.myPresence;
|
|
1596
|
+
const myself = this._usersManager.myself ?? null;
|
|
1597
|
+
this._stateNotifier.subjects["my-presence"].next(presence);
|
|
1598
|
+
this._stateNotifier.subjects.myself.next(myself);
|
|
1599
|
+
const update = await (async () => {
|
|
1600
|
+
if (state) {
|
|
1601
|
+
this._logDebug("Retrieving initial state");
|
|
1602
|
+
return this._crdtManager.getInitialState();
|
|
1603
|
+
}
|
|
1604
|
+
const resolved = this._crdtManager.resolveEncodedState(await this._storageStore.getUpdates());
|
|
1605
|
+
if (resolved) {
|
|
1606
|
+
this._logDebug("Retrieving storage store state");
|
|
1607
|
+
return resolved;
|
|
1608
|
+
}
|
|
1609
|
+
this._logDebug("Retrieving initial state");
|
|
1610
|
+
return this._crdtManager.getInitialState();
|
|
1611
|
+
})();
|
|
1612
|
+
this._sendMessage({
|
|
1613
|
+
type: "$initializeSession",
|
|
1614
|
+
data: {
|
|
1615
|
+
presence,
|
|
1616
|
+
stateVector: this._crdtManager.doc.getStateVector(),
|
|
1617
|
+
update
|
|
1618
|
+
}
|
|
1619
|
+
});
|
|
1620
|
+
}
|
|
1621
|
+
async _handleStorageReceivedMessage(message) {
|
|
1622
|
+
const { connectionId } = message;
|
|
1623
|
+
if (!connectionId) return;
|
|
1624
|
+
if (!this._state.webSocket) throw new Error("Could not find WebSocket");
|
|
1625
|
+
const data = message.data;
|
|
1626
|
+
const changeKind = data.changeKind;
|
|
1627
|
+
const state = data.state;
|
|
1628
|
+
const updates = await this._storageStore.getUpdates();
|
|
1629
|
+
const origin = ORIGIN_INITIALIZED;
|
|
1630
|
+
const updateStorage = (update) => {
|
|
1631
|
+
return this._crdtManager.initialized ? this._crdtManager.applyUpdate({
|
|
1632
|
+
update,
|
|
1633
|
+
origin
|
|
1634
|
+
}) : this._crdtManager.initialize({
|
|
1635
|
+
update,
|
|
1636
|
+
origin
|
|
1637
|
+
});
|
|
1638
|
+
};
|
|
1639
|
+
if (changeKind === "empty") {
|
|
1640
|
+
this._crdtManager.destroy();
|
|
1641
|
+
this._crdtManager.initialize({
|
|
1642
|
+
origin,
|
|
1643
|
+
update: updates
|
|
1644
|
+
});
|
|
1645
|
+
} else if (changeKind === "initialized") {
|
|
1646
|
+
if (updates.length) updateStorage(updates);
|
|
1647
|
+
else {
|
|
1648
|
+
const encodedState = updateStorage(state).doc.getEncodedState();
|
|
1649
|
+
this._addToStorageStore(encodedState);
|
|
1650
|
+
}
|
|
1651
|
+
} else {
|
|
1652
|
+
const encodedState = updateStorage(state).doc.getEncodedState();
|
|
1653
|
+
if (updates.length) this._crdtManager.applyUpdate({
|
|
1654
|
+
update: updates,
|
|
1655
|
+
origin
|
|
1656
|
+
});
|
|
1657
|
+
this._addToStorageStore(encodedState);
|
|
1658
|
+
}
|
|
1659
|
+
for (const update of this._storageSync.markReady()) this._crdtManager.doc.applyEncodedState({
|
|
1660
|
+
origin: ORIGIN_STORAGE_UPDATED,
|
|
1661
|
+
update
|
|
1662
|
+
});
|
|
1663
|
+
const encodedState = this._crdtManager.doc.getEncodedState();
|
|
1664
|
+
this._updateState((oldState) => {
|
|
1665
|
+
oldState.storage.state = StorageState.Synced;
|
|
1666
|
+
return oldState;
|
|
1667
|
+
});
|
|
1668
|
+
this._emitSharedTypes();
|
|
1669
|
+
this._observeCrdt();
|
|
1670
|
+
this._stateNotifier.subjects["storage-loaded"].next(true);
|
|
1671
|
+
this._settleStorageDiff({
|
|
1672
|
+
fromDiff: false,
|
|
1673
|
+
moved: true
|
|
1674
|
+
});
|
|
1675
|
+
this._sendMessage({
|
|
1676
|
+
type: "$updateStorage",
|
|
1677
|
+
data: {
|
|
1678
|
+
origin,
|
|
1679
|
+
update: encodedState
|
|
1680
|
+
}
|
|
1681
|
+
});
|
|
1682
|
+
}
|
|
1683
|
+
_handleStorageUpdatedMessage(message) {
|
|
1684
|
+
const { connectionId } = message;
|
|
1685
|
+
if (!connectionId) return;
|
|
1686
|
+
if (!this._state.webSocket) throw new Error("Could not find WebSocket");
|
|
1687
|
+
const data = message.data;
|
|
1688
|
+
if (!this._storageSync.isReady) {
|
|
1689
|
+
this._storageSync.hold(data.state);
|
|
1690
|
+
return;
|
|
1691
|
+
}
|
|
1692
|
+
this._applyRemoteStorage(data.state, { fromDiff: false });
|
|
1693
|
+
}
|
|
1694
|
+
_handleStorageDiffMessage(message) {
|
|
1695
|
+
const { connectionId } = message;
|
|
1696
|
+
if (!connectionId) return;
|
|
1697
|
+
if (!this._state.webSocket) throw new Error("Could not find WebSocket");
|
|
1698
|
+
const data = message.data;
|
|
1699
|
+
if (!this._storageSync.isReady) {
|
|
1700
|
+
if (data.update) this._storageSync.hold(data.update);
|
|
1701
|
+
return;
|
|
1702
|
+
}
|
|
1703
|
+
if (!data.update) {
|
|
1704
|
+
if (this._crdtManager.doc.hasPending() || !this._crdtManager.doc.canDetectPending()) this._noteUnchangedStorage();
|
|
1705
|
+
return;
|
|
1706
|
+
}
|
|
1707
|
+
this._applyRemoteStorage(data.update, { fromDiff: true });
|
|
1708
|
+
}
|
|
1709
|
+
_handleStorageResyncMessage(message) {
|
|
1710
|
+
const { connectionId } = message;
|
|
1711
|
+
if (!connectionId || connectionId !== this._state.connection.id) return;
|
|
1712
|
+
if (!this._state.webSocket) return;
|
|
1713
|
+
const stateVector = message.data.stateVector;
|
|
1714
|
+
if (!stateVector) return;
|
|
1715
|
+
const update = this._crdtManager.doc.encodeDiff(stateVector);
|
|
1716
|
+
if (!update) return;
|
|
1717
|
+
this._sendMessage({
|
|
1718
|
+
type: "$updateStorage",
|
|
1719
|
+
data: {
|
|
1720
|
+
origin: connectionId,
|
|
1721
|
+
resync: true,
|
|
1722
|
+
update
|
|
1723
|
+
}
|
|
1724
|
+
});
|
|
1725
|
+
}
|
|
1726
|
+
_applyRemoteStorage(update, params) {
|
|
1727
|
+
const before = this._crdtManager.doc.getStateVector();
|
|
1728
|
+
this._crdtManager.doc.applyEncodedState({
|
|
1729
|
+
origin: ORIGIN_STORAGE_UPDATED,
|
|
1730
|
+
update
|
|
1731
|
+
});
|
|
1732
|
+
const moved = this._crdtManager.doc.getStateVector() !== before;
|
|
1733
|
+
const sharedTypes = this._crdtManager.doc.get();
|
|
1734
|
+
const storageRoot = Object.keys(sharedTypes).reduce((acc, prop) => {
|
|
1735
|
+
const serialized = this._crdtManager.doc.toJson(prop);
|
|
1736
|
+
this._crdtNotifier.subject(prop).next(serialized);
|
|
1737
|
+
acc[prop] = serialized;
|
|
1738
|
+
return acc;
|
|
1739
|
+
}, {});
|
|
1740
|
+
this._crdtNotifier.rootSubject.next(storageRoot);
|
|
1741
|
+
this._settleStorageDiff({
|
|
1742
|
+
fromDiff: params.fromDiff,
|
|
1743
|
+
moved
|
|
1744
|
+
});
|
|
1745
|
+
}
|
|
1746
|
+
_noteUnchangedStorage() {
|
|
1747
|
+
if (!this._storageSync.noteStall()) return;
|
|
1748
|
+
console.warn("Storage update did not change the document");
|
|
1749
|
+
}
|
|
1750
|
+
_settleStorageDiff(params) {
|
|
1751
|
+
const { fromDiff, moved } = params;
|
|
1752
|
+
const doc = this._crdtManager.doc;
|
|
1753
|
+
if (!(doc.hasPending() || !doc.canDetectPending())) {
|
|
1754
|
+
this._storageSync.clearStall();
|
|
1755
|
+
return;
|
|
1756
|
+
}
|
|
1757
|
+
if (fromDiff && !moved) {
|
|
1758
|
+
this._noteUnchangedStorage();
|
|
1759
|
+
return;
|
|
1760
|
+
}
|
|
1761
|
+
this._storageSync.clearStall();
|
|
1762
|
+
this._requestStorageDiff();
|
|
1763
|
+
}
|
|
1764
|
+
_requestStorageDiff() {
|
|
1765
|
+
const webSocket = this._state.webSocket;
|
|
1766
|
+
if (!webSocket || webSocket.readyState !== WebSocket.OPEN) return;
|
|
1767
|
+
this._sendMessage({
|
|
1768
|
+
type: "$syncStorage",
|
|
1769
|
+
data: { stateVector: this._crdtManager.doc.canDetectPending() ? this._crdtManager.doc.getStateVector() : "" }
|
|
1770
|
+
});
|
|
1771
|
+
}
|
|
1772
|
+
_handleSyncStateReceived(message) {
|
|
1773
|
+
if (!this._state.webSocket) throw new Error("Could not find WebSocket");
|
|
1774
|
+
if (!this._usersManager.myself) return;
|
|
1775
|
+
const data = message.data;
|
|
1776
|
+
const active = new Set(data?.connectionIds ?? []);
|
|
1777
|
+
this._usersManager.pruneConnections(active).forEach((quitter) => {
|
|
1778
|
+
const remaining = this._usersManager.getOthers();
|
|
1779
|
+
this._deleteOtherNotifier({
|
|
1780
|
+
id: this._usersManager.getParticipantId(quitter),
|
|
1781
|
+
kind: quitter.kind
|
|
1782
|
+
});
|
|
1783
|
+
this._usersNotifier.others.next({
|
|
1784
|
+
others: remaining,
|
|
1785
|
+
event: {
|
|
1786
|
+
kind: "leave",
|
|
1787
|
+
user: quitter
|
|
1788
|
+
}
|
|
1789
|
+
});
|
|
1790
|
+
});
|
|
1791
|
+
const remaining = this._usersManager.getOthers();
|
|
1792
|
+
this._stateNotifier.subjects.others.next(remaining);
|
|
1793
|
+
}
|
|
1794
|
+
_handleRoomError(message) {
|
|
1795
|
+
const data = message.data;
|
|
1796
|
+
this._errorSubject.next({
|
|
1797
|
+
message: data.message,
|
|
1798
|
+
stack: data.stack ?? null
|
|
1799
|
+
});
|
|
1800
|
+
}
|
|
1801
|
+
_handleRoomStats(message) {
|
|
1802
|
+
const data = message.data;
|
|
1803
|
+
this._setRoomStats(data);
|
|
1804
|
+
}
|
|
1805
|
+
_handleUsersPage(message) {
|
|
1806
|
+
const data = message.data;
|
|
1807
|
+
this._requests.complete(data.requestId, data.success ? {
|
|
1808
|
+
success: true,
|
|
1809
|
+
pageInfo: data.pageInfo,
|
|
1810
|
+
users: data.users
|
|
1811
|
+
} : {
|
|
1812
|
+
success: false,
|
|
1813
|
+
error: data.error
|
|
1814
|
+
});
|
|
1815
|
+
}
|
|
1816
|
+
_handleUserJoinedMessage(message) {
|
|
1817
|
+
const { connectionId } = message;
|
|
1818
|
+
if (!connectionId) return;
|
|
1819
|
+
if (!this._state.webSocket) throw new Error("Could not find WebSocket");
|
|
1820
|
+
if (!this._usersManager.myself) return;
|
|
1821
|
+
const data = message.data;
|
|
1822
|
+
if (connectionId === this._state.connection.id) {
|
|
1823
|
+
this._sendMessage({
|
|
1824
|
+
type: "$getOthers",
|
|
1825
|
+
data: {}
|
|
1826
|
+
});
|
|
1827
|
+
return;
|
|
1828
|
+
}
|
|
1829
|
+
const added = this._usersManager.addConnection({
|
|
1830
|
+
connectionId,
|
|
1831
|
+
data: data.user,
|
|
1832
|
+
kind: data.session.kind,
|
|
1833
|
+
operator: data.session.operator,
|
|
1834
|
+
presence: data.session.presence,
|
|
1835
|
+
presenceSeq: data.session.seq.presence
|
|
1836
|
+
});
|
|
1837
|
+
if (added.isMyself) return;
|
|
1838
|
+
const other = this._usersManager.getOtherByConnectionId(connectionId, { kinds: ["user", "operator"] });
|
|
1839
|
+
const others = this._usersManager.getOthers();
|
|
1840
|
+
if (added.remaining !== 1) {
|
|
1841
|
+
if (!added.presenceChanged || !other) return;
|
|
1842
|
+
this._notifyOther(other);
|
|
1843
|
+
this._stateNotifier.subjects.others.next(others);
|
|
1844
|
+
this._usersNotifier.others.next({
|
|
1845
|
+
others,
|
|
1846
|
+
event: {
|
|
1847
|
+
kind: "update",
|
|
1848
|
+
user: other
|
|
1849
|
+
}
|
|
1850
|
+
});
|
|
1851
|
+
return;
|
|
1852
|
+
}
|
|
1853
|
+
if (other) this._notifyOther(other);
|
|
1854
|
+
this._stateNotifier.subjects.others.next(others);
|
|
1855
|
+
this._usersNotifier.others.next({
|
|
1856
|
+
others,
|
|
1857
|
+
event: {
|
|
1858
|
+
kind: "enter",
|
|
1859
|
+
user: added.data
|
|
1860
|
+
}
|
|
1861
|
+
});
|
|
1862
|
+
}
|
|
1863
|
+
_heartbeat() {
|
|
1864
|
+
this._clearTimeout(this._timeouts.pong);
|
|
1865
|
+
this._timeouts.pong = setTimeout(this._reconnect.bind(this), PONG_TIMEOUT_MS);
|
|
1866
|
+
/**
|
|
1867
|
+
* !HACK
|
|
1868
|
+
* @description Send data as a stable string so that the server can react to this in a
|
|
1869
|
+
* consistent way.
|
|
1870
|
+
* @date July 8, 2024
|
|
1871
|
+
*/
|
|
1872
|
+
this._sendMessage("{\"type\":\"$ping\",\"data\":{}}");
|
|
1873
|
+
}
|
|
1874
|
+
_logDebug(...data) {
|
|
1875
|
+
if (typeof process === "undefined") return;
|
|
1876
|
+
if (process.env?.NODE_ENV === "production") return;
|
|
1877
|
+
if (this._debug) console.log(...data);
|
|
1878
|
+
}
|
|
1879
|
+
_observeCrdt() {
|
|
1880
|
+
this._subscriptions.observeCrdt?.();
|
|
1881
|
+
const unsubscribe = this._crdtManager.doc.subscribe((event) => {
|
|
1882
|
+
const origin = event.origin ?? null;
|
|
1883
|
+
this._emitSharedTypes();
|
|
1884
|
+
if (origin === ORIGIN_INITIALIZED || origin === ORIGIN_STORAGE_UPDATED) return;
|
|
1885
|
+
this._addToStorageStore(event.update);
|
|
1886
|
+
if (!(!!this._state.webSocket && !!this._state.connection.id && this._state.webSocket.readyState === WebSocket.OPEN)) {
|
|
1887
|
+
this._logDebug("Dropped a storage update: storage is not syncing");
|
|
1888
|
+
return;
|
|
1889
|
+
}
|
|
1890
|
+
this._sendMessage({
|
|
1891
|
+
type: "$updateStorage",
|
|
1892
|
+
data: {
|
|
1893
|
+
origin,
|
|
1894
|
+
update: event.update,
|
|
1895
|
+
...this._storageProcedureNames.at(-1) ? { procedure: this._storageProcedureNames.at(-1) } : {}
|
|
1896
|
+
}
|
|
1897
|
+
});
|
|
1898
|
+
});
|
|
1899
|
+
this._subscriptions.observeCrdt = unsubscribe;
|
|
1900
|
+
}
|
|
1901
|
+
_onAuthorizationFail(error) {
|
|
1902
|
+
const authError = error instanceof Error ? error : /* @__PURE__ */ new Error("Authorization failed");
|
|
1903
|
+
this._listeners.onAuthorizationFail(authError);
|
|
1904
|
+
this._updateState((oldState) => {
|
|
1905
|
+
oldState.connection.state = ConnectionState$1.Unavailable;
|
|
1906
|
+
oldState.storage.state = StorageState.Unavailable;
|
|
1907
|
+
return oldState;
|
|
1908
|
+
});
|
|
1909
|
+
}
|
|
1910
|
+
async _onClose(event) {
|
|
1911
|
+
this._listenerManager.subjects.close.next(event);
|
|
1912
|
+
this._logDebug("WebSocket closed");
|
|
1913
|
+
if (event.reason) this._logDebug(event.reason);
|
|
1914
|
+
const shouldRetry = [
|
|
1915
|
+
1001,
|
|
1916
|
+
1006,
|
|
1917
|
+
1011,
|
|
1918
|
+
1012,
|
|
1919
|
+
1013,
|
|
1920
|
+
1014
|
|
1921
|
+
].some((okToRetryCode) => event.code === okToRetryCode);
|
|
1922
|
+
this._updateState((oldState) => {
|
|
1923
|
+
oldState.authorization.token = null;
|
|
1924
|
+
oldState.connection.state = shouldRetry ? ConnectionState$1.Unavailable : ConnectionState$1.Closed;
|
|
1925
|
+
oldState.storage.state = oldState.storage.state === StorageState.Synced ? StorageState.Offline : StorageState.Unavailable;
|
|
1926
|
+
oldState.webSocket = null;
|
|
1927
|
+
return oldState;
|
|
1928
|
+
});
|
|
1929
|
+
if (shouldRetry) {
|
|
1930
|
+
await this._reconnect();
|
|
1931
|
+
return;
|
|
1932
|
+
}
|
|
1933
|
+
this._closeWs();
|
|
1934
|
+
this._clearTimeout(this._timeouts.reconnect);
|
|
1935
|
+
}
|
|
1936
|
+
/**
|
|
1937
|
+
* TODO
|
|
1938
|
+
* @description Handle websocket errors in a meaningful way, should they occur
|
|
1939
|
+
* @date August 19, 2022
|
|
1940
|
+
*/
|
|
1941
|
+
_onError(event) {
|
|
1942
|
+
this._listenerManager.subjects.error.next(event);
|
|
1943
|
+
}
|
|
1944
|
+
_onMessage(event) {
|
|
1945
|
+
this._listenerManager.subjects.message.next(event);
|
|
1946
|
+
const message = this._parseMessage(event);
|
|
1947
|
+
if (!message) return;
|
|
1948
|
+
if (typeof this._debug === "boolean" ? this._debug : this._debug.output.find((value) => value === message.type)) this._logDebug("WebSocket event received: ", message.type, message);
|
|
1949
|
+
if (!message.type.startsWith("$")) this._eventNotifier.subject(message.type).next(message);
|
|
1950
|
+
switch (message.type) {
|
|
1951
|
+
case "$error":
|
|
1952
|
+
this._handleRoomError(message);
|
|
1953
|
+
return;
|
|
1954
|
+
case "$exit":
|
|
1955
|
+
this._handleExit(message);
|
|
1956
|
+
return;
|
|
1957
|
+
case "$othersReceived":
|
|
1958
|
+
this._handleReceiveOthers(message);
|
|
1959
|
+
return;
|
|
1960
|
+
case "$pong":
|
|
1961
|
+
this._clearTimeout(this._timeouts.pong);
|
|
1962
|
+
return;
|
|
1963
|
+
case "$presenceUpdated":
|
|
1964
|
+
this._handlePresenceUpdatedMessage(message);
|
|
1965
|
+
return;
|
|
1966
|
+
case "$registered":
|
|
1967
|
+
this._handleRegisteredMessage(message);
|
|
1968
|
+
return;
|
|
1969
|
+
case "$roomStats":
|
|
1970
|
+
this._handleRoomStats(message);
|
|
1971
|
+
return;
|
|
1972
|
+
case "$storageReceived":
|
|
1973
|
+
this._handleStorageReceivedMessage(message);
|
|
1974
|
+
return;
|
|
1975
|
+
case "$storageDiff":
|
|
1976
|
+
this._handleStorageDiffMessage(message);
|
|
1977
|
+
return;
|
|
1978
|
+
case "$storageResync":
|
|
1979
|
+
this._handleStorageResyncMessage(message);
|
|
1980
|
+
return;
|
|
1981
|
+
case "$storageUpdated":
|
|
1982
|
+
this._handleStorageUpdatedMessage(message);
|
|
1983
|
+
return;
|
|
1984
|
+
case "$syncStateReceived":
|
|
1985
|
+
this._handleSyncStateReceived(message);
|
|
1986
|
+
return;
|
|
1987
|
+
case "$userJoined":
|
|
1988
|
+
this._handleUserJoinedMessage(message);
|
|
1989
|
+
return;
|
|
1990
|
+
case "$usersPage":
|
|
1991
|
+
this._handleUsersPage(message);
|
|
1992
|
+
return;
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
_onOpen(event) {
|
|
1996
|
+
this._listenerManager.subjects.open.next(event);
|
|
1997
|
+
if (this._state.connection.state !== ConnectionState$1.Connecting) return;
|
|
1998
|
+
this._logDebug("WebSocket connected");
|
|
1999
|
+
this._updateState((oldState) => {
|
|
2000
|
+
oldState.connection.state = ConnectionState$1.Open;
|
|
2001
|
+
return oldState;
|
|
2002
|
+
});
|
|
2003
|
+
this._clearInterval(this._intervals.heartbeat);
|
|
2004
|
+
this._intervals.heartbeat = setInterval(this._heartbeat.bind(this), HEARTBEAT_INTERVAL_MS);
|
|
2005
|
+
}
|
|
2006
|
+
async _onNavigatorOffline() {
|
|
2007
|
+
if (this._state.connection.state === ConnectionState$1.Closed) return;
|
|
2008
|
+
this._updateState((oldState) => {
|
|
2009
|
+
oldState.connection.state = ConnectionState$1.Unavailable;
|
|
2010
|
+
oldState.storage.state = oldState.storage.state === StorageState.Synced ? StorageState.Offline : StorageState.Unavailable;
|
|
2011
|
+
return oldState;
|
|
2012
|
+
});
|
|
2013
|
+
}
|
|
2014
|
+
async _onNavigatorOnline() {
|
|
2015
|
+
if (this._state.connection.state !== ConnectionState$1.Unavailable) return;
|
|
2016
|
+
await this._reconnect();
|
|
2017
|
+
}
|
|
2018
|
+
async _onVisibilityChange() {
|
|
2019
|
+
if (typeof document === "undefined") return;
|
|
2020
|
+
if (document.visibilityState !== "visible") return;
|
|
2021
|
+
switch (this._state.connection.state) {
|
|
2022
|
+
case ConnectionState$1.Open:
|
|
2023
|
+
this._clearInterval(this._intervals.heartbeat);
|
|
2024
|
+
setInterval(this._heartbeat.bind(this), HEARTBEAT_INTERVAL_MS);
|
|
2025
|
+
return;
|
|
2026
|
+
case ConnectionState$1.Unavailable:
|
|
2027
|
+
await this._reconnect();
|
|
2028
|
+
return;
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
async _onWindowFocus() {
|
|
2032
|
+
return await this._onVisibilityChange();
|
|
2033
|
+
}
|
|
2034
|
+
_parseMessage(message) {
|
|
2035
|
+
/**
|
|
2036
|
+
* !HACK
|
|
2037
|
+
* @description We'll only handle stringified JSONs for now
|
|
2038
|
+
* @date August 9, 2022
|
|
2039
|
+
*/
|
|
2040
|
+
if (typeof message.data !== "string") return null;
|
|
2041
|
+
try {
|
|
2042
|
+
const config = JSON.parse(message.data);
|
|
2043
|
+
if (typeof config !== "object") return null;
|
|
2044
|
+
if (typeof config.type !== "string") return null;
|
|
2045
|
+
if (typeof config.data !== "object") return null;
|
|
2046
|
+
return config;
|
|
2047
|
+
} catch {
|
|
2048
|
+
return null;
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
2051
|
+
_pollReconnect() {
|
|
2052
|
+
this._clearTimeout(this._timeouts.reconnect);
|
|
2053
|
+
const timeoutMs = typeof this._reconnectTimeoutMs === "number" ? this._reconnectTimeoutMs : this._reconnectTimeoutMs({ attempts: this._state.connection.attempts });
|
|
2054
|
+
const clampedMs = Math.max(Math.min(MIN_RECONNECT_TIMEOUT_MS, timeoutMs), MAX_RECONNECT_TIMEOUT_MS);
|
|
2055
|
+
this._timeouts.reconnect = setTimeout(this._reconnect.bind(this), clampedMs);
|
|
2056
|
+
}
|
|
2057
|
+
async _reconnect() {
|
|
2058
|
+
this._closeWs();
|
|
2059
|
+
/**
|
|
2060
|
+
* @description User has manually closed this connection. We're not going to retry, since
|
|
2061
|
+
* the user probably intends for this to stay closed. They will need to invoke connect
|
|
2062
|
+
* themselves.
|
|
2063
|
+
* @date April 28, 2025
|
|
2064
|
+
*/
|
|
2065
|
+
if (this._state.connection.state === ConnectionState$1.Closed) return;
|
|
2066
|
+
const metadata = this._lastMetadata;
|
|
2067
|
+
const params = typeof metadata === "undefined" ? [] : [{ metadata }];
|
|
2068
|
+
await this.connect(...params);
|
|
2069
|
+
}
|
|
2070
|
+
async _runPresenceProcedure(name, data) {
|
|
2071
|
+
const procedures = this._treaty._defs.procedures.presence;
|
|
2072
|
+
if (!procedures || !Object.hasOwn(procedures, name)) throw new Error(`Unknown presence procedure "${name}"`);
|
|
2073
|
+
const procedure = procedures[name];
|
|
2074
|
+
const myself = this._usersManager.myself;
|
|
2075
|
+
if (!myself) throw new Error("Cannot run a treaty procedure before the local user is available");
|
|
2076
|
+
const doc = this._crdtManager.doc;
|
|
2077
|
+
const seq = ++this._seq.presence;
|
|
2078
|
+
const patch = await procedure.apply(data, {
|
|
2079
|
+
user: myself.data,
|
|
2080
|
+
operator: myself.operator,
|
|
2081
|
+
presence: this.getMyPresence(),
|
|
2082
|
+
doc
|
|
2083
|
+
});
|
|
2084
|
+
if (seq !== this._seq.presence) return;
|
|
2085
|
+
await this._applyMyPresence(patch, name);
|
|
2086
|
+
}
|
|
2087
|
+
_runStorageProcedure(name, data) {
|
|
2088
|
+
if (!this.getStorageLoaded()) throw new Error("Cannot run a treaty storage procedure until storage is loaded");
|
|
2089
|
+
if (typeof this._state.connection.id !== "string") throw new Error("Cannot run a treaty storage procedure without a connection id");
|
|
2090
|
+
const procedures = this._treaty._defs.procedures.storage;
|
|
2091
|
+
if (!procedures || !Object.hasOwn(procedures, name)) throw new Error(`Unknown storage procedure "${name}"`);
|
|
2092
|
+
const procedure = procedures[name];
|
|
2093
|
+
const myself = this._usersManager.myself;
|
|
2094
|
+
if (!myself) throw new Error("Cannot run a treaty procedure before the local user is available");
|
|
2095
|
+
const presence = this.getMyPresence();
|
|
2096
|
+
const commit = (loaded) => {
|
|
2097
|
+
if (!this.getStorageLoaded()) return;
|
|
2098
|
+
const doc = this._crdtManager.doc;
|
|
2099
|
+
const apply = () => {
|
|
2100
|
+
procedure.apply(loaded ? loaded.input : data, {
|
|
2101
|
+
user: myself.data,
|
|
2102
|
+
operator: myself.operator,
|
|
2103
|
+
presence,
|
|
2104
|
+
doc
|
|
2105
|
+
}, loaded?.prepared);
|
|
2106
|
+
};
|
|
2107
|
+
this._storageProcedureNames.push(name);
|
|
2108
|
+
try {
|
|
2109
|
+
if (procedure.config.transact === false) {
|
|
2110
|
+
apply();
|
|
2111
|
+
return;
|
|
2112
|
+
}
|
|
2113
|
+
this.transact(() => {
|
|
2114
|
+
apply();
|
|
2115
|
+
});
|
|
2116
|
+
} finally {
|
|
2117
|
+
this._storageProcedureNames.pop();
|
|
2118
|
+
}
|
|
2119
|
+
};
|
|
2120
|
+
if (!procedure.prepared || !procedure.load) {
|
|
2121
|
+
commit(void 0);
|
|
2122
|
+
return;
|
|
2123
|
+
}
|
|
2124
|
+
const loading = procedure.load(data, {
|
|
2125
|
+
user: myself.data,
|
|
2126
|
+
operator: myself.operator,
|
|
2127
|
+
presence
|
|
2128
|
+
});
|
|
2129
|
+
this._storageCommits = sequenceCommit({
|
|
2130
|
+
tail: this._storageCommits,
|
|
2131
|
+
ready: loading,
|
|
2132
|
+
commit
|
|
2133
|
+
});
|
|
2134
|
+
}
|
|
2135
|
+
_sendMessage(data) {
|
|
2136
|
+
const webSocket = this._state.webSocket;
|
|
2137
|
+
if (webSocket?.readyState !== WebSocket.OPEN) return;
|
|
2138
|
+
const dataType = (() => {
|
|
2139
|
+
if (typeof data === "object") return data.type ?? null;
|
|
2140
|
+
if (typeof data !== "string") return null;
|
|
2141
|
+
try {
|
|
2142
|
+
return JSON.parse(data)?.type ?? null;
|
|
2143
|
+
} catch {
|
|
2144
|
+
return null;
|
|
2145
|
+
}
|
|
2146
|
+
})();
|
|
2147
|
+
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);
|
|
2148
|
+
const message = typeof data === "string" ? data : JSON.stringify(data);
|
|
2149
|
+
webSocket.send(message);
|
|
2150
|
+
}
|
|
2151
|
+
_setMetadata(metadata) {
|
|
2152
|
+
const parsed = this.metadata ? parsePluvSchema(this.metadata, metadata) : metadata;
|
|
2153
|
+
this._lastMetadata = parsed;
|
|
2154
|
+
return parsed;
|
|
2155
|
+
}
|
|
2156
|
+
#_storage;
|
|
2157
|
+
_setRoomStats(stats) {
|
|
2158
|
+
this._roomStats = stats.user;
|
|
2159
|
+
this._operatorStats = stats.operator;
|
|
2160
|
+
this._stateNotifier.subjects.roomStats.next(this._roomStats);
|
|
2161
|
+
}
|
|
2162
|
+
_notifyOther(other) {
|
|
2163
|
+
this._usersNotifier.other({
|
|
2164
|
+
id: this._usersManager.getParticipantId(other),
|
|
2165
|
+
kind: other.kind
|
|
2166
|
+
}).next(other);
|
|
2167
|
+
}
|
|
2168
|
+
_deleteOtherNotifier(params) {
|
|
2169
|
+
this._usersNotifier.delete(params);
|
|
2170
|
+
}
|
|
2171
|
+
_updateState(updater) {
|
|
2172
|
+
let authorization;
|
|
2173
|
+
try {
|
|
2174
|
+
authorization = JSON.parse(JSON.stringify(this._state.authorization));
|
|
2175
|
+
} catch {
|
|
2176
|
+
throw new Error("User is not JSON serializable");
|
|
2177
|
+
}
|
|
2178
|
+
const newState = updater({
|
|
2179
|
+
...this._state,
|
|
2180
|
+
authorization,
|
|
2181
|
+
connection: JSON.parse(JSON.stringify(this._state.connection)),
|
|
2182
|
+
storage: JSON.parse(JSON.stringify(this._state.storage)),
|
|
2183
|
+
webSocket: this._state.webSocket
|
|
2184
|
+
});
|
|
2185
|
+
this._state = newState;
|
|
2186
|
+
this._logDebug("WebSocket new state: ", newState);
|
|
2187
|
+
this._stateNotifier.subjects.connection.next(newState);
|
|
2188
|
+
return newState;
|
|
2189
|
+
}
|
|
2190
|
+
};
|
|
2191
|
+
// src/PluvClient.ts
|
|
2192
|
+
var PluvClient = class {
|
|
2193
|
+
get _defs() {
|
|
2194
|
+
return {
|
|
2195
|
+
initialStorage: this._initialStorage,
|
|
2196
|
+
storage: this._treaty.storage,
|
|
2197
|
+
treaty: this._treaty
|
|
2198
|
+
};
|
|
2199
|
+
}
|
|
2200
|
+
constructor(options) {
|
|
2201
|
+
this._rooms = /* @__PURE__ */ new Map();
|
|
2202
|
+
this.createRoom = (room, options = {}) => {
|
|
2203
|
+
const oldRoom = this.getRoom(room);
|
|
2204
|
+
if (oldRoom) return oldRoom;
|
|
2205
|
+
const newRoom = new PluvRoom(room, {
|
|
2206
|
+
addons: options.addons,
|
|
2207
|
+
authEndpoint: this._authEndpoint,
|
|
2208
|
+
debug: options.debug,
|
|
2209
|
+
initialPresence: options.initialPresence,
|
|
2210
|
+
initialStorage: options.initialStorage ?? this._initialStorage,
|
|
2211
|
+
limits: this._limits,
|
|
2212
|
+
metadata: this.metadata,
|
|
2213
|
+
onAuthorizationFail: options.onAuthorizationFail,
|
|
2214
|
+
presence: this._treaty.presence,
|
|
2215
|
+
reconnectTimeoutMs: options.reconnectTimeoutMs,
|
|
2216
|
+
storage: this._treaty.storage ?? noop.doc(),
|
|
2217
|
+
treaty: this._treaty,
|
|
2218
|
+
wsEndpoint: this._wsEndpoint
|
|
2219
|
+
});
|
|
2220
|
+
this._rooms.set(room, newRoom);
|
|
2221
|
+
this._logDebug(`New room was created: ${room}`);
|
|
2222
|
+
return newRoom;
|
|
2223
|
+
};
|
|
2224
|
+
this.enter = async (room, ...args) => {
|
|
2225
|
+
const toEnter = typeof room === "string" ? this.getRoom(room) : room;
|
|
2226
|
+
const roomId = typeof room === "string" ? room : room.id;
|
|
2227
|
+
if (!toEnter) throw new Error(`Could not find room: ${roomId}.`);
|
|
2228
|
+
this._rooms.set(toEnter.id, toEnter);
|
|
2229
|
+
await toEnter.connect(...args);
|
|
2230
|
+
this._logDebug(`Entered room: ${roomId}`);
|
|
2231
|
+
return toEnter;
|
|
2232
|
+
};
|
|
2233
|
+
this.getRoom = (room) => {
|
|
2234
|
+
return this._rooms.get(room) ?? null;
|
|
2235
|
+
};
|
|
2236
|
+
this.getRooms = () => {
|
|
2237
|
+
return Array.from(this._rooms.values());
|
|
2238
|
+
};
|
|
2239
|
+
this.leave = async (room) => {
|
|
2240
|
+
const toLeave = typeof room === "string" ? this.getRoom(room) : room;
|
|
2241
|
+
if (!toLeave) return;
|
|
2242
|
+
await toLeave.disconnect();
|
|
2243
|
+
this._rooms.delete(toLeave.id);
|
|
2244
|
+
this._logDebug(`Left and deleted room: ${toLeave.id}`);
|
|
2245
|
+
};
|
|
2246
|
+
const { authEndpoint, debug = false, initialStorage, limits, metadata, treaty, wsEndpoint } = options;
|
|
2247
|
+
this.metadata = metadata;
|
|
2248
|
+
if (metadata) assertSerializingSchema(metadata);
|
|
2249
|
+
this._authEndpoint = authEndpoint;
|
|
2250
|
+
this._debug = debug;
|
|
2251
|
+
this._initialStorage = initialStorage;
|
|
2252
|
+
this._limits = {
|
|
2253
|
+
presenceMaxSize: 512,
|
|
2254
|
+
...limits
|
|
2255
|
+
};
|
|
2256
|
+
this._treaty = treaty;
|
|
2257
|
+
this._wsEndpoint = wsEndpoint;
|
|
2258
|
+
}
|
|
2259
|
+
_logDebug(...data) {
|
|
2260
|
+
if (typeof process === "undefined") return;
|
|
2261
|
+
if (process.env?.NODE_ENV === "production") return;
|
|
2262
|
+
if (this._debug) console.log(...data);
|
|
2263
|
+
}
|
|
2264
|
+
};
|
|
2265
|
+
// src/createClient.ts
|
|
2266
|
+
const createClient = () => ({ config: ((options) => new PluvClient(options)) });
|
|
2267
|
+
// src/MockedRoom.ts
|
|
2268
|
+
const participantOrder = (kind) => {
|
|
2269
|
+
switch (kind) {
|
|
2270
|
+
case "user": return 0;
|
|
2271
|
+
case "operator": return 1;
|
|
2272
|
+
default: return assertExhaustive(kind);
|
|
2273
|
+
}
|
|
2274
|
+
};
|
|
2275
|
+
var MockedRoom = class {
|
|
2276
|
+
constructor(room, options) {
|
|
2277
|
+
this._crdtNotifier = new CrdtNotifier();
|
|
2278
|
+
this._eventNotifier = new EventNotifier();
|
|
2279
|
+
this._errorSubject = makeSubject();
|
|
2280
|
+
this._usersNotifier = new UsersNotifier();
|
|
2281
|
+
this._seq = { presence: 0 };
|
|
2282
|
+
this._storageCommits = Promise.resolve();
|
|
2283
|
+
this._state = {
|
|
2284
|
+
authorization: {
|
|
2285
|
+
token: null,
|
|
2286
|
+
user: null,
|
|
2287
|
+
kind: "user",
|
|
2288
|
+
operator: null
|
|
2289
|
+
},
|
|
2290
|
+
connection: {
|
|
2291
|
+
attempts: 0,
|
|
2292
|
+
id: null,
|
|
2293
|
+
state: ConnectionState$1.Untouched
|
|
2294
|
+
},
|
|
2295
|
+
storage: { state: StorageState.Unavailable },
|
|
2296
|
+
webSocket: null
|
|
2297
|
+
};
|
|
2298
|
+
this._stateNotifier = new StateNotifier();
|
|
2299
|
+
this._subscriptions = { observeCrdt: null };
|
|
2300
|
+
this.broadcast = new Proxy(async (event, data) => {
|
|
2301
|
+
this._simulateEvent(event, data);
|
|
2302
|
+
}, { get(fn, prop) {
|
|
2303
|
+
return async (data) => {
|
|
2304
|
+
return await fn(prop, data);
|
|
2305
|
+
};
|
|
2306
|
+
} });
|
|
2307
|
+
this.canRedo = () => {
|
|
2308
|
+
return this._crdtManager.doc.canRedo();
|
|
2309
|
+
};
|
|
2310
|
+
this.canUndo = () => {
|
|
2311
|
+
return this._crdtManager.doc.canUndo();
|
|
2312
|
+
};
|
|
2313
|
+
this.getConnection = () => {
|
|
2314
|
+
return Object.freeze(JSON.parse(JSON.stringify(this._state.connection)));
|
|
2315
|
+
};
|
|
2316
|
+
this.getMyPresence = () => {
|
|
2317
|
+
return this._usersManager.myPresence;
|
|
2318
|
+
};
|
|
2319
|
+
this.getMyself = () => {
|
|
2320
|
+
return this._usersManager.myself;
|
|
2321
|
+
};
|
|
2322
|
+
this.getOther = (userId, options) => {
|
|
2323
|
+
return this._usersManager.getOther(userId, options);
|
|
2324
|
+
};
|
|
2325
|
+
this.getOtherByConnectionId = (connectionId, options) => {
|
|
2326
|
+
return this._usersManager.getOtherByConnectionId(connectionId, options);
|
|
2327
|
+
};
|
|
2328
|
+
this.getOthers = (options) => {
|
|
2329
|
+
return this._usersManager.getOthers(options);
|
|
2330
|
+
};
|
|
2331
|
+
this.getRoomStats = (options) => {
|
|
2332
|
+
return this._usersManager.getOccupancy(options);
|
|
2333
|
+
};
|
|
2334
|
+
this.getStorage = (type) => {
|
|
2335
|
+
const sharedType = this._crdtManager.get(type);
|
|
2336
|
+
if (typeof sharedType === "undefined") return null;
|
|
2337
|
+
return sharedType;
|
|
2338
|
+
};
|
|
2339
|
+
this.listUsers = (options = {}) => {
|
|
2340
|
+
const kinds = new Set(options.kinds ?? ["user"]);
|
|
2341
|
+
const myself = this._usersManager.myself;
|
|
2342
|
+
const users = [...myself && kinds.has(myself.kind) ? [myself] : [], ...this._usersManager.getOthers({ kinds: [...kinds] })].map((person) => ({
|
|
2343
|
+
data: person.data,
|
|
2344
|
+
kind: person.kind,
|
|
2345
|
+
operator: person.operator
|
|
2346
|
+
})).toSorted((left, right) => {
|
|
2347
|
+
const byKind = participantOrder(left.kind) - participantOrder(right.kind);
|
|
2348
|
+
if (byKind !== 0) return byKind;
|
|
2349
|
+
const a = String(left.data.id);
|
|
2350
|
+
const b = String(right.data.id);
|
|
2351
|
+
if (a < b) return -1;
|
|
2352
|
+
if (a > b) return 1;
|
|
2353
|
+
return 0;
|
|
2354
|
+
});
|
|
2355
|
+
const last = users.at(-1);
|
|
2356
|
+
return Promise.resolve({
|
|
2357
|
+
success: true,
|
|
2358
|
+
pageInfo: {
|
|
2359
|
+
endCursor: last ? {
|
|
2360
|
+
kind: last.kind,
|
|
2361
|
+
id: String(last.data.id)
|
|
2362
|
+
} : null,
|
|
2363
|
+
hasNextPage: false
|
|
2364
|
+
},
|
|
2365
|
+
users
|
|
2366
|
+
});
|
|
2367
|
+
};
|
|
2368
|
+
this.presence = new Proxy(async (name, data) => {
|
|
2369
|
+
await this._runPresenceProcedure(name, data);
|
|
2370
|
+
}, { get(fn, prop) {
|
|
2371
|
+
return (data) => fn(prop, data);
|
|
2372
|
+
} });
|
|
2373
|
+
this.redo = () => {
|
|
2374
|
+
this._crdtManager.doc.redo();
|
|
2375
|
+
};
|
|
2376
|
+
this.storage = new Proxy((name, data) => {
|
|
2377
|
+
this._runStorageProcedure(name, data);
|
|
2378
|
+
}, { get(fn, prop) {
|
|
2379
|
+
return (data) => fn(prop, data);
|
|
2380
|
+
} });
|
|
2381
|
+
this.storageRoot = (fn) => {
|
|
2382
|
+
return this._crdtNotifier.subcribeRoot(fn);
|
|
2383
|
+
};
|
|
2384
|
+
this.subscribe = new Proxy((name, callback) => {
|
|
2385
|
+
return this._stateNotifier.subscribe(name, callback);
|
|
2386
|
+
}, { get: (fn, prop) => {
|
|
2387
|
+
if (prop === "connection") return (callback) => {
|
|
2388
|
+
return fn("connection", callback);
|
|
2389
|
+
};
|
|
2390
|
+
if (prop === "error") return (callback) => {
|
|
2391
|
+
const subscription = subscribe(callback)(this._errorSubject.source);
|
|
2392
|
+
return () => {
|
|
2393
|
+
subscription.unsubscribe();
|
|
2394
|
+
};
|
|
2395
|
+
};
|
|
2396
|
+
if (prop === "myPresence") return (callback) => {
|
|
2397
|
+
return fn("my-presence", callback);
|
|
2398
|
+
};
|
|
2399
|
+
if (prop === "myself") return (callback) => {
|
|
2400
|
+
return fn("myself", callback);
|
|
2401
|
+
};
|
|
2402
|
+
if (prop === "others") return (callback, options) => {
|
|
2403
|
+
return this._usersNotifier.subscribeOthers((_listed, event) => {
|
|
2404
|
+
const others = this._usersManager.getOthers(options);
|
|
2405
|
+
if (event.kind === "sync") {
|
|
2406
|
+
callback(others, {
|
|
2407
|
+
kind: "sync",
|
|
2408
|
+
users: others
|
|
2409
|
+
});
|
|
2410
|
+
return;
|
|
2411
|
+
}
|
|
2412
|
+
callback(others, event);
|
|
2413
|
+
}, options);
|
|
2414
|
+
};
|
|
2415
|
+
if (prop === "roomStats") return (callback) => {
|
|
2416
|
+
return fn("roomStats", callback);
|
|
2417
|
+
};
|
|
2418
|
+
if (prop === "storageLoaded") return (callback) => {
|
|
2419
|
+
return fn("storage-loaded", callback);
|
|
2420
|
+
};
|
|
2421
|
+
if (prop === "event") return this._event;
|
|
2422
|
+
if (prop === "other") return this._other;
|
|
2423
|
+
if (prop === "storage") return this.#_storage;
|
|
2424
|
+
throw new Error(`Unknown subscription: ${String(prop)}`);
|
|
2425
|
+
} });
|
|
2426
|
+
this.transact = (fn, origin) => {
|
|
2427
|
+
const _origin = origin ?? this._state.connection.id;
|
|
2428
|
+
const crdtManager = this._crdtManager;
|
|
2429
|
+
/**
|
|
2430
|
+
* !HACK
|
|
2431
|
+
* @description Don't transact anything if there is no origin, because
|
|
2432
|
+
* that means the user isn't connected yet. This will mean events are
|
|
2433
|
+
* lost unfortunately.
|
|
2434
|
+
* @date September 23, 2023
|
|
2435
|
+
*/
|
|
2436
|
+
if (typeof _origin !== "string") return;
|
|
2437
|
+
if (!crdtManager) return;
|
|
2438
|
+
crdtManager.doc.transact(() => {
|
|
2439
|
+
fn(crdtManager.doc.get());
|
|
2440
|
+
}, _origin);
|
|
2441
|
+
};
|
|
2442
|
+
this.undo = () => {
|
|
2443
|
+
this._crdtManager.doc.undo();
|
|
2444
|
+
};
|
|
2445
|
+
this.updateMyPresence = (presence) => {
|
|
2446
|
+
this._applyMyPresence(presence);
|
|
2447
|
+
};
|
|
2448
|
+
this._event = new Proxy((event, callback) => this._eventNotifier.subscribe(event, callback), { get(fn, prop) {
|
|
2449
|
+
return (callback) => fn(prop, callback);
|
|
2450
|
+
} });
|
|
2451
|
+
this._other = (userId, callback, options) => {
|
|
2452
|
+
return this._usersNotifier.subscribeOther(userId, callback, options);
|
|
2453
|
+
};
|
|
2454
|
+
this.#_storage = new Proxy((key, callback) => this._crdtNotifier.subscribe(key, callback), { get: (fn, prop) => {
|
|
2455
|
+
if (!!prop) return (callback) => {
|
|
2456
|
+
return this._crdtNotifier.subcribeRoot(callback);
|
|
2457
|
+
};
|
|
2458
|
+
return (callback) => {
|
|
2459
|
+
return fn(prop, callback);
|
|
2460
|
+
};
|
|
2461
|
+
} });
|
|
2462
|
+
const { events, initialPresence, initialStorage, limits, myself, presence, storage, treaty } = options;
|
|
2463
|
+
this.id = room;
|
|
2464
|
+
this._events = events;
|
|
2465
|
+
this._limits = {
|
|
2466
|
+
presenceMaxSize: 512,
|
|
2467
|
+
...limits
|
|
2468
|
+
};
|
|
2469
|
+
this._treaty = treaty;
|
|
2470
|
+
this._usersManager = new UsersManager({
|
|
2471
|
+
initialPresence,
|
|
2472
|
+
limits: this._limits,
|
|
2473
|
+
presence
|
|
2474
|
+
});
|
|
2475
|
+
if (myself) {
|
|
2476
|
+
const connectionId = myself.connectionId ?? "mocked";
|
|
2477
|
+
const kind = myself.kind ?? "user";
|
|
2478
|
+
this._usersManager.setMyself({
|
|
2479
|
+
connectionId,
|
|
2480
|
+
data: myself.data,
|
|
2481
|
+
kind,
|
|
2482
|
+
operator: myself.operator,
|
|
2483
|
+
presence: initialPresence
|
|
2484
|
+
});
|
|
2485
|
+
this._state.authorization.user = myself.data;
|
|
2486
|
+
this._state.authorization.kind = kind;
|
|
2487
|
+
this._state.authorization.operator = myself.operator ?? null;
|
|
2488
|
+
this._state.connection.id = connectionId;
|
|
2489
|
+
this._state.connection.state = ConnectionState$1.Open;
|
|
2490
|
+
}
|
|
2491
|
+
this._publishRoomStats();
|
|
2492
|
+
this._crdtManager = new CrdtManager({
|
|
2493
|
+
initialStorage,
|
|
2494
|
+
storage
|
|
2495
|
+
});
|
|
2496
|
+
this._observeCrdt();
|
|
2497
|
+
}
|
|
2498
|
+
addEventListener(kind, handler) {
|
|
2499
|
+
return () => void 0;
|
|
2500
|
+
}
|
|
2501
|
+
getDoc() {
|
|
2502
|
+
return this._crdtManager.doc;
|
|
2503
|
+
}
|
|
2504
|
+
getStorageJson(type) {
|
|
2505
|
+
if (this._state.connection.id === null) return null;
|
|
2506
|
+
if (typeof type === "undefined") return this._crdtManager.doc.toJson();
|
|
2507
|
+
return this._crdtManager.doc.toJson(type);
|
|
2508
|
+
}
|
|
2509
|
+
getStorageLoaded() {
|
|
2510
|
+
return true;
|
|
2511
|
+
}
|
|
2512
|
+
_applyMyPresence(presence) {
|
|
2513
|
+
const newPresence = typeof presence === "function" ? presence(this.getMyPresence()) : presence;
|
|
2514
|
+
this._usersManager.updateMyPresence(newPresence);
|
|
2515
|
+
const myPresence = this._usersManager.myPresence;
|
|
2516
|
+
const myself = this._usersManager.myself ?? null;
|
|
2517
|
+
this._stateNotifier.subjects["my-presence"].next(myPresence);
|
|
2518
|
+
if (!!myself) this._stateNotifier.subjects["myself"].next(myself);
|
|
2519
|
+
}
|
|
2520
|
+
_observeCrdt() {
|
|
2521
|
+
this._subscriptions.observeCrdt?.();
|
|
2522
|
+
if (!this._crdtManager) return;
|
|
2523
|
+
const unsubscribe = this._crdtManager.doc.subscribe((event) => {
|
|
2524
|
+
const origin = event.origin ?? null;
|
|
2525
|
+
if (!this._crdtManager) return;
|
|
2526
|
+
if (origin === "$storageUpdated") return;
|
|
2527
|
+
const sharedTypes = this._crdtManager.doc.get();
|
|
2528
|
+
const storageRoot = Object.keys(sharedTypes).reduce((acc, prop) => {
|
|
2529
|
+
if (!this._crdtManager) return acc;
|
|
2530
|
+
const serialized = this._crdtManager.doc.toJson(prop);
|
|
2531
|
+
this._crdtNotifier.subject(prop).next(serialized);
|
|
2532
|
+
acc[prop] = serialized;
|
|
2533
|
+
return acc;
|
|
2534
|
+
}, {});
|
|
2535
|
+
this._crdtNotifier.rootSubject.next(storageRoot);
|
|
2536
|
+
});
|
|
2537
|
+
this._subscriptions.observeCrdt = unsubscribe;
|
|
2538
|
+
}
|
|
2539
|
+
_publishRoomStats() {
|
|
2540
|
+
this._stateNotifier.subjects.roomStats.next(this._usersManager.getOccupancy());
|
|
2541
|
+
}
|
|
2542
|
+
async _runPresenceProcedure(name, data) {
|
|
2543
|
+
const procedures = this._treaty._defs.procedures.presence;
|
|
2544
|
+
if (!procedures || !Object.hasOwn(procedures, name)) throw new Error(`Unknown presence procedure "${name}"`);
|
|
2545
|
+
const procedure = procedures[name];
|
|
2546
|
+
const myself = this._usersManager.myself;
|
|
2547
|
+
if (!myself) throw new Error("Cannot run a treaty procedure before the local user is available");
|
|
2548
|
+
const doc = this._crdtManager.doc;
|
|
2549
|
+
const seq = ++this._seq.presence;
|
|
2550
|
+
const patch = await procedure.apply(data, {
|
|
2551
|
+
user: myself.data,
|
|
2552
|
+
operator: myself.operator,
|
|
2553
|
+
presence: this.getMyPresence(),
|
|
2554
|
+
doc
|
|
2555
|
+
});
|
|
2556
|
+
if (seq !== this._seq.presence) return;
|
|
2557
|
+
this._applyMyPresence(patch);
|
|
2558
|
+
}
|
|
2559
|
+
_runStorageProcedure(name, data) {
|
|
2560
|
+
const origin = this._state.connection.id ?? "mocked";
|
|
2561
|
+
const procedures = this._treaty._defs.procedures.storage;
|
|
2562
|
+
if (!procedures || !Object.hasOwn(procedures, name)) throw new Error(`Unknown storage procedure "${name}"`);
|
|
2563
|
+
const procedure = procedures[name];
|
|
2564
|
+
const myself = this._usersManager.myself;
|
|
2565
|
+
if (!myself) throw new Error("Cannot run a treaty procedure before the local user is available");
|
|
2566
|
+
const presence = this.getMyPresence();
|
|
2567
|
+
const commit = (loaded) => {
|
|
2568
|
+
if (!this.getStorageLoaded()) return;
|
|
2569
|
+
const doc = this._crdtManager.doc;
|
|
2570
|
+
const apply = () => {
|
|
2571
|
+
procedure.apply(loaded ? loaded.input : data, {
|
|
2572
|
+
user: myself.data,
|
|
2573
|
+
operator: myself.operator,
|
|
2574
|
+
presence,
|
|
2575
|
+
doc
|
|
2576
|
+
}, loaded?.prepared);
|
|
2577
|
+
};
|
|
2578
|
+
if (procedure.config.transact === false) {
|
|
2579
|
+
apply();
|
|
2580
|
+
return;
|
|
2581
|
+
}
|
|
2582
|
+
this.transact(() => {
|
|
2583
|
+
apply();
|
|
2584
|
+
}, origin);
|
|
2585
|
+
};
|
|
2586
|
+
if (!procedure.prepared || !procedure.load) {
|
|
2587
|
+
commit(void 0);
|
|
2588
|
+
return;
|
|
2589
|
+
}
|
|
2590
|
+
const loading = procedure.load(data, {
|
|
2591
|
+
user: myself.data,
|
|
2592
|
+
operator: myself.operator,
|
|
2593
|
+
presence
|
|
2594
|
+
});
|
|
2595
|
+
this._storageCommits = sequenceCommit({
|
|
2596
|
+
tail: this._storageCommits,
|
|
2597
|
+
ready: loading,
|
|
2598
|
+
commit
|
|
2599
|
+
});
|
|
2600
|
+
}
|
|
2601
|
+
_simulateEvent(event, data) {
|
|
2602
|
+
if (!this._events) return;
|
|
2603
|
+
const type = event.toString();
|
|
2604
|
+
const resolver = this._events[type];
|
|
2605
|
+
if (!resolver) return;
|
|
2606
|
+
const result = resolver(data);
|
|
2607
|
+
Object.keys(result).forEach((outputType) => {
|
|
2608
|
+
const _type = outputType;
|
|
2609
|
+
const outputData = result[_type];
|
|
2610
|
+
if (!outputData) return;
|
|
2611
|
+
this._eventNotifier.subject(_type).next({
|
|
2612
|
+
connectionId: this._state.connection.id,
|
|
2613
|
+
data: outputData,
|
|
2614
|
+
operator: this._state.authorization.operator,
|
|
2615
|
+
room: this.id,
|
|
2616
|
+
type: _type,
|
|
2617
|
+
user: this._state.authorization.user
|
|
2618
|
+
});
|
|
2619
|
+
});
|
|
2620
|
+
}
|
|
2621
|
+
#_storage;
|
|
2622
|
+
};
|
|
2623
|
+
export { AbstractStorageStore, ConnectionState, MockedRoom, PluvClient, PluvRoom, createClient, parsePluvSchema };
|
|
2624
|
+
|
|
2625
|
+
//# sourceMappingURL=index.mjs.map
|