@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.
@@ -0,0 +1,1251 @@
1
+ /**
2
+ * Tiny typed event emitter — framework-agnostic base for NoLag SDKs.
3
+ *
4
+ * EventMap is a record of event name → tuple of handler arguments.
5
+ */
6
+ class EventEmitter {
7
+ constructor() {
8
+ this._handlers = new Map();
9
+ }
10
+ on(event, handler) {
11
+ if (!this._handlers.has(event)) {
12
+ this._handlers.set(event, new Set());
13
+ }
14
+ this._handlers.get(event).add(handler);
15
+ return this;
16
+ }
17
+ off(event, handler) {
18
+ if (handler) {
19
+ this._handlers.get(event)?.delete(handler);
20
+ }
21
+ else {
22
+ this._handlers.delete(event);
23
+ }
24
+ return this;
25
+ }
26
+ removeAllListeners() {
27
+ this._handlers.clear();
28
+ return this;
29
+ }
30
+ emit(event, ...args) {
31
+ const handlers = this._handlers.get(event);
32
+ if (!handlers)
33
+ return;
34
+ for (const handler of handlers) {
35
+ try {
36
+ handler(...args);
37
+ }
38
+ catch (e) {
39
+ console.error(`Error in ${String(event)} handler:`, e);
40
+ }
41
+ }
42
+ }
43
+ listenerCount(event) {
44
+ return this._handlers.get(event)?.size ?? 0;
45
+ }
46
+ }
47
+
48
+ /** Valid state transitions for job lifecycle */
49
+ const VALID_TRANSITIONS = {
50
+ pending: ['claimed'],
51
+ claimed: ['active'],
52
+ active: ['completed', 'failed'],
53
+ failed: ['pending'], // retry path
54
+ };
55
+ /**
56
+ * In-memory job store with deduplication, filtering, and state-transition validation.
57
+ */
58
+ class JobStore {
59
+ constructor(maxSize) {
60
+ this._jobs = new Map();
61
+ this._maxSize = maxSize;
62
+ }
63
+ /**
64
+ * Add a job to the store.
65
+ * Returns true if added, false if already present (dedup).
66
+ */
67
+ add(job) {
68
+ if (this._jobs.has(job.id))
69
+ return false;
70
+ // Evict oldest entry if at capacity
71
+ if (this._jobs.size >= this._maxSize) {
72
+ const firstKey = this._jobs.keys().next().value;
73
+ if (firstKey !== undefined) {
74
+ this._jobs.delete(firstKey);
75
+ }
76
+ }
77
+ this._jobs.set(job.id, job);
78
+ return true;
79
+ }
80
+ /**
81
+ * Get a job by ID.
82
+ */
83
+ get(id) {
84
+ return this._jobs.get(id);
85
+ }
86
+ /**
87
+ * Get all jobs, optionally filtered.
88
+ */
89
+ getAll(filter) {
90
+ const jobs = Array.from(this._jobs.values());
91
+ if (!filter)
92
+ return jobs;
93
+ return jobs.filter((job) => {
94
+ if (filter.status !== undefined && job.status !== filter.status)
95
+ return false;
96
+ if (filter.type !== undefined && job.type !== filter.type)
97
+ return false;
98
+ if (filter.priority !== undefined && job.priority !== filter.priority)
99
+ return false;
100
+ return true;
101
+ });
102
+ }
103
+ /**
104
+ * Transition a job to a new status with optional data update.
105
+ * Validates the state transition. Returns updated job or null if invalid.
106
+ */
107
+ updateStatus(id, status, data) {
108
+ const job = this._jobs.get(id);
109
+ if (!job)
110
+ return null;
111
+ const allowed = VALID_TRANSITIONS[job.status];
112
+ if (!allowed || !allowed.includes(status))
113
+ return null;
114
+ const now = Date.now();
115
+ const updated = {
116
+ ...job,
117
+ status,
118
+ updatedAt: now,
119
+ ...data,
120
+ };
121
+ if (status === 'completed' || status === 'failed') {
122
+ updated.completedAt = now;
123
+ }
124
+ this._jobs.set(id, updated);
125
+ return updated;
126
+ }
127
+ /**
128
+ * Update the progress percentage (0–100) for a job.
129
+ * Returns the updated job or null if not found.
130
+ */
131
+ updateProgress(id, progress) {
132
+ const job = this._jobs.get(id);
133
+ if (!job)
134
+ return null;
135
+ const clamped = Math.min(100, Math.max(0, progress));
136
+ const updated = { ...job, progress: clamped, updatedAt: Date.now() };
137
+ this._jobs.set(id, updated);
138
+ return updated;
139
+ }
140
+ /**
141
+ * Number of jobs currently in 'pending' status.
142
+ */
143
+ get pendingCount() {
144
+ let count = 0;
145
+ for (const job of this._jobs.values()) {
146
+ if (job.status === 'pending')
147
+ count++;
148
+ }
149
+ return count;
150
+ }
151
+ /**
152
+ * Number of jobs currently in 'active' status.
153
+ */
154
+ get activeCount() {
155
+ let count = 0;
156
+ for (const job of this._jobs.values()) {
157
+ if (job.status === 'active')
158
+ count++;
159
+ }
160
+ return count;
161
+ }
162
+ /**
163
+ * Check if a job with the given ID exists.
164
+ */
165
+ has(id) {
166
+ return this._jobs.has(id);
167
+ }
168
+ /**
169
+ * Total number of jobs in the store.
170
+ */
171
+ get size() {
172
+ return this._jobs.size;
173
+ }
174
+ /**
175
+ * Clear all jobs from the store.
176
+ */
177
+ clear() {
178
+ this._jobs.clear();
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Tracks all known queue workers (local and remote).
184
+ */
185
+ class WorkerManager {
186
+ constructor() {
187
+ this._workers = new Map();
188
+ }
189
+ /**
190
+ * Add or replace a worker entry.
191
+ */
192
+ addWorker(worker) {
193
+ this._workers.set(worker.workerId, worker);
194
+ }
195
+ /**
196
+ * Remove a worker by workerId.
197
+ * Returns the removed worker, or null if not found.
198
+ */
199
+ removeWorker(workerId) {
200
+ const worker = this._workers.get(workerId) ?? null;
201
+ this._workers.delete(workerId);
202
+ return worker;
203
+ }
204
+ /**
205
+ * Get a worker by workerId.
206
+ */
207
+ getWorker(workerId) {
208
+ return this._workers.get(workerId);
209
+ }
210
+ /**
211
+ * Get all tracked workers.
212
+ */
213
+ getAll() {
214
+ return Array.from(this._workers.values());
215
+ }
216
+ /**
217
+ * Increment the active job count for a worker.
218
+ * Returns the updated worker or null if not found.
219
+ */
220
+ incrementActiveJobs(workerId) {
221
+ const worker = this._workers.get(workerId);
222
+ if (!worker)
223
+ return null;
224
+ const updated = { ...worker, activeJobs: worker.activeJobs + 1 };
225
+ this._workers.set(workerId, updated);
226
+ return updated;
227
+ }
228
+ /**
229
+ * Decrement the active job count for a worker (floor 0).
230
+ * Returns the updated worker or null if not found.
231
+ */
232
+ decrementActiveJobs(workerId) {
233
+ const worker = this._workers.get(workerId);
234
+ if (!worker)
235
+ return null;
236
+ const updated = {
237
+ ...worker,
238
+ activeJobs: Math.max(0, worker.activeJobs - 1),
239
+ };
240
+ this._workers.set(workerId, updated);
241
+ return updated;
242
+ }
243
+ /**
244
+ * Check whether a worker can accept more work (activeJobs < concurrency).
245
+ */
246
+ canAcceptWork(workerId) {
247
+ const worker = this._workers.get(workerId);
248
+ if (!worker)
249
+ return false;
250
+ return worker.activeJobs < worker.concurrency;
251
+ }
252
+ /**
253
+ * Clear all tracked workers.
254
+ */
255
+ clear() {
256
+ this._workers.clear();
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Maps actorTokenId ↔ QueueWorker, filtering self.
262
+ */
263
+ class PresenceManager {
264
+ constructor(localActorId) {
265
+ this._workers = new Map();
266
+ this._actorToWorkerId = new Map();
267
+ this._localActorId = localActorId;
268
+ }
269
+ /**
270
+ * Add or update a worker from presence data.
271
+ * Returns the QueueWorker if it's a remote worker, null if it's self.
272
+ */
273
+ addFromPresence(actorTokenId, presence, joinedAt) {
274
+ const isLocal = actorTokenId === this._localActorId;
275
+ // Skip self
276
+ if (isLocal)
277
+ return null;
278
+ const existing = this._actorToWorkerId.get(actorTokenId);
279
+ const workerId = presence.workerId || existing || actorTokenId;
280
+ const worker = {
281
+ workerId,
282
+ actorTokenId,
283
+ role: presence.role,
284
+ activeJobs: presence.activeJobs ?? 0,
285
+ concurrency: presence.concurrency ?? 1,
286
+ metadata: presence.metadata,
287
+ joinedAt: joinedAt || Date.now(),
288
+ isLocal: false,
289
+ };
290
+ this._workers.set(workerId, worker);
291
+ this._actorToWorkerId.set(actorTokenId, workerId);
292
+ return worker;
293
+ }
294
+ /**
295
+ * Remove a worker by actorTokenId.
296
+ * Returns the removed worker, or null if not found / is self.
297
+ */
298
+ removeByActorId(actorTokenId) {
299
+ if (actorTokenId === this._localActorId)
300
+ return null;
301
+ const workerId = this._actorToWorkerId.get(actorTokenId);
302
+ if (!workerId)
303
+ return null;
304
+ const worker = this._workers.get(workerId) ?? null;
305
+ this._workers.delete(workerId);
306
+ this._actorToWorkerId.delete(actorTokenId);
307
+ return worker;
308
+ }
309
+ /**
310
+ * Get a worker by workerId.
311
+ */
312
+ getWorker(workerId) {
313
+ return this._workers.get(workerId);
314
+ }
315
+ /**
316
+ * Get a worker by actorTokenId.
317
+ */
318
+ getWorkerByActorId(actorTokenId) {
319
+ const workerId = this._actorToWorkerId.get(actorTokenId);
320
+ return workerId ? this._workers.get(workerId) : undefined;
321
+ }
322
+ /**
323
+ * Get all remote workers.
324
+ */
325
+ getAll() {
326
+ return Array.from(this._workers.values());
327
+ }
328
+ /**
329
+ * Get the workers Map (readonly view).
330
+ */
331
+ get workers() {
332
+ return this._workers;
333
+ }
334
+ /**
335
+ * Clear all tracked workers.
336
+ */
337
+ clear() {
338
+ this._workers.clear();
339
+ this._actorToWorkerId.clear();
340
+ }
341
+ }
342
+
343
+ function generateId() {
344
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
345
+ return crypto.randomUUID();
346
+ }
347
+ return 'xxxx-xxxx-xxxx-xxxx'.replace(/x/g, () => Math.floor(Math.random() * 16).toString(16));
348
+ }
349
+ function createLogger(prefix, enabled) {
350
+ if (!enabled) {
351
+ return (..._args) => { };
352
+ }
353
+ return (...args) => {
354
+ console.log(`[${prefix}]`, ...args);
355
+ };
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
+ }
380
+
381
+ /** Default app name for NoLag queue SDK */
382
+ const DEFAULT_APP_NAME = 'queue';
383
+ /** Default maximum number of jobs to cache in memory */
384
+ const DEFAULT_MAX_JOB_CACHE = 1000;
385
+ /** Default maximum number of attempts before a job is permanently failed */
386
+ const DEFAULT_MAX_ATTEMPTS = 3;
387
+ /** Topic name for job lifecycle messages within a queue room */
388
+ const TOPIC_JOBS = 'jobs';
389
+ /** Topic name for job progress updates within a queue room */
390
+ const TOPIC_PROGRESS = '_progress';
391
+ /** Lobby ID for global online presence */
392
+ const LOBBY_ID = 'online';
393
+ /** Delay before the post-setup lobby presence refetch (catches simultaneous joiners) */
394
+ const LOBBY_REFRESH_DELAY_MS = 2000;
395
+
396
+ /**
397
+ * QueueRoom — a single named queue with job lifecycle, progress tracking, and worker presence.
398
+ *
399
+ * Created via `NoLagQueue.joinQueue(name)`. Do not instantiate directly.
400
+ */
401
+ class QueueRoom extends EventEmitter {
402
+ /** @internal */
403
+ constructor(name, roomContext, localWorkerId, options, log, isConnected) {
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;
409
+ this.name = name;
410
+ this._roomContext = roomContext;
411
+ this._localWorkerId = localWorkerId;
412
+ this._options = options;
413
+ this._log = log;
414
+ this._isConnected = isConnected;
415
+ this._jobStore = new JobStore(options.maxJobCache);
416
+ this._workerManager = new WorkerManager();
417
+ this._presenceManager = new PresenceManager(''); // local actor set after connect
418
+ }
419
+ /** @internal Set the local actor ID once connected */
420
+ _setLocalActorId(actorId) {
421
+ this._presenceManager = new PresenceManager(actorId);
422
+ }
423
+ // ============ Producer Methods ============
424
+ /**
425
+ * Add a new job to the queue. Only producers should call this.
426
+ */
427
+ addJob(opts) {
428
+ const now = Date.now();
429
+ const job = {
430
+ id: generateId(),
431
+ type: opts.type,
432
+ payload: opts.payload,
433
+ priority: opts.priority ?? 'normal',
434
+ status: 'pending',
435
+ progress: 0,
436
+ attempts: 0,
437
+ maxAttempts: opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
438
+ createdBy: this._localWorkerId,
439
+ createdAt: now,
440
+ updatedAt: now,
441
+ isReplay: false,
442
+ };
443
+ this._jobStore.add(job);
444
+ this._log('Job added:', job.id, job.type);
445
+ this._roomContext.emit(TOPIC_JOBS, { event: 'jobAdded', job }, { echo: true });
446
+ this.emit('jobAdded', job);
447
+ return job;
448
+ }
449
+ // ============ Worker Methods ============
450
+ /**
451
+ * Claim a pending job. Only workers should call this.
452
+ */
453
+ claimJob(jobId) {
454
+ const updated = this._jobStore.updateStatus(jobId, 'claimed', {
455
+ claimedBy: this._localWorkerId,
456
+ });
457
+ if (!updated)
458
+ return null;
459
+ this._log('Job claimed:', jobId, 'by', this._localWorkerId);
460
+ this._roomContext.emit(TOPIC_JOBS, { event: 'jobClaimed', job: updated }, { echo: true });
461
+ this.emit('jobClaimed', updated);
462
+ return updated;
463
+ }
464
+ /**
465
+ * Report progress on an active job (0–100).
466
+ */
467
+ reportProgress(jobId, progress) {
468
+ const job = this._jobStore.updateProgress(jobId, progress);
469
+ if (!job)
470
+ return;
471
+ const progressEvent = {
472
+ jobId,
473
+ progress: job.progress,
474
+ workerId: this._localWorkerId,
475
+ timestamp: Date.now(),
476
+ };
477
+ this._log('Job progress:', jobId, job.progress + '%');
478
+ this._roomContext.emit(TOPIC_PROGRESS, progressEvent, { echo: true });
479
+ this.emit('jobProgress', progressEvent);
480
+ }
481
+ /**
482
+ * Mark a claimed/active job as completed with an optional result.
483
+ */
484
+ completeJob(jobId, result) {
485
+ // Transition claimed → active → completed in one step for simplicity
486
+ let updated = this._jobStore.updateStatus(jobId, 'active');
487
+ if (!updated) {
488
+ // Already active — go straight to completed
489
+ updated = this._jobStore.get(jobId) ?? null;
490
+ }
491
+ if (!updated)
492
+ return null;
493
+ const completed = this._jobStore.updateStatus(jobId, 'completed', { result });
494
+ if (!completed)
495
+ return null;
496
+ this._log('Job completed:', jobId);
497
+ this._roomContext.emit(TOPIC_JOBS, { event: 'jobCompleted', job: completed }, { echo: true });
498
+ this.emit('jobCompleted', completed);
499
+ return completed;
500
+ }
501
+ /**
502
+ * Mark an active job as failed with an optional error message.
503
+ * Automatically retries if attempts < maxAttempts.
504
+ */
505
+ failJob(jobId, error) {
506
+ const job = this._jobStore.get(jobId);
507
+ if (!job)
508
+ return null;
509
+ // Move to active if still claimed
510
+ if (job.status === 'claimed') {
511
+ this._jobStore.updateStatus(jobId, 'active');
512
+ }
513
+ const nextAttempts = job.attempts + 1;
514
+ const failed = this._jobStore.updateStatus(jobId, 'failed', {
515
+ error,
516
+ attempts: nextAttempts,
517
+ });
518
+ if (!failed)
519
+ return null;
520
+ this._log('Job failed:', jobId, 'attempts:', nextAttempts, '/', failed.maxAttempts);
521
+ this._roomContext.emit(TOPIC_JOBS, { event: 'jobFailed', job: failed }, { echo: true });
522
+ this.emit('jobFailed', failed);
523
+ // Auto-retry if under maxAttempts
524
+ if (nextAttempts < failed.maxAttempts) {
525
+ const retried = this._jobStore.updateStatus(jobId, 'pending');
526
+ if (retried) {
527
+ this._log('Job retrying:', jobId, 'attempt', nextAttempts + 1);
528
+ this._roomContext.emit(TOPIC_JOBS, { event: 'jobRetrying', job: retried }, { echo: true });
529
+ this.emit('jobRetrying', retried);
530
+ }
531
+ }
532
+ return failed;
533
+ }
534
+ // ============ Monitor / Query Methods ============
535
+ /**
536
+ * Get a single job by ID.
537
+ */
538
+ getJob(id) {
539
+ return this._jobStore.get(id);
540
+ }
541
+ /**
542
+ * Get all jobs, optionally filtered.
543
+ */
544
+ getJobs(filter) {
545
+ return this._jobStore.getAll(filter);
546
+ }
547
+ /**
548
+ * Number of pending jobs.
549
+ */
550
+ get pendingCount() {
551
+ return this._jobStore.pendingCount;
552
+ }
553
+ /**
554
+ * Number of active jobs.
555
+ */
556
+ get activeCount() {
557
+ return this._jobStore.activeCount;
558
+ }
559
+ /**
560
+ * All workers currently in this queue room.
561
+ */
562
+ get workers() {
563
+ return this._presenceManager.workers;
564
+ }
565
+ /**
566
+ * Get all workers in this queue room.
567
+ */
568
+ getWorkers() {
569
+ return this._presenceManager.getAll();
570
+ }
571
+ // ============ Internal (called by NoLagQueue) ============
572
+ /** @internal Subscribe to jobs and progress topics, attach listeners */
573
+ _subscribe() {
574
+ this._log('Room subscribe:', this.name);
575
+ // Workers use load balancing so each job event is delivered to only ONE worker
576
+ // (round-robin across all workers in the same group). Producers and monitors
577
+ // receive all messages so they can track full queue state.
578
+ if (this._options.role === 'worker') {
579
+ const group = this._options.loadBalanceGroup ?? `queue-workers-${this.name}`;
580
+ this._log('Subscribing with load balance, group:', group);
581
+ this._roomContext.subscribe(TOPIC_JOBS, { loadBalance: true, loadBalanceGroup: group });
582
+ }
583
+ else {
584
+ this._roomContext.subscribe(TOPIC_JOBS);
585
+ }
586
+ this._roomContext.subscribe(TOPIC_PROGRESS);
587
+ // Listen for job lifecycle messages (refs stored for handler-specific removal)
588
+ this._onJobsRef = (data) => {
589
+ this._handleJobMessage(data);
590
+ };
591
+ this._roomContext.on(TOPIC_JOBS, this._onJobsRef);
592
+ this._onProgressRef = (data) => {
593
+ this._handleProgressMessage(data);
594
+ };
595
+ this._roomContext.on(TOPIC_PROGRESS, this._onProgressRef);
596
+ }
597
+ /** @internal Set presence and fetch room members */
598
+ _activate() {
599
+ this._log('Room activate:', this.name);
600
+ this._setPresence();
601
+ this._roomContext.fetchPresence().then((actors) => {
602
+ this._log('Room presence fetched:', this.name, actors.length, 'actors');
603
+ for (const actor of actors) {
604
+ if (actor.presence) {
605
+ const worker = this._presenceManager.addFromPresence(actor.actorTokenId, actor.presence, actor.joinedAt);
606
+ if (worker) {
607
+ this._workerManager.addWorker(worker);
608
+ this.emit('workerJoined', worker);
609
+ }
610
+ }
611
+ }
612
+ }).catch((err) => {
613
+ this._log('Failed to fetch room presence:', err);
614
+ });
615
+ }
616
+ /** @internal Re-set presence after reconnect */
617
+ _updateLocalPresence() {
618
+ this._setPresence();
619
+ }
620
+ /** @internal Handle a presence:join event */
621
+ _handlePresenceJoin(actorTokenId, presenceData) {
622
+ const worker = this._presenceManager.addFromPresence(actorTokenId, presenceData);
623
+ if (worker) {
624
+ this._log('Worker joined queue:', this.name, worker.workerId);
625
+ this._workerManager.addWorker(worker);
626
+ this.emit('workerJoined', worker);
627
+ }
628
+ }
629
+ /** @internal Handle a presence:leave event */
630
+ _handlePresenceLeave(actorTokenId) {
631
+ const worker = this._presenceManager.removeByActorId(actorTokenId);
632
+ if (worker) {
633
+ this._log('Worker left queue:', this.name, worker.workerId);
634
+ this._workerManager.removeWorker(worker.workerId);
635
+ this.emit('workerLeft', worker);
636
+ }
637
+ }
638
+ /** @internal Handle a presence:update event */
639
+ _handlePresenceUpdate(actorTokenId, presenceData) {
640
+ const worker = this._presenceManager.addFromPresence(actorTokenId, presenceData);
641
+ if (worker) {
642
+ this._workerManager.addWorker(worker);
643
+ }
644
+ }
645
+ /** @internal Unsubscribe and clean up */
646
+ _cleanup() {
647
+ this._log('Room cleanup:', this.name);
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;
662
+ this._jobStore.clear();
663
+ this._workerManager.clear();
664
+ this._presenceManager.clear();
665
+ this.removeAllListeners();
666
+ }
667
+ // ============ Private ============
668
+ _handleJobMessage(data) {
669
+ const msg = data;
670
+ if (!msg?.event || !msg?.job)
671
+ return;
672
+ const { event, job } = msg;
673
+ this._log('Job message:', event, job.id);
674
+ switch (event) {
675
+ case 'jobAdded':
676
+ if (this._jobStore.add(job)) {
677
+ this.emit('jobAdded', job);
678
+ }
679
+ break;
680
+ case 'jobClaimed': {
681
+ const existing = this._jobStore.get(job.id);
682
+ if (existing) {
683
+ this._jobStore.updateStatus(job.id, 'claimed', { claimedBy: job.claimedBy });
684
+ const updated = this._jobStore.get(job.id);
685
+ this.emit('jobClaimed', updated);
686
+ }
687
+ break;
688
+ }
689
+ case 'jobCompleted': {
690
+ const existing = this._jobStore.get(job.id);
691
+ if (existing) {
692
+ // Sync final state directly since remote already processed transitions
693
+ const synced = { ...existing, ...job };
694
+ this._jobStore.add(synced);
695
+ this.emit('jobCompleted', synced);
696
+ }
697
+ break;
698
+ }
699
+ case 'jobFailed': {
700
+ const existing = this._jobStore.get(job.id);
701
+ if (existing) {
702
+ const synced = { ...existing, ...job };
703
+ this._jobStore.add(synced);
704
+ this.emit('jobFailed', synced);
705
+ }
706
+ break;
707
+ }
708
+ case 'jobRetrying': {
709
+ const existing = this._jobStore.get(job.id);
710
+ if (existing) {
711
+ const synced = { ...existing, ...job };
712
+ this._jobStore.add(synced);
713
+ this.emit('jobRetrying', synced);
714
+ }
715
+ break;
716
+ }
717
+ }
718
+ }
719
+ _handleProgressMessage(data) {
720
+ const progress = data;
721
+ if (!progress?.jobId)
722
+ return;
723
+ this._jobStore.updateProgress(progress.jobId, progress.progress);
724
+ this._log('Job progress update:', progress.jobId, progress.progress + '%');
725
+ this.emit('jobProgress', progress);
726
+ }
727
+ _setPresence() {
728
+ const presenceData = {
729
+ workerId: this._localWorkerId,
730
+ role: this._options.role,
731
+ activeJobs: 0,
732
+ concurrency: this._options.concurrency,
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,
737
+ };
738
+ this._roomContext.setPresence(presenceData);
739
+ }
740
+ }
741
+
742
+ /**
743
+ * NoLagQueue — high-level real-time job queue SDK built on @nolag/js-sdk.
744
+ *
745
+ * Provides job lifecycle management, progress tracking, worker management,
746
+ * and global presence tracking — all framework-agnostic via events.
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
+ *
752
+ * @example
753
+ * ```typescript
754
+ * import { NoLag } from '@nolag/js-sdk';
755
+ * import { NoLagQueue } from '@nolag/queue';
756
+ *
757
+ * const client = NoLag(async () => (await (await fetch('/api/nolag-token')).json()).token);
758
+ * const queue = new NoLagQueue({ client, role: 'worker', concurrency: 2 });
759
+ *
760
+ * queue.on('connected', () => console.log('Connected!'));
761
+ *
762
+ * await client.connect(); // the app owns the connection
763
+ * await queue.ready(); // wrapper setup done (identity, lobby, queues)
764
+ *
765
+ * const room = queue.joinQueue('image-processing');
766
+ * room.on('jobAdded', (job) => {
767
+ * room.claimJob(job.id);
768
+ * room.reportProgress(job.id, 50);
769
+ * room.completeJob(job.id, { output: 'result' });
770
+ * });
771
+ *
772
+ * queue.detach(); // wrapper releases its handlers and topics
773
+ * client.disconnect(); // the app closes the socket
774
+ * ```
775
+ */
776
+ class NoLagQueue extends EventEmitter {
777
+ constructor(options) {
778
+ super();
779
+ this._localWorker = null;
780
+ this._queues = new Map();
781
+ this._lobby = null;
782
+ this._onlineWorkers = new Map();
783
+ this._actorToWorkerId = new Map();
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;
815
+ this._workerId = options.workerId ?? generateId();
816
+ this._options = {
817
+ workerId: this._workerId,
818
+ role: options.role ?? 'monitor',
819
+ concurrency: options.concurrency ?? 1,
820
+ metadata: options.metadata,
821
+ appName: options.appName ?? DEFAULT_APP_NAME,
822
+ maxJobCache: options.maxJobCache ?? DEFAULT_MAX_JOB_CACHE,
823
+ debug: options.debug ?? false,
824
+ queues: options.queues ?? [],
825
+ loadBalanceGroup: options.loadBalanceGroup,
826
+ };
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
+ });
854
+ }
855
+ // ============ Public Properties ============
856
+ /** Whether the underlying connection is established (connected ≠ ready) */
857
+ get connected() {
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;
863
+ }
864
+ /** The local worker's info (available after ready) */
865
+ get localWorker() {
866
+ return this._localWorker;
867
+ }
868
+ /** All currently joined queue rooms */
869
+ get queues() {
870
+ return this._queues;
871
+ }
872
+ // ============ Lifecycle ============
873
+ /**
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.
878
+ */
879
+ ready() {
880
+ return this._readyPromise;
881
+ }
882
+ /**
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.
887
+ */
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
910
+ for (const name of [...this._queues.keys()]) {
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
+ }
922
+ }
923
+ this._lobby = null;
924
+ this._onlineWorkers.clear();
925
+ this._actorToWorkerId.clear();
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);
1027
+ }
1028
+ // ============ Queue Management ============
1029
+ /**
1030
+ * Join a queue room. Creates, subscribes, and activates it.
1031
+ * Returns an existing room if already joined.
1032
+ */
1033
+ joinQueue(name) {
1034
+ this._assertUsable();
1035
+ let room = this._queues.get(name);
1036
+ if (!room) {
1037
+ room = this._subscribeQueue(name);
1038
+ room._activate();
1039
+ }
1040
+ return room;
1041
+ }
1042
+ /**
1043
+ * Leave a queue room. Fully unsubscribes and removes it.
1044
+ */
1045
+ leaveQueue(name) {
1046
+ const room = this._queues.get(name);
1047
+ if (!room)
1048
+ return;
1049
+ this._log('Leaving queue:', name);
1050
+ room._cleanup();
1051
+ this._queues.delete(name);
1052
+ }
1053
+ /**
1054
+ * Get all joined queue rooms.
1055
+ */
1056
+ getQueues() {
1057
+ return Array.from(this._queues.values());
1058
+ }
1059
+ // ============ Global Presence ============
1060
+ /**
1061
+ * Get all workers currently online across all queue rooms.
1062
+ */
1063
+ getOnlineWorkers() {
1064
+ return Array.from(this._onlineWorkers.values());
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
+ }
1075
+ // ============ Private: Queue Setup ============
1076
+ _subscribeQueue(name) {
1077
+ this._log('Subscribing queue:', name);
1078
+ const roomContext = this._client.setApp(this._options.appName).setRoom(name);
1079
+ const room = new QueueRoom(name, roomContext, this._workerId, this._options, createLogger(`QueueRoom:${name}`, this._options.debug), () => this._client.connected);
1080
+ room._setLocalActorId(this._localWorker.actorTokenId);
1081
+ this._queues.set(name, room);
1082
+ room._subscribe();
1083
+ return room;
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
+ }
1096
+ // ============ Private: Room Presence ============
1097
+ _handleRoomPresenceJoin(data) {
1098
+ if (data.actorTokenId === this._localWorker?.actorTokenId)
1099
+ return;
1100
+ const presenceData = data.presence;
1101
+ if (!presenceData?.workerId || this._foreignScope(presenceData))
1102
+ return;
1103
+ const worker = this._presenceToWorker(data.actorTokenId, presenceData);
1104
+ this._actorToWorkerId.set(data.actorTokenId, worker.workerId);
1105
+ if (!this._onlineWorkers.has(worker.workerId)) {
1106
+ this._onlineWorkers.set(worker.workerId, worker);
1107
+ this.emit('workerOnline', worker);
1108
+ }
1109
+ // Route to all queue rooms
1110
+ for (const room of this._queues.values()) {
1111
+ room._handlePresenceJoin(data.actorTokenId, presenceData);
1112
+ }
1113
+ }
1114
+ _handleRoomPresenceLeave(data) {
1115
+ if (data.actorTokenId === this._localWorker?.actorTokenId)
1116
+ return;
1117
+ // Route to all queue rooms
1118
+ for (const room of this._queues.values()) {
1119
+ room._handlePresenceLeave(data.actorTokenId);
1120
+ }
1121
+ }
1122
+ _handleRoomPresenceUpdate(data) {
1123
+ if (data.actorTokenId === this._localWorker?.actorTokenId)
1124
+ return;
1125
+ const presenceData = data.presence;
1126
+ if (!presenceData?.workerId || this._foreignScope(presenceData))
1127
+ return;
1128
+ if (this._onlineWorkers.has(presenceData.workerId)) {
1129
+ const worker = this._presenceToWorker(data.actorTokenId, presenceData);
1130
+ this._onlineWorkers.set(worker.workerId, worker);
1131
+ }
1132
+ // Route to all queue rooms
1133
+ for (const room of this._queues.values()) {
1134
+ room._handlePresenceUpdate(data.actorTokenId, presenceData);
1135
+ }
1136
+ }
1137
+ // ============ Private: Lobby ============
1138
+ _handleLobbyJoin(event) {
1139
+ const { actorId, data } = event;
1140
+ if (actorId === this._localWorker?.actorTokenId)
1141
+ return;
1142
+ const presenceData = data;
1143
+ if (!presenceData?.workerId || this._foreignScope(presenceData))
1144
+ return;
1145
+ const worker = this._presenceToWorker(actorId, presenceData);
1146
+ this._actorToWorkerId.set(actorId, worker.workerId);
1147
+ if (!this._onlineWorkers.has(worker.workerId)) {
1148
+ this._onlineWorkers.set(worker.workerId, worker);
1149
+ this.emit('workerOnline', worker);
1150
+ }
1151
+ }
1152
+ _handleLobbyLeave(event) {
1153
+ const { actorId, data } = event;
1154
+ if (actorId === this._localWorker?.actorTokenId)
1155
+ return;
1156
+ const presenceData = data;
1157
+ if (this._foreignScope(presenceData))
1158
+ return;
1159
+ const workerId = presenceData?.workerId
1160
+ || this._actorToWorkerId.get(actorId)
1161
+ || this._findWorkerIdByActorId(actorId);
1162
+ if (workerId) {
1163
+ const worker = this._onlineWorkers.get(workerId);
1164
+ if (worker) {
1165
+ this._onlineWorkers.delete(workerId);
1166
+ this._actorToWorkerId.delete(actorId);
1167
+ this.emit('workerOffline', worker);
1168
+ }
1169
+ }
1170
+ }
1171
+ _handleLobbyUpdate(event) {
1172
+ const { actorId, data } = event;
1173
+ if (actorId === this._localWorker?.actorTokenId)
1174
+ return;
1175
+ const presenceData = data;
1176
+ if (!presenceData?.workerId || this._foreignScope(presenceData))
1177
+ return;
1178
+ const worker = this._presenceToWorker(actorId, presenceData);
1179
+ this._onlineWorkers.set(worker.workerId, worker);
1180
+ }
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();
1190
+ for (const roomId of Object.keys(state)) {
1191
+ const roomPresence = state[roomId];
1192
+ for (const actorId of Object.keys(roomPresence)) {
1193
+ if (actorId === this._localWorker?.actorTokenId)
1194
+ continue;
1195
+ const raw = roomPresence[actorId];
1196
+ // Server returns full actor records with presence nested under .presence
1197
+ const presenceData = (raw?.presence ?? raw);
1198
+ if (presenceData?.workerId && !this._foreignScope(presenceData)) {
1199
+ if (!fresh.has(presenceData.workerId)) {
1200
+ fresh.set(presenceData.workerId, this._presenceToWorker(actorId, presenceData));
1201
+ }
1202
+ freshActors.set(actorId, presenceData.workerId);
1203
+ }
1204
+ }
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
+ }
1227
+ }
1228
+ // ============ Private: Helpers ============
1229
+ _presenceToWorker(actorTokenId, data) {
1230
+ return {
1231
+ workerId: data.workerId,
1232
+ actorTokenId,
1233
+ role: data.role,
1234
+ activeJobs: data.activeJobs ?? 0,
1235
+ concurrency: data.concurrency ?? 1,
1236
+ metadata: data.metadata,
1237
+ joinedAt: Date.now(),
1238
+ isLocal: false,
1239
+ };
1240
+ }
1241
+ _findWorkerIdByActorId(actorTokenId) {
1242
+ for (const worker of this._onlineWorkers.values()) {
1243
+ if (worker.actorTokenId === actorTokenId)
1244
+ return worker.workerId;
1245
+ }
1246
+ return undefined;
1247
+ }
1248
+ }
1249
+
1250
+ export { EventEmitter, JobStore, NoLagQueue, PresenceManager, QueueRoom, WorkerManager };
1251
+ //# sourceMappingURL=react-native.js.map