@nolag/queue 0.1.3 → 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
  *
@@ -358,6 +356,29 @@ function createLogger(prefix, enabled) {
358
356
  console.log(`[${prefix}]`, ...args);
359
357
  };
360
358
  }
359
+ // ============ Wrapper registry ============
360
+ // One wrapper instance per (client, appName): two wrappers sharing an app on
361
+ // one connection would collide on topics, presence and the online lobby.
362
+ // Warn (not throw): HMR and tests legitimately construct before disposing.
363
+ const wrapperRegistry = new WeakMap();
364
+ /** Register a wrapper against a client + appName; warns on collision. */
365
+ function registerWrapper(client, appName, wrapperName) {
366
+ let apps = wrapperRegistry.get(client);
367
+ if (!apps) {
368
+ apps = new Map();
369
+ wrapperRegistry.set(client, apps);
370
+ }
371
+ const existing = apps.get(appName);
372
+ if (existing) {
373
+ console.warn(`[${wrapperName}] Another wrapper (${existing}) is already attached to this client for app "${appName}". ` +
374
+ `Use one wrapper per (client, app) — detach the other instance first.`);
375
+ }
376
+ apps.set(appName, wrapperName);
377
+ }
378
+ /** Release a wrapper's (client, appName) registration on detach. */
379
+ function releaseWrapper(client, appName) {
380
+ wrapperRegistry.get(client)?.delete(appName);
381
+ }
361
382
 
362
383
  /** Default app name for NoLag queue SDK */
363
384
  const DEFAULT_APP_NAME = 'queue';
@@ -371,6 +392,8 @@ const TOPIC_JOBS = 'jobs';
371
392
  const TOPIC_PROGRESS = '_progress';
372
393
  /** Lobby ID for global online presence */
373
394
  const LOBBY_ID = 'online';
395
+ /** Delay before the post-setup lobby presence refetch (catches simultaneous joiners) */
396
+ const LOBBY_REFRESH_DELAY_MS = 2000;
374
397
 
375
398
  /**
376
399
  * QueueRoom — a single named queue with job lifecycle, progress tracking, and worker presence.
@@ -379,13 +402,18 @@ const LOBBY_ID = 'online';
379
402
  */
380
403
  class QueueRoom extends EventEmitter {
381
404
  /** @internal */
382
- constructor(name, roomContext, localWorkerId, options, log) {
405
+ constructor(name, roomContext, localWorkerId, options, log, isConnected) {
383
406
  super();
407
+ // Stored topic handler refs — cleanup removes exactly these, never all
408
+ // handlers for a topic (the client may be shared with other consumers).
409
+ this._onJobsRef = null;
410
+ this._onProgressRef = null;
384
411
  this.name = name;
385
412
  this._roomContext = roomContext;
386
413
  this._localWorkerId = localWorkerId;
387
414
  this._options = options;
388
415
  this._log = log;
416
+ this._isConnected = isConnected;
389
417
  this._jobStore = new JobStore(options.maxJobCache);
390
418
  this._workerManager = new WorkerManager();
391
419
  this._presenceManager = new PresenceManager(''); // local actor set after connect
@@ -558,12 +586,15 @@ class QueueRoom extends EventEmitter {
558
586
  this._roomContext.subscribe(TOPIC_JOBS);
559
587
  }
560
588
  this._roomContext.subscribe(TOPIC_PROGRESS);
561
- this._roomContext.on(TOPIC_JOBS, (data) => {
589
+ // Listen for job lifecycle messages (refs stored for handler-specific removal)
590
+ this._onJobsRef = (data) => {
562
591
  this._handleJobMessage(data);
563
- });
564
- this._roomContext.on(TOPIC_PROGRESS, (data) => {
592
+ };
593
+ this._roomContext.on(TOPIC_JOBS, this._onJobsRef);
594
+ this._onProgressRef = (data) => {
565
595
  this._handleProgressMessage(data);
566
- });
596
+ };
597
+ this._roomContext.on(TOPIC_PROGRESS, this._onProgressRef);
567
598
  }
568
599
  /** @internal Set presence and fetch room members */
569
600
  _activate() {
@@ -616,10 +647,20 @@ class QueueRoom extends EventEmitter {
616
647
  /** @internal Unsubscribe and clean up */
617
648
  _cleanup() {
618
649
  this._log('Room cleanup:', this.name);
619
- this._roomContext.unsubscribe(TOPIC_JOBS);
620
- this._roomContext.unsubscribe(TOPIC_PROGRESS);
621
- this._roomContext.off(TOPIC_JOBS);
622
- this._roomContext.off(TOPIC_PROGRESS);
650
+ // Server unsubscribes need a live socket; skip when disconnected
651
+ // (best-effort — the core would no-op with an error callback anyway).
652
+ if (this._isConnected()) {
653
+ this._roomContext.unsubscribe(TOPIC_JOBS);
654
+ this._roomContext.unsubscribe(TOPIC_PROGRESS);
655
+ }
656
+ // Handler-specific removal only: the client may be shared, and a bare
657
+ // off(topic) would strip other consumers' handlers too.
658
+ if (this._onJobsRef)
659
+ this._roomContext.off(TOPIC_JOBS, this._onJobsRef);
660
+ if (this._onProgressRef)
661
+ this._roomContext.off(TOPIC_PROGRESS, this._onProgressRef);
662
+ this._onJobsRef = null;
663
+ this._onProgressRef = null;
623
664
  this._jobStore.clear();
624
665
  this._workerManager.clear();
625
666
  this._presenceManager.clear();
@@ -692,6 +733,9 @@ class QueueRoom extends EventEmitter {
692
733
  activeJobs: 0,
693
734
  concurrency: this._options.concurrency,
694
735
  metadata: this._options.metadata,
736
+ // Scope tag: on a shared client, other apps' wrappers filter our
737
+ // presence out by this (and we filter theirs).
738
+ __scope: this._options.appName,
695
739
  };
696
740
  this._roomContext.setPresence(presenceData);
697
741
  }
@@ -703,35 +747,73 @@ class QueueRoom extends EventEmitter {
703
747
  * Provides job lifecycle management, progress tracking, worker management,
704
748
  * and global presence tracking — all framework-agnostic via events.
705
749
  *
750
+ * The wrapper NEVER manages the connection. The app owns one core NoLag
751
+ * client (shared by any number of wrappers on distinct apps) and the
752
+ * wrapper attaches to it at construction and releases it via `detach()`.
753
+ *
706
754
  * @example
707
755
  * ```typescript
756
+ * import { NoLag } from '@nolag/js-sdk';
708
757
  * import { NoLagQueue } from '@nolag/queue';
709
758
  *
710
- * const queue = new NoLagQueue(token, { role: 'worker', concurrency: 2 });
759
+ * const client = NoLag(async () => (await (await fetch('/api/nolag-token')).json()).token);
760
+ * const queue = new NoLagQueue({ client, role: 'worker', concurrency: 2 });
711
761
  *
712
762
  * queue.on('connected', () => console.log('Connected!'));
713
763
  *
714
- * await queue.connect();
764
+ * await client.connect(); // the app owns the connection
765
+ * await queue.ready(); // wrapper setup done (identity, lobby, queues)
715
766
  *
716
767
  * const room = queue.joinQueue('image-processing');
717
768
  * room.on('jobAdded', (job) => {
718
769
  * room.claimJob(job.id);
719
- * // process...
720
770
  * room.reportProgress(job.id, 50);
721
771
  * room.completeJob(job.id, { output: 'result' });
722
772
  * });
773
+ *
774
+ * queue.detach(); // wrapper releases its handlers and topics
775
+ * client.disconnect(); // the app closes the socket
723
776
  * ```
724
777
  */
725
778
  class NoLagQueue extends EventEmitter {
726
- constructor(token, options = {}) {
779
+ constructor(options) {
727
780
  super();
728
- this._client = null;
729
781
  this._localWorker = null;
730
782
  this._queues = new Map();
731
783
  this._lobby = null;
732
784
  this._onlineWorkers = new Map();
733
785
  this._actorToWorkerId = new Map();
734
- this._token = token;
786
+ // Lifecycle: one setup run per connection epoch; detach is terminal.
787
+ this._epoch = 0;
788
+ this._detached = false;
789
+ this._isReady = false;
790
+ this._lobbyRefreshTimer = null;
791
+ // Stored client handler refs. INVARIANT: every client.on() below has a
792
+ // matching client.off() in detach() — never bare off(event), never inline
793
+ // closures on the client.
794
+ this._onConnectRef = () => this._onConnect();
795
+ this._onDisconnectRef = (reason) => {
796
+ this._log('Disconnected:', reason);
797
+ this.emit('disconnected', reason);
798
+ };
799
+ this._onReconnectRef = () => {
800
+ this._log('Reconnecting...');
801
+ this.emit('reconnecting');
802
+ };
803
+ this._onErrorRef = (error) => {
804
+ this._log('Error:', error);
805
+ this.emit('error', error);
806
+ };
807
+ this._onPresenceJoinRef = (data) => this._handleRoomPresenceJoin(data);
808
+ this._onPresenceLeaveRef = (data) => this._handleRoomPresenceLeave(data);
809
+ this._onPresenceUpdateRef = (data) => this._handleRoomPresenceUpdate(data);
810
+ this._onLobbyJoinRef = (data) => this._handleLobbyJoin(data);
811
+ this._onLobbyLeaveRef = (data) => this._handleLobbyLeave(data);
812
+ this._onLobbyUpdateRef = (data) => this._handleLobbyUpdate(data);
813
+ if (!options?.client) {
814
+ throw new TypeError('NoLagQueue requires an injected NoLag client: new NoLagQueue({ client, role, ... })');
815
+ }
816
+ this._client = options.client;
735
817
  this._workerId = options.workerId ?? generateId();
736
818
  this._options = {
737
819
  workerId: this._workerId,
@@ -739,21 +821,49 @@ class NoLagQueue extends EventEmitter {
739
821
  concurrency: options.concurrency ?? 1,
740
822
  metadata: options.metadata,
741
823
  appName: options.appName ?? DEFAULT_APP_NAME,
742
- url: options.url,
743
824
  maxJobCache: options.maxJobCache ?? DEFAULT_MAX_JOB_CACHE,
744
825
  debug: options.debug ?? false,
745
- reconnect: options.reconnect ?? true,
746
826
  queues: options.queues ?? [],
747
827
  loadBalanceGroup: options.loadBalanceGroup,
748
828
  };
749
829
  this._log = createLogger('NoLagQueue', this._options.debug);
830
+ this._readyPromise = new Promise((resolve, reject) => {
831
+ this._readyResolve = resolve;
832
+ this._readyReject = reject;
833
+ });
834
+ // ready() rejection is only meaningful to callers that await it
835
+ this._readyPromise.catch(() => { });
836
+ registerWrapper(this._client, this._options.appName, 'NoLagQueue');
837
+ // Construction = attach: wire everything now, with stored refs.
838
+ this._client.on('connect', this._onConnectRef);
839
+ this._client.on('disconnect', this._onDisconnectRef);
840
+ this._client.on('reconnect', this._onReconnectRef);
841
+ this._client.on('error', this._onErrorRef);
842
+ this._client.on('presence:join', this._onPresenceJoinRef);
843
+ this._client.on('presence:leave', this._onPresenceLeaveRef);
844
+ this._client.on('presence:update', this._onPresenceUpdateRef);
845
+ this._client.on('lobbyPresence:join', this._onLobbyJoinRef);
846
+ this._client.on('lobbyPresence:leave', this._onLobbyLeaveRef);
847
+ this._client.on('lobbyPresence:update', this._onLobbyUpdateRef);
848
+ // Attach-to-connected: if the client is already authenticated, run setup.
849
+ // The microtask lets the caller wire wrapper event handlers synchronously
850
+ // first; a racing real 'connect' event wins via the epoch guard.
851
+ queueMicrotask(() => {
852
+ if (this._epoch === 0 && !this._detached && this._client.connected) {
853
+ this._onConnect();
854
+ }
855
+ });
750
856
  }
751
857
  // ============ Public Properties ============
752
- /** Whether the underlying connection is established */
858
+ /** Whether the underlying connection is established (connected ≠ ready) */
753
859
  get connected() {
754
- return this._client?.connected ?? false;
860
+ return !this._detached && this._client.connected;
861
+ }
862
+ /** The injected core client (owned by the app, not the wrapper) */
863
+ get client() {
864
+ return this._client;
755
865
  }
756
- /** The local worker's info (available after connect) */
866
+ /** The local worker's info (available after ready) */
757
867
  get localWorker() {
758
868
  return this._localWorker;
759
869
  }
@@ -763,94 +873,159 @@ class NoLagQueue extends EventEmitter {
763
873
  }
764
874
  // ============ Lifecycle ============
765
875
  /**
766
- * Connect to NoLag and set up global presence.
876
+ * Resolves once the wrapper's first setup completed (identity, lobby and
877
+ * configured queues ready — equivalently, once 'connected' has fired).
878
+ * Rejects only if detach() is called before that. Client auth failures
879
+ * surface via the app's own `await client.connect()`, not here.
767
880
  */
768
- async connect() {
769
- this._log('Connecting...');
770
- const clientOptions = {
771
- debug: this._options.debug,
772
- reconnect: this._options.reconnect,
773
- };
774
- if (this._options.url) {
775
- clientOptions.url = this._options.url;
776
- }
777
- this._client = jsSdk.NoLag(this._token, clientOptions);
778
- // Wire client lifecycle events
779
- this._client.on('connect', () => {
780
- this._log('Connected');
781
- if (this._queues.size > 0) {
782
- this._log('Reconnected — restoring queues...');
783
- this._restoreQueues();
784
- this.emit('reconnected');
785
- }
786
- });
787
- this._client.on('disconnect', (reason) => {
788
- this._log('Disconnected:', reason);
789
- this.emit('disconnected', reason);
790
- });
791
- this._client.on('reconnect', () => {
792
- this._log('Reconnecting...');
793
- });
794
- this._client.on('error', (error) => {
795
- this._log('Error:', error);
796
- this.emit('error', error);
797
- });
798
- // Connect
799
- await this._client.connect();
800
- // Wire room-level presence events
801
- this._client.on('presence:join', (data) => {
802
- this._handleRoomPresenceJoin(data);
803
- });
804
- this._client.on('presence:leave', (data) => {
805
- this._handleRoomPresenceLeave(data);
806
- });
807
- this._client.on('presence:update', (data) => {
808
- this._handleRoomPresenceUpdate(data);
809
- });
810
- // Create local worker record
811
- this._localWorker = {
812
- workerId: this._workerId,
813
- actorTokenId: this._client.actorId,
814
- role: this._options.role,
815
- activeJobs: 0,
816
- concurrency: this._options.concurrency,
817
- metadata: this._options.metadata,
818
- joinedAt: Date.now(),
819
- isLocal: true,
820
- };
821
- this._log('Local worker:', this._localWorker.workerId, '→', this._localWorker.actorTokenId);
822
- // Set up lobby for global presence
823
- await this._setupLobby();
824
- // Emit connected now that _localWorker and lobby are ready
825
- this.emit('connected');
826
- // Deferred lobby refetch to catch workers who joined during the setup window
827
- setTimeout(() => {
828
- if (this._lobby && this._client?.connected) {
829
- this._lobby.fetchPresence().then((state) => {
830
- this._hydrateOnlineWorkers(state);
831
- }).catch(() => { });
832
- }
833
- }, 2000);
881
+ ready() {
882
+ return this._readyPromise;
834
883
  }
835
884
  /**
836
- * Disconnect from NoLag and clean up all queue rooms.
885
+ * Detach from the client: remove every handler this wrapper added,
886
+ * unsubscribe its topics and lobby (when connected), clear state.
887
+ * Terminal and idempotent; never touches the socket. To use the queue
888
+ * again, construct a new instance.
837
889
  */
838
- disconnect() {
839
- this._log('Disconnecting...');
840
- // Clean up queue rooms
890
+ detach() {
891
+ if (this._detached)
892
+ return;
893
+ this._log('Detaching...');
894
+ this._detached = true;
895
+ this._epoch++; // aborts any in-flight setup at its next checkpoint
896
+ if (this._lobbyRefreshTimer) {
897
+ clearTimeout(this._lobbyRefreshTimer);
898
+ this._lobbyRefreshTimer = null;
899
+ }
900
+ // Remove all client handlers by stored ref
901
+ this._client.off('connect', this._onConnectRef);
902
+ this._client.off('disconnect', this._onDisconnectRef);
903
+ this._client.off('reconnect', this._onReconnectRef);
904
+ this._client.off('error', this._onErrorRef);
905
+ this._client.off('presence:join', this._onPresenceJoinRef);
906
+ this._client.off('presence:leave', this._onPresenceLeaveRef);
907
+ this._client.off('presence:update', this._onPresenceUpdateRef);
908
+ this._client.off('lobbyPresence:join', this._onLobbyJoinRef);
909
+ this._client.off('lobbyPresence:leave', this._onLobbyLeaveRef);
910
+ this._client.off('lobbyPresence:update', this._onLobbyUpdateRef);
911
+ // Queue rooms: handler-specific off + connected-gated server unsubscribe
841
912
  for (const name of [...this._queues.keys()]) {
842
- this.leaveQueue(name);
913
+ this._queues.get(name)._cleanup();
914
+ this._queues.delete(name);
915
+ }
916
+ // Lobby: server unsubscribe is best-effort and needs a live socket
917
+ if (this._lobby && this._client.connected) {
918
+ try {
919
+ this._lobby.unsubscribe();
920
+ }
921
+ catch {
922
+ /* best-effort */
923
+ }
843
924
  }
844
- // Unsubscribe from lobby
845
- this._lobby?.unsubscribe();
846
925
  this._lobby = null;
847
- // Disconnect client
848
- this._client?.disconnect();
849
- this._client = null;
850
- // Clear state
851
926
  this._onlineWorkers.clear();
852
927
  this._actorToWorkerId.clear();
853
928
  this._localWorker = null;
929
+ releaseWrapper(this._client, this._options.appName);
930
+ if (!this._isReady) {
931
+ this._readyReject(new Error('NoLagQueue detached before ready'));
932
+ }
933
+ }
934
+ // ============ Private: Epoch Setup ============
935
+ _onConnect() {
936
+ this._epoch++;
937
+ void this._runSetup(this._epoch);
938
+ }
939
+ /**
940
+ * One setup pass per connection epoch. Serves both initial setup (epoch 1)
941
+ * and reconnect restore (epoch > 1). Aborts silently whenever a newer
942
+ * epoch started or the wrapper detached — checked after every await.
943
+ */
944
+ async _runSetup(epoch) {
945
+ const stale = () => epoch !== this._epoch || this._detached;
946
+ this._log(this._isReady ? 'Restoring after reconnect...' : 'Setting up...');
947
+ // Identity (client.actorId is guaranteed post-auth)
948
+ if (!this._localWorker) {
949
+ this._localWorker = {
950
+ workerId: this._workerId,
951
+ actorTokenId: this._client.actorId,
952
+ role: this._options.role,
953
+ activeJobs: 0,
954
+ concurrency: this._options.concurrency,
955
+ metadata: this._options.metadata,
956
+ joinedAt: Date.now(),
957
+ isLocal: true,
958
+ };
959
+ this._log('Local worker:', this._localWorker.workerId, '→', this._localWorker.actorTokenId);
960
+ }
961
+ else {
962
+ this._localWorker.actorTokenId = this._client.actorId;
963
+ }
964
+ // Lobby: subscribe every epoch (idempotent server-side) and diff-hydrate
965
+ // from the returned snapshot — one path for setup and restore.
966
+ if (!this._lobby) {
967
+ this._lobby = this._client.setApp(this._options.appName).setLobby(LOBBY_ID);
968
+ }
969
+ try {
970
+ const state = await this._lobby.subscribe();
971
+ if (stale())
972
+ return;
973
+ this._diffHydrateOnlineWorkers(state);
974
+ this._log('Lobby subscribed, online workers:', this._onlineWorkers.size);
975
+ }
976
+ catch (err) {
977
+ if (stale())
978
+ return;
979
+ this._log('Lobby subscription failed:', err);
980
+ }
981
+ if (!this._isReady) {
982
+ // First successful setup: pre-subscribe configured queues.
983
+ for (const queueName of this._options.queues) {
984
+ this._subscribeQueue(queueName);
985
+ }
986
+ }
987
+ else {
988
+ // Server auto-restored topic subscriptions; only room-scoped presence
989
+ // needs re-applying (the core does not restore it).
990
+ for (const room of this._queues.values()) {
991
+ room._updateLocalPresence();
992
+ }
993
+ }
994
+ if (stale())
995
+ return;
996
+ // Ready keys on the first setup that COMPLETES, not on epoch 1: an
997
+ // epoch aborted by a racing reconnect must not strand ready().
998
+ if (!this._isReady) {
999
+ this._isReady = true;
1000
+ this._readyResolve();
1001
+ this.emit('connected');
1002
+ }
1003
+ else {
1004
+ this.emit('reconnected');
1005
+ }
1006
+ // Deferred lobby refetch: catches workers who joined during the setup
1007
+ // window (e.g. simultaneous multi-tab connects).
1008
+ this._scheduleLobbyRefresh(epoch);
1009
+ }
1010
+ _scheduleLobbyRefresh(epoch) {
1011
+ if (this._lobbyRefreshTimer)
1012
+ clearTimeout(this._lobbyRefreshTimer);
1013
+ this._lobbyRefreshTimer = setTimeout(() => {
1014
+ this._lobbyRefreshTimer = null;
1015
+ if (epoch !== this._epoch || this._detached || !this._client.connected || !this._lobby) {
1016
+ return;
1017
+ }
1018
+ this._lobby
1019
+ .fetchPresence()
1020
+ .then((state) => {
1021
+ if (epoch !== this._epoch || this._detached)
1022
+ return;
1023
+ this._diffHydrateOnlineWorkers(state);
1024
+ })
1025
+ .catch(() => {
1026
+ /* best-effort */
1027
+ });
1028
+ }, LOBBY_REFRESH_DELAY_MS);
854
1029
  }
855
1030
  // ============ Queue Management ============
856
1031
  /**
@@ -858,9 +1033,7 @@ class NoLagQueue extends EventEmitter {
858
1033
  * Returns an existing room if already joined.
859
1034
  */
860
1035
  joinQueue(name) {
861
- if (!this._client || !this._localWorker) {
862
- throw new Error('Not connected — call connect() first');
863
- }
1036
+ this._assertUsable();
864
1037
  let room = this._queues.get(name);
865
1038
  if (!room) {
866
1039
  room = this._subscribeQueue(name);
@@ -892,25 +1065,42 @@ class NoLagQueue extends EventEmitter {
892
1065
  getOnlineWorkers() {
893
1066
  return Array.from(this._onlineWorkers.values());
894
1067
  }
1068
+ // ============ Private: Guards ============
1069
+ _assertUsable() {
1070
+ if (this._detached) {
1071
+ throw new Error('NoLagQueue has been detached — construct a new instance');
1072
+ }
1073
+ if (!this._isReady || !this._localWorker) {
1074
+ throw new Error('NoLagQueue not ready — await ready() or the "connected" event');
1075
+ }
1076
+ }
895
1077
  // ============ Private: Queue Setup ============
896
1078
  _subscribeQueue(name) {
897
- if (!this._client || !this._localWorker) {
898
- throw new Error('Not connected — call connect() first');
899
- }
900
1079
  this._log('Subscribing queue:', name);
901
1080
  const roomContext = this._client.setApp(this._options.appName).setRoom(name);
902
- const room = new QueueRoom(name, roomContext, this._workerId, this._options, createLogger(`QueueRoom:${name}`, this._options.debug));
1081
+ const room = new QueueRoom(name, roomContext, this._workerId, this._options, createLogger(`QueueRoom:${name}`, this._options.debug), () => this._client.connected);
903
1082
  room._setLocalActorId(this._localWorker.actorTokenId);
904
1083
  this._queues.set(name, room);
905
1084
  room._subscribe();
906
1085
  return room;
907
1086
  }
1087
+ // ============ Private: Scope Filtering ============
1088
+ /**
1089
+ * On a shared client, presence events from other apps' wrappers arrive on
1090
+ * the same connection-level events. Wrappers stamp their presence with a
1091
+ * `__scope` (their appName); a mismatched tag means another app's data.
1092
+ * Untagged presence is accepted (older peers in this same app).
1093
+ */
1094
+ _foreignScope(data) {
1095
+ const scope = data?.__scope;
1096
+ return typeof scope === 'string' && scope !== this._options.appName;
1097
+ }
908
1098
  // ============ Private: Room Presence ============
909
1099
  _handleRoomPresenceJoin(data) {
910
1100
  if (data.actorTokenId === this._localWorker?.actorTokenId)
911
1101
  return;
912
1102
  const presenceData = data.presence;
913
- if (!presenceData?.workerId)
1103
+ if (!presenceData?.workerId || this._foreignScope(presenceData))
914
1104
  return;
915
1105
  const worker = this._presenceToWorker(data.actorTokenId, presenceData);
916
1106
  this._actorToWorkerId.set(data.actorTokenId, worker.workerId);
@@ -935,7 +1125,7 @@ class NoLagQueue extends EventEmitter {
935
1125
  if (data.actorTokenId === this._localWorker?.actorTokenId)
936
1126
  return;
937
1127
  const presenceData = data.presence;
938
- if (!presenceData?.workerId)
1128
+ if (!presenceData?.workerId || this._foreignScope(presenceData))
939
1129
  return;
940
1130
  if (this._onlineWorkers.has(presenceData.workerId)) {
941
1131
  const worker = this._presenceToWorker(data.actorTokenId, presenceData);
@@ -947,37 +1137,12 @@ class NoLagQueue extends EventEmitter {
947
1137
  }
948
1138
  }
949
1139
  // ============ Private: Lobby ============
950
- async _setupLobby() {
951
- if (!this._client)
952
- return;
953
- this._lobby = this._client.setApp(this._options.appName).setLobby(LOBBY_ID);
954
- const lobbyHandler = (type) => (data) => {
955
- const event = data;
956
- if (type === 'join')
957
- this._handleLobbyJoin(event);
958
- else if (type === 'leave')
959
- this._handleLobbyLeave(event);
960
- else
961
- this._handleLobbyUpdate(event);
962
- };
963
- this._client.on('lobbyPresence:join', lobbyHandler('join'));
964
- this._client.on('lobbyPresence:leave', lobbyHandler('leave'));
965
- this._client.on('lobbyPresence:update', lobbyHandler('update'));
966
- try {
967
- const initialState = await this._lobby.subscribe();
968
- this._hydrateOnlineWorkers(initialState);
969
- this._log('Lobby subscribed, online workers:', this._onlineWorkers.size);
970
- }
971
- catch (err) {
972
- this._log('Lobby subscription failed:', err);
973
- }
974
- }
975
1140
  _handleLobbyJoin(event) {
976
1141
  const { actorId, data } = event;
977
1142
  if (actorId === this._localWorker?.actorTokenId)
978
1143
  return;
979
1144
  const presenceData = data;
980
- if (!presenceData.workerId)
1145
+ if (!presenceData?.workerId || this._foreignScope(presenceData))
981
1146
  return;
982
1147
  const worker = this._presenceToWorker(actorId, presenceData);
983
1148
  this._actorToWorkerId.set(actorId, worker.workerId);
@@ -991,6 +1156,8 @@ class NoLagQueue extends EventEmitter {
991
1156
  if (actorId === this._localWorker?.actorTokenId)
992
1157
  return;
993
1158
  const presenceData = data;
1159
+ if (this._foreignScope(presenceData))
1160
+ return;
994
1161
  const workerId = presenceData?.workerId
995
1162
  || this._actorToWorkerId.get(actorId)
996
1163
  || this._findWorkerIdByActorId(actorId);
@@ -1008,29 +1175,57 @@ class NoLagQueue extends EventEmitter {
1008
1175
  if (actorId === this._localWorker?.actorTokenId)
1009
1176
  return;
1010
1177
  const presenceData = data;
1011
- if (!presenceData.workerId)
1178
+ if (!presenceData?.workerId || this._foreignScope(presenceData))
1012
1179
  return;
1013
1180
  const worker = this._presenceToWorker(actorId, presenceData);
1014
1181
  this._onlineWorkers.set(worker.workerId, worker);
1015
1182
  }
1016
- _hydrateOnlineWorkers(state) {
1183
+ /**
1184
+ * Reconcile the online-worker map against a fresh lobby snapshot, emitting
1185
+ * only the deltas (workerOffline for vanished, workerOnline for new). One
1186
+ * path for initial hydration, reconnect restore, and the deferred refetch.
1187
+ */
1188
+ _diffHydrateOnlineWorkers(state) {
1189
+ // Build the fresh worker set from the snapshot
1190
+ const fresh = new Map();
1191
+ const freshActors = new Map();
1017
1192
  for (const roomId of Object.keys(state)) {
1018
1193
  const roomPresence = state[roomId];
1019
1194
  for (const actorId of Object.keys(roomPresence)) {
1020
1195
  if (actorId === this._localWorker?.actorTokenId)
1021
1196
  continue;
1022
1197
  const raw = roomPresence[actorId];
1198
+ // Server returns full actor records with presence nested under .presence
1023
1199
  const presenceData = (raw?.presence ?? raw);
1024
- if (presenceData?.workerId) {
1025
- const worker = this._presenceToWorker(actorId, presenceData);
1026
- this._actorToWorkerId.set(actorId, worker.workerId);
1027
- if (!this._onlineWorkers.has(worker.workerId)) {
1028
- this._onlineWorkers.set(worker.workerId, worker);
1029
- this.emit('workerOnline', worker);
1200
+ if (presenceData?.workerId && !this._foreignScope(presenceData)) {
1201
+ if (!fresh.has(presenceData.workerId)) {
1202
+ fresh.set(presenceData.workerId, this._presenceToWorker(actorId, presenceData));
1030
1203
  }
1204
+ freshActors.set(actorId, presenceData.workerId);
1031
1205
  }
1032
1206
  }
1033
1207
  }
1208
+ // Vanished workers
1209
+ for (const [workerId, worker] of [...this._onlineWorkers]) {
1210
+ if (!fresh.has(workerId)) {
1211
+ this._onlineWorkers.delete(workerId);
1212
+ for (const [actorId, mappedWorkerId] of [...this._actorToWorkerId]) {
1213
+ if (mappedWorkerId === workerId)
1214
+ this._actorToWorkerId.delete(actorId);
1215
+ }
1216
+ this.emit('workerOffline', worker);
1217
+ }
1218
+ }
1219
+ // New workers
1220
+ for (const [workerId, worker] of fresh) {
1221
+ if (!this._onlineWorkers.has(workerId)) {
1222
+ this._onlineWorkers.set(workerId, worker);
1223
+ this.emit('workerOnline', worker);
1224
+ }
1225
+ }
1226
+ for (const [actorId, workerId] of freshActors) {
1227
+ this._actorToWorkerId.set(actorId, workerId);
1228
+ }
1034
1229
  }
1035
1230
  // ============ Private: Helpers ============
1036
1231
  _presenceToWorker(actorTokenId, data) {
@@ -1052,21 +1247,6 @@ class NoLagQueue extends EventEmitter {
1052
1247
  }
1053
1248
  return undefined;
1054
1249
  }
1055
- _restoreQueues() {
1056
- // On reconnect, js-sdk auto-restores subscriptions.
1057
- // Re-set presence on all active queue rooms.
1058
- for (const room of this._queues.values()) {
1059
- room._updateLocalPresence();
1060
- }
1061
- // Re-fetch lobby presence
1062
- this._lobby?.fetchPresence().then((state) => {
1063
- this._onlineWorkers.clear();
1064
- this._actorToWorkerId.clear();
1065
- this._hydrateOnlineWorkers(state);
1066
- }).catch((err) => {
1067
- this._log('Failed to re-fetch lobby presence:', err);
1068
- });
1069
- }
1070
1250
  }
1071
1251
 
1072
1252
  exports.EventEmitter = EventEmitter;