@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 +39 -1
- package/lib/core.d.ts +0 -1
- package/lib/core.js +0 -1
- package/lib/nitpicker.d.ts +18 -15
- package/lib/nitpicker.js +113 -87
- package/lib/page-analysis-worker.d.ts +7 -5
- package/lib/page-analysis-worker.js +4 -3
- package/lib/types.d.ts +12 -1
- package/lib/url-event-bus.d.ts +7 -6
- package/lib/url-event-bus.js +7 -6
- package/lib/worker/runner.d.ts +2 -2
- package/lib/worker/runner.js +2 -2
- package/lib/worker/types.d.ts +3 -3
- package/lib/worker/worker-pool.d.ts +82 -0
- package/lib/worker/worker-pool.js +278 -0
- package/lib/worker/worker.d.ts +21 -14
- package/lib/worker/worker.js +81 -41
- package/package.json +4 -4
- package/lib/find-nitpicker-modules-dir.d.ts +0 -12
- package/lib/find-nitpicker-modules-dir.js +0 -23
- package/lib/hooks/actions.d.ts +0 -9
- package/lib/hooks/actions.js +0 -9
- package/lib/hooks/child-process.d.ts +0 -1
- package/lib/hooks/child-process.js +0 -34
- package/lib/hooks/index.d.ts +0 -1
- package/lib/hooks/index.js +0 -1
- package/lib/hooks/runner.d.ts +0 -10
- package/lib/hooks/runner.js +0 -32
- package/lib/index.d.ts +0 -5
- package/lib/index.js +0 -5
- package/lib/utils.d.ts +0 -36
- package/lib/utils.js +0 -43
- package/lib/worker/run-in-worker.d.ts +0 -51
- package/lib/worker/run-in-worker.js +0 -125
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
package/lib/core.js
CHANGED
package/lib/nitpicker.d.ts
CHANGED
|
@@ -12,15 +12,17 @@ export { definePlugin } from './hooks/define-plugin.js';
|
|
|
12
12
|
*
|
|
13
13
|
* ## Architecture decisions
|
|
14
14
|
*
|
|
15
|
-
* - **Worker
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
-
*
|
|
22
|
-
*
|
|
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)
|
|
61
|
-
*
|
|
62
|
-
* {@link
|
|
63
|
-
*
|
|
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`,
|
|
68
|
-
*
|
|
69
|
-
*
|
|
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 {
|
|
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
|
|
17
|
-
*
|
|
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
|
-
/**
|
|
22
|
-
|
|
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
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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
|
-
*
|
|
40
|
-
*
|
|
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)
|
|
98
|
-
*
|
|
99
|
-
* {@link
|
|
100
|
-
*
|
|
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`,
|
|
105
|
-
*
|
|
106
|
-
*
|
|
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
|
-
|
|
186
|
-
const
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
201
|
-
|
|
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
|
-
|
|
211
|
-
|
|
270
|
+
catch (error) {
|
|
271
|
+
pluginErrorCount++;
|
|
212
272
|
done++;
|
|
213
273
|
updateProgress();
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
15
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
15
|
-
*
|
|
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/
|
|
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. */
|
package/lib/url-event-bus.d.ts
CHANGED
|
@@ -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/
|
|
24
|
-
*
|
|
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
|
|
27
|
-
*
|
|
28
|
-
* @see {@link ./worker/worker.ts} for
|
|
29
|
-
* @see {@link ./worker/
|
|
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
|
}
|
package/lib/url-event-bus.js
CHANGED
|
@@ -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/
|
|
12
|
-
*
|
|
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
|
|
15
|
-
*
|
|
16
|
-
* @see {@link ./worker/worker.ts} for
|
|
17
|
-
* @see {@link ./worker/
|
|
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
|
}
|
package/lib/worker/runner.d.ts
CHANGED
|
@@ -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
|
|
16
|
-
*
|
|
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.
|
package/lib/worker/runner.js
CHANGED
|
@@ -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
|
|
14
|
-
*
|
|
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.
|
package/lib/worker/types.d.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Data payload
|
|
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
|
|
10
|
-
* @see {@link
|
|
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
|
+
}
|