@nolag/queue 1.0.0 → 1.2.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,1387 @@
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
+ // ============ Filters ============
358
+ /**
359
+ * Build the filter fragment of an emit options object.
360
+ *
361
+ * `filter` wins over `filters`: a publish is routed to exactly one topic, so
362
+ * honouring both would silently drop one of them.
363
+ */
364
+ function filterEmitOptions(opts) {
365
+ if (opts?.filter)
366
+ return { filter: opts.filter };
367
+ if (opts?.filters && opts.filters.length > 0)
368
+ return { filters: opts.filters };
369
+ return {};
370
+ }
371
+ /**
372
+ * Rebuild publish options from the filter a message arrived with, so a reply
373
+ * to it reaches the same audience the original did.
374
+ *
375
+ * The server joins AND groups into one composite value with '|', which is not
376
+ * a legal character in a plain filter, so split those back apart.
377
+ */
378
+ function inheritFilter(filter) {
379
+ if (!filter)
380
+ return {};
381
+ if (filter.includes('|'))
382
+ return { filters: filter.split('|') };
383
+ return { filter };
384
+ }
385
+ /**
386
+ * Merge OR terms into an existing filter set. AND groups (nested arrays) are
387
+ * preserved as-is — only plain string terms are deduplicated.
388
+ */
389
+ function mergeFilters(existing, add) {
390
+ const simple = new Set();
391
+ const groups = [];
392
+ for (const f of existing) {
393
+ if (typeof f === 'string')
394
+ simple.add(f);
395
+ else
396
+ groups.push(f);
397
+ }
398
+ for (const v of add)
399
+ simple.add(v);
400
+ return [...simple, ...groups];
401
+ }
402
+ /**
403
+ * Drop OR terms from a filter set. AND groups are left untouched — remove
404
+ * those by calling `setFilters` with the set you want.
405
+ */
406
+ function withoutFilters(existing, remove) {
407
+ const drop = new Set(remove);
408
+ return existing.filter((f) => typeof f !== 'string' || !drop.has(f));
409
+ }
410
+ /**
411
+ * The composite key the server derives from an AND filter group: values are
412
+ * lowercased, sorted, and joined with '|'. Mirrored here so an item created
413
+ * locally carries the same filter string as one arriving off the wire.
414
+ */
415
+ function compositeFilterKey(values) {
416
+ return [...values].map((v) => v.toLowerCase()).sort().join('|');
417
+ }
418
+ /**
419
+ * The single string form of whatever filter a publish used, for recording on
420
+ * the local copy of an item. Round-trips through `inheritFilter`.
421
+ */
422
+ function recordedFilter(opts) {
423
+ if (opts?.filter)
424
+ return opts.filter;
425
+ if (opts?.filters && opts.filters.length > 0)
426
+ return compositeFilterKey(opts.filters);
427
+ return undefined;
428
+ }
429
+ // ============ Wrapper registry ============
430
+ // One wrapper instance per (client, appName): two wrappers sharing an app on
431
+ // one connection would collide on topics, presence and the online lobby.
432
+ // Warn (not throw): HMR and tests legitimately construct before disposing.
433
+ const wrapperRegistry = new WeakMap();
434
+ /** Register a wrapper against a client + appName; warns on collision. */
435
+ function registerWrapper(client, appName, wrapperName) {
436
+ let apps = wrapperRegistry.get(client);
437
+ if (!apps) {
438
+ apps = new Map();
439
+ wrapperRegistry.set(client, apps);
440
+ }
441
+ const existing = apps.get(appName);
442
+ if (existing) {
443
+ console.warn(`[${wrapperName}] Another wrapper (${existing}) is already attached to this client for app "${appName}". ` +
444
+ `Use one wrapper per (client, app) — detach the other instance first.`);
445
+ }
446
+ apps.set(appName, wrapperName);
447
+ }
448
+ /** Release a wrapper's (client, appName) registration on detach. */
449
+ function releaseWrapper(client, appName) {
450
+ wrapperRegistry.get(client)?.delete(appName);
451
+ }
452
+
453
+ /** Default app name for NoLag queue SDK */
454
+ const DEFAULT_APP_NAME = 'queue';
455
+ /** Default maximum number of jobs to cache in memory */
456
+ const DEFAULT_MAX_JOB_CACHE = 1000;
457
+ /** Default maximum number of attempts before a job is permanently failed */
458
+ const DEFAULT_MAX_ATTEMPTS = 3;
459
+ /** Topic name for job lifecycle messages within a queue room */
460
+ const TOPIC_JOBS = 'jobs';
461
+ /** Topic name for job progress updates within a queue room */
462
+ const TOPIC_PROGRESS = '_progress';
463
+ /** Lobby ID for global online presence */
464
+ const LOBBY_ID = 'online';
465
+ /** Delay before the post-setup lobby presence refetch (catches simultaneous joiners) */
466
+ const LOBBY_REFRESH_DELAY_MS = 2000;
467
+
468
+ /**
469
+ * QueueRoom — a single named queue with job lifecycle, progress tracking, and worker presence.
470
+ *
471
+ * Created via `NoLagQueue.joinQueue(name)`. Do not instantiate directly.
472
+ */
473
+ class QueueRoom extends EventEmitter {
474
+ /** @internal */
475
+ constructor(name, roomContext, localWorkerId, options, log, isConnected) {
476
+ super();
477
+ // Stored topic handler refs — cleanup removes exactly these, never all
478
+ // handlers for a topic (the client may be shared with other consumers).
479
+ this._onJobsRef = null;
480
+ this._onProgressRef = null;
481
+ /** Filter values applied to the jobs subscription. */
482
+ this._filters = [];
483
+ this.name = name;
484
+ this._roomContext = roomContext;
485
+ this._localWorkerId = localWorkerId;
486
+ this._options = options;
487
+ this._log = log;
488
+ this._isConnected = isConnected;
489
+ this._jobStore = new JobStore(options.maxJobCache);
490
+ this._workerManager = new WorkerManager();
491
+ this._presenceManager = new PresenceManager(''); // local actor set after connect
492
+ }
493
+ /** @internal Set the local actor ID once connected */
494
+ _setLocalActorId(actorId) {
495
+ this._presenceManager = new PresenceManager(actorId);
496
+ }
497
+ // ============ Producer Methods ============
498
+ /**
499
+ * Add a new job to the queue. Only producers should call this.
500
+ */
501
+ addJob(opts) {
502
+ const now = Date.now();
503
+ const job = {
504
+ id: generateId(),
505
+ type: opts.type,
506
+ payload: opts.payload,
507
+ priority: opts.priority ?? 'normal',
508
+ status: 'pending',
509
+ progress: 0,
510
+ attempts: 0,
511
+ maxAttempts: opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
512
+ createdBy: this._localWorkerId,
513
+ createdAt: now,
514
+ updatedAt: now,
515
+ filter: recordedFilter(opts),
516
+ isReplay: false,
517
+ };
518
+ this._jobStore.add(job);
519
+ this._log('Job added:', job.id, job.type);
520
+ this._roomContext.emit(TOPIC_JOBS, { event: 'jobAdded', job }, { echo: true, ...filterEmitOptions(opts) });
521
+ this.emit('jobAdded', job);
522
+ return job;
523
+ }
524
+ // ============ Worker Methods ============
525
+ /**
526
+ * Claim a pending job. Only workers should call this.
527
+ */
528
+ claimJob(jobId) {
529
+ const updated = this._jobStore.updateStatus(jobId, 'claimed', {
530
+ claimedBy: this._localWorkerId,
531
+ });
532
+ if (!updated)
533
+ return null;
534
+ this._log('Job claimed:', jobId, 'by', this._localWorkerId);
535
+ this._roomContext.emit(TOPIC_JOBS, { event: 'jobClaimed', job: updated }, { echo: true, ...inheritFilter(updated.filter) });
536
+ this.emit('jobClaimed', updated);
537
+ return updated;
538
+ }
539
+ /**
540
+ * Report progress on an active job (0–100).
541
+ */
542
+ reportProgress(jobId, progress) {
543
+ const job = this._jobStore.updateProgress(jobId, progress);
544
+ if (!job)
545
+ return;
546
+ const progressEvent = {
547
+ jobId,
548
+ progress: job.progress,
549
+ workerId: this._localWorkerId,
550
+ timestamp: Date.now(),
551
+ };
552
+ this._log('Job progress:', jobId, job.progress + '%');
553
+ this._roomContext.emit(TOPIC_PROGRESS, progressEvent, { echo: true, ...inheritFilter(job.filter) });
554
+ this.emit('jobProgress', progressEvent);
555
+ }
556
+ /**
557
+ * Mark a claimed/active job as completed with an optional result.
558
+ */
559
+ completeJob(jobId, result) {
560
+ // Transition claimed → active → completed in one step for simplicity
561
+ let updated = this._jobStore.updateStatus(jobId, 'active');
562
+ if (!updated) {
563
+ // Already active — go straight to completed
564
+ updated = this._jobStore.get(jobId) ?? null;
565
+ }
566
+ if (!updated)
567
+ return null;
568
+ const completed = this._jobStore.updateStatus(jobId, 'completed', { result });
569
+ if (!completed)
570
+ return null;
571
+ this._log('Job completed:', jobId);
572
+ this._roomContext.emit(TOPIC_JOBS, { event: 'jobCompleted', job: completed }, { echo: true, ...inheritFilter(completed.filter) });
573
+ this.emit('jobCompleted', completed);
574
+ return completed;
575
+ }
576
+ /**
577
+ * Mark an active job as failed with an optional error message.
578
+ * Automatically retries if attempts < maxAttempts.
579
+ */
580
+ failJob(jobId, error) {
581
+ const job = this._jobStore.get(jobId);
582
+ if (!job)
583
+ return null;
584
+ // Move to active if still claimed
585
+ if (job.status === 'claimed') {
586
+ this._jobStore.updateStatus(jobId, 'active');
587
+ }
588
+ const nextAttempts = job.attempts + 1;
589
+ const failed = this._jobStore.updateStatus(jobId, 'failed', {
590
+ error,
591
+ attempts: nextAttempts,
592
+ });
593
+ if (!failed)
594
+ return null;
595
+ this._log('Job failed:', jobId, 'attempts:', nextAttempts, '/', failed.maxAttempts);
596
+ this._roomContext.emit(TOPIC_JOBS, { event: 'jobFailed', job: failed }, { echo: true, ...inheritFilter(failed.filter) });
597
+ this.emit('jobFailed', failed);
598
+ // Auto-retry if under maxAttempts
599
+ if (nextAttempts < failed.maxAttempts) {
600
+ const retried = this._jobStore.updateStatus(jobId, 'pending');
601
+ if (retried) {
602
+ this._log('Job retrying:', jobId, 'attempt', nextAttempts + 1);
603
+ this._roomContext.emit(TOPIC_JOBS, { event: 'jobRetrying', job: retried }, { echo: true, ...inheritFilter(retried.filter) });
604
+ this.emit('jobRetrying', retried);
605
+ }
606
+ }
607
+ return failed;
608
+ }
609
+ // ============ Filters ============
610
+ /** The filter values currently applied to this queue's subscription. */
611
+ get filters() {
612
+ return [...this._filters];
613
+ }
614
+ /**
615
+ * Replace this queue's filters — only jobs published with one of these
616
+ * values are delivered. For a worker this declares its capabilities: it is
617
+ * offered only the jobs it can actually run, and load balancing still hands
618
+ * each such job to exactly one matching worker.
619
+ *
620
+ * Passing an empty array clears filtering and restores the wildcard
621
+ * subscription, which receives every job on the queue.
622
+ *
623
+ * Not to be confused with `getJobs(filter)`, which filters the local cache
624
+ * by status/type and does not change what the server sends.
625
+ *
626
+ * @example
627
+ * ```ts
628
+ * queue.setFilters(['gpu', 'render']); // gpu OR render jobs
629
+ * queue.setFilters([['gpu', 'eu-west']]); // gpu AND eu-west
630
+ * queue.setFilters([]); // every job
631
+ * ```
632
+ */
633
+ setFilters(values) {
634
+ this._filters = [...values];
635
+ for (const topic of [TOPIC_JOBS, TOPIC_PROGRESS]) {
636
+ // The core types filters as `string[]`, but both its implementation and
637
+ // the wire protocol accept AND groups (nested arrays).
638
+ this._roomContext.setFilters(topic, this._filters);
639
+ }
640
+ }
641
+ /** Add filter values to the existing set. Existing AND groups are kept. */
642
+ addFilters(values) {
643
+ this.setFilters(mergeFilters(this._filters, values));
644
+ }
645
+ /**
646
+ * Remove filter values from the existing set. Removing the last value
647
+ * restores the wildcard subscription.
648
+ */
649
+ removeFilters(values) {
650
+ this.setFilters(withoutFilters(this._filters, values));
651
+ }
652
+ // ============ Monitor / Query Methods ============
653
+ /**
654
+ * Get a single job by ID.
655
+ */
656
+ getJob(id) {
657
+ return this._jobStore.get(id);
658
+ }
659
+ /**
660
+ * Get all jobs, optionally filtered.
661
+ */
662
+ getJobs(filter) {
663
+ return this._jobStore.getAll(filter);
664
+ }
665
+ /**
666
+ * Number of pending jobs.
667
+ */
668
+ get pendingCount() {
669
+ return this._jobStore.pendingCount;
670
+ }
671
+ /**
672
+ * Number of active jobs.
673
+ */
674
+ get activeCount() {
675
+ return this._jobStore.activeCount;
676
+ }
677
+ /**
678
+ * All workers currently in this queue room.
679
+ */
680
+ get workers() {
681
+ return this._presenceManager.workers;
682
+ }
683
+ /**
684
+ * Get all workers in this queue room.
685
+ */
686
+ getWorkers() {
687
+ return this._presenceManager.getAll();
688
+ }
689
+ // ============ Internal (called by NoLagQueue) ============
690
+ /** @internal Subscribe to jobs and progress topics, attach listeners */
691
+ _subscribe(filters) {
692
+ this._log('Room subscribe:', this.name);
693
+ this._filters = filters ? [...filters] : [];
694
+ // Filters and load balancing compose: the server shares each filtered
695
+ // sub-topic across the group, so one matching worker claims each job.
696
+ const filterOpts = this._filters.length > 0 ? { filters: this._filters } : {};
697
+ // Workers use load balancing so each job event is delivered to only ONE worker
698
+ // (round-robin across all workers in the same group). Producers and monitors
699
+ // receive all messages so they can track full queue state.
700
+ if (this._options.role === 'worker') {
701
+ const group = this._options.loadBalanceGroup ?? `queue-workers-${this.name}`;
702
+ this._log('Subscribing with load balance, group:', group);
703
+ this._roomContext.subscribe(TOPIC_JOBS, { loadBalance: true, loadBalanceGroup: group, ...filterOpts });
704
+ }
705
+ else if (this._filters.length > 0) {
706
+ this._roomContext.subscribe(TOPIC_JOBS, filterOpts);
707
+ }
708
+ else {
709
+ this._roomContext.subscribe(TOPIC_JOBS);
710
+ }
711
+ // Progress is never load balanced — every monitor wants every update —
712
+ // but it carries the job's filter so it tracks the jobs topic.
713
+ if (this._filters.length > 0) {
714
+ this._roomContext.subscribe(TOPIC_PROGRESS, filterOpts);
715
+ }
716
+ else {
717
+ this._roomContext.subscribe(TOPIC_PROGRESS);
718
+ }
719
+ // Listen for job lifecycle messages (refs stored for handler-specific removal)
720
+ this._onJobsRef = (data) => {
721
+ this._handleJobMessage(data);
722
+ };
723
+ this._roomContext.on(TOPIC_JOBS, this._onJobsRef);
724
+ this._onProgressRef = (data) => {
725
+ this._handleProgressMessage(data);
726
+ };
727
+ this._roomContext.on(TOPIC_PROGRESS, this._onProgressRef);
728
+ }
729
+ /** @internal Set presence and fetch room members */
730
+ _activate() {
731
+ this._log('Room activate:', this.name);
732
+ this._setPresence();
733
+ this._roomContext.fetchPresence().then((actors) => {
734
+ this._log('Room presence fetched:', this.name, actors.length, 'actors');
735
+ for (const actor of actors) {
736
+ if (actor.presence) {
737
+ const worker = this._presenceManager.addFromPresence(actor.actorTokenId, actor.presence, actor.joinedAt);
738
+ if (worker) {
739
+ this._workerManager.addWorker(worker);
740
+ this.emit('workerJoined', worker);
741
+ }
742
+ }
743
+ }
744
+ }).catch((err) => {
745
+ this._log('Failed to fetch room presence:', err);
746
+ });
747
+ }
748
+ /** @internal Re-set presence after reconnect */
749
+ _updateLocalPresence() {
750
+ this._setPresence();
751
+ }
752
+ /** @internal Handle a presence:join event */
753
+ _handlePresenceJoin(actorTokenId, presenceData) {
754
+ const worker = this._presenceManager.addFromPresence(actorTokenId, presenceData);
755
+ if (worker) {
756
+ this._log('Worker joined queue:', this.name, worker.workerId);
757
+ this._workerManager.addWorker(worker);
758
+ this.emit('workerJoined', worker);
759
+ }
760
+ }
761
+ /** @internal Handle a presence:leave event */
762
+ _handlePresenceLeave(actorTokenId) {
763
+ const worker = this._presenceManager.removeByActorId(actorTokenId);
764
+ if (worker) {
765
+ this._log('Worker left queue:', this.name, worker.workerId);
766
+ this._workerManager.removeWorker(worker.workerId);
767
+ this.emit('workerLeft', worker);
768
+ }
769
+ }
770
+ /** @internal Handle a presence:update event */
771
+ _handlePresenceUpdate(actorTokenId, presenceData) {
772
+ const worker = this._presenceManager.addFromPresence(actorTokenId, presenceData);
773
+ if (worker) {
774
+ this._workerManager.addWorker(worker);
775
+ }
776
+ }
777
+ /** @internal Unsubscribe and clean up */
778
+ _cleanup() {
779
+ this._log('Room cleanup:', this.name);
780
+ // Server unsubscribes need a live socket; skip when disconnected
781
+ // (best-effort — the core would no-op with an error callback anyway).
782
+ if (this._isConnected()) {
783
+ this._roomContext.unsubscribe(TOPIC_JOBS);
784
+ this._roomContext.unsubscribe(TOPIC_PROGRESS);
785
+ }
786
+ // Handler-specific removal only: the client may be shared, and a bare
787
+ // off(topic) would strip other consumers' handlers too.
788
+ if (this._onJobsRef)
789
+ this._roomContext.off(TOPIC_JOBS, this._onJobsRef);
790
+ if (this._onProgressRef)
791
+ this._roomContext.off(TOPIC_PROGRESS, this._onProgressRef);
792
+ this._onJobsRef = null;
793
+ this._onProgressRef = null;
794
+ this._jobStore.clear();
795
+ this._workerManager.clear();
796
+ this._presenceManager.clear();
797
+ this.removeAllListeners();
798
+ }
799
+ // ============ Private ============
800
+ _handleJobMessage(data) {
801
+ const msg = data;
802
+ if (!msg?.event || !msg?.job)
803
+ return;
804
+ const { event, job } = msg;
805
+ this._log('Job message:', event, job.id);
806
+ switch (event) {
807
+ case 'jobAdded':
808
+ if (this._jobStore.add(job)) {
809
+ this.emit('jobAdded', job);
810
+ }
811
+ break;
812
+ case 'jobClaimed': {
813
+ const existing = this._jobStore.get(job.id);
814
+ if (existing) {
815
+ this._jobStore.updateStatus(job.id, 'claimed', { claimedBy: job.claimedBy });
816
+ const updated = this._jobStore.get(job.id);
817
+ this.emit('jobClaimed', updated);
818
+ }
819
+ break;
820
+ }
821
+ case 'jobCompleted': {
822
+ const existing = this._jobStore.get(job.id);
823
+ if (existing) {
824
+ // Sync final state directly since remote already processed transitions
825
+ const synced = { ...existing, ...job };
826
+ this._jobStore.add(synced);
827
+ this.emit('jobCompleted', synced);
828
+ }
829
+ break;
830
+ }
831
+ case 'jobFailed': {
832
+ const existing = this._jobStore.get(job.id);
833
+ if (existing) {
834
+ const synced = { ...existing, ...job };
835
+ this._jobStore.add(synced);
836
+ this.emit('jobFailed', synced);
837
+ }
838
+ break;
839
+ }
840
+ case 'jobRetrying': {
841
+ const existing = this._jobStore.get(job.id);
842
+ if (existing) {
843
+ const synced = { ...existing, ...job };
844
+ this._jobStore.add(synced);
845
+ this.emit('jobRetrying', synced);
846
+ }
847
+ break;
848
+ }
849
+ }
850
+ }
851
+ _handleProgressMessage(data) {
852
+ const progress = data;
853
+ if (!progress?.jobId)
854
+ return;
855
+ this._jobStore.updateProgress(progress.jobId, progress.progress);
856
+ this._log('Job progress update:', progress.jobId, progress.progress + '%');
857
+ this.emit('jobProgress', progress);
858
+ }
859
+ _setPresence() {
860
+ const presenceData = {
861
+ workerId: this._localWorkerId,
862
+ role: this._options.role,
863
+ activeJobs: 0,
864
+ concurrency: this._options.concurrency,
865
+ metadata: this._options.metadata,
866
+ // Scope tag: on a shared client, other apps' wrappers filter our
867
+ // presence out by this (and we filter theirs).
868
+ __scope: this._options.appName,
869
+ };
870
+ this._roomContext.setPresence(presenceData);
871
+ }
872
+ }
873
+
874
+ /**
875
+ * NoLagQueue — high-level real-time job queue SDK built on @nolag/js-sdk.
876
+ *
877
+ * Provides job lifecycle management, progress tracking, worker management,
878
+ * and global presence tracking — all framework-agnostic via events.
879
+ *
880
+ * The wrapper NEVER manages the connection. The app owns one core NoLag
881
+ * client (shared by any number of wrappers on distinct apps) and the
882
+ * wrapper attaches to it at construction and releases it via `detach()`.
883
+ *
884
+ * @example
885
+ * ```typescript
886
+ * import { NoLag } from '@nolag/js-sdk';
887
+ * import { NoLagQueue } from '@nolag/queue';
888
+ *
889
+ * const client = NoLag(async () => (await (await fetch('/api/nolag-token')).json()).token);
890
+ * const queue = new NoLagQueue({ client, role: 'worker', concurrency: 2 });
891
+ *
892
+ * queue.on('connected', () => console.log('Connected!'));
893
+ *
894
+ * await client.connect(); // the app owns the connection
895
+ * await queue.ready(); // wrapper setup done (identity, lobby, queues)
896
+ *
897
+ * const room = queue.joinQueue('image-processing');
898
+ * room.on('jobAdded', (job) => {
899
+ * room.claimJob(job.id);
900
+ * room.reportProgress(job.id, 50);
901
+ * room.completeJob(job.id, { output: 'result' });
902
+ * });
903
+ *
904
+ * queue.detach(); // wrapper releases its handlers and topics
905
+ * client.disconnect(); // the app closes the socket
906
+ * ```
907
+ */
908
+ class NoLagQueue extends EventEmitter {
909
+ constructor(options) {
910
+ super();
911
+ this._localWorker = null;
912
+ this._queues = new Map();
913
+ this._lobby = null;
914
+ this._onlineWorkers = new Map();
915
+ this._actorToWorkerId = new Map();
916
+ // Lifecycle: one setup run per connection epoch; detach is terminal.
917
+ this._epoch = 0;
918
+ this._detached = false;
919
+ this._isReady = false;
920
+ this._lobbyRefreshTimer = null;
921
+ // Stored client handler refs. INVARIANT: every client.on() below has a
922
+ // matching client.off() in detach() — never bare off(event), never inline
923
+ // closures on the client.
924
+ this._onConnectRef = () => this._onConnect();
925
+ this._onDisconnectRef = (reason) => {
926
+ this._log('Disconnected:', reason);
927
+ this.emit('disconnected', reason);
928
+ };
929
+ this._onReconnectRef = () => {
930
+ this._log('Reconnecting...');
931
+ this.emit('reconnecting');
932
+ };
933
+ this._onErrorRef = (error) => {
934
+ this._log('Error:', error);
935
+ this.emit('error', error);
936
+ };
937
+ this._onPresenceJoinRef = (data) => this._handleRoomPresenceJoin(data);
938
+ this._onPresenceLeaveRef = (data) => this._handleRoomPresenceLeave(data);
939
+ this._onPresenceUpdateRef = (data) => this._handleRoomPresenceUpdate(data);
940
+ this._onLobbyJoinRef = (data) => this._handleLobbyJoin(data);
941
+ this._onLobbyLeaveRef = (data) => this._handleLobbyLeave(data);
942
+ this._onLobbyUpdateRef = (data) => this._handleLobbyUpdate(data);
943
+ if (!options?.client) {
944
+ throw new TypeError('NoLagQueue requires an injected NoLag client: new NoLagQueue({ client, role, ... })');
945
+ }
946
+ this._client = options.client;
947
+ this._workerId = options.workerId ?? generateId();
948
+ this._options = {
949
+ workerId: this._workerId,
950
+ role: options.role ?? 'monitor',
951
+ concurrency: options.concurrency ?? 1,
952
+ metadata: options.metadata,
953
+ appName: options.appName ?? DEFAULT_APP_NAME,
954
+ maxJobCache: options.maxJobCache ?? DEFAULT_MAX_JOB_CACHE,
955
+ debug: options.debug ?? false,
956
+ queues: options.queues ?? [],
957
+ loadBalanceGroup: options.loadBalanceGroup,
958
+ };
959
+ this._log = createLogger('NoLagQueue', this._options.debug);
960
+ this._readyPromise = new Promise((resolve, reject) => {
961
+ this._readyResolve = resolve;
962
+ this._readyReject = reject;
963
+ });
964
+ // ready() rejection is only meaningful to callers that await it
965
+ this._readyPromise.catch(() => { });
966
+ registerWrapper(this._client, this._options.appName, 'NoLagQueue');
967
+ // Construction = attach: wire everything now, with stored refs.
968
+ this._client.on('connect', this._onConnectRef);
969
+ this._client.on('disconnect', this._onDisconnectRef);
970
+ this._client.on('reconnect', this._onReconnectRef);
971
+ this._client.on('error', this._onErrorRef);
972
+ this._client.on('presence:join', this._onPresenceJoinRef);
973
+ this._client.on('presence:leave', this._onPresenceLeaveRef);
974
+ this._client.on('presence:update', this._onPresenceUpdateRef);
975
+ this._client.on('lobbyPresence:join', this._onLobbyJoinRef);
976
+ this._client.on('lobbyPresence:leave', this._onLobbyLeaveRef);
977
+ this._client.on('lobbyPresence:update', this._onLobbyUpdateRef);
978
+ // Attach-to-connected: if the client is already authenticated, run setup.
979
+ // The microtask lets the caller wire wrapper event handlers synchronously
980
+ // first; a racing real 'connect' event wins via the epoch guard.
981
+ queueMicrotask(() => {
982
+ if (this._epoch === 0 && !this._detached && this._client.connected) {
983
+ this._onConnect();
984
+ }
985
+ });
986
+ }
987
+ // ============ Public Properties ============
988
+ /** Whether the underlying connection is established (connected ≠ ready) */
989
+ get connected() {
990
+ return !this._detached && this._client.connected;
991
+ }
992
+ /** The injected core client (owned by the app, not the wrapper) */
993
+ get client() {
994
+ return this._client;
995
+ }
996
+ /** The local worker's info (available after ready) */
997
+ get localWorker() {
998
+ return this._localWorker;
999
+ }
1000
+ /** All currently joined queue rooms */
1001
+ get queues() {
1002
+ return this._queues;
1003
+ }
1004
+ // ============ Lifecycle ============
1005
+ /**
1006
+ * Resolves once the wrapper's first setup completed (identity, lobby and
1007
+ * configured queues ready — equivalently, once 'connected' has fired).
1008
+ * Rejects only if detach() is called before that. Client auth failures
1009
+ * surface via the app's own `await client.connect()`, not here.
1010
+ */
1011
+ ready() {
1012
+ return this._readyPromise;
1013
+ }
1014
+ /**
1015
+ * Detach from the client: remove every handler this wrapper added,
1016
+ * unsubscribe its topics and lobby (when connected), clear state.
1017
+ * Terminal and idempotent; never touches the socket. To use the queue
1018
+ * again, construct a new instance.
1019
+ */
1020
+ detach() {
1021
+ if (this._detached)
1022
+ return;
1023
+ this._log('Detaching...');
1024
+ this._detached = true;
1025
+ this._epoch++; // aborts any in-flight setup at its next checkpoint
1026
+ if (this._lobbyRefreshTimer) {
1027
+ clearTimeout(this._lobbyRefreshTimer);
1028
+ this._lobbyRefreshTimer = null;
1029
+ }
1030
+ // Remove all client handlers by stored ref
1031
+ this._client.off('connect', this._onConnectRef);
1032
+ this._client.off('disconnect', this._onDisconnectRef);
1033
+ this._client.off('reconnect', this._onReconnectRef);
1034
+ this._client.off('error', this._onErrorRef);
1035
+ this._client.off('presence:join', this._onPresenceJoinRef);
1036
+ this._client.off('presence:leave', this._onPresenceLeaveRef);
1037
+ this._client.off('presence:update', this._onPresenceUpdateRef);
1038
+ this._client.off('lobbyPresence:join', this._onLobbyJoinRef);
1039
+ this._client.off('lobbyPresence:leave', this._onLobbyLeaveRef);
1040
+ this._client.off('lobbyPresence:update', this._onLobbyUpdateRef);
1041
+ // Queue rooms: handler-specific off + connected-gated server unsubscribe
1042
+ for (const name of [...this._queues.keys()]) {
1043
+ this._queues.get(name)._cleanup();
1044
+ this._queues.delete(name);
1045
+ }
1046
+ // Lobby: server unsubscribe is best-effort and needs a live socket
1047
+ if (this._lobby && this._client.connected) {
1048
+ try {
1049
+ this._lobby.unsubscribe();
1050
+ }
1051
+ catch {
1052
+ /* best-effort */
1053
+ }
1054
+ }
1055
+ this._lobby = null;
1056
+ this._onlineWorkers.clear();
1057
+ this._actorToWorkerId.clear();
1058
+ this._localWorker = null;
1059
+ releaseWrapper(this._client, this._options.appName);
1060
+ if (!this._isReady) {
1061
+ this._readyReject(new Error('NoLagQueue detached before ready'));
1062
+ }
1063
+ }
1064
+ // ============ Private: Epoch Setup ============
1065
+ _onConnect() {
1066
+ this._epoch++;
1067
+ void this._runSetup(this._epoch);
1068
+ }
1069
+ /**
1070
+ * One setup pass per connection epoch. Serves both initial setup (epoch 1)
1071
+ * and reconnect restore (epoch > 1). Aborts silently whenever a newer
1072
+ * epoch started or the wrapper detached — checked after every await.
1073
+ */
1074
+ async _runSetup(epoch) {
1075
+ const stale = () => epoch !== this._epoch || this._detached;
1076
+ this._log(this._isReady ? 'Restoring after reconnect...' : 'Setting up...');
1077
+ // Identity (client.actorId is guaranteed post-auth)
1078
+ if (!this._localWorker) {
1079
+ this._localWorker = {
1080
+ workerId: this._workerId,
1081
+ actorTokenId: this._client.actorId,
1082
+ role: this._options.role,
1083
+ activeJobs: 0,
1084
+ concurrency: this._options.concurrency,
1085
+ metadata: this._options.metadata,
1086
+ joinedAt: Date.now(),
1087
+ isLocal: true,
1088
+ };
1089
+ this._log('Local worker:', this._localWorker.workerId, '→', this._localWorker.actorTokenId);
1090
+ }
1091
+ else {
1092
+ this._localWorker.actorTokenId = this._client.actorId;
1093
+ }
1094
+ // Lobby: subscribe every epoch (idempotent server-side) and diff-hydrate
1095
+ // from the returned snapshot — one path for setup and restore.
1096
+ if (!this._lobby) {
1097
+ this._lobby = this._client.setApp(this._options.appName).setLobby(LOBBY_ID);
1098
+ }
1099
+ try {
1100
+ const state = await this._lobby.subscribe();
1101
+ if (stale())
1102
+ return;
1103
+ this._diffHydrateOnlineWorkers(state);
1104
+ this._log('Lobby subscribed, online workers:', this._onlineWorkers.size);
1105
+ }
1106
+ catch (err) {
1107
+ if (stale())
1108
+ return;
1109
+ this._log('Lobby subscription failed:', err);
1110
+ }
1111
+ if (!this._isReady) {
1112
+ // First successful setup: pre-subscribe configured queues.
1113
+ for (const queueName of this._options.queues) {
1114
+ this._subscribeQueue(queueName);
1115
+ }
1116
+ }
1117
+ else {
1118
+ // Server auto-restored topic subscriptions; only room-scoped presence
1119
+ // needs re-applying (the core does not restore it).
1120
+ for (const room of this._queues.values()) {
1121
+ room._updateLocalPresence();
1122
+ }
1123
+ }
1124
+ if (stale())
1125
+ return;
1126
+ // Ready keys on the first setup that COMPLETES, not on epoch 1: an
1127
+ // epoch aborted by a racing reconnect must not strand ready().
1128
+ if (!this._isReady) {
1129
+ this._isReady = true;
1130
+ this._readyResolve();
1131
+ this.emit('connected');
1132
+ }
1133
+ else {
1134
+ this.emit('reconnected');
1135
+ }
1136
+ // Deferred lobby refetch: catches workers who joined during the setup
1137
+ // window (e.g. simultaneous multi-tab connects).
1138
+ this._scheduleLobbyRefresh(epoch);
1139
+ }
1140
+ _scheduleLobbyRefresh(epoch) {
1141
+ if (this._lobbyRefreshTimer)
1142
+ clearTimeout(this._lobbyRefreshTimer);
1143
+ this._lobbyRefreshTimer = setTimeout(() => {
1144
+ this._lobbyRefreshTimer = null;
1145
+ if (epoch !== this._epoch || this._detached || !this._client.connected || !this._lobby) {
1146
+ return;
1147
+ }
1148
+ this._lobby
1149
+ .fetchPresence()
1150
+ .then((state) => {
1151
+ if (epoch !== this._epoch || this._detached)
1152
+ return;
1153
+ this._diffHydrateOnlineWorkers(state);
1154
+ })
1155
+ .catch(() => {
1156
+ /* best-effort */
1157
+ });
1158
+ }, LOBBY_REFRESH_DELAY_MS);
1159
+ }
1160
+ // ============ Queue Management ============
1161
+ /**
1162
+ * Join a queue room. Creates, subscribes, and activates it.
1163
+ * Returns an existing room if already joined.
1164
+ */
1165
+ joinQueue(name, opts) {
1166
+ this._assertUsable();
1167
+ let room = this._queues.get(name);
1168
+ if (!room) {
1169
+ room = this._subscribeQueue(name, opts?.filters);
1170
+ room._activate();
1171
+ }
1172
+ else if (opts?.filters) {
1173
+ // Already joined — re-point its filters rather than ignoring them.
1174
+ room.setFilters(opts.filters);
1175
+ }
1176
+ return room;
1177
+ }
1178
+ /**
1179
+ * Leave a queue room. Fully unsubscribes and removes it.
1180
+ */
1181
+ leaveQueue(name) {
1182
+ const room = this._queues.get(name);
1183
+ if (!room)
1184
+ return;
1185
+ this._log('Leaving queue:', name);
1186
+ room._cleanup();
1187
+ this._queues.delete(name);
1188
+ }
1189
+ /**
1190
+ * Get all joined queue rooms.
1191
+ */
1192
+ getQueues() {
1193
+ return Array.from(this._queues.values());
1194
+ }
1195
+ // ============ Global Presence ============
1196
+ /**
1197
+ * Get all workers currently online across all queue rooms.
1198
+ */
1199
+ getOnlineWorkers() {
1200
+ return Array.from(this._onlineWorkers.values());
1201
+ }
1202
+ // ============ Private: Guards ============
1203
+ _assertUsable() {
1204
+ if (this._detached) {
1205
+ throw new Error('NoLagQueue has been detached — construct a new instance');
1206
+ }
1207
+ if (!this._isReady || !this._localWorker) {
1208
+ throw new Error('NoLagQueue not ready — await ready() or the "connected" event');
1209
+ }
1210
+ }
1211
+ // ============ Private: Queue Setup ============
1212
+ _subscribeQueue(name, filters) {
1213
+ this._log('Subscribing queue:', name);
1214
+ const roomContext = this._client.setApp(this._options.appName).setRoom(name);
1215
+ const room = new QueueRoom(name, roomContext, this._workerId, this._options, createLogger(`QueueRoom:${name}`, this._options.debug), () => this._client.connected);
1216
+ room._setLocalActorId(this._localWorker.actorTokenId);
1217
+ this._queues.set(name, room);
1218
+ room._subscribe(filters);
1219
+ return room;
1220
+ }
1221
+ // ============ Private: Scope Filtering ============
1222
+ /**
1223
+ * On a shared client, presence events from other apps' wrappers arrive on
1224
+ * the same connection-level events. Wrappers stamp their presence with a
1225
+ * `__scope` (their appName); a mismatched tag means another app's data.
1226
+ * Untagged presence is accepted (older peers in this same app).
1227
+ */
1228
+ _foreignScope(data) {
1229
+ const scope = data?.__scope;
1230
+ return typeof scope === 'string' && scope !== this._options.appName;
1231
+ }
1232
+ // ============ Private: Room Presence ============
1233
+ _handleRoomPresenceJoin(data) {
1234
+ if (data.actorTokenId === this._localWorker?.actorTokenId)
1235
+ return;
1236
+ const presenceData = data.presence;
1237
+ if (!presenceData?.workerId || this._foreignScope(presenceData))
1238
+ return;
1239
+ const worker = this._presenceToWorker(data.actorTokenId, presenceData);
1240
+ this._actorToWorkerId.set(data.actorTokenId, worker.workerId);
1241
+ if (!this._onlineWorkers.has(worker.workerId)) {
1242
+ this._onlineWorkers.set(worker.workerId, worker);
1243
+ this.emit('workerOnline', worker);
1244
+ }
1245
+ // Route to all queue rooms
1246
+ for (const room of this._queues.values()) {
1247
+ room._handlePresenceJoin(data.actorTokenId, presenceData);
1248
+ }
1249
+ }
1250
+ _handleRoomPresenceLeave(data) {
1251
+ if (data.actorTokenId === this._localWorker?.actorTokenId)
1252
+ return;
1253
+ // Route to all queue rooms
1254
+ for (const room of this._queues.values()) {
1255
+ room._handlePresenceLeave(data.actorTokenId);
1256
+ }
1257
+ }
1258
+ _handleRoomPresenceUpdate(data) {
1259
+ if (data.actorTokenId === this._localWorker?.actorTokenId)
1260
+ return;
1261
+ const presenceData = data.presence;
1262
+ if (!presenceData?.workerId || this._foreignScope(presenceData))
1263
+ return;
1264
+ if (this._onlineWorkers.has(presenceData.workerId)) {
1265
+ const worker = this._presenceToWorker(data.actorTokenId, presenceData);
1266
+ this._onlineWorkers.set(worker.workerId, worker);
1267
+ }
1268
+ // Route to all queue rooms
1269
+ for (const room of this._queues.values()) {
1270
+ room._handlePresenceUpdate(data.actorTokenId, presenceData);
1271
+ }
1272
+ }
1273
+ // ============ Private: Lobby ============
1274
+ _handleLobbyJoin(event) {
1275
+ const { actorId, data } = event;
1276
+ if (actorId === this._localWorker?.actorTokenId)
1277
+ return;
1278
+ const presenceData = data;
1279
+ if (!presenceData?.workerId || this._foreignScope(presenceData))
1280
+ return;
1281
+ const worker = this._presenceToWorker(actorId, presenceData);
1282
+ this._actorToWorkerId.set(actorId, worker.workerId);
1283
+ if (!this._onlineWorkers.has(worker.workerId)) {
1284
+ this._onlineWorkers.set(worker.workerId, worker);
1285
+ this.emit('workerOnline', worker);
1286
+ }
1287
+ }
1288
+ _handleLobbyLeave(event) {
1289
+ const { actorId, data } = event;
1290
+ if (actorId === this._localWorker?.actorTokenId)
1291
+ return;
1292
+ const presenceData = data;
1293
+ if (this._foreignScope(presenceData))
1294
+ return;
1295
+ const workerId = presenceData?.workerId
1296
+ || this._actorToWorkerId.get(actorId)
1297
+ || this._findWorkerIdByActorId(actorId);
1298
+ if (workerId) {
1299
+ const worker = this._onlineWorkers.get(workerId);
1300
+ if (worker) {
1301
+ this._onlineWorkers.delete(workerId);
1302
+ this._actorToWorkerId.delete(actorId);
1303
+ this.emit('workerOffline', worker);
1304
+ }
1305
+ }
1306
+ }
1307
+ _handleLobbyUpdate(event) {
1308
+ const { actorId, data } = event;
1309
+ if (actorId === this._localWorker?.actorTokenId)
1310
+ return;
1311
+ const presenceData = data;
1312
+ if (!presenceData?.workerId || this._foreignScope(presenceData))
1313
+ return;
1314
+ const worker = this._presenceToWorker(actorId, presenceData);
1315
+ this._onlineWorkers.set(worker.workerId, worker);
1316
+ }
1317
+ /**
1318
+ * Reconcile the online-worker map against a fresh lobby snapshot, emitting
1319
+ * only the deltas (workerOffline for vanished, workerOnline for new). One
1320
+ * path for initial hydration, reconnect restore, and the deferred refetch.
1321
+ */
1322
+ _diffHydrateOnlineWorkers(state) {
1323
+ // Build the fresh worker set from the snapshot
1324
+ const fresh = new Map();
1325
+ const freshActors = new Map();
1326
+ for (const roomId of Object.keys(state)) {
1327
+ const roomPresence = state[roomId];
1328
+ for (const actorId of Object.keys(roomPresence)) {
1329
+ if (actorId === this._localWorker?.actorTokenId)
1330
+ continue;
1331
+ const raw = roomPresence[actorId];
1332
+ // Server returns full actor records with presence nested under .presence
1333
+ const presenceData = (raw?.presence ?? raw);
1334
+ if (presenceData?.workerId && !this._foreignScope(presenceData)) {
1335
+ if (!fresh.has(presenceData.workerId)) {
1336
+ fresh.set(presenceData.workerId, this._presenceToWorker(actorId, presenceData));
1337
+ }
1338
+ freshActors.set(actorId, presenceData.workerId);
1339
+ }
1340
+ }
1341
+ }
1342
+ // Vanished workers
1343
+ for (const [workerId, worker] of [...this._onlineWorkers]) {
1344
+ if (!fresh.has(workerId)) {
1345
+ this._onlineWorkers.delete(workerId);
1346
+ for (const [actorId, mappedWorkerId] of [...this._actorToWorkerId]) {
1347
+ if (mappedWorkerId === workerId)
1348
+ this._actorToWorkerId.delete(actorId);
1349
+ }
1350
+ this.emit('workerOffline', worker);
1351
+ }
1352
+ }
1353
+ // New workers
1354
+ for (const [workerId, worker] of fresh) {
1355
+ if (!this._onlineWorkers.has(workerId)) {
1356
+ this._onlineWorkers.set(workerId, worker);
1357
+ this.emit('workerOnline', worker);
1358
+ }
1359
+ }
1360
+ for (const [actorId, workerId] of freshActors) {
1361
+ this._actorToWorkerId.set(actorId, workerId);
1362
+ }
1363
+ }
1364
+ // ============ Private: Helpers ============
1365
+ _presenceToWorker(actorTokenId, data) {
1366
+ return {
1367
+ workerId: data.workerId,
1368
+ actorTokenId,
1369
+ role: data.role,
1370
+ activeJobs: data.activeJobs ?? 0,
1371
+ concurrency: data.concurrency ?? 1,
1372
+ metadata: data.metadata,
1373
+ joinedAt: Date.now(),
1374
+ isLocal: false,
1375
+ };
1376
+ }
1377
+ _findWorkerIdByActorId(actorTokenId) {
1378
+ for (const worker of this._onlineWorkers.values()) {
1379
+ if (worker.actorTokenId === actorTokenId)
1380
+ return worker.workerId;
1381
+ }
1382
+ return undefined;
1383
+ }
1384
+ }
1385
+
1386
+ export { EventEmitter, JobStore, NoLagQueue, PresenceManager, QueueRoom, WorkerManager };
1387
+ //# sourceMappingURL=react-native.js.map