@nolag/queue 0.1.3 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -38
- package/dist/NoLagQueue.d.ts +68 -15
- package/dist/QueueRoom.d.ts +4 -1
- package/dist/browser.js +1 -1
- package/dist/browser.js.map +1 -1
- package/dist/constants.d.ts +2 -0
- package/dist/index.cjs +338 -158
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +338 -158
- package/dist/index.mjs.map +1 -1
- package/dist/react-native.d.ts +13 -0
- package/dist/react-native.js +1251 -0
- package/dist/react-native.js.map +1 -0
- package/dist/types.d.ts +9 -7
- package/dist/utils.d.ts +4 -0
- package/package.json +10 -4
|
@@ -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
|