@nitpicker/core 0.6.4 → 0.7.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
@@ -4,10 +4,48 @@
4
4
 
5
5
  ## 概要
6
6
 
7
- `.nitpicker` アーカイブ内の各ページに対して、analyze プラグインを並列実行するためのエンジンです。プラグインの検出・読み込み・Worker スレッドでの実行を担当します。
7
+ `.nitpicker` アーカイブ内の各ページに対して、analyze プラグインを並列実行するためのエンジンです。プラグインの検出・読み込み・Worker スレッドプールでの実行を担当します。
8
8
 
9
9
  このパッケージは [Nitpicker](../../README.md) モノレポの内部パッケージです。単体での利用は想定していません。
10
10
 
11
+ ## プラグイン作者向け
12
+
13
+ analyze プラグインは `definePlugin()` で定義します。
14
+
15
+ ```ts
16
+ import { definePlugin } from '@nitpicker/core';
17
+
18
+ type Options = {
19
+ keywords: string[];
20
+ };
21
+
22
+ export default definePlugin((options: Options) => {
23
+ return {
24
+ label: 'キーワード検索',
25
+ // 並列度を宣言できる。省略時は os.cpus().length
26
+ // 重いプラグイン(Chrome 起動など)は小さく設定する
27
+ concurrency: 4,
28
+ headers: {
29
+ found: 'Keywords Found',
30
+ },
31
+ async eachPage({ window }) {
32
+ const text = window.document.body.textContent ?? '';
33
+ const count = options.keywords.filter((k) => text.includes(k)).length;
34
+ return {
35
+ page: { found: { value: count } },
36
+ };
37
+ },
38
+ };
39
+ });
40
+ ```
41
+
42
+ ### `concurrency` の指針
43
+
44
+ - **省略**: `os.cpus().length`(CPU コア数)が使われる。軽量な DOM 解析のみのプラグインはこのままで良い
45
+ - **小さく設定すべきケース**: Chrome や Puppeteer などの重いプロセスを起動するプラグインは 2〜4 程度に絞る。例えば `@nitpicker/analyze-lighthouse` は Chrome 1 プロセス ≒ 300〜500MB を消費するため `concurrency: 2`
46
+
47
+ 詳細は [CONTRIBUTING.md](../../../CONTRIBUTING.md) と [ARCHITECTURE.md](../../../ARCHITECTURE.md) を参照。
48
+
11
49
  ## ライセンス
12
50
 
13
51
  Apache-2.0
package/lib/core.d.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  export { Nitpicker } from './nitpicker.js';
2
2
  export { definePlugin } from './hooks/define-plugin.js';
3
3
  export { readPluginLabels } from './read-plugin-labels.js';
4
- export { runInWorker } from './worker/run-in-worker.js';
5
4
  export * from './types.js';
package/lib/core.js CHANGED
@@ -1,5 +1,4 @@
1
1
  export { Nitpicker } from './nitpicker.js';
2
2
  export { definePlugin } from './hooks/define-plugin.js';
3
3
  export { readPluginLabels } from './read-plugin-labels.js';
4
- export { runInWorker } from './worker/run-in-worker.js';
5
4
  export * from './types.js';
@@ -12,15 +12,17 @@ export { definePlugin } from './hooks/define-plugin.js';
12
12
  *
13
13
  * ## Architecture decisions
14
14
  *
15
- * - **Worker threads for `eachPage`**: DOM-heavy analysis (JSDOM + axe-core,
16
- * markuplint, etc.) runs in isolated Worker threads so that a crashing plugin
17
- * cannot take down the main process and memory from JSDOM windows is reclaimed
18
- * when the Worker exits. See {@link ./worker/run-in-worker.ts!runInWorker}.
15
+ * - **Worker pool per plugin**: Each plugin gets its own long-lived
16
+ * {@link ./worker/worker-pool.ts!WorkerPool}, sized by either the plugin's
17
+ * declared `concurrency` or {@link DEFAULT_CONCURRENCY} (CPU core count).
18
+ * Workers are spawned once and reused across every page, so the V8 isolate
19
+ * / JSDOM module / plugin module boot cost is paid only `concurrency`
20
+ * times per plugin instead of once per page.
19
21
  *
20
- * - **Plugin-outer, page-inner loop**: Plugins are processed sequentially,
21
- * and for each plugin, pages are processed in parallel (limit: 50) using
22
- * a bounded Promise pool. This enables per-plugin progress tracking via
23
- * {@link https://www.npmjs.com/package/@d-zero/dealer | Lanes}.
22
+ * - **Plugin-outer, page-inner loop**: Plugins are processed sequentially.
23
+ * For each plugin, pages are submitted to that plugin's pool, which queues
24
+ * them and dispatches to idle workers. This enables per-plugin progress
25
+ * tracking via {@link https://www.npmjs.com/package/@d-zero/dealer | Lanes}.
24
26
  *
25
27
  * - **Cache layer**: Results are cached per `pluginName:url` using
26
28
  * `@d-zero/shared/cache` so that re-running analysis after a partial failure
@@ -57,16 +59,17 @@ export declare class Nitpicker extends EventEmitter<NitpickerEvent> {
57
59
  * Runs all configured analyze plugins (or a filtered subset) against
58
60
  * every page in the archive.
59
61
  *
60
- * Plugins are processed **sequentially** (one at a time), while pages
61
- * within each plugin are processed in **parallel** (bounded to
62
- * {@link CONCURRENCY_LIMIT}). This architecture enables per-plugin
63
- * progress tracking via Lanes.
62
+ * Plugins are processed **sequentially** (one at a time). For each plugin,
63
+ * pages are processed in **parallel** through a dedicated long-lived
64
+ * {@link ./worker/worker-pool.ts!WorkerPool} sized either by the plugin's
65
+ * declared concurrency or by {@link DEFAULT_CONCURRENCY}. This architecture
66
+ * enables per-plugin progress tracking via Lanes.
64
67
  *
65
68
  * The analysis proceeds in two phases:
66
69
  *
67
- * 1. **`eachPage` phase** - For each plugin with `eachPage`, spawns
68
- * Worker threads via a bounded Promise pool to analyze all pages.
69
- * Progress is displayed via Lanes if provided in options.
70
+ * 1. **`eachPage` phase** - For each plugin with `eachPage`, dispatches
71
+ * page analysis tasks to the plugin's worker pool. Progress is
72
+ * displayed via Lanes if provided in options.
70
73
  *
71
74
  * 2. **`eachUrl` phase** - For all plugins with `eachUrl`, runs
72
75
  * sequentially in the main thread. These are lightweight checks
package/lib/nitpicker.js CHANGED
@@ -8,18 +8,27 @@ import { importModules } from './import-modules.js';
8
8
  import { loadPluginSettings } from './load-plugin-settings.js';
9
9
  import { Table } from './table.js';
10
10
  import { UrlEventBus } from './url-event-bus.js';
11
- import { runInWorker } from './worker/run-in-worker.js';
11
+ import { WorkerPool } from './worker/worker-pool.js';
12
12
  export { definePlugin } from './hooks/define-plugin.js';
13
13
  const __filename = new URL(import.meta.url).pathname;
14
14
  const __dirname = path.dirname(__filename);
15
15
  /**
16
- * Resolved path to the compiled Worker entry point.
17
- * This file is loaded by `runInWorker()` in a `new Worker(...)` call.
16
+ * Resolved path to the compiled per-page analysis module.
17
+ * Each task posted to the pool carries this path so the worker can
18
+ * dynamically import it (cached after first import).
18
19
  * @see {@link ./page-analysis-worker.ts} for the source
19
20
  */
20
21
  const pageAnalysisWorkerPath = path.resolve(__dirname, 'page-analysis-worker.js');
21
- /** Maximum number of concurrent Worker threads per plugin. */
22
- const CONCURRENCY_LIMIT = 50;
22
+ /**
23
+ * Resolved path to the compiled long-lived worker thread script.
24
+ * Used as the `workerPath` when constructing {@link WorkerPool} instances.
25
+ */
26
+ const workerPath = path.resolve(__dirname, 'worker/worker.js');
27
+ /**
28
+ * Default concurrency used when a plugin does not declare its own value.
29
+ * Tracks the number of CPU cores so that worker pools scale with the host.
30
+ */
31
+ const DEFAULT_CONCURRENCY = Math.max(1, os.cpus().length);
23
32
  /**
24
33
  * Core orchestrator for running analyze plugins against a `.nitpicker` archive.
25
34
  *
@@ -30,15 +39,17 @@ const CONCURRENCY_LIMIT = 50;
30
39
  *
31
40
  * ## Architecture decisions
32
41
  *
33
- * - **Worker threads for `eachPage`**: DOM-heavy analysis (JSDOM + axe-core,
34
- * markuplint, etc.) runs in isolated Worker threads so that a crashing plugin
35
- * cannot take down the main process and memory from JSDOM windows is reclaimed
36
- * when the Worker exits. See {@link ./worker/run-in-worker.ts!runInWorker}.
42
+ * - **Worker pool per plugin**: Each plugin gets its own long-lived
43
+ * {@link ./worker/worker-pool.ts!WorkerPool}, sized by either the plugin's
44
+ * declared `concurrency` or {@link DEFAULT_CONCURRENCY} (CPU core count).
45
+ * Workers are spawned once and reused across every page, so the V8 isolate
46
+ * / JSDOM module / plugin module boot cost is paid only `concurrency`
47
+ * times per plugin instead of once per page.
37
48
  *
38
- * - **Plugin-outer, page-inner loop**: Plugins are processed sequentially,
39
- * and for each plugin, pages are processed in parallel (limit: 50) using
40
- * a bounded Promise pool. This enables per-plugin progress tracking via
41
- * {@link https://www.npmjs.com/package/@d-zero/dealer | Lanes}.
49
+ * - **Plugin-outer, page-inner loop**: Plugins are processed sequentially.
50
+ * For each plugin, pages are submitted to that plugin's pool, which queues
51
+ * them and dispatches to idle workers. This enables per-plugin progress
52
+ * tracking via {@link https://www.npmjs.com/package/@d-zero/dealer | Lanes}.
42
53
  *
43
54
  * - **Cache layer**: Results are cached per `pluginName:url` using
44
55
  * `@d-zero/shared/cache` so that re-running analysis after a partial failure
@@ -94,16 +105,17 @@ export class Nitpicker extends EventEmitter {
94
105
  * Runs all configured analyze plugins (or a filtered subset) against
95
106
  * every page in the archive.
96
107
  *
97
- * Plugins are processed **sequentially** (one at a time), while pages
98
- * within each plugin are processed in **parallel** (bounded to
99
- * {@link CONCURRENCY_LIMIT}). This architecture enables per-plugin
100
- * progress tracking via Lanes.
108
+ * Plugins are processed **sequentially** (one at a time). For each plugin,
109
+ * pages are processed in **parallel** through a dedicated long-lived
110
+ * {@link ./worker/worker-pool.ts!WorkerPool} sized either by the plugin's
111
+ * declared concurrency or by {@link DEFAULT_CONCURRENCY}. This architecture
112
+ * enables per-plugin progress tracking via Lanes.
101
113
  *
102
114
  * The analysis proceeds in two phases:
103
115
  *
104
- * 1. **`eachPage` phase** - For each plugin with `eachPage`, spawns
105
- * Worker threads via a bounded Promise pool to analyze all pages.
106
- * Progress is displayed via Lanes if provided in options.
116
+ * 1. **`eachPage` phase** - For each plugin with `eachPage`, dispatches
117
+ * page analysis tasks to the plugin's worker pool. Progress is
118
+ * displayed via Lanes if provided in options.
107
119
  *
108
120
  * 2. **`eachUrl` phase** - For all plugins with `eachUrl`, runs
109
121
  * sequentially in the main thread. These are lightweight checks
@@ -170,7 +182,7 @@ export class Nitpicker extends EventEmitter {
170
182
  await this.archive.getPagesWithRefs(100_000, async (pages) => {
171
183
  const urlEmitter = new UrlEventBus();
172
184
  // Phase 1: eachPage plugins (sequentially, pages in parallel)
173
- for (const [pluginSeqIndex, { plugin }] of eachPagePlugins.entries()) {
185
+ for (const [pluginSeqIndex, { plugin, modIndex }] of eachPagePlugins.entries()) {
174
186
  const laneId = pluginLaneIds.get(plugin.name);
175
187
  const label = pluginLabels.get(plugin.name) ?? plugin.name;
176
188
  let done = 0;
@@ -182,87 +194,101 @@ export class Nitpicker extends EventEmitter {
182
194
  lanes?.update(laneId, `${label}: ${done}/${pages.length} (${pluginPercent}%)%braille%`);
183
195
  };
184
196
  updateProgress();
185
- // Bounded Promise pool (replaces deal())
186
- const executing = new Set();
197
+ const pluginConcurrency = Math.max(1, analyzeMods[modIndex]?.concurrency ?? DEFAULT_CONCURRENCY);
198
+ const pool = new WorkerPool({
199
+ size: pluginConcurrency,
200
+ workerPath,
201
+ });
187
202
  let pluginPageDataCount = 0;
188
203
  let pluginErrorCount = 0;
189
- for (const [pageIndex, page] of pages.entries()) {
190
- const task = (async () => {
191
- try {
192
- const cacheKey = `${plugin.name}:${page.url.href}`;
193
- const cached = await cache.load(cacheKey);
194
- if (cached) {
195
- const { pages: cachedPages, violations } = cached;
196
- if (cachedPages) {
197
- table.addData(cachedPages);
198
- pluginPageDataCount += Object.keys(cachedPages).length;
204
+ try {
205
+ // Bound the number of in-flight IIFEs so HTML strings loaded
206
+ // via page.getHtml() do not pile up on the main thread while
207
+ // they wait for a worker. A small buffer above pool size keeps
208
+ // the pool continuously fed without staging too much HTML.
209
+ const inFlight = new Set();
210
+ const highWater = pluginConcurrency * 2;
211
+ for (const [pageIndex, page] of pages.entries()) {
212
+ const task = (async () => {
213
+ try {
214
+ const cacheKey = `${plugin.name}:${page.url.href}`;
215
+ const cached = await cache.load(cacheKey);
216
+ if (cached) {
217
+ const { pages: cachedPages, violations } = cached;
218
+ if (cachedPages) {
219
+ table.addData(cachedPages);
220
+ pluginPageDataCount += Object.keys(cachedPages).length;
221
+ }
222
+ if (violations) {
223
+ for (const v of violations) {
224
+ allViolations.push(v);
225
+ }
226
+ pluginViolationCount += violations.length;
227
+ }
228
+ done++;
229
+ updateProgress();
230
+ return;
199
231
  }
200
- if (violations) {
201
- for (const v of violations) {
232
+ const html = await page.getHtml();
233
+ if (!html) {
234
+ done++;
235
+ updateProgress();
236
+ return;
237
+ }
238
+ const report = await pool.run({
239
+ filePath: pageAnalysisWorkerPath,
240
+ num: pageIndex,
241
+ total: pages.length,
242
+ emitter: urlEmitter,
243
+ initialData: {
244
+ plugin,
245
+ pages: {
246
+ html,
247
+ url: page.url,
248
+ },
249
+ },
250
+ });
251
+ const tablePages = {};
252
+ if (report?.page) {
253
+ tablePages[page.url.href] = report.page;
254
+ table.addDataToUrl(page.url, report.page);
255
+ pluginPageDataCount++;
256
+ }
257
+ await cache.store(cacheKey, {
258
+ pages: Object.keys(tablePages).length > 0 ? tablePages : undefined,
259
+ violations: report?.violations,
260
+ });
261
+ if (report?.violations) {
262
+ for (const v of report.violations) {
202
263
  allViolations.push(v);
203
264
  }
204
- pluginViolationCount += violations.length;
265
+ pluginViolationCount += report.violations.length;
205
266
  }
206
267
  done++;
207
268
  updateProgress();
208
- return;
209
269
  }
210
- const html = await page.getHtml();
211
- if (!html) {
270
+ catch (error) {
271
+ pluginErrorCount++;
212
272
  done++;
213
273
  updateProgress();
214
- return;
215
- }
216
- const report = await runInWorker({
217
- filePath: pageAnalysisWorkerPath,
218
- num: pageIndex,
219
- total: pages.length,
220
- emitter: urlEmitter,
221
- initialData: {
222
- plugin,
223
- pages: {
224
- html,
225
- url: page.url,
226
- },
227
- },
228
- });
229
- const tablePages = {};
230
- if (report?.page) {
231
- tablePages[page.url.href] = report.page;
232
- table.addDataToUrl(page.url, report.page);
233
- pluginPageDataCount++;
274
+ const message = error instanceof Error ? error.message : String(error);
275
+ await this.emit('error', {
276
+ message: `[${plugin.name}] Failed to analyze ${page.url.href}: ${message}`,
277
+ error: error instanceof Error ? error : null,
278
+ });
234
279
  }
235
- await cache.store(cacheKey, {
236
- pages: Object.keys(tablePages).length > 0 ? tablePages : undefined,
237
- violations: report?.violations,
238
- });
239
- if (report?.violations) {
240
- for (const v of report.violations) {
241
- allViolations.push(v);
242
- }
243
- pluginViolationCount += report.violations.length;
244
- }
245
- done++;
246
- updateProgress();
247
- }
248
- catch (error) {
249
- pluginErrorCount++;
250
- done++;
251
- updateProgress();
252
- const message = error instanceof Error ? error.message : String(error);
253
- await this.emit('error', {
254
- message: `[${plugin.name}] Failed to analyze ${page.url.href}: ${message}`,
255
- error: error instanceof Error ? error : null,
256
- });
280
+ })();
281
+ inFlight.add(task);
282
+ task.then(() => inFlight.delete(task), () => inFlight.delete(task));
283
+ if (inFlight.size >= highWater) {
284
+ await Promise.race(inFlight);
257
285
  }
258
- })();
259
- executing.add(task);
260
- task.then(() => executing.delete(task), () => executing.delete(task));
261
- if (executing.size >= CONCURRENCY_LIMIT) {
262
- await Promise.race(executing);
263
286
  }
287
+ await Promise.all(inFlight);
288
+ }
289
+ finally {
290
+ await pool.terminate();
264
291
  }
265
- await Promise.all(executing);
266
292
  // Warn when plugin produced no page data at all
267
293
  if (pluginPageDataCount === 0 && pages.length > 0) {
268
294
  const errorDetail = pluginErrorCount > 0 ? ` (${pluginErrorCount} errors occurred)` : '';
@@ -2,17 +2,18 @@
2
2
  * Worker thread module for per-page single-plugin execution.
3
3
  *
4
4
  * This is the default export loaded by {@link ./worker/runner.ts!runner}
5
- * when analyzing a single page with a single plugin. It:
5
+ * for each task dispatched by the {@link ./worker/worker-pool.ts!WorkerPool}. It:
6
6
  *
7
7
  * 1. Dynamically imports the configured plugin via {@link importModules}
8
+ * (cached after the first task on the same worker)
8
9
  * 2. If the plugin implements `eachPage`:
9
10
  * - Creates a JSDOM instance from the raw HTML
10
11
  * - Calls the plugin's `eachPage` hook with the DOM window
11
12
  * - Closes the JSDOM window to free memory
12
13
  * 3. Returns the plugin result as {@link ReportPage} or `null`
13
14
  *
14
- * Each Worker invocation handles exactly one plugin, so the calling code
15
- * can track per-plugin progress independently.
15
+ * One invocation processes exactly one plugin × one page, even though
16
+ * the worker hosting this module handles many tasks over its lifetime.
16
17
  * @module
17
18
  */
18
19
  import type { Plugin, ReportPage } from './types.js';
@@ -20,10 +21,11 @@ import type { UrlEventBus } from './url-event-bus.js';
20
21
  import type { ExURL as URL } from '@d-zero/shared/parse-url';
21
22
  /**
22
23
  * Initial data payload for the page analysis worker.
23
- * Passed via `workerData` and consumed by the default export.
24
+ * Wrapped inside the `task` message dispatched by the {@link ./worker/worker-pool.ts!WorkerPool}
25
+ * and consumed by this module's default export.
24
26
  *
25
27
  * Uses `type` instead of `interface` because this type must satisfy the
26
- * `Record<string, unknown>` constraint required by the Worker data serialization.
28
+ * `Record<string, unknown>` constraint required by the Worker message serialization.
27
29
  */
28
30
  export type PageAnalysisWorkerData = {
29
31
  /** Single analyze plugin to execute against the page. */
@@ -2,17 +2,18 @@
2
2
  * Worker thread module for per-page single-plugin execution.
3
3
  *
4
4
  * This is the default export loaded by {@link ./worker/runner.ts!runner}
5
- * when analyzing a single page with a single plugin. It:
5
+ * for each task dispatched by the {@link ./worker/worker-pool.ts!WorkerPool}. It:
6
6
  *
7
7
  * 1. Dynamically imports the configured plugin via {@link importModules}
8
+ * (cached after the first task on the same worker)
8
9
  * 2. If the plugin implements `eachPage`:
9
10
  * - Creates a JSDOM instance from the raw HTML
10
11
  * - Calls the plugin's `eachPage` hook with the DOM window
11
12
  * - Closes the JSDOM window to free memory
12
13
  * 3. Returns the plugin result as {@link ReportPage} or `null`
13
14
  *
14
- * Each Worker invocation handles exactly one plugin, so the calling code
15
- * can track per-plugin progress independently.
15
+ * One invocation processes exactly one plugin × one page, even though
16
+ * the worker hosting this module handles many tasks over its lifetime.
16
17
  * @module
17
18
  */
18
19
  import { JSDOM } from 'jsdom';
package/lib/types.d.ts CHANGED
@@ -119,6 +119,17 @@ export interface AnalyzePlugin<T extends string = string> {
119
119
  * Shown instead of the raw package name (e.g. `"axe: アクセシビリティチェック"`).
120
120
  */
121
121
  label?: string;
122
+ /**
123
+ * Maximum number of pages this plugin processes in parallel.
124
+ *
125
+ * Each plugin is allocated its own {@link ../worker/worker-pool.ts!WorkerPool}
126
+ * sized to this value. Heavy plugins (e.g. Lighthouse, which spawns a
127
+ * full Chrome instance per page) should set a small number; lightweight
128
+ * DOM-only plugins can leave it unset.
129
+ *
130
+ * When omitted, the orchestrator falls back to `os.cpus().length`.
131
+ */
132
+ concurrency?: number;
122
133
  /**
123
134
  * Column header definitions contributed by this plugin.
124
135
  * Keys are column identifiers (`T`), values are human-readable labels
@@ -188,7 +199,7 @@ export interface ReportPage<T extends string> {
188
199
  * ran `eachPage` for a single page.
189
200
  * @template T - Column key union.
190
201
  * @see {@link ./page-analysis-worker.ts} for the Worker entry point that produces this
191
- * @see {@link ./worker/run-in-worker.ts!runInWorker} for the main-thread consumer
202
+ * @see {@link ./worker/worker-pool.ts!WorkerPool} for the main-thread consumer
192
203
  */
193
204
  export interface ReportPages<T extends string> {
194
205
  /** Per-URL table data from all plugins. */
@@ -20,13 +20,14 @@ export interface UrlEventBusEvent {
20
20
  * UrlEventBus. The Worker thread entry point ({@link ./worker/worker.ts})
21
21
  * listens for these and forwards them to the main thread via `parentPort.postMessage`.
22
22
  *
23
- * - **In the main thread**: {@link ./worker/run-in-worker.ts!runInWorker} creates its own
24
- * UrlEventBus and re-emits `'url'` messages received from the Worker.
23
+ * - **In the main thread**: {@link ./worker/worker-pool.ts!WorkerPool} routes
24
+ * `'url'` messages received from workers to the per-task UrlEventBus
25
+ * supplied by the caller.
25
26
  *
26
- * This indirection allows the same plugin code to work both in Worker threads
27
- * and in direct execution mode (when `useWorker` is `false`).
28
- * @see {@link ./worker/worker.ts} for Worker-side forwarding
29
- * @see {@link ./worker/run-in-worker.ts!runInWorker} for main-thread re-emission
27
+ * This indirection lets plugin code work the same way regardless of which
28
+ * worker in the pool is currently processing a given page.
29
+ * @see {@link ./worker/worker.ts} for worker-side forwarding
30
+ * @see {@link ./worker/worker-pool.ts!WorkerPool} for main-thread routing
30
31
  */
31
32
  export declare class UrlEventBus extends EventEmitter<UrlEventBusEvent> {
32
33
  }
@@ -8,13 +8,14 @@ import { TypedAwaitEventEmitter as EventEmitter } from '@d-zero/shared/typed-awa
8
8
  * UrlEventBus. The Worker thread entry point ({@link ./worker/worker.ts})
9
9
  * listens for these and forwards them to the main thread via `parentPort.postMessage`.
10
10
  *
11
- * - **In the main thread**: {@link ./worker/run-in-worker.ts!runInWorker} creates its own
12
- * UrlEventBus and re-emits `'url'` messages received from the Worker.
11
+ * - **In the main thread**: {@link ./worker/worker-pool.ts!WorkerPool} routes
12
+ * `'url'` messages received from workers to the per-task UrlEventBus
13
+ * supplied by the caller.
13
14
  *
14
- * This indirection allows the same plugin code to work both in Worker threads
15
- * and in direct execution mode (when `useWorker` is `false`).
16
- * @see {@link ./worker/worker.ts} for Worker-side forwarding
17
- * @see {@link ./worker/run-in-worker.ts!runInWorker} for main-thread re-emission
15
+ * This indirection lets plugin code work the same way regardless of which
16
+ * worker in the pool is currently processing a given page.
17
+ * @see {@link ./worker/worker.ts} for worker-side forwarding
18
+ * @see {@link ./worker/worker-pool.ts!WorkerPool} for main-thread routing
18
19
  */
19
20
  export class UrlEventBus extends EventEmitter {
20
21
  }
@@ -12,8 +12,8 @@ import type { UrlEventBus } from '../url-event-bus.js';
12
12
  * The `filePath` field is deleted from `data` before passing it to the
13
13
  * module function, so the module only receives its own domain-specific data.
14
14
  *
15
- * This function is called both from the Worker thread ({@link ./worker.ts})
16
- * and as a direct fallback when `useWorker` is `false` in {@link ./run-in-worker.ts}.
15
+ * This function is called from the Worker thread ({@link ./worker.ts}) for
16
+ * every task delivered by the {@link ./worker-pool.ts!WorkerPool}.
17
17
  * @template I - Shape of the caller's initial data (minus the worker infrastructure fields).
18
18
  * @template R - Return type of the plugin module's default export.
19
19
  * @param data - Combined worker data containing the module path and plugin-specific payload.
@@ -10,8 +10,8 @@
10
10
  * The `filePath` field is deleted from `data` before passing it to the
11
11
  * module function, so the module only receives its own domain-specific data.
12
12
  *
13
- * This function is called both from the Worker thread ({@link ./worker.ts})
14
- * and as a direct fallback when `useWorker` is `false` in {@link ./run-in-worker.ts}.
13
+ * This function is called from the Worker thread ({@link ./worker.ts}) for
14
+ * every task delivered by the {@link ./worker-pool.ts!WorkerPool}.
15
15
  * @template I - Shape of the caller's initial data (minus the worker infrastructure fields).
16
16
  * @template R - Return type of the plugin module's default export.
17
17
  * @param data - Combined worker data containing the module path and plugin-specific payload.
@@ -1,13 +1,13 @@
1
1
  /**
2
- * Data payload passed to a Worker thread via `workerData`.
2
+ * Data payload sent inside a `task` message from {@link ./worker-pool.ts!WorkerPool} to a worker.
3
3
  *
4
4
  * This type merges the worker infrastructure fields (`filePath`, `num`, `total`)
5
5
  * with the caller-supplied initial data (`I`). The `filePath` points to the
6
6
  * module whose default export will be invoked by the {@link ../runner.ts!runner}.
7
7
  * @template I - Shape of the additional data the caller provides
8
8
  * (e.g. {@link ../../page-analysis-worker.ts!PageAnalysisWorkerData}).
9
- * @see {@link ../run-in-worker.ts!runInWorker} for where this payload is constructed
10
- * @see {@link ../runner.ts!runner} for where it is consumed
9
+ * @see {@link ./worker-pool.ts!WorkerPool.run} for where this payload is constructed
10
+ * @see {@link ./runner.ts!runner} for where it is consumed
11
11
  */
12
12
  export type WorkerData<I extends Record<string, unknown>> = {
13
13
  /**
@@ -0,0 +1,82 @@
1
+ import type { UrlEventBus } from '../url-event-bus.js';
2
+ /**
3
+ * Per-task payload sent into the pool's `run()` method.
4
+ * @template I - Shape of the per-task data merged into the worker message.
5
+ */
6
+ export interface PoolTaskParams<I extends Record<string, unknown>> {
7
+ /** Absolute path to the module to dynamically import inside 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 (for progress display). */
12
+ readonly total: number;
13
+ /** Event bus to receive `'url'` events forwarded from the worker. */
14
+ readonly emitter: UrlEventBus;
15
+ /** Plugin-specific data delivered to the dynamically imported module. */
16
+ readonly initialData: I;
17
+ }
18
+ /** Constructor options for {@link WorkerPool}. */
19
+ export interface WorkerPoolOptions {
20
+ /** Number of long-lived worker threads to maintain. Must be >= 1. */
21
+ readonly size: number;
22
+ /** Absolute path to the worker entry script (compiled `worker.js`). */
23
+ readonly workerPath: string;
24
+ }
25
+ /**
26
+ * Long-lived pool of Worker threads that share execution across many tasks.
27
+ *
28
+ * Replaces the previous "one worker per page" model where every analyzed page
29
+ * paid the cost of spawning a new V8 isolate, importing JSDOM, importing the
30
+ * plugin module, and tearing it all down. With the pool, each worker is
31
+ * created once at the start of an analyze run and processes tasks via
32
+ * message passing until the pool is terminated.
33
+ *
34
+ * ## Why this matters
35
+ *
36
+ * On a 750-page archive with `CONCURRENCY_LIMIT = 50`, the old model spawned
37
+ * 50 workers nearly simultaneously and repeated this every time a worker
38
+ * finished, producing recurring "boot waves" that drove peak memory above
39
+ * 20GB. The pool model performs N initial boots and then reuses those V8
40
+ * isolates for the rest of the run, eliminating the recurring cost entirely.
41
+ *
42
+ * ## Message protocol (Pool ↔ Worker)
43
+ *
44
+ * | Direction | `type` | Payload |
45
+ * |------------------|---------------|----------------------------------|
46
+ * | Pool → Worker | `'task'` | `{ taskId, data }` |
47
+ * | Pool → Worker | `'shutdown'` | (none) |
48
+ * | Worker → Pool | `'url'` | `{ taskId, url }` (forwarded) |
49
+ * | Worker → Pool | `'result'` | `{ taskId, result, error? }` |
50
+ *
51
+ * Each task carries a unique numeric `taskId` so that responses and URL
52
+ * notifications from a worker can be routed back to the correct caller.
53
+ */
54
+ export declare class WorkerPool {
55
+ #private;
56
+ /**
57
+ * Constructs a pool with the given size and worker entry script.
58
+ * @param options - Pool size and worker entry script path.
59
+ */
60
+ constructor(options: WorkerPoolOptions);
61
+ /**
62
+ * Submits a task to the pool. Resolves with the worker's `result` payload
63
+ * once a worker has finished processing it. The task may be queued if all
64
+ * workers are currently busy.
65
+ * @template I - Shape of the per-task `initialData` payload.
66
+ * @template R - Expected result type returned by the worker module.
67
+ * @param params - Task payload (file path, batch indices, emitter, data).
68
+ * @returns The worker's result.
69
+ */
70
+ run<I extends Record<string, unknown>, R>(params: PoolTaskParams<I>): Promise<R>;
71
+ /**
72
+ * Rejects every queued or in-flight task, sends each worker a `shutdown`
73
+ * message, and awaits its exit. After this call returns, no further
74
+ * submissions are accepted.
75
+ *
76
+ * Pending tasks are rejected synchronously so callers waiting on
77
+ * `pool.run()` Promises do not hang. Workers handle the shutdown message
78
+ * by exiting cleanly; if that fails, {@link #shutdownSlot} forces
79
+ * termination after a 5 second timeout.
80
+ */
81
+ terminate(): Promise<void>;
82
+ }