poveste 0.15.0 → 0.16.1
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 +1 -1
- package/bin.mjs +19 -1
- package/dist/node/build-counts.d.ts +11 -1
- package/dist/node/build-counts.js +24 -3
- package/dist/node/build.js +2 -2
- package/dist/node/collect/define-globals.js +7 -22
- package/dist/node/collect/error.d.ts +2 -0
- package/dist/node/collect/error.js +70 -0
- package/dist/node/collect/index.js +15 -43
- package/dist/node/collect/module-server.d.ts +4 -0
- package/dist/node/collect/module-server.js +3 -3
- package/dist/node/collect/pool.d.ts +14 -0
- package/dist/node/collect/pool.js +106 -0
- package/dist/node/collect/rpc.d.ts +7 -3
- package/dist/node/collect/rpc.js +26 -11
- package/dist/node/collect/runner.d.ts +32 -2
- package/dist/node/collect/runner.js +59 -16
- package/dist/node/collect/task.d.ts +14 -0
- package/dist/node/collect/task.js +81 -0
- package/dist/node/collect/types.d.ts +10 -0
- package/dist/node/collect/types.js +1 -0
- package/dist/node/collect/worker.d.ts +1 -14
- package/dist/node/collect/worker.js +28 -17
- package/dist/node/colors.js +8 -1
- package/dist/node/dom/env.js +24 -0
- package/dist/node/load.js +1 -1
- package/dist/node/plugin.d.ts +1 -1
- package/dist/node/virtual/resolved-theme.js +10 -4
- package/dist/node/vite.js +12 -13
- package/node-floor.mjs +87 -0
- package/package.json +15 -14
- package/dist/node/__tests__/dev-cleanup/poveste.config.d.ts +0 -7
- package/dist/node/__tests__/dev-cleanup/poveste.config.js +0 -15
package/README.md
CHANGED
|
@@ -47,7 +47,7 @@ Run `npx poveste --help` for the full list of options.
|
|
|
47
47
|
|
|
48
48
|
## Requirements
|
|
49
49
|
|
|
50
|
-
Node
|
|
50
|
+
Node `>=22.22.2` and Vite `^8.0.0`. See
|
|
51
51
|
[supported versions](https://poveste.dev/guide/getting-started#supported-versions) for
|
|
52
52
|
what proves each range.
|
|
53
53
|
|
package/bin.mjs
CHANGED
|
@@ -1,4 +1,22 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict'
|
|
3
3
|
|
|
4
|
-
import '
|
|
4
|
+
import process from 'node:process'
|
|
5
|
+
import { supportedNode } from './node-floor.mjs'
|
|
6
|
+
|
|
7
|
+
// Before the CLI is imported, not inside it: on a Node below the floor the import
|
|
8
|
+
// itself is one of the things that can fail, and it fails naming neither Node nor
|
|
9
|
+
// Poveste — story collection loads jsdom, which answers `webidl.util
|
|
10
|
+
// .markAsUncloneable is not a function` on Node 20 (#913).
|
|
11
|
+
//
|
|
12
|
+
// Imported dynamically for the same reason. A static import is parsed with this
|
|
13
|
+
// module, before a line of it runs, so syntax `dist/` may carry would win the race
|
|
14
|
+
// against the message explaining it.
|
|
15
|
+
if (supportedNode()) {
|
|
16
|
+
import('./dist/node/bin.js').catch((error) => {
|
|
17
|
+
// `exitCode`, not `exit`: the error is the last thing written, and forcing the
|
|
18
|
+
// process out on the same tick truncates it on a pipe.
|
|
19
|
+
process.exitCode = 1
|
|
20
|
+
console.error(error)
|
|
21
|
+
})
|
|
22
|
+
}
|
|
@@ -53,4 +53,14 @@ export declare function builtSomething(counts: BuildCounts): boolean;
|
|
|
53
53
|
* Documents count as content: a book of `.story.md` pages with no variants has
|
|
54
54
|
* something in it, and telling its author nothing was found would be wrong.
|
|
55
55
|
*/
|
|
56
|
-
export declare function buildSummary(counts: BuildCounts, seconds: number, storyMatch: string[]): string[];
|
|
56
|
+
export declare function buildSummary(counts: BuildCounts, seconds: number, storyMatch: string[], guideUrl?: string): string[];
|
|
57
|
+
/**
|
|
58
|
+
* The page that shows a story in the framework this book uses.
|
|
59
|
+
*
|
|
60
|
+
* The message used to send everyone to `/guide/`, which explains what a story is
|
|
61
|
+
* for a second time rather than showing one — two hops of misdirection for a
|
|
62
|
+
* reader who has just been told they have no stories (#905). Nuxt and Quasar
|
|
63
|
+
* write Vue stories, SvelteKit writes Svelte ones, so the plugin names map onto
|
|
64
|
+
* the two pages that exist.
|
|
65
|
+
*/
|
|
66
|
+
export declare function storyGuideUrl(pluginNames: string[]): string;
|
|
@@ -60,7 +60,7 @@ export function builtSomething(counts) {
|
|
|
60
60
|
* Documents count as content: a book of `.story.md` pages with no variants has
|
|
61
61
|
* something in it, and telling its author nothing was found would be wrong.
|
|
62
62
|
*/
|
|
63
|
-
export function buildSummary(counts, seconds, storyMatch) {
|
|
63
|
+
export function buildSummary(counts, seconds, storyMatch, guideUrl = 'https://poveste.dev/guide/getting-started') {
|
|
64
64
|
const took = `in ${seconds}s`;
|
|
65
65
|
const globs = storyMatch.join(', ');
|
|
66
66
|
if (!builtSomething(counts)) {
|
|
@@ -68,12 +68,12 @@ export function buildSummary(counts, seconds, storyMatch) {
|
|
|
68
68
|
const files = `${counts.empty.length} file${counts.empty.length === 1 ? '' : 's'}`;
|
|
69
69
|
return [
|
|
70
70
|
`Built 0 stories ${took} — ${files} matched ${globs} and produced no story`,
|
|
71
|
-
|
|
71
|
+
`A story file needs a <Story> in it: ${guideUrl}`,
|
|
72
72
|
];
|
|
73
73
|
}
|
|
74
74
|
return [
|
|
75
75
|
`Built 0 stories ${took} — nothing matched ${globs}`,
|
|
76
|
-
|
|
76
|
+
`Write a story file and run this again: ${guideUrl}`,
|
|
77
77
|
];
|
|
78
78
|
}
|
|
79
79
|
const stories = `${counts.stories} stor${counts.stories === 1 ? 'y' : 'ies'}`;
|
|
@@ -81,3 +81,24 @@ export function buildSummary(counts, seconds, storyMatch) {
|
|
|
81
81
|
const docs = counts.docs ? ` and ${counts.docs} document${counts.docs === 1 ? '' : 's'}` : '';
|
|
82
82
|
return [`✅ Built ${stories} (${variants})${docs} ${took}`];
|
|
83
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* The page that shows a story in the framework this book uses.
|
|
86
|
+
*
|
|
87
|
+
* The message used to send everyone to `/guide/`, which explains what a story is
|
|
88
|
+
* for a second time rather than showing one — two hops of misdirection for a
|
|
89
|
+
* reader who has just been told they have no stories (#905). Nuxt and Quasar
|
|
90
|
+
* write Vue stories, SvelteKit writes Svelte ones, so the plugin names map onto
|
|
91
|
+
* the two pages that exist.
|
|
92
|
+
*/
|
|
93
|
+
export function storyGuideUrl(pluginNames) {
|
|
94
|
+
const vue = ['@poveste/plugin-vue', '@poveste/plugin-nuxt', '@poveste/plugin-quasar'];
|
|
95
|
+
const svelte = ['@poveste/plugin-svelte'];
|
|
96
|
+
if (pluginNames.some(name => svelte.includes(name))) {
|
|
97
|
+
return 'https://poveste.dev/guide/svelte/stories';
|
|
98
|
+
}
|
|
99
|
+
if (pluginNames.some(name => vue.includes(name))) {
|
|
100
|
+
return 'https://poveste.dev/guide/vue/stories';
|
|
101
|
+
}
|
|
102
|
+
// A book with neither, such as vanilla: the framework list is the next step.
|
|
103
|
+
return 'https://poveste.dev/guide/getting-started';
|
|
104
|
+
}
|
package/dist/node/build.js
CHANGED
|
@@ -5,7 +5,7 @@ import { join } from 'pathe';
|
|
|
5
5
|
import pc from 'picocolors';
|
|
6
6
|
import { createServer as createViteServer, mergeConfig as mergeViteConfig, build as viteBuild, } from 'vite';
|
|
7
7
|
import { APP_PATH } from './alias.js';
|
|
8
|
-
import { buildCounts, buildSummary, builtSomething } from './build-counts.js';
|
|
8
|
+
import { buildCounts, buildSummary, builtSomething, storyGuideUrl } from './build-counts.js';
|
|
9
9
|
import { getSerializedStoryData } from './build-serialize.js';
|
|
10
10
|
import { useCollectStories } from './collect/index.js';
|
|
11
11
|
import { useModuleLoader } from './load.js';
|
|
@@ -286,7 +286,7 @@ export async function build(ctx) {
|
|
|
286
286
|
// Green only when something was built. A tick over nothing is the claim
|
|
287
287
|
// #624 is about, and it is the first command the guide asks for.
|
|
288
288
|
const paint = builtSomething(counts) ? pc.green : pc.yellow;
|
|
289
|
-
for (const line of buildSummary(counts, Math.round(duration / 1000 * 100) / 100, ctx.config.storyMatch)) {
|
|
289
|
+
for (const line of buildSummary(counts, Math.round(duration / 1000 * 100) / 100, ctx.config.storyMatch, storyGuideUrl(ctx.config.plugins.map(plugin => plugin.name)))) {
|
|
290
290
|
console.log(paint(line));
|
|
291
291
|
}
|
|
292
292
|
// Render
|
|
@@ -1,26 +1,11 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// The fix is to give the collection environment the same values a real build would
|
|
8
|
-
// substitute, taken from Vite's own resolved `define` rather than a list of library
|
|
9
|
-
// names this package would have to keep current.
|
|
10
|
-
// Only `__FLAG__`-shaped keys are seeded. Every flag this exists for follows that
|
|
11
|
-
// convention — `__VUE_OPTIONS_API__`, `__VUE_PROD_DEVTOOLS__`, `__VUE_I18N_LEGACY_API__`,
|
|
12
|
-
// `__INTLIFY_PROD_DEVTOOLS__` — and narrow is the right failure here. A browser-targeted
|
|
13
|
-
// config shimming a Node global (`define: { process: '{"env":{}}' }` is a real shape)
|
|
14
|
-
// would otherwise replace the collection worker's own `process`, surfacing far from the
|
|
15
|
-
// config that caused it. Refusing to evaluate arbitrary expressions while letting
|
|
16
|
-
// arbitrary identifiers overwrite the runtime would be a guard in name only.
|
|
17
|
-
// `process.env.NODE_ENV` and friends fail the same test, being member expressions.
|
|
1
|
+
// Externalised deps load through native Node ESM, which Vite never applies `define`
|
|
2
|
+
// to, so a compile-time flag is an undefined identifier in a collection worker —
|
|
3
|
+
// vue-i18n reading `__VUE_PROD_DEVTOOLS__` was the reported case (#284).
|
|
4
|
+
// Narrow on purpose: a browser-targeted `define: { process: '{"env":{}}' }` would
|
|
5
|
+
// otherwise overwrite the worker's own `process`, far from the config that caused it.
|
|
18
6
|
const FEATURE_FLAG = /^__[A-Z0-9_]+__$/;
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
// string `"false"` — and seeding that string would make the flag *truthy*, silently
|
|
22
|
-
// inverting it. So strings are parsed as literals, and anything that is not a literal
|
|
23
|
-
// is skipped: evaluating arbitrary expressions here is not something this should do.
|
|
7
|
+
// `define` values are expression *source*, so a user's `define: { X: 'false' }` arrives
|
|
8
|
+
// as the string `"false"` and seeding it verbatim would invert the flag.
|
|
24
9
|
export function globalsFromDefine(define) {
|
|
25
10
|
const globals = {};
|
|
26
11
|
for (const [key, value] of Object.entries(define ?? {})) {
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Structured clone keeps an `Error`'s `name`, `message` and `stack` and drops every
|
|
3
|
+
* own property beyond them, so the code frame a Vite transform error puts on `frame`
|
|
4
|
+
* never once survived the worker boundary (#1020).
|
|
5
|
+
*/
|
|
6
|
+
const MARK = 'pvt:error';
|
|
7
|
+
function isSerialized(value) {
|
|
8
|
+
return typeof value === 'object' && value !== null && value.kind === MARK;
|
|
9
|
+
}
|
|
10
|
+
// A property the clone rejects takes the whole message down with it, which is worse
|
|
11
|
+
// than dropping the property.
|
|
12
|
+
function cloneable(value) {
|
|
13
|
+
try {
|
|
14
|
+
structuredClone(value);
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/*
|
|
22
|
+
* `seen` breaks a cause that points back into its own chain. `structuredClone`
|
|
23
|
+
* carries a cycle happily; this walk is the only thing here that cannot, and it
|
|
24
|
+
* runs on the path that reports a failure — so overflowing the stack would lose
|
|
25
|
+
* the error it was called to describe and replace it with its own.
|
|
26
|
+
*/
|
|
27
|
+
export function serializeError(error, seen = new WeakSet()) {
|
|
28
|
+
if (!(error instanceof Error)) {
|
|
29
|
+
return cloneable(error) ? error : String(error);
|
|
30
|
+
}
|
|
31
|
+
if (seen.has(error)) {
|
|
32
|
+
// Named and described, without the cause that leads back here.
|
|
33
|
+
return { kind: MARK, name: error.name, message: error.message, stack: error.stack, props: {} };
|
|
34
|
+
}
|
|
35
|
+
seen.add(error);
|
|
36
|
+
const props = {};
|
|
37
|
+
for (const key of Object.getOwnPropertyNames(error)) {
|
|
38
|
+
if (key === 'name' || key === 'message' || key === 'stack') {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const value = error[key];
|
|
42
|
+
if (key === 'cause') {
|
|
43
|
+
props[key] = serializeError(value, seen);
|
|
44
|
+
}
|
|
45
|
+
else if (cloneable(value)) {
|
|
46
|
+
props[key] = value;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return { kind: MARK, name: error.name, message: error.message, stack: error.stack, props };
|
|
50
|
+
}
|
|
51
|
+
export function deserializeError(value) {
|
|
52
|
+
if (!isSerialized(value)) {
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
const error = new Error(value.message);
|
|
56
|
+
error.name = value.name;
|
|
57
|
+
// Assigning `undefined` would erase the stack `new Error` just gave this one.
|
|
58
|
+
if (value.stack !== undefined) {
|
|
59
|
+
error.stack = value.stack;
|
|
60
|
+
}
|
|
61
|
+
for (const [key, prop] of Object.entries(value.props)) {
|
|
62
|
+
Object.defineProperty(error, key, {
|
|
63
|
+
value: key === 'cause' ? deserializeError(prop) : prop,
|
|
64
|
+
writable: true,
|
|
65
|
+
enumerable: true,
|
|
66
|
+
configurable: true,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return error;
|
|
70
|
+
}
|
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
import { cpus } from 'node:os';
|
|
2
|
-
import { MessageChannel } from 'node:worker_threads';
|
|
3
|
-
import Tinypool from '@akryum/tinypool';
|
|
4
2
|
import { escapeRegExp } from '@poveste/shared';
|
|
5
3
|
import path, { relative } from 'pathe';
|
|
6
4
|
import pc from 'picocolors';
|
|
@@ -9,16 +7,14 @@ import { createPath } from '../tree.js';
|
|
|
9
7
|
import { slash } from '../util/fs.js';
|
|
10
8
|
import { globalsFromDefine } from './define-globals.js';
|
|
11
9
|
import { createModuleServer } from './module-server.js';
|
|
12
|
-
import {
|
|
10
|
+
import { createPool } from './pool.js';
|
|
11
|
+
import { INVALIDATE, INVALIDATE_ALL } from './task.js';
|
|
13
12
|
export function useCollectStories(options, ctx) {
|
|
14
13
|
const { server, mainServer } = options;
|
|
15
14
|
const node = createModuleServer(server, {
|
|
16
15
|
inline: [
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
// `virtual:` imports; otherwise they're loaded via native Node ESM,
|
|
20
|
-
// which throws ERR_UNSUPPORTED_ESM_URL_SCHEME on `virtual:` and breaks
|
|
21
|
-
// story collection for any fresh npm install.
|
|
16
|
+
// Must be inlined: loaded through native Node ESM instead, their `virtual:`
|
|
17
|
+
// imports throw ERR_UNSUPPORTED_ESM_URL_SCHEME on any fresh npm install.
|
|
22
18
|
/\/poveste\/dist/,
|
|
23
19
|
/\/poveste\/client/,
|
|
24
20
|
/@poveste\/[\w-]+\/dist/,
|
|
@@ -33,64 +29,44 @@ export function useCollectStories(options, ctx) {
|
|
|
33
29
|
],
|
|
34
30
|
transformMode: ctx.config.viteNodeTransformMode,
|
|
35
31
|
});
|
|
36
|
-
// Same values a real build substitutes, so externalised deps see their flags.
|
|
37
32
|
const defineGlobals = globalsFromDefine(server.config.define);
|
|
38
33
|
const maxThreads = ctx.config.collectMaxThreads ?? cpus().length;
|
|
39
34
|
const threadsCount = ctx.mode === 'dev'
|
|
40
35
|
? Math.max(Math.min(maxThreads, Math.floor(cpus().length / 2)), 1)
|
|
41
36
|
: Math.max(Math.min(maxThreads, cpus().length - 1), 1);
|
|
42
37
|
console.log(pc.blue(`Using ${threadsCount} thread${threadsCount === 1 ? '' : 's'} for story collection`));
|
|
43
|
-
const threadPool =
|
|
44
|
-
filename: new URL('./worker.js', import.meta.url)
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
minThreads: threadsCount,
|
|
48
|
-
maxThreads: threadsCount,
|
|
38
|
+
const threadPool = createPool({
|
|
39
|
+
filename: new URL('./worker.js', import.meta.url),
|
|
40
|
+
threads: threadsCount,
|
|
41
|
+
invoke: (name, data) => node.invoke(name, data),
|
|
49
42
|
});
|
|
50
43
|
function clearCache() {
|
|
51
44
|
server.moduleGraph.invalidateAll();
|
|
52
45
|
node.clearCache();
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const workerPort = channel.port1;
|
|
58
|
-
serveInvoke(port, (name, data) => node.invoke(name, data));
|
|
59
|
-
return {
|
|
60
|
-
port,
|
|
61
|
-
workerPort,
|
|
62
|
-
};
|
|
46
|
+
// Both of those make every module read again, and a worker told nothing would
|
|
47
|
+
// discover that one at a time, dropping each as some story asks for it — which
|
|
48
|
+
// is how one run ends up holding two instances of a module.
|
|
49
|
+
threadPool.broadcast({ kind: INVALIDATE_ALL });
|
|
63
50
|
}
|
|
64
51
|
if (mainServer) {
|
|
65
52
|
mainServer.watcher.on('change', (file) => {
|
|
66
53
|
file = slash(file);
|
|
67
|
-
threadPool.
|
|
68
|
-
kind:
|
|
54
|
+
threadPool.broadcast({
|
|
55
|
+
kind: INVALIDATE,
|
|
69
56
|
file,
|
|
70
57
|
});
|
|
71
58
|
});
|
|
72
59
|
}
|
|
73
60
|
let destroying = false;
|
|
74
61
|
async function executeStoryFile(storyFile) {
|
|
75
|
-
// The channel belongs to this execution, and nothing used to close it. On a
|
|
76
|
-
// build that fails, the executions still in flight are abandoned mid-run and
|
|
77
|
-
// their main-thread ports stay open and listening — enough live handles to
|
|
78
|
-
// keep the process alive with nothing left to do (#426).
|
|
79
|
-
let channel;
|
|
80
62
|
try {
|
|
81
|
-
channel = createChannel();
|
|
82
63
|
const payload = {
|
|
83
64
|
root: server.config.root,
|
|
84
65
|
base: server.config.base,
|
|
85
66
|
storyFile,
|
|
86
|
-
port: channel.workerPort,
|
|
87
67
|
defineGlobals,
|
|
88
68
|
};
|
|
89
|
-
const { storyData } = await threadPool.run(payload
|
|
90
|
-
transferList: [
|
|
91
|
-
channel.workerPort,
|
|
92
|
-
],
|
|
93
|
-
});
|
|
69
|
+
const { storyData } = await threadPool.run(payload);
|
|
94
70
|
const finalData = storyData[0];
|
|
95
71
|
if (!finalData) {
|
|
96
72
|
console.warn(pc.yellow(`⚠️ No story found for ${storyFile.path}`));
|
|
@@ -99,7 +75,6 @@ export function useCollectStories(options, ctx) {
|
|
|
99
75
|
else if (storyData.length > 1) {
|
|
100
76
|
console.warn(pc.yellow(`⚠️ Multiple stories not supported: ${storyFile.path}`));
|
|
101
77
|
}
|
|
102
|
-
// Default props
|
|
103
78
|
if (ctx.config.defaultStoryProps) {
|
|
104
79
|
for (const [key, value] of Object.entries(ctx.config.defaultStoryProps)) {
|
|
105
80
|
if (Reflect.get(finalData, key) == null) {
|
|
@@ -132,9 +107,6 @@ export function useCollectStories(options, ctx) {
|
|
|
132
107
|
throw e;
|
|
133
108
|
}
|
|
134
109
|
}
|
|
135
|
-
finally {
|
|
136
|
-
channel?.port.close();
|
|
137
|
-
}
|
|
138
110
|
}
|
|
139
111
|
async function destroy() {
|
|
140
112
|
destroying = true;
|
|
@@ -7,6 +7,10 @@ export interface ModuleServerOptions {
|
|
|
7
7
|
web?: RegExp[];
|
|
8
8
|
} | undefined;
|
|
9
9
|
}
|
|
10
|
+
export declare function isInternalRequest(id: string): boolean;
|
|
11
|
+
/** A request id as the transformed code wrote it, back to a module id. */
|
|
12
|
+
export declare function normalizeRequestId(id: string, base?: string): string;
|
|
13
|
+
export declare function matches(id: string, patterns: (string | RegExp)[] | undefined): boolean;
|
|
10
14
|
export interface ModuleServer {
|
|
11
15
|
invoke: (name: string, data: unknown[]) => Promise<unknown>;
|
|
12
16
|
clearCache: () => void;
|
|
@@ -37,11 +37,11 @@ function withTrailingSlash(path) {
|
|
|
37
37
|
function cleanUrl(url) {
|
|
38
38
|
return url.replace(/[?#].*$/, '');
|
|
39
39
|
}
|
|
40
|
-
function isInternalRequest(id) {
|
|
40
|
+
export function isInternalRequest(id) {
|
|
41
41
|
return /^\/?@vite\/(?:client|env)$/.test(id);
|
|
42
42
|
}
|
|
43
43
|
/** A request id as the transformed code wrote it, back to a module id. */
|
|
44
|
-
function normalizeRequestId(id, base) {
|
|
44
|
+
export function normalizeRequestId(id, base) {
|
|
45
45
|
if (base && base !== '/' && id.startsWith(withTrailingSlash(base))) {
|
|
46
46
|
id = `/${id.slice(base.length)}`;
|
|
47
47
|
}
|
|
@@ -93,7 +93,7 @@ function toFilePath(id, root) {
|
|
|
93
93
|
exists,
|
|
94
94
|
};
|
|
95
95
|
}
|
|
96
|
-
function matches(id, patterns) {
|
|
96
|
+
export function matches(id, patterns) {
|
|
97
97
|
return (patterns ?? []).some(pattern => typeof pattern === 'string'
|
|
98
98
|
? MODULE_DIRECTORIES.some(dir => id.includes(join(dir, pattern)))
|
|
99
99
|
: pattern.test(id));
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Invoke } from './runner.js';
|
|
2
|
+
export interface PoolOptions {
|
|
3
|
+
filename: string | URL;
|
|
4
|
+
threads: number;
|
|
5
|
+
/** Answers the `invoke` calls workers make while they run. */
|
|
6
|
+
invoke: Invoke;
|
|
7
|
+
}
|
|
8
|
+
export interface Pool<P, R> {
|
|
9
|
+
run: (payload: P) => Promise<R>;
|
|
10
|
+
/** Reaches every worker, busy ones included. */
|
|
11
|
+
broadcast: (message: unknown) => void;
|
|
12
|
+
destroy: () => Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
export declare function createPool<P, R>(options: PoolOptions): Pool<P, R>;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { Worker } from 'node:worker_threads';
|
|
2
|
+
import { deserializeError } from './error.js';
|
|
3
|
+
import { serveInvoke } from './rpc.js';
|
|
4
|
+
import { DONE, FAILED, TASK } from './task.js';
|
|
5
|
+
export function createPool(options) {
|
|
6
|
+
const queue = [];
|
|
7
|
+
const idle = [];
|
|
8
|
+
const workers = [];
|
|
9
|
+
let destroyed = false;
|
|
10
|
+
let nextId = 0;
|
|
11
|
+
for (let i = 0; i < options.threads; i++) {
|
|
12
|
+
const worker = new Worker(options.filename, { stdout: false, stderr: false });
|
|
13
|
+
const handle = { worker, current: undefined };
|
|
14
|
+
serveInvoke(worker, options.invoke);
|
|
15
|
+
worker.on('message', (message) => {
|
|
16
|
+
if (message?.kind !== DONE && message?.kind !== FAILED) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const task = handle.current;
|
|
20
|
+
// A late or repeated answer is for a task this worker has already finished,
|
|
21
|
+
// and without the id it would settle whichever one it is running now.
|
|
22
|
+
if (!task || message.id !== task.id) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
handle.current = undefined;
|
|
26
|
+
if (message.kind === DONE) {
|
|
27
|
+
task.resolve(message.result);
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
task.reject(deserializeError(message.error));
|
|
31
|
+
}
|
|
32
|
+
release(handle);
|
|
33
|
+
});
|
|
34
|
+
// A worker that dies takes its task with it. Without this the story's
|
|
35
|
+
// promise never settles and the build hangs rather than failing.
|
|
36
|
+
worker.on('error', (error) => {
|
|
37
|
+
const task = handle.current;
|
|
38
|
+
handle.current = undefined;
|
|
39
|
+
task?.reject(error);
|
|
40
|
+
drop(handle);
|
|
41
|
+
});
|
|
42
|
+
worker.on('exit', () => {
|
|
43
|
+
const task = handle.current;
|
|
44
|
+
handle.current = undefined;
|
|
45
|
+
task?.reject(new Error('[poveste] a collection worker exited before its story finished'));
|
|
46
|
+
drop(handle);
|
|
47
|
+
});
|
|
48
|
+
workers.push(handle);
|
|
49
|
+
idle.push(handle);
|
|
50
|
+
}
|
|
51
|
+
function drop(handle) {
|
|
52
|
+
remove(workers, handle);
|
|
53
|
+
remove(idle, handle);
|
|
54
|
+
// Nothing is left to run the queue, so fail it rather than let it wait.
|
|
55
|
+
if (workers.length === 0) {
|
|
56
|
+
for (const task of queue.splice(0)) {
|
|
57
|
+
task.reject(new Error('[poveste] every collection worker is gone'));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function remove(list, handle) {
|
|
62
|
+
const at = list.indexOf(handle);
|
|
63
|
+
if (at !== -1) {
|
|
64
|
+
list.splice(at, 1);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function release(handle) {
|
|
68
|
+
const next = queue.shift();
|
|
69
|
+
if (next) {
|
|
70
|
+
dispatch(handle, next);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
idle.push(handle);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function dispatch(handle, task) {
|
|
77
|
+
handle.current = task;
|
|
78
|
+
handle.worker.postMessage({ kind: TASK, id: task.id, payload: task.payload });
|
|
79
|
+
}
|
|
80
|
+
function run(payload) {
|
|
81
|
+
return new Promise((resolve, reject) => {
|
|
82
|
+
if (destroyed) {
|
|
83
|
+
reject(new Error('[poveste] the collection pool is destroyed'));
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const task = { id: nextId++, payload, resolve, reject };
|
|
87
|
+
const handle = idle.shift();
|
|
88
|
+
if (handle) {
|
|
89
|
+
dispatch(handle, task);
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
queue.push(task);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
function broadcast(message) {
|
|
97
|
+
for (const handle of workers) {
|
|
98
|
+
handle.worker.postMessage(message);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
async function destroy() {
|
|
102
|
+
destroyed = true;
|
|
103
|
+
await Promise.all(workers.slice().map(handle => handle.worker.terminate()));
|
|
104
|
+
}
|
|
105
|
+
return { run, broadcast, destroy };
|
|
106
|
+
}
|
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
import type { MessagePort } from 'node:worker_threads';
|
|
2
1
|
import type { Invoke } from './runner.js';
|
|
2
|
+
/** A `MessagePort` or a `Worker` — the two ends this runs over. */
|
|
3
|
+
export interface PortLike {
|
|
4
|
+
on: (event: 'message', listener: (value: any) => void) => unknown;
|
|
5
|
+
postMessage: (value: any) => void;
|
|
6
|
+
}
|
|
3
7
|
/** Answers `invoke` requests arriving on `port`. */
|
|
4
|
-
export declare function serveInvoke(port:
|
|
8
|
+
export declare function serveInvoke(port: PortLike, invoke: Invoke): void;
|
|
5
9
|
/** An `invoke` that sends its calls over `port`. */
|
|
6
|
-
export declare function invokeOver(port:
|
|
10
|
+
export declare function invokeOver(port: PortLike): Invoke;
|
package/dist/node/collect/rpc.js
CHANGED
|
@@ -1,21 +1,29 @@
|
|
|
1
|
+
import { deserializeError, serializeError } from './error.js';
|
|
1
2
|
/*
|
|
2
|
-
* The one call a collection worker makes to the main thread
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* The one call a collection worker makes to the main thread. It replaced birpc
|
|
4
|
+
* (#344): after #167 the channel carries a single method, and a dependency that
|
|
5
|
+
* ships in `poveste` was three majors behind to carry it.
|
|
6
|
+
*
|
|
7
|
+
* Task dispatch shares the channel (#1020), so both sides tag what they send.
|
|
6
8
|
*/
|
|
7
9
|
// What birpc waited before rejecting a call, kept so a stuck transform still
|
|
8
10
|
// fails the story rather than holding its worker forever.
|
|
9
11
|
const TIMEOUT_MS = 60_000;
|
|
12
|
+
const REQUEST = 'pvt:invoke';
|
|
13
|
+
const RESPONSE = 'pvt:invoked';
|
|
10
14
|
/** Answers `invoke` requests arriving on `port`. */
|
|
11
15
|
export function serveInvoke(port, invoke) {
|
|
12
|
-
port.on('message', async (
|
|
16
|
+
port.on('message', async (message) => {
|
|
17
|
+
if (message?.kind !== REQUEST) {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const { id, name, data } = message;
|
|
13
21
|
let response;
|
|
14
22
|
try {
|
|
15
|
-
response = { id, result: await invoke(name, data) };
|
|
23
|
+
response = { kind: RESPONSE, id, result: await invoke(name, data) };
|
|
16
24
|
}
|
|
17
25
|
catch (error) {
|
|
18
|
-
response = { id, error };
|
|
26
|
+
response = { kind: RESPONSE, id, error: serializeError(error) };
|
|
19
27
|
}
|
|
20
28
|
port.postMessage(response);
|
|
21
29
|
});
|
|
@@ -24,15 +32,22 @@ export function serveInvoke(port, invoke) {
|
|
|
24
32
|
export function invokeOver(port) {
|
|
25
33
|
let nextId = 0;
|
|
26
34
|
const pending = new Map();
|
|
27
|
-
port.on('message', (
|
|
35
|
+
port.on('message', (message) => {
|
|
36
|
+
if (message?.kind !== RESPONSE) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const { id, result, error } = message;
|
|
28
40
|
const call = pending.get(id);
|
|
29
41
|
if (!call) {
|
|
30
42
|
return;
|
|
31
43
|
}
|
|
32
44
|
pending.delete(id);
|
|
33
45
|
clearTimeout(call.timeout);
|
|
34
|
-
|
|
35
|
-
|
|
46
|
+
// Presence rather than truthiness: `serveInvoke` sets the key only when the
|
|
47
|
+
// call threw, and a thrown `undefined` or `''` read as no error at all —
|
|
48
|
+
// resolving the runner with `undefined` where it expects a module.
|
|
49
|
+
if ('error' in message) {
|
|
50
|
+
call.reject(deserializeError(error));
|
|
36
51
|
}
|
|
37
52
|
else {
|
|
38
53
|
call.resolve(result);
|
|
@@ -46,6 +61,6 @@ export function invokeOver(port) {
|
|
|
46
61
|
}, TIMEOUT_MS);
|
|
47
62
|
timeout.unref();
|
|
48
63
|
pending.set(id, { resolve, reject, timeout });
|
|
49
|
-
port.postMessage({ id, name, data });
|
|
64
|
+
port.postMessage({ kind: REQUEST, id, name, data });
|
|
50
65
|
});
|
|
51
66
|
}
|
|
@@ -1,4 +1,34 @@
|
|
|
1
|
+
import type { EvaluatedModuleNode } from 'vite/module-runner';
|
|
1
2
|
import { EvaluatedModules, ModuleRunner } from 'vite/module-runner';
|
|
2
3
|
export type Invoke = (name: string, data: unknown[]) => Promise<unknown>;
|
|
3
|
-
/**
|
|
4
|
-
|
|
4
|
+
/**
|
|
5
|
+
* The file a CJS module should believe it is, for `require`, `__filename` and
|
|
6
|
+
* `__dirname`.
|
|
7
|
+
*
|
|
8
|
+
* A Vite id is root-relative, so `/node_modules/vue/index.js` is an id and not a
|
|
9
|
+
* path that exists. `isAbsolute()` answers "does this start with a separator",
|
|
10
|
+
* which that satisfies, so an id used to become a `filename` and the module's own
|
|
11
|
+
* `require('./dist/…')` resolved against a directory under the filesystem root.
|
|
12
|
+
* Quasar's config pulls `vue` through here, and a Quasar book could not collect a
|
|
13
|
+
* story at all (#1048).
|
|
14
|
+
*
|
|
15
|
+
* `file` is no better a source: measured over two books, it was `cleanUrl(id)` on
|
|
16
|
+
* every one of 1360 calls and a resolved path on none, so it differs from the id
|
|
17
|
+
* only in having the query stripped. It is read first because when the two ever do
|
|
18
|
+
* disagree Vite means `file`, not because being set makes it a path.
|
|
19
|
+
*
|
|
20
|
+
* So an absolute-looking candidate is accepted only if it names something — one
|
|
21
|
+
* `existsSync` against evaluating a module, and it is the question the old
|
|
22
|
+
* predicate meant to ask. `root` is the Vite root and not `process.cwd()`: they
|
|
23
|
+
* coincide only when the consumer runs poveste from the directory Vite is rooted
|
|
24
|
+
* at, and this branch is taken on most modules in a book.
|
|
25
|
+
*
|
|
26
|
+
* Vite's `/@fs/` ids — its escape hatch for a file outside the root — never reach
|
|
27
|
+
* here: `normalizeModuleId` strips the prefix server-side, so such a module arrives
|
|
28
|
+
* as a plain absolute path and is found by the first `existsSync`. Measured, not
|
|
29
|
+
* assumed: 81 of the Quasar book's modules resolve outside its root, all through
|
|
30
|
+
* that branch, and no id or file in either book carried the prefix.
|
|
31
|
+
*/
|
|
32
|
+
export declare function filenameOf(module: Readonly<EvaluatedModuleNode>, root: string): string;
|
|
33
|
+
/** A runner over a module server, reached through `invoke`. `root` is the Vite root. */
|
|
34
|
+
export declare function createRunner(invoke: Invoke, root: string, evaluatedModules?: EvaluatedModules): ModuleRunner;
|