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 CHANGED
@@ -47,7 +47,7 @@ Run `npx poveste --help` for the full list of options.
47
47
 
48
48
  ## Requirements
49
49
 
50
- Node `^22.22.2 || ^24.15.0 || >=26.0.0` and Vite `^8.0.0`. See
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 './dist/node/bin.js'
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
- 'A story file needs a <Story> in it: https://poveste.dev/guide/',
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
- 'Write a story file and run this again: https://poveste.dev/guide/',
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
+ }
@@ -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
- // Story collection runs in a Node worker. Externalised dependencies are loaded by
2
- // native Node ESM, so Vite never applies `define` to them — and a bare compile-time
3
- // feature flag is just an undefined identifier there. vue-i18n reading
4
- // `__VUE_PROD_DEVTOOLS__` was the reported case (#284); anything with build-time
5
- // flags fails the same way.
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
- // Vite `define` values are JS expression *source*. A plugin-injected flag arrives
20
- // already parsed (`false`), while a user's `define: { X: 'false' }` arrives as the
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,2 @@
1
+ export declare function serializeError(error: unknown, seen?: WeakSet<Error>): unknown;
2
+ export declare function deserializeError(value: unknown): unknown;
@@ -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 { serveInvoke } from './rpc.js';
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
- // Published layout: `poveste` and scoped `@poveste/*` packages. These
18
- // MUST be inlined so collection transforms them and resolves their
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 = new Tinypool({
44
- filename: new URL('./worker.js', import.meta.url).href,
45
- // WebContainer compatibility (Stackblitz)
46
- useAtomics: typeof process.versions['webcontainer'] !== 'string',
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
- function createChannel() {
55
- const channel = new MessageChannel();
56
- const port = channel.port2;
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.broadcastMessage({
68
- kind: 'hst:invalidate',
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: MessagePort, invoke: Invoke): void;
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: MessagePort): Invoke;
10
+ export declare function invokeOver(port: PortLike): Invoke;
@@ -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, over the
3
- * `MessagePort` each task brings. It replaced birpc (#344): after #167 the
4
- * channel carries a single method, and a dependency that ships in `poveste` was
5
- * three majors behind to carry it.
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 ({ id, name, data }) => {
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', ({ id, result, error }) => {
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
- if (error) {
35
- call.reject(error);
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
- /** A runner over a module server, reached through `invoke`. */
4
- export declare function createRunner(invoke: Invoke, evaluatedModules?: EvaluatedModules): ModuleRunner;
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;