@offmain/workerkit 0.8.9 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,16 +2,16 @@
2
2
 
3
3
  A lightweight TypeScript library for running functions in Web Workers with support for partitioning, retries, and concurrency control — all without the boilerplate.
4
4
 
5
- Instead of manually creating worker scripts and wiring up `postMessage` / `onmessage`, you write plain exported functions and hand them to `MainWorkerFactory`. The library serialises them into Blob workers, manages a pool per function, handles retries on failure, and merges results back on the main thread.
5
+ Instead of manually creating worker scripts and wiring up `postMessage` / `onmessage`, you write plain exported functions and hand them to `MainWorkerFactory`. The library serializes them into Blob workers, manages threads per function, handles retries on failure, and merges results back on the main thread.
6
6
 
7
7
  ---
8
8
 
9
9
  ## Installation
10
10
 
11
11
  ```bash
12
- npm install workerkit
12
+ npm install @offmain/workerkit
13
13
  # or
14
- pnpm add workerkit
14
+ pnpm add @offmain/workerkit
15
15
  ```
16
16
 
17
17
  ---
@@ -32,10 +32,10 @@ export function sum({ data }: { data: number[] }): number {
32
32
  ### 2. Register and run it
33
33
 
34
34
  ```ts
35
- import { MainWorkerFactory } from 'workerkit';
35
+ import { MainWorkerFactory } from '@offmain/workerkit';
36
36
  import { sum } from './sum.worker.ts';
37
37
 
38
- const factory = new MainWorkerFactory(initiator, {
38
+ const factory = new MainWorkerFactory({
39
39
  workers: [
40
40
  {
41
41
  name: 'sum',
@@ -47,10 +47,10 @@ const factory = new MainWorkerFactory(initiator, {
47
47
  ],
48
48
  });
49
49
 
50
- const result = await factory.runWorker('sum', { srcData: [1, 2, 3, 4, 5] });
51
- const { data } = await factory.collectResults(result);
50
+ const settled = await factory.runWorker('sum', { srcData: [1, 2, 3, 4, 5] });
51
+ const { data } = await factory.collectResults(settled);
52
52
 
53
- console.log(data); // 15
53
+ console.log(data); // [15]
54
54
  ```
55
55
 
56
56
  ---
@@ -63,7 +63,7 @@ console.log(data); // 15
63
63
  | `role` | `string` | — | Logical grouping label |
64
64
  | `func` | `Function` | — | The exported worker function to run |
65
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 |
66
+ | `retries` | `number` | `0` | How many times to retry a failed shard before marking it as rejected |
67
67
  | `partition` | `boolean` | `false` | Split array input across multiple workers automatically |
68
68
 
69
69
  ---
@@ -73,21 +73,85 @@ console.log(data); // 15
73
73
  When `partition: true`, an array passed as `srcData` is automatically split across worker instances and results are merged back.
74
74
 
75
75
  ```ts
76
- const result = await factory.runWorker('sum', {
76
+ const settled = await factory.runWorker('sum', {
77
77
  srcData: largeArray, // split across workers
78
78
  });
79
79
 
80
- const { data, succeeded, failed } = await factory.collectResults(result);
80
+ const { data, succeeded, failed } = await factory.collectResults(settled);
81
81
  ```
82
82
 
83
83
  You can also provide a custom reducer to control how shard results are merged:
84
84
 
85
85
  ```ts
86
- const { data } = await factory.collectResults(result, {
86
+ const { data } = await factory.collectResults(settled, {
87
87
  reducer: (shards) => shards.flat().sort((a, b) => b.score - a.score),
88
88
  });
89
89
  ```
90
90
 
91
+ > **Note:** The reducer runs inside a worker thread and must be self-contained — it cannot reference variables from the outer scope.
92
+
93
+ ---
94
+
95
+ ## Pipeline
96
+
97
+ Chain multiple workers together so data flows directly between them via `MessageChannel` — without passing through the main thread between steps.
98
+
99
+ ### Why use a pipeline?
100
+
101
+ In a traditional multi-step workflow, intermediate data is serialized back to the main thread after each step:
102
+
103
+ ```
104
+ Main → Worker A → Main → Worker B → Main → Worker C → Main
105
+ ↑ serialize ↑ serialize ↑ serialize
106
+ ```
107
+
108
+ With large datasets (100k+ records), each serialization round-trip adds significant overhead — both in time and memory pressure on the main thread. The pipeline eliminates this:
109
+
110
+ ```
111
+ Main → Worker A → Worker B → Worker C → Main
112
+ ↑ MessageChannel ↑ only final result
113
+ ```
114
+
115
+ Only the final result crosses back to the main thread. If your pipeline generates 20 MB of intermediate data but produces a 1 KB summary, you save ~40 MB of serialization (two round-trips avoided).
116
+
117
+ ### Usage
118
+
119
+ ```ts
120
+ import { MainWorkerFactory } from '@offmain/workerkit';
121
+ import { fetchData, transform, aggregate } from './workers.ts';
122
+
123
+ const factory = new MainWorkerFactory({
124
+ workers: [
125
+ { name: 'fetchData', role: 'io', func: fetchData },
126
+ { name: 'transform', role: 'compute', func: transform },
127
+ { name: 'aggregate', role: 'compute', func: aggregate },
128
+ ] as const,
129
+ });
130
+
131
+ const result = await factory.pipeline<AggregateResult>([
132
+ { worker: 'fetchData', srcData: { url: '/api/records' } },
133
+ { worker: 'transform' }, // receives fetchData output directly
134
+ { worker: 'aggregate' }, // receives transform output directly
135
+ ]);
136
+
137
+ console.log(result); // only this small result crossed to main thread
138
+ ```
139
+
140
+ ### How each step receives data
141
+
142
+ - The first step receives `srcData` as `{ data: srcData, index: 0 }` — same as `runWorker`.
143
+ - Each subsequent step receives the previous step's output as `{ data: previousOutput, index: 0 }`.
144
+ - Worker functions don't need any special handling — they use the same `{ data }` parameter signature as regular workers.
145
+
146
+ ### When to use pipeline vs runWorker
147
+
148
+ | Scenario | Use |
149
+ | ---------------------------------------------------- | ----------------------- |
150
+ | Single step, or steps that need partitioning/retries | `runWorker` |
151
+ | Multi-step chain where intermediate data is large | `pipeline` |
152
+ | Steps that are independent (not sequential) | `runWorker` in parallel |
153
+ | Steps where only the final result matters to the UI | `pipeline` |
154
+
91
155
  ---
92
156
 
93
157
  ## ESLint Plugin
@@ -98,7 +162,7 @@ The package ships with two ESLint rules to catch common worker mistakes at lint
98
162
 
99
163
  ```js
100
164
  // eslint.config.js
101
- import workerPlugin from 'workerkit/eslint-plugin';
165
+ import workerPlugin from '@offmain/workerkit/eslint-plugin';
102
166
 
103
167
  export default [...workerPlugin.configs.recommended];
104
168
  ```
@@ -113,16 +177,16 @@ Flags usage of browser main-thread-only APIs that are unavailable inside Web Wor
113
177
 
114
178
  ```ts
115
179
  // sum.worker.ts ❌ — will be flagged
116
- export function sum({ data }: MessageEvent) {
180
+ export function sum({ data }: { data: number[] }) {
117
181
  document.title = 'working...'; // Error: 'document' is not available inside Web Workers
118
- return data.reduce((a: number, b: number) => a + b, 0);
182
+ return data.reduce((a, b) => a + b, 0);
119
183
  }
120
184
  ```
121
185
 
122
186
  ```ts
123
187
  // sum.worker.ts ✅
124
- export function sum({ data }: MessageEvent) {
125
- return data.reduce((a: number, b: number) => a + b, 0);
188
+ export function sum({ data }: { data: number[] }) {
189
+ return data.reduce((a, b) => a + b, 0);
126
190
  }
127
191
  ```
128
192
 
@@ -150,8 +214,8 @@ You can also import rules individually if you don't want the full recommended co
150
214
 
151
215
  ```js
152
216
  // eslint.config.js
153
- import noDomInWorker from 'workerkit/eslint-rules/no-dom-in-worker';
154
- import workerExportable from 'workerkit/eslint-rules/worker-exportable';
217
+ import noDomInWorker from '@offmain/workerkit/eslint-rules/no-dom-in-worker';
218
+ import workerExportable from '@offmain/workerkit/eslint-rules/worker-exportable';
155
219
 
156
220
  export default [
157
221
  {
package/dist/index.cjs CHANGED
@@ -1,4 +1,4 @@
1
- "use strict";var g=Object.defineProperty;var m=(t,e,r)=>e in t?g(t,e,{enumerable:!0,configurable:!0,writable:!0,value:r}):t[e]=r;var h=(t,e,r)=>m(t,typeof e!="symbol"?e+"":e,r);Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const w=t=>`
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=>`
2
2
  const extractTransferables = (value, seen = new Set()) => {
3
3
  if (value === null || typeof value !== 'object') return [];
4
4
  if (seen.has(value)) return [];
@@ -14,12 +14,87 @@ const extractTransferables = (value, seen = new Set()) => {
14
14
  };
15
15
 
16
16
  self.addEventListener('message', async (event) => {
17
- const begin = performance.now();
18
- const output = await ${t}(event.data);
19
- self.postMessage(output, extractTransferables(output));
20
- })
21
- `;class k{constructor(e){h(this,"_worker");const r=w(e.toString()),s=new Blob([r],{type:"application/javascript"});this._worker=new Worker(URL.createObjectURL(s))}get getWorker(){return this._worker}}function p(t,e=new Set){return t===null||typeof t!="object"?[]:e.has(t)?[]:(e.add(t),t instanceof ArrayBuffer||t instanceof MessagePort||typeof ImageBitmap<"u"&&t instanceof ImageBitmap||typeof OffscreenCanvas<"u"&&t instanceof OffscreenCanvas?[t]:ArrayBuffer.isView(t)?[t.buffer]:Array.isArray(t)?t.flatMap(r=>p(r,e)):Object.values(t).flatMap(r=>p(r,e)))}class W{constructor(e,r){h(this,"_workers");h(this,"_threads");this._workers=r.workers,this._threads=navigator.hardwareConcurrency}initWorker(e){return new k(e)}partitionArray(e,r){if(!e.length)return[];if(r<=0)throw new Error("numChunks must be positive");const s=Math.min(r,e.length),a=Math.floor(e.length/s),c=e.length%s,o=[];let f=0;for(let n=0;n<s;n++){const i=a+(n<c?1:0);o.push(e.slice(f,f+i)),f+=i}return o}findWorkerByName(e){return this._workers.find(r=>r.name===e)}async runWorker(e,{srcData:r,...s}){const a=this.findWorkerByName(e);if(!a)return Promise.reject(new Error(`Worker "${e}" not found`));const c=a.maxConcurrency??this._threads,o=!!(Array.isArray(r)&&r.length>1&&a.partition),f=o?this.partitionArray(r,c):r,n=this.createWorkerPromises(a,e,{data:f,...s},c,o);return Promise.allSettled(n)}createWorkerPromises(e,r,s,a,c){const{data:o,...f}=s;return Array.from({length:a},(n,i)=>{const u=c&&Array.isArray(o)?o[i]:o;return this.runWorkerWithRetry({workerFunc:e.func,workerName:r,index:i,data:{data:u,...f}},e.retries)})}async runWorkerWithRetry(e,r=2){try{return await this.initiateWorker(e)}catch(s){if(r>0)return console.error(`Worker ${e.index} failed, retrying (${r} left):`,s),this.runWorkerWithRetry(e,r-1);throw console.error("Worker failed after all retries:",s),s}}initiateWorker({workerFunc:e,workerName:r,index:s,data:a}){return new Promise((c,o)=>{const n=this.initWorker(e).getWorker;n.onerror=u=>{n.terminate(),o({index:s,workerConfigs:{workerFunc:e,workerName:r,index:s,data:a},failedResult:u})},n.onmessage=u=>{c({index:s,workerConfigs:{workerFunc:e,workerName:r,index:s,data:a},successResult:u}),n.terminate()};const i={index:s,...Array.isArray(a)?{data:a}:a};n.postMessage(i,p(i))})}async collectResults(e,r={}){const s=e.filter(n=>n.status==="fulfilled"),a=e.filter(n=>n.status==="rejected"),c=s.map(n=>n.value.successResult.data),o=r.reducer?r.reducer.toString():"(shards) => shards.flat()";return{data:await new Promise((n,i)=>{const u=`
22
- const reducer = ${o};
17
+ try {
18
+ const output = await ${o}(event.data);
19
+ self.postMessage({ ok: true, data: output }, extractTransferables(output));
20
+ } catch (err) {
21
+ self.postMessage({ ok: false, error: err instanceof Error ? err.message : String(err) });
22
+ }
23
+ })
24
+ `,v=o=>`
25
+ const extractTransferables = (value, seen = new Set()) => {
26
+ if (value === null || typeof value !== 'object') return [];
27
+ if (seen.has(value)) return [];
28
+ seen.add(value);
29
+ if (value instanceof ArrayBuffer || value instanceof MessagePort ||
30
+ (typeof ImageBitmap !== 'undefined' && value instanceof ImageBitmap) ||
31
+ (typeof OffscreenCanvas !== 'undefined' && value instanceof OffscreenCanvas)) {
32
+ return [value];
33
+ }
34
+ if (ArrayBuffer.isView(value)) return [value.buffer];
35
+ if (Array.isArray(value)) return value.flatMap(i => extractTransferables(i, seen));
36
+ return Object.values(value).flatMap(v => extractTransferables(v, seen));
37
+ };
38
+
39
+ const workerFn = ${o};
40
+ let outputPort = null;
41
+ let inputPort = null;
42
+ let pendingData = null;
43
+
44
+ async function processData(data) {
45
+ try {
46
+ const output = await workerFn(data);
47
+ const result = { ok: true, data: output };
48
+ const transfers = extractTransferables(output);
49
+ if (outputPort) {
50
+ outputPort.postMessage(result, transfers);
51
+ } else {
52
+ self.postMessage(result, transfers);
53
+ }
54
+ } catch (err) {
55
+ const result = { ok: false, error: err instanceof Error ? err.message : String(err) };
56
+ if (outputPort) {
57
+ outputPort.postMessage(result);
58
+ } else {
59
+ self.postMessage(result);
60
+ }
61
+ }
62
+ }
63
+
64
+ self.addEventListener('message', (event) => {
65
+ if (event.data && event.data.__pipeline_ports__) {
66
+ if (event.data.outputPort) {
67
+ outputPort = event.data.outputPort;
68
+ }
69
+ if (event.data.inputPort) {
70
+ inputPort = event.data.inputPort;
71
+ inputPort.onmessage = (e) => {
72
+ if (e.data && e.data.ok === false) {
73
+ // Propagate errors through the pipeline
74
+ if (outputPort) outputPort.postMessage(e.data);
75
+ else self.postMessage(e.data);
76
+ } else {
77
+ processData({ data: e.data.data, index: 0 });
78
+ }
79
+ };
80
+ }
81
+ // If we already received data before ports, process it now
82
+ if (pendingData !== null) {
83
+ processData(pendingData);
84
+ pendingData = null;
85
+ }
86
+ return;
87
+ }
88
+ // First worker in pipeline or standalone — process directly
89
+ if (!inputPort) {
90
+ processData(event.data);
91
+ } else {
92
+ // Store data until ports are configured
93
+ pendingData = event.data;
94
+ }
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=`
97
+ const reducer = ${l};
23
98
  self.addEventListener('message', (event) => {
24
99
  try {
25
100
  const result = reducer(event.data);
@@ -28,4 +103,4 @@ self.addEventListener('message', async (event) => {
28
103
  self.postMessage({ ok: false, error: String(err) });
29
104
  }
30
105
  });
31
- `,y=new Blob([u],{type:"application/javascript"}),l=new Worker(URL.createObjectURL(y));l.onmessage=d=>{l.terminate(),d.data.ok?n(d.data.data):i(new Error(d.data.error))},l.onerror=d=>{l.terminate(),i(d)},l.postMessage(c)}),succeeded:s.length,failed:a.length,errors:a}}}exports.MainWorkerFactory=W;exports.WorkerFactory=k;
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;