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