@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/LICENSE +21 -0
- package/README.md +1156 -0
- package/package.json +133 -0
- package/src/adapters.js +404 -0
- package/src/config/adapters.js +188 -0
- package/src/config/define.js +171 -0
- package/src/config/env.js +218 -0
- package/src/config/frameworks.js +268 -0
- package/src/config/index.js +256 -0
- package/src/config/schema.js +375 -0
- package/src/config.js +52 -0
- package/src/deno.js +29 -0
- package/src/edge.js +38 -0
- package/src/env.js +361 -0
- package/src/error.js +340 -0
- package/src/factory.js +591 -0
- package/src/gpu/adapters.js +227 -0
- package/src/gpu/chains.js +261 -0
- package/src/gpu/env.js +372 -0
- package/src/gpu/gpu.js +1199 -0
- package/src/gpu/helpers.js +240 -0
- package/src/gpu/hooks.js +284 -0
- package/src/gpu/index.js +16 -0
- package/src/gpu/shaders.js +834 -0
- package/src/gpu/special.js +493 -0
- package/src/hooks.js +448 -0
- package/src/index.js +557 -0
- package/src/metrix.js +222 -0
- package/src/node.js +36 -0
- package/src/pool.js +740 -0
- package/src/serializer.js +139 -0
- package/src/thread.js +1246 -0
- package/src/types.js +1354 -0
- package/src/worker-factory.js +347 -0
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
|
+
}
|