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