@nolag/collab 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +191 -0
- package/dist/AwarenessManager.d.ts +51 -0
- package/dist/CollabDocument.d.ts +81 -0
- package/dist/EventEmitter.d.ts +15 -0
- package/dist/NoLagCollab.d.ts +81 -0
- package/dist/OperationStore.d.ts +39 -0
- package/dist/PresenceManager.d.ts +44 -0
- package/dist/browser.d.ts +7 -0
- package/dist/browser.js +2 -0
- package/dist/browser.js.map +1 -0
- package/dist/constants.d.ts +14 -0
- package/dist/index.cjs +984 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.mjs +980 -0
- package/dist/index.mjs.map +1 -0
- package/dist/types.d.ts +178 -0
- package/dist/utils.d.ts +2 -0
- package/package.json +57 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,984 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var jsSdk = require('@nolag/js-sdk');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Tiny typed event emitter — framework-agnostic base for NoLag SDKs.
|
|
7
|
+
*
|
|
8
|
+
* EventMap is a record of event name → tuple of handler arguments.
|
|
9
|
+
*/
|
|
10
|
+
class EventEmitter {
|
|
11
|
+
constructor() {
|
|
12
|
+
this._handlers = new Map();
|
|
13
|
+
}
|
|
14
|
+
on(event, handler) {
|
|
15
|
+
if (!this._handlers.has(event)) {
|
|
16
|
+
this._handlers.set(event, new Set());
|
|
17
|
+
}
|
|
18
|
+
this._handlers.get(event).add(handler);
|
|
19
|
+
return this;
|
|
20
|
+
}
|
|
21
|
+
off(event, handler) {
|
|
22
|
+
if (handler) {
|
|
23
|
+
this._handlers.get(event)?.delete(handler);
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
this._handlers.delete(event);
|
|
27
|
+
}
|
|
28
|
+
return this;
|
|
29
|
+
}
|
|
30
|
+
removeAllListeners() {
|
|
31
|
+
this._handlers.clear();
|
|
32
|
+
return this;
|
|
33
|
+
}
|
|
34
|
+
emit(event, ...args) {
|
|
35
|
+
const handlers = this._handlers.get(event);
|
|
36
|
+
if (!handlers)
|
|
37
|
+
return;
|
|
38
|
+
for (const handler of handlers) {
|
|
39
|
+
try {
|
|
40
|
+
handler(...args);
|
|
41
|
+
}
|
|
42
|
+
catch (e) {
|
|
43
|
+
console.error(`Error in ${String(event)} handler:`, e);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
listenerCount(event) {
|
|
48
|
+
return this._handlers.get(event)?.size ?? 0;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* PresenceManager — maps actorTokenId ↔ CollabUser, filtering self.
|
|
54
|
+
*/
|
|
55
|
+
class PresenceManager {
|
|
56
|
+
constructor(localActorId) {
|
|
57
|
+
this._users = new Map();
|
|
58
|
+
this._actorToUserId = new Map();
|
|
59
|
+
this._localActorId = localActorId;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Add or update a user from presence data.
|
|
63
|
+
* Returns the CollabUser if it is a remote user, null if it is self.
|
|
64
|
+
*/
|
|
65
|
+
addFromPresence(actorTokenId, presence, joinedAt) {
|
|
66
|
+
const isLocal = actorTokenId === this._localActorId;
|
|
67
|
+
// Skip self
|
|
68
|
+
if (isLocal)
|
|
69
|
+
return null;
|
|
70
|
+
const existing = this._actorToUserId.get(actorTokenId);
|
|
71
|
+
const userId = presence.userId || existing || actorTokenId;
|
|
72
|
+
const user = {
|
|
73
|
+
userId,
|
|
74
|
+
actorTokenId,
|
|
75
|
+
username: presence.username,
|
|
76
|
+
avatar: presence.avatar,
|
|
77
|
+
color: presence.color,
|
|
78
|
+
status: presence.status ?? 'active',
|
|
79
|
+
metadata: presence.metadata,
|
|
80
|
+
joinedAt: joinedAt ?? Date.now(),
|
|
81
|
+
isLocal: false,
|
|
82
|
+
};
|
|
83
|
+
this._users.set(userId, user);
|
|
84
|
+
this._actorToUserId.set(actorTokenId, userId);
|
|
85
|
+
return user;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Remove a user by actorTokenId.
|
|
89
|
+
* Returns the removed CollabUser, or null if not found / is self.
|
|
90
|
+
*/
|
|
91
|
+
removeByActorId(actorTokenId) {
|
|
92
|
+
if (actorTokenId === this._localActorId)
|
|
93
|
+
return null;
|
|
94
|
+
const userId = this._actorToUserId.get(actorTokenId);
|
|
95
|
+
if (!userId)
|
|
96
|
+
return null;
|
|
97
|
+
const user = this._users.get(userId) ?? null;
|
|
98
|
+
this._users.delete(userId);
|
|
99
|
+
this._actorToUserId.delete(actorTokenId);
|
|
100
|
+
return user;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Update only the status field for an existing user.
|
|
104
|
+
*/
|
|
105
|
+
updateStatus(actorTokenId, status) {
|
|
106
|
+
const userId = this._actorToUserId.get(actorTokenId);
|
|
107
|
+
if (!userId)
|
|
108
|
+
return null;
|
|
109
|
+
const user = this._users.get(userId);
|
|
110
|
+
if (!user)
|
|
111
|
+
return null;
|
|
112
|
+
const updated = { ...user, status };
|
|
113
|
+
this._users.set(userId, updated);
|
|
114
|
+
return updated;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Get a user by userId.
|
|
118
|
+
*/
|
|
119
|
+
getUser(userId) {
|
|
120
|
+
return this._users.get(userId);
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Get a user by actorTokenId.
|
|
124
|
+
*/
|
|
125
|
+
getUserByActorId(actorTokenId) {
|
|
126
|
+
const userId = this._actorToUserId.get(actorTokenId);
|
|
127
|
+
return userId ? this._users.get(userId) : undefined;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Get all remote users.
|
|
131
|
+
*/
|
|
132
|
+
getAll() {
|
|
133
|
+
return Array.from(this._users.values());
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Get the users Map (readonly view).
|
|
137
|
+
*/
|
|
138
|
+
get users() {
|
|
139
|
+
return this._users;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Clear all tracked users.
|
|
143
|
+
*/
|
|
144
|
+
clear() {
|
|
145
|
+
this._users.clear();
|
|
146
|
+
this._actorToUserId.clear();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Ordered, deduplicated operation log bounded by maxOperationCache.
|
|
152
|
+
*
|
|
153
|
+
* Operations are stored sorted by timestamp ascending. Duplicate IDs are
|
|
154
|
+
* silently ignored. When the cache exceeds its limit the oldest entries
|
|
155
|
+
* are evicted.
|
|
156
|
+
*/
|
|
157
|
+
class OperationStore {
|
|
158
|
+
constructor(maxSize) {
|
|
159
|
+
this._ops = [];
|
|
160
|
+
this._ids = new Set();
|
|
161
|
+
this._maxSize = maxSize;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Add an operation to the store.
|
|
165
|
+
* Returns true if the operation was added, false if it was a duplicate.
|
|
166
|
+
*/
|
|
167
|
+
add(op) {
|
|
168
|
+
if (this._ids.has(op.id))
|
|
169
|
+
return false;
|
|
170
|
+
this._ids.add(op.id);
|
|
171
|
+
this._ops.push(op);
|
|
172
|
+
// Keep sorted by timestamp ascending
|
|
173
|
+
this._ops.sort((a, b) => a.timestamp - b.timestamp);
|
|
174
|
+
// Evict oldest entries when over capacity
|
|
175
|
+
while (this._ops.length > this._maxSize) {
|
|
176
|
+
const evicted = this._ops.shift();
|
|
177
|
+
if (evicted)
|
|
178
|
+
this._ids.delete(evicted.id);
|
|
179
|
+
}
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Get all stored operations in timestamp order.
|
|
184
|
+
*/
|
|
185
|
+
getAll() {
|
|
186
|
+
return [...this._ops];
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Get all operations sent by a specific user.
|
|
190
|
+
*/
|
|
191
|
+
getByUser(userId) {
|
|
192
|
+
return this._ops.filter((op) => op.userId === userId);
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Check whether an operation ID is already stored.
|
|
196
|
+
*/
|
|
197
|
+
has(id) {
|
|
198
|
+
return this._ids.has(id);
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Number of operations currently stored.
|
|
202
|
+
*/
|
|
203
|
+
get size() {
|
|
204
|
+
return this._ops.length;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Clear all stored operations.
|
|
208
|
+
*/
|
|
209
|
+
clear() {
|
|
210
|
+
this._ops = [];
|
|
211
|
+
this._ids.clear();
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* AwarenessManager — cursor tracking and idle detection per user.
|
|
217
|
+
*
|
|
218
|
+
* Tracks cursor positions for all connected users and manages per-user
|
|
219
|
+
* idle timers that fire a callback when a user has been inactive.
|
|
220
|
+
*/
|
|
221
|
+
class AwarenessManager {
|
|
222
|
+
constructor(localUserId) {
|
|
223
|
+
this._cursors = new Map();
|
|
224
|
+
this._statuses = new Map();
|
|
225
|
+
this._idleTimers = new Map();
|
|
226
|
+
this._localUserId = localUserId;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Update the cursor position for a user and reset their idle timer.
|
|
230
|
+
*/
|
|
231
|
+
updateCursor(userId, position) {
|
|
232
|
+
this._cursors.set(userId, position);
|
|
233
|
+
// Reset idle timer if one is running for this user
|
|
234
|
+
if (this._idleTimers.has(userId)) {
|
|
235
|
+
const timer = this._idleTimers.get(userId);
|
|
236
|
+
clearTimeout(timer);
|
|
237
|
+
this._idleTimers.delete(userId);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Get the last known cursor position for a user.
|
|
242
|
+
*/
|
|
243
|
+
getCursor(userId) {
|
|
244
|
+
return this._cursors.get(userId);
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Get all cursor positions except the local user's.
|
|
248
|
+
*/
|
|
249
|
+
getCursors() {
|
|
250
|
+
return Array.from(this._cursors.values()).filter((c) => c.userId !== this._localUserId);
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Set the activity status for a user.
|
|
254
|
+
*/
|
|
255
|
+
setStatus(userId, status) {
|
|
256
|
+
this._statuses.set(userId, status);
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Get the current activity status for a user (defaults to 'active').
|
|
260
|
+
*/
|
|
261
|
+
getStatus(userId) {
|
|
262
|
+
return this._statuses.get(userId) ?? 'active';
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Start an idle timer for a user. If the timer fires, onIdle is called
|
|
266
|
+
* and the user's status is set to 'idle'. Calling updateCursor resets it.
|
|
267
|
+
*/
|
|
268
|
+
startIdleTracking(userId, timeout, onIdle) {
|
|
269
|
+
// Cancel any existing timer
|
|
270
|
+
this.stopIdleTracking(userId);
|
|
271
|
+
const timer = setTimeout(() => {
|
|
272
|
+
this._idleTimers.delete(userId);
|
|
273
|
+
this._statuses.set(userId, 'idle');
|
|
274
|
+
onIdle();
|
|
275
|
+
}, timeout);
|
|
276
|
+
this._idleTimers.set(userId, timer);
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Cancel the idle timer for a user without firing the callback.
|
|
280
|
+
*/
|
|
281
|
+
stopIdleTracking(userId) {
|
|
282
|
+
const timer = this._idleTimers.get(userId);
|
|
283
|
+
if (timer !== undefined) {
|
|
284
|
+
clearTimeout(timer);
|
|
285
|
+
this._idleTimers.delete(userId);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Remove all cursor data for a user.
|
|
290
|
+
*/
|
|
291
|
+
removeCursor(userId) {
|
|
292
|
+
this._cursors.delete(userId);
|
|
293
|
+
this._statuses.delete(userId);
|
|
294
|
+
this.stopIdleTracking(userId);
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Dispose — clear all timers and state.
|
|
298
|
+
*/
|
|
299
|
+
dispose() {
|
|
300
|
+
for (const timer of this._idleTimers.values()) {
|
|
301
|
+
clearTimeout(timer);
|
|
302
|
+
}
|
|
303
|
+
this._idleTimers.clear();
|
|
304
|
+
this._cursors.clear();
|
|
305
|
+
this._statuses.clear();
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function generateId() {
|
|
310
|
+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
311
|
+
return crypto.randomUUID();
|
|
312
|
+
}
|
|
313
|
+
return 'xxxx-xxxx-xxxx-xxxx'.replace(/x/g, () => Math.floor(Math.random() * 16).toString(16));
|
|
314
|
+
}
|
|
315
|
+
function createLogger(prefix, enabled) {
|
|
316
|
+
if (!enabled) {
|
|
317
|
+
return (..._args) => { };
|
|
318
|
+
}
|
|
319
|
+
return (...args) => {
|
|
320
|
+
console.log(`[${prefix}]`, ...args);
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Default app name for NoLag collab SDK */
|
|
325
|
+
const DEFAULT_APP_NAME = 'collab';
|
|
326
|
+
/** Maximum number of operations to keep in the cache */
|
|
327
|
+
const DEFAULT_MAX_OPERATION_CACHE = 1000;
|
|
328
|
+
/** Idle timeout in milliseconds before a user is marked idle */
|
|
329
|
+
const DEFAULT_IDLE_TIMEOUT = 60000;
|
|
330
|
+
/** Cursor throttle in milliseconds — minimum interval between cursor updates */
|
|
331
|
+
const DEFAULT_CURSOR_THROTTLE = 50;
|
|
332
|
+
/** Topic name for operation messages within a document */
|
|
333
|
+
const TOPIC_OPERATIONS = 'operations';
|
|
334
|
+
/** Topic name for cursor presence messages within a document */
|
|
335
|
+
const TOPIC_CURSORS = '_cursors';
|
|
336
|
+
/** Lobby ID for global online presence */
|
|
337
|
+
const LOBBY_ID = 'online';
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* CollabDocument — a single collaborative document room.
|
|
341
|
+
*
|
|
342
|
+
* Created via `NoLagCollab.joinDocument(name)`. Do not instantiate directly.
|
|
343
|
+
*
|
|
344
|
+
* Subscribes to 'operations' and '_cursors' topics and exposes a clean API
|
|
345
|
+
* for sending operations, broadcasting cursor positions, and managing
|
|
346
|
+
* user awareness (idle detection, status).
|
|
347
|
+
*/
|
|
348
|
+
class CollabDocument extends EventEmitter {
|
|
349
|
+
/** @internal */
|
|
350
|
+
constructor(name, roomContext, localUser, options, log) {
|
|
351
|
+
super();
|
|
352
|
+
/** Throttle state for cursor updates */
|
|
353
|
+
this._cursorThrottleTimer = null;
|
|
354
|
+
this._pendingCursorUpdate = null;
|
|
355
|
+
this.name = name;
|
|
356
|
+
this._roomContext = roomContext;
|
|
357
|
+
this._localUser = localUser;
|
|
358
|
+
this._options = options;
|
|
359
|
+
this._log = log;
|
|
360
|
+
this._presenceManager = new PresenceManager(localUser.actorTokenId);
|
|
361
|
+
this._operationStore = new OperationStore(options.maxOperationCache);
|
|
362
|
+
this._awarenessManager = new AwarenessManager(localUser.userId);
|
|
363
|
+
}
|
|
364
|
+
// ============ Public Properties ============
|
|
365
|
+
/** All remote users currently in this document */
|
|
366
|
+
get users() {
|
|
367
|
+
return this._presenceManager.users;
|
|
368
|
+
}
|
|
369
|
+
// ============ Operations ============
|
|
370
|
+
/**
|
|
371
|
+
* Send an operation to all collaborators in this document.
|
|
372
|
+
* Returns the operation that was created and broadcast.
|
|
373
|
+
*/
|
|
374
|
+
sendOperation(type, opts = {}) {
|
|
375
|
+
const op = {
|
|
376
|
+
id: generateId(),
|
|
377
|
+
type,
|
|
378
|
+
path: opts.path,
|
|
379
|
+
position: opts.position,
|
|
380
|
+
length: opts.length,
|
|
381
|
+
content: opts.content,
|
|
382
|
+
data: opts.data,
|
|
383
|
+
userId: this._localUser.userId,
|
|
384
|
+
username: this._localUser.username,
|
|
385
|
+
timestamp: Date.now(),
|
|
386
|
+
isReplay: false,
|
|
387
|
+
};
|
|
388
|
+
this._log('Sending operation:', type, op.id);
|
|
389
|
+
this._operationStore.add(op);
|
|
390
|
+
this._roomContext.emit(TOPIC_OPERATIONS, op, { echo: false });
|
|
391
|
+
return op;
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Get all cached operations for this document, in timestamp order.
|
|
395
|
+
*/
|
|
396
|
+
getOperations() {
|
|
397
|
+
return this._operationStore.getAll();
|
|
398
|
+
}
|
|
399
|
+
// ============ Cursors ============
|
|
400
|
+
/**
|
|
401
|
+
* Broadcast a cursor position update. Calls are throttled by the
|
|
402
|
+
* cursorThrottle option (default 50 ms) to avoid flooding.
|
|
403
|
+
*/
|
|
404
|
+
updateCursor(opts) {
|
|
405
|
+
this._pendingCursorUpdate = opts;
|
|
406
|
+
if (this._cursorThrottleTimer !== null) {
|
|
407
|
+
// Already scheduled — the pending update will be sent when it fires
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
// Send immediately for the first call in the window, then throttle
|
|
411
|
+
this._flushCursorUpdate();
|
|
412
|
+
this._cursorThrottleTimer = setTimeout(() => {
|
|
413
|
+
this._cursorThrottleTimer = null;
|
|
414
|
+
if (this._pendingCursorUpdate) {
|
|
415
|
+
this._flushCursorUpdate();
|
|
416
|
+
}
|
|
417
|
+
}, this._options.cursorThrottle);
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Get all remote cursor positions.
|
|
421
|
+
*/
|
|
422
|
+
getCursors() {
|
|
423
|
+
return this._awarenessManager.getCursors();
|
|
424
|
+
}
|
|
425
|
+
// ============ Awareness ============
|
|
426
|
+
/**
|
|
427
|
+
* Update the local user's activity status and broadcast it.
|
|
428
|
+
*/
|
|
429
|
+
setStatus(status) {
|
|
430
|
+
this._localUser = { ...this._localUser, status };
|
|
431
|
+
this._setPresence();
|
|
432
|
+
this._log('Status updated:', status);
|
|
433
|
+
}
|
|
434
|
+
// ============ Users ============
|
|
435
|
+
/**
|
|
436
|
+
* Get all remote users currently in the document.
|
|
437
|
+
*/
|
|
438
|
+
getUsers() {
|
|
439
|
+
return this._presenceManager.getAll();
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Get a specific user by userId.
|
|
443
|
+
*/
|
|
444
|
+
getUser(userId) {
|
|
445
|
+
return this._presenceManager.getUser(userId);
|
|
446
|
+
}
|
|
447
|
+
// ============ Internal (called by NoLagCollab) ============
|
|
448
|
+
/** @internal Subscribe to operations and cursors topics and attach listeners */
|
|
449
|
+
_subscribe() {
|
|
450
|
+
this._log('Document subscribe:', this.name);
|
|
451
|
+
this._roomContext.subscribe(TOPIC_OPERATIONS);
|
|
452
|
+
this._roomContext.subscribe(TOPIC_CURSORS);
|
|
453
|
+
this._roomContext.on(TOPIC_OPERATIONS, (data) => {
|
|
454
|
+
this._handleIncomingOperation(data);
|
|
455
|
+
});
|
|
456
|
+
this._roomContext.on(TOPIC_CURSORS, (data) => {
|
|
457
|
+
this._handleIncomingCursor(data);
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
/** @internal Set presence and fetch current room members */
|
|
461
|
+
_activate() {
|
|
462
|
+
this._log('Document activate:', this.name);
|
|
463
|
+
this._setPresence();
|
|
464
|
+
this._roomContext.fetchPresence().then((actors) => {
|
|
465
|
+
this._log('Document presence fetched:', this.name, actors.length, 'actors');
|
|
466
|
+
for (const actor of actors) {
|
|
467
|
+
if (actor.presence) {
|
|
468
|
+
const user = this._presenceManager.addFromPresence(actor.actorTokenId, actor.presence, actor.joinedAt);
|
|
469
|
+
if (user) {
|
|
470
|
+
this._awarenessManager.setStatus(user.userId, user.status);
|
|
471
|
+
this.emit('userJoined', user);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}).catch((err) => {
|
|
476
|
+
this._log('Failed to fetch document presence:', err);
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
/** @internal Re-set presence after reconnect */
|
|
480
|
+
_updateLocalPresence() {
|
|
481
|
+
this._setPresence();
|
|
482
|
+
}
|
|
483
|
+
/** @internal Handle a presence:join event */
|
|
484
|
+
_handlePresenceJoin(actorTokenId, presenceData) {
|
|
485
|
+
const user = this._presenceManager.addFromPresence(actorTokenId, presenceData);
|
|
486
|
+
if (user) {
|
|
487
|
+
this._log('User joined document:', this.name, user.userId);
|
|
488
|
+
this._awarenessManager.setStatus(user.userId, user.status);
|
|
489
|
+
this._startUserIdleTracking(user);
|
|
490
|
+
this.emit('userJoined', user);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
/** @internal Handle a presence:leave event */
|
|
494
|
+
_handlePresenceLeave(actorTokenId) {
|
|
495
|
+
const user = this._presenceManager.removeByActorId(actorTokenId);
|
|
496
|
+
if (user) {
|
|
497
|
+
this._log('User left document:', this.name, user.userId);
|
|
498
|
+
this._awarenessManager.removeCursor(user.userId);
|
|
499
|
+
this.emit('userLeft', user);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
/** @internal Handle a presence:update event */
|
|
503
|
+
_handlePresenceUpdate(actorTokenId, presenceData) {
|
|
504
|
+
const user = this._presenceManager.addFromPresence(actorTokenId, presenceData);
|
|
505
|
+
if (user) {
|
|
506
|
+
this._awarenessManager.setStatus(user.userId, user.status);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
/** @internal Replay operations from another source (e.g. history fetch) */
|
|
510
|
+
_replayOperations(ops) {
|
|
511
|
+
const pending = ops.filter((op) => !this._operationStore.has(op.id));
|
|
512
|
+
if (pending.length === 0)
|
|
513
|
+
return;
|
|
514
|
+
this._log('Replaying', pending.length, 'operations');
|
|
515
|
+
this.emit('replayStart', { count: pending.length });
|
|
516
|
+
let replayed = 0;
|
|
517
|
+
for (const op of pending) {
|
|
518
|
+
const replayOp = { ...op, isReplay: true };
|
|
519
|
+
if (this._operationStore.add(replayOp)) {
|
|
520
|
+
this.emit('operation', replayOp);
|
|
521
|
+
replayed++;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
this.emit('replayEnd', { replayed });
|
|
525
|
+
}
|
|
526
|
+
/** @internal Unsubscribe and clean up */
|
|
527
|
+
_cleanup() {
|
|
528
|
+
this._log('Document cleanup:', this.name);
|
|
529
|
+
// Cancel throttle timer
|
|
530
|
+
if (this._cursorThrottleTimer !== null) {
|
|
531
|
+
clearTimeout(this._cursorThrottleTimer);
|
|
532
|
+
this._cursorThrottleTimer = null;
|
|
533
|
+
}
|
|
534
|
+
this._roomContext.unsubscribe(TOPIC_OPERATIONS);
|
|
535
|
+
this._roomContext.unsubscribe(TOPIC_CURSORS);
|
|
536
|
+
this._roomContext.off(TOPIC_OPERATIONS);
|
|
537
|
+
this._roomContext.off(TOPIC_CURSORS);
|
|
538
|
+
this._awarenessManager.dispose();
|
|
539
|
+
this._presenceManager.clear();
|
|
540
|
+
this._operationStore.clear();
|
|
541
|
+
this.removeAllListeners();
|
|
542
|
+
}
|
|
543
|
+
// ============ Private ============
|
|
544
|
+
_handleIncomingOperation(data) {
|
|
545
|
+
const op = data;
|
|
546
|
+
// Deduplicate
|
|
547
|
+
if (this._operationStore.has(op.id))
|
|
548
|
+
return;
|
|
549
|
+
const stored = { ...op, isReplay: false };
|
|
550
|
+
this._operationStore.add(stored);
|
|
551
|
+
this._log('Received operation:', op.type, op.id, 'from', op.userId);
|
|
552
|
+
this.emit('operation', stored);
|
|
553
|
+
}
|
|
554
|
+
_handleIncomingCursor(data) {
|
|
555
|
+
const cursor = data;
|
|
556
|
+
// Ignore own cursor echoes (should not happen with echo: false, but guard anyway)
|
|
557
|
+
if (cursor.userId === this._localUser.userId)
|
|
558
|
+
return;
|
|
559
|
+
this._awarenessManager.updateCursor(cursor.userId, cursor);
|
|
560
|
+
// Reset idle tracking for this user
|
|
561
|
+
const user = this._presenceManager.getUser(cursor.userId);
|
|
562
|
+
if (user) {
|
|
563
|
+
this._startUserIdleTracking(user);
|
|
564
|
+
}
|
|
565
|
+
this._log('Cursor moved:', cursor.userId);
|
|
566
|
+
this.emit('cursorMoved', cursor);
|
|
567
|
+
}
|
|
568
|
+
_flushCursorUpdate() {
|
|
569
|
+
if (!this._pendingCursorUpdate)
|
|
570
|
+
return;
|
|
571
|
+
const opts = this._pendingCursorUpdate;
|
|
572
|
+
this._pendingCursorUpdate = null;
|
|
573
|
+
const cursor = {
|
|
574
|
+
userId: this._localUser.userId,
|
|
575
|
+
username: this._localUser.username,
|
|
576
|
+
color: this._localUser.color,
|
|
577
|
+
timestamp: Date.now(),
|
|
578
|
+
...opts,
|
|
579
|
+
};
|
|
580
|
+
this._awarenessManager.updateCursor(this._localUser.userId, cursor);
|
|
581
|
+
this._roomContext.emit(TOPIC_CURSORS, cursor, { echo: false });
|
|
582
|
+
}
|
|
583
|
+
_setPresence() {
|
|
584
|
+
const presenceData = {
|
|
585
|
+
userId: this._localUser.userId,
|
|
586
|
+
username: this._localUser.username,
|
|
587
|
+
avatar: this._localUser.avatar,
|
|
588
|
+
color: this._localUser.color,
|
|
589
|
+
status: this._localUser.status,
|
|
590
|
+
metadata: this._localUser.metadata,
|
|
591
|
+
};
|
|
592
|
+
this._roomContext.setPresence(presenceData);
|
|
593
|
+
}
|
|
594
|
+
_startUserIdleTracking(user) {
|
|
595
|
+
// Mark user as active first
|
|
596
|
+
if (this._awarenessManager.getStatus(user.userId) !== 'active') {
|
|
597
|
+
this._awarenessManager.setStatus(user.userId, 'active');
|
|
598
|
+
}
|
|
599
|
+
this._awarenessManager.startIdleTracking(user.userId, this._options.idleTimeout, () => {
|
|
600
|
+
this._log('User went idle:', user.userId);
|
|
601
|
+
this.emit('awarenessChanged', { userId: user.userId, status: 'idle' });
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* NoLagCollab — high-level real-time collaboration SDK built on @nolag/js-sdk.
|
|
608
|
+
*
|
|
609
|
+
* Provides document-scoped operations, cursor broadcasting, and user awareness
|
|
610
|
+
* (idle detection, status tracking) — all framework-agnostic via events.
|
|
611
|
+
*
|
|
612
|
+
* @example
|
|
613
|
+
* ```typescript
|
|
614
|
+
* import { NoLagCollab } from '@nolag/collab';
|
|
615
|
+
*
|
|
616
|
+
* const collab = new NoLagCollab(token, { username: 'Alice', debug: true });
|
|
617
|
+
*
|
|
618
|
+
* collab.on('connected', () => console.log('Connected!'));
|
|
619
|
+
* collab.on('userOnline', (user) => console.log(user.username, 'is online'));
|
|
620
|
+
*
|
|
621
|
+
* await collab.connect();
|
|
622
|
+
*
|
|
623
|
+
* const doc = collab.joinDocument('my-doc');
|
|
624
|
+
* doc.on('operation', (op) => applyOp(op));
|
|
625
|
+
* doc.sendOperation('insert', { position: 0, content: 'Hello' });
|
|
626
|
+
* ```
|
|
627
|
+
*/
|
|
628
|
+
class NoLagCollab extends EventEmitter {
|
|
629
|
+
constructor(token, options) {
|
|
630
|
+
super();
|
|
631
|
+
this._client = null;
|
|
632
|
+
this._localUser = null;
|
|
633
|
+
this._documents = new Map();
|
|
634
|
+
this._lobby = null;
|
|
635
|
+
this._onlineUsers = new Map();
|
|
636
|
+
this._actorToUserId = new Map();
|
|
637
|
+
this._token = token;
|
|
638
|
+
this._userId = generateId();
|
|
639
|
+
this._options = {
|
|
640
|
+
username: options.username,
|
|
641
|
+
avatar: options.avatar,
|
|
642
|
+
color: options.color,
|
|
643
|
+
metadata: options.metadata,
|
|
644
|
+
appName: options.appName ?? DEFAULT_APP_NAME,
|
|
645
|
+
url: options.url,
|
|
646
|
+
maxOperationCache: options.maxOperationCache ?? DEFAULT_MAX_OPERATION_CACHE,
|
|
647
|
+
idleTimeout: options.idleTimeout ?? DEFAULT_IDLE_TIMEOUT,
|
|
648
|
+
cursorThrottle: options.cursorThrottle ?? DEFAULT_CURSOR_THROTTLE,
|
|
649
|
+
debug: options.debug ?? false,
|
|
650
|
+
reconnect: options.reconnect ?? true,
|
|
651
|
+
documents: options.documents ?? [],
|
|
652
|
+
};
|
|
653
|
+
this._log = createLogger('NoLagCollab', this._options.debug);
|
|
654
|
+
}
|
|
655
|
+
// ============ Public Properties ============
|
|
656
|
+
/** Whether the underlying connection is established */
|
|
657
|
+
get connected() {
|
|
658
|
+
return this._client?.connected ?? false;
|
|
659
|
+
}
|
|
660
|
+
/** The local user's info (available after connect) */
|
|
661
|
+
get localUser() {
|
|
662
|
+
return this._localUser;
|
|
663
|
+
}
|
|
664
|
+
/** All currently joined documents */
|
|
665
|
+
get documents() {
|
|
666
|
+
return this._documents;
|
|
667
|
+
}
|
|
668
|
+
// ============ Lifecycle ============
|
|
669
|
+
/**
|
|
670
|
+
* Connect to NoLag and set up global presence.
|
|
671
|
+
*/
|
|
672
|
+
async connect() {
|
|
673
|
+
this._log('Connecting...');
|
|
674
|
+
const clientOptions = {
|
|
675
|
+
debug: this._options.debug,
|
|
676
|
+
reconnect: this._options.reconnect,
|
|
677
|
+
};
|
|
678
|
+
if (this._options.url) {
|
|
679
|
+
clientOptions.url = this._options.url;
|
|
680
|
+
}
|
|
681
|
+
this._client = jsSdk.NoLag(this._token, clientOptions);
|
|
682
|
+
// Wire client lifecycle events
|
|
683
|
+
this._client.on('connect', () => {
|
|
684
|
+
this._log('Connected');
|
|
685
|
+
if (this._documents.size > 0) {
|
|
686
|
+
this._log('Reconnected — restoring documents...');
|
|
687
|
+
this._restoreDocuments();
|
|
688
|
+
this.emit('reconnected');
|
|
689
|
+
}
|
|
690
|
+
});
|
|
691
|
+
this._client.on('disconnect', (reason) => {
|
|
692
|
+
this._log('Disconnected:', reason);
|
|
693
|
+
this.emit('disconnected', reason);
|
|
694
|
+
});
|
|
695
|
+
this._client.on('reconnect', () => {
|
|
696
|
+
this._log('Reconnecting...');
|
|
697
|
+
});
|
|
698
|
+
this._client.on('error', (error) => {
|
|
699
|
+
this._log('Error:', error);
|
|
700
|
+
this.emit('error', error);
|
|
701
|
+
});
|
|
702
|
+
// Connect
|
|
703
|
+
await this._client.connect();
|
|
704
|
+
// Wire room-level presence events
|
|
705
|
+
this._client.on('presence:join', (data) => {
|
|
706
|
+
this._handleRoomPresenceJoin(data);
|
|
707
|
+
});
|
|
708
|
+
this._client.on('presence:leave', (data) => {
|
|
709
|
+
this._handleRoomPresenceLeave(data);
|
|
710
|
+
});
|
|
711
|
+
this._client.on('presence:update', (data) => {
|
|
712
|
+
this._handleRoomPresenceUpdate(data);
|
|
713
|
+
});
|
|
714
|
+
// Create local user
|
|
715
|
+
this._localUser = {
|
|
716
|
+
userId: this._userId,
|
|
717
|
+
actorTokenId: this._client.actorId,
|
|
718
|
+
username: this._options.username,
|
|
719
|
+
avatar: this._options.avatar,
|
|
720
|
+
color: this._options.color,
|
|
721
|
+
status: 'active',
|
|
722
|
+
metadata: this._options.metadata,
|
|
723
|
+
joinedAt: Date.now(),
|
|
724
|
+
isLocal: true,
|
|
725
|
+
};
|
|
726
|
+
this._log('Local user:', this._localUser.userId, '→', this._localUser.actorTokenId);
|
|
727
|
+
// Set up lobby for global presence
|
|
728
|
+
await this._setupLobby();
|
|
729
|
+
// Emit connected now that _localUser and lobby are ready
|
|
730
|
+
this.emit('connected');
|
|
731
|
+
// Auto-join documents specified in options
|
|
732
|
+
for (const docName of this._options.documents) {
|
|
733
|
+
this.joinDocument(docName);
|
|
734
|
+
}
|
|
735
|
+
// Deferred lobby refetch to catch users who joined during the setup window
|
|
736
|
+
setTimeout(() => {
|
|
737
|
+
if (this._lobby && this._client?.connected) {
|
|
738
|
+
this._lobby.fetchPresence().then((state) => {
|
|
739
|
+
this._hydrateOnlineUsers(state);
|
|
740
|
+
}).catch(() => { });
|
|
741
|
+
}
|
|
742
|
+
}, 2000);
|
|
743
|
+
}
|
|
744
|
+
/**
|
|
745
|
+
* Disconnect from NoLag and clean up all documents.
|
|
746
|
+
*/
|
|
747
|
+
disconnect() {
|
|
748
|
+
this._log('Disconnecting...');
|
|
749
|
+
// Clean up documents
|
|
750
|
+
for (const name of [...this._documents.keys()]) {
|
|
751
|
+
this.leaveDocument(name);
|
|
752
|
+
}
|
|
753
|
+
// Unsubscribe from lobby
|
|
754
|
+
this._lobby?.unsubscribe();
|
|
755
|
+
this._lobby = null;
|
|
756
|
+
// Disconnect client
|
|
757
|
+
this._client?.disconnect();
|
|
758
|
+
this._client = null;
|
|
759
|
+
// Clear state
|
|
760
|
+
this._onlineUsers.clear();
|
|
761
|
+
this._actorToUserId.clear();
|
|
762
|
+
this._localUser = null;
|
|
763
|
+
}
|
|
764
|
+
// ============ Document Management ============
|
|
765
|
+
/**
|
|
766
|
+
* Join a collaborative document. Creates, subscribes, and activates it.
|
|
767
|
+
* Returns an existing document if already joined.
|
|
768
|
+
*/
|
|
769
|
+
joinDocument(name) {
|
|
770
|
+
if (!this._client || !this._localUser) {
|
|
771
|
+
throw new Error('Not connected — call connect() first');
|
|
772
|
+
}
|
|
773
|
+
let doc = this._documents.get(name);
|
|
774
|
+
if (!doc) {
|
|
775
|
+
doc = this._subscribeDocument(name);
|
|
776
|
+
doc._activate();
|
|
777
|
+
}
|
|
778
|
+
return doc;
|
|
779
|
+
}
|
|
780
|
+
/**
|
|
781
|
+
* Leave a collaborative document. Fully unsubscribes and removes it.
|
|
782
|
+
*/
|
|
783
|
+
leaveDocument(name) {
|
|
784
|
+
const doc = this._documents.get(name);
|
|
785
|
+
if (!doc)
|
|
786
|
+
return;
|
|
787
|
+
this._log('Leaving document:', name);
|
|
788
|
+
doc._cleanup();
|
|
789
|
+
this._documents.delete(name);
|
|
790
|
+
}
|
|
791
|
+
/**
|
|
792
|
+
* Get all joined documents.
|
|
793
|
+
*/
|
|
794
|
+
getDocuments() {
|
|
795
|
+
return Array.from(this._documents.values());
|
|
796
|
+
}
|
|
797
|
+
// ============ Global Presence ============
|
|
798
|
+
/**
|
|
799
|
+
* Get all users currently online across all documents.
|
|
800
|
+
*/
|
|
801
|
+
getOnlineUsers() {
|
|
802
|
+
return Array.from(this._onlineUsers.values());
|
|
803
|
+
}
|
|
804
|
+
// ============ Private: Document Setup ============
|
|
805
|
+
_subscribeDocument(name) {
|
|
806
|
+
if (!this._client || !this._localUser) {
|
|
807
|
+
throw new Error('Not connected — call connect() first');
|
|
808
|
+
}
|
|
809
|
+
this._log('Subscribing document:', name);
|
|
810
|
+
const roomContext = this._client.setApp(this._options.appName).setRoom(name);
|
|
811
|
+
const doc = new CollabDocument(name, roomContext, this._localUser, this._options, createLogger(`CollabDocument:${name}`, this._options.debug));
|
|
812
|
+
this._documents.set(name, doc);
|
|
813
|
+
doc._subscribe();
|
|
814
|
+
return doc;
|
|
815
|
+
}
|
|
816
|
+
// ============ Private: Room Presence ============
|
|
817
|
+
_handleRoomPresenceJoin(data) {
|
|
818
|
+
if (data.actorTokenId === this._localUser?.actorTokenId)
|
|
819
|
+
return;
|
|
820
|
+
const presenceData = data.presence;
|
|
821
|
+
if (!presenceData?.userId)
|
|
822
|
+
return;
|
|
823
|
+
const user = this._presenceToUser(data.actorTokenId, presenceData);
|
|
824
|
+
this._actorToUserId.set(data.actorTokenId, user.userId);
|
|
825
|
+
if (!this._onlineUsers.has(user.userId)) {
|
|
826
|
+
this._onlineUsers.set(user.userId, user);
|
|
827
|
+
this.emit('userOnline', user);
|
|
828
|
+
}
|
|
829
|
+
// Route to all documents
|
|
830
|
+
for (const doc of this._documents.values()) {
|
|
831
|
+
doc._handlePresenceJoin(data.actorTokenId, presenceData);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
_handleRoomPresenceLeave(data) {
|
|
835
|
+
if (data.actorTokenId === this._localUser?.actorTokenId)
|
|
836
|
+
return;
|
|
837
|
+
// Route to all documents
|
|
838
|
+
for (const doc of this._documents.values()) {
|
|
839
|
+
doc._handlePresenceLeave(data.actorTokenId);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
_handleRoomPresenceUpdate(data) {
|
|
843
|
+
if (data.actorTokenId === this._localUser?.actorTokenId)
|
|
844
|
+
return;
|
|
845
|
+
const presenceData = data.presence;
|
|
846
|
+
if (!presenceData?.userId)
|
|
847
|
+
return;
|
|
848
|
+
if (this._onlineUsers.has(presenceData.userId)) {
|
|
849
|
+
const user = this._presenceToUser(data.actorTokenId, presenceData);
|
|
850
|
+
this._onlineUsers.set(user.userId, user);
|
|
851
|
+
}
|
|
852
|
+
// Route to all documents
|
|
853
|
+
for (const doc of this._documents.values()) {
|
|
854
|
+
doc._handlePresenceUpdate(data.actorTokenId, presenceData);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
// ============ Private: Lobby ============
|
|
858
|
+
async _setupLobby() {
|
|
859
|
+
if (!this._client)
|
|
860
|
+
return;
|
|
861
|
+
this._lobby = this._client.setApp(this._options.appName).setLobby(LOBBY_ID);
|
|
862
|
+
const lobbyHandler = (type) => (data) => {
|
|
863
|
+
const event = data;
|
|
864
|
+
if (type === 'join')
|
|
865
|
+
this._handleLobbyJoin(event);
|
|
866
|
+
else if (type === 'leave')
|
|
867
|
+
this._handleLobbyLeave(event);
|
|
868
|
+
else
|
|
869
|
+
this._handleLobbyUpdate(event);
|
|
870
|
+
};
|
|
871
|
+
this._client.on('lobbyPresence:join', lobbyHandler('join'));
|
|
872
|
+
this._client.on('lobbyPresence:leave', lobbyHandler('leave'));
|
|
873
|
+
this._client.on('lobbyPresence:update', lobbyHandler('update'));
|
|
874
|
+
try {
|
|
875
|
+
const initialState = await this._lobby.subscribe();
|
|
876
|
+
this._hydrateOnlineUsers(initialState);
|
|
877
|
+
this._log('Lobby subscribed, online users:', this._onlineUsers.size);
|
|
878
|
+
}
|
|
879
|
+
catch (err) {
|
|
880
|
+
this._log('Lobby subscription failed:', err);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
_handleLobbyJoin(event) {
|
|
884
|
+
const { actorId, data } = event;
|
|
885
|
+
if (actorId === this._localUser?.actorTokenId)
|
|
886
|
+
return;
|
|
887
|
+
const presenceData = data;
|
|
888
|
+
if (!presenceData.userId)
|
|
889
|
+
return;
|
|
890
|
+
const user = this._presenceToUser(actorId, presenceData);
|
|
891
|
+
this._actorToUserId.set(actorId, user.userId);
|
|
892
|
+
if (!this._onlineUsers.has(user.userId)) {
|
|
893
|
+
this._onlineUsers.set(user.userId, user);
|
|
894
|
+
this.emit('userOnline', user);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
_handleLobbyLeave(event) {
|
|
898
|
+
const { actorId, data } = event;
|
|
899
|
+
if (actorId === this._localUser?.actorTokenId)
|
|
900
|
+
return;
|
|
901
|
+
const presenceData = data;
|
|
902
|
+
const userId = presenceData?.userId
|
|
903
|
+
|| this._actorToUserId.get(actorId)
|
|
904
|
+
|| this._findUserIdByActorId(actorId);
|
|
905
|
+
if (userId) {
|
|
906
|
+
const user = this._onlineUsers.get(userId);
|
|
907
|
+
if (user) {
|
|
908
|
+
this._onlineUsers.delete(userId);
|
|
909
|
+
this._actorToUserId.delete(actorId);
|
|
910
|
+
this.emit('userOffline', user);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
_handleLobbyUpdate(event) {
|
|
915
|
+
const { actorId, data } = event;
|
|
916
|
+
if (actorId === this._localUser?.actorTokenId)
|
|
917
|
+
return;
|
|
918
|
+
const presenceData = data;
|
|
919
|
+
if (!presenceData.userId)
|
|
920
|
+
return;
|
|
921
|
+
const user = this._presenceToUser(actorId, presenceData);
|
|
922
|
+
this._onlineUsers.set(user.userId, user);
|
|
923
|
+
}
|
|
924
|
+
_hydrateOnlineUsers(state) {
|
|
925
|
+
for (const roomId of Object.keys(state)) {
|
|
926
|
+
const roomPresence = state[roomId];
|
|
927
|
+
for (const actorId of Object.keys(roomPresence)) {
|
|
928
|
+
if (actorId === this._localUser?.actorTokenId)
|
|
929
|
+
continue;
|
|
930
|
+
const raw = roomPresence[actorId];
|
|
931
|
+
const presenceData = (raw?.presence ?? raw);
|
|
932
|
+
if (presenceData?.userId) {
|
|
933
|
+
const user = this._presenceToUser(actorId, presenceData);
|
|
934
|
+
this._actorToUserId.set(actorId, user.userId);
|
|
935
|
+
if (!this._onlineUsers.has(user.userId)) {
|
|
936
|
+
this._onlineUsers.set(user.userId, user);
|
|
937
|
+
this.emit('userOnline', user);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
// ============ Private: Helpers ============
|
|
944
|
+
_presenceToUser(actorTokenId, data) {
|
|
945
|
+
return {
|
|
946
|
+
userId: data.userId,
|
|
947
|
+
actorTokenId,
|
|
948
|
+
username: data.username,
|
|
949
|
+
avatar: data.avatar,
|
|
950
|
+
color: data.color,
|
|
951
|
+
status: data.status ?? 'active',
|
|
952
|
+
metadata: data.metadata,
|
|
953
|
+
joinedAt: Date.now(),
|
|
954
|
+
isLocal: false,
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
_findUserIdByActorId(actorTokenId) {
|
|
958
|
+
for (const user of this._onlineUsers.values()) {
|
|
959
|
+
if (user.actorTokenId === actorTokenId)
|
|
960
|
+
return user.userId;
|
|
961
|
+
}
|
|
962
|
+
return undefined;
|
|
963
|
+
}
|
|
964
|
+
_restoreDocuments() {
|
|
965
|
+
// On reconnect, js-sdk auto-restores subscriptions.
|
|
966
|
+
// Re-set presence on all active documents.
|
|
967
|
+
for (const doc of this._documents.values()) {
|
|
968
|
+
doc._updateLocalPresence();
|
|
969
|
+
}
|
|
970
|
+
// Re-fetch lobby presence
|
|
971
|
+
this._lobby?.fetchPresence().then((state) => {
|
|
972
|
+
this._onlineUsers.clear();
|
|
973
|
+
this._actorToUserId.clear();
|
|
974
|
+
this._hydrateOnlineUsers(state);
|
|
975
|
+
}).catch((err) => {
|
|
976
|
+
this._log('Failed to re-fetch lobby presence:', err);
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
exports.CollabDocument = CollabDocument;
|
|
982
|
+
exports.EventEmitter = EventEmitter;
|
|
983
|
+
exports.NoLagCollab = NoLagCollab;
|
|
984
|
+
//# sourceMappingURL=index.cjs.map
|