@nolag/collab 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1249 @@
1
+ /**
2
+ * Tiny typed event emitter — framework-agnostic base for NoLag SDKs.
3
+ *
4
+ * EventMap is a record of event name → tuple of handler arguments.
5
+ */
6
+ class EventEmitter {
7
+ constructor() {
8
+ this._handlers = new Map();
9
+ }
10
+ on(event, handler) {
11
+ if (!this._handlers.has(event)) {
12
+ this._handlers.set(event, new Set());
13
+ }
14
+ this._handlers.get(event).add(handler);
15
+ return this;
16
+ }
17
+ off(event, handler) {
18
+ if (handler) {
19
+ this._handlers.get(event)?.delete(handler);
20
+ }
21
+ else {
22
+ this._handlers.delete(event);
23
+ }
24
+ return this;
25
+ }
26
+ removeAllListeners() {
27
+ this._handlers.clear();
28
+ return this;
29
+ }
30
+ emit(event, ...args) {
31
+ const handlers = this._handlers.get(event);
32
+ if (!handlers)
33
+ return;
34
+ for (const handler of handlers) {
35
+ try {
36
+ handler(...args);
37
+ }
38
+ catch (e) {
39
+ console.error(`Error in ${String(event)} handler:`, e);
40
+ }
41
+ }
42
+ }
43
+ listenerCount(event) {
44
+ return this._handlers.get(event)?.size ?? 0;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * PresenceManager — maps actorTokenId ↔ CollabUser, filtering self.
50
+ */
51
+ class PresenceManager {
52
+ constructor(localActorId) {
53
+ this._users = new Map();
54
+ this._actorToUserId = new Map();
55
+ this._localActorId = localActorId;
56
+ }
57
+ /**
58
+ * Add or update a user from presence data.
59
+ * Returns the CollabUser if it is a remote user, null if it is self.
60
+ */
61
+ addFromPresence(actorTokenId, presence, joinedAt) {
62
+ const isLocal = actorTokenId === this._localActorId;
63
+ // Skip self
64
+ if (isLocal)
65
+ return null;
66
+ const existing = this._actorToUserId.get(actorTokenId);
67
+ const userId = presence.userId || existing || actorTokenId;
68
+ const user = {
69
+ userId,
70
+ actorTokenId,
71
+ username: presence.username,
72
+ avatar: presence.avatar,
73
+ color: presence.color,
74
+ status: presence.status ?? 'active',
75
+ metadata: presence.metadata,
76
+ joinedAt: joinedAt ?? Date.now(),
77
+ isLocal: false,
78
+ };
79
+ this._users.set(userId, user);
80
+ this._actorToUserId.set(actorTokenId, userId);
81
+ return user;
82
+ }
83
+ /**
84
+ * Remove a user by actorTokenId.
85
+ * Returns the removed CollabUser, or null if not found / is self.
86
+ */
87
+ removeByActorId(actorTokenId) {
88
+ if (actorTokenId === this._localActorId)
89
+ return null;
90
+ const userId = this._actorToUserId.get(actorTokenId);
91
+ if (!userId)
92
+ return null;
93
+ const user = this._users.get(userId) ?? null;
94
+ this._users.delete(userId);
95
+ this._actorToUserId.delete(actorTokenId);
96
+ return user;
97
+ }
98
+ /**
99
+ * Update only the status field for an existing user.
100
+ */
101
+ updateStatus(actorTokenId, status) {
102
+ const userId = this._actorToUserId.get(actorTokenId);
103
+ if (!userId)
104
+ return null;
105
+ const user = this._users.get(userId);
106
+ if (!user)
107
+ return null;
108
+ const updated = { ...user, status };
109
+ this._users.set(userId, updated);
110
+ return updated;
111
+ }
112
+ /**
113
+ * Get a user by userId.
114
+ */
115
+ getUser(userId) {
116
+ return this._users.get(userId);
117
+ }
118
+ /**
119
+ * Get a user by actorTokenId.
120
+ */
121
+ getUserByActorId(actorTokenId) {
122
+ const userId = this._actorToUserId.get(actorTokenId);
123
+ return userId ? this._users.get(userId) : undefined;
124
+ }
125
+ /**
126
+ * Get all remote users.
127
+ */
128
+ getAll() {
129
+ return Array.from(this._users.values());
130
+ }
131
+ /**
132
+ * Get the users Map (readonly view).
133
+ */
134
+ get users() {
135
+ return this._users;
136
+ }
137
+ /**
138
+ * Clear all tracked users.
139
+ */
140
+ clear() {
141
+ this._users.clear();
142
+ this._actorToUserId.clear();
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Ordered, deduplicated operation log bounded by maxOperationCache.
148
+ *
149
+ * Operations are stored sorted by timestamp ascending. Duplicate IDs are
150
+ * silently ignored. When the cache exceeds its limit the oldest entries
151
+ * are evicted.
152
+ */
153
+ class OperationStore {
154
+ constructor(maxSize) {
155
+ this._ops = [];
156
+ this._ids = new Set();
157
+ this._maxSize = maxSize;
158
+ }
159
+ /**
160
+ * Add an operation to the store.
161
+ * Returns true if the operation was added, false if it was a duplicate.
162
+ */
163
+ add(op) {
164
+ if (this._ids.has(op.id))
165
+ return false;
166
+ this._ids.add(op.id);
167
+ this._ops.push(op);
168
+ // Keep sorted by timestamp ascending
169
+ this._ops.sort((a, b) => a.timestamp - b.timestamp);
170
+ // Evict oldest entries when over capacity
171
+ while (this._ops.length > this._maxSize) {
172
+ const evicted = this._ops.shift();
173
+ if (evicted)
174
+ this._ids.delete(evicted.id);
175
+ }
176
+ return true;
177
+ }
178
+ /**
179
+ * Get all stored operations in timestamp order.
180
+ */
181
+ getAll() {
182
+ return [...this._ops];
183
+ }
184
+ /**
185
+ * Get all operations sent by a specific user.
186
+ */
187
+ getByUser(userId) {
188
+ return this._ops.filter((op) => op.userId === userId);
189
+ }
190
+ /**
191
+ * Check whether an operation ID is already stored.
192
+ */
193
+ has(id) {
194
+ return this._ids.has(id);
195
+ }
196
+ /**
197
+ * Number of operations currently stored.
198
+ */
199
+ get size() {
200
+ return this._ops.length;
201
+ }
202
+ /**
203
+ * Clear all stored operations.
204
+ */
205
+ clear() {
206
+ this._ops = [];
207
+ this._ids.clear();
208
+ }
209
+ }
210
+
211
+ /**
212
+ * AwarenessManager — cursor tracking and idle detection per user.
213
+ *
214
+ * Tracks cursor positions for all connected users and manages per-user
215
+ * idle timers that fire a callback when a user has been inactive.
216
+ */
217
+ class AwarenessManager {
218
+ constructor(localUserId) {
219
+ this._cursors = new Map();
220
+ this._statuses = new Map();
221
+ this._idleTimers = new Map();
222
+ this._localUserId = localUserId;
223
+ }
224
+ /**
225
+ * Update the cursor position for a user and reset their idle timer.
226
+ */
227
+ updateCursor(userId, position) {
228
+ this._cursors.set(userId, position);
229
+ // Reset idle timer if one is running for this user
230
+ if (this._idleTimers.has(userId)) {
231
+ const timer = this._idleTimers.get(userId);
232
+ clearTimeout(timer);
233
+ this._idleTimers.delete(userId);
234
+ }
235
+ }
236
+ /**
237
+ * Get the last known cursor position for a user.
238
+ */
239
+ getCursor(userId) {
240
+ return this._cursors.get(userId);
241
+ }
242
+ /**
243
+ * Get all cursor positions except the local user's.
244
+ */
245
+ getCursors() {
246
+ return Array.from(this._cursors.values()).filter((c) => c.userId !== this._localUserId);
247
+ }
248
+ /**
249
+ * Set the activity status for a user.
250
+ */
251
+ setStatus(userId, status) {
252
+ this._statuses.set(userId, status);
253
+ }
254
+ /**
255
+ * Get the current activity status for a user (defaults to 'active').
256
+ */
257
+ getStatus(userId) {
258
+ return this._statuses.get(userId) ?? 'active';
259
+ }
260
+ /**
261
+ * Start an idle timer for a user. If the timer fires, onIdle is called
262
+ * and the user's status is set to 'idle'. Calling updateCursor resets it.
263
+ */
264
+ startIdleTracking(userId, timeout, onIdle) {
265
+ // Cancel any existing timer
266
+ this.stopIdleTracking(userId);
267
+ const timer = setTimeout(() => {
268
+ this._idleTimers.delete(userId);
269
+ this._statuses.set(userId, 'idle');
270
+ onIdle();
271
+ }, timeout);
272
+ this._idleTimers.set(userId, timer);
273
+ }
274
+ /**
275
+ * Cancel the idle timer for a user without firing the callback.
276
+ */
277
+ stopIdleTracking(userId) {
278
+ const timer = this._idleTimers.get(userId);
279
+ if (timer !== undefined) {
280
+ clearTimeout(timer);
281
+ this._idleTimers.delete(userId);
282
+ }
283
+ }
284
+ /**
285
+ * Remove all cursor data for a user.
286
+ */
287
+ removeCursor(userId) {
288
+ this._cursors.delete(userId);
289
+ this._statuses.delete(userId);
290
+ this.stopIdleTracking(userId);
291
+ }
292
+ /**
293
+ * Dispose — clear all timers and state.
294
+ */
295
+ dispose() {
296
+ for (const timer of this._idleTimers.values()) {
297
+ clearTimeout(timer);
298
+ }
299
+ this._idleTimers.clear();
300
+ this._cursors.clear();
301
+ this._statuses.clear();
302
+ }
303
+ }
304
+
305
+ function generateId() {
306
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
307
+ return crypto.randomUUID();
308
+ }
309
+ return 'xxxx-xxxx-xxxx-xxxx'.replace(/x/g, () => Math.floor(Math.random() * 16).toString(16));
310
+ }
311
+ function createLogger(prefix, enabled) {
312
+ if (!enabled) {
313
+ return (..._args) => { };
314
+ }
315
+ return (...args) => {
316
+ console.log(`[${prefix}]`, ...args);
317
+ };
318
+ }
319
+ // ============ Filters ============
320
+ /**
321
+ * Build the filter fragment of an emit options object.
322
+ *
323
+ * `filter` wins over `filters`: a publish is routed to exactly one topic, so
324
+ * honouring both would silently drop one of them.
325
+ */
326
+ function filterEmitOptions(opts) {
327
+ if (opts?.filter)
328
+ return { filter: opts.filter };
329
+ if (opts?.filters && opts.filters.length > 0)
330
+ return { filters: opts.filters };
331
+ return {};
332
+ }
333
+ /**
334
+ * Merge OR terms into an existing filter set. AND groups (nested arrays) are
335
+ * preserved as-is — only plain string terms are deduplicated.
336
+ */
337
+ function mergeFilters(existing, add) {
338
+ const simple = new Set();
339
+ const groups = [];
340
+ for (const f of existing) {
341
+ if (typeof f === 'string')
342
+ simple.add(f);
343
+ else
344
+ groups.push(f);
345
+ }
346
+ for (const v of add)
347
+ simple.add(v);
348
+ return [...simple, ...groups];
349
+ }
350
+ /**
351
+ * Drop OR terms from a filter set. AND groups are left untouched — remove
352
+ * those by calling `setFilters` with the set you want.
353
+ */
354
+ function withoutFilters(existing, remove) {
355
+ const drop = new Set(remove);
356
+ return existing.filter((f) => typeof f !== 'string' || !drop.has(f));
357
+ }
358
+ // ============ Wrapper registry ============
359
+ // One wrapper instance per (client, appName): two wrappers sharing an app on
360
+ // one connection would collide on topics, presence and the online lobby.
361
+ // Warn (not throw): HMR and tests legitimately construct before disposing.
362
+ const wrapperRegistry = new WeakMap();
363
+ /** Register a wrapper against a client + appName; warns on collision. */
364
+ function registerWrapper(client, appName, wrapperName) {
365
+ let apps = wrapperRegistry.get(client);
366
+ if (!apps) {
367
+ apps = new Map();
368
+ wrapperRegistry.set(client, apps);
369
+ }
370
+ const existing = apps.get(appName);
371
+ if (existing) {
372
+ console.warn(`[${wrapperName}] Another wrapper (${existing}) is already attached to this client for app "${appName}". ` +
373
+ `Use one wrapper per (client, app) — detach the other instance first.`);
374
+ }
375
+ apps.set(appName, wrapperName);
376
+ }
377
+ /** Release a wrapper's (client, appName) registration on detach. */
378
+ function releaseWrapper(client, appName) {
379
+ wrapperRegistry.get(client)?.delete(appName);
380
+ }
381
+
382
+ /** Default app name for NoLag collab SDK */
383
+ const DEFAULT_APP_NAME = 'collab';
384
+ /** Maximum number of operations to keep in the cache */
385
+ const DEFAULT_MAX_OPERATION_CACHE = 1000;
386
+ /** Idle timeout in milliseconds before a user is marked idle */
387
+ const DEFAULT_IDLE_TIMEOUT = 60000;
388
+ /** Cursor throttle in milliseconds — minimum interval between cursor updates */
389
+ const DEFAULT_CURSOR_THROTTLE = 50;
390
+ /** Topic name for operation messages within a document */
391
+ const TOPIC_OPERATIONS = 'operations';
392
+ /** Topic name for cursor presence messages within a document */
393
+ const TOPIC_CURSORS = '_cursors';
394
+ /** Lobby ID for global online presence */
395
+ const LOBBY_ID = 'online';
396
+ /** Delay before the post-setup lobby presence refetch (catches simultaneous joiners) */
397
+ const LOBBY_REFRESH_DELAY_MS = 2000;
398
+
399
+ /**
400
+ * CollabDocument — a single collaborative document room.
401
+ *
402
+ * Created via `NoLagCollab.joinDocument(name)`. Do not instantiate directly.
403
+ *
404
+ * Subscribes to 'operations' and '_cursors' topics and exposes a clean API
405
+ * for sending operations, broadcasting cursor positions, and managing
406
+ * user awareness (idle detection, status).
407
+ */
408
+ class CollabDocument extends EventEmitter {
409
+ /** @internal */
410
+ constructor(name, roomContext, localUser, options, log, isConnected) {
411
+ super();
412
+ /** Throttle state for cursor updates */
413
+ this._cursorThrottleTimer = null;
414
+ this._pendingCursorUpdate = null;
415
+ // Stored topic handler refs — cleanup removes exactly these, never all
416
+ // handlers for a topic (the client may be shared with other consumers).
417
+ this._onOperationsRef = null;
418
+ this._onCursorsRef = null;
419
+ /** Filter values applied to the operations subscription. */
420
+ this._filters = [];
421
+ this.name = name;
422
+ this._roomContext = roomContext;
423
+ this._localUser = localUser;
424
+ this._options = options;
425
+ this._log = log;
426
+ this._isConnected = isConnected;
427
+ this._presenceManager = new PresenceManager(localUser.actorTokenId);
428
+ this._operationStore = new OperationStore(options.maxOperationCache);
429
+ this._awarenessManager = new AwarenessManager(localUser.userId);
430
+ }
431
+ // ============ Public Properties ============
432
+ /** All remote users currently in this document */
433
+ get users() {
434
+ return this._presenceManager.users;
435
+ }
436
+ // ============ Operations ============
437
+ /**
438
+ * Send an operation to all collaborators in this document.
439
+ * Returns the operation that was created and broadcast.
440
+ */
441
+ sendOperation(type, opts = {}) {
442
+ const op = {
443
+ id: generateId(),
444
+ type,
445
+ path: opts.path,
446
+ position: opts.position,
447
+ length: opts.length,
448
+ content: opts.content,
449
+ data: opts.data,
450
+ userId: this._localUser.userId,
451
+ username: this._localUser.username,
452
+ timestamp: Date.now(),
453
+ isReplay: false,
454
+ };
455
+ this._log('Sending operation:', type, op.id);
456
+ this._operationStore.add(op);
457
+ this._roomContext.emit(TOPIC_OPERATIONS, op, { echo: false, ...filterEmitOptions(opts) });
458
+ return op;
459
+ }
460
+ // ============ Filters ============
461
+ /** The filter values currently applied to this document's operations. */
462
+ get filters() {
463
+ return [...this._filters];
464
+ }
465
+ /**
466
+ * Replace this document's operation filters — only operations published with
467
+ * one of these values are delivered. Useful for scoping a large document to
468
+ * the section or file path a client is actually editing.
469
+ *
470
+ * Passing an empty array clears filtering and restores the wildcard
471
+ * subscription, which receives every operation.
472
+ *
473
+ * @example
474
+ * ```ts
475
+ * doc.setFilters(['src/index.ts']); // one path
476
+ * doc.setFilters([['src/index.ts', 'v2']]); // that path AND v2
477
+ * doc.setFilters([]); // everything
478
+ * ```
479
+ */
480
+ setFilters(values) {
481
+ this._filters = [...values];
482
+ // The core types filters as `string[]`, but both its implementation and
483
+ // the wire protocol accept AND groups (nested arrays).
484
+ this._roomContext.setFilters(TOPIC_OPERATIONS, this._filters);
485
+ }
486
+ /** Add filter values to the existing set. Existing AND groups are kept. */
487
+ addFilters(values) {
488
+ this.setFilters(mergeFilters(this._filters, values));
489
+ }
490
+ /**
491
+ * Remove filter values from the existing set. Removing the last value
492
+ * restores the wildcard subscription.
493
+ */
494
+ removeFilters(values) {
495
+ this.setFilters(withoutFilters(this._filters, values));
496
+ }
497
+ /**
498
+ * Get all cached operations for this document, in timestamp order.
499
+ */
500
+ getOperations() {
501
+ return this._operationStore.getAll();
502
+ }
503
+ // ============ Cursors ============
504
+ /**
505
+ * Broadcast a cursor position update. Calls are throttled by the
506
+ * cursorThrottle option (default 50 ms) to avoid flooding.
507
+ */
508
+ updateCursor(opts) {
509
+ this._pendingCursorUpdate = opts;
510
+ if (this._cursorThrottleTimer !== null) {
511
+ // Already scheduled — the pending update will be sent when it fires
512
+ return;
513
+ }
514
+ // Send immediately for the first call in the window, then throttle
515
+ this._flushCursorUpdate();
516
+ this._cursorThrottleTimer = setTimeout(() => {
517
+ this._cursorThrottleTimer = null;
518
+ if (this._pendingCursorUpdate) {
519
+ this._flushCursorUpdate();
520
+ }
521
+ }, this._options.cursorThrottle);
522
+ }
523
+ /**
524
+ * Get all remote cursor positions.
525
+ */
526
+ getCursors() {
527
+ return this._awarenessManager.getCursors();
528
+ }
529
+ // ============ Awareness ============
530
+ /**
531
+ * Update the local user's activity status and broadcast it.
532
+ */
533
+ setStatus(status) {
534
+ this._localUser = { ...this._localUser, status };
535
+ this._setPresence();
536
+ this._log('Status updated:', status);
537
+ }
538
+ // ============ Users ============
539
+ /**
540
+ * Get all remote users currently in the document.
541
+ */
542
+ getUsers() {
543
+ return this._presenceManager.getAll();
544
+ }
545
+ /**
546
+ * Get a specific user by userId.
547
+ */
548
+ getUser(userId) {
549
+ return this._presenceManager.getUser(userId);
550
+ }
551
+ // ============ Internal (called by NoLagCollab) ============
552
+ /** @internal Subscribe to operations and cursors topics and attach listeners */
553
+ _subscribe(filters) {
554
+ this._log('Document subscribe:', this.name);
555
+ this._filters = filters ? [...filters] : [];
556
+ // Cursors stay unfiltered: awareness is ephemeral and document-wide.
557
+ if (this._filters.length > 0) {
558
+ this._roomContext.subscribe(TOPIC_OPERATIONS, { filters: this._filters });
559
+ }
560
+ else {
561
+ this._roomContext.subscribe(TOPIC_OPERATIONS);
562
+ }
563
+ this._roomContext.subscribe(TOPIC_CURSORS);
564
+ // Listen for operations (refs stored for handler-specific removal)
565
+ this._onOperationsRef = (data) => {
566
+ this._handleIncomingOperation(data);
567
+ };
568
+ this._roomContext.on(TOPIC_OPERATIONS, this._onOperationsRef);
569
+ // Listen for cursors
570
+ this._onCursorsRef = (data) => {
571
+ this._handleIncomingCursor(data);
572
+ };
573
+ this._roomContext.on(TOPIC_CURSORS, this._onCursorsRef);
574
+ }
575
+ /** @internal Set presence and fetch current room members */
576
+ _activate() {
577
+ this._log('Document activate:', this.name);
578
+ this._setPresence();
579
+ this._roomContext.fetchPresence().then((actors) => {
580
+ this._log('Document presence fetched:', this.name, actors.length, 'actors');
581
+ for (const actor of actors) {
582
+ if (actor.presence) {
583
+ const user = this._presenceManager.addFromPresence(actor.actorTokenId, actor.presence, actor.joinedAt);
584
+ if (user) {
585
+ this._awarenessManager.setStatus(user.userId, user.status);
586
+ this.emit('userJoined', user);
587
+ }
588
+ }
589
+ }
590
+ }).catch((err) => {
591
+ this._log('Failed to fetch document presence:', err);
592
+ });
593
+ }
594
+ /** @internal Re-set presence after reconnect */
595
+ _updateLocalPresence() {
596
+ this._setPresence();
597
+ }
598
+ /** @internal Handle a presence:join event */
599
+ _handlePresenceJoin(actorTokenId, presenceData) {
600
+ const user = this._presenceManager.addFromPresence(actorTokenId, presenceData);
601
+ if (user) {
602
+ this._log('User joined document:', this.name, user.userId);
603
+ this._awarenessManager.setStatus(user.userId, user.status);
604
+ this._startUserIdleTracking(user);
605
+ this.emit('userJoined', user);
606
+ }
607
+ }
608
+ /** @internal Handle a presence:leave event */
609
+ _handlePresenceLeave(actorTokenId) {
610
+ const user = this._presenceManager.removeByActorId(actorTokenId);
611
+ if (user) {
612
+ this._log('User left document:', this.name, user.userId);
613
+ this._awarenessManager.removeCursor(user.userId);
614
+ this.emit('userLeft', user);
615
+ }
616
+ }
617
+ /** @internal Handle a presence:update event */
618
+ _handlePresenceUpdate(actorTokenId, presenceData) {
619
+ const user = this._presenceManager.addFromPresence(actorTokenId, presenceData);
620
+ if (user) {
621
+ this._awarenessManager.setStatus(user.userId, user.status);
622
+ }
623
+ }
624
+ /** @internal Replay operations from another source (e.g. history fetch) */
625
+ _replayOperations(ops) {
626
+ const pending = ops.filter((op) => !this._operationStore.has(op.id));
627
+ if (pending.length === 0)
628
+ return;
629
+ this._log('Replaying', pending.length, 'operations');
630
+ this.emit('replayStart', { count: pending.length });
631
+ let replayed = 0;
632
+ for (const op of pending) {
633
+ const replayOp = { ...op, isReplay: true };
634
+ if (this._operationStore.add(replayOp)) {
635
+ this.emit('operation', replayOp);
636
+ replayed++;
637
+ }
638
+ }
639
+ this.emit('replayEnd', { replayed });
640
+ }
641
+ /** @internal Unsubscribe and clean up */
642
+ _cleanup() {
643
+ this._log('Document cleanup:', this.name);
644
+ // Cancel cursor throttle timer
645
+ if (this._cursorThrottleTimer !== null) {
646
+ clearTimeout(this._cursorThrottleTimer);
647
+ this._cursorThrottleTimer = null;
648
+ }
649
+ this._pendingCursorUpdate = null;
650
+ // Server unsubscribes need a live socket; skip when disconnected
651
+ // (best-effort — the core would no-op with an error callback anyway).
652
+ if (this._isConnected()) {
653
+ this._roomContext.unsubscribe(TOPIC_OPERATIONS);
654
+ this._roomContext.unsubscribe(TOPIC_CURSORS);
655
+ }
656
+ // Handler-specific removal only: the client may be shared, and a bare
657
+ // off(topic) would strip other consumers' handlers too.
658
+ if (this._onOperationsRef)
659
+ this._roomContext.off(TOPIC_OPERATIONS, this._onOperationsRef);
660
+ if (this._onCursorsRef)
661
+ this._roomContext.off(TOPIC_CURSORS, this._onCursorsRef);
662
+ this._onOperationsRef = null;
663
+ this._onCursorsRef = null;
664
+ // Disposes all per-user idle timers alongside cursor/status state.
665
+ this._awarenessManager.dispose();
666
+ this._presenceManager.clear();
667
+ this._operationStore.clear();
668
+ this.removeAllListeners();
669
+ }
670
+ // ============ Private ============
671
+ _handleIncomingOperation(data) {
672
+ const op = data;
673
+ // Deduplicate
674
+ if (this._operationStore.has(op.id))
675
+ return;
676
+ const stored = { ...op, isReplay: false };
677
+ this._operationStore.add(stored);
678
+ this._log('Received operation:', op.type, op.id, 'from', op.userId);
679
+ this.emit('operation', stored);
680
+ }
681
+ _handleIncomingCursor(data) {
682
+ const cursor = data;
683
+ // Ignore own cursor echoes (should not happen with echo: false, but guard anyway)
684
+ if (cursor.userId === this._localUser.userId)
685
+ return;
686
+ this._awarenessManager.updateCursor(cursor.userId, cursor);
687
+ // Reset idle tracking for this user
688
+ const user = this._presenceManager.getUser(cursor.userId);
689
+ if (user) {
690
+ this._startUserIdleTracking(user);
691
+ }
692
+ this._log('Cursor moved:', cursor.userId);
693
+ this.emit('cursorMoved', cursor);
694
+ }
695
+ _flushCursorUpdate() {
696
+ if (!this._pendingCursorUpdate)
697
+ return;
698
+ const opts = this._pendingCursorUpdate;
699
+ this._pendingCursorUpdate = null;
700
+ const cursor = {
701
+ userId: this._localUser.userId,
702
+ username: this._localUser.username,
703
+ color: this._localUser.color,
704
+ timestamp: Date.now(),
705
+ ...opts,
706
+ };
707
+ this._awarenessManager.updateCursor(this._localUser.userId, cursor);
708
+ this._roomContext.emit(TOPIC_CURSORS, cursor, { echo: false });
709
+ }
710
+ _setPresence() {
711
+ const presenceData = {
712
+ userId: this._localUser.userId,
713
+ username: this._localUser.username,
714
+ avatar: this._localUser.avatar,
715
+ color: this._localUser.color,
716
+ status: this._localUser.status,
717
+ metadata: this._localUser.metadata,
718
+ // Scope tag: on a shared client, other apps' wrappers filter our
719
+ // presence out by this (and we filter theirs).
720
+ __scope: this._options.appName,
721
+ };
722
+ this._roomContext.setPresence(presenceData);
723
+ }
724
+ _startUserIdleTracking(user) {
725
+ // Mark user as active first
726
+ if (this._awarenessManager.getStatus(user.userId) !== 'active') {
727
+ this._awarenessManager.setStatus(user.userId, 'active');
728
+ }
729
+ this._awarenessManager.startIdleTracking(user.userId, this._options.idleTimeout, () => {
730
+ this._log('User went idle:', user.userId);
731
+ this.emit('awarenessChanged', { userId: user.userId, status: 'idle' });
732
+ });
733
+ }
734
+ }
735
+
736
+ /**
737
+ * NoLagCollab — high-level real-time collaboration SDK built on @nolag/js-sdk.
738
+ *
739
+ * Provides document-scoped operations, cursor broadcasting, and user awareness
740
+ * (idle detection, status tracking) — all framework-agnostic via events.
741
+ *
742
+ * The wrapper NEVER manages the connection. The app owns one core NoLag
743
+ * client (shared by any number of wrappers on distinct apps) and the
744
+ * wrapper attaches to it at construction and releases it via `detach()`.
745
+ *
746
+ * @example
747
+ * ```typescript
748
+ * import { NoLag } from '@nolag/js-sdk';
749
+ * import { NoLagCollab } from '@nolag/collab';
750
+ *
751
+ * const client = NoLag(async () => (await (await fetch('/api/nolag-token')).json()).token);
752
+ * const collab = new NoLagCollab({ client, appName: 'my-collab', username: 'Alice' });
753
+ *
754
+ * collab.on('userOnline', (user) => console.log(user.username, 'is online'));
755
+ *
756
+ * await client.connect(); // the app owns the connection
757
+ * await collab.ready(); // wrapper setup done (identity, lobby, documents)
758
+ *
759
+ * const doc = collab.joinDocument('my-doc');
760
+ * doc.on('operation', (op) => applyOp(op));
761
+ * doc.sendOperation('insert', { position: 0, content: 'Hello' });
762
+ *
763
+ * collab.detach(); // wrapper releases its handlers and topics
764
+ * client.disconnect(); // the app closes the socket
765
+ * ```
766
+ */
767
+ class NoLagCollab extends EventEmitter {
768
+ constructor(options) {
769
+ super();
770
+ this._localUser = null;
771
+ this._documents = new Map();
772
+ this._lobby = null;
773
+ this._onlineUsers = new Map();
774
+ this._actorToUserId = new Map();
775
+ // Lifecycle: one setup run per connection epoch; detach is terminal.
776
+ this._epoch = 0;
777
+ this._detached = false;
778
+ this._isReady = false;
779
+ this._lobbyRefreshTimer = null;
780
+ // Stored client handler refs. INVARIANT: every client.on() below has a
781
+ // matching client.off() in detach() — never bare off(event), never inline
782
+ // closures on the client.
783
+ this._onConnectRef = () => this._onConnect();
784
+ this._onDisconnectRef = (reason) => {
785
+ this._log('Disconnected:', reason);
786
+ this.emit('disconnected', reason);
787
+ };
788
+ this._onReconnectRef = () => {
789
+ this._log('Reconnecting...');
790
+ this.emit('reconnecting');
791
+ };
792
+ this._onErrorRef = (error) => {
793
+ this._log('Error:', error);
794
+ this.emit('error', error);
795
+ };
796
+ this._onPresenceJoinRef = (data) => this._handleRoomPresenceJoin(data);
797
+ this._onPresenceLeaveRef = (data) => this._handleRoomPresenceLeave(data);
798
+ this._onPresenceUpdateRef = (data) => this._handleRoomPresenceUpdate(data);
799
+ this._onLobbyJoinRef = (data) => this._handleLobbyJoin(data);
800
+ this._onLobbyLeaveRef = (data) => this._handleLobbyLeave(data);
801
+ this._onLobbyUpdateRef = (data) => this._handleLobbyUpdate(data);
802
+ if (!options?.client) {
803
+ throw new TypeError('NoLagCollab requires an injected NoLag client: new NoLagCollab({ client, username, ... })');
804
+ }
805
+ this._client = options.client;
806
+ this._userId = generateId();
807
+ this._options = {
808
+ username: options.username,
809
+ avatar: options.avatar,
810
+ color: options.color,
811
+ metadata: options.metadata,
812
+ appName: options.appName ?? DEFAULT_APP_NAME,
813
+ maxOperationCache: options.maxOperationCache ?? DEFAULT_MAX_OPERATION_CACHE,
814
+ idleTimeout: options.idleTimeout ?? DEFAULT_IDLE_TIMEOUT,
815
+ cursorThrottle: options.cursorThrottle ?? DEFAULT_CURSOR_THROTTLE,
816
+ debug: options.debug ?? false,
817
+ documents: options.documents ?? [],
818
+ };
819
+ this._log = createLogger('NoLagCollab', this._options.debug);
820
+ this._readyPromise = new Promise((resolve, reject) => {
821
+ this._readyResolve = resolve;
822
+ this._readyReject = reject;
823
+ });
824
+ // ready() rejection is only meaningful to callers that await it
825
+ this._readyPromise.catch(() => { });
826
+ registerWrapper(this._client, this._options.appName, 'NoLagCollab');
827
+ // Construction = attach: wire everything now, with stored refs.
828
+ this._client.on('connect', this._onConnectRef);
829
+ this._client.on('disconnect', this._onDisconnectRef);
830
+ this._client.on('reconnect', this._onReconnectRef);
831
+ this._client.on('error', this._onErrorRef);
832
+ this._client.on('presence:join', this._onPresenceJoinRef);
833
+ this._client.on('presence:leave', this._onPresenceLeaveRef);
834
+ this._client.on('presence:update', this._onPresenceUpdateRef);
835
+ this._client.on('lobbyPresence:join', this._onLobbyJoinRef);
836
+ this._client.on('lobbyPresence:leave', this._onLobbyLeaveRef);
837
+ this._client.on('lobbyPresence:update', this._onLobbyUpdateRef);
838
+ // Attach-to-connected: if the client is already authenticated, run setup.
839
+ // The microtask lets the caller wire wrapper event handlers synchronously
840
+ // first; a racing real 'connect' event wins via the epoch guard.
841
+ queueMicrotask(() => {
842
+ if (this._epoch === 0 && !this._detached && this._client.connected) {
843
+ this._onConnect();
844
+ }
845
+ });
846
+ }
847
+ // ============ Public Properties ============
848
+ /** Whether the underlying connection is established (connected ≠ ready) */
849
+ get connected() {
850
+ return !this._detached && this._client.connected;
851
+ }
852
+ /** The injected core client (owned by the app, not the wrapper) */
853
+ get client() {
854
+ return this._client;
855
+ }
856
+ /** The local user's info (available after ready) */
857
+ get localUser() {
858
+ return this._localUser;
859
+ }
860
+ /** All currently joined documents */
861
+ get documents() {
862
+ return this._documents;
863
+ }
864
+ // ============ Lifecycle ============
865
+ /**
866
+ * Resolves once the wrapper's first setup completed (identity, lobby and
867
+ * configured documents ready — equivalently, once 'connected' has fired).
868
+ * Rejects only if detach() is called before that. Client auth failures
869
+ * surface via the app's own `await client.connect()`, not here.
870
+ */
871
+ ready() {
872
+ return this._readyPromise;
873
+ }
874
+ /**
875
+ * Detach from the client: remove every handler this wrapper added,
876
+ * unsubscribe its topics and lobby (when connected), clear state.
877
+ * Terminal and idempotent; never touches the socket. To use collab again,
878
+ * construct a new instance.
879
+ */
880
+ detach() {
881
+ if (this._detached)
882
+ return;
883
+ this._log('Detaching...');
884
+ this._detached = true;
885
+ this._epoch++; // aborts any in-flight setup at its next checkpoint
886
+ if (this._lobbyRefreshTimer) {
887
+ clearTimeout(this._lobbyRefreshTimer);
888
+ this._lobbyRefreshTimer = null;
889
+ }
890
+ // Remove all client handlers by stored ref
891
+ this._client.off('connect', this._onConnectRef);
892
+ this._client.off('disconnect', this._onDisconnectRef);
893
+ this._client.off('reconnect', this._onReconnectRef);
894
+ this._client.off('error', this._onErrorRef);
895
+ this._client.off('presence:join', this._onPresenceJoinRef);
896
+ this._client.off('presence:leave', this._onPresenceLeaveRef);
897
+ this._client.off('presence:update', this._onPresenceUpdateRef);
898
+ this._client.off('lobbyPresence:join', this._onLobbyJoinRef);
899
+ this._client.off('lobbyPresence:leave', this._onLobbyLeaveRef);
900
+ this._client.off('lobbyPresence:update', this._onLobbyUpdateRef);
901
+ // Documents: handler-specific off + connected-gated server unsubscribe.
902
+ // _cleanup also clears each document's cursor-throttle and idle timers.
903
+ for (const name of [...this._documents.keys()]) {
904
+ this._documents.get(name)._cleanup();
905
+ this._documents.delete(name);
906
+ }
907
+ // Lobby: server unsubscribe is best-effort and needs a live socket
908
+ if (this._lobby && this._client.connected) {
909
+ try {
910
+ this._lobby.unsubscribe();
911
+ }
912
+ catch {
913
+ /* best-effort */
914
+ }
915
+ }
916
+ this._lobby = null;
917
+ this._onlineUsers.clear();
918
+ this._actorToUserId.clear();
919
+ this._localUser = null;
920
+ releaseWrapper(this._client, this._options.appName);
921
+ if (!this._isReady) {
922
+ this._readyReject(new Error('NoLagCollab detached before ready'));
923
+ }
924
+ }
925
+ // ============ Private: Epoch Setup ============
926
+ _onConnect() {
927
+ this._epoch++;
928
+ void this._runSetup(this._epoch);
929
+ }
930
+ /**
931
+ * One setup pass per connection epoch. Serves both initial setup (epoch 1)
932
+ * and reconnect restore (epoch > 1). Aborts silently whenever a newer
933
+ * epoch started or the wrapper detached — checked after every await.
934
+ */
935
+ async _runSetup(epoch) {
936
+ const stale = () => epoch !== this._epoch || this._detached;
937
+ this._log(this._isReady ? 'Restoring after reconnect...' : 'Setting up...');
938
+ // Identity (client.actorId is guaranteed post-auth)
939
+ if (!this._localUser) {
940
+ this._localUser = {
941
+ userId: this._userId,
942
+ actorTokenId: this._client.actorId,
943
+ username: this._options.username,
944
+ avatar: this._options.avatar,
945
+ color: this._options.color,
946
+ status: 'active',
947
+ metadata: this._options.metadata,
948
+ joinedAt: Date.now(),
949
+ isLocal: true,
950
+ };
951
+ this._log('Local user:', this._localUser.userId, '→', this._localUser.actorTokenId);
952
+ }
953
+ else {
954
+ this._localUser.actorTokenId = this._client.actorId;
955
+ }
956
+ // Lobby: subscribe every epoch (idempotent server-side) and diff-hydrate
957
+ // from the returned snapshot — one path for setup and restore.
958
+ if (!this._lobby) {
959
+ this._lobby = this._client.setApp(this._options.appName).setLobby(LOBBY_ID);
960
+ }
961
+ try {
962
+ const state = await this._lobby.subscribe();
963
+ if (stale())
964
+ return;
965
+ this._diffHydrateOnlineUsers(state);
966
+ this._log('Lobby subscribed, online users:', this._onlineUsers.size);
967
+ }
968
+ catch (err) {
969
+ if (stale())
970
+ return;
971
+ this._log('Lobby subscription failed:', err);
972
+ }
973
+ if (!this._isReady) {
974
+ // First successful setup: auto-join configured documents
975
+ for (const name of this._options.documents) {
976
+ this._subscribeDocumentInternal(name);
977
+ }
978
+ }
979
+ else {
980
+ // Server auto-restored topic subscriptions; only room-scoped presence
981
+ // needs re-applying (the core does not restore it).
982
+ for (const doc of this._documents.values()) {
983
+ doc._updateLocalPresence();
984
+ }
985
+ }
986
+ if (stale())
987
+ return;
988
+ // Ready keys on the first setup that COMPLETES, not on epoch 1: an
989
+ // epoch aborted by a racing reconnect must not strand ready().
990
+ if (!this._isReady) {
991
+ this._isReady = true;
992
+ this._readyResolve();
993
+ this.emit('connected');
994
+ }
995
+ else {
996
+ this.emit('reconnected');
997
+ }
998
+ // Deferred lobby refetch: catches users who joined during the setup
999
+ // window (e.g. simultaneous multi-tab connects).
1000
+ this._scheduleLobbyRefresh(epoch);
1001
+ }
1002
+ _scheduleLobbyRefresh(epoch) {
1003
+ if (this._lobbyRefreshTimer)
1004
+ clearTimeout(this._lobbyRefreshTimer);
1005
+ this._lobbyRefreshTimer = setTimeout(() => {
1006
+ this._lobbyRefreshTimer = null;
1007
+ if (epoch !== this._epoch || this._detached || !this._client.connected || !this._lobby) {
1008
+ return;
1009
+ }
1010
+ this._lobby
1011
+ .fetchPresence()
1012
+ .then((state) => {
1013
+ if (epoch !== this._epoch || this._detached)
1014
+ return;
1015
+ this._diffHydrateOnlineUsers(state);
1016
+ })
1017
+ .catch(() => {
1018
+ /* best-effort */
1019
+ });
1020
+ }, LOBBY_REFRESH_DELAY_MS);
1021
+ }
1022
+ // ============ Document Management ============
1023
+ /**
1024
+ * Join a collaborative document. Creates, subscribes, and activates it.
1025
+ * Returns an existing document if already joined.
1026
+ */
1027
+ joinDocument(name, opts) {
1028
+ this._assertUsable();
1029
+ let doc = this._documents.get(name);
1030
+ if (!doc) {
1031
+ doc = this._subscribeDocumentInternal(name, opts?.filters);
1032
+ doc._activate();
1033
+ }
1034
+ else if (opts?.filters) {
1035
+ // Already joined — re-point its filters rather than ignoring them.
1036
+ doc.setFilters(opts.filters);
1037
+ }
1038
+ return doc;
1039
+ }
1040
+ /**
1041
+ * Leave a collaborative document. Fully unsubscribes and removes it.
1042
+ */
1043
+ leaveDocument(name) {
1044
+ const doc = this._documents.get(name);
1045
+ if (!doc)
1046
+ return;
1047
+ this._log('Leaving document:', name);
1048
+ doc._cleanup();
1049
+ this._documents.delete(name);
1050
+ }
1051
+ /**
1052
+ * Get all joined documents.
1053
+ */
1054
+ getDocuments() {
1055
+ return Array.from(this._documents.values());
1056
+ }
1057
+ // ============ Global Presence ============
1058
+ /**
1059
+ * Get all users currently online across all documents.
1060
+ */
1061
+ getOnlineUsers() {
1062
+ return Array.from(this._onlineUsers.values());
1063
+ }
1064
+ // ============ Private: Guards ============
1065
+ _assertUsable() {
1066
+ if (this._detached) {
1067
+ throw new Error('NoLagCollab has been detached — construct a new instance');
1068
+ }
1069
+ if (!this._isReady || !this._localUser) {
1070
+ throw new Error('NoLagCollab not ready — await ready() or the "connected" event');
1071
+ }
1072
+ }
1073
+ // ============ Private: Document Setup ============
1074
+ _subscribeDocumentInternal(name, filters) {
1075
+ this._log('Subscribing document:', name);
1076
+ const roomContext = this._client.setApp(this._options.appName).setRoom(name);
1077
+ const doc = new CollabDocument(name, roomContext, this._localUser, this._options, createLogger(`CollabDocument:${name}`, this._options.debug), () => this._client.connected);
1078
+ this._documents.set(name, doc);
1079
+ doc._subscribe(filters);
1080
+ return doc;
1081
+ }
1082
+ // ============ Private: Scope Filtering ============
1083
+ /**
1084
+ * On a shared client, presence events from other apps' wrappers arrive on
1085
+ * the same connection-level events. Wrappers stamp their presence with a
1086
+ * `__scope` (their appName); a mismatched tag means another app's data.
1087
+ * Untagged presence is accepted (older peers in this same app).
1088
+ */
1089
+ _foreignScope(data) {
1090
+ const scope = data?.__scope;
1091
+ return typeof scope === 'string' && scope !== this._options.appName;
1092
+ }
1093
+ // ============ Private: Room Presence ============
1094
+ _handleRoomPresenceJoin(data) {
1095
+ if (data.actorTokenId === this._localUser?.actorTokenId)
1096
+ return;
1097
+ const presenceData = data.presence;
1098
+ if (!presenceData?.userId || this._foreignScope(presenceData))
1099
+ return;
1100
+ const user = this._presenceToUser(data.actorTokenId, presenceData);
1101
+ this._actorToUserId.set(data.actorTokenId, user.userId);
1102
+ if (!this._onlineUsers.has(user.userId)) {
1103
+ this._onlineUsers.set(user.userId, user);
1104
+ this.emit('userOnline', user);
1105
+ }
1106
+ // Route to all documents
1107
+ for (const doc of this._documents.values()) {
1108
+ doc._handlePresenceJoin(data.actorTokenId, presenceData);
1109
+ }
1110
+ }
1111
+ _handleRoomPresenceLeave(data) {
1112
+ if (data.actorTokenId === this._localUser?.actorTokenId)
1113
+ return;
1114
+ // Route to all documents
1115
+ for (const doc of this._documents.values()) {
1116
+ doc._handlePresenceLeave(data.actorTokenId);
1117
+ }
1118
+ }
1119
+ _handleRoomPresenceUpdate(data) {
1120
+ if (data.actorTokenId === this._localUser?.actorTokenId)
1121
+ return;
1122
+ const presenceData = data.presence;
1123
+ if (!presenceData?.userId || this._foreignScope(presenceData))
1124
+ return;
1125
+ if (this._onlineUsers.has(presenceData.userId)) {
1126
+ const user = this._presenceToUser(data.actorTokenId, presenceData);
1127
+ this._onlineUsers.set(user.userId, user);
1128
+ }
1129
+ // Route to all documents
1130
+ for (const doc of this._documents.values()) {
1131
+ doc._handlePresenceUpdate(data.actorTokenId, presenceData);
1132
+ }
1133
+ }
1134
+ // ============ Private: Lobby ============
1135
+ _handleLobbyJoin(event) {
1136
+ const { actorId, data } = event;
1137
+ if (actorId === this._localUser?.actorTokenId)
1138
+ return;
1139
+ const presenceData = data;
1140
+ if (!presenceData.userId || this._foreignScope(presenceData))
1141
+ return;
1142
+ const user = this._presenceToUser(actorId, presenceData);
1143
+ this._actorToUserId.set(actorId, user.userId);
1144
+ if (!this._onlineUsers.has(user.userId)) {
1145
+ this._onlineUsers.set(user.userId, user);
1146
+ this.emit('userOnline', user);
1147
+ }
1148
+ }
1149
+ _handleLobbyLeave(event) {
1150
+ const { actorId, data } = event;
1151
+ if (actorId === this._localUser?.actorTokenId)
1152
+ return;
1153
+ const presenceData = data;
1154
+ if (this._foreignScope(presenceData))
1155
+ return;
1156
+ const userId = presenceData?.userId
1157
+ || this._actorToUserId.get(actorId)
1158
+ || this._findUserIdByActorId(actorId);
1159
+ if (userId) {
1160
+ const user = this._onlineUsers.get(userId);
1161
+ if (user) {
1162
+ this._onlineUsers.delete(userId);
1163
+ this._actorToUserId.delete(actorId);
1164
+ this.emit('userOffline', user);
1165
+ }
1166
+ }
1167
+ }
1168
+ _handleLobbyUpdate(event) {
1169
+ const { actorId, data } = event;
1170
+ if (actorId === this._localUser?.actorTokenId)
1171
+ return;
1172
+ const presenceData = data;
1173
+ if (!presenceData.userId || this._foreignScope(presenceData))
1174
+ return;
1175
+ const user = this._presenceToUser(actorId, presenceData);
1176
+ this._onlineUsers.set(user.userId, user);
1177
+ }
1178
+ /**
1179
+ * Reconcile the online-user map against a fresh lobby snapshot, emitting
1180
+ * only the deltas (userOffline for vanished, userOnline for new). One path
1181
+ * for initial hydration, reconnect restore, and the deferred refetch.
1182
+ */
1183
+ _diffHydrateOnlineUsers(state) {
1184
+ // Build the fresh user set from the snapshot
1185
+ const fresh = new Map();
1186
+ const freshActors = new Map();
1187
+ for (const roomId of Object.keys(state)) {
1188
+ const roomPresence = state[roomId];
1189
+ for (const actorId of Object.keys(roomPresence)) {
1190
+ if (actorId === this._localUser?.actorTokenId)
1191
+ continue;
1192
+ const raw = roomPresence[actorId];
1193
+ // Server returns full actor records with presence nested under .presence
1194
+ const presenceData = (raw?.presence ?? raw);
1195
+ if (presenceData?.userId && !this._foreignScope(presenceData)) {
1196
+ if (!fresh.has(presenceData.userId)) {
1197
+ fresh.set(presenceData.userId, this._presenceToUser(actorId, presenceData));
1198
+ }
1199
+ freshActors.set(actorId, presenceData.userId);
1200
+ }
1201
+ }
1202
+ }
1203
+ // Vanished users
1204
+ for (const [userId, user] of [...this._onlineUsers]) {
1205
+ if (!fresh.has(userId)) {
1206
+ this._onlineUsers.delete(userId);
1207
+ for (const [actorId, mappedUserId] of [...this._actorToUserId]) {
1208
+ if (mappedUserId === userId)
1209
+ this._actorToUserId.delete(actorId);
1210
+ }
1211
+ this.emit('userOffline', user);
1212
+ }
1213
+ }
1214
+ // New users
1215
+ for (const [userId, user] of fresh) {
1216
+ if (!this._onlineUsers.has(userId)) {
1217
+ this._onlineUsers.set(userId, user);
1218
+ this.emit('userOnline', user);
1219
+ }
1220
+ }
1221
+ for (const [actorId, userId] of freshActors) {
1222
+ this._actorToUserId.set(actorId, userId);
1223
+ }
1224
+ }
1225
+ // ============ Private: Helpers ============
1226
+ _presenceToUser(actorTokenId, data) {
1227
+ return {
1228
+ userId: data.userId,
1229
+ actorTokenId,
1230
+ username: data.username,
1231
+ avatar: data.avatar,
1232
+ color: data.color,
1233
+ status: data.status ?? 'active',
1234
+ metadata: data.metadata,
1235
+ joinedAt: Date.now(),
1236
+ isLocal: false,
1237
+ };
1238
+ }
1239
+ _findUserIdByActorId(actorTokenId) {
1240
+ for (const user of this._onlineUsers.values()) {
1241
+ if (user.actorTokenId === actorTokenId)
1242
+ return user.userId;
1243
+ }
1244
+ return undefined;
1245
+ }
1246
+ }
1247
+
1248
+ export { CollabDocument, EventEmitter, NoLagCollab };
1249
+ //# sourceMappingURL=react-native.js.map