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