@offmain/workerkit 0.10.0 → 0.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -57,14 +57,68 @@ console.log(data); // [15]
57
57
 
58
58
  ## WorkerConfig Options
59
59
 
60
- | Option | Type | Default | Description |
61
- | ---------------- | ---------- | ------------------------------- | --------------------------------------------------------------------------------------------------- |
62
- | `name` | `string` | — | Unique identifier used to call the worker |
63
- | `role` | `string` | — | Logical grouping label |
64
- | `func` | `Function` | — | The exported worker function to run |
65
- | `maxConcurrency` | `number` | `navigator.hardwareConcurrency` | Max parallel worker instances defaults to the number of logical CPU cores reported by the browser |
66
- | `retries` | `number` | `0` | How many times to retry a failed shard before marking it as rejected |
67
- | `partition` | `boolean` | `false` | Split array input across multiple workers automatically |
60
+ | Option | Type | Default | Description |
61
+ | ---------------- | --------------- | ------------------------------- | --------------------------------------------------------------------------------------------------- |
62
+ | `name` | `string` | — | Unique identifier used to call the worker |
63
+ | `role` | `string` | — | Logical grouping label |
64
+ | `func` | `Function` | — | The exported worker function to run (optional if `workerURL` is provided) |
65
+ | `workerURL` | `string \| URL` | — | Worker script URL or `new URL(..., import.meta.url)` for module bundlers |
66
+ | `maxConcurrency` | `number` | `navigator.hardwareConcurrency` | Max parallel worker instances — defaults to the number of logical CPU cores reported by the browser |
67
+ | `retries` | `number` | `0` | How many times to retry a failed shard before marking it as rejected |
68
+ | `partition` | `boolean` | `false` | Split array input across multiple workers automatically |
69
+
70
+ ---
71
+
72
+ ## Module Bundler Integration (`workerURL`)
73
+
74
+ When worker logic relies on external npm packages (such as Luxon, `date-fns`, `i18next`, or custom data transformation modules), dynamic inline stringification (`.toString()`) cannot access those closed-over imports.
75
+
76
+ Passing `workerURL` enables modern module bundlers (Webpack 5, Vite, Rollup, Parcel) to analyze and bundle the worker file along with all of its dependencies into a dedicated ES module worker chunk.
77
+
78
+ ### Dedicated Worker Script
79
+
80
+ ```ts
81
+ // transform-data.worker.ts
82
+ import { format } from 'date-fns';
83
+ import { t } from './i18n.ts';
84
+
85
+ self.addEventListener('message', (event) => {
86
+ try {
87
+ const { items, locale } = event.data.data;
88
+ const result = items.map((item: any) => ({
89
+ ...item,
90
+ formattedDate: format(new Date(item.timestamp), 'yyyy-MM-dd'),
91
+ label: t('transaction', locale),
92
+ }));
93
+
94
+ self.postMessage({ ok: true, data: result });
95
+ } catch (err) {
96
+ self.postMessage({ ok: false, error: (err as Error).message });
97
+ }
98
+ });
99
+ ```
100
+
101
+ ### Registering with `workerURL`
102
+
103
+ ```ts
104
+ import { MainWorkerFactory } from '@offmain/workerkit';
105
+
106
+ const factory = new MainWorkerFactory({
107
+ workers: [
108
+ {
109
+ name: 'transformData',
110
+ role: 'compute',
111
+ // Webpack 5 / Vite statically analyzes new URL(..., import.meta.url)
112
+ workerURL: new URL('./transform-data.worker.ts', import.meta.url),
113
+ maxConcurrency: 4,
114
+ },
115
+ ] as const,
116
+ });
117
+
118
+ const settled = await factory.runWorker('transformData', {
119
+ srcData: { locale: 'es', items: [{ timestamp: Date.now() }] },
120
+ });
121
+ ```
68
122
 
69
123
  ---
70
124
 
@@ -154,6 +208,110 @@ console.log(result); // only this small result crossed to main thread
154
208
 
155
209
  ---
156
210
 
211
+ ## Persistent Workers
212
+
213
+ Keep a worker alive with a cached dataset, then re-run it with different configs without re-sending the data.
214
+
215
+ ### Why use persistent workers?
216
+
217
+ In a typical workflow where you apply multiple transformations to the same dataset, the standard `runWorker` approach re-serializes the entire dataset on every call:
218
+
219
+ ```
220
+ Call 1: Main ──[200k items]──→ Worker → Main
221
+ Call 2: Main ──[200k items]──→ Worker → Main ← same data, different config
222
+ Call 3: Main ──[200k items]──→ Worker → Main ← same data again
223
+ ```
224
+
225
+ With 5 config variations on a 1.6 MB dataset, that's ~8 MB of redundant serialization. Persistent workers eliminate this by caching the dataset inside the worker:
226
+
227
+ ```
228
+ Call 1: Main ──[200k items + config]──→ Worker → Main ← dataset cached
229
+ Call 2: Main ──[config only]──────────→ Worker → Main ← reuses cache
230
+ Call 3: Main ──[config only]──────────→ Worker → Main ← reuses cache
231
+ ```
232
+
233
+ Only the first call transfers the dataset. Subsequent calls send just the config object (typically a few bytes), saving both serialization time and memory pressure.
234
+
235
+ ### Usage
236
+
237
+ ```ts
238
+ import { MainWorkerFactory } from '@offmain/workerkit';
239
+ import { transformArray } from './transform.worker.ts';
240
+
241
+ const factory = new MainWorkerFactory({
242
+ workers: [
243
+ { name: 'transform', role: 'computation', func: transformArray },
244
+ ] as const,
245
+ });
246
+
247
+ // First call: send dataset + config (dataset gets cached in worker memory)
248
+ const r1 = await factory.runPersistent('transform', {
249
+ dataset: largeArray,
250
+ config: { multiplier: 2, filter: 'even' },
251
+ });
252
+
253
+ // Subsequent calls: only config — dataset is reused from cache
254
+ const r2 = await factory.runPersistent('transform', {
255
+ config: { multiplier: 5, filter: 'odd' },
256
+ });
257
+
258
+ const r3 = await factory.runPersistent('transform', {
259
+ config: { multiplier: 1, filter: 'none', limit: 1000 },
260
+ });
261
+
262
+ // Update the dataset when it changes
263
+ const r4 = await factory.runPersistent('transform', {
264
+ dataset: newArray, // replaces cached dataset
265
+ config: { multiplier: 3, filter: 'even' },
266
+ });
267
+
268
+ // Release the worker when done — frees memory
269
+ factory.release('transform');
270
+ ```
271
+
272
+ ### How the worker function receives data
273
+
274
+ The worker function signature stays the same as a regular worker — it receives `{ data, config }`:
275
+
276
+ ```ts
277
+ // transform.worker.ts
278
+ export function transformArray({
279
+ data,
280
+ config,
281
+ }: {
282
+ data: number[];
283
+ config: { multiplier: number; filter: string };
284
+ }) {
285
+ return data
286
+ .filter((n) => /* apply filter */)
287
+ .map((n) => n * config.multiplier);
288
+ }
289
+ ```
290
+
291
+ The framework handles the caching transparently — your function always receives the full `data` (from cache or freshly provided) plus the current `config`.
292
+
293
+ ### When to use persistent vs runWorker
294
+
295
+ | Scenario | Use |
296
+ | ------------------------------------------------------ | --------------- |
297
+ | One-off computation | `runWorker` |
298
+ | Same dataset, multiple config variations | `runPersistent` |
299
+ | Interactive UI where user tweaks params on static data | `runPersistent` |
300
+ | Dataset changes frequently | `runWorker` |
301
+ | Need partitioning across multiple threads | `runWorker` |
302
+
303
+ ### Memory management
304
+
305
+ The cached dataset lives in worker memory until `release()` is called. For large datasets, always call `release()` when you're done to free the memory:
306
+
307
+ ```ts
308
+ factory.release('transform');
309
+ ```
310
+
311
+ After releasing, the next `runPersistent` call will create a fresh worker instance (requiring a new dataset).
312
+
313
+ ---
314
+
157
315
  ## ESLint Plugin
158
316
 
159
317
  The package ships with two ESLint rules to catch common worker mistakes at lint time.
package/dist/index.cjs CHANGED
@@ -1,4 +1,4 @@
1
- "use strict";var w=Object.defineProperty;var y=(o,e,t)=>e in o?w(o,e,{enumerable:!0,configurable:!0,writable:!0,value:t}):o[e]=t;var h=(o,e,t)=>y(o,typeof e!="symbol"?e+"":e,t);Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const m=o=>`
1
+ "use strict";var v=Object.defineProperty;var y=(n,e,r)=>e in n?v(n,e,{enumerable:!0,configurable:!0,writable:!0,value:r}):n[e]=r;var k=(n,e,r)=>y(n,typeof e!="symbol"?e+"":e,r);Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const m=n=>`
2
2
  const extractTransferables = (value, seen = new Set()) => {
3
3
  if (value === null || typeof value !== 'object') return [];
4
4
  if (seen.has(value)) return [];
@@ -15,13 +15,13 @@ const extractTransferables = (value, seen = new Set()) => {
15
15
 
16
16
  self.addEventListener('message', async (event) => {
17
17
  try {
18
- const output = await ${o}(event.data);
18
+ const output = await ${n}(event.data);
19
19
  self.postMessage({ ok: true, data: output }, extractTransferables(output));
20
20
  } catch (err) {
21
21
  self.postMessage({ ok: false, error: err instanceof Error ? err.message : String(err) });
22
22
  }
23
23
  })
24
- `,v=o=>`
24
+ `,P=n=>`
25
25
  const extractTransferables = (value, seen = new Set()) => {
26
26
  if (value === null || typeof value !== 'object') return [];
27
27
  if (seen.has(value)) return [];
@@ -36,7 +36,7 @@ const extractTransferables = (value, seen = new Set()) => {
36
36
  return Object.values(value).flatMap(v => extractTransferables(v, seen));
37
37
  };
38
38
 
39
- const workerFn = ${o};
39
+ const workerFn = ${n};
40
40
  let outputPort = null;
41
41
  let inputPort = null;
42
42
  let pendingData = null;
@@ -93,7 +93,63 @@ self.addEventListener('message', (event) => {
93
93
  pendingData = event.data;
94
94
  }
95
95
  });
96
- `;class k{constructor(e,t){h(this,"_worker");const a=(t!=null&&t.pipeline?v:m)(e.toString()),u=new Blob([a],{type:"application/javascript"});this._worker=new Worker(URL.createObjectURL(u))}get getWorker(){return this._worker}}class P{constructor(e){this.results=e}}function g(o,e=new Set){return o===null||typeof o!="object"?[]:e.has(o)?[]:(e.add(o),o instanceof ArrayBuffer||o instanceof MessagePort||typeof ImageBitmap<"u"&&o instanceof ImageBitmap||typeof OffscreenCanvas<"u"&&o instanceof OffscreenCanvas?[o]:ArrayBuffer.isView(o)?[o.buffer]:Array.isArray(o)?o.flatMap(t=>g(t,e)):Object.values(o).flatMap(t=>g(t,e)))}class W{constructor(e){h(this,"_workers");h(this,"_threads");this._workers=e.workers,this._threads=navigator.hardwareConcurrency}initWorker(e){return new k(e)}partitionArray(e,t){if(!e.length)return[];if(t<=0)throw new Error("numChunks must be positive");const n=Math.min(t,e.length),a=Math.floor(e.length/n),u=e.length%n,l=[];let f=0;for(let r=0;r<n;r++){const s=a+(r<u?1:0);l.push(e.slice(f,f+s)),f+=s}return l}findWorkerByName(e){return this._workers.find(t=>t.name===e)}async runWorker(e,{srcData:t,...n}){const a=this.findWorkerByName(e);if(!a)return Promise.reject(new Error(`Worker "${e}" not found`));const u=a.maxConcurrency??this._threads,l=!!(Array.isArray(t)&&t.length>1&&a.partition),f=l?this.partitionArray(t,u):t,r=this.createWorkerPromises(a,e,{data:f,...n},u,l),s=await Promise.allSettled(r);return new P(s)}createWorkerPromises(e,t,n,a,u){const{data:l,...f}=n;return Array.from({length:a},(r,s)=>{const i=u&&Array.isArray(l)?l[s]:l;return this.runWorkerWithRetry({workerFunc:e.func,workerName:t,index:s,data:{data:i,...f}},e.retries)})}async runWorkerWithRetry(e,t=2){try{return await this.initiateWorker(e)}catch(n){if(t>0)return console.error(`Worker ${e.index} failed, retrying (${t} left):`,n),this.runWorkerWithRetry(e,t-1);throw console.error("Worker failed after all retries:",n),n}}initiateWorker({workerFunc:e,workerName:t,index:n,data:a}){return new Promise((u,l)=>{const r=this.initWorker(e).getWorker;r.onerror=i=>{r.terminate(),l({index:n,workerConfigs:{workerFunc:e,workerName:t,index:n,data:a},failedResult:i})},r.onmessage=i=>{var c,p;if(((c=i.data)==null?void 0:c.ok)===!1){r.terminate(),l({index:n,workerConfigs:{workerFunc:e,workerName:t,index:n,data:a},failedResult:new ErrorEvent("error",{message:i.data.error})});return}u({index:n,workerConfigs:{workerFunc:e,workerName:t,index:n,data:a},successResult:new MessageEvent("message",{data:(p=i.data)==null?void 0:p.data})}),r.terminate()};const s={index:n,...Array.isArray(a)?{data:a}:a};r.postMessage(s,g(s))})}async collectResults(e,t={}){const n=e.results.filter(r=>r.status==="fulfilled"),a=e.results.filter(r=>r.status==="rejected"),u=n.map(r=>r.value.successResult.data),l=t.reducer?t.reducer.toString():"(shards) => shards.flat()";return{data:await new Promise((r,s)=>{const i=`
96
+ `,W=n=>`
97
+ const extractTransferables = (value, seen = new Set()) => {
98
+ if (value === null || typeof value !== 'object') return [];
99
+ if (seen.has(value)) return [];
100
+ seen.add(value);
101
+ if (value instanceof ArrayBuffer || value instanceof MessagePort ||
102
+ (typeof ImageBitmap !== 'undefined' && value instanceof ImageBitmap) ||
103
+ (typeof OffscreenCanvas !== 'undefined' && value instanceof OffscreenCanvas)) {
104
+ return [value];
105
+ }
106
+ if (ArrayBuffer.isView(value)) return [value.buffer];
107
+ if (Array.isArray(value)) return value.flatMap(i => extractTransferables(i, seen));
108
+ return Object.values(value).flatMap(v => extractTransferables(v, seen));
109
+ };
110
+
111
+ const workerFn = ${n};
112
+ let cachedDataset = null;
113
+
114
+ self.addEventListener('message', async (event) => {
115
+ const msg = event.data;
116
+
117
+ if (msg && msg.type === 'release') {
118
+ cachedDataset = null;
119
+ self.postMessage({ ok: true, data: null, type: 'released' });
120
+ self.close();
121
+ return;
122
+ }
123
+
124
+ if (msg && msg.type === 'run') {
125
+ // Update cache if new dataset provided
126
+ if (msg.dataset !== undefined) {
127
+ cachedDataset = msg.dataset;
128
+ }
129
+
130
+ if (cachedDataset === null) {
131
+ self.postMessage({ ok: false, error: 'No dataset cached. Provide a dataset on the first call.' });
132
+ return;
133
+ }
134
+
135
+ try {
136
+ const output = await workerFn({ data: cachedDataset, config: msg.config });
137
+ self.postMessage({ ok: true, data: output }, extractTransferables(output));
138
+ } catch (err) {
139
+ self.postMessage({ ok: false, error: err instanceof Error ? err.message : String(err) });
140
+ }
141
+ return;
142
+ }
143
+
144
+ // Fallback: treat as a regular one-shot call for backwards compat
145
+ try {
146
+ const output = await workerFn(msg);
147
+ self.postMessage({ ok: true, data: output }, extractTransferables(output));
148
+ } catch (err) {
149
+ self.postMessage({ ok: false, error: err instanceof Error ? err.message : String(err) });
150
+ }
151
+ });
152
+ `;var w=(n=>(n.Default="default",n.Pipeline="pipeline",n.Persistent="persistent",n))(w||{});const M=Object.freeze({persistent:W,pipeline:P,default:m});class h{constructor(e,r){k(this,"_worker");if(r!=null&&r.workerURL)this._worker=new Worker(r.workerURL,{type:"module"});else if(e){const o=(r==null?void 0:r.mode)??"default",a=M[o](e.toString()),i=new Blob([a],{type:"application/javascript"});this._worker=new Worker(URL.createObjectURL(i))}else throw new Error("Either workerFunction or options.workerURL must be provided to WorkerFactory.")}get getWorker(){return this._worker}}class b{constructor(e){this.results=e}}function g(n,e=new Set){return n===null||typeof n!="object"?[]:e.has(n)?[]:(e.add(n),n instanceof ArrayBuffer||n instanceof MessagePort||typeof ImageBitmap<"u"&&n instanceof ImageBitmap||typeof OffscreenCanvas<"u"&&n instanceof OffscreenCanvas?[n]:ArrayBuffer.isView(n)?[n.buffer]:Array.isArray(n)?n.flatMap(r=>g(r,e)):Object.values(n).flatMap(r=>g(r,e)))}class E{constructor(e){k(this,"_workers");k(this,"_threads");k(this,"_persistentWorkers",new Map);this._workers=e.workers,this._threads=navigator.hardwareConcurrency}initWorker(e){return new h(e.func,{workerURL:e.workerURL})}partitionArray(e,r){if(!e.length)return[];if(r<=0)throw new Error("numChunks must be positive");const o=Math.min(r,e.length),a=Math.floor(e.length/o),i=e.length%o,l=[];let f=0;for(let t=0;t<o;t++){const s=a+(t<i?1:0);l.push(e.slice(f,f+s)),f+=s}return l}findWorkerByName(e){return this._workers.find(r=>r.name===e)}async runWorker(e,{srcData:r,...o}){const a=this.findWorkerByName(e);if(!a)return Promise.reject(new Error(`Worker "${e}" not found`));const i=a.maxConcurrency??this._threads,l=!!(Array.isArray(r)&&r.length>1&&a.partition),f=l?this.partitionArray(r,i):r,t=this.createWorkerPromises(a,e,{data:f,...o},i,l),s=await Promise.allSettled(t);return new b(s)}createWorkerPromises(e,r,o,a,i){const{data:l,...f}=o;return Array.from({length:a},(t,s)=>{const u=i&&Array.isArray(l)?l[s]:l;return this.runWorkerWithRetry({workerFunc:e.func,workerURL:e.workerURL,workerName:r,index:s,data:{data:u,...f}},e.retries)})}async runWorkerWithRetry(e,r=2){try{return await this.initiateWorker(e)}catch(o){if(r>0)return console.error(`Worker ${e.index} failed, retrying (${r} left):`,o),this.runWorkerWithRetry(e,r-1);throw console.error("Worker failed after all retries:",o),o}}initiateWorker({workerFunc:e,workerURL:r,workerName:o,index:a,data:i}){return new Promise((l,f)=>{const s=this.initWorker({name:o,role:"",func:e,workerURL:r}).getWorker;s.onerror=c=>{s.terminate(),f({index:a,workerConfigs:{workerFunc:e,workerName:o,index:a,data:i},failedResult:c})},s.onmessage=c=>{var d,p;if(((d=c.data)==null?void 0:d.ok)===!1){s.terminate(),f({index:a,workerConfigs:{workerFunc:e,workerName:o,index:a,data:i},failedResult:new ErrorEvent("error",{message:c.data.error})});return}l({index:a,workerConfigs:{workerFunc:e,workerName:o,index:a,data:i},successResult:new MessageEvent("message",{data:(p=c.data)==null?void 0:p.data})}),s.terminate()};const u={index:a,...Array.isArray(i)?{data:i}:i};s.postMessage(u,g(u))})}async collectResults(e,r={}){const o=e.results.filter(t=>t.status==="fulfilled"),a=e.results.filter(t=>t.status==="rejected"),i=o.map(t=>t.value.successResult.data),l=r.reducer?r.reducer.toString():"(shards) => shards.flat()";return{data:await new Promise((t,s)=>{const u=`
97
153
  const reducer = ${l};
98
154
  self.addEventListener('message', (event) => {
99
155
  try {
@@ -103,4 +159,4 @@ self.addEventListener('message', (event) => {
103
159
  self.postMessage({ ok: false, error: String(err) });
104
160
  }
105
161
  });
106
- `,c=new Blob([i],{type:"application/javascript"}),p=new Worker(URL.createObjectURL(c));p.onmessage=d=>{p.terminate(),d.data.ok?r(d.data.data):s(new Error(d.data.error))},p.onerror=d=>{p.terminate(),s(d)},p.postMessage(u)}),succeeded:n.length,failed:a.length,errors:a}}async pipeline(e){if(e.length===0)throw new Error("Pipeline requires at least one step");if(e.length===1){const t=e[0],n=this.findWorkerByName(t.worker);if(!n)throw new Error(`Worker "${t.worker}" not found`);const u=this.initWorker(n.func).getWorker;return new Promise((l,f)=>{u.onmessage=s=>{var i,c;u.terminate(),((i=s.data)==null?void 0:i.ok)===!1?f(new Error(s.data.error)):l((c=s.data)==null?void 0:c.data)},u.onerror=s=>{u.terminate(),f(s)};const r=t.srcData??{};u.postMessage({data:r,index:0},g(r))})}return new Promise((t,n)=>{const a=[],u=[];for(const r of e){const s=this.findWorkerByName(r.worker);if(!s){n(new Error(`Worker "${r.worker}" not found`));return}const i=new k(s.func,{pipeline:!0});a.push(i.getWorker)}for(let r=0;r<a.length-1;r++)u.push(new MessageChannel);for(let r=0;r<a.length;r++){const s=[],i={};r>0&&(i.inputPort=u[r-1].port1,s.push(i.inputPort)),r<a.length-1&&(i.outputPort=u[r].port2,s.push(i.outputPort)),a[r].postMessage({__pipeline_ports__:!0,...i},s)}const l=a[a.length-1];l.onmessage=r=>{var s,i;a.forEach(c=>c.terminate()),((s=r.data)==null?void 0:s.ok)===!1?n(new Error(r.data.error)):t((i=r.data)==null?void 0:i.data)},l.onerror=r=>{a.forEach(s=>s.terminate()),n(r)};const f=e[0].srcData??{};a[0].postMessage({data:f,index:0},g(f))})}}exports.MainWorkerFactory=W;exports.WorkerFactory=k;
162
+ `,c=new Blob([u],{type:"application/javascript"}),d=new Worker(URL.createObjectURL(c));d.onmessage=p=>{d.terminate(),p.data.ok?t(p.data.data):s(new Error(p.data.error))},d.onerror=p=>{d.terminate(),s(p)},d.postMessage(i)}),succeeded:o.length,failed:a.length,errors:a}}async pipeline(e){if(e.length===0)throw new Error("Pipeline requires at least one step");if(e.length===1){const r=e[0],o=this.findWorkerByName(r.worker);if(!o)throw new Error(`Worker "${r.worker}" not found`);const i=this.initWorker(o).getWorker;return new Promise((l,f)=>{i.onmessage=s=>{var u,c;i.terminate(),((u=s.data)==null?void 0:u.ok)===!1?f(new Error(s.data.error)):l((c=s.data)==null?void 0:c.data)},i.onerror=s=>{i.terminate(),f(s)};const t=r.srcData??{};i.postMessage({data:t,index:0},g(t))})}return new Promise((r,o)=>{const a=[],i=[];for(const t of e){const s=this.findWorkerByName(t.worker);if(!s){o(new Error(`Worker "${t.worker}" not found`));return}const u=new h(s.func,{mode:w.Pipeline,workerURL:s.workerURL});a.push(u.getWorker)}for(let t=0;t<a.length-1;t++)i.push(new MessageChannel);for(let t=0;t<a.length;t++){const s=[],u={};t>0&&(u.inputPort=i[t-1].port1,s.push(u.inputPort)),t<a.length-1&&(u.outputPort=i[t].port2,s.push(u.outputPort)),a[t].postMessage({__pipeline_ports__:!0,...u},s)}const l=a[a.length-1];l.onmessage=t=>{var s,u;a.forEach(c=>c.terminate()),((s=t.data)==null?void 0:s.ok)===!1?o(new Error(t.data.error)):r((u=t.data)==null?void 0:u.data)},l.onerror=t=>{a.forEach(s=>s.terminate()),o(t)};const f=e[0].srcData??{};a[0].postMessage({data:f,index:0},g(f))})}async runPersistent(e,r){const o=this.findWorkerByName(e);if(!o)throw new Error(`Worker "${e}" not found`);let a=this._persistentWorkers.get(e);return a||(a=new h(o.func,{mode:w.Persistent,workerURL:o.workerURL}).getWorker,this._persistentWorkers.set(e,a)),new Promise((i,l)=>{a.onmessage=t=>{var s,u;((s=t.data)==null?void 0:s.ok)===!1?l(new Error(t.data.error)):i((u=t.data)==null?void 0:u.data)},a.onerror=t=>{l(t)};const f={type:"run",config:r.config};r.dataset!==void 0&&(f.dataset=r.dataset),a.postMessage(f,g(f))})}release(e){const r=this._persistentWorkers.get(e);r&&(r.postMessage({type:"release"}),r.terminate(),this._persistentWorkers.delete(e))}}exports.MainWorkerFactory=E;exports.WorkerFactory=h;
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
- var w = Object.defineProperty;
2
- var m = (o, e, t) => e in o ? w(o, e, { enumerable: !0, configurable: !0, writable: !0, value: t }) : o[e] = t;
3
- var h = (o, e, t) => m(o, typeof e != "symbol" ? e + "" : e, t);
4
- const y = (o) => `
1
+ var v = Object.defineProperty;
2
+ var m = (n, e, r) => e in n ? v(n, e, { enumerable: !0, configurable: !0, writable: !0, value: r }) : n[e] = r;
3
+ var h = (n, e, r) => m(n, typeof e != "symbol" ? e + "" : e, r);
4
+ const y = (n) => `
5
5
  const extractTransferables = (value, seen = new Set()) => {
6
6
  if (value === null || typeof value !== 'object') return [];
7
7
  if (seen.has(value)) return [];
@@ -18,13 +18,13 @@ const extractTransferables = (value, seen = new Set()) => {
18
18
 
19
19
  self.addEventListener('message', async (event) => {
20
20
  try {
21
- const output = await ${o}(event.data);
21
+ const output = await ${n}(event.data);
22
22
  self.postMessage({ ok: true, data: output }, extractTransferables(output));
23
23
  } catch (err) {
24
24
  self.postMessage({ ok: false, error: err instanceof Error ? err.message : String(err) });
25
25
  }
26
26
  })
27
- `, v = (o) => `
27
+ `, P = (n) => `
28
28
  const extractTransferables = (value, seen = new Set()) => {
29
29
  if (value === null || typeof value !== 'object') return [];
30
30
  if (seen.has(value)) return [];
@@ -39,7 +39,7 @@ const extractTransferables = (value, seen = new Set()) => {
39
39
  return Object.values(value).flatMap(v => extractTransferables(v, seen));
40
40
  };
41
41
 
42
- const workerFn = ${o};
42
+ const workerFn = ${n};
43
43
  let outputPort = null;
44
44
  let inputPort = null;
45
45
  let pendingData = null;
@@ -96,7 +96,69 @@ self.addEventListener('message', (event) => {
96
96
  pendingData = event.data;
97
97
  }
98
98
  });
99
+ `, W = (n) => `
100
+ const extractTransferables = (value, seen = new Set()) => {
101
+ if (value === null || typeof value !== 'object') return [];
102
+ if (seen.has(value)) return [];
103
+ seen.add(value);
104
+ if (value instanceof ArrayBuffer || value instanceof MessagePort ||
105
+ (typeof ImageBitmap !== 'undefined' && value instanceof ImageBitmap) ||
106
+ (typeof OffscreenCanvas !== 'undefined' && value instanceof OffscreenCanvas)) {
107
+ return [value];
108
+ }
109
+ if (ArrayBuffer.isView(value)) return [value.buffer];
110
+ if (Array.isArray(value)) return value.flatMap(i => extractTransferables(i, seen));
111
+ return Object.values(value).flatMap(v => extractTransferables(v, seen));
112
+ };
113
+
114
+ const workerFn = ${n};
115
+ let cachedDataset = null;
116
+
117
+ self.addEventListener('message', async (event) => {
118
+ const msg = event.data;
119
+
120
+ if (msg && msg.type === 'release') {
121
+ cachedDataset = null;
122
+ self.postMessage({ ok: true, data: null, type: 'released' });
123
+ self.close();
124
+ return;
125
+ }
126
+
127
+ if (msg && msg.type === 'run') {
128
+ // Update cache if new dataset provided
129
+ if (msg.dataset !== undefined) {
130
+ cachedDataset = msg.dataset;
131
+ }
132
+
133
+ if (cachedDataset === null) {
134
+ self.postMessage({ ok: false, error: 'No dataset cached. Provide a dataset on the first call.' });
135
+ return;
136
+ }
137
+
138
+ try {
139
+ const output = await workerFn({ data: cachedDataset, config: msg.config });
140
+ self.postMessage({ ok: true, data: output }, extractTransferables(output));
141
+ } catch (err) {
142
+ self.postMessage({ ok: false, error: err instanceof Error ? err.message : String(err) });
143
+ }
144
+ return;
145
+ }
146
+
147
+ // Fallback: treat as a regular one-shot call for backwards compat
148
+ try {
149
+ const output = await workerFn(msg);
150
+ self.postMessage({ ok: true, data: output }, extractTransferables(output));
151
+ } catch (err) {
152
+ self.postMessage({ ok: false, error: err instanceof Error ? err.message : String(err) });
153
+ }
154
+ });
99
155
  `;
156
+ var w = /* @__PURE__ */ ((n) => (n.Default = "default", n.Pipeline = "pipeline", n.Persistent = "persistent", n))(w || {});
157
+ const M = Object.freeze({
158
+ persistent: W,
159
+ pipeline: P,
160
+ default: y
161
+ });
100
162
  class k {
101
163
  /**
102
164
  * Creates a new `Worker` from the given function.
@@ -106,16 +168,23 @@ class k {
106
168
  *
107
169
  * @param workerFunction - The function to run inside the worker thread.
108
170
  * Must be self-contained — it cannot reference variables from the outer
109
- * scope because it is serialised via `.toString()`.
110
- * @param options - Optional configuration. Set `pipeline: true` for
111
- * pipeline-aware workers that support MessagePort forwarding.
171
+ * scope because it is serialized via `.toString()`.
172
+ * @param options - Optional configuration. Set `mode` to control the
173
+ * worker execution mode (default, pipeline, or persistent).
112
174
  */
113
- constructor(e, t) {
175
+ constructor(e, r) {
114
176
  h(this, "_worker");
115
- const a = (t != null && t.pipeline ? v : y)(e.toString()), u = new Blob([a], {
116
- type: "application/javascript"
117
- });
118
- this._worker = new Worker(URL.createObjectURL(u));
177
+ if (r != null && r.workerURL)
178
+ this._worker = new Worker(r.workerURL, { type: "module" });
179
+ else if (e) {
180
+ const o = (r == null ? void 0 : r.mode) ?? "default", a = M[o](e.toString()), i = new Blob([a], {
181
+ type: "application/javascript"
182
+ });
183
+ this._worker = new Worker(URL.createObjectURL(i));
184
+ } else
185
+ throw new Error(
186
+ "Either workerFunction or options.workerURL must be provided to WorkerFactory."
187
+ );
119
188
  }
120
189
  /**
121
190
  * Returns the underlying native `Worker` instance.
@@ -127,17 +196,17 @@ class k {
127
196
  return this._worker;
128
197
  }
129
198
  }
130
- class P {
199
+ class b {
131
200
  constructor(e) {
132
201
  this.results = e;
133
202
  }
134
203
  }
135
- function g(o, e = /* @__PURE__ */ new Set()) {
136
- return o === null || typeof o != "object" ? [] : e.has(o) ? [] : (e.add(o), o instanceof ArrayBuffer || o instanceof MessagePort || typeof ImageBitmap < "u" && o instanceof ImageBitmap || typeof OffscreenCanvas < "u" && o instanceof OffscreenCanvas ? [o] : ArrayBuffer.isView(o) ? [o.buffer] : Array.isArray(o) ? o.flatMap((t) => g(t, e)) : Object.values(o).flatMap(
137
- (t) => g(t, e)
204
+ function g(n, e = /* @__PURE__ */ new Set()) {
205
+ return n === null || typeof n != "object" ? [] : e.has(n) ? [] : (e.add(n), n instanceof ArrayBuffer || n instanceof MessagePort || typeof ImageBitmap < "u" && n instanceof ImageBitmap || typeof OffscreenCanvas < "u" && n instanceof OffscreenCanvas ? [n] : ArrayBuffer.isView(n) ? [n.buffer] : Array.isArray(n) ? n.flatMap((r) => g(r, e)) : Object.values(n).flatMap(
206
+ (r) => g(r, e)
138
207
  ));
139
208
  }
140
- class M {
209
+ class A {
141
210
  /**
142
211
  * Creates a new `MainWorkerFactory`.
143
212
  *
@@ -146,16 +215,19 @@ class M {
146
215
  constructor(e) {
147
216
  h(this, "_workers");
148
217
  h(this, "_threads");
218
+ h(this, "_persistentWorkers", /* @__PURE__ */ new Map());
149
219
  this._workers = e.workers, this._threads = navigator.hardwareConcurrency;
150
220
  }
151
221
  /**
152
- * Instantiates a {@link WorkerFactory} for the given worker function.
222
+ * Instantiates a {@link WorkerFactory} for the given worker configuration.
153
223
  *
154
- * @param workerFunction - The function to run inside the worker thread.
224
+ * @param config - The worker configuration containing `func` or `workerURL`.
155
225
  * @returns A new `WorkerFactory` wrapping the worker.
156
226
  */
157
227
  initWorker(e) {
158
- return new k(e);
228
+ return new k(e.func, {
229
+ workerURL: e.workerURL
230
+ });
159
231
  }
160
232
  /**
161
233
  * Splits an array into up to `numChunks` evenly-sized sub-arrays.
@@ -173,16 +245,16 @@ class M {
173
245
  * partitionArray([1, 2, 3, 4, 5], 3);
174
246
  * // → [[1, 2], [3, 4], [5]]
175
247
  */
176
- partitionArray(e, t) {
248
+ partitionArray(e, r) {
177
249
  if (!e.length) return [];
178
- if (t <= 0) throw new Error("numChunks must be positive");
179
- const n = Math.min(t, e.length), a = Math.floor(e.length / n), u = e.length % n, l = [];
180
- let f = 0;
181
- for (let r = 0; r < n; r++) {
182
- const s = a + (r < u ? 1 : 0);
183
- l.push(e.slice(f, f + s)), f += s;
250
+ if (r <= 0) throw new Error("numChunks must be positive");
251
+ const o = Math.min(r, e.length), a = Math.floor(e.length / o), i = e.length % o, f = [];
252
+ let l = 0;
253
+ for (let t = 0; t < o; t++) {
254
+ const s = a + (t < i ? 1 : 0);
255
+ f.push(e.slice(l, l + s)), l += s;
184
256
  }
185
- return l;
257
+ return f;
186
258
  }
187
259
  /**
188
260
  * Looks up a registered worker configuration by name.
@@ -191,7 +263,7 @@ class M {
191
263
  * @returns The matching config, or `undefined` if not found.
192
264
  */
193
265
  findWorkerByName(e) {
194
- return this._workers.find((t) => t.name === e);
266
+ return this._workers.find((r) => r.name === e);
195
267
  }
196
268
  /**
197
269
  * Runs a named worker against the provided data, distributing work across
@@ -220,20 +292,20 @@ class M {
220
292
  * const settled = await foreman.runWorker('sum', { srcData: [1, 2, 3] });
221
293
  */
222
294
  async runWorker(e, {
223
- srcData: t,
224
- ...n
295
+ srcData: r,
296
+ ...o
225
297
  }) {
226
298
  const a = this.findWorkerByName(e);
227
299
  if (!a)
228
300
  return Promise.reject(new Error(`Worker "${e}" not found`));
229
- const u = a.maxConcurrency ?? this._threads, l = !!(Array.isArray(t) && t.length > 1 && a.partition), f = l ? this.partitionArray(t, u) : t, r = this.createWorkerPromises(
301
+ const i = a.maxConcurrency ?? this._threads, f = !!(Array.isArray(r) && r.length > 1 && a.partition), l = f ? this.partitionArray(r, i) : r, t = this.createWorkerPromises(
230
302
  a,
231
303
  e,
232
- { data: f, ...n },
233
- u,
234
- l
235
- ), s = await Promise.allSettled(r);
236
- return new P(s);
304
+ { data: l, ...o },
305
+ i,
306
+ f
307
+ ), s = await Promise.allSettled(t);
308
+ return new b(s);
237
309
  }
238
310
  /**
239
311
  * Builds the array of per-thread worker promises for a single `runWorker`
@@ -249,16 +321,17 @@ class M {
249
321
  * @param isPartitioned - Whether `data` is a pre-split array of shards.
250
322
  * @returns An array of promises, one per thread.
251
323
  */
252
- createWorkerPromises(e, t, n, a, u) {
253
- const { data: l, ...f } = n;
254
- return Array.from({ length: a }, (r, s) => {
255
- const i = u && Array.isArray(l) ? l[s] : l;
324
+ createWorkerPromises(e, r, o, a, i) {
325
+ const { data: f, ...l } = o;
326
+ return Array.from({ length: a }, (t, s) => {
327
+ const u = i && Array.isArray(f) ? f[s] : f;
256
328
  return this.runWorkerWithRetry(
257
329
  {
258
330
  workerFunc: e.func,
259
- workerName: t,
331
+ workerURL: e.workerURL,
332
+ workerName: r,
260
333
  index: s,
261
- data: { data: i, ...f }
334
+ data: { data: u, ...l }
262
335
  },
263
336
  e.retries
264
337
  );
@@ -276,16 +349,16 @@ class M {
276
349
  * @returns The successful {@link WorkerResult} once the worker resolves.
277
350
  * @throws The last caught error when all retries are exhausted.
278
351
  */
279
- async runWorkerWithRetry(e, t = 2) {
352
+ async runWorkerWithRetry(e, r = 2) {
280
353
  try {
281
354
  return await this.initiateWorker(e);
282
- } catch (n) {
283
- if (t > 0)
355
+ } catch (o) {
356
+ if (r > 0)
284
357
  return console.error(
285
- `Worker ${e.index} failed, retrying (${t} left):`,
286
- n
287
- ), this.runWorkerWithRetry(e, t - 1);
288
- throw console.error("Worker failed after all retries:", n), n;
358
+ `Worker ${e.index} failed, retrying (${r} left):`,
359
+ o
360
+ ), this.runWorkerWithRetry(e, r - 1);
361
+ throw console.error("Worker failed after all retries:", o), o;
289
362
  }
290
363
  }
291
364
  /**
@@ -303,48 +376,54 @@ class M {
303
376
  * The underlying `Worker` is always terminated after the first message,
304
377
  * whether it succeeded or failed.
305
378
  *
306
- * @param instanceConfig - Worker function, name, shard index, and data.
379
+ * @param instanceConfig - Worker function, URL, name, shard index, and data.
307
380
  * @returns A promise that resolves with the worker's result.
308
381
  */
309
382
  initiateWorker({
310
383
  workerFunc: e,
311
- workerName: t,
312
- index: n,
313
- data: a
384
+ workerURL: r,
385
+ workerName: o,
386
+ index: a,
387
+ data: i
314
388
  }) {
315
- return new Promise((u, l) => {
316
- const r = this.initWorker(e).getWorker;
317
- r.onerror = (i) => {
318
- r.terminate(), l({
319
- index: n,
320
- workerConfigs: { workerFunc: e, workerName: t, index: n, data: a },
321
- failedResult: i
389
+ return new Promise((f, l) => {
390
+ const s = this.initWorker({
391
+ name: o,
392
+ role: "",
393
+ func: e,
394
+ workerURL: r
395
+ }).getWorker;
396
+ s.onerror = (c) => {
397
+ s.terminate(), l({
398
+ index: a,
399
+ workerConfigs: { workerFunc: e, workerName: o, index: a, data: i },
400
+ failedResult: c
322
401
  });
323
- }, r.onmessage = (i) => {
324
- var c, p;
325
- if (((c = i.data) == null ? void 0 : c.ok) === !1) {
326
- r.terminate(), l({
327
- index: n,
328
- workerConfigs: { workerFunc: e, workerName: t, index: n, data: a },
402
+ }, s.onmessage = (c) => {
403
+ var d, p;
404
+ if (((d = c.data) == null ? void 0 : d.ok) === !1) {
405
+ s.terminate(), l({
406
+ index: a,
407
+ workerConfigs: { workerFunc: e, workerName: o, index: a, data: i },
329
408
  failedResult: new ErrorEvent("error", {
330
- message: i.data.error
409
+ message: c.data.error
331
410
  })
332
411
  });
333
412
  return;
334
413
  }
335
- u({
336
- index: n,
337
- workerConfigs: { workerFunc: e, workerName: t, index: n, data: a },
414
+ f({
415
+ index: a,
416
+ workerConfigs: { workerFunc: e, workerName: o, index: a, data: i },
338
417
  successResult: new MessageEvent("message", {
339
- data: (p = i.data) == null ? void 0 : p.data
418
+ data: (p = c.data) == null ? void 0 : p.data
340
419
  })
341
- }), r.terminate();
420
+ }), s.terminate();
342
421
  };
343
- const s = {
344
- index: n,
345
- ...Array.isArray(a) ? { data: a } : a
422
+ const u = {
423
+ index: a,
424
+ ...Array.isArray(i) ? { data: i } : i
346
425
  };
347
- r.postMessage(s, g(s));
426
+ s.postMessage(u, g(u));
348
427
  });
349
428
  }
350
429
  /**
@@ -379,16 +458,16 @@ class M {
379
458
  * reducer: (shards) => shards.flat().reduce((a, b) => a + b, 0),
380
459
  * });
381
460
  */
382
- async collectResults(e, t = {}) {
383
- const n = e.results.filter(
384
- (r) => r.status === "fulfilled"
461
+ async collectResults(e, r = {}) {
462
+ const o = e.results.filter(
463
+ (t) => t.status === "fulfilled"
385
464
  ), a = e.results.filter(
386
- (r) => r.status === "rejected"
387
- ), u = n.map((r) => r.value.successResult.data), l = t.reducer ? t.reducer.toString() : "(shards) => shards.flat()";
465
+ (t) => t.status === "rejected"
466
+ ), i = o.map((t) => t.value.successResult.data), f = r.reducer ? r.reducer.toString() : "(shards) => shards.flat()";
388
467
  return {
389
- data: await new Promise((r, s) => {
390
- const i = `
391
- const reducer = ${l};
468
+ data: await new Promise((t, s) => {
469
+ const u = `
470
+ const reducer = ${f};
392
471
  self.addEventListener('message', (event) => {
393
472
  try {
394
473
  const result = reducer(event.data);
@@ -397,14 +476,14 @@ class M {
397
476
  self.postMessage({ ok: false, error: String(err) });
398
477
  }
399
478
  });
400
- `, c = new Blob([i], { type: "application/javascript" }), p = new Worker(URL.createObjectURL(c));
401
- p.onmessage = (d) => {
402
- p.terminate(), d.data.ok ? r(d.data.data) : s(new Error(d.data.error));
403
- }, p.onerror = (d) => {
404
- p.terminate(), s(d);
405
- }, p.postMessage(u);
479
+ `, c = new Blob([u], { type: "application/javascript" }), d = new Worker(URL.createObjectURL(c));
480
+ d.onmessage = (p) => {
481
+ d.terminate(), p.data.ok ? t(p.data.data) : s(new Error(p.data.error));
482
+ }, d.onerror = (p) => {
483
+ d.terminate(), s(p);
484
+ }, d.postMessage(i);
406
485
  }),
407
- succeeded: n.length,
486
+ succeeded: o.length,
408
487
  failed: a.length,
409
488
  errors: a
410
489
  };
@@ -417,13 +496,18 @@ class M {
417
496
  * Only the final result is sent back to the main thread, minimising
418
497
  * serialisation overhead for large intermediate data.
419
498
  *
420
- * @param steps - An ordered array of pipeline steps. The first step must
421
- * include `srcData`; subsequent steps receive the previous step's output.
499
+ * @typeParam TResult - The expected type of the final pipeline output.
500
+ * Defaults to `unknown` if not specified.
501
+ *
502
+ * @param steps - An ordered array of {@link PipelineStep} objects. The first
503
+ * step must include `srcData`; subsequent steps receive the previous
504
+ * step's output as `{ data: previousOutput, index: 0 }`.
422
505
  *
423
506
  * @returns A promise that resolves with the final step's output.
507
+ * @throws {Error} When `steps` is empty or a worker name is not found.
424
508
  *
425
509
  * @example
426
- * const result = await foreman.pipeline([
510
+ * const result = await foreman.pipeline<FilteredPost[]>([
427
511
  * { worker: 'fetchPosts', srcData: { url: '/api/posts' } },
428
512
  * { worker: 'transformPosts' },
429
513
  * { worker: 'filterPosts' },
@@ -434,59 +518,130 @@ class M {
434
518
  if (e.length === 0)
435
519
  throw new Error("Pipeline requires at least one step");
436
520
  if (e.length === 1) {
437
- const t = e[0], n = this.findWorkerByName(t.worker);
438
- if (!n) throw new Error(`Worker "${t.worker}" not found`);
439
- const u = this.initWorker(n.func).getWorker;
440
- return new Promise((l, f) => {
441
- u.onmessage = (s) => {
442
- var i, c;
443
- u.terminate(), ((i = s.data) == null ? void 0 : i.ok) === !1 ? f(new Error(s.data.error)) : l((c = s.data) == null ? void 0 : c.data);
444
- }, u.onerror = (s) => {
445
- u.terminate(), f(s);
521
+ const r = e[0], o = this.findWorkerByName(r.worker);
522
+ if (!o) throw new Error(`Worker "${r.worker}" not found`);
523
+ const i = this.initWorker(o).getWorker;
524
+ return new Promise((f, l) => {
525
+ i.onmessage = (s) => {
526
+ var u, c;
527
+ i.terminate(), ((u = s.data) == null ? void 0 : u.ok) === !1 ? l(new Error(s.data.error)) : f((c = s.data) == null ? void 0 : c.data);
528
+ }, i.onerror = (s) => {
529
+ i.terminate(), l(s);
446
530
  };
447
- const r = t.srcData ?? {};
448
- u.postMessage(
449
- { data: r, index: 0 },
450
- g(r)
531
+ const t = r.srcData ?? {};
532
+ i.postMessage(
533
+ { data: t, index: 0 },
534
+ g(t)
451
535
  );
452
536
  });
453
537
  }
454
- return new Promise((t, n) => {
455
- const a = [], u = [];
456
- for (const r of e) {
457
- const s = this.findWorkerByName(r.worker);
538
+ return new Promise((r, o) => {
539
+ const a = [], i = [];
540
+ for (const t of e) {
541
+ const s = this.findWorkerByName(t.worker);
458
542
  if (!s) {
459
- n(new Error(`Worker "${r.worker}" not found`));
543
+ o(new Error(`Worker "${t.worker}" not found`));
460
544
  return;
461
545
  }
462
- const i = new k(s.func, { pipeline: !0 });
463
- a.push(i.getWorker);
546
+ const u = new k(s.func, {
547
+ mode: w.Pipeline,
548
+ workerURL: s.workerURL
549
+ });
550
+ a.push(u.getWorker);
464
551
  }
465
- for (let r = 0; r < a.length - 1; r++)
466
- u.push(new MessageChannel());
467
- for (let r = 0; r < a.length; r++) {
468
- const s = [], i = {};
469
- r > 0 && (i.inputPort = u[r - 1].port1, s.push(i.inputPort)), r < a.length - 1 && (i.outputPort = u[r].port2, s.push(i.outputPort)), a[r].postMessage(
470
- { __pipeline_ports__: !0, ...i },
552
+ for (let t = 0; t < a.length - 1; t++)
553
+ i.push(new MessageChannel());
554
+ for (let t = 0; t < a.length; t++) {
555
+ const s = [], u = {};
556
+ t > 0 && (u.inputPort = i[t - 1].port1, s.push(u.inputPort)), t < a.length - 1 && (u.outputPort = i[t].port2, s.push(u.outputPort)), a[t].postMessage(
557
+ { __pipeline_ports__: !0, ...u },
471
558
  s
472
559
  );
473
560
  }
474
- const l = a[a.length - 1];
475
- l.onmessage = (r) => {
476
- var s, i;
477
- a.forEach((c) => c.terminate()), ((s = r.data) == null ? void 0 : s.ok) === !1 ? n(new Error(r.data.error)) : t((i = r.data) == null ? void 0 : i.data);
478
- }, l.onerror = (r) => {
479
- a.forEach((s) => s.terminate()), n(r);
561
+ const f = a[a.length - 1];
562
+ f.onmessage = (t) => {
563
+ var s, u;
564
+ a.forEach((c) => c.terminate()), ((s = t.data) == null ? void 0 : s.ok) === !1 ? o(new Error(t.data.error)) : r((u = t.data) == null ? void 0 : u.data);
565
+ }, f.onerror = (t) => {
566
+ a.forEach((s) => s.terminate()), o(t);
480
567
  };
481
- const f = e[0].srcData ?? {};
568
+ const l = e[0].srcData ?? {};
482
569
  a[0].postMessage(
483
- { data: f, index: 0 },
484
- g(f)
570
+ { data: l, index: 0 },
571
+ g(l)
485
572
  );
486
573
  });
487
574
  }
575
+ /**
576
+ * Runs a persistent worker that caches its dataset between calls.
577
+ *
578
+ * On the first call, provide both `dataset` and `config`. The worker stores
579
+ * the dataset in memory. On subsequent calls, only `config` is needed — the
580
+ * worker reuses the cached dataset and reprocesses it with the new config.
581
+ *
582
+ * The worker stays alive until {@link release} is called.
583
+ *
584
+ * @param workerName - Name of the registered worker.
585
+ * @param params - Object with optional `dataset` and required `config`.
586
+ * @returns The worker function's return value.
587
+ *
588
+ * @example
589
+ * // First call: send dataset + config
590
+ * const r1 = await factory.runPersistent('transform', {
591
+ * dataset: largeArray,
592
+ * config: { multiplier: 2 },
593
+ * });
594
+ *
595
+ * // Subsequent calls: only config, dataset is cached
596
+ * const r2 = await factory.runPersistent('transform', {
597
+ * config: { multiplier: 5 },
598
+ * });
599
+ *
600
+ * // Update dataset when needed
601
+ * const r3 = await factory.runPersistent('transform', {
602
+ * dataset: newArray,
603
+ * config: { multiplier: 3 },
604
+ * });
605
+ *
606
+ * // Release when done
607
+ * factory.release('transform');
608
+ */
609
+ async runPersistent(e, r) {
610
+ const o = this.findWorkerByName(e);
611
+ if (!o) throw new Error(`Worker "${e}" not found`);
612
+ let a = this._persistentWorkers.get(e);
613
+ return a || (a = new k(o.func, {
614
+ mode: w.Persistent,
615
+ workerURL: o.workerURL
616
+ }).getWorker, this._persistentWorkers.set(e, a)), new Promise((i, f) => {
617
+ a.onmessage = (t) => {
618
+ var s, u;
619
+ ((s = t.data) == null ? void 0 : s.ok) === !1 ? f(new Error(t.data.error)) : i((u = t.data) == null ? void 0 : u.data);
620
+ }, a.onerror = (t) => {
621
+ f(t);
622
+ };
623
+ const l = {
624
+ type: "run",
625
+ config: r.config
626
+ };
627
+ r.dataset !== void 0 && (l.dataset = r.dataset), a.postMessage(l, g(l));
628
+ });
629
+ }
630
+ /**
631
+ * Releases a persistent worker, freeing its cached dataset and terminating
632
+ * the thread.
633
+ *
634
+ * After calling `release`, subsequent `runPersistent` calls for this worker
635
+ * will create a fresh instance (requiring a new dataset).
636
+ *
637
+ * @param workerName - Name of the persistent worker to release.
638
+ */
639
+ release(e) {
640
+ const r = this._persistentWorkers.get(e);
641
+ r && (r.postMessage({ type: "release" }), r.terminate(), this._persistentWorkers.delete(e));
642
+ }
488
643
  }
489
644
  export {
490
- M as MainWorkerFactory,
645
+ A as MainWorkerFactory,
491
646
  k as WorkerFactory
492
647
  };
@@ -12,6 +12,13 @@ export declare function extractTransferables(value: unknown, seen?: Set<object>)
12
12
  * handles the full lifecycle of each worker: spawning, partitioning input
13
13
  * data across threads, retrying on failure, and collecting results.
14
14
  *
15
+ * Also supports:
16
+ * - **Pipelines** — chain workers via `MessageChannel` so intermediate data
17
+ * never crosses back to the main thread ({@link pipeline}).
18
+ * - **Persistent workers** — keep a worker alive with a cached dataset,
19
+ * re-running it with different configs without re-sending the data
20
+ * ({@link runPersistent}, {@link release}).
21
+ *
15
22
  * @typeParam TConfigs - A readonly tuple of {@link WorkerConfig} objects that
16
23
  * defines the set of available workers and their typed signatures.
17
24
  *
@@ -19,7 +26,7 @@ export declare function extractTransferables(value: unknown, seen?: Set<object>)
19
26
  * const foreman = new MainWorkerFactory({
20
27
  * workers: [
21
28
  * { name: 'sum', role: 'compute', func: sumWorker, partition: true },
22
- * ],
29
+ * ] as const,
23
30
  * });
24
31
  *
25
32
  * const settled = await foreman.runWorker('sum', { srcData: [1, 2, 3, 4] });
@@ -28,6 +35,7 @@ export declare function extractTransferables(value: unknown, seen?: Set<object>)
28
35
  declare class MainWorkerFactory<TConfigs extends readonly WorkerConfig<WorkerFunction<any, any>>[]> {
29
36
  private readonly _workers;
30
37
  private readonly _threads;
38
+ private readonly _persistentWorkers;
31
39
  /**
32
40
  * Creates a new `MainWorkerFactory`.
33
41
  *
@@ -37,9 +45,9 @@ declare class MainWorkerFactory<TConfigs extends readonly WorkerConfig<WorkerFun
37
45
  workers: TConfigs;
38
46
  });
39
47
  /**
40
- * Instantiates a {@link WorkerFactory} for the given worker function.
48
+ * Instantiates a {@link WorkerFactory} for the given worker configuration.
41
49
  *
42
- * @param workerFunction - The function to run inside the worker thread.
50
+ * @param config - The worker configuration containing `func` or `workerURL`.
43
51
  * @returns A new `WorkerFactory` wrapping the worker.
44
52
  */
45
53
  private initWorker;
@@ -139,7 +147,7 @@ declare class MainWorkerFactory<TConfigs extends readonly WorkerConfig<WorkerFun
139
147
  * The underlying `Worker` is always terminated after the first message,
140
148
  * whether it succeeded or failed.
141
149
  *
142
- * @param instanceConfig - Worker function, name, shard index, and data.
150
+ * @param instanceConfig - Worker function, URL, name, shard index, and data.
143
151
  * @returns A promise that resolves with the worker's result.
144
152
  */
145
153
  private initiateWorker;
@@ -184,13 +192,18 @@ declare class MainWorkerFactory<TConfigs extends readonly WorkerConfig<WorkerFun
184
192
  * Only the final result is sent back to the main thread, minimising
185
193
  * serialisation overhead for large intermediate data.
186
194
  *
187
- * @param steps - An ordered array of pipeline steps. The first step must
188
- * include `srcData`; subsequent steps receive the previous step's output.
195
+ * @typeParam TResult - The expected type of the final pipeline output.
196
+ * Defaults to `unknown` if not specified.
197
+ *
198
+ * @param steps - An ordered array of {@link PipelineStep} objects. The first
199
+ * step must include `srcData`; subsequent steps receive the previous
200
+ * step's output as `{ data: previousOutput, index: 0 }`.
189
201
  *
190
202
  * @returns A promise that resolves with the final step's output.
203
+ * @throws {Error} When `steps` is empty or a worker name is not found.
191
204
  *
192
205
  * @example
193
- * const result = await foreman.pipeline([
206
+ * const result = await foreman.pipeline<FilteredPost[]>([
194
207
  * { worker: 'fetchPosts', srcData: { url: '/api/posts' } },
195
208
  * { worker: 'transformPosts' },
196
209
  * { worker: 'filterPosts' },
@@ -198,5 +211,53 @@ declare class MainWorkerFactory<TConfigs extends readonly WorkerConfig<WorkerFun
198
211
  * console.log(result); // final transformed + filtered data
199
212
  */
200
213
  pipeline<TResult = unknown>(steps: PipelineStep[]): Promise<TResult>;
214
+ /**
215
+ * Runs a persistent worker that caches its dataset between calls.
216
+ *
217
+ * On the first call, provide both `dataset` and `config`. The worker stores
218
+ * the dataset in memory. On subsequent calls, only `config` is needed — the
219
+ * worker reuses the cached dataset and reprocesses it with the new config.
220
+ *
221
+ * The worker stays alive until {@link release} is called.
222
+ *
223
+ * @param workerName - Name of the registered worker.
224
+ * @param params - Object with optional `dataset` and required `config`.
225
+ * @returns The worker function's return value.
226
+ *
227
+ * @example
228
+ * // First call: send dataset + config
229
+ * const r1 = await factory.runPersistent('transform', {
230
+ * dataset: largeArray,
231
+ * config: { multiplier: 2 },
232
+ * });
233
+ *
234
+ * // Subsequent calls: only config, dataset is cached
235
+ * const r2 = await factory.runPersistent('transform', {
236
+ * config: { multiplier: 5 },
237
+ * });
238
+ *
239
+ * // Update dataset when needed
240
+ * const r3 = await factory.runPersistent('transform', {
241
+ * dataset: newArray,
242
+ * config: { multiplier: 3 },
243
+ * });
244
+ *
245
+ * // Release when done
246
+ * factory.release('transform');
247
+ */
248
+ runPersistent<TResult = unknown>(workerName: string, params: {
249
+ dataset?: unknown;
250
+ config: unknown;
251
+ }): Promise<TResult>;
252
+ /**
253
+ * Releases a persistent worker, freeing its cached dataset and terminating
254
+ * the thread.
255
+ *
256
+ * After calling `release`, subsequent `runPersistent` calls for this worker
257
+ * will create a fresh instance (requiring a new dataset).
258
+ *
259
+ * @param workerName - Name of the persistent worker to release.
260
+ */
261
+ release(workerName: string): void;
201
262
  }
202
263
  export default MainWorkerFactory;
@@ -23,8 +23,13 @@ export interface WorkerConfig<TFunc extends WorkerFunction = WorkerFunction> {
23
23
  name: WorkerName;
24
24
  /** Human-readable role label (e.g. `'compute'`, `'transform'`). */
25
25
  role: WorkerRole;
26
- /** The worker function that will be serialised and run in a thread. */
27
- func: TFunc;
26
+ /** The worker function that will be serialised and run in a thread. Optional if `workerURL` is provided. */
27
+ func?: TFunc;
28
+ /**
29
+ * The worker script URL (or `URL` object created via `new URL(..., import.meta.url)`).
30
+ * Enables module bundlers like Webpack 5, Vite, Rollup, and Parcel to bundle worker code and its dependencies.
31
+ */
32
+ workerURL?: string | URL;
28
33
  /**
29
34
  * Maximum number of parallel threads to spawn for this worker.
30
35
  * Defaults to `navigator.hardwareConcurrency` when omitted.
@@ -41,6 +46,11 @@ export interface WorkerConfig<TFunc extends WorkerFunction = WorkerFunction> {
41
46
  * array.
42
47
  */
43
48
  partition?: boolean;
49
+ /**
50
+ * A list of worker functions that must complete before this worker can start.
51
+ * If any dependency fails, this worker will not run.
52
+ */
53
+ dependencies?: Array<() => void>;
44
54
  }
45
55
  /**
46
56
  * Derives a `name → function` map from a readonly tuple of
@@ -52,9 +62,11 @@ export interface WorkerConfig<TFunc extends WorkerFunction = WorkerFunction> {
52
62
  * @typeParam T - The readonly tuple of `WorkerConfig` values.
53
63
  */
54
64
  export type WorkerConfigMap<T extends readonly WorkerConfig<WorkerFunction<any, any>>[]> = {
55
- [K in T[number]['name']]: Extract<T[number], {
65
+ [K in T[number]['name']]: NonNullable<Extract<T[number], {
66
+ name: K;
67
+ }>['func']> extends WorkerFunction ? NonNullable<Extract<T[number], {
56
68
  name: K;
57
- }>['func'];
69
+ }>['func']> : WorkerFunction;
58
70
  };
59
71
  /**
60
72
  * Extracts the `params` type from a {@link WorkerFunction}.
@@ -91,8 +103,10 @@ export interface MainWorkerFactoryWorker extends WorkerConfig {
91
103
  export interface WorkerInstanceConfig<TFunc extends WorkerFunction = WorkerFunction> {
92
104
  /** Name of the parent worker config, used in logs and error objects. */
93
105
  workerName: WorkerName;
94
- /** The function serialised and executed inside the thread. */
95
- workerFunc: TFunc;
106
+ /** The function serialised and executed inside the thread (optional if `workerURL` is set). */
107
+ workerFunc?: TFunc;
108
+ /** Worker script URL (string or URL object). */
109
+ workerURL?: string | URL;
96
110
  /** Zero-based shard index assigned to this thread. */
97
111
  index: number;
98
112
  /** The data payload (full or partitioned shard) sent to the thread. */
@@ -1,18 +1,35 @@
1
1
  import { WorkerFunction } from '../main-worker-factory/types';
2
+ export declare enum WorkerMode {
3
+ Default = "default",
4
+ Pipeline = "pipeline",
5
+ Persistent = "persistent"
6
+ }
2
7
  export interface WorkerFactoryOptions {
3
- /** When true, generates a pipeline-aware worker that supports MessagePort forwarding */
4
- pipeline?: boolean;
8
+ /** The worker execution mode. Defaults to `WorkerMode.Default`. */
9
+ mode?: WorkerMode;
10
+ /** The worker URL to use instead of creating a worker from a function. */
11
+ workerURL?: string | URL;
5
12
  }
6
13
  /**
7
- * Low-level factory that serialises a {@link WorkerFunction} into a Blob URL
14
+ * Low-level factory that serializes a {@link WorkerFunction} into a Blob URL
8
15
  * and spawns a native `Worker` from it.
9
16
  *
10
17
  * `WorkerFactory` is an internal building block used by `MainWorkerFactory`.
11
18
  * It handles the mechanics of turning a plain TypeScript function into a
12
19
  * runnable worker thread — you rarely need to use it directly.
13
20
  *
14
- * The worker script is generated by {@link workerTemplate}, which wraps the
15
- * function with a message listener and transferable-extraction logic.
21
+ * Supports three execution modes via {@link WorkerMode}:
22
+ * - **Default** — one-shot worker that processes a single message and is
23
+ * terminated after responding.
24
+ * - **Pipeline** — stays alive and forwards output to the next worker via
25
+ * `MessagePort`, enabling worker-to-worker data flow without main-thread
26
+ * round-trips.
27
+ * - **Persistent** — stays alive indefinitely, caches a dataset in memory,
28
+ * and re-processes it with different configs on subsequent messages.
29
+ *
30
+ * The worker script is generated by the template corresponding to the chosen
31
+ * mode, which wraps the function with a message listener and
32
+ * transferable-extraction logic.
16
33
  */
17
34
  declare class WorkerFactory {
18
35
  readonly _worker: Worker;
@@ -24,11 +41,11 @@ declare class WorkerFactory {
24
41
  *
25
42
  * @param workerFunction - The function to run inside the worker thread.
26
43
  * Must be self-contained — it cannot reference variables from the outer
27
- * scope because it is serialised via `.toString()`.
28
- * @param options - Optional configuration. Set `pipeline: true` for
29
- * pipeline-aware workers that support MessagePort forwarding.
44
+ * scope because it is serialized via `.toString()`.
45
+ * @param options - Optional configuration. Set `mode` to control the
46
+ * worker execution mode (default, pipeline, or persistent).
30
47
  */
31
- constructor(workerFunction: WorkerFunction, options?: WorkerFactoryOptions);
48
+ constructor(workerFunction?: WorkerFunction, options?: WorkerFactoryOptions);
32
49
  /**
33
50
  * Returns the underlying native `Worker` instance.
34
51
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@offmain/workerkit",
3
- "version": "0.10.0",
3
+ "version": "0.12.2",
4
4
  "description": "A lightweight manager for running functions in Web Workers with partitioning, retries, and concurrency control",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -76,6 +76,9 @@
76
76
  "typescript": "^5.7.2",
77
77
  "vite": "^6.3.3",
78
78
  "vite-plugin-dts": "^4.5.4",
79
- "vitest": "^1.0.0"
79
+ "vitest": "^1.0.0",
80
+ "luxon": "^3.7.2",
81
+ "i18next": "^25.5.1",
82
+ "@types/luxon": "^3.7.2"
80
83
  }
81
84
  }