@nitpicker/core 0.6.5-alpha.0 → 0.8.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.
@@ -0,0 +1,278 @@
1
+ import { Worker } from 'node:worker_threads';
2
+ /**
3
+ * Long-lived pool of Worker threads that share execution across many tasks.
4
+ *
5
+ * Replaces the previous "one worker per page" model where every analyzed page
6
+ * paid the cost of spawning a new V8 isolate, importing JSDOM, importing the
7
+ * plugin module, and tearing it all down. With the pool, each worker is
8
+ * created once at the start of an analyze run and processes tasks via
9
+ * message passing until the pool is terminated.
10
+ *
11
+ * ## Why this matters
12
+ *
13
+ * On a 750-page archive with `CONCURRENCY_LIMIT = 50`, the old model spawned
14
+ * 50 workers nearly simultaneously and repeated this every time a worker
15
+ * finished, producing recurring "boot waves" that drove peak memory above
16
+ * 20GB. The pool model performs N initial boots and then reuses those V8
17
+ * isolates for the rest of the run, eliminating the recurring cost entirely.
18
+ *
19
+ * ## Message protocol (Pool ↔ Worker)
20
+ *
21
+ * | Direction | `type` | Payload |
22
+ * |------------------|---------------|----------------------------------|
23
+ * | Pool → Worker | `'task'` | `{ taskId, data }` |
24
+ * | Pool → Worker | `'shutdown'` | (none) |
25
+ * | Worker → Pool | `'url'` | `{ taskId, url }` (forwarded) |
26
+ * | Worker → Pool | `'result'` | `{ taskId, result, error? }` |
27
+ *
28
+ * Each task carries a unique numeric `taskId` so that responses and URL
29
+ * notifications from a worker can be routed back to the correct caller.
30
+ */
31
+ export class WorkerPool {
32
+ /** FIFO queue of slots ready to accept a task. */
33
+ #idle = [];
34
+ /** Tasks awaiting their result, keyed by taskId. */
35
+ #inFlight = new Map();
36
+ /** Monotonic task identifier source. */
37
+ #nextTaskId = 0;
38
+ /** FIFO queue of pending tasks waiting for an idle slot. */
39
+ #queue = [];
40
+ /** Once `terminate()` has been called, no new tasks may be submitted. */
41
+ #shuttingDown = false;
42
+ /** All worker slots, idle or busy. */
43
+ #slots = [];
44
+ /** Per-task data payload to send when a queued task is dispatched. */
45
+ #taskData = new Map();
46
+ /** Worker entry script path; reused when replacing crashed workers. */
47
+ #workerPath;
48
+ /**
49
+ * Constructs a pool with the given size and worker entry script.
50
+ * @param options - Pool size and worker entry script path.
51
+ */
52
+ constructor(options) {
53
+ const size = Math.max(1, Math.floor(options.size));
54
+ this.#workerPath = options.workerPath;
55
+ for (let i = 0; i < size; i++) {
56
+ this.#spawn();
57
+ }
58
+ }
59
+ /**
60
+ * Submits a task to the pool. Resolves with the worker's `result` payload
61
+ * once a worker has finished processing it. The task may be queued if all
62
+ * workers are currently busy.
63
+ * @template I - Shape of the per-task `initialData` payload.
64
+ * @template R - Expected result type returned by the worker module.
65
+ * @param params - Task payload (file path, batch indices, emitter, data).
66
+ * @returns The worker's result.
67
+ */
68
+ run(params) {
69
+ if (this.#shuttingDown) {
70
+ return Promise.reject(new Error('WorkerPool is shutting down'));
71
+ }
72
+ const taskId = this.#nextTaskId++;
73
+ const data = {
74
+ filePath: params.filePath,
75
+ num: params.num,
76
+ total: params.total,
77
+ ...params.initialData,
78
+ };
79
+ return new Promise((resolve, reject) => {
80
+ const task = {
81
+ taskId,
82
+ emitter: params.emitter,
83
+ resolve: resolve,
84
+ reject,
85
+ };
86
+ this.#taskData.set(taskId, data);
87
+ const slot = this.#idle.shift();
88
+ if (slot) {
89
+ this.#dispatch(slot, task);
90
+ return;
91
+ }
92
+ this.#queue.push(task);
93
+ });
94
+ }
95
+ /**
96
+ * Rejects every queued or in-flight task, sends each worker a `shutdown`
97
+ * message, and awaits its exit. After this call returns, no further
98
+ * submissions are accepted.
99
+ *
100
+ * Pending tasks are rejected synchronously so callers waiting on
101
+ * `pool.run()` Promises do not hang. Workers handle the shutdown message
102
+ * by exiting cleanly; if that fails, {@link #shutdownSlot} forces
103
+ * termination after a 5 second timeout.
104
+ */
105
+ async terminate() {
106
+ this.#shuttingDown = true;
107
+ const pendingError = new Error('WorkerPool terminated before task completed');
108
+ for (const task of this.#queue) {
109
+ task.reject(pendingError);
110
+ this.#taskData.delete(task.taskId);
111
+ }
112
+ this.#queue = [];
113
+ for (const task of this.#inFlight.values()) {
114
+ task.reject(pendingError);
115
+ }
116
+ this.#inFlight.clear();
117
+ await Promise.all(this.#slots.map((slot) => this.#shutdownSlot(slot)));
118
+ this.#slots = [];
119
+ this.#idle = [];
120
+ }
121
+ /**
122
+ * Completes the current task on `slot` and dispatches the next queued task.
123
+ * @param slot
124
+ * @param taskId
125
+ * @param result
126
+ * @param error
127
+ */
128
+ #completeTask(slot, taskId, result, error) {
129
+ const task = this.#inFlight.get(taskId);
130
+ this.#inFlight.delete(taskId);
131
+ slot.current = null;
132
+ if (task) {
133
+ if (error) {
134
+ task.reject(new Error(error));
135
+ }
136
+ else {
137
+ task.resolve(result);
138
+ }
139
+ }
140
+ const next = this.#queue.shift();
141
+ if (next) {
142
+ this.#dispatch(slot, next);
143
+ }
144
+ else {
145
+ this.#idle.push(slot);
146
+ }
147
+ }
148
+ /**
149
+ * Sends a queued task to an idle slot and registers it as in-flight.
150
+ *
151
+ * If `postMessage` throws (e.g. the worker died between handoff and
152
+ * dispatch), the task is rejected immediately and the slot is left out
153
+ * of the idle pool so the worker's `error` event can replace it via
154
+ * {@link #onWorkerCrash}.
155
+ * @param slot - Pool slot that owns the worker.
156
+ * @param task - Pending task to dispatch.
157
+ */
158
+ #dispatch(slot, task) {
159
+ const data = this.#taskData.get(task.taskId);
160
+ try {
161
+ slot.worker.postMessage({ type: 'task', taskId: task.taskId, data });
162
+ }
163
+ catch (error) {
164
+ this.#taskData.delete(task.taskId);
165
+ task.reject(error instanceof Error ? error : new Error(String(error)));
166
+ return;
167
+ }
168
+ slot.current = task;
169
+ this.#inFlight.set(task.taskId, task);
170
+ this.#taskData.delete(task.taskId);
171
+ }
172
+ /**
173
+ * Routes an incoming worker message to the matching pending task.
174
+ * @param slot
175
+ * @param message
176
+ */
177
+ #onMessage(slot, message) {
178
+ if (!message || typeof message !== 'object') {
179
+ return;
180
+ }
181
+ const msg = message;
182
+ if (msg.type === 'url' &&
183
+ typeof msg.taskId === 'number' &&
184
+ typeof msg.url === 'string') {
185
+ const task = this.#inFlight.get(msg.taskId);
186
+ if (task) {
187
+ void task.emitter.emit('url', msg.url);
188
+ }
189
+ return;
190
+ }
191
+ if (msg.type === 'result' && typeof msg.taskId === 'number') {
192
+ this.#completeTask(slot, msg.taskId, msg.result, msg.error);
193
+ return;
194
+ }
195
+ }
196
+ /**
197
+ * Handles an unrecoverable worker failure: rejects the current task,
198
+ * removes the dead slot, and spawns a replacement so the pool keeps its
199
+ * configured size. Queued tasks are picked up by the new slot when ready.
200
+ * @param slot
201
+ * @param error
202
+ */
203
+ #onWorkerCrash(slot, error) {
204
+ const task = slot.current;
205
+ slot.current = null;
206
+ if (task) {
207
+ this.#inFlight.delete(task.taskId);
208
+ task.reject(error instanceof Error ? error : new Error(String(error)));
209
+ }
210
+ const slotIndex = this.#slots.indexOf(slot);
211
+ if (slotIndex !== -1) {
212
+ this.#slots.splice(slotIndex, 1);
213
+ }
214
+ const idleIndex = this.#idle.indexOf(slot);
215
+ if (idleIndex !== -1) {
216
+ this.#idle.splice(idleIndex, 1);
217
+ }
218
+ slot.worker.removeAllListeners();
219
+ void slot.worker.terminate();
220
+ if (this.#shuttingDown) {
221
+ return;
222
+ }
223
+ const replacement = this.#spawn();
224
+ const next = this.#queue.shift();
225
+ if (next) {
226
+ const idleIdx = this.#idle.indexOf(replacement);
227
+ if (idleIdx !== -1) {
228
+ this.#idle.splice(idleIdx, 1);
229
+ }
230
+ this.#dispatch(replacement, next);
231
+ }
232
+ }
233
+ /**
234
+ * Sends a shutdown message and waits for the worker to exit cleanly.
235
+ * @param slot
236
+ */
237
+ #shutdownSlot(slot) {
238
+ return new Promise((resolve) => {
239
+ const finish = () => {
240
+ slot.worker.removeAllListeners();
241
+ resolve();
242
+ };
243
+ slot.worker.once('exit', finish);
244
+ slot.worker.once('error', finish);
245
+ try {
246
+ slot.worker.postMessage({ type: 'shutdown' });
247
+ }
248
+ catch {
249
+ void slot.worker.terminate().finally(finish);
250
+ return;
251
+ }
252
+ // Safety net in case the worker fails to acknowledge the shutdown.
253
+ setTimeout(() => {
254
+ void slot.worker.terminate().finally(finish);
255
+ }, 5000).unref();
256
+ });
257
+ }
258
+ /** Spawns a fresh worker, wires up its event handlers, and adds it to the pool. */
259
+ #spawn() {
260
+ const worker = new Worker(this.#workerPath);
261
+ const slot = { worker, current: null };
262
+ this.#slots.push(slot);
263
+ worker.on('message', (message) => {
264
+ this.#onMessage(slot, message);
265
+ });
266
+ worker.on('error', (error) => {
267
+ this.#onWorkerCrash(slot, error);
268
+ });
269
+ worker.on('messageerror', (error) => {
270
+ this.#onWorkerCrash(slot, error);
271
+ });
272
+ this.#idle.push(slot);
273
+ // Workers should not keep the event loop alive on their own;
274
+ // the pool's terminate() controls their lifetime.
275
+ worker.unref();
276
+ return slot;
277
+ }
278
+ }
@@ -1,26 +1,33 @@
1
1
  /**
2
2
  * Worker thread entry point for plugin execution.
3
3
  *
4
- * This module runs inside a `new Worker(...)` created by {@link ./run-in-worker.ts!runInWorker}.
5
- * It uses a message-based protocol to communicate with the main thread:
4
+ * This module runs inside a `new Worker(...)` created by the
5
+ * {@link ./worker-pool.ts!WorkerPool}. Unlike the previous one-shot model,
6
+ * each worker is long-lived and processes many tasks via a message loop until
7
+ * it receives a `shutdown` message.
6
8
  *
7
- * ## Message protocol (Worker -> Main)
9
+ * ## Message protocol (Worker ↔ Pool)
8
10
  *
9
- * | `type` | Payload | Description |
10
- * |------------|------------------|------------------------------------------------|
11
- * | `'url'` | `{ url: string }` | A URL was discovered during analysis |
12
- * | `'finish'` | `{ result: R }` | Analysis complete, carries the plugin result |
11
+ * | Direction | `type` | Payload |
12
+ * |-----------------|---------------|----------------------------------|
13
+ * | Pool → Worker | `'task'` | `{ taskId, data }` |
14
+ * | Pool → Worker | `'shutdown'` | (none) |
15
+ * | Worker → Pool | `'url'` | `{ taskId, url }` |
16
+ * | Worker → Pool | `'result'` | `{ taskId, result, error? }` |
13
17
  *
14
18
  * ## Lifecycle
15
19
  *
16
- * 1. Reads `workerData` containing the module path + plugin data
17
- * 2. Creates a local {@link ../url-event-bus.ts!UrlEventBus} that forwards `'url'`
18
- * events to the main thread via `parentPort.postMessage`
19
- * 3. Delegates to {@link ./runner.ts!runner} for dynamic import and execution
20
- * 4. Posts the `'finish'` message with the result
20
+ * 1. Listens on `parentPort` for `task` messages.
21
+ * 2. For each task, delegates to {@link ./runner.ts!runner} which dynamically
22
+ * `import()`s the module pointed to by `data.filePath`. Subsequent tasks
23
+ * that target the same module reuse the import cache, so the heavy boot
24
+ * cost is paid once per module per worker.
25
+ * 3. Posts a `result` message back to the pool and waits for the next task.
26
+ * 4. On `shutdown`, exits the process so the V8 isolate is fully released.
21
27
  *
22
- * The main thread terminates this Worker after receiving `'finish'`.
23
- * @see {@link ./run-in-worker.ts!runInWorker} for the main-thread counterpart
28
+ * The `'url'` events emitted by plugins are forwarded with the active
29
+ * `taskId` so the pool can route them back to the correct caller's emitter.
30
+ * @see {@link ./worker-pool.ts!WorkerPool} for the main-thread counterpart
24
31
  * @see {@link ./runner.ts!runner} for the actual module loading logic
25
32
  * @module
26
33
  */
@@ -1,68 +1,108 @@
1
1
  /**
2
2
  * Worker thread entry point for plugin execution.
3
3
  *
4
- * This module runs inside a `new Worker(...)` created by {@link ./run-in-worker.ts!runInWorker}.
5
- * It uses a message-based protocol to communicate with the main thread:
4
+ * This module runs inside a `new Worker(...)` created by the
5
+ * {@link ./worker-pool.ts!WorkerPool}. Unlike the previous one-shot model,
6
+ * each worker is long-lived and processes many tasks via a message loop until
7
+ * it receives a `shutdown` message.
6
8
  *
7
- * ## Message protocol (Worker -> Main)
9
+ * ## Message protocol (Worker ↔ Pool)
8
10
  *
9
- * | `type` | Payload | Description |
10
- * |------------|------------------|------------------------------------------------|
11
- * | `'url'` | `{ url: string }` | A URL was discovered during analysis |
12
- * | `'finish'` | `{ result: R }` | Analysis complete, carries the plugin result |
11
+ * | Direction | `type` | Payload |
12
+ * |-----------------|---------------|----------------------------------|
13
+ * | Pool → Worker | `'task'` | `{ taskId, data }` |
14
+ * | Pool → Worker | `'shutdown'` | (none) |
15
+ * | Worker → Pool | `'url'` | `{ taskId, url }` |
16
+ * | Worker → Pool | `'result'` | `{ taskId, result, error? }` |
13
17
  *
14
18
  * ## Lifecycle
15
19
  *
16
- * 1. Reads `workerData` containing the module path + plugin data
17
- * 2. Creates a local {@link ../url-event-bus.ts!UrlEventBus} that forwards `'url'`
18
- * events to the main thread via `parentPort.postMessage`
19
- * 3. Delegates to {@link ./runner.ts!runner} for dynamic import and execution
20
- * 4. Posts the `'finish'` message with the result
20
+ * 1. Listens on `parentPort` for `task` messages.
21
+ * 2. For each task, delegates to {@link ./runner.ts!runner} which dynamically
22
+ * `import()`s the module pointed to by `data.filePath`. Subsequent tasks
23
+ * that target the same module reuse the import cache, so the heavy boot
24
+ * cost is paid once per module per worker.
25
+ * 3. Posts a `result` message back to the pool and waits for the next task.
26
+ * 4. On `shutdown`, exits the process so the V8 isolate is fully released.
21
27
  *
22
- * The main thread terminates this Worker after receiving `'finish'`.
23
- * @see {@link ./run-in-worker.ts!runInWorker} for the main-thread counterpart
28
+ * The `'url'` events emitted by plugins are forwarded with the active
29
+ * `taskId` so the pool can route them back to the correct caller's emitter.
30
+ * @see {@link ./worker-pool.ts!WorkerPool} for the main-thread counterpart
24
31
  * @see {@link ./runner.ts!runner} for the actual module loading logic
25
32
  * @module
26
33
  */
27
- import { parentPort, workerData } from 'node:worker_threads';
34
+ import { parentPort } from 'node:worker_threads';
28
35
  import { UrlEventBus } from '../url-event-bus.js';
29
36
  import { runner } from './runner.js';
30
- const data = workerData;
37
+ /** Identifier for the task currently being processed; used to tag forwarded `'url'` events. */
38
+ let currentTaskId = null;
39
+ /** Long-lived event bus that forwards `'url'` events from plugins to the pool. */
31
40
  const emitter = new UrlEventBus();
32
- /**
33
- * Forward URL discovery events from the plugin to the main thread.
34
- * The main thread's {@link ../url-event-bus.ts!UrlEventBus} re-emits these
35
- * so that the orchestrator can track discovered URLs.
36
- */
37
41
  emitter.on('url', (url) => {
38
42
  if (!parentPort) {
39
43
  throw new Error('Use in worker thread');
40
44
  }
45
+ if (currentTaskId === null) {
46
+ return;
47
+ }
41
48
  parentPort.postMessage({
42
49
  type: 'url',
50
+ taskId: currentTaskId,
43
51
  url,
44
52
  });
45
53
  });
46
- try {
47
- const result = await runner(data, emitter);
48
- if (!parentPort) {
49
- throw new Error('Use in worker thread');
50
- }
51
- parentPort.postMessage({
52
- type: 'finish',
53
- result,
54
- });
54
+ if (!parentPort) {
55
+ throw new Error('Use in worker thread');
55
56
  }
56
- catch (error) {
57
- if (!parentPort) {
58
- throw new Error('Use in worker thread');
57
+ parentPort.on('message', (message) => {
58
+ if (!message || typeof message !== 'object') {
59
+ return;
60
+ }
61
+ const msg = message;
62
+ if (msg.type === 'shutdown') {
63
+ // Defer to the next tick so any in-flight `postMessage` from the most
64
+ // recent task has a chance to flush before the worker exits.
65
+ setImmediate(() => process.exit(0));
66
+ return;
67
+ }
68
+ if (msg.type === 'task' && typeof msg.taskId === 'number' && msg.data) {
69
+ void runTask(msg.taskId, msg.data);
70
+ }
71
+ });
72
+ /**
73
+ * Executes a single task and posts the result back to the pool.
74
+ *
75
+ * Errors are caught and reported via the `error` field on the `result` message
76
+ * so the pool can reject the originating Promise without the worker crashing.
77
+ * @param taskId - Identifier echoed back so the pool can match the response.
78
+ * @param data - Payload from the pool, including `filePath` consumed by `runner()`.
79
+ */
80
+ async function runTask(taskId, data) {
81
+ currentTaskId = taskId;
82
+ try {
83
+ const result = await runner(data, emitter);
84
+ if (!parentPort) {
85
+ throw new Error('Use in worker thread');
86
+ }
87
+ parentPort.postMessage({
88
+ type: 'result',
89
+ taskId,
90
+ result,
91
+ });
92
+ }
93
+ catch (error) {
94
+ if (!parentPort) {
95
+ throw new Error('Use in worker thread');
96
+ }
97
+ const message = error instanceof Error ? error.message : String(error);
98
+ parentPort.postMessage({
99
+ type: 'result',
100
+ taskId,
101
+ result: null,
102
+ error: message,
103
+ });
104
+ }
105
+ finally {
106
+ currentTaskId = null;
59
107
  }
60
- const message = error instanceof Error ? error.message : String(error);
61
- // eslint-disable-next-line no-console
62
- console.error(`[worker] Fatal error: ${message}`);
63
- parentPort.postMessage({
64
- type: 'finish',
65
- result: null,
66
- error: message,
67
- });
68
108
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitpicker/core",
3
- "version": "0.6.5-alpha.0",
3
+ "version": "0.8.0",
4
4
  "description": "Plugin-based page analysis engine for Nitpicker",
5
5
  "author": "D-ZERO",
6
6
  "license": "Apache-2.0",
@@ -29,11 +29,11 @@
29
29
  "dependencies": {
30
30
  "@d-zero/dealer": "1.7.0",
31
31
  "@d-zero/shared": "0.20.1",
32
- "@nitpicker/crawler": "0.6.5-alpha.0",
33
- "@nitpicker/types": "0.6.5-alpha.0",
32
+ "@nitpicker/crawler": "0.8.0",
33
+ "@nitpicker/types": "0.8.0",
34
34
  "ansi-colors": "4.1.3",
35
35
  "cosmiconfig": "9.0.0",
36
36
  "jsdom": "28.1.0"
37
37
  },
38
- "gitHead": "e084aba5a0887a80059ff8aa0608f61a58cc9288"
38
+ "gitHead": "8456d02c3052b2b3438f0d32e096ed7e916c5234"
39
39
  }
@@ -1,51 +0,0 @@
1
- import type { UrlEventBus } from '../url-event-bus.js';
2
- /**
3
- * Parameters for {@link runInWorker}.
4
- * @template I - Shape of the additional data merged into `workerData`.
5
- */
6
- export interface RunInWorkerParams<I extends Record<string, unknown>> {
7
- /** Absolute path to the module to execute in the Worker. */
8
- readonly filePath: string;
9
- /** Zero-based index of the current item (for progress display). */
10
- readonly num: number;
11
- /** Total number of items in the batch. */
12
- readonly total: number;
13
- /** URL event bus; `'url'` messages from the Worker are re-emitted here. */
14
- readonly emitter: UrlEventBus;
15
- /** Plugin-specific data to pass to the Worker module. */
16
- readonly initialData: I;
17
- }
18
- /**
19
- * Spawns a Worker thread to execute a plugin module and returns its result.
20
- *
21
- * This is the bridge between the main thread's `deal()` parallelism and the
22
- * per-page Worker execution. Each call creates a new Worker, passes the
23
- * initial data via `workerData`, and listens for messages until the Worker
24
- * signals completion.
25
- *
26
- * ## Why Worker threads?
27
- *
28
- * DOM-heavy plugins (JSDOM + axe-core, markuplint, etc.) allocate significant
29
- * memory per page. Running them in Workers ensures:
30
- * - **Memory isolation**: JSDOM windows are fully GC'd when the Worker exits
31
- * - **Crash containment**: A plugin segfault/OOM kills only the Worker, not the process
32
- * - **Signal handling**: Graceful cleanup on SIGABRT, SIGQUIT, and other signals
33
- *
34
- * ## Message protocol
35
- *
36
- * The Worker sends two types of messages:
37
- * - `{ type: 'url', url: string }` - URL discovery notification, forwarded to the emitter
38
- * - `{ type: 'finish', result: R }` - Execution complete, resolves the Promise
39
- *
40
- * ## Fallback mode
41
- *
42
- * When `useWorker` is `false`, execution delegates directly to
43
- * {@link ./runner.ts!runner} in the main thread.
44
- * @template I - Shape of the additional data merged into `workerData`.
45
- * @template R - Return type expected from the Worker module.
46
- * @param params - Parameters containing file path, progress info, emitter, and data.
47
- * @returns The result produced by the Worker module's default export.
48
- * @see {@link ./worker.ts} for the Worker-side entry point
49
- * @see {@link ./runner.ts!runner} for the direct (non-Worker) execution path
50
- */
51
- export declare function runInWorker<I extends Record<string, unknown>, R>(params: RunInWorkerParams<I>): Promise<R>;
@@ -1,125 +0,0 @@
1
- import path from 'node:path';
2
- import { Worker } from 'node:worker_threads';
3
- import { runner } from './runner.js';
4
- const __filename = new URL(import.meta.url).pathname;
5
- const __dirname = path.dirname(__filename);
6
- /** Resolved path to the compiled Worker thread entry point ({@link ./worker.ts}). */
7
- const workerPath = path.resolve(__dirname, 'worker.js');
8
- /**
9
- * Feature flag controlling whether plugin execution uses Worker threads.
10
- * When `true` (default), each plugin invocation runs in an isolated Worker,
11
- * providing memory isolation and crash protection. When `false`, the runner
12
- * executes directly in the main thread (useful for debugging).
13
- */
14
- const useWorker = true;
15
- /**
16
- * Spawns a Worker thread to execute a plugin module and returns its result.
17
- *
18
- * This is the bridge between the main thread's `deal()` parallelism and the
19
- * per-page Worker execution. Each call creates a new Worker, passes the
20
- * initial data via `workerData`, and listens for messages until the Worker
21
- * signals completion.
22
- *
23
- * ## Why Worker threads?
24
- *
25
- * DOM-heavy plugins (JSDOM + axe-core, markuplint, etc.) allocate significant
26
- * memory per page. Running them in Workers ensures:
27
- * - **Memory isolation**: JSDOM windows are fully GC'd when the Worker exits
28
- * - **Crash containment**: A plugin segfault/OOM kills only the Worker, not the process
29
- * - **Signal handling**: Graceful cleanup on SIGABRT, SIGQUIT, and other signals
30
- *
31
- * ## Message protocol
32
- *
33
- * The Worker sends two types of messages:
34
- * - `{ type: 'url', url: string }` - URL discovery notification, forwarded to the emitter
35
- * - `{ type: 'finish', result: R }` - Execution complete, resolves the Promise
36
- *
37
- * ## Fallback mode
38
- *
39
- * When `useWorker` is `false`, execution delegates directly to
40
- * {@link ./runner.ts!runner} in the main thread.
41
- * @template I - Shape of the additional data merged into `workerData`.
42
- * @template R - Return type expected from the Worker module.
43
- * @param params - Parameters containing file path, progress info, emitter, and data.
44
- * @returns The result produced by the Worker module's default export.
45
- * @see {@link ./worker.ts} for the Worker-side entry point
46
- * @see {@link ./runner.ts!runner} for the direct (non-Worker) execution path
47
- */
48
- export function runInWorker(params) {
49
- const { filePath, num, total, emitter, initialData } = params;
50
- if (useWorker) {
51
- const worker = new Worker(workerPath, {
52
- workerData: {
53
- filePath,
54
- num,
55
- total,
56
- ...initialData,
57
- },
58
- });
59
- return new Promise((resolve, reject) => {
60
- const killWorker = async (sig) => {
61
- await worker.terminate();
62
- worker.unref();
63
- worker.removeAllListeners();
64
- process.removeListener('SIGABRT', killWorker);
65
- process.removeListener('SIGLOST', killWorker);
66
- process.removeListener('SIGQUIT', killWorker);
67
- process.removeListener('disconnect', killWorker);
68
- process.removeListener('exit', killWorker);
69
- process.removeListener('uncaughtException', killWorker);
70
- process.removeListener('uncaughtExceptionMonitor', killWorker);
71
- process.removeListener('unhandledRejection', killWorker);
72
- // eslint-disable-next-line no-console
73
- console.log(`Kill Worker cause: %O`, sig);
74
- reject(`SIG: ${sig}`);
75
- };
76
- // Changed from old issue
77
- // @see https://github.com/nodejs/node-v0.x-archive/issues/6339
78
- // process.once('SIGKILL', killWorker);
79
- // process.once('SIGSTOP', killWorker);
80
- process.once('SIGABRT', killWorker);
81
- process.once('SIGLOST', killWorker);
82
- process.once('SIGQUIT', killWorker);
83
- process.once('disconnect', killWorker);
84
- process.once('exit', killWorker);
85
- process.once('uncaughtException', killWorker);
86
- process.once('uncaughtExceptionMonitor', killWorker);
87
- process.once('unhandledRejection', killWorker);
88
- worker.once('error', killWorker);
89
- worker.once('messageerror', killWorker);
90
- worker.on('message', async (message) => {
91
- if (!message) {
92
- return;
93
- }
94
- if (message.type === 'url') {
95
- void emitter.emit('url', message.url);
96
- }
97
- if (message.type === 'finish') {
98
- await worker.terminate();
99
- worker.removeAllListeners();
100
- worker.unref();
101
- process.removeListener('SIGABRT', killWorker);
102
- process.removeListener('SIGLOST', killWorker);
103
- process.removeListener('SIGQUIT', killWorker);
104
- process.removeListener('disconnect', killWorker);
105
- process.removeListener('exit', killWorker);
106
- process.removeListener('uncaughtException', killWorker);
107
- process.removeListener('uncaughtExceptionMonitor', killWorker);
108
- process.removeListener('unhandledRejection', killWorker);
109
- if (message.error) {
110
- reject(new Error(message.error));
111
- }
112
- else {
113
- resolve(message.result);
114
- }
115
- }
116
- });
117
- });
118
- }
119
- return runner({
120
- filePath,
121
- num,
122
- total,
123
- ...initialData,
124
- }, emitter);
125
- }