@nolag/collab 0.1.1 → 1.0.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 +63 -18
- package/dist/CollabDocument.d.ts +4 -1
- package/dist/NoLagCollab.d.ts +69 -16
- package/dist/browser.js +1 -1
- package/dist/browser.js.map +1 -1
- package/dist/constants.d.ts +2 -0
- package/dist/index.cjs +346 -166
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +346 -166
- package/dist/index.mjs.map +1 -1
- package/dist/types.d.ts +8 -6
- package/dist/utils.d.ts +4 -0
- package/package.json +3 -3
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { NoLag } from '@nolag/js-sdk';
|
|
2
|
-
|
|
3
1
|
/**
|
|
4
2
|
* Tiny typed event emitter — framework-agnostic base for NoLag SDKs.
|
|
5
3
|
*
|
|
@@ -318,6 +316,29 @@ function createLogger(prefix, enabled) {
|
|
|
318
316
|
console.log(`[${prefix}]`, ...args);
|
|
319
317
|
};
|
|
320
318
|
}
|
|
319
|
+
// ============ Wrapper registry ============
|
|
320
|
+
// One wrapper instance per (client, appName): two wrappers sharing an app on
|
|
321
|
+
// one connection would collide on topics, presence and the online lobby.
|
|
322
|
+
// Warn (not throw): HMR and tests legitimately construct before disposing.
|
|
323
|
+
const wrapperRegistry = new WeakMap();
|
|
324
|
+
/** Register a wrapper against a client + appName; warns on collision. */
|
|
325
|
+
function registerWrapper(client, appName, wrapperName) {
|
|
326
|
+
let apps = wrapperRegistry.get(client);
|
|
327
|
+
if (!apps) {
|
|
328
|
+
apps = new Map();
|
|
329
|
+
wrapperRegistry.set(client, apps);
|
|
330
|
+
}
|
|
331
|
+
const existing = apps.get(appName);
|
|
332
|
+
if (existing) {
|
|
333
|
+
console.warn(`[${wrapperName}] Another wrapper (${existing}) is already attached to this client for app "${appName}". ` +
|
|
334
|
+
`Use one wrapper per (client, app) — detach the other instance first.`);
|
|
335
|
+
}
|
|
336
|
+
apps.set(appName, wrapperName);
|
|
337
|
+
}
|
|
338
|
+
/** Release a wrapper's (client, appName) registration on detach. */
|
|
339
|
+
function releaseWrapper(client, appName) {
|
|
340
|
+
wrapperRegistry.get(client)?.delete(appName);
|
|
341
|
+
}
|
|
321
342
|
|
|
322
343
|
/** Default app name for NoLag collab SDK */
|
|
323
344
|
const DEFAULT_APP_NAME = 'collab';
|
|
@@ -333,6 +354,8 @@ const TOPIC_OPERATIONS = 'operations';
|
|
|
333
354
|
const TOPIC_CURSORS = '_cursors';
|
|
334
355
|
/** Lobby ID for global online presence */
|
|
335
356
|
const LOBBY_ID = 'online';
|
|
357
|
+
/** Delay before the post-setup lobby presence refetch (catches simultaneous joiners) */
|
|
358
|
+
const LOBBY_REFRESH_DELAY_MS = 2000;
|
|
336
359
|
|
|
337
360
|
/**
|
|
338
361
|
* CollabDocument — a single collaborative document room.
|
|
@@ -345,16 +368,21 @@ const LOBBY_ID = 'online';
|
|
|
345
368
|
*/
|
|
346
369
|
class CollabDocument extends EventEmitter {
|
|
347
370
|
/** @internal */
|
|
348
|
-
constructor(name, roomContext, localUser, options, log) {
|
|
371
|
+
constructor(name, roomContext, localUser, options, log, isConnected) {
|
|
349
372
|
super();
|
|
350
373
|
/** Throttle state for cursor updates */
|
|
351
374
|
this._cursorThrottleTimer = null;
|
|
352
375
|
this._pendingCursorUpdate = null;
|
|
376
|
+
// Stored topic handler refs — cleanup removes exactly these, never all
|
|
377
|
+
// handlers for a topic (the client may be shared with other consumers).
|
|
378
|
+
this._onOperationsRef = null;
|
|
379
|
+
this._onCursorsRef = null;
|
|
353
380
|
this.name = name;
|
|
354
381
|
this._roomContext = roomContext;
|
|
355
382
|
this._localUser = localUser;
|
|
356
383
|
this._options = options;
|
|
357
384
|
this._log = log;
|
|
385
|
+
this._isConnected = isConnected;
|
|
358
386
|
this._presenceManager = new PresenceManager(localUser.actorTokenId);
|
|
359
387
|
this._operationStore = new OperationStore(options.maxOperationCache);
|
|
360
388
|
this._awarenessManager = new AwarenessManager(localUser.userId);
|
|
@@ -448,12 +476,16 @@ class CollabDocument extends EventEmitter {
|
|
|
448
476
|
this._log('Document subscribe:', this.name);
|
|
449
477
|
this._roomContext.subscribe(TOPIC_OPERATIONS);
|
|
450
478
|
this._roomContext.subscribe(TOPIC_CURSORS);
|
|
451
|
-
|
|
479
|
+
// Listen for operations (refs stored for handler-specific removal)
|
|
480
|
+
this._onOperationsRef = (data) => {
|
|
452
481
|
this._handleIncomingOperation(data);
|
|
453
|
-
}
|
|
454
|
-
this._roomContext.on(
|
|
482
|
+
};
|
|
483
|
+
this._roomContext.on(TOPIC_OPERATIONS, this._onOperationsRef);
|
|
484
|
+
// Listen for cursors
|
|
485
|
+
this._onCursorsRef = (data) => {
|
|
455
486
|
this._handleIncomingCursor(data);
|
|
456
|
-
}
|
|
487
|
+
};
|
|
488
|
+
this._roomContext.on(TOPIC_CURSORS, this._onCursorsRef);
|
|
457
489
|
}
|
|
458
490
|
/** @internal Set presence and fetch current room members */
|
|
459
491
|
_activate() {
|
|
@@ -524,15 +556,27 @@ class CollabDocument extends EventEmitter {
|
|
|
524
556
|
/** @internal Unsubscribe and clean up */
|
|
525
557
|
_cleanup() {
|
|
526
558
|
this._log('Document cleanup:', this.name);
|
|
527
|
-
// Cancel throttle timer
|
|
559
|
+
// Cancel cursor throttle timer
|
|
528
560
|
if (this._cursorThrottleTimer !== null) {
|
|
529
561
|
clearTimeout(this._cursorThrottleTimer);
|
|
530
562
|
this._cursorThrottleTimer = null;
|
|
531
563
|
}
|
|
532
|
-
this.
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
this.
|
|
564
|
+
this._pendingCursorUpdate = null;
|
|
565
|
+
// Server unsubscribes need a live socket; skip when disconnected
|
|
566
|
+
// (best-effort — the core would no-op with an error callback anyway).
|
|
567
|
+
if (this._isConnected()) {
|
|
568
|
+
this._roomContext.unsubscribe(TOPIC_OPERATIONS);
|
|
569
|
+
this._roomContext.unsubscribe(TOPIC_CURSORS);
|
|
570
|
+
}
|
|
571
|
+
// Handler-specific removal only: the client may be shared, and a bare
|
|
572
|
+
// off(topic) would strip other consumers' handlers too.
|
|
573
|
+
if (this._onOperationsRef)
|
|
574
|
+
this._roomContext.off(TOPIC_OPERATIONS, this._onOperationsRef);
|
|
575
|
+
if (this._onCursorsRef)
|
|
576
|
+
this._roomContext.off(TOPIC_CURSORS, this._onCursorsRef);
|
|
577
|
+
this._onOperationsRef = null;
|
|
578
|
+
this._onCursorsRef = null;
|
|
579
|
+
// Disposes all per-user idle timers alongside cursor/status state.
|
|
536
580
|
this._awarenessManager.dispose();
|
|
537
581
|
this._presenceManager.clear();
|
|
538
582
|
this._operationStore.clear();
|
|
@@ -586,6 +630,9 @@ class CollabDocument extends EventEmitter {
|
|
|
586
630
|
color: this._localUser.color,
|
|
587
631
|
status: this._localUser.status,
|
|
588
632
|
metadata: this._localUser.metadata,
|
|
633
|
+
// Scope tag: on a shared client, other apps' wrappers filter our
|
|
634
|
+
// presence out by this (and we filter theirs).
|
|
635
|
+
__scope: this._options.appName,
|
|
589
636
|
};
|
|
590
637
|
this._roomContext.setPresence(presenceData);
|
|
591
638
|
}
|
|
@@ -607,32 +654,70 @@ class CollabDocument extends EventEmitter {
|
|
|
607
654
|
* Provides document-scoped operations, cursor broadcasting, and user awareness
|
|
608
655
|
* (idle detection, status tracking) — all framework-agnostic via events.
|
|
609
656
|
*
|
|
657
|
+
* The wrapper NEVER manages the connection. The app owns one core NoLag
|
|
658
|
+
* client (shared by any number of wrappers on distinct apps) and the
|
|
659
|
+
* wrapper attaches to it at construction and releases it via `detach()`.
|
|
660
|
+
*
|
|
610
661
|
* @example
|
|
611
662
|
* ```typescript
|
|
663
|
+
* import { NoLag } from '@nolag/js-sdk';
|
|
612
664
|
* import { NoLagCollab } from '@nolag/collab';
|
|
613
665
|
*
|
|
614
|
-
* const
|
|
666
|
+
* const client = NoLag(async () => (await (await fetch('/api/nolag-token')).json()).token);
|
|
667
|
+
* const collab = new NoLagCollab({ client, appName: 'my-collab', username: 'Alice' });
|
|
615
668
|
*
|
|
616
|
-
* collab.on('connected', () => console.log('Connected!'));
|
|
617
669
|
* collab.on('userOnline', (user) => console.log(user.username, 'is online'));
|
|
618
670
|
*
|
|
619
|
-
* await
|
|
671
|
+
* await client.connect(); // the app owns the connection
|
|
672
|
+
* await collab.ready(); // wrapper setup done (identity, lobby, documents)
|
|
620
673
|
*
|
|
621
674
|
* const doc = collab.joinDocument('my-doc');
|
|
622
675
|
* doc.on('operation', (op) => applyOp(op));
|
|
623
676
|
* doc.sendOperation('insert', { position: 0, content: 'Hello' });
|
|
677
|
+
*
|
|
678
|
+
* collab.detach(); // wrapper releases its handlers and topics
|
|
679
|
+
* client.disconnect(); // the app closes the socket
|
|
624
680
|
* ```
|
|
625
681
|
*/
|
|
626
682
|
class NoLagCollab extends EventEmitter {
|
|
627
|
-
constructor(
|
|
683
|
+
constructor(options) {
|
|
628
684
|
super();
|
|
629
|
-
this._client = null;
|
|
630
685
|
this._localUser = null;
|
|
631
686
|
this._documents = new Map();
|
|
632
687
|
this._lobby = null;
|
|
633
688
|
this._onlineUsers = new Map();
|
|
634
689
|
this._actorToUserId = new Map();
|
|
635
|
-
|
|
690
|
+
// Lifecycle: one setup run per connection epoch; detach is terminal.
|
|
691
|
+
this._epoch = 0;
|
|
692
|
+
this._detached = false;
|
|
693
|
+
this._isReady = false;
|
|
694
|
+
this._lobbyRefreshTimer = null;
|
|
695
|
+
// Stored client handler refs. INVARIANT: every client.on() below has a
|
|
696
|
+
// matching client.off() in detach() — never bare off(event), never inline
|
|
697
|
+
// closures on the client.
|
|
698
|
+
this._onConnectRef = () => this._onConnect();
|
|
699
|
+
this._onDisconnectRef = (reason) => {
|
|
700
|
+
this._log('Disconnected:', reason);
|
|
701
|
+
this.emit('disconnected', reason);
|
|
702
|
+
};
|
|
703
|
+
this._onReconnectRef = () => {
|
|
704
|
+
this._log('Reconnecting...');
|
|
705
|
+
this.emit('reconnecting');
|
|
706
|
+
};
|
|
707
|
+
this._onErrorRef = (error) => {
|
|
708
|
+
this._log('Error:', error);
|
|
709
|
+
this.emit('error', error);
|
|
710
|
+
};
|
|
711
|
+
this._onPresenceJoinRef = (data) => this._handleRoomPresenceJoin(data);
|
|
712
|
+
this._onPresenceLeaveRef = (data) => this._handleRoomPresenceLeave(data);
|
|
713
|
+
this._onPresenceUpdateRef = (data) => this._handleRoomPresenceUpdate(data);
|
|
714
|
+
this._onLobbyJoinRef = (data) => this._handleLobbyJoin(data);
|
|
715
|
+
this._onLobbyLeaveRef = (data) => this._handleLobbyLeave(data);
|
|
716
|
+
this._onLobbyUpdateRef = (data) => this._handleLobbyUpdate(data);
|
|
717
|
+
if (!options?.client) {
|
|
718
|
+
throw new TypeError('NoLagCollab requires an injected NoLag client: new NoLagCollab({ client, username, ... })');
|
|
719
|
+
}
|
|
720
|
+
this._client = options.client;
|
|
636
721
|
this._userId = generateId();
|
|
637
722
|
this._options = {
|
|
638
723
|
username: options.username,
|
|
@@ -640,22 +725,50 @@ class NoLagCollab extends EventEmitter {
|
|
|
640
725
|
color: options.color,
|
|
641
726
|
metadata: options.metadata,
|
|
642
727
|
appName: options.appName ?? DEFAULT_APP_NAME,
|
|
643
|
-
url: options.url,
|
|
644
728
|
maxOperationCache: options.maxOperationCache ?? DEFAULT_MAX_OPERATION_CACHE,
|
|
645
729
|
idleTimeout: options.idleTimeout ?? DEFAULT_IDLE_TIMEOUT,
|
|
646
730
|
cursorThrottle: options.cursorThrottle ?? DEFAULT_CURSOR_THROTTLE,
|
|
647
731
|
debug: options.debug ?? false,
|
|
648
|
-
reconnect: options.reconnect ?? true,
|
|
649
732
|
documents: options.documents ?? [],
|
|
650
733
|
};
|
|
651
734
|
this._log = createLogger('NoLagCollab', this._options.debug);
|
|
735
|
+
this._readyPromise = new Promise((resolve, reject) => {
|
|
736
|
+
this._readyResolve = resolve;
|
|
737
|
+
this._readyReject = reject;
|
|
738
|
+
});
|
|
739
|
+
// ready() rejection is only meaningful to callers that await it
|
|
740
|
+
this._readyPromise.catch(() => { });
|
|
741
|
+
registerWrapper(this._client, this._options.appName, 'NoLagCollab');
|
|
742
|
+
// Construction = attach: wire everything now, with stored refs.
|
|
743
|
+
this._client.on('connect', this._onConnectRef);
|
|
744
|
+
this._client.on('disconnect', this._onDisconnectRef);
|
|
745
|
+
this._client.on('reconnect', this._onReconnectRef);
|
|
746
|
+
this._client.on('error', this._onErrorRef);
|
|
747
|
+
this._client.on('presence:join', this._onPresenceJoinRef);
|
|
748
|
+
this._client.on('presence:leave', this._onPresenceLeaveRef);
|
|
749
|
+
this._client.on('presence:update', this._onPresenceUpdateRef);
|
|
750
|
+
this._client.on('lobbyPresence:join', this._onLobbyJoinRef);
|
|
751
|
+
this._client.on('lobbyPresence:leave', this._onLobbyLeaveRef);
|
|
752
|
+
this._client.on('lobbyPresence:update', this._onLobbyUpdateRef);
|
|
753
|
+
// Attach-to-connected: if the client is already authenticated, run setup.
|
|
754
|
+
// The microtask lets the caller wire wrapper event handlers synchronously
|
|
755
|
+
// first; a racing real 'connect' event wins via the epoch guard.
|
|
756
|
+
queueMicrotask(() => {
|
|
757
|
+
if (this._epoch === 0 && !this._detached && this._client.connected) {
|
|
758
|
+
this._onConnect();
|
|
759
|
+
}
|
|
760
|
+
});
|
|
652
761
|
}
|
|
653
762
|
// ============ Public Properties ============
|
|
654
|
-
/** Whether the underlying connection is established */
|
|
763
|
+
/** Whether the underlying connection is established (connected ≠ ready) */
|
|
655
764
|
get connected() {
|
|
656
|
-
return this._client
|
|
765
|
+
return !this._detached && this._client.connected;
|
|
766
|
+
}
|
|
767
|
+
/** The injected core client (owned by the app, not the wrapper) */
|
|
768
|
+
get client() {
|
|
769
|
+
return this._client;
|
|
657
770
|
}
|
|
658
|
-
/** The local user's info (available after
|
|
771
|
+
/** The local user's info (available after ready) */
|
|
659
772
|
get localUser() {
|
|
660
773
|
return this._localUser;
|
|
661
774
|
}
|
|
@@ -665,99 +778,161 @@ class NoLagCollab extends EventEmitter {
|
|
|
665
778
|
}
|
|
666
779
|
// ============ Lifecycle ============
|
|
667
780
|
/**
|
|
668
|
-
*
|
|
781
|
+
* Resolves once the wrapper's first setup completed (identity, lobby and
|
|
782
|
+
* configured documents ready — equivalently, once 'connected' has fired).
|
|
783
|
+
* Rejects only if detach() is called before that. Client auth failures
|
|
784
|
+
* surface via the app's own `await client.connect()`, not here.
|
|
669
785
|
*/
|
|
670
|
-
|
|
671
|
-
this.
|
|
672
|
-
const clientOptions = {
|
|
673
|
-
debug: this._options.debug,
|
|
674
|
-
reconnect: this._options.reconnect,
|
|
675
|
-
};
|
|
676
|
-
if (this._options.url) {
|
|
677
|
-
clientOptions.url = this._options.url;
|
|
678
|
-
}
|
|
679
|
-
this._client = NoLag(this._token, clientOptions);
|
|
680
|
-
// Wire client lifecycle events
|
|
681
|
-
this._client.on('connect', () => {
|
|
682
|
-
this._log('Connected');
|
|
683
|
-
if (this._documents.size > 0) {
|
|
684
|
-
this._log('Reconnected — restoring documents...');
|
|
685
|
-
this._restoreDocuments();
|
|
686
|
-
this.emit('reconnected');
|
|
687
|
-
}
|
|
688
|
-
});
|
|
689
|
-
this._client.on('disconnect', (reason) => {
|
|
690
|
-
this._log('Disconnected:', reason);
|
|
691
|
-
this.emit('disconnected', reason);
|
|
692
|
-
});
|
|
693
|
-
this._client.on('reconnect', () => {
|
|
694
|
-
this._log('Reconnecting...');
|
|
695
|
-
});
|
|
696
|
-
this._client.on('error', (error) => {
|
|
697
|
-
this._log('Error:', error);
|
|
698
|
-
this.emit('error', error);
|
|
699
|
-
});
|
|
700
|
-
// Connect
|
|
701
|
-
await this._client.connect();
|
|
702
|
-
// Wire room-level presence events
|
|
703
|
-
this._client.on('presence:join', (data) => {
|
|
704
|
-
this._handleRoomPresenceJoin(data);
|
|
705
|
-
});
|
|
706
|
-
this._client.on('presence:leave', (data) => {
|
|
707
|
-
this._handleRoomPresenceLeave(data);
|
|
708
|
-
});
|
|
709
|
-
this._client.on('presence:update', (data) => {
|
|
710
|
-
this._handleRoomPresenceUpdate(data);
|
|
711
|
-
});
|
|
712
|
-
// Create local user
|
|
713
|
-
this._localUser = {
|
|
714
|
-
userId: this._userId,
|
|
715
|
-
actorTokenId: this._client.actorId,
|
|
716
|
-
username: this._options.username,
|
|
717
|
-
avatar: this._options.avatar,
|
|
718
|
-
color: this._options.color,
|
|
719
|
-
status: 'active',
|
|
720
|
-
metadata: this._options.metadata,
|
|
721
|
-
joinedAt: Date.now(),
|
|
722
|
-
isLocal: true,
|
|
723
|
-
};
|
|
724
|
-
this._log('Local user:', this._localUser.userId, '→', this._localUser.actorTokenId);
|
|
725
|
-
// Set up lobby for global presence
|
|
726
|
-
await this._setupLobby();
|
|
727
|
-
// Emit connected now that _localUser and lobby are ready
|
|
728
|
-
this.emit('connected');
|
|
729
|
-
// Auto-join documents specified in options
|
|
730
|
-
for (const docName of this._options.documents) {
|
|
731
|
-
this.joinDocument(docName);
|
|
732
|
-
}
|
|
733
|
-
// Deferred lobby refetch to catch users who joined during the setup window
|
|
734
|
-
setTimeout(() => {
|
|
735
|
-
if (this._lobby && this._client?.connected) {
|
|
736
|
-
this._lobby.fetchPresence().then((state) => {
|
|
737
|
-
this._hydrateOnlineUsers(state);
|
|
738
|
-
}).catch(() => { });
|
|
739
|
-
}
|
|
740
|
-
}, 2000);
|
|
786
|
+
ready() {
|
|
787
|
+
return this._readyPromise;
|
|
741
788
|
}
|
|
742
789
|
/**
|
|
743
|
-
*
|
|
790
|
+
* Detach from the client: remove every handler this wrapper added,
|
|
791
|
+
* unsubscribe its topics and lobby (when connected), clear state.
|
|
792
|
+
* Terminal and idempotent; never touches the socket. To use collab again,
|
|
793
|
+
* construct a new instance.
|
|
744
794
|
*/
|
|
745
|
-
|
|
746
|
-
this.
|
|
747
|
-
|
|
795
|
+
detach() {
|
|
796
|
+
if (this._detached)
|
|
797
|
+
return;
|
|
798
|
+
this._log('Detaching...');
|
|
799
|
+
this._detached = true;
|
|
800
|
+
this._epoch++; // aborts any in-flight setup at its next checkpoint
|
|
801
|
+
if (this._lobbyRefreshTimer) {
|
|
802
|
+
clearTimeout(this._lobbyRefreshTimer);
|
|
803
|
+
this._lobbyRefreshTimer = null;
|
|
804
|
+
}
|
|
805
|
+
// Remove all client handlers by stored ref
|
|
806
|
+
this._client.off('connect', this._onConnectRef);
|
|
807
|
+
this._client.off('disconnect', this._onDisconnectRef);
|
|
808
|
+
this._client.off('reconnect', this._onReconnectRef);
|
|
809
|
+
this._client.off('error', this._onErrorRef);
|
|
810
|
+
this._client.off('presence:join', this._onPresenceJoinRef);
|
|
811
|
+
this._client.off('presence:leave', this._onPresenceLeaveRef);
|
|
812
|
+
this._client.off('presence:update', this._onPresenceUpdateRef);
|
|
813
|
+
this._client.off('lobbyPresence:join', this._onLobbyJoinRef);
|
|
814
|
+
this._client.off('lobbyPresence:leave', this._onLobbyLeaveRef);
|
|
815
|
+
this._client.off('lobbyPresence:update', this._onLobbyUpdateRef);
|
|
816
|
+
// Documents: handler-specific off + connected-gated server unsubscribe.
|
|
817
|
+
// _cleanup also clears each document's cursor-throttle and idle timers.
|
|
748
818
|
for (const name of [...this._documents.keys()]) {
|
|
749
|
-
this.
|
|
819
|
+
this._documents.get(name)._cleanup();
|
|
820
|
+
this._documents.delete(name);
|
|
821
|
+
}
|
|
822
|
+
// Lobby: server unsubscribe is best-effort and needs a live socket
|
|
823
|
+
if (this._lobby && this._client.connected) {
|
|
824
|
+
try {
|
|
825
|
+
this._lobby.unsubscribe();
|
|
826
|
+
}
|
|
827
|
+
catch {
|
|
828
|
+
/* best-effort */
|
|
829
|
+
}
|
|
750
830
|
}
|
|
751
|
-
// Unsubscribe from lobby
|
|
752
|
-
this._lobby?.unsubscribe();
|
|
753
831
|
this._lobby = null;
|
|
754
|
-
// Disconnect client
|
|
755
|
-
this._client?.disconnect();
|
|
756
|
-
this._client = null;
|
|
757
|
-
// Clear state
|
|
758
832
|
this._onlineUsers.clear();
|
|
759
833
|
this._actorToUserId.clear();
|
|
760
834
|
this._localUser = null;
|
|
835
|
+
releaseWrapper(this._client, this._options.appName);
|
|
836
|
+
if (!this._isReady) {
|
|
837
|
+
this._readyReject(new Error('NoLagCollab detached before ready'));
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
// ============ Private: Epoch Setup ============
|
|
841
|
+
_onConnect() {
|
|
842
|
+
this._epoch++;
|
|
843
|
+
void this._runSetup(this._epoch);
|
|
844
|
+
}
|
|
845
|
+
/**
|
|
846
|
+
* One setup pass per connection epoch. Serves both initial setup (epoch 1)
|
|
847
|
+
* and reconnect restore (epoch > 1). Aborts silently whenever a newer
|
|
848
|
+
* epoch started or the wrapper detached — checked after every await.
|
|
849
|
+
*/
|
|
850
|
+
async _runSetup(epoch) {
|
|
851
|
+
const stale = () => epoch !== this._epoch || this._detached;
|
|
852
|
+
this._log(this._isReady ? 'Restoring after reconnect...' : 'Setting up...');
|
|
853
|
+
// Identity (client.actorId is guaranteed post-auth)
|
|
854
|
+
if (!this._localUser) {
|
|
855
|
+
this._localUser = {
|
|
856
|
+
userId: this._userId,
|
|
857
|
+
actorTokenId: this._client.actorId,
|
|
858
|
+
username: this._options.username,
|
|
859
|
+
avatar: this._options.avatar,
|
|
860
|
+
color: this._options.color,
|
|
861
|
+
status: 'active',
|
|
862
|
+
metadata: this._options.metadata,
|
|
863
|
+
joinedAt: Date.now(),
|
|
864
|
+
isLocal: true,
|
|
865
|
+
};
|
|
866
|
+
this._log('Local user:', this._localUser.userId, '→', this._localUser.actorTokenId);
|
|
867
|
+
}
|
|
868
|
+
else {
|
|
869
|
+
this._localUser.actorTokenId = this._client.actorId;
|
|
870
|
+
}
|
|
871
|
+
// Lobby: subscribe every epoch (idempotent server-side) and diff-hydrate
|
|
872
|
+
// from the returned snapshot — one path for setup and restore.
|
|
873
|
+
if (!this._lobby) {
|
|
874
|
+
this._lobby = this._client.setApp(this._options.appName).setLobby(LOBBY_ID);
|
|
875
|
+
}
|
|
876
|
+
try {
|
|
877
|
+
const state = await this._lobby.subscribe();
|
|
878
|
+
if (stale())
|
|
879
|
+
return;
|
|
880
|
+
this._diffHydrateOnlineUsers(state);
|
|
881
|
+
this._log('Lobby subscribed, online users:', this._onlineUsers.size);
|
|
882
|
+
}
|
|
883
|
+
catch (err) {
|
|
884
|
+
if (stale())
|
|
885
|
+
return;
|
|
886
|
+
this._log('Lobby subscription failed:', err);
|
|
887
|
+
}
|
|
888
|
+
if (!this._isReady) {
|
|
889
|
+
// First successful setup: auto-join configured documents
|
|
890
|
+
for (const name of this._options.documents) {
|
|
891
|
+
this._subscribeDocumentInternal(name);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
else {
|
|
895
|
+
// Server auto-restored topic subscriptions; only room-scoped presence
|
|
896
|
+
// needs re-applying (the core does not restore it).
|
|
897
|
+
for (const doc of this._documents.values()) {
|
|
898
|
+
doc._updateLocalPresence();
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
if (stale())
|
|
902
|
+
return;
|
|
903
|
+
// Ready keys on the first setup that COMPLETES, not on epoch 1: an
|
|
904
|
+
// epoch aborted by a racing reconnect must not strand ready().
|
|
905
|
+
if (!this._isReady) {
|
|
906
|
+
this._isReady = true;
|
|
907
|
+
this._readyResolve();
|
|
908
|
+
this.emit('connected');
|
|
909
|
+
}
|
|
910
|
+
else {
|
|
911
|
+
this.emit('reconnected');
|
|
912
|
+
}
|
|
913
|
+
// Deferred lobby refetch: catches users who joined during the setup
|
|
914
|
+
// window (e.g. simultaneous multi-tab connects).
|
|
915
|
+
this._scheduleLobbyRefresh(epoch);
|
|
916
|
+
}
|
|
917
|
+
_scheduleLobbyRefresh(epoch) {
|
|
918
|
+
if (this._lobbyRefreshTimer)
|
|
919
|
+
clearTimeout(this._lobbyRefreshTimer);
|
|
920
|
+
this._lobbyRefreshTimer = setTimeout(() => {
|
|
921
|
+
this._lobbyRefreshTimer = null;
|
|
922
|
+
if (epoch !== this._epoch || this._detached || !this._client.connected || !this._lobby) {
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
this._lobby
|
|
926
|
+
.fetchPresence()
|
|
927
|
+
.then((state) => {
|
|
928
|
+
if (epoch !== this._epoch || this._detached)
|
|
929
|
+
return;
|
|
930
|
+
this._diffHydrateOnlineUsers(state);
|
|
931
|
+
})
|
|
932
|
+
.catch(() => {
|
|
933
|
+
/* best-effort */
|
|
934
|
+
});
|
|
935
|
+
}, LOBBY_REFRESH_DELAY_MS);
|
|
761
936
|
}
|
|
762
937
|
// ============ Document Management ============
|
|
763
938
|
/**
|
|
@@ -765,12 +940,10 @@ class NoLagCollab extends EventEmitter {
|
|
|
765
940
|
* Returns an existing document if already joined.
|
|
766
941
|
*/
|
|
767
942
|
joinDocument(name) {
|
|
768
|
-
|
|
769
|
-
throw new Error('Not connected — call connect() first');
|
|
770
|
-
}
|
|
943
|
+
this._assertUsable();
|
|
771
944
|
let doc = this._documents.get(name);
|
|
772
945
|
if (!doc) {
|
|
773
|
-
doc = this.
|
|
946
|
+
doc = this._subscribeDocumentInternal(name);
|
|
774
947
|
doc._activate();
|
|
775
948
|
}
|
|
776
949
|
return doc;
|
|
@@ -799,24 +972,41 @@ class NoLagCollab extends EventEmitter {
|
|
|
799
972
|
getOnlineUsers() {
|
|
800
973
|
return Array.from(this._onlineUsers.values());
|
|
801
974
|
}
|
|
802
|
-
// ============ Private:
|
|
803
|
-
|
|
804
|
-
if (
|
|
805
|
-
throw new Error('
|
|
975
|
+
// ============ Private: Guards ============
|
|
976
|
+
_assertUsable() {
|
|
977
|
+
if (this._detached) {
|
|
978
|
+
throw new Error('NoLagCollab has been detached — construct a new instance');
|
|
979
|
+
}
|
|
980
|
+
if (!this._isReady || !this._localUser) {
|
|
981
|
+
throw new Error('NoLagCollab not ready — await ready() or the "connected" event');
|
|
806
982
|
}
|
|
983
|
+
}
|
|
984
|
+
// ============ Private: Document Setup ============
|
|
985
|
+
_subscribeDocumentInternal(name) {
|
|
807
986
|
this._log('Subscribing document:', name);
|
|
808
987
|
const roomContext = this._client.setApp(this._options.appName).setRoom(name);
|
|
809
|
-
const doc = new CollabDocument(name, roomContext, this._localUser, this._options, createLogger(`CollabDocument:${name}`, this._options.debug));
|
|
988
|
+
const doc = new CollabDocument(name, roomContext, this._localUser, this._options, createLogger(`CollabDocument:${name}`, this._options.debug), () => this._client.connected);
|
|
810
989
|
this._documents.set(name, doc);
|
|
811
990
|
doc._subscribe();
|
|
812
991
|
return doc;
|
|
813
992
|
}
|
|
993
|
+
// ============ Private: Scope Filtering ============
|
|
994
|
+
/**
|
|
995
|
+
* On a shared client, presence events from other apps' wrappers arrive on
|
|
996
|
+
* the same connection-level events. Wrappers stamp their presence with a
|
|
997
|
+
* `__scope` (their appName); a mismatched tag means another app's data.
|
|
998
|
+
* Untagged presence is accepted (older peers in this same app).
|
|
999
|
+
*/
|
|
1000
|
+
_foreignScope(data) {
|
|
1001
|
+
const scope = data?.__scope;
|
|
1002
|
+
return typeof scope === 'string' && scope !== this._options.appName;
|
|
1003
|
+
}
|
|
814
1004
|
// ============ Private: Room Presence ============
|
|
815
1005
|
_handleRoomPresenceJoin(data) {
|
|
816
1006
|
if (data.actorTokenId === this._localUser?.actorTokenId)
|
|
817
1007
|
return;
|
|
818
1008
|
const presenceData = data.presence;
|
|
819
|
-
if (!presenceData?.userId)
|
|
1009
|
+
if (!presenceData?.userId || this._foreignScope(presenceData))
|
|
820
1010
|
return;
|
|
821
1011
|
const user = this._presenceToUser(data.actorTokenId, presenceData);
|
|
822
1012
|
this._actorToUserId.set(data.actorTokenId, user.userId);
|
|
@@ -841,7 +1031,7 @@ class NoLagCollab extends EventEmitter {
|
|
|
841
1031
|
if (data.actorTokenId === this._localUser?.actorTokenId)
|
|
842
1032
|
return;
|
|
843
1033
|
const presenceData = data.presence;
|
|
844
|
-
if (!presenceData?.userId)
|
|
1034
|
+
if (!presenceData?.userId || this._foreignScope(presenceData))
|
|
845
1035
|
return;
|
|
846
1036
|
if (this._onlineUsers.has(presenceData.userId)) {
|
|
847
1037
|
const user = this._presenceToUser(data.actorTokenId, presenceData);
|
|
@@ -853,37 +1043,12 @@ class NoLagCollab extends EventEmitter {
|
|
|
853
1043
|
}
|
|
854
1044
|
}
|
|
855
1045
|
// ============ Private: Lobby ============
|
|
856
|
-
async _setupLobby() {
|
|
857
|
-
if (!this._client)
|
|
858
|
-
return;
|
|
859
|
-
this._lobby = this._client.setApp(this._options.appName).setLobby(LOBBY_ID);
|
|
860
|
-
const lobbyHandler = (type) => (data) => {
|
|
861
|
-
const event = data;
|
|
862
|
-
if (type === 'join')
|
|
863
|
-
this._handleLobbyJoin(event);
|
|
864
|
-
else if (type === 'leave')
|
|
865
|
-
this._handleLobbyLeave(event);
|
|
866
|
-
else
|
|
867
|
-
this._handleLobbyUpdate(event);
|
|
868
|
-
};
|
|
869
|
-
this._client.on('lobbyPresence:join', lobbyHandler('join'));
|
|
870
|
-
this._client.on('lobbyPresence:leave', lobbyHandler('leave'));
|
|
871
|
-
this._client.on('lobbyPresence:update', lobbyHandler('update'));
|
|
872
|
-
try {
|
|
873
|
-
const initialState = await this._lobby.subscribe();
|
|
874
|
-
this._hydrateOnlineUsers(initialState);
|
|
875
|
-
this._log('Lobby subscribed, online users:', this._onlineUsers.size);
|
|
876
|
-
}
|
|
877
|
-
catch (err) {
|
|
878
|
-
this._log('Lobby subscription failed:', err);
|
|
879
|
-
}
|
|
880
|
-
}
|
|
881
1046
|
_handleLobbyJoin(event) {
|
|
882
1047
|
const { actorId, data } = event;
|
|
883
1048
|
if (actorId === this._localUser?.actorTokenId)
|
|
884
1049
|
return;
|
|
885
1050
|
const presenceData = data;
|
|
886
|
-
if (!presenceData.userId)
|
|
1051
|
+
if (!presenceData.userId || this._foreignScope(presenceData))
|
|
887
1052
|
return;
|
|
888
1053
|
const user = this._presenceToUser(actorId, presenceData);
|
|
889
1054
|
this._actorToUserId.set(actorId, user.userId);
|
|
@@ -897,6 +1062,8 @@ class NoLagCollab extends EventEmitter {
|
|
|
897
1062
|
if (actorId === this._localUser?.actorTokenId)
|
|
898
1063
|
return;
|
|
899
1064
|
const presenceData = data;
|
|
1065
|
+
if (this._foreignScope(presenceData))
|
|
1066
|
+
return;
|
|
900
1067
|
const userId = presenceData?.userId
|
|
901
1068
|
|| this._actorToUserId.get(actorId)
|
|
902
1069
|
|| this._findUserIdByActorId(actorId);
|
|
@@ -914,29 +1081,57 @@ class NoLagCollab extends EventEmitter {
|
|
|
914
1081
|
if (actorId === this._localUser?.actorTokenId)
|
|
915
1082
|
return;
|
|
916
1083
|
const presenceData = data;
|
|
917
|
-
if (!presenceData.userId)
|
|
1084
|
+
if (!presenceData.userId || this._foreignScope(presenceData))
|
|
918
1085
|
return;
|
|
919
1086
|
const user = this._presenceToUser(actorId, presenceData);
|
|
920
1087
|
this._onlineUsers.set(user.userId, user);
|
|
921
1088
|
}
|
|
922
|
-
|
|
1089
|
+
/**
|
|
1090
|
+
* Reconcile the online-user map against a fresh lobby snapshot, emitting
|
|
1091
|
+
* only the deltas (userOffline for vanished, userOnline for new). One path
|
|
1092
|
+
* for initial hydration, reconnect restore, and the deferred refetch.
|
|
1093
|
+
*/
|
|
1094
|
+
_diffHydrateOnlineUsers(state) {
|
|
1095
|
+
// Build the fresh user set from the snapshot
|
|
1096
|
+
const fresh = new Map();
|
|
1097
|
+
const freshActors = new Map();
|
|
923
1098
|
for (const roomId of Object.keys(state)) {
|
|
924
1099
|
const roomPresence = state[roomId];
|
|
925
1100
|
for (const actorId of Object.keys(roomPresence)) {
|
|
926
1101
|
if (actorId === this._localUser?.actorTokenId)
|
|
927
1102
|
continue;
|
|
928
1103
|
const raw = roomPresence[actorId];
|
|
1104
|
+
// Server returns full actor records with presence nested under .presence
|
|
929
1105
|
const presenceData = (raw?.presence ?? raw);
|
|
930
|
-
if (presenceData?.userId) {
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
if (!this._onlineUsers.has(user.userId)) {
|
|
934
|
-
this._onlineUsers.set(user.userId, user);
|
|
935
|
-
this.emit('userOnline', user);
|
|
1106
|
+
if (presenceData?.userId && !this._foreignScope(presenceData)) {
|
|
1107
|
+
if (!fresh.has(presenceData.userId)) {
|
|
1108
|
+
fresh.set(presenceData.userId, this._presenceToUser(actorId, presenceData));
|
|
936
1109
|
}
|
|
1110
|
+
freshActors.set(actorId, presenceData.userId);
|
|
937
1111
|
}
|
|
938
1112
|
}
|
|
939
1113
|
}
|
|
1114
|
+
// Vanished users
|
|
1115
|
+
for (const [userId, user] of [...this._onlineUsers]) {
|
|
1116
|
+
if (!fresh.has(userId)) {
|
|
1117
|
+
this._onlineUsers.delete(userId);
|
|
1118
|
+
for (const [actorId, mappedUserId] of [...this._actorToUserId]) {
|
|
1119
|
+
if (mappedUserId === userId)
|
|
1120
|
+
this._actorToUserId.delete(actorId);
|
|
1121
|
+
}
|
|
1122
|
+
this.emit('userOffline', user);
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
// New users
|
|
1126
|
+
for (const [userId, user] of fresh) {
|
|
1127
|
+
if (!this._onlineUsers.has(userId)) {
|
|
1128
|
+
this._onlineUsers.set(userId, user);
|
|
1129
|
+
this.emit('userOnline', user);
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
for (const [actorId, userId] of freshActors) {
|
|
1133
|
+
this._actorToUserId.set(actorId, userId);
|
|
1134
|
+
}
|
|
940
1135
|
}
|
|
941
1136
|
// ============ Private: Helpers ============
|
|
942
1137
|
_presenceToUser(actorTokenId, data) {
|
|
@@ -959,21 +1154,6 @@ class NoLagCollab extends EventEmitter {
|
|
|
959
1154
|
}
|
|
960
1155
|
return undefined;
|
|
961
1156
|
}
|
|
962
|
-
_restoreDocuments() {
|
|
963
|
-
// On reconnect, js-sdk auto-restores subscriptions.
|
|
964
|
-
// Re-set presence on all active documents.
|
|
965
|
-
for (const doc of this._documents.values()) {
|
|
966
|
-
doc._updateLocalPresence();
|
|
967
|
-
}
|
|
968
|
-
// Re-fetch lobby presence
|
|
969
|
-
this._lobby?.fetchPresence().then((state) => {
|
|
970
|
-
this._onlineUsers.clear();
|
|
971
|
-
this._actorToUserId.clear();
|
|
972
|
-
this._hydrateOnlineUsers(state);
|
|
973
|
-
}).catch((err) => {
|
|
974
|
-
this._log('Failed to re-fetch lobby presence:', err);
|
|
975
|
-
});
|
|
976
|
-
}
|
|
977
1157
|
}
|
|
978
1158
|
|
|
979
1159
|
export { CollabDocument, EventEmitter, NoLagCollab };
|