@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/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/react-native.d.ts +13 -0
- package/dist/react-native.js +1251 -0
- package/dist/react-native.js.map +1 -0
- package/dist/types.d.ts +9 -7
- package/dist/utils.d.ts +4 -0
- package/package.json +10 -4
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
|
-
|
|
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(
|
|
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
|
-
|
|
618
|
-
|
|
619
|
-
this.
|
|
620
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
767
|
-
this.
|
|
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
|
-
*
|
|
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
|
-
|
|
837
|
-
this.
|
|
838
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
1024
|
-
|
|
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 };
|