@nolag/collab 0.1.2 → 1.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/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
- this._roomContext.on(TOPIC_OPERATIONS, (data) => {
479
+ // Listen for operations (refs stored for handler-specific removal)
480
+ this._onOperationsRef = (data) => {
452
481
  this._handleIncomingOperation(data);
453
- });
454
- this._roomContext.on(TOPIC_CURSORS, (data) => {
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._roomContext.unsubscribe(TOPIC_OPERATIONS);
533
- this._roomContext.unsubscribe(TOPIC_CURSORS);
534
- this._roomContext.off(TOPIC_OPERATIONS);
535
- this._roomContext.off(TOPIC_CURSORS);
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 collab = new NoLagCollab(token, { username: 'Alice', debug: true });
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 collab.connect();
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(token, options) {
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
- this._token = token;
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?.connected ?? false;
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 connect) */
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
- * Connect to NoLag and set up global presence.
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
- async connect() {
671
- this._log('Connecting...');
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
- * Disconnect from NoLag and clean up all documents.
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
- disconnect() {
746
- this._log('Disconnecting...');
747
- // Clean up documents
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.leaveDocument(name);
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
- if (!this._client || !this._localUser) {
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._subscribeDocument(name);
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: Document Setup ============
803
- _subscribeDocument(name) {
804
- if (!this._client || !this._localUser) {
805
- throw new Error('Not connectedcall connect() first');
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
- _hydrateOnlineUsers(state) {
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
- const user = this._presenceToUser(actorId, presenceData);
932
- this._actorToUserId.set(actorId, user.userId);
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 };