@offmain/workerkit 0.3.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Amin Motamedi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,179 @@
1
+ # workerkit
2
+
3
+ A lightweight TypeScript library for running functions in Web Workers with support for partitioning, retries, and concurrency control — all without the boilerplate.
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.
6
+
7
+ ---
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ npm install workerkit
13
+ # or
14
+ pnpm add workerkit
15
+ ```
16
+
17
+ ---
18
+
19
+ ## Quick Start
20
+
21
+ ### 1. Write a worker function
22
+
23
+ Worker functions live in `*.worker.ts` files and must be plain named exports.
24
+
25
+ ```ts
26
+ // sum.worker.ts
27
+ export function sum({ data }: { data: number[] }): number {
28
+ return data.reduce((acc, n) => acc + n, 0);
29
+ }
30
+ ```
31
+
32
+ ### 2. Register and run it
33
+
34
+ ```ts
35
+ import { MainWorkerFactory } from 'workerkit';
36
+ import { sum } from './sum.worker.ts';
37
+
38
+ const factory = new MainWorkerFactory(initiator, {
39
+ workers: [
40
+ {
41
+ name: 'sum',
42
+ role: 'computation',
43
+ func: sum,
44
+ maxConcurrency: 4,
45
+ retries: 2,
46
+ },
47
+ ],
48
+ });
49
+
50
+ const result = await factory.runWorker('sum', { srcData: [1, 2, 3, 4, 5] });
51
+ const { data } = await factory.collectResults(result);
52
+
53
+ console.log(data); // 15
54
+ ```
55
+
56
+ ---
57
+
58
+ ## WorkerConfig Options
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 |
67
+ | `partition` | `boolean` | `false` | Split array input across multiple workers automatically |
68
+
69
+ ---
70
+
71
+ ## Partitioning
72
+
73
+ When `partition: true`, an array passed as `srcData` is automatically split across worker instances and results are merged back.
74
+
75
+ ```ts
76
+ const result = await factory.runWorker('sum', {
77
+ srcData: largeArray, // split across workers
78
+ });
79
+
80
+ const { data, succeeded, failed } = await factory.collectResults(result);
81
+ ```
82
+
83
+ You can also provide a custom reducer to control how shard results are merged:
84
+
85
+ ```ts
86
+ const { data } = await factory.collectResults(result, {
87
+ reducer: (shards) => shards.flat().sort((a, b) => b.score - a.score),
88
+ });
89
+ ```
90
+
91
+ ---
92
+
93
+ ## ESLint Plugin
94
+
95
+ The package ships with two ESLint rules to catch common worker mistakes at lint time.
96
+
97
+ ### Setup
98
+
99
+ ```js
100
+ // eslint.config.js
101
+ import workerPlugin from 'workerkit/eslint-plugin';
102
+
103
+ export default [...workerPlugin.configs.recommended];
104
+ ```
105
+
106
+ This applies both rules to all `*.worker.ts` and `*.worker.js` files.
107
+
108
+ ### Rules
109
+
110
+ #### `no-dom-in-worker`
111
+
112
+ Flags usage of browser main-thread-only APIs that are unavailable inside Web Workers — things like `document`, `window`, `localStorage`, `alert`, DOM constructors, etc.
113
+
114
+ ```ts
115
+ // sum.worker.ts ❌ — will be flagged
116
+ export function sum({ data }: MessageEvent) {
117
+ document.title = 'working...'; // Error: 'document' is not available inside Web Workers
118
+ return data.reduce((a: number, b: number) => a + b, 0);
119
+ }
120
+ ```
121
+
122
+ ```ts
123
+ // sum.worker.ts ✅
124
+ export function sum({ data }: MessageEvent) {
125
+ return data.reduce((a: number, b: number) => a + b, 0);
126
+ }
127
+ ```
128
+
129
+ #### `worker-exportable`
130
+
131
+ Enforces that worker files only export named functions — the shape required by `MainWorkerFactory`. Flags `export default`, class exports, non-function value exports, and re-exports.
132
+
133
+ ```ts
134
+ // bad.worker.ts ❌
135
+ export default function() { ... } // Error: must not use export default
136
+ export class MyWorker { ... } // Error: must not export classes
137
+ export const config = { x: 1 }; // Error: must not export non-function values
138
+ ```
139
+
140
+ ```ts
141
+ // good.worker.ts ✅
142
+ export function processData({ data }: { data: number[] }) {
143
+ return data.map((n) => n * 2);
144
+ }
145
+ ```
146
+
147
+ ### Using individual rules
148
+
149
+ You can also import rules individually if you don't want the full recommended config:
150
+
151
+ ```js
152
+ // eslint.config.js
153
+ import noDomInWorker from 'workerkit/eslint-rules/no-dom-in-worker';
154
+ import workerExportable from 'workerkit/eslint-rules/worker-exportable';
155
+
156
+ export default [
157
+ {
158
+ files: ['**/*.worker.ts'],
159
+ plugins: {
160
+ workerkit: {
161
+ rules: {
162
+ 'no-dom-in-worker': noDomInWorker,
163
+ 'worker-exportable': workerExportable,
164
+ },
165
+ },
166
+ },
167
+ rules: {
168
+ 'workerkit/no-dom-in-worker': 'error',
169
+ 'workerkit/worker-exportable': 'warn',
170
+ },
171
+ },
172
+ ];
173
+ ```
174
+
175
+ ---
176
+
177
+ ## License
178
+
179
+ MIT
package/dist/index.cjs ADDED
@@ -0,0 +1,31 @@
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=>`
2
+ const extractTransferables = (value, seen = new Set()) => {
3
+ if (value === null || typeof value !== 'object') return [];
4
+ if (seen.has(value)) return [];
5
+ seen.add(value);
6
+ if (value instanceof ArrayBuffer || value instanceof MessagePort ||
7
+ (typeof ImageBitmap !== 'undefined' && value instanceof ImageBitmap) ||
8
+ (typeof OffscreenCanvas !== 'undefined' && value instanceof OffscreenCanvas)) {
9
+ return [value];
10
+ }
11
+ if (ArrayBuffer.isView(value)) return [value.buffer];
12
+ if (Array.isArray(value)) return value.flatMap(i => extractTransferables(i, seen));
13
+ return Object.values(value).flatMap(v => extractTransferables(v, seen));
14
+ };
15
+
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};
23
+ self.addEventListener('message', (event) => {
24
+ try {
25
+ const result = reducer(event.data);
26
+ self.postMessage({ ok: true, data: result });
27
+ } catch (err) {
28
+ self.postMessage({ ok: false, error: String(err) });
29
+ }
30
+ });
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;
package/dist/index.js ADDED
@@ -0,0 +1,186 @@
1
+ var y = Object.defineProperty;
2
+ var g = (t, e, r) => e in t ? y(t, e, { enumerable: !0, configurable: !0, writable: !0, value: r }) : t[e] = r;
3
+ var h = (t, e, r) => g(t, typeof e != "symbol" ? e + "" : e, r);
4
+ const m = (t) => `
5
+ const extractTransferables = (value, seen = new Set()) => {
6
+ if (value === null || typeof value !== 'object') return [];
7
+ if (seen.has(value)) return [];
8
+ seen.add(value);
9
+ if (value instanceof ArrayBuffer || value instanceof MessagePort ||
10
+ (typeof ImageBitmap !== 'undefined' && value instanceof ImageBitmap) ||
11
+ (typeof OffscreenCanvas !== 'undefined' && value instanceof OffscreenCanvas)) {
12
+ return [value];
13
+ }
14
+ if (ArrayBuffer.isView(value)) return [value.buffer];
15
+ if (Array.isArray(value)) return value.flatMap(i => extractTransferables(i, seen));
16
+ return Object.values(value).flatMap(v => extractTransferables(v, seen));
17
+ };
18
+
19
+ self.addEventListener('message', async (event) => {
20
+ const begin = performance.now();
21
+ const output = await ${t}(event.data);
22
+ self.postMessage(output, extractTransferables(output));
23
+ })
24
+ `;
25
+ class w {
26
+ constructor(e) {
27
+ h(this, "_worker");
28
+ const r = m(e.toString()), s = new Blob([r], {
29
+ type: "application/javascript"
30
+ });
31
+ this._worker = new Worker(URL.createObjectURL(s));
32
+ }
33
+ get getWorker() {
34
+ return this._worker;
35
+ }
36
+ }
37
+ function p(t, e = /* @__PURE__ */ new Set()) {
38
+ 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(
39
+ (r) => p(r, e)
40
+ ));
41
+ }
42
+ class v {
43
+ constructor(e, r) {
44
+ h(this, "_workers");
45
+ h(this, "_threads");
46
+ this._workers = r.workers, this._threads = navigator.hardwareConcurrency;
47
+ }
48
+ initWorker(e) {
49
+ return new w(e);
50
+ }
51
+ /**
52
+ * Partitions an array into up to numChunks evenly-sized chunks.
53
+ */
54
+ partitionArray(e, r) {
55
+ if (!e.length) return [];
56
+ if (r <= 0) throw new Error("numChunks must be positive");
57
+ const s = Math.min(r, e.length), a = Math.floor(e.length / s), c = e.length % s, o = [];
58
+ let f = 0;
59
+ for (let n = 0; n < s; n++) {
60
+ const i = a + (n < c ? 1 : 0);
61
+ o.push(e.slice(f, f + i)), f += i;
62
+ }
63
+ return o;
64
+ }
65
+ findWorkerByName(e) {
66
+ return this._workers.find((r) => r.name === e);
67
+ }
68
+ async runWorker(e, { srcData: r, ...s }) {
69
+ const a = this.findWorkerByName(e);
70
+ if (!a)
71
+ return Promise.reject(new Error(`Worker "${e}" not found`));
72
+ 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(
73
+ a,
74
+ e,
75
+ { data: f, ...s },
76
+ c,
77
+ o
78
+ );
79
+ return Promise.allSettled(n);
80
+ }
81
+ createWorkerPromises(e, r, s, a, c) {
82
+ const { data: o, ...f } = s;
83
+ return Array.from({ length: a }, (n, i) => {
84
+ const u = c && Array.isArray(o) ? o[i] : o;
85
+ return this.runWorkerWithRetry(
86
+ {
87
+ workerFunc: e.func,
88
+ workerName: r,
89
+ index: i,
90
+ data: { data: u, ...f }
91
+ },
92
+ e.retries
93
+ );
94
+ });
95
+ }
96
+ async runWorkerWithRetry(e, r = 2) {
97
+ try {
98
+ return await this.initiateWorker(e);
99
+ } catch (s) {
100
+ if (r > 0)
101
+ return console.error(
102
+ `Worker ${e.index} failed, retrying (${r} left):`,
103
+ s
104
+ ), this.runWorkerWithRetry(e, r - 1);
105
+ throw console.error("Worker failed after all retries:", s), s;
106
+ }
107
+ }
108
+ initiateWorker({
109
+ workerFunc: e,
110
+ workerName: r,
111
+ index: s,
112
+ data: a
113
+ }) {
114
+ return new Promise((c, o) => {
115
+ const n = this.initWorker(e).getWorker;
116
+ n.onerror = (u) => {
117
+ n.terminate(), o({
118
+ index: s,
119
+ workerConfigs: { workerFunc: e, workerName: r, index: s, data: a },
120
+ failedResult: u
121
+ });
122
+ }, n.onmessage = (u) => {
123
+ c({
124
+ index: s,
125
+ workerConfigs: { workerFunc: e, workerName: r, index: s, data: a },
126
+ successResult: u
127
+ }), n.terminate();
128
+ };
129
+ const i = {
130
+ index: s,
131
+ ...Array.isArray(a) ? { data: a } : a
132
+ };
133
+ n.postMessage(i, p(i));
134
+ });
135
+ }
136
+ /**
137
+ * Collects and merges the settled results from `runWorker` — off the main thread.
138
+ *
139
+ * @param settled The `PromiseSettledResult[]` returned by `runWorker`
140
+ * @param options Optional `reducer` function (must be self-contained)
141
+ *
142
+ * @example
143
+ * // default: flat array of all shard data
144
+ * const { data, succeeded, failed } = await foreman.collectResults(res);
145
+ *
146
+ * @example
147
+ * // custom reducer: sum numbers across shards
148
+ * const { data } = await foreman.collectResults<number[], number>(res, {
149
+ * reducer: (shards) => shards.flat().reduce((a, b) => a + b, 0),
150
+ * });
151
+ */
152
+ async collectResults(e, r = {}) {
153
+ const s = e.filter(
154
+ (n) => n.status === "fulfilled"
155
+ ), a = e.filter(
156
+ (n) => n.status === "rejected"
157
+ ), c = s.map((n) => n.value.successResult.data), o = r.reducer ? r.reducer.toString() : "(shards) => shards.flat()";
158
+ return {
159
+ data: await new Promise((n, i) => {
160
+ const u = `
161
+ const reducer = ${o};
162
+ self.addEventListener('message', (event) => {
163
+ try {
164
+ const result = reducer(event.data);
165
+ self.postMessage({ ok: true, data: result });
166
+ } catch (err) {
167
+ self.postMessage({ ok: false, error: String(err) });
168
+ }
169
+ });
170
+ `, k = new Blob([u], { type: "application/javascript" }), l = new Worker(URL.createObjectURL(k));
171
+ l.onmessage = (d) => {
172
+ l.terminate(), d.data.ok ? n(d.data.data) : i(new Error(d.data.error));
173
+ }, l.onerror = (d) => {
174
+ l.terminate(), i(d);
175
+ }, l.postMessage(c);
176
+ }),
177
+ succeeded: s.length,
178
+ failed: a.length,
179
+ errors: a
180
+ };
181
+ }
182
+ }
183
+ export {
184
+ v as MainWorkerFactory,
185
+ w as WorkerFactory
186
+ };
@@ -0,0 +1,2 @@
1
+ export * from './worker-factory';
2
+ export * from './main-worker-factory';
@@ -0,0 +1,2 @@
1
+ export { default as MainWorkerFactory } from './main-worker-factory.ts';
2
+ export type { WorkerFunction, MainWorkerFactoryWorker, MainWorkerFactoryOptions, WorkerConfig, WorkerName, WorkerRole, } from './types.ts';
@@ -0,0 +1,42 @@
1
+ import { CollectOptions, CollectedResult, MainWorkerFactoryOptions, WorkerFunction, WorkerResult } from './types.ts';
2
+ /**
3
+ * Recursively collects all Transferable objects from a value.
4
+ * Transferables (ArrayBuffer, MessagePort, ImageBitmap, OffscreenCanvas)
5
+ * are zero-copy — they are moved to the worker instead of cloned.
6
+ */
7
+ export declare function extractTransferables(value: unknown, seen?: Set<object>): Transferable[];
8
+ declare class MainWorkerFactory {
9
+ private readonly _workers;
10
+ private readonly _threads;
11
+ constructor(_initiator: WorkerFunction, options: MainWorkerFactoryOptions);
12
+ private initWorker;
13
+ /**
14
+ * Partitions an array into up to numChunks evenly-sized chunks.
15
+ */
16
+ partitionArray<T>(array: T[], numChunks: number): T[][];
17
+ private findWorkerByName;
18
+ runWorker(workerName: string, { srcData, ...otherParams }: {
19
+ srcData: unknown;
20
+ } & Record<string, unknown>): Promise<PromiseSettledResult<WorkerResult>[]>;
21
+ private createWorkerPromises;
22
+ private runWorkerWithRetry;
23
+ private initiateWorker;
24
+ /**
25
+ * Collects and merges the settled results from `runWorker` — off the main thread.
26
+ *
27
+ * @param settled The `PromiseSettledResult[]` returned by `runWorker`
28
+ * @param options Optional `reducer` function (must be self-contained)
29
+ *
30
+ * @example
31
+ * // default: flat array of all shard data
32
+ * const { data, succeeded, failed } = await foreman.collectResults(res);
33
+ *
34
+ * @example
35
+ * // custom reducer: sum numbers across shards
36
+ * const { data } = await foreman.collectResults<number[], number>(res, {
37
+ * reducer: (shards) => shards.flat().reduce((a, b) => a + b, 0),
38
+ * });
39
+ */
40
+ collectResults<T = unknown, R = T[]>(settled: PromiseSettledResult<WorkerResult>[], options?: CollectOptions<T, R>): Promise<CollectedResult<R>>;
41
+ }
42
+ export default MainWorkerFactory;
@@ -0,0 +1,56 @@
1
+ import { WorkerFactory } from '../worker-factory';
2
+ export type WorkerName = string;
3
+ export type WorkerRole = string;
4
+ export type WorkerFunction = (...params: unknown[]) => void;
5
+ export interface WorkerConfig {
6
+ name: WorkerName;
7
+ role: WorkerRole;
8
+ func: WorkerFunction;
9
+ maxConcurrency?: number;
10
+ retries?: number;
11
+ partition?: boolean;
12
+ }
13
+ export interface MainWorkerFactoryOptions {
14
+ workers: WorkerConfig[];
15
+ }
16
+ export interface MainWorkerFactoryWorker extends WorkerConfig {
17
+ worker: WorkerFactory;
18
+ }
19
+ export interface WorkerInstanceConfig {
20
+ workerName: WorkerName;
21
+ workerFunc: WorkerFunction;
22
+ index: number;
23
+ data: unknown;
24
+ }
25
+ export type WorkerSuccessResult = MessageEvent;
26
+ export type WorkerFailedResult = MessageEvent;
27
+ export interface WorkerResult {
28
+ index: number;
29
+ workerConfigs: WorkerInstanceConfig;
30
+ successResult?: WorkerSuccessResult;
31
+ failedResult?: WorkerFailedResult;
32
+ }
33
+ /** Options for collectResults */
34
+ export interface CollectOptions<T, R = T[]> {
35
+ /**
36
+ * Custom reducer applied to the array of fulfilled shard values.
37
+ * Runs inside a worker — must be self-contained (no external references).
38
+ * Defaults to a flat array of all shard data values.
39
+ *
40
+ * @example
41
+ * // sum all numbers across shards
42
+ * reducer: (shards) => shards.flat().reduce((a, b) => a + b, 0)
43
+ */
44
+ reducer?: (shards: T[]) => R;
45
+ }
46
+ /** Result returned by collectResults */
47
+ export interface CollectedResult<R> {
48
+ /** The merged output produced by the reducer */
49
+ data: R;
50
+ /** Number of shards that succeeded */
51
+ succeeded: number;
52
+ /** Number of shards that failed (after all retries) */
53
+ failed: number;
54
+ /** Raw rejected results, if any */
55
+ errors: PromiseRejectedResult[];
56
+ }
@@ -0,0 +1 @@
1
+ export { default as WorkerFactory } from './worker-factory';
@@ -0,0 +1,7 @@
1
+ import { WorkerFunction } from '../main-worker-factory/types';
2
+ declare class WorkerFactory {
3
+ readonly _worker: Worker;
4
+ constructor(workerFunction: WorkerFunction);
5
+ get getWorker(): Worker;
6
+ }
7
+ export default WorkerFactory;
@@ -0,0 +1,2 @@
1
+ declare const _default: () => void;
2
+ export default _default;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,35 @@
1
+ 'use strict';
2
+
3
+ const noDomInWorker = require('./no-dom-in-worker.cjs');
4
+ const workerExportable = require('./worker-exportable.cjs');
5
+
6
+ /** All rules bundled as a flat-config-compatible ESLint plugin */
7
+ module.exports = {
8
+ rules: {
9
+ 'no-dom-in-worker': noDomInWorker,
10
+ 'worker-exportable': workerExportable,
11
+ },
12
+
13
+ /**
14
+ * Ready-made flat config that applies both rules to *.worker.ts files.
15
+ *
16
+ * @example
17
+ * // eslint.config.js
18
+ * import workerPlugin from 'workerkit/eslint-plugin';
19
+ * export default [ ...workerPlugin.configs.recommended ];
20
+ */
21
+ configs: {
22
+ recommended: [
23
+ {
24
+ files: ['**/*.worker.ts', '**/*.worker.js'],
25
+ plugins: {
26
+ workerkit: module.exports,
27
+ },
28
+ rules: {
29
+ 'workerkit/no-dom-in-worker': 'error',
30
+ 'workerkit/worker-exportable': 'error',
31
+ },
32
+ },
33
+ ],
34
+ },
35
+ };
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Custom ESLint rule: no-dom-in-worker
3
+ *
4
+ * Flags usage of browser main-thread-only APIs that are NOT available
5
+ * inside Web Workers (DedicatedWorker, SharedWorker, ServiceWorker).
6
+ *
7
+ * Safe in workers: self, postMessage, fetch, setTimeout, setInterval,
8
+ * performance, console, crypto, indexedDB, caches, WebSockets, etc.
9
+ *
10
+ * NOT safe in workers: document, window, navigator (partially),
11
+ * localStorage, sessionStorage, alert/confirm/prompt, DOM constructors,
12
+ * DOM element methods, history, location (partially), screen, etc.
13
+ */
14
+
15
+ /** Global identifiers that are unavailable in Web Workers */
16
+ const FORBIDDEN_GLOBALS = new Set([
17
+ 'document',
18
+ 'window',
19
+ 'alert',
20
+ 'confirm',
21
+ 'prompt',
22
+ 'localStorage',
23
+ 'sessionStorage',
24
+ 'history',
25
+ 'screen',
26
+ 'frames',
27
+ 'parent',
28
+ 'top',
29
+ 'opener',
30
+ 'frameElement',
31
+ 'getComputedStyle',
32
+ 'matchMedia',
33
+ 'requestAnimationFrame',
34
+ 'cancelAnimationFrame',
35
+ 'requestIdleCallback',
36
+ 'cancelIdleCallback',
37
+ 'scrollTo',
38
+ 'scrollBy',
39
+ 'scroll',
40
+ 'resizeTo',
41
+ 'resizeBy',
42
+ 'moveTo',
43
+ 'moveBy',
44
+ 'focus',
45
+ 'blur',
46
+ 'print',
47
+ 'stop',
48
+ 'open',
49
+ 'close',
50
+ ]);
51
+
52
+ /**
53
+ * DOM constructor names unavailable in workers.
54
+ * e.g. new HTMLElement(), new Document(), new Event() is fine but
55
+ * new HTMLDivElement() is not.
56
+ */
57
+ const FORBIDDEN_CONSTRUCTORS = new Set([
58
+ 'HTMLElement',
59
+ 'HTMLDivElement',
60
+ 'HTMLSpanElement',
61
+ 'HTMLInputElement',
62
+ 'HTMLButtonElement',
63
+ 'HTMLFormElement',
64
+ 'HTMLAnchorElement',
65
+ 'HTMLImageElement',
66
+ 'HTMLCanvasElement', // regular Canvas; OffscreenCanvas IS allowed
67
+ 'HTMLVideoElement',
68
+ 'HTMLAudioElement',
69
+ 'HTMLTableElement',
70
+ 'HTMLSelectElement',
71
+ 'HTMLTextAreaElement',
72
+ 'SVGElement',
73
+ 'Document',
74
+ 'Window',
75
+ 'Navigator',
76
+ 'Element',
77
+ 'Node',
78
+ 'NodeList',
79
+ 'HTMLCollection',
80
+ 'MutationObserver',
81
+ 'IntersectionObserver',
82
+ 'ResizeObserver',
83
+ 'PerformanceObserver', // available in workers, but flag to be safe
84
+ 'XPathResult',
85
+ 'Range',
86
+ 'Selection',
87
+ 'TreeWalker',
88
+ 'NodeIterator',
89
+ 'DOMParser',
90
+ 'XMLSerializer',
91
+ 'CSSStyleDeclaration',
92
+ 'CSSRule',
93
+ 'StyleSheet',
94
+ 'MediaQueryList',
95
+ 'Screen',
96
+ 'History',
97
+ 'Location',
98
+ 'Storage',
99
+ 'Clipboard',
100
+ 'Notification', // constructor exists but requires window context
101
+ ]);
102
+
103
+ /**
104
+ * Member expressions whose object is a known forbidden global.
105
+ * e.g. document.querySelector, window.location, navigator.geolocation
106
+ */
107
+ const FORBIDDEN_MEMBER_OBJECTS = new Set([
108
+ 'document',
109
+ 'window',
110
+ 'localStorage',
111
+ 'sessionStorage',
112
+ 'history',
113
+ 'screen',
114
+ 'navigator', // navigator itself is partially available; flag member access
115
+ ]);
116
+
117
+ /** @type {import('eslint').Rule.RuleModule} */
118
+ module.exports = {
119
+ meta: {
120
+ type: 'problem',
121
+ docs: {
122
+ description:
123
+ 'Disallow browser main-thread-only APIs inside Web Worker files',
124
+ category: 'Web Workers',
125
+ recommended: true,
126
+ url: 'https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Functions_and_classes_available_to_workers',
127
+ },
128
+ schema: [],
129
+ messages: {
130
+ forbiddenGlobal:
131
+ "'{{name}}' is not available inside Web Workers. " +
132
+ 'Web Workers run in a separate thread without access to the main-thread DOM. ' +
133
+ "Use worker-safe alternatives (e.g. 'self', 'postMessage', 'fetch', 'indexedDB') " +
134
+ 'or move this logic to the main thread and communicate via messages. ' +
135
+ 'See: https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Functions_and_classes_available_to_workers',
136
+ forbiddenConstructor:
137
+ "'new {{name}}(...)' is not available inside Web Workers. " +
138
+ 'DOM constructors require a browsing context that workers do not have. ' +
139
+ "If you need to manipulate DOM elements, send a message to the main thread via 'postMessage' and handle it there. " +
140
+ 'See: https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Functions_and_classes_available_to_workers',
141
+ forbiddenMember:
142
+ "'{{object}}.{{property}}' is not available inside Web Workers. " +
143
+ "'{{object}}' is a main-thread-only global that does not exist in worker scope. " +
144
+ "Pass required data into the worker via 'postMessage' instead of accessing it directly. " +
145
+ 'See: https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Functions_and_classes_available_to_workers',
146
+ },
147
+ },
148
+
149
+ create(context) {
150
+ return {
151
+ // Flag standalone identifiers that are forbidden globals
152
+ // e.g. `alert()`, `localStorage` used directly (not as `document.X`)
153
+ Identifier(node) {
154
+ if (!FORBIDDEN_GLOBALS.has(node.name)) return;
155
+
156
+ const parent = node.parent;
157
+
158
+ // Skip property keys in member expressions / object literals / class methods
159
+ if (
160
+ parent.type === 'MemberExpression' &&
161
+ parent.property === node &&
162
+ !parent.computed
163
+ )
164
+ return;
165
+ if (parent.type === 'Property' && parent.key === node) return;
166
+ if (parent.type === 'MethodDefinition' && parent.key === node) return;
167
+
168
+ // Skip when this identifier is the *object* of a MemberExpression that
169
+ // is already in FORBIDDEN_MEMBER_OBJECTS — the MemberExpression handler
170
+ // will report a richer message for that case (e.g. document.querySelector).
171
+ if (
172
+ parent.type === 'MemberExpression' &&
173
+ parent.object === node &&
174
+ FORBIDDEN_MEMBER_OBJECTS.has(node.name)
175
+ ) {
176
+ return;
177
+ }
178
+
179
+ context.report({
180
+ node,
181
+ messageId: 'forbiddenGlobal',
182
+ data: { name: node.name },
183
+ });
184
+ },
185
+
186
+ // Flag `new ForbiddenConstructor()`
187
+ NewExpression(node) {
188
+ const callee = node.callee;
189
+ if (
190
+ callee.type === 'Identifier' &&
191
+ FORBIDDEN_CONSTRUCTORS.has(callee.name)
192
+ ) {
193
+ context.report({
194
+ node,
195
+ messageId: 'forbiddenConstructor',
196
+ data: { name: callee.name },
197
+ });
198
+ }
199
+ },
200
+
201
+ // Flag member access on forbidden objects: document.X, window.X, etc.
202
+ MemberExpression(node) {
203
+ if (
204
+ node.object.type === 'Identifier' &&
205
+ FORBIDDEN_MEMBER_OBJECTS.has(node.object.name)
206
+ ) {
207
+ const property =
208
+ node.property.type === 'Identifier'
209
+ ? node.property.name
210
+ : node.computed
211
+ ? '<computed>'
212
+ : '?';
213
+
214
+ context.report({
215
+ node,
216
+ messageId: 'forbiddenMember',
217
+ data: {
218
+ object: node.object.name,
219
+ property,
220
+ },
221
+ });
222
+ }
223
+ },
224
+ };
225
+ },
226
+ };
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Custom ESLint rule: worker-exportable
3
+ *
4
+ * Enforces that Web Worker files (*.worker.ts) only export named, callable
5
+ * functions — the shape required by MainWorkerFactory.
6
+ *
7
+ * WHY: WorkerFactory serialises the function via `.toString()` and injects it
8
+ * into a Blob worker. For this to work the export must be:
9
+ * ✓ export function myWorker(...) { ... }
10
+ * ✓ export const myWorker = function(...) { ... }
11
+ * ✓ export const myWorker = (...) => { ... }
12
+ *
13
+ * The following are flagged because they cannot be passed directly to
14
+ * MainWorkerFactory as a worker function:
15
+ * ✗ export default ... (anonymous / unnamed — no stable identifier)
16
+ * ✗ export class Foo { ... } (not a plain function)
17
+ * ✗ export const x = 42 (not callable)
18
+ * ✗ export { foo } from '...' (re-exports hide the original source)
19
+ */
20
+
21
+ /** @type {import('eslint').Rule.RuleModule} */
22
+ module.exports = {
23
+ meta: {
24
+ type: 'suggestion',
25
+ docs: {
26
+ description:
27
+ 'Enforce that worker files only export named functions importable by MainWorkerFactory',
28
+ category: 'Web Workers',
29
+ recommended: true,
30
+ },
31
+ schema: [],
32
+ messages: {
33
+ noDefaultExport:
34
+ 'Worker files must not use `export default`. ' +
35
+ 'Export a named function instead so it can be imported and passed to MainWorkerFactory. ' +
36
+ 'Example: export function myWorker({ data }) { ... }',
37
+
38
+ noClassExport:
39
+ "Worker files must not export classes ('{{name}}'). " +
40
+ 'MainWorkerFactory expects a plain callable function. ' +
41
+ 'Example: export function myWorker({ data }) { ... }',
42
+
43
+ noNonFunctionExport:
44
+ "Worker files must not export non-function values ('{{name}}'). " +
45
+ 'Only exported functions can be passed to MainWorkerFactory. ' +
46
+ 'Example: export function myWorker({ data }) { ... }',
47
+
48
+ noReExport:
49
+ 'Worker files must not use re-exports (`export { ... } from ...`). ' +
50
+ 'Define and export the worker function directly in this file so ' +
51
+ 'WorkerFactory can serialise it via .toString().',
52
+
53
+ mustExportFunction:
54
+ 'Worker files must export at least one named function. ' +
55
+ 'MainWorkerFactory requires a named exported function to run in the worker thread. ' +
56
+ 'Example: export function myWorker({ data }) { ... }',
57
+ },
58
+ },
59
+
60
+ create(context) {
61
+ let hasNamedFunctionExport = false;
62
+
63
+ /** Returns true when an AST node represents a function (any flavour). */
64
+ function isFunction(node) {
65
+ return (
66
+ node.type === 'FunctionDeclaration' ||
67
+ node.type === 'FunctionExpression' ||
68
+ node.type === 'ArrowFunctionExpression'
69
+ );
70
+ }
71
+
72
+ return {
73
+ // export default <anything>
74
+ ExportDefaultDeclaration(node) {
75
+ context.report({ node, messageId: 'noDefaultExport' });
76
+ },
77
+
78
+ // export function foo() {} / export class Foo {} / export const x = ...
79
+ ExportNamedDeclaration(node) {
80
+ const decl = node.declaration;
81
+
82
+ // export { foo } from './somewhere' — re-export from another module
83
+ if (!decl) {
84
+ if (node.source) {
85
+ context.report({ node, messageId: 'noReExport' });
86
+ }
87
+ // plain `export { localVar }` — allowed (re-surfaces a local name)
88
+ return;
89
+ }
90
+
91
+ if (decl.type === 'ClassDeclaration') {
92
+ const name = decl.id ? decl.id.name : '<anonymous>';
93
+ context.report({ node, messageId: 'noClassExport', data: { name } });
94
+ return;
95
+ }
96
+
97
+ if (decl.type === 'FunctionDeclaration') {
98
+ // export function foo() {}
99
+ hasNamedFunctionExport = true;
100
+ return;
101
+ }
102
+
103
+ if (decl.type === 'VariableDeclaration') {
104
+ for (const declarator of decl.declarations) {
105
+ const name =
106
+ declarator.id && declarator.id.type === 'Identifier'
107
+ ? declarator.id.name
108
+ : '<unknown>';
109
+
110
+ if (declarator.init && isFunction(declarator.init)) {
111
+ // export const foo = () => {} or export const foo = function() {}
112
+ hasNamedFunctionExport = true;
113
+ } else if (
114
+ decl.kind !== 'type' &&
115
+ declarator.init !== null &&
116
+ declarator.init !== undefined &&
117
+ !isFunction(declarator.init)
118
+ ) {
119
+ context.report({
120
+ node: declarator,
121
+ messageId: 'noNonFunctionExport',
122
+ data: { name },
123
+ });
124
+ }
125
+ }
126
+ }
127
+
128
+ // TSTypeAliasDeclaration / TSInterfaceDeclaration — always fine, skip.
129
+ },
130
+
131
+ // After visiting the whole file, ensure at least one named function was exported.
132
+ 'Program:exit'(node) {
133
+ if (!hasNamedFunctionExport) {
134
+ context.report({ node, messageId: 'mustExportFunction' });
135
+ }
136
+ },
137
+ };
138
+ },
139
+ };
package/package.json ADDED
@@ -0,0 +1,72 @@
1
+ {
2
+ "name": "@offmain/workerkit",
3
+ "version": "0.3.0",
4
+ "description": "A lightweight manager for running functions in Web Workers with partitioning, retries, and concurrency control",
5
+ "type": "module",
6
+ "main": "./dist/index.cjs",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/types/tools/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/types/tools/index.d.ts",
12
+ "import": "./dist/index.js",
13
+ "require": "./dist/index.cjs"
14
+ },
15
+ "./eslint-plugin": "./eslint-rules/index.cjs",
16
+ "./eslint-rules/no-dom-in-worker": "./eslint-rules/no-dom-in-worker.cjs",
17
+ "./eslint-rules/worker-exportable": "./eslint-rules/worker-exportable.cjs"
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "eslint-rules"
22
+ ],
23
+ "keywords": [
24
+ "web-worker",
25
+ "worker",
26
+ "concurrency",
27
+ "parallel",
28
+ "thread",
29
+ "browser"
30
+ ],
31
+ "license": "MIT",
32
+ "lint-staged": {
33
+ "**/*.{ts,tsx}": [
34
+ "eslint --fix",
35
+ "prettier --write"
36
+ ],
37
+ "**/*.{js,cjs,json,md}": [
38
+ "prettier --write"
39
+ ]
40
+ },
41
+ "devDependencies": {
42
+ "@typescript-eslint/eslint-plugin": "^8.58.2",
43
+ "@typescript-eslint/parser": "^8.58.2",
44
+ "date-fns": "^4.1.0",
45
+ "date-fns-tz": "^3.2.0",
46
+ "eslint": "^9.16.0",
47
+ "eslint-config-prettier": "^10.1.8",
48
+ "eslint-plugin-local-rules": "^3.0.2",
49
+ "eslint-plugin-prettier": "^5.5.5",
50
+ "husky": "^9.1.7",
51
+ "jsdom": "^24.0.0",
52
+ "lint-staged": "^16.4.0",
53
+ "prettier": "^3.4.2",
54
+ "typescript": "^5.7.2",
55
+ "vite": "^6.3.3",
56
+ "vite-plugin-dts": "^4.5.4",
57
+ "vitest": "^1.0.0"
58
+ },
59
+ "scripts": {
60
+ "dev": "vite",
61
+ "build": "vite build",
62
+ "build:lib": "vite build --config vite.lib.config.ts",
63
+ "preview": "vite preview",
64
+ "lint": "eslint --fix .",
65
+ "test": "vitest run",
66
+ "test:watch": "vitest",
67
+ "release:patch": "pnpm version patch && pnpm run release:publish",
68
+ "release:minor": "pnpm version minor && pnpm run release:publish",
69
+ "release:major": "pnpm version major && pnpm run release:publish",
70
+ "release:publish": "pnpm run build:lib && pnpm publish"
71
+ }
72
+ }