@nolag/notify 0.1.3 → 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/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
  */
@@ -150,6 +148,29 @@ function createLogger(prefix, enabled) {
150
148
  console.log(`[${prefix}]`, ...args);
151
149
  };
152
150
  }
151
+ // ============ Wrapper registry ============
152
+ // One wrapper instance per (client, appName): two wrappers sharing an app on
153
+ // one connection would collide on topics, presence and the online lobby.
154
+ // Warn (not throw): HMR and tests legitimately construct before disposing.
155
+ const wrapperRegistry = new WeakMap();
156
+ /** Register a wrapper against a client + appName; warns on collision. */
157
+ function registerWrapper(client, appName, wrapperName) {
158
+ let apps = wrapperRegistry.get(client);
159
+ if (!apps) {
160
+ apps = new Map();
161
+ wrapperRegistry.set(client, apps);
162
+ }
163
+ const existing = apps.get(appName);
164
+ if (existing) {
165
+ console.warn(`[${wrapperName}] Another wrapper (${existing}) is already attached to this client for app "${appName}". ` +
166
+ `Use one wrapper per (client, app) — detach the other instance first.`);
167
+ }
168
+ apps.set(appName, wrapperName);
169
+ }
170
+ /** Release a wrapper's (client, appName) registration on detach. */
171
+ function releaseWrapper(client, appName) {
172
+ wrapperRegistry.get(client)?.delete(appName);
173
+ }
153
174
 
154
175
  /** Default app name for channel topic prefixes */
155
176
  const DEFAULT_APP_NAME = 'notify';
@@ -161,6 +182,8 @@ const TOPIC_NOTIFICATIONS = 'notifications';
161
182
  const TOPIC_READ = '_read';
162
183
  /** Lobby ID for global online presence */
163
184
  const LOBBY_ID = 'online';
185
+ /** Delay before the post-setup lobby presence refetch (catches simultaneous joiners) */
186
+ const LOBBY_REFRESH_DELAY_MS = 2000;
164
187
 
165
188
  /**
166
189
  * NotifyChannel — a single notification channel with read/unread tracking.
@@ -169,14 +192,19 @@ const LOBBY_ID = 'online';
169
192
  */
170
193
  class NotifyChannel extends EventEmitter {
171
194
  /** @internal */
172
- constructor(name, roomContext, options, log) {
195
+ constructor(name, roomContext, options, log, isConnected) {
173
196
  super();
174
197
  this._active = false;
198
+ // Stored topic handler refs — cleanup removes exactly these, never all
199
+ // handlers for a topic (the client may be shared with other consumers).
200
+ this._onNotificationsRef = null;
201
+ this._onReadRef = null;
175
202
  this.name = name;
176
203
  this._roomContext = roomContext;
177
204
  this._options = options;
178
205
  this._store = new NotificationStore(options.maxNotificationCache);
179
206
  this._log = log;
207
+ this._isConnected = isConnected;
180
208
  }
181
209
  // ============ Public Properties ============
182
210
  /** All notifications in this channel (timestamp order) */
@@ -253,12 +281,15 @@ class NotifyChannel extends EventEmitter {
253
281
  this._log('Channel subscribe:', this.name);
254
282
  this._roomContext.subscribe(TOPIC_NOTIFICATIONS);
255
283
  this._roomContext.subscribe(TOPIC_READ);
256
- this._roomContext.on(TOPIC_NOTIFICATIONS, (data, meta) => {
284
+ // Listen for notifications (refs stored for handler-specific removal)
285
+ this._onNotificationsRef = (data, meta) => {
257
286
  this._handleIncomingNotification(data, meta);
258
- });
259
- this._roomContext.on(TOPIC_READ, (data) => {
287
+ };
288
+ this._roomContext.on(TOPIC_NOTIFICATIONS, this._onNotificationsRef);
289
+ this._onReadRef = (data) => {
260
290
  this._handleIncomingRead(data);
261
- });
291
+ };
292
+ this._roomContext.on(TOPIC_READ, this._onReadRef);
262
293
  }
263
294
  /** @internal Activate this channel (mark as visible/active) */
264
295
  _activate() {
@@ -281,10 +312,20 @@ class NotifyChannel extends EventEmitter {
281
312
  /** @internal Unsubscribe and clean up */
282
313
  _cleanup() {
283
314
  this._log('Channel cleanup:', this.name);
284
- this._roomContext.unsubscribe(TOPIC_NOTIFICATIONS);
285
- this._roomContext.unsubscribe(TOPIC_READ);
286
- this._roomContext.off(TOPIC_NOTIFICATIONS);
287
- this._roomContext.off(TOPIC_READ);
315
+ // Server unsubscribes need a live socket; skip when disconnected
316
+ // (best-effort — the core would no-op with an error callback anyway).
317
+ if (this._isConnected()) {
318
+ this._roomContext.unsubscribe(TOPIC_NOTIFICATIONS);
319
+ this._roomContext.unsubscribe(TOPIC_READ);
320
+ }
321
+ // Handler-specific removal only: the client may be shared, and a bare
322
+ // off(topic) would strip other consumers' handlers too.
323
+ if (this._onNotificationsRef)
324
+ this._roomContext.off(TOPIC_NOTIFICATIONS, this._onNotificationsRef);
325
+ if (this._onReadRef)
326
+ this._roomContext.off(TOPIC_READ, this._onReadRef);
327
+ this._onNotificationsRef = null;
328
+ this._onReadRef = null;
288
329
  this._store.clear();
289
330
  this.removeAllListeners();
290
331
  }
@@ -434,47 +475,127 @@ class PresenceManager {
434
475
  * Provides multi-channel notifications, read/unread tracking, badge counts,
435
476
  * message replay, and global presence — all framework-agnostic via events.
436
477
  *
478
+ * The wrapper NEVER manages the connection. The app owns one core NoLag
479
+ * client (shared by any number of wrappers on distinct apps) and the
480
+ * wrapper attaches to it at construction and releases it via `detach()`.
481
+ *
437
482
  * @example
438
483
  * ```typescript
484
+ * import { NoLag } from '@nolag/js-sdk';
439
485
  * import { NoLagNotify } from '@nolag/notify';
440
486
  *
441
- * const notify = new NoLagNotify(token);
487
+ * const client = NoLag(async () => (await (await fetch('/api/nolag-token')).json()).token);
488
+ * const notify = new NoLagNotify({ client, appName: 'my-notify' });
442
489
  *
443
- * notify.on('connected', () => console.log('Connected!'));
444
490
  * notify.on('notification', (n) => console.log('New notification:', n.title));
445
491
  *
446
- * await notify.connect();
492
+ * await client.connect(); // the app owns the connection
493
+ * await notify.ready(); // wrapper setup done (identity, lobby, channels)
447
494
  *
448
495
  * const alerts = notify.subscribe('alerts');
449
496
  * alerts.on('notification', (n) => console.log(n.title));
497
+ *
498
+ * notify.detach(); // wrapper releases its handlers and topics
499
+ * client.disconnect(); // the app closes the socket
450
500
  * ```
451
501
  */
452
502
  class NoLagNotify extends EventEmitter {
453
- constructor(token, options = {}) {
503
+ constructor(options) {
454
504
  super();
455
- this._client = null;
456
505
  this._channels = new Map();
457
506
  this._lobby = null;
458
507
  this._badgeManager = new BadgeManager();
459
508
  this._presenceManager = new PresenceManager();
460
509
  this._actorToUserId = new Map();
461
- this._token = token;
510
+ // Lifecycle: one setup run per connection epoch; detach is terminal.
511
+ this._epoch = 0;
512
+ this._detached = false;
513
+ this._isReady = false;
514
+ this._lobbyRefreshTimer = null;
515
+ // Stored client handler refs. INVARIANT: every client.on() below has a
516
+ // matching client.off() in detach() — never bare off(event), never inline
517
+ // closures on the client.
518
+ this._onConnectRef = () => this._onConnect();
519
+ this._onDisconnectRef = (reason) => {
520
+ this._log('Disconnected:', reason);
521
+ this.emit('disconnected', reason);
522
+ };
523
+ this._onReconnectRef = () => {
524
+ this._log('Reconnecting...');
525
+ this.emit('reconnecting');
526
+ };
527
+ this._onErrorRef = (error) => {
528
+ this._log('Error:', error);
529
+ this.emit('error', error);
530
+ };
531
+ this._onReplayStartRef = (data) => {
532
+ const event = data;
533
+ for (const channel of this._channels.values()) {
534
+ channel._handleReplayStart(event.count);
535
+ }
536
+ };
537
+ this._onReplayEndRef = (data) => {
538
+ const event = data;
539
+ for (const channel of this._channels.values()) {
540
+ channel._handleReplayEnd(event.replayed);
541
+ }
542
+ };
543
+ this._onPresenceJoinRef = (data) => this._handleRoomPresenceJoin(data);
544
+ this._onPresenceLeaveRef = (data) => this._handleRoomPresenceLeave(data);
545
+ this._onPresenceUpdateRef = (data) => this._handleRoomPresenceUpdate(data);
546
+ this._onLobbyJoinRef = (data) => this._handleLobbyJoin(data);
547
+ this._onLobbyLeaveRef = (data) => this._handleLobbyLeave(data);
548
+ this._onLobbyUpdateRef = (data) => this._handleLobbyUpdate(data);
549
+ if (!options?.client) {
550
+ throw new TypeError('NoLagNotify requires an injected NoLag client: new NoLagNotify({ client, ... })');
551
+ }
552
+ this._client = options.client;
462
553
  this._userId = generateId();
463
554
  this._options = {
464
555
  metadata: options.metadata,
465
556
  appName: options.appName ?? DEFAULT_APP_NAME,
466
- url: options.url,
467
557
  maxNotificationCache: options.maxNotificationCache ?? DEFAULT_MAX_NOTIFICATION_CACHE,
468
558
  debug: options.debug ?? false,
469
- reconnect: options.reconnect ?? true,
470
559
  channels: options.channels ?? [],
471
560
  };
472
561
  this._log = createLogger('NoLagNotify', this._options.debug);
562
+ this._readyPromise = new Promise((resolve, reject) => {
563
+ this._readyResolve = resolve;
564
+ this._readyReject = reject;
565
+ });
566
+ // ready() rejection is only meaningful to callers that await it
567
+ this._readyPromise.catch(() => { });
568
+ registerWrapper(this._client, this._options.appName, 'NoLagNotify');
569
+ // Construction = attach: wire everything now, with stored refs.
570
+ this._client.on('connect', this._onConnectRef);
571
+ this._client.on('disconnect', this._onDisconnectRef);
572
+ this._client.on('reconnect', this._onReconnectRef);
573
+ this._client.on('error', this._onErrorRef);
574
+ this._client.on('replay:start', this._onReplayStartRef);
575
+ this._client.on('replay:end', this._onReplayEndRef);
576
+ this._client.on('presence:join', this._onPresenceJoinRef);
577
+ this._client.on('presence:leave', this._onPresenceLeaveRef);
578
+ this._client.on('presence:update', this._onPresenceUpdateRef);
579
+ this._client.on('lobbyPresence:join', this._onLobbyJoinRef);
580
+ this._client.on('lobbyPresence:leave', this._onLobbyLeaveRef);
581
+ this._client.on('lobbyPresence:update', this._onLobbyUpdateRef);
582
+ // Attach-to-connected: if the client is already authenticated, run setup.
583
+ // The microtask lets the caller wire wrapper event handlers synchronously
584
+ // first; a racing real 'connect' event wins via the epoch guard.
585
+ queueMicrotask(() => {
586
+ if (this._epoch === 0 && !this._detached && this._client.connected) {
587
+ this._onConnect();
588
+ }
589
+ });
473
590
  }
474
591
  // ============ Public Properties ============
475
- /** Whether the underlying connection is established */
592
+ /** Whether the underlying connection is established (connected ≠ ready) */
476
593
  get connected() {
477
- return this._client?.connected ?? false;
594
+ return !this._detached && this._client.connected;
595
+ }
596
+ /** The injected core client (owned by the app, not the wrapper) */
597
+ get client() {
598
+ return this._client;
478
599
  }
479
600
  /** All currently subscribed channels */
480
601
  get channels() {
@@ -482,96 +603,139 @@ class NoLagNotify extends EventEmitter {
482
603
  }
483
604
  // ============ Lifecycle ============
484
605
  /**
485
- * Connect to NoLag and set up global presence.
606
+ * Resolves once the wrapper's first setup completed (identity, lobby and
607
+ * configured channels ready — equivalently, once 'connected' has fired).
608
+ * Rejects only if detach() is called before that. Client auth failures
609
+ * surface via the app's own `await client.connect()`, not here.
486
610
  */
487
- async connect() {
488
- this._log('Connecting...');
489
- const clientOptions = {
490
- debug: this._options.debug,
491
- reconnect: this._options.reconnect,
492
- };
493
- if (this._options.url) {
494
- clientOptions.url = this._options.url;
495
- }
496
- this._client = jsSdk.NoLag(this._token, clientOptions);
497
- // Wire client lifecycle events
498
- this._client.on('connect', () => {
499
- this._log('Connected');
500
- if (this._channels.size > 0) {
501
- this._log('Reconnected — restoring channels...');
502
- this._restoreChannels();
503
- this.emit('reconnected');
504
- }
505
- });
506
- this._client.on('disconnect', (reason) => {
507
- this._log('Disconnected:', reason);
508
- this.emit('disconnected', reason);
509
- });
510
- this._client.on('reconnect', () => {
511
- this._log('Reconnecting...');
512
- });
513
- this._client.on('error', (error) => {
514
- this._log('Error:', error);
515
- this.emit('error', error);
516
- });
517
- // Wire replay events
518
- this._client.on('replay:start', (data) => {
519
- const event = data;
520
- for (const channel of this._channels.values()) {
521
- channel._handleReplayStart(event.count);
522
- }
523
- });
524
- this._client.on('replay:end', (data) => {
525
- const event = data;
526
- for (const channel of this._channels.values()) {
527
- channel._handleReplayEnd(event.replayed);
528
- }
529
- });
530
- // Connect
531
- await this._client.connect();
532
- // Wire room-level presence events
533
- this._client.on('presence:join', (data) => {
534
- this._handleRoomPresenceJoin(data);
535
- });
536
- this._client.on('presence:leave', (data) => {
537
- this._handleRoomPresenceLeave(data);
538
- });
539
- this._client.on('presence:update', (data) => {
540
- this._handleRoomPresenceUpdate(data);
541
- });
542
- this._log('Local userId:', this._userId, '→ actorId:', this._client.actorId);
543
- // Set up lobby for global presence
544
- await this._setupLobby();
545
- // Pre-subscribe to all configured channels
546
- for (const channelName of this._options.channels) {
547
- this._subscribeChannel(channelName);
548
- }
549
- // Emit connected now that lobby is ready
550
- this.emit('connected');
551
- // Deferred lobby refetch to catch late-joining users
552
- setTimeout(() => {
553
- if (this._lobby && this._client?.connected) {
554
- this._lobby.fetchPresence().then((state) => {
555
- this._hydratePresence(state);
556
- }).catch(() => { });
557
- }
558
- }, 2000);
611
+ ready() {
612
+ return this._readyPromise;
559
613
  }
560
614
  /**
561
- * Disconnect from NoLag and clean up all channels.
615
+ * Detach from the client: remove every handler this wrapper added,
616
+ * unsubscribe its topics and lobby (when connected), clear state.
617
+ * Terminal and idempotent; never touches the socket. To use notify again,
618
+ * construct a new instance.
562
619
  */
563
- disconnect() {
564
- this._log('Disconnecting...');
620
+ detach() {
621
+ if (this._detached)
622
+ return;
623
+ this._log('Detaching...');
624
+ this._detached = true;
625
+ this._epoch++; // aborts any in-flight setup at its next checkpoint
626
+ if (this._lobbyRefreshTimer) {
627
+ clearTimeout(this._lobbyRefreshTimer);
628
+ this._lobbyRefreshTimer = null;
629
+ }
630
+ // Remove all client handlers by stored ref
631
+ this._client.off('connect', this._onConnectRef);
632
+ this._client.off('disconnect', this._onDisconnectRef);
633
+ this._client.off('reconnect', this._onReconnectRef);
634
+ this._client.off('error', this._onErrorRef);
635
+ this._client.off('replay:start', this._onReplayStartRef);
636
+ this._client.off('replay:end', this._onReplayEndRef);
637
+ this._client.off('presence:join', this._onPresenceJoinRef);
638
+ this._client.off('presence:leave', this._onPresenceLeaveRef);
639
+ this._client.off('presence:update', this._onPresenceUpdateRef);
640
+ this._client.off('lobbyPresence:join', this._onLobbyJoinRef);
641
+ this._client.off('lobbyPresence:leave', this._onLobbyLeaveRef);
642
+ this._client.off('lobbyPresence:update', this._onLobbyUpdateRef);
643
+ // Channels: handler-specific off + connected-gated server unsubscribe
565
644
  for (const name of [...this._channels.keys()]) {
566
- this.unsubscribe(name);
645
+ this._channels.get(name)._cleanup();
646
+ this._channels.delete(name);
647
+ }
648
+ // Lobby: server unsubscribe is best-effort and needs a live socket
649
+ if (this._lobby && this._client.connected) {
650
+ try {
651
+ this._lobby.unsubscribe();
652
+ }
653
+ catch {
654
+ /* best-effort */
655
+ }
567
656
  }
568
- this._lobby?.unsubscribe();
569
657
  this._lobby = null;
570
- this._client?.disconnect();
571
- this._client = null;
572
658
  this._badgeManager.clear();
573
659
  this._presenceManager.clear();
574
660
  this._actorToUserId.clear();
661
+ releaseWrapper(this._client, this._options.appName);
662
+ if (!this._isReady) {
663
+ this._readyReject(new Error('NoLagNotify detached before ready'));
664
+ }
665
+ }
666
+ // ============ Private: Epoch Setup ============
667
+ _onConnect() {
668
+ this._epoch++;
669
+ void this._runSetup(this._epoch);
670
+ }
671
+ /**
672
+ * One setup pass per connection epoch. Serves both initial setup (epoch 1)
673
+ * and reconnect restore (epoch > 1). Aborts silently whenever a newer
674
+ * epoch started or the wrapper detached — checked after every await.
675
+ */
676
+ async _runSetup(epoch) {
677
+ const stale = () => epoch !== this._epoch || this._detached;
678
+ this._log(this._isReady ? 'Restoring after reconnect...' : 'Setting up...');
679
+ this._log('Local userId:', this._userId, '→ actorId:', this._client.actorId);
680
+ // Lobby: subscribe every epoch (idempotent server-side) and diff-hydrate
681
+ // from the returned snapshot — one path for setup and restore.
682
+ if (!this._lobby) {
683
+ this._lobby = this._client.setApp(this._options.appName).setLobby(LOBBY_ID);
684
+ }
685
+ try {
686
+ const state = await this._lobby.subscribe();
687
+ if (stale())
688
+ return;
689
+ this._diffHydratePresence(state);
690
+ this._log('Lobby subscribed');
691
+ }
692
+ catch (err) {
693
+ if (stale())
694
+ return;
695
+ this._log('Lobby subscription failed:', err);
696
+ }
697
+ // First successful setup: pre-subscribe configured channels. The core
698
+ // auto-restores topic subscriptions on reconnect, so later epochs skip it.
699
+ if (!this._isReady) {
700
+ for (const channelName of this._options.channels) {
701
+ this._subscribeChannel(channelName);
702
+ }
703
+ }
704
+ if (stale())
705
+ return;
706
+ // Ready keys on the first setup that COMPLETES, not on epoch 1: an
707
+ // epoch aborted by a racing reconnect must not strand ready().
708
+ if (!this._isReady) {
709
+ this._isReady = true;
710
+ this._readyResolve();
711
+ this.emit('connected');
712
+ }
713
+ else {
714
+ this.emit('reconnected');
715
+ }
716
+ // Deferred lobby refetch: catches users who joined during the setup
717
+ // window (e.g. simultaneous multi-tab connects).
718
+ this._scheduleLobbyRefresh(epoch);
719
+ }
720
+ _scheduleLobbyRefresh(epoch) {
721
+ if (this._lobbyRefreshTimer)
722
+ clearTimeout(this._lobbyRefreshTimer);
723
+ this._lobbyRefreshTimer = setTimeout(() => {
724
+ this._lobbyRefreshTimer = null;
725
+ if (epoch !== this._epoch || this._detached || !this._client.connected || !this._lobby) {
726
+ return;
727
+ }
728
+ this._lobby
729
+ .fetchPresence()
730
+ .then((state) => {
731
+ if (epoch !== this._epoch || this._detached)
732
+ return;
733
+ this._diffHydratePresence(state);
734
+ })
735
+ .catch(() => {
736
+ /* best-effort */
737
+ });
738
+ }, LOBBY_REFRESH_DELAY_MS);
575
739
  }
576
740
  // ============ Channel Management ============
577
741
  /**
@@ -579,9 +743,7 @@ class NoLagNotify extends EventEmitter {
579
743
  * Returns the NotifyChannel instance.
580
744
  */
581
745
  subscribe(channelName) {
582
- if (!this._client) {
583
- throw new Error('Not connected — call connect() first');
584
- }
746
+ this._assertUsable();
585
747
  const existing = this._channels.get(channelName);
586
748
  if (existing)
587
749
  return existing;
@@ -618,14 +780,20 @@ class NoLagNotify extends EventEmitter {
618
780
  channel.markAllRead();
619
781
  }
620
782
  }
783
+ // ============ Private: Guards ============
784
+ _assertUsable() {
785
+ if (this._detached) {
786
+ throw new Error('NoLagNotify has been detached — construct a new instance');
787
+ }
788
+ if (!this._isReady) {
789
+ throw new Error('NoLagNotify not ready — await ready() or the "connected" event');
790
+ }
791
+ }
621
792
  // ============ Private: Channel Setup ============
622
793
  _subscribeChannel(name) {
623
- if (!this._client) {
624
- throw new Error('Not connected — call connect() first');
625
- }
626
794
  this._log('Subscribing channel:', name);
627
795
  const roomContext = this._client.setApp(this._options.appName).setRoom(name);
628
- const channel = new NotifyChannel(name, roomContext, this._options, createLogger(`NotifyChannel:${name}`, this._options.debug));
796
+ const channel = new NotifyChannel(name, roomContext, this._options, createLogger(`NotifyChannel:${name}`, this._options.debug), () => this._client.connected);
629
797
  this._channels.set(name, channel);
630
798
  channel._subscribe();
631
799
  // Relay notifications up to the main client and update badges
@@ -647,12 +815,23 @@ class NoLagNotify extends EventEmitter {
647
815
  _emitBadgeUpdated() {
648
816
  this.emit('badgeUpdated', this._badgeManager.getAll());
649
817
  }
818
+ // ============ Private: Scope Filtering ============
819
+ /**
820
+ * On a shared client, presence events from other apps' wrappers arrive on
821
+ * the same connection-level events. Wrappers stamp their presence with a
822
+ * `__scope` (their appName); a mismatched tag means another app's data.
823
+ * Untagged presence is accepted (older peers in this same app).
824
+ */
825
+ _foreignScope(data) {
826
+ const scope = data?.__scope;
827
+ return typeof scope === 'string' && scope !== this._options.appName;
828
+ }
650
829
  // ============ Private: Room Presence ============
651
830
  _handleRoomPresenceJoin(data) {
652
- if (data.actorTokenId === this._client?.actorId)
831
+ if (data.actorTokenId === this._client.actorId)
653
832
  return;
654
833
  const presenceData = data.presence;
655
- if (!presenceData?.userId)
834
+ if (!presenceData?.userId || this._foreignScope(presenceData))
656
835
  return;
657
836
  const user = this._presenceManager.addFromPresence(data.actorTokenId, presenceData);
658
837
  if (user) {
@@ -660,50 +839,25 @@ class NoLagNotify extends EventEmitter {
660
839
  }
661
840
  }
662
841
  _handleRoomPresenceLeave(data) {
663
- if (data.actorTokenId === this._client?.actorId)
842
+ if (data.actorTokenId === this._client.actorId)
664
843
  return;
665
844
  this._presenceManager.removeByActorId(data.actorTokenId);
666
845
  }
667
846
  _handleRoomPresenceUpdate(data) {
668
- if (data.actorTokenId === this._client?.actorId)
847
+ if (data.actorTokenId === this._client.actorId)
669
848
  return;
670
849
  const presenceData = data.presence;
671
- if (!presenceData?.userId)
850
+ if (!presenceData?.userId || this._foreignScope(presenceData))
672
851
  return;
673
852
  this._presenceManager.addFromPresence(data.actorTokenId, presenceData);
674
853
  }
675
854
  // ============ Private: Lobby ============
676
- async _setupLobby() {
677
- if (!this._client)
678
- return;
679
- this._lobby = this._client.setApp(this._options.appName).setLobby(LOBBY_ID);
680
- const lobbyHandler = (type) => (data) => {
681
- const event = data;
682
- if (type === 'join')
683
- this._handleLobbyJoin(event);
684
- else if (type === 'leave')
685
- this._handleLobbyLeave(event);
686
- else
687
- this._handleLobbyUpdate(event);
688
- };
689
- this._client.on('lobbyPresence:join', lobbyHandler('join'));
690
- this._client.on('lobbyPresence:leave', lobbyHandler('leave'));
691
- this._client.on('lobbyPresence:update', lobbyHandler('update'));
692
- try {
693
- const initialState = await this._lobby.subscribe();
694
- this._hydratePresence(initialState);
695
- this._log('Lobby subscribed');
696
- }
697
- catch (err) {
698
- this._log('Lobby subscription failed:', err);
699
- }
700
- }
701
855
  _handleLobbyJoin(event) {
702
856
  const { actorId, data } = event;
703
- if (actorId === this._client?.actorId)
857
+ if (actorId === this._client.actorId)
704
858
  return;
705
859
  const presenceData = data;
706
- if (!presenceData?.userId)
860
+ if (!presenceData?.userId || this._foreignScope(presenceData))
707
861
  return;
708
862
  const user = this._presenceManager.addFromPresence(actorId, presenceData);
709
863
  if (user) {
@@ -711,30 +865,41 @@ class NoLagNotify extends EventEmitter {
711
865
  }
712
866
  }
713
867
  _handleLobbyLeave(event) {
714
- const { actorId } = event;
715
- if (actorId === this._client?.actorId)
868
+ const { actorId, data } = event;
869
+ if (actorId === this._client.actorId)
870
+ return;
871
+ const presenceData = data;
872
+ if (this._foreignScope(presenceData))
716
873
  return;
717
874
  this._presenceManager.removeByActorId(actorId);
718
875
  this._actorToUserId.delete(actorId);
719
876
  }
720
877
  _handleLobbyUpdate(event) {
721
878
  const { actorId, data } = event;
722
- if (actorId === this._client?.actorId)
879
+ if (actorId === this._client.actorId)
723
880
  return;
724
881
  const presenceData = data;
725
- if (!presenceData?.userId)
882
+ if (!presenceData?.userId || this._foreignScope(presenceData))
726
883
  return;
727
884
  this._presenceManager.addFromPresence(actorId, presenceData);
728
885
  }
729
- _hydratePresence(state) {
886
+ /**
887
+ * Reconcile tracked presence against a fresh lobby snapshot. One path for
888
+ * initial hydration, reconnect restore, and the deferred refetch.
889
+ */
890
+ _diffHydratePresence(state) {
891
+ // Build the fresh actor set from the snapshot
892
+ const freshActors = new Set();
730
893
  for (const roomId of Object.keys(state)) {
731
894
  const roomPresence = state[roomId];
732
895
  for (const actorId of Object.keys(roomPresence)) {
733
- if (actorId === this._client?.actorId)
896
+ if (actorId === this._client.actorId)
734
897
  continue;
735
898
  const raw = roomPresence[actorId];
899
+ // Server returns full actor records with presence nested under .presence
736
900
  const presenceData = (raw?.presence ?? raw);
737
- if (presenceData?.userId) {
901
+ if (presenceData?.userId && !this._foreignScope(presenceData)) {
902
+ freshActors.add(actorId);
738
903
  const user = this._presenceManager.addFromPresence(actorId, presenceData);
739
904
  if (user) {
740
905
  this._actorToUserId.set(actorId, user.userId);
@@ -742,16 +907,13 @@ class NoLagNotify extends EventEmitter {
742
907
  }
743
908
  }
744
909
  }
745
- }
746
- // ============ Private: Reconnect ============
747
- _restoreChannels() {
748
- this._lobby?.fetchPresence().then((state) => {
749
- this._presenceManager.clear();
750
- this._actorToUserId.clear();
751
- this._hydratePresence(state);
752
- }).catch((err) => {
753
- this._log('Failed to re-fetch lobby presence:', err);
754
- });
910
+ // Vanished actors: present locally but absent from the fresh snapshot
911
+ for (const [actorId] of [...this._actorToUserId]) {
912
+ if (!freshActors.has(actorId)) {
913
+ this._presenceManager.removeByActorId(actorId);
914
+ this._actorToUserId.delete(actorId);
915
+ }
916
+ }
755
917
  }
756
918
  }
757
919