@nadrif/thread 0.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/src/thread.js ADDED
@@ -0,0 +1,1246 @@
1
+ /**
2
+ * @file Single Web Worker wrapper with advanced features.
3
+ *
4
+ * `Thread` turns a plain JavaScript function (or a setup/exec/cleanup
5
+ * definition) into a managed Web Worker that you can call from the main
6
+ * thread. Every `Thread` instance:
7
+ *
8
+ * - Spawns a real `Worker` from a Blob URL (zero network requests).
9
+ * - Serialises your function and sends it to the worker automatically.
10
+ * - Manages timeouts, abort signals, retries, and caching.
11
+ * - Reports timing, progress, logs, and memory back to the host.
12
+ * - Restarts automatically on crash with exponential back-off.
13
+ * - Supports hot-reloading the exec function without re-creating the thread.
14
+ *
15
+ * **Quick start:**
16
+ *
17
+ * ```js
18
+ * import { Thread } from './thread.js';
19
+ *
20
+ * const t = new Thread((x) => x * 2);
21
+ * const result = await t.run(21); // 42
22
+ * t.terminate();
23
+ * ```
24
+ *
25
+ * **With stateful setup/exec/cleanup:**
26
+ *
27
+ * ```js
28
+ * const t = new Thread({
29
+ * setup() {
30
+ * return { count: 0 };
31
+ * },
32
+ * exec(state, delta) {
33
+ * state.count += delta;
34
+ * return state.count;
35
+ * },
36
+ * cleanup(state) {
37
+ * console.log('Final count:', state.count);
38
+ * },
39
+ * });
40
+ *
41
+ * console.log(await t.run(1)); // 1
42
+ * console.log(await t.run(5)); // 6
43
+ * await t.terminateGracefully(); // logs "Final count: 6"
44
+ * ```
45
+ *
46
+ * **How it works under the hood:**
47
+ *
48
+ * 1. Your function is converted to a string via `.toString()`.
49
+ * 2. A small worker script is assembled that imports `importScripts`,
50
+ * initialises state via `setup`, and handles `onmessage`.
51
+ * 3. The script is bundled into a `Blob` and loaded via
52
+ * `URL.createObjectURL`.
53
+ * 4. When you call `run()`, the arguments are posted to the worker.
54
+ * The worker executes the function and posts the result back.
55
+ * 5. On success the promise resolves; on error or timeout it rejects
56
+ * with one of the thread-specific error types.
57
+ *
58
+ * @module thread
59
+ */
60
+
61
+ import {
62
+ ThreadAbortError,
63
+ ThreadError,
64
+ ThreadDependencyError,
65
+ ThreadHealthError,
66
+ ThreadTerminatedError,
67
+ ThreadTimeoutError
68
+ } from "./error";
69
+ import { Metrics } from "./metrix";
70
+ import { createWorker, terminateWorker, env } from "./worker-factory.js";
71
+
72
+ /**
73
+ * Convert a function to a valid expression string for worker eval.
74
+ * Handles method shorthand (e.g. `setup() { ... }`) by prefixing `function`.
75
+ * @private
76
+ */
77
+ function _toExpr(fn) {
78
+ const s = fn.toString();
79
+ if (/^\w+\s*\(/.test(s) && !s.startsWith('function') && !s.startsWith('async ')) {
80
+ return 'function ' + s;
81
+ }
82
+ return s;
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Thread class
87
+ // ---------------------------------------------------------------------------
88
+
89
+ /**
90
+ * A single Web Worker instance with advanced features.
91
+ *
92
+ * @see {@link ThreadOptions} for constructor configuration.
93
+ * @see {@link ThreadRunOptions} for per-task overrides.
94
+ */
95
+ export class Thread {
96
+ // -----------------------------------------------------------------------
97
+ // Constructor
98
+ // -----------------------------------------------------------------------
99
+
100
+ /**
101
+ * Create a new managed Web Worker thread.
102
+ *
103
+ * @param {import('./types.js').ThreadDefinition | Function} definition
104
+ * Either a plain function (used as `exec` with no state) **or** an
105
+ * object with `setup`, `exec`, and `cleanup` methods.
106
+ *
107
+ * When a function is passed, the worker has no persistent state –
108
+ * each invocation is independent. When an object is passed, `setup`
109
+ * runs once and its return value is passed as the first argument to
110
+ * every `exec` call.
111
+ *
112
+ * @param {import('./types.js').ThreadOptions} [options={}]
113
+ * Configuration for timeouts, idle behaviour, health checks,
114
+ * event listeners, and more.
115
+ *
116
+ * @throws {TypeError} If `definition` is not a function or a valid
117
+ * `{ exec }` object.
118
+ *
119
+ * @example
120
+ * ```js
121
+ * // Simple function thread
122
+ * const t1 = new Thread((a, b) => a + b);
123
+ * await t1.run(2, 3); // 5
124
+ * ```
125
+ *
126
+ * @example
127
+ * ```js
128
+ * // Stateful definition with lifecycle hooks
129
+ * const t2 = new Thread({
130
+ * setup() { return { db: openDatabase() }; },
131
+ * async exec(state, query) {
132
+ * return state.db.query(query);
133
+ * },
134
+ * async cleanup(state) {
135
+ * await state.db.close();
136
+ * },
137
+ * }, {
138
+ * timeout: 10_000,
139
+ * healthCheckInterval: 5_000,
140
+ * onLog: (msg) => console.log('[worker]', msg),
141
+ * onTiming: (ms) => console.log(`Query took ${ms.toFixed(1)}ms`),
142
+ * });
143
+ * ```
144
+ */
145
+ constructor(definition, options = {}) {
146
+ // ---- options ----
147
+ this._options = options;
148
+ this._timeout = options.timeout || 30000;
149
+ this._idleTimeout = options.idleTimeout || 0;
150
+ this._imports = options.imports || [];
151
+ this._healthCheckInterval = options.healthCheckInterval || 0;
152
+ this._healthCheckTimeout = options.healthCheckTimeout || 5000;
153
+ this._concurrency = options.concurrency || 1;
154
+ this._activeTasks = 0;
155
+
156
+ // ---- internal state ----
157
+ this._nextId = 0;
158
+ this._pending = new Map(); // id -> { resolve, reject, timer, abortHandler, startTime, args }
159
+ this._listeners = {
160
+ result: [],
161
+ error: [],
162
+ progress: [],
163
+ terminate: [],
164
+ idle: [],
165
+ timing: [],
166
+ beforeRun: [],
167
+ afterRun: [],
168
+ log: [],
169
+ memory: [],
170
+ health: [],
171
+ metrics: [],
172
+ };
173
+ this._isTerminated = false;
174
+ this._isIdle = true;
175
+ this._idleTimer = null;
176
+ this._healthTimer = null;
177
+ this._crashes = 0;
178
+ this._backoffTimer = null;
179
+ this._isRestarting = false;
180
+ this._cache = new Map(); // cacheKey -> { result, timestamp, ttl }
181
+
182
+ // ---- metrics ----
183
+ this._metrics = new Metrics();
184
+
185
+ // ---- normalise definition ----
186
+ if (typeof definition === 'function') {
187
+ this._setupFn = null;
188
+ this._execFn = definition;
189
+ this._cleanupFn = null;
190
+ } else if (typeof definition === 'object' && definition !== null) {
191
+ this._setupFn = definition.setup || null;
192
+ this._execFn = definition.exec;
193
+ this._cleanupFn = definition.cleanup || null;
194
+ if (typeof this._execFn !== 'function') {
195
+ throw new TypeError('exec must be a function');
196
+ }
197
+ } else {
198
+ throw new TypeError('definition must be a function or { setup?, exec?, cleanup? }');
199
+ }
200
+
201
+ // ---- build worker script and spawn via factory ----
202
+ this._workerScript = this._buildWorkerScript();
203
+ this._spawnWorker();
204
+
205
+ // ---- register global listeners ----
206
+ if (options.onBeforeRun) this.on('beforeRun', options.onBeforeRun);
207
+ if (options.onAfterRun) this.on('afterRun', options.onAfterRun);
208
+ if (options.onResult) this.on('result', options.onResult);
209
+ if (options.onError) this.on('error', options.onError);
210
+ if (options.onProgress) this.on('progress', options.onProgress);
211
+ if (options.onTiming) this.on('timing', options.onTiming);
212
+ if (options.onLog) this.on('log', options.onLog);
213
+ if (options.onMemory) this.on('memory', options.onMemory);
214
+ if (options.onMetrics) this.on('metrics', options.onMetrics);
215
+
216
+ // ---- start timers ----
217
+ this._resetIdleTimer();
218
+ if (this._healthCheckInterval > 0) this._startHealthChecks();
219
+
220
+ // ---- silent initialisation ----
221
+ if (options.initArgs !== undefined) {
222
+ const args = Array.isArray(options.initArgs) ? options.initArgs : [options.initArgs];
223
+ this.run(...args, { transfer: options.initTransfer || [] }).catch(() => {});
224
+ }
225
+ }
226
+
227
+ // -----------------------------------------------------------------------
228
+ // Worker script generation (internal)
229
+ // -----------------------------------------------------------------------
230
+
231
+ /**
232
+ * Build the JavaScript source for the Web Worker.
233
+ *
234
+ * The script is self-contained: it defines `onmessage`, handles
235
+ * setup/cleanup, and provides `ctx.log()` and `ctx.reportMemory()`
236
+ * helpers. Your `exec` function is embedded as a string via
237
+ * `.toString()`.
238
+ *
239
+ * @returns {string} The complete worker script.
240
+ * @private
241
+ */
242
+ _buildWorkerScript() {
243
+ const setupSrc = this._setupFn ? `(${_toExpr(this._setupFn)})` : 'null';
244
+ const execSrc = `(${_toExpr(this._execFn)})`;
245
+ const cleanupSrc = this._cleanupFn ? `(${_toExpr(this._cleanupFn)})` : 'null';
246
+
247
+ // Use globalThis for cross-environment compatibility (browser, Node, Deno, Bun)
248
+ return `
249
+ let state = null;
250
+ let isInitialised = false;
251
+
252
+ const g = typeof globalThis !== 'undefined' ? globalThis : typeof self !== 'undefined' ? self : this;
253
+
254
+ g.log = function(message) {
255
+ g.postMessage({ type: 'log', message: String(message) });
256
+ };
257
+ g.reportMemory = function() {
258
+ if (typeof performance !== 'undefined' && performance.memory) {
259
+ g.postMessage({ type: 'memory', memory: performance.memory });
260
+ }
261
+ };
262
+
263
+ const runSetup = async () => {
264
+ if (${setupSrc} !== null) {
265
+ try {
266
+ state = await (${setupSrc})();
267
+ } catch (err) {
268
+ g.postMessage({ type: 'setupError', error: err.message || String(err) });
269
+ throw err;
270
+ }
271
+ }
272
+ isInitialised = true;
273
+ g.postMessage({ type: 'ready' });
274
+ };
275
+
276
+ g.onmessage = async function(e) {
277
+ const { id, args, transfer, hasProgress, isBatch, isHealthCheck, isCleanup } = e.data || {};
278
+
279
+ if (isHealthCheck) {
280
+ g.postMessage({ id, type: 'health' });
281
+ return;
282
+ }
283
+ if (isCleanup) {
284
+ if (${cleanupSrc} !== null && state !== null) {
285
+ try { await (${cleanupSrc})(state); } catch (err) {
286
+ g.postMessage({ type: 'cleanupError', error: err.message || String(err) });
287
+ }
288
+ }
289
+ g.postMessage({ id, type: 'cleanupDone' });
290
+ return;
291
+ }
292
+
293
+ if (!isInitialised) {
294
+ try { await runSetup(); } catch (err) {
295
+ g.postMessage({ id, type: 'error', error: err.message || String(err) });
296
+ return;
297
+ }
298
+ }
299
+
300
+ const context = {
301
+ reportProgress: (value) => {
302
+ if (hasProgress) g.postMessage({ id, type: 'progress', value });
303
+ },
304
+ log: g.log,
305
+ reportMemory: g.reportMemory,
306
+ };
307
+
308
+ try {
309
+ let result;
310
+ const hasState = ${setupSrc} !== null;
311
+ if (isBatch) {
312
+ result = await Promise.all(args.map((argSet) => hasState ? (${execSrc})(state, ...argSet, context) : (${execSrc})(...argSet, context)));
313
+ } else {
314
+ result = hasState ? await (${execSrc})(state, ...args, context) : await (${execSrc})(...args, context);
315
+ }
316
+ g.postMessage({ id, type: 'result', result });
317
+ } catch (err) {
318
+ g.postMessage({
319
+ id,
320
+ type: 'error',
321
+ error: err.message || String(err),
322
+ stack: err.stack,
323
+ });
324
+ }
325
+ };
326
+ `;
327
+ }
328
+
329
+ // -----------------------------------------------------------------------
330
+ // Worker lifecycle (internal)
331
+ // -----------------------------------------------------------------------
332
+
333
+ /**
334
+ * Spawn (or re-spawn) the underlying Web Worker.
335
+ *
336
+ * If a worker already exists it is terminated first. The new worker
337
+ * is wired to {@link _handleMessage}, {@link _handleError}, and
338
+ * `onmessageerror`.
339
+ *
340
+ * @private
341
+ */
342
+ _spawnWorker() {
343
+ if (this._worker) terminateWorker(this._worker);
344
+ const worker = createWorker(this._workerScript, { imports: this._imports });
345
+ worker.onmessage = this._handleMessage.bind(this);
346
+ worker.onerror = this._handleError.bind(this);
347
+ worker.onmessageerror = this._handleError.bind(this);
348
+ this._worker = worker;
349
+ this._isTerminated = false;
350
+ this._isRestarting = false;
351
+ }
352
+
353
+ /**
354
+ * Process a message received from the worker.
355
+ *
356
+ * Dispatches by `type` field: `result`, `error`, `progress`, `log`,
357
+ * `memory`, `health`, `ready`. For `result`/`error` messages tied to
358
+ * a pending task, the corresponding promise is resolved/rejected.
359
+ *
360
+ * @param {MessageEvent} event - The raw message event.
361
+ * @private
362
+ */
363
+ _handleMessage(event) {
364
+ const { id, type, result, error, value, stack, message, memory } = event.data || {};
365
+ this._resetIdleTimer();
366
+
367
+ // Health check response
368
+ if (type === 'health' && id !== undefined && this._pending.has(id)) {
369
+ const { resolve, timer } = this._pending.get(id);
370
+ clearTimeout(timer);
371
+ this._pending.delete(id);
372
+ resolve(true);
373
+ return;
374
+ }
375
+ // Log / memory / ready
376
+ if (type === 'log') {
377
+ this._listeners.log.forEach((h) => h(message, event));
378
+ return;
379
+ }
380
+ if (type === 'memory') {
381
+ this._listeners.memory.forEach((h) => h(memory, event));
382
+ return;
383
+ }
384
+ if (type === 'ready') {
385
+ this._crashes = 0;
386
+ this._listeners.health.forEach((h) => h({ status: 'ready' }));
387
+ return;
388
+ }
389
+
390
+ // Result / progress / error
391
+ if (type === 'result') this._listeners.result.forEach((h) => h(result, event));
392
+ else if (type === 'progress') this._listeners.progress.forEach((h) => h(value, event));
393
+ else if (type === 'error' || type === 'setupError' || type === 'cleanupError') {
394
+ this._listeners.error.forEach((h) => h({ error, stack }, event));
395
+ }
396
+
397
+ // Resolve pending promise
398
+ if (id !== undefined && this._pending.has(id)) {
399
+ const entry = this._pending.get(id);
400
+ if (type === 'result' || type === 'error') {
401
+ clearTimeout(entry.timer);
402
+ if (entry.abortHandler) entry.abortHandler();
403
+ this._pending.delete(id);
404
+ this._isIdle = this._pending.size === 0;
405
+ this._activeTasks--;
406
+
407
+ // Metrics and timing
408
+ if (type === 'result' && entry.startTime) {
409
+ const duration = performance.now() - entry.startTime;
410
+ this._metrics.record(duration, true);
411
+ this._listeners.timing.forEach((h) => h(duration, entry.args));
412
+ this._listeners.metrics.forEach((h) => h(this._metrics.snapshot()));
413
+ }
414
+ if (type === 'result') {
415
+ let finalResult = result;
416
+ for (const hook of this._listeners.afterRun) {
417
+ const transformed = hook(finalResult);
418
+ if (transformed !== undefined) finalResult = transformed;
419
+ }
420
+ entry.resolve(finalResult);
421
+ } else {
422
+ this._metrics.record(0, false);
423
+ this._listeners.metrics.forEach((h) => h(this._metrics.snapshot()));
424
+ const err = new ThreadError(error);
425
+ err.stack = stack;
426
+ entry.reject(err);
427
+ }
428
+ }
429
+ }
430
+ }
431
+
432
+ /**
433
+ * Handle a fatal worker error (uncaught exception, message error, etc.).
434
+ *
435
+ * All pending tasks are rejected immediately, the worker is destroyed,
436
+ * and a restart is scheduled (unless the thread was intentionally
437
+ * terminated).
438
+ *
439
+ * @param {ErrorEvent|MessageEvent} event - The error event.
440
+ * @private
441
+ */
442
+ _handleError(event) {
443
+ this._listeners.error.forEach((h) => h(event));
444
+ for (const [id, { reject, timer, abortHandler }] of this._pending) {
445
+ clearTimeout(timer);
446
+ if (abortHandler) abortHandler();
447
+ reject(new ThreadError(`Worker crashed: ${event.message || 'unknown'}`));
448
+ this._metrics.record(0, false);
449
+ }
450
+ this._pending.clear();
451
+ this._isIdle = true;
452
+ this._activeTasks = 0;
453
+ this._worker = null;
454
+ if (typeof this._onCrash === 'function') this._onCrash();
455
+ if (!this._isTerminated) this._scheduleRestart();
456
+ }
457
+
458
+ /**
459
+ * Schedule a worker restart with exponential back-off.
460
+ *
461
+ * Delay formula: `min(1000 * 2^(crashes-1), 30000)` ms. After the
462
+ * delay, a fresh worker is spawned (unless the thread has been
463
+ * terminated in the meantime).
464
+ *
465
+ * @private
466
+ */
467
+ _scheduleRestart() {
468
+ if (this._isRestarting || this._isTerminated) return;
469
+ this._isRestarting = true;
470
+ this._crashes++;
471
+ const delay = Math.min(1000 * Math.pow(2, this._crashes - 1), 30000);
472
+ this._backoffTimer = setTimeout(() => {
473
+ this._isRestarting = false;
474
+ if (!this._isTerminated) {
475
+ this._spawnWorker();
476
+ if (this._healthCheckInterval > 0) this._startHealthChecks();
477
+ }
478
+ }, delay);
479
+ }
480
+
481
+ // -----------------------------------------------------------------------
482
+ // Health checks (internal)
483
+ // -----------------------------------------------------------------------
484
+
485
+ /**
486
+ * Start periodic health-check pings.
487
+ *
488
+ * Each ping sends a lightweight `isHealthCheck` message and waits for
489
+ * a response within {@link _healthCheckTimeout}. If the worker fails
490
+ * to respond, it is killed and restarted.
491
+ *
492
+ * @private
493
+ */
494
+ _startHealthChecks() {
495
+ if (this._healthTimer) clearInterval(this._healthTimer);
496
+ this._healthTimer = setInterval(() => {
497
+ if (this._isTerminated || !this._worker) return;
498
+ this._pingWorker()
499
+ .then(() => (this._crashes = 0))
500
+ .catch(() => {
501
+ if (this._worker) {
502
+ this._worker.terminate();
503
+ this._worker = null;
504
+ this._scheduleRestart();
505
+ }
506
+ });
507
+ }, this._healthCheckInterval);
508
+ }
509
+
510
+ /**
511
+ * Send a single health-check ping and wait for the pong.
512
+ *
513
+ * @returns {Promise<boolean>} Resolves `true` on pong, rejects with
514
+ * {@link ThreadHealthError} on timeout.
515
+ * @private
516
+ */
517
+ _pingWorker() {
518
+ return new Promise((resolve, reject) => {
519
+ const id = this._nextId++;
520
+ const timer = setTimeout(() => {
521
+ if (this._pending.has(id)) {
522
+ this._pending.delete(id);
523
+ reject(new ThreadHealthError(`Health check timed out after ${this._healthCheckTimeout}ms`));
524
+ }
525
+ }, this._healthCheckTimeout);
526
+ this._pending.set(id, { resolve, reject, timer, abortHandler: null, startTime: null, args: null });
527
+ try {
528
+ this._worker.postMessage({ id, isHealthCheck: true });
529
+ } catch (err) {
530
+ clearTimeout(timer);
531
+ this._pending.delete(id);
532
+ reject(err);
533
+ }
534
+ });
535
+ }
536
+
537
+ // -----------------------------------------------------------------------
538
+ // Idle timer (internal)
539
+ // -----------------------------------------------------------------------
540
+
541
+ /**
542
+ * Reset the idle timer. Called on every message from the worker.
543
+ *
544
+ * If `idleTimeout > 0` and no tasks are pending when the timer fires,
545
+ * the thread terminates itself.
546
+ *
547
+ * @private
548
+ */
549
+ _resetIdleTimer() {
550
+ if (this._idleTimer) clearTimeout(this._idleTimer);
551
+ if (this._idleTimeout > 0 && !this._isTerminated) {
552
+ this._idleTimer = setTimeout(() => {
553
+ if (this._pending.size === 0) {
554
+ this._isIdle = true;
555
+ this._listeners.idle.forEach((h) => h());
556
+ if (this._idleTimeout > 0) this.terminate();
557
+ }
558
+ }, this._idleTimeout);
559
+ }
560
+ }
561
+
562
+ // -----------------------------------------------------------------------
563
+ // Cache (internal)
564
+ // -----------------------------------------------------------------------
565
+
566
+ /**
567
+ * Produce a JSON string key from the argument list.
568
+ *
569
+ * Returns `null` if the arguments are not JSON-serialisable (e.g.
570
+ * contain functions or circular references).
571
+ *
572
+ * @param {any[]} args - Task arguments.
573
+ * @returns {string | null} Cache key.
574
+ * @private
575
+ */
576
+ _getCacheKey(args) {
577
+ try {
578
+ return JSON.stringify(args);
579
+ } catch {
580
+ return null;
581
+ }
582
+ }
583
+
584
+ /**
585
+ * Evict expired entries from the cache.
586
+ *
587
+ * @private
588
+ */
589
+ _clearExpiredCache() {
590
+ const now = Date.now();
591
+ for (const [key, entry] of this._cache) {
592
+ if (entry.ttl && now - entry.timestamp > entry.ttl) this._cache.delete(key);
593
+ }
594
+ }
595
+
596
+ // -----------------------------------------------------------------------
597
+ // Public API – run
598
+ // -----------------------------------------------------------------------
599
+
600
+ /**
601
+ * Run a single task on the worker.
602
+ *
603
+ * All arguments are forwarded to the worker's `exec` function. If
604
+ * the last argument is a {@link ThreadRunOptions} object (detected
605
+ * by the presence of `timeout`, `transfer`, `signal`, `retries`, or
606
+ * `cacheTTL` keys), it is consumed as options and **not** forwarded.
607
+ *
608
+ * @param {...any} args
609
+ * Arguments for the worker's `exec` function. The last argument
610
+ * may be a {@link ThreadRunOptions} object.
611
+ * @returns {Promise<any>}
612
+ * Resolves with the return value of `exec`, or rejects with:
613
+ * - {@link ThreadTimeoutError} – task exceeded its timeout
614
+ * - {@link ThreadAbortError} – task was aborted via `signal`
615
+ * - {@link ThreadTerminatedError} – thread was terminated
616
+ * - {@link ThreadError} – worker-side exception
617
+ *
618
+ * @example
619
+ * ```js
620
+ * const t = new Thread((a, b) => a + b);
621
+ * const sum = await t.run(3, 4); // 7
622
+ * ```
623
+ *
624
+ * @example
625
+ * ```js
626
+ * // With per-task options
627
+ * const result = await t.run(data, {
628
+ * timeout: 5000,
629
+ * transfer: [data.buffer],
630
+ * retries: 2,
631
+ * cacheTTL: 60_000,
632
+ * });
633
+ * ```
634
+ *
635
+ * @example
636
+ * ```js
637
+ * // With AbortController
638
+ * const controller = new AbortController();
639
+ * setTimeout(() => controller.abort(), 1000);
640
+ *
641
+ * try {
642
+ * await t.run(slowWork, { signal: controller.signal });
643
+ * } catch (err) {
644
+ * if (err instanceof ThreadAbortError) console.log('Cancelled');
645
+ * }
646
+ * ```
647
+ */
648
+ run(...args) {
649
+ return this._runTask(false, ...args);
650
+ }
651
+
652
+ /**
653
+ * Run multiple independent argument sets in a **single** worker call.
654
+ *
655
+ * The worker receives all argument sets at once and executes them in
656
+ * parallel via `Promise.all`. This is useful when you have many
657
+ * small tasks and want to avoid the overhead of one `postMessage`
658
+ * round-trip per task.
659
+ *
660
+ * @param {Array<Array>} tasks
661
+ * Array of argument arrays. Each sub-array is spread as arguments
662
+ * to one `exec` invocation.
663
+ * @param {import('./types.js').ThreadRunOptions & {_batch?: boolean}} [options={}]
664
+ * Options applied to the entire batch (timeout, transfer, etc.).
665
+ * @returns {Promise<any[]>}
666
+ * Array of results in the same order as the input tasks.
667
+ *
668
+ * @throws {TypeError} If `tasks` is not an array.
669
+ *
670
+ * @example
671
+ * ```js
672
+ * const t = new Thread((x) => x * x);
673
+ * const results = await t.runBatch([[2], [3], [4]]);
674
+ * console.log(results); // [4, 9, 16]
675
+ * ```
676
+ */
677
+ runBatch(tasks, options = {}) {
678
+ if (!Array.isArray(tasks)) throw new TypeError('tasks must be an array');
679
+ options._batch = true;
680
+ return this._runTask(true, tasks, options);
681
+ }
682
+
683
+ /**
684
+ * Fire-and-forget: send a task **without waiting** for a reply.
685
+ *
686
+ * Useful for one-way messages where you don't need the result. The
687
+ * task runs with `id = -1` so the worker's response (if any) is
688
+ * silently ignored.
689
+ *
690
+ * @param {...any} args
691
+ * Arguments; the last may contain `{ transfer }`.
692
+ *
693
+ * @example
694
+ * ```js
695
+ * // Send a log message to the worker without waiting
696
+ * t.runAsync({ type: 'heartbeat' });
697
+ * ```
698
+ */
699
+ runAsync(...args) {
700
+ let options = {};
701
+ const last = args[args.length - 1];
702
+ if (last && typeof last === 'object' && !Array.isArray(last) && 'transfer' in last) {
703
+ options = args.pop();
704
+ }
705
+ const transfer = options.transfer || [];
706
+ if (this._isTerminated || !this._worker) return;
707
+ try {
708
+ this._worker.postMessage(
709
+ {
710
+ id: -1,
711
+ args,
712
+ transfer,
713
+ hasProgress: false,
714
+ isBatch: false,
715
+ },
716
+ transfer,
717
+ );
718
+ } catch (err) {
719
+ // ignore
720
+ }
721
+ }
722
+
723
+ /**
724
+ * Run a chain of functions sequentially, each feeding its result
725
+ * into the next.
726
+ *
727
+ * Every function in the chain is executed in its **own** temporary
728
+ * thread, which is terminated after use. This means each step runs
729
+ * in a fresh worker – state does not carry over between steps (by
730
+ * design). The `initArgs`/`initTransfer` from the parent thread's
731
+ * options are **not** forwarded to the temporary threads.
732
+ *
733
+ * @param {*} initialValue - The input to the first function.
734
+ * @param {...Function} fns
735
+ * Functions to execute in order. Each receives the previous
736
+ * result and returns the next result.
737
+ * @returns {Promise<any>}
738
+ * The final result after all functions have been applied.
739
+ *
740
+ * @throws {TypeError} If any chain element is not a function.
741
+ *
742
+ * @example
743
+ * ```js
744
+ * const t = new Thread((x) => x);
745
+ *
746
+ * const result = await t.runChain(
747
+ * 10,
748
+ * (x) => x + 5, // 15
749
+ * (x) => x * 2, // 30
750
+ * (x) => x - 3, // 27
751
+ * );
752
+ * console.log(result); // 27
753
+ * ```
754
+ */
755
+ async runChain(initialValue, ...fns) {
756
+ let current = initialValue;
757
+ const chainOptions = { ...this._options };
758
+ delete chainOptions.initArgs;
759
+ delete chainOptions.initTransfer;
760
+ for (const fn of fns) {
761
+ if (typeof fn !== 'function') throw new TypeError('Chain elements must be functions');
762
+ const temp = new Thread(fn, chainOptions);
763
+ try {
764
+ current = await temp.run(current);
765
+ } finally {
766
+ temp.terminate();
767
+ }
768
+ }
769
+ return current;
770
+ }
771
+
772
+ /**
773
+ * Process an array in **chunks**, yielding results as they complete.
774
+ *
775
+ * This is an async generator – use `for await` to consume results.
776
+ * Each chunk is sent to the thread sequentially (to avoid overhead).
777
+ * For true parallelism, use a {@link ThreadPool} instead.
778
+ *
779
+ * @param {Array} array - Data to process.
780
+ * @param {number} chunkSize - Number of elements per chunk (≥ 1).
781
+ * @param {Function} processor
782
+ * Function that receives `(chunk, ctx)` and returns a result.
783
+ * Receives the same `ctx` object as `exec`.
784
+ * @param {import('./types.js').ThreadRunOptions} [options={}]
785
+ * Options forwarded to each `run()` call.
786
+ * @yields {*} The result of processing each chunk.
787
+ *
788
+ * @throws {TypeError} If `array` is not an array.
789
+ * @throws {TypeError} If `chunkSize < 1`.
790
+ * @throws {TypeError} If `processor` is not a function.
791
+ *
792
+ * @example
793
+ * ```js
794
+ * const t = new Thread((chunk) => chunk.reduce((a, b) => a + b, 0));
795
+ * const data = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
796
+ *
797
+ * for await (const partial of t.runStreaming(data, 3, null)) {
798
+ * console.log('Chunk sum:', partial);
799
+ * }
800
+ * // Chunk sum: 6 (1+2+3)
801
+ * // Chunk sum: 15 (4+5+6)
802
+ * // Chunk sum: 24 (7+8+9)
803
+ * // Chunk sum: 10 (10)
804
+ * ```
805
+ */
806
+ async *runStreaming(array, chunkSize, processor, options = {}) {
807
+ if (!Array.isArray(array)) throw new TypeError('array must be an array');
808
+ if (chunkSize < 1) throw new TypeError('chunkSize must be >= 1');
809
+ if (typeof processor !== 'function') throw new TypeError('processor must be a function');
810
+
811
+ for (let i = 0; i < array.length; i += chunkSize) {
812
+ const chunk = array.slice(i, i + chunkSize);
813
+ const result = await this.run(chunk, { ...options, _batch: false });
814
+ yield result;
815
+ }
816
+ }
817
+
818
+ // -----------------------------------------------------------------------
819
+ // Public API – lifecycle
820
+ // -----------------------------------------------------------------------
821
+
822
+ /**
823
+ * Hot-swap the worker's `exec` function and restart.
824
+ *
825
+ * The old worker is terminated, a new Blob URL is created from the
826
+ * updated script, and a fresh worker is spawned. Pending tasks are
827
+ * **not** preserved – they are rejected with {@link ThreadTerminatedError}.
828
+ *
829
+ * @param {Function} newExec - The new exec function.
830
+ * @throws {ThreadTerminatedError} If the thread has already been terminated.
831
+ * @throws {TypeError} If `newExec` is not a function.
832
+ *
833
+ * @example
834
+ * ```js
835
+ * const t = new Thread((x) => x + 1);
836
+ * await t.run(5); // 6
837
+ *
838
+ * // Hot-swap to a new implementation
839
+ * t.reload((x) => x * 10);
840
+ * await t.run(5); // 50
841
+ * ```
842
+ */
843
+ reload(newExec) {
844
+ if (this._isTerminated) throw new ThreadTerminatedError('Thread terminated');
845
+ if (typeof newExec !== 'function') throw new TypeError('newExec must be a function');
846
+ this._execFn = newExec;
847
+ // Rebuild script and restart
848
+ this._workerScript = this._buildWorkerScript();
849
+
850
+ if (this._worker) {
851
+ terminateWorker(this._worker);
852
+ this._worker = null;
853
+ }
854
+ this._spawnWorker();
855
+ if (this._healthCheckInterval > 0) this._startHealthChecks();
856
+ }
857
+
858
+ /**
859
+ * Warm up the worker by running a no-op task.
860
+ *
861
+ * Useful after creating the thread to force the worker to compile
862
+ * your function and be ready for real tasks.
863
+ *
864
+ * @param {number} [timeout=5000] - Max ms to wait for the warmup.
865
+ * @returns {Promise<void>}
866
+ *
867
+ * @example
868
+ * ```js
869
+ * const t = new Thread(heavyFunction);
870
+ * await t.warmup(); // worker is now compiled and ready
871
+ * const t0 = performance.now();
872
+ * await t.run(input); // first call is fast
873
+ * ```
874
+ */
875
+ warmup(timeout = 5000) {
876
+ return this.run(undefined, { timeout });
877
+ }
878
+
879
+ /**
880
+ * Gracefully terminate after running cleanup and waiting for
881
+ * pending tasks.
882
+ *
883
+ * 1. Waits (polling every 50ms) until all pending tasks resolve.
884
+ * 2. Sends an `isCleanup` message to the worker and waits for
885
+ * `cleanupDone` (or a 5s timeout).
886
+ * 3. Calls {@link Thread.terminate} to kill the worker and release
887
+ * the Blob URL.
888
+ *
889
+ * @returns {Promise<void>}
890
+ *
891
+ * @example
892
+ * ```js
893
+ * const t = new Thread({
894
+ * setup() { return { conn: openDB() }; },
895
+ * exec(state, query) { return state.conn.query(query); },
896
+ * cleanup(state) { state.conn.close(); },
897
+ * });
898
+ *
899
+ * // ... do work ...
900
+ * await t.terminateGracefully(); // DB connection is properly closed
901
+ * ```
902
+ */
903
+ async terminateGracefully() {
904
+ if (this._isTerminated) return;
905
+ while (this._pending.size > 0) {
906
+ await new Promise((resolve) => setTimeout(resolve, 50));
907
+ }
908
+ if (this._worker && !this._isTerminated) {
909
+ const cleanupId = this._nextId++;
910
+ try {
911
+ await new Promise((resolve, reject) => {
912
+ const timer = setTimeout(
913
+ () => reject(new ThreadTimeoutError('Cleanup timed out')),
914
+ 5000,
915
+ );
916
+ const handler = (event) => {
917
+ if (event.data && event.data.id === cleanupId && event.data.type === 'cleanupDone') {
918
+ clearTimeout(timer);
919
+ resolve();
920
+ }
921
+ };
922
+ this._worker.addEventListener('message', handler, { once: true });
923
+ this._worker.postMessage({ id: cleanupId, isCleanup: true });
924
+ });
925
+ } catch (_) {
926
+ // ignore
927
+ }
928
+ }
929
+ this.terminate();
930
+ }
931
+
932
+ /**
933
+ * Immediately terminate the worker.
934
+ *
935
+ * - All pending task promises are rejected with
936
+ * {@link ThreadTerminatedError}.
937
+ * - The worker is killed via `.terminate()`.
938
+ * - The Blob URL is revoked.
939
+ * - Idle and health-check timers are cleared.
940
+ * - The cache is cleared.
941
+ *
942
+ * After calling this method, `thread.terminated` is `true` and no
943
+ * further tasks can be submitted.
944
+ *
945
+ * @example
946
+ * ```js
947
+ * const t = new Thread((x) => x);
948
+ * t.terminate();
949
+ * console.log(t.terminated); // true
950
+ * ```
951
+ */
952
+ terminate() {
953
+ if (this._isTerminated) return;
954
+ this._isTerminated = true;
955
+ if (this._idleTimer) clearTimeout(this._idleTimer);
956
+ if (this._healthTimer) clearInterval(this._healthTimer);
957
+ if (this._backoffTimer) clearTimeout(this._backoffTimer);
958
+
959
+ if (this._worker) {
960
+ terminateWorker(this._worker);
961
+ this._worker = null;
962
+ }
963
+ for (const [id, { reject, timer, abortHandler }] of this._pending) {
964
+ clearTimeout(timer);
965
+ if (abortHandler) abortHandler();
966
+ reject(new ThreadTerminatedError('Thread terminated'));
967
+ }
968
+ this._pending.clear();
969
+ this._isIdle = true;
970
+ this._activeTasks = 0;
971
+ this._listeners.terminate.forEach((h) => h());
972
+ this._cache.clear();
973
+ }
974
+
975
+ // -----------------------------------------------------------------------
976
+ // Public API – events
977
+ // -----------------------------------------------------------------------
978
+
979
+ /**
980
+ * Register an event listener.
981
+ *
982
+ * Returns `this` for method chaining.
983
+ *
984
+ * @param {import('./types.js').ThreadEventName} event - Event name.
985
+ * @param {Function} handler - Callback function.
986
+ * @returns {this} This thread instance (chainable).
987
+ *
988
+ * @throws {Error} If `event` is not a supported event name.
989
+ *
990
+ * @example
991
+ * ```js
992
+ * const t = new Thread((x) => x * 2);
993
+ *
994
+ * t
995
+ * .on('result', (result) => console.log('Result:', result))
996
+ * .on('error', (info) => console.error('Error:', info.error))
997
+ * .on('timing', (ms) => console.log(`${ms.toFixed(1)}ms`));
998
+ *
999
+ * await t.run(21); // logs "Result: 42" and "0.3ms"
1000
+ * ```
1001
+ *
1002
+ * @example
1003
+ * ```js
1004
+ * // Listen to all metrics updates
1005
+ * t.on('metrics', (snap) => {
1006
+ * console.log(`${snap.count} tasks, avg ${snap.avg.toFixed(1)}ms`);
1007
+ * });
1008
+ * ```
1009
+ */
1010
+ on(event, handler) {
1011
+ if (this._listeners[event]) {
1012
+ this._listeners[event].push(handler);
1013
+ } else {
1014
+ throw new Error(`Unsupported event: ${event}`);
1015
+ }
1016
+ return this;
1017
+ }
1018
+
1019
+ /**
1020
+ * Remove an event listener.
1021
+ *
1022
+ * The handler must be the same function reference that was passed to
1023
+ * {@link Thread.on}. Returns `this` for method chaining.
1024
+ *
1025
+ * @param {import('./types.js').ThreadEventName} event - Event name.
1026
+ * @param {Function} handler - The handler to remove.
1027
+ * @returns {this} This thread instance (chainable).
1028
+ *
1029
+ * @example
1030
+ * ```js
1031
+ * const handler = (result) => console.log(result);
1032
+ * t.on('result', handler);
1033
+ * // later...
1034
+ * t.off('result', handler);
1035
+ * ```
1036
+ */
1037
+ off(event, handler) {
1038
+ if (this._listeners[event]) {
1039
+ const idx = this._listeners[event].indexOf(handler);
1040
+ if (idx !== -1) this._listeners[event].splice(idx, 1);
1041
+ }
1042
+ return this;
1043
+ }
1044
+
1045
+ // -----------------------------------------------------------------------
1046
+ // Internal task runner
1047
+ // -----------------------------------------------------------------------
1048
+
1049
+ /**
1050
+ * Core task execution logic shared by `run` and `runBatch`.
1051
+ *
1052
+ * Handles: argument parsing, cache lookup, concurrency gating,
1053
+ * beforeRun hooks, timeout/abort wiring, retry logic, and cache
1054
+ * storage.
1055
+ *
1056
+ * @param {boolean} isBatch - Whether this is a batch call.
1057
+ * @param {...any} args - Task arguments and optional options object.
1058
+ * @returns {Promise<any>}
1059
+ * @private
1060
+ */
1061
+ _runTask(isBatch, ...args) {
1062
+ let options = {};
1063
+ const last = args[args.length - 1];
1064
+ if (
1065
+ last &&
1066
+ typeof last === 'object' &&
1067
+ !Array.isArray(last) &&
1068
+ ('timeout' in last ||
1069
+ 'transfer' in last ||
1070
+ 'signal' in last ||
1071
+ 'retries' in last ||
1072
+ '_batch' in last ||
1073
+ 'cacheTTL' in last)
1074
+ ) {
1075
+ options = args.pop();
1076
+ }
1077
+
1078
+ const timeout = options.timeout || this._timeout;
1079
+ const transfer = options.transfer || [];
1080
+ const signal = options.signal || null;
1081
+ const retries = options.retries || 0;
1082
+ const isBatchMode = options._batch || isBatch;
1083
+ const cacheTTL = options.cacheTTL || 0;
1084
+
1085
+ const taskArgs = isBatchMode ? args[0] : args;
1086
+
1087
+ // Cache check (only for non-batch)
1088
+ let cacheKey = null;
1089
+ if (cacheTTL > 0 && !isBatchMode) {
1090
+ this._clearExpiredCache();
1091
+ cacheKey = this._getCacheKey(taskArgs);
1092
+ if (cacheKey && this._cache.has(cacheKey)) {
1093
+ const entry = this._cache.get(cacheKey);
1094
+ return Promise.resolve(entry.result);
1095
+ }
1096
+ }
1097
+
1098
+ return new Promise((resolve, reject) => {
1099
+ if (this._isTerminated) {
1100
+ return reject(new ThreadTerminatedError('Thread terminated'));
1101
+ }
1102
+ if (this._activeTasks >= this._concurrency) {
1103
+ return reject(new Error(`Concurrency limit ${this._concurrency} exceeded`));
1104
+ }
1105
+
1106
+ // beforeRun hooks
1107
+ let finalArgs = taskArgs;
1108
+ for (const hook of this._listeners.beforeRun) {
1109
+ const transformed = hook(finalArgs);
1110
+ if (transformed !== undefined) finalArgs = transformed;
1111
+ }
1112
+
1113
+ const id = this._nextId++;
1114
+ let attempts = 0;
1115
+ this._activeTasks++;
1116
+
1117
+ const execute = () => {
1118
+ const startTime = performance.now();
1119
+ const timer = setTimeout(() => {
1120
+ if (this._pending.has(id)) {
1121
+ this._pending.delete(id);
1122
+ this._activeTasks--;
1123
+ reject(new ThreadTimeoutError(`Timed out after ${timeout}ms`));
1124
+ }
1125
+ }, timeout);
1126
+
1127
+ let abortHandler = null;
1128
+ if (signal) {
1129
+ if (signal.aborted) {
1130
+ clearTimeout(timer);
1131
+ this._activeTasks--;
1132
+ return reject(new ThreadAbortError('Task aborted'));
1133
+ }
1134
+ const onAbort = () => {
1135
+ if (this._pending.has(id)) {
1136
+ const { reject: r, timer: t } = this._pending.get(id);
1137
+ clearTimeout(t);
1138
+ this._pending.delete(id);
1139
+ this._activeTasks--;
1140
+ r(new ThreadAbortError('Task aborted'));
1141
+ }
1142
+ };
1143
+ signal.addEventListener('abort', onAbort);
1144
+ abortHandler = () => signal.removeEventListener('abort', onAbort);
1145
+ }
1146
+
1147
+ this._pending.set(id, {
1148
+ resolve: (res) => {
1149
+ if (cacheTTL > 0 && cacheKey && !isBatchMode) {
1150
+ this._cache.set(cacheKey, { result: res, timestamp: Date.now(), ttl: cacheTTL });
1151
+ }
1152
+ resolve(res);
1153
+ },
1154
+ reject,
1155
+ timer,
1156
+ abortHandler,
1157
+ startTime,
1158
+ args: finalArgs,
1159
+ });
1160
+
1161
+ try {
1162
+ this._worker.postMessage(
1163
+ {
1164
+ id,
1165
+ args: finalArgs,
1166
+ transfer,
1167
+ hasProgress: this._listeners.progress.length > 0,
1168
+ isBatch: isBatchMode,
1169
+ },
1170
+ transfer,
1171
+ );
1172
+ } catch (err) {
1173
+ clearTimeout(timer);
1174
+ if (abortHandler) abortHandler();
1175
+ this._pending.delete(id);
1176
+ this._activeTasks--;
1177
+ if (attempts < retries && !this._isTerminated) {
1178
+ attempts++;
1179
+ execute();
1180
+ } else {
1181
+ reject(err);
1182
+ }
1183
+ }
1184
+ };
1185
+
1186
+ execute();
1187
+ });
1188
+ }
1189
+
1190
+ // -----------------------------------------------------------------------
1191
+ // Getters
1192
+ // -----------------------------------------------------------------------
1193
+
1194
+ /**
1195
+ * Current metrics snapshot for this thread.
1196
+ *
1197
+ * Equivalent to `this._metrics.snapshot()`.
1198
+ *
1199
+ * @type {import('./types.js').MetricsSnapshot}
1200
+ *
1201
+ * @example
1202
+ * ```js
1203
+ * console.log(thread.metrics.avg); // 42.5
1204
+ * console.log(thread.metrics.throughput); // 150 tasks/sec
1205
+ * ```
1206
+ */
1207
+ get metrics() {
1208
+ return this._metrics.snapshot();
1209
+ }
1210
+
1211
+ /**
1212
+ * Whether the thread has any pending (in-flight) tasks.
1213
+ *
1214
+ * @type {boolean}
1215
+ *
1216
+ * @example
1217
+ * ```js
1218
+ * console.log(thread.busy); // false
1219
+ * const p = thread.run(data);
1220
+ * console.log(thread.busy); // true
1221
+ * await p;
1222
+ * console.log(thread.busy); // false
1223
+ * ```
1224
+ */
1225
+ get busy() {
1226
+ return this._pending.size > 0;
1227
+ }
1228
+
1229
+ /**
1230
+ * Whether the thread has been terminated.
1231
+ *
1232
+ * Once `true`, no further tasks can be submitted.
1233
+ *
1234
+ * @type {boolean}
1235
+ *
1236
+ * @example
1237
+ * ```js
1238
+ * console.log(thread.terminated); // false
1239
+ * thread.terminate();
1240
+ * console.log(thread.terminated); // true
1241
+ * ```
1242
+ */
1243
+ get terminated() {
1244
+ return this._isTerminated;
1245
+ }
1246
+ }