@nolag/queue 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/README.md +69 -38
- package/dist/NoLagQueue.d.ts +68 -15
- package/dist/QueueRoom.d.ts +4 -1
- package/dist/browser.js +1 -1
- package/dist/browser.js.map +1 -1
- package/dist/constants.d.ts +2 -0
- package/dist/index.cjs +338 -158
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +338 -158
- package/dist/index.mjs.map +1 -1
- package/dist/types.d.ts +9 -7
- package/dist/utils.d.ts +4 -0
- package/package.json +3 -3
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
|
-
|
|
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(
|
|
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
|
-
|
|
620
|
-
|
|
621
|
-
this.
|
|
622
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
769
|
-
this.
|
|
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
|
-
*
|
|
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
|
-
|
|
839
|
-
this.
|
|
840
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
1026
|
-
|
|
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;
|