forgebook 0.3.0 → 0.3.2
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 +14 -4
- package/dist/bin/forgebook/agents.md +2 -1
- package/dist/src/shell/SearchPanel.js +2 -3
- package/dist/src/shell/Shell.js +14 -5
- package/dist/src/shell/Sidebar.d.ts +8 -2
- package/dist/src/shell/Sidebar.js +3 -3
- package/dist/src/shell/SidebarHandle.d.ts +14 -1
- package/dist/src/shell/SidebarHandle.js +10 -0
- package/dist/src/shell/SidebarSearch.js +7 -4
- package/dist/src/shell/Toolbar.d.ts +6 -4
- package/dist/src/shell/Toolbar.js +3 -3
- package/dist/src/shell/shell.css +28 -1
- package/dist/src/shell/useSidebarLayout.d.ts +13 -3
- package/dist/src/shell/useSidebarLayout.js +43 -5
- package/dist/src/shell/useSidebarOverlay.d.ts +34 -0
- package/dist/src/shell/useSidebarOverlay.js +51 -0
- package/dist/src/storyMocks.d.ts +53 -0
- package/dist/src/storyMocks.js +208 -0
- package/dist/src/vite/docsExtractor.d.ts +8 -1
- package/dist/src/vite/docsExtractor.js +52 -10
- package/dist/src/vite/docsWorker.js +6 -1
- package/dist/src/vite/fileAccess.d.ts +33 -9
- package/dist/src/vite/fileAccess.js +107 -17
- package/dist/src/vite/forgebookPlugin.js +10 -6
- package/dist/src/vite/pluginIds.d.ts +15 -0
- package/dist/src/vite/pluginIds.js +23 -0
- package/package.json +9 -5
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A story's mock handlers (`parameters.msw.handlers`) read from its parameters, and run on a server in place of the
|
|
3
|
+
* network. Exported as `forgebook/story-mocks`, for `@forgebook/plugin-msw` and the adapters that render on a server.
|
|
4
|
+
*
|
|
5
|
+
* @remarks
|
|
6
|
+
* A server render (the generated server tests) has no service worker, and whatever answers on the test machine must
|
|
7
|
+
* not decide what a page draws. So the handlers the frame's worker serves are run here through MSW's `getResponse`, a
|
|
8
|
+
* plain call with no interception, so concurrent renders never see each other's handlers. A request no handler answers,
|
|
9
|
+
* one a handler answers with `passthrough()`, or one made with `bypass()`, fails with the error the adapter writes, which
|
|
10
|
+
* knows what its stories can give instead. A handler's `HttpResponse.error()` rejects the fetch and its redirect is
|
|
11
|
+
* followed, as in the frame.
|
|
12
|
+
*
|
|
13
|
+
* Imports nothing, MSW included: `msw` is an optional peer of each adapter that uses this, so it is resolved from that
|
|
14
|
+
* package and passed in. A strict package manager would not find it from here.
|
|
15
|
+
*
|
|
16
|
+
* @module
|
|
17
|
+
*/
|
|
18
|
+
/** How many redirects a fetch follows, as browsers and Node's `fetch` do. */
|
|
19
|
+
const MOST_REDIRECTS = 20;
|
|
20
|
+
/**
|
|
21
|
+
* How many requests of a render wait at once. A handler that fetches the address it answers comes back to itself for
|
|
22
|
+
* ever, in promises alone, so no timer of the render's ever fires: only this stops it.
|
|
23
|
+
*/
|
|
24
|
+
const MOST_WAITING = 1000;
|
|
25
|
+
/** The headers a redirect to another origin does not carry. */
|
|
26
|
+
const CREDENTIAL_HEADERS = ["authorization", "cookie", "proxy-authorization"];
|
|
27
|
+
/** Whether a value is an object with named fields. */
|
|
28
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
29
|
+
/**
|
|
30
|
+
* The handlers a story's parameters declare, flat: a list, or a record of lists as MSW's Storybook addon writes them.
|
|
31
|
+
*
|
|
32
|
+
* @param parameters - the story's parameters.
|
|
33
|
+
* @returns the handlers; none when the story has no mocks.
|
|
34
|
+
*/
|
|
35
|
+
export const mockHandlersOf = (parameters) => {
|
|
36
|
+
const msw = parameters["msw"];
|
|
37
|
+
const handlers = isRecord(msw) ? msw["handlers"] : undefined;
|
|
38
|
+
if (Array.isArray(handlers))
|
|
39
|
+
return handlers;
|
|
40
|
+
return isRecord(handlers) ? Object.values(handlers).flat() : [];
|
|
41
|
+
};
|
|
42
|
+
/** The addresses of this machine that are not a range: every interface's, which connecting to reaches this machine. */
|
|
43
|
+
const OWN_ADDRESSES = new Set(["[::1]", "0.0.0.0", "[::]"]);
|
|
44
|
+
/**
|
|
45
|
+
* Whether a host name is this machine's own, as `URL` writes it: `localhost` and its subdomains, with or without the
|
|
46
|
+
* final dot, `127.0.0.0/8`, the same range mapped into IPv6, and the addresses that reach every interface.
|
|
47
|
+
*
|
|
48
|
+
* @param hostname - the host name, from a `URL`.
|
|
49
|
+
*/
|
|
50
|
+
const isLoopback = (hostname) => {
|
|
51
|
+
const name = hostname.endsWith(".") ? hostname.slice(0, -1) : hostname;
|
|
52
|
+
return (name === "localhost" ||
|
|
53
|
+
name.endsWith(".localhost") ||
|
|
54
|
+
OWN_ADDRESSES.has(name) ||
|
|
55
|
+
/^127\.\d+\.\d+\.\d+$/.test(name) ||
|
|
56
|
+
/^\[::ffff:7f[0-9a-f]{2}:[0-9a-f]{1,4}\]$/.test(name));
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* Whether an address reaches the same server as a page: its origin, or another name of this machine on the same scheme
|
|
60
|
+
* and port (`127.0.0.1` or `[::1]` beside a `localhost` page), which a real machine answers the same.
|
|
61
|
+
*
|
|
62
|
+
* @param address - the address.
|
|
63
|
+
* @param page - the page's address.
|
|
64
|
+
*/
|
|
65
|
+
export const isOnPageOrigin = (address, page) => {
|
|
66
|
+
if (address.origin === page.origin)
|
|
67
|
+
return true;
|
|
68
|
+
return (address.protocol === page.protocol &&
|
|
69
|
+
address.port === page.port &&
|
|
70
|
+
isLoopback(address.hostname) &&
|
|
71
|
+
isLoopback(page.hostname));
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* Makes a story's `{ once: true }` handlers answer again, as MSW's `restoreHandlers` does: each render on a server is a
|
|
75
|
+
* page of its own, as each frame is, while the handler objects outlive it in the test process.
|
|
76
|
+
*
|
|
77
|
+
* @param handlers - the story's handlers.
|
|
78
|
+
*/
|
|
79
|
+
const restoreHandlers = (handlers) => {
|
|
80
|
+
for (const handler of handlers)
|
|
81
|
+
if (isRecord(handler) && typeof handler["isUsed"] === "boolean")
|
|
82
|
+
Reflect.set(handler, "isUsed", false);
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* Whether a module is MSW's, as much as a server render uses.
|
|
86
|
+
*
|
|
87
|
+
* @param module - the module, as `import()` gave it.
|
|
88
|
+
*/
|
|
89
|
+
const isMswCore = (module) => typeof module === "object" &&
|
|
90
|
+
module !== null &&
|
|
91
|
+
"getResponse" in module &&
|
|
92
|
+
typeof module.getResponse === "function";
|
|
93
|
+
/**
|
|
94
|
+
* Loads MSW, which only a story with handlers needs on a server.
|
|
95
|
+
*
|
|
96
|
+
* @param loadMsw - imports it.
|
|
97
|
+
* @throws when MSW is not installed, or is older than its `getResponse`.
|
|
98
|
+
*/
|
|
99
|
+
const mswCore = async (loadMsw) => {
|
|
100
|
+
let module;
|
|
101
|
+
try {
|
|
102
|
+
module = await loadMsw();
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
throw new Error("Answering a fetch with the story's mocks on a server needs MSW: npm install --save-dev msw", { cause: error });
|
|
106
|
+
}
|
|
107
|
+
if (!isMswCore(module))
|
|
108
|
+
throw new Error("The installed MSW has no getResponse: MSW 2.11 or later is needed");
|
|
109
|
+
return module;
|
|
110
|
+
};
|
|
111
|
+
/**
|
|
112
|
+
* Whether a request asks to skip the mocks and reach the network: MSW's `bypass()` adds this to its `accept` header. A
|
|
113
|
+
* handler's `fetch(bypass(request))` would otherwise come back to the same handler for ever.
|
|
114
|
+
*
|
|
115
|
+
* @param request - the request.
|
|
116
|
+
*/
|
|
117
|
+
const isBypass = (request) => request.headers.get("accept")?.includes("msw/passthrough") === true;
|
|
118
|
+
/**
|
|
119
|
+
* Whether an answer is MSW's `passthrough()`: MSW marks it with this status and header, which its own
|
|
120
|
+
* `isPassthroughResponse` reads (exported from MSW 3 only).
|
|
121
|
+
*
|
|
122
|
+
* @param answer - a handler's answer.
|
|
123
|
+
*/
|
|
124
|
+
const isPassthrough = (answer) => answer.status === 302 && answer.headers.get("x-msw-intention") === "passthrough";
|
|
125
|
+
/**
|
|
126
|
+
* What the story's handlers answer: none when they do not, or when the one that does sends the request on to the
|
|
127
|
+
* network, which a server render never reaches.
|
|
128
|
+
*
|
|
129
|
+
* @param mocks - the story's mocks.
|
|
130
|
+
* @param request - the request.
|
|
131
|
+
*/
|
|
132
|
+
const mockAnswer = async (mocks, request) => {
|
|
133
|
+
if (mocks.handlers.length === 0 || isBypass(request))
|
|
134
|
+
return undefined;
|
|
135
|
+
const { getResponse } = await mswCore(mocks.loadMsw);
|
|
136
|
+
const address = new URL(request.url);
|
|
137
|
+
// an address on this machine, whichever name or port, is the app's own: `/api/users` answers it as in the app
|
|
138
|
+
const baseUrl = isLoopback(address.hostname) ? address.origin : mocks.origin;
|
|
139
|
+
const answer = await getResponse([...mocks.handlers], request, { baseUrl });
|
|
140
|
+
return answer === undefined || isPassthrough(answer) ? undefined : answer;
|
|
141
|
+
};
|
|
142
|
+
/**
|
|
143
|
+
* The request a mocked redirect leads to, as `fetch` follows it: the same method and body for a 307 or a 308, a GET
|
|
144
|
+
* for a 303 and for a POST's 301 or 302; none when the answer is no redirect or the request does not follow one.
|
|
145
|
+
*
|
|
146
|
+
* @param answer - a handler's answer.
|
|
147
|
+
* @param request - the request it answered, its body unread.
|
|
148
|
+
* @throws a `TypeError` for a redirect a request with `redirect: "error"` gets, as `fetch` does.
|
|
149
|
+
*/
|
|
150
|
+
const redirectOf = (answer, request) => {
|
|
151
|
+
const location = answer.headers.get("location");
|
|
152
|
+
if (![301, 302, 303, 307, 308].includes(answer.status) || location === null)
|
|
153
|
+
return undefined;
|
|
154
|
+
if (request.redirect === "manual")
|
|
155
|
+
return undefined;
|
|
156
|
+
if (request.redirect === "error")
|
|
157
|
+
throw new TypeError(`Redirected to ${location}, which the request refuses`);
|
|
158
|
+
const target = new URL(location, request.url);
|
|
159
|
+
const isGet = answer.status === 303 || (request.method === "POST" && answer.status !== 307 && answer.status !== 308);
|
|
160
|
+
const next = isGet
|
|
161
|
+
? new Request(target, { method: request.method === "HEAD" ? "HEAD" : "GET", headers: request.headers })
|
|
162
|
+
: new Request(target, request);
|
|
163
|
+
// an adapter may send this one to the network: another origin never gets the first one's credentials, as with `fetch`
|
|
164
|
+
if (target.origin !== new URL(request.url).origin)
|
|
165
|
+
for (const name of CREDENTIAL_HEADERS)
|
|
166
|
+
next.headers.delete(name);
|
|
167
|
+
return next;
|
|
168
|
+
};
|
|
169
|
+
/**
|
|
170
|
+
* A `fetch` of one render, its requests answered by the story's mocks. Make one per render: its `{ once: true }` handlers
|
|
171
|
+
* answer again from here.
|
|
172
|
+
*
|
|
173
|
+
* @param mocks - the story's mocks.
|
|
174
|
+
* @returns what sends a request; an address the mocks do not take goes to the global `fetch`.
|
|
175
|
+
*/
|
|
176
|
+
export const mockedFetch = (mocks) => {
|
|
177
|
+
restoreHandlers(mocks.handlers);
|
|
178
|
+
const send = async (request, redirects) => {
|
|
179
|
+
if (mocks.isMocked?.(new URL(request.url)) === false)
|
|
180
|
+
return globalThis.fetch(request);
|
|
181
|
+
// kept for a redirect that sends the request again, body included, after a handler read it
|
|
182
|
+
const kept = request.clone();
|
|
183
|
+
const answer = await mockAnswer(mocks, request);
|
|
184
|
+
if (answer === undefined)
|
|
185
|
+
throw mocks.onUnanswered(request);
|
|
186
|
+
// MSW's mocked network error: `fetch` rejects, as it does in the frame
|
|
187
|
+
if (answer.type === "error")
|
|
188
|
+
throw new TypeError("Failed to fetch", { cause: answer });
|
|
189
|
+
const next = redirectOf(answer, kept);
|
|
190
|
+
if (next === undefined)
|
|
191
|
+
return answer;
|
|
192
|
+
if (redirects >= MOST_REDIRECTS)
|
|
193
|
+
throw new TypeError(`Too many redirects, the last to ${next.url}`);
|
|
194
|
+
return send(next, redirects + 1);
|
|
195
|
+
};
|
|
196
|
+
let waiting = 0;
|
|
197
|
+
return async (request) => {
|
|
198
|
+
if (waiting >= MOST_WAITING)
|
|
199
|
+
throw new TypeError(`${request.method} ${request.url}: a handler keeps fetching what it answers`);
|
|
200
|
+
waiting += 1;
|
|
201
|
+
try {
|
|
202
|
+
return await send(request, 0);
|
|
203
|
+
}
|
|
204
|
+
finally {
|
|
205
|
+
waiting -= 1;
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
};
|
|
@@ -83,6 +83,11 @@ export type DocsWorkerData = {
|
|
|
83
83
|
/** Whether its answers are kept on disk, so it stamps what each was read from; a build's are not. */
|
|
84
84
|
readonly keepsAnswers: boolean;
|
|
85
85
|
};
|
|
86
|
+
/** What the worker says of its own, unasked: that its modules, the framework's docs module's included, are loaded. */
|
|
87
|
+
export type DocsWorkerLoaded = {
|
|
88
|
+
/** The message's kind. */
|
|
89
|
+
readonly kind: "loaded";
|
|
90
|
+
};
|
|
86
91
|
/** How an extractor reads. */
|
|
87
92
|
export type DocsExtractorOptions = {
|
|
88
93
|
/** Whether every story's docs will be read, as in a build; a dev server's reads by default. */
|
|
@@ -94,6 +99,8 @@ export type DocsExtractorOptions = {
|
|
|
94
99
|
* never ends before the build does.
|
|
95
100
|
*/
|
|
96
101
|
readonly idleMs?: number;
|
|
102
|
+
/** How long ending the worker waits for its modules to load, in milliseconds; `LOAD_WAIT_MS` by default. */
|
|
103
|
+
readonly loadWaitMs?: number;
|
|
97
104
|
};
|
|
98
105
|
/**
|
|
99
106
|
* How long a dev server's worker stays with nothing to do, in milliseconds, before it ends. Its programs are the
|
|
@@ -114,4 +121,4 @@ export declare const NO_DOCS_EXTRACTOR: DocsExtractor;
|
|
|
114
121
|
* @param options - how it reads, and where it keeps what it read.
|
|
115
122
|
* @returns the extractor.
|
|
116
123
|
*/
|
|
117
|
-
export declare const createDocsExtractor: (docsModule: string, { readsEverything, cacheFolder, idleMs }?: DocsExtractorOptions) => DocsExtractor;
|
|
124
|
+
export declare const createDocsExtractor: (docsModule: string, { readsEverything, cacheFolder, idleMs, loadWaitMs }?: DocsExtractorOptions) => DocsExtractor;
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* the disk keeps no docs at all (a first start, a build), one warm-up at a time and only while nothing waits. For the
|
|
11
11
|
* same reason a dev server's worker left with nothing to do ends (`IDLE_MS`), so the memory a first start took does
|
|
12
12
|
* not stay with the server until it stops. Requests are answered in order. The worker never keeps the process alive
|
|
13
|
-
* (`unref`), and `close` ends it when the build or the server ends.
|
|
13
|
+
* (`unref`), and `close` ends it when the build or the server ends, once its modules are loaded (`endOnceLoaded`).
|
|
14
14
|
*
|
|
15
15
|
* @module
|
|
16
16
|
*/
|
|
@@ -40,6 +40,11 @@ const YOUNG_GENERATION_MB = 8;
|
|
|
40
40
|
* looks keeps them, renewed after each edit rather than built again (seconds, in a large monorepo).
|
|
41
41
|
*/
|
|
42
42
|
export const IDLE_MS = 5 * 60_000;
|
|
43
|
+
/**
|
|
44
|
+
* How long ending a worker waits for its modules to load, in milliseconds: well past a slow machine's load of the
|
|
45
|
+
* compiler, and a limit so that a docs module whose loading never ends cannot keep a server from closing.
|
|
46
|
+
*/
|
|
47
|
+
const LOAD_WAIT_MS = 30_000;
|
|
43
48
|
/**
|
|
44
49
|
* The worker's young generation in a build, in MB: a build reads every story's docs in one go, and with the dev
|
|
45
50
|
* server's small one it would spend much of that time collecting garbage.
|
|
@@ -75,6 +80,28 @@ export const NO_DOCS_EXTRACTOR = {
|
|
|
75
80
|
listed: () => undefined,
|
|
76
81
|
close: () => Promise.resolve()
|
|
77
82
|
};
|
|
83
|
+
/**
|
|
84
|
+
* Ends a worker once its modules are loaded.
|
|
85
|
+
*
|
|
86
|
+
* @remarks
|
|
87
|
+
* Node 24 aborts the whole process when a worker is terminated while it reads the exports of a CommonJS module it
|
|
88
|
+
* imports, and the compiler is one: a command answered before the warm-up's worker had loaded would die with nothing
|
|
89
|
+
* printed. Past the loading, terminating is safe and stops a program being built at once, which a request to the worker
|
|
90
|
+
* to end by itself would have to wait for.
|
|
91
|
+
*
|
|
92
|
+
* @param worker - the worker.
|
|
93
|
+
* @param loaded - settled once its modules are loaded, or it has ended.
|
|
94
|
+
* @param waitMs - how long to wait for that, in milliseconds.
|
|
95
|
+
*/
|
|
96
|
+
const endOnceLoaded = async (worker, loaded, waitMs) => {
|
|
97
|
+
let limit;
|
|
98
|
+
const overLimit = new Promise((resolve) => {
|
|
99
|
+
limit = setTimeout(resolve, waitMs);
|
|
100
|
+
});
|
|
101
|
+
await Promise.race([loaded, overLimit]);
|
|
102
|
+
clearTimeout(limit);
|
|
103
|
+
await worker.terminate();
|
|
104
|
+
};
|
|
78
105
|
/**
|
|
79
106
|
* A docs extractor with its worker, started on its first request.
|
|
80
107
|
*
|
|
@@ -82,7 +109,7 @@ export const NO_DOCS_EXTRACTOR = {
|
|
|
82
109
|
* @param options - how it reads, and where it keeps what it read.
|
|
83
110
|
* @returns the extractor.
|
|
84
111
|
*/
|
|
85
|
-
export const createDocsExtractor = (docsModule, { readsEverything = false, cacheFolder, idleMs = IDLE_MS } = {}) => {
|
|
112
|
+
export const createDocsExtractor = (docsModule, { readsEverything = false, cacheFolder, idleMs = IDLE_MS, loadWaitMs = LOAD_WAIT_MS } = {}) => {
|
|
86
113
|
// the code that writes the answers: this package's and the framework's docs, whose edit or new version is a miss
|
|
87
114
|
const code = cacheFolder === undefined ? undefined : codeStampOf(docsModule);
|
|
88
115
|
const cacheOf = (kind, isValue) => cacheFolder === undefined || code === undefined
|
|
@@ -91,6 +118,8 @@ export const createDocsExtractor = (docsModule, { readsEverything = false, cache
|
|
|
91
118
|
const docsCache = cacheOf("docs", isKeptDocs);
|
|
92
119
|
const usageCache = cacheOf("usage", isKeptUsage);
|
|
93
120
|
let worker;
|
|
121
|
+
// settled once the current worker's modules are loaded, or it has ended
|
|
122
|
+
let workerLoaded = Promise.resolve();
|
|
94
123
|
let nextId = 0;
|
|
95
124
|
// the changes the watcher reported: an answer read across one may have read a file before it changed, so it is not kept
|
|
96
125
|
let changes = 0;
|
|
@@ -103,17 +132,23 @@ export const createDocsExtractor = (docsModule, { readsEverything = false, cache
|
|
|
103
132
|
// the warm-ups not sent yet: one goes out only when nothing is pending, so a read never waits behind them all
|
|
104
133
|
const warmUps = [];
|
|
105
134
|
let idle;
|
|
135
|
+
// every ending under way: a second close must wait for the first's too, as a process may exit once it resolves
|
|
136
|
+
let ending = Promise.resolve();
|
|
137
|
+
// lets the current worker go, so the next request starts another, and ends it
|
|
138
|
+
const endWorker = async () => {
|
|
139
|
+
const running = worker;
|
|
140
|
+
worker = undefined;
|
|
141
|
+
if (running !== undefined)
|
|
142
|
+
ending = Promise.all([ending, endOnceLoaded(running, workerLoaded, loadWaitMs)]);
|
|
143
|
+
await ending;
|
|
144
|
+
};
|
|
106
145
|
// ends the worker once nothing is asked of it for a while; a request in the meantime keeps it
|
|
107
146
|
const stopWhenIdle = () => {
|
|
108
147
|
clearTimeout(idle);
|
|
109
148
|
// a build reads every story, and a quiet stretch in it would build every program again
|
|
110
149
|
if (readsEverything || pending.size > 0 || warmUps.length > 0)
|
|
111
150
|
return;
|
|
112
|
-
idle = setTimeout(() =>
|
|
113
|
-
const running = worker;
|
|
114
|
-
worker = undefined;
|
|
115
|
-
void running?.terminate();
|
|
116
|
-
}, idleMs);
|
|
151
|
+
idle = setTimeout(() => void endWorker(), idleMs);
|
|
117
152
|
idle.unref();
|
|
118
153
|
};
|
|
119
154
|
const sendWarmUp = () => {
|
|
@@ -135,7 +170,16 @@ export const createDocsExtractor = (docsModule, { readsEverything = false, cache
|
|
|
135
170
|
}
|
|
136
171
|
});
|
|
137
172
|
created.unref();
|
|
173
|
+
workerLoaded = new Promise((resolve) => {
|
|
174
|
+
created.on("message", (message) => {
|
|
175
|
+
if (message.kind === "loaded")
|
|
176
|
+
resolve();
|
|
177
|
+
});
|
|
178
|
+
created.once("exit", () => resolve());
|
|
179
|
+
});
|
|
138
180
|
created.on("message", (response) => {
|
|
181
|
+
if (response.kind === "loaded")
|
|
182
|
+
return;
|
|
139
183
|
pending.get(response.id)?.(response);
|
|
140
184
|
pending.delete(response.id);
|
|
141
185
|
sendWarmUp();
|
|
@@ -229,10 +273,8 @@ export const createDocsExtractor = (docsModule, { readsEverything = false, cache
|
|
|
229
273
|
},
|
|
230
274
|
close: async () => {
|
|
231
275
|
clearTimeout(idle);
|
|
232
|
-
const running = worker;
|
|
233
|
-
worker = undefined;
|
|
234
276
|
warmUps.length = 0;
|
|
235
|
-
await
|
|
277
|
+
await endWorker();
|
|
236
278
|
}
|
|
237
279
|
};
|
|
238
280
|
};
|
|
@@ -17,7 +17,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
17
17
|
* read builds one, and a changed source file (`{ kind: "changed" }`) renews them on their next read. A usage request
|
|
18
18
|
* (`{ kind: "usage", id, files }`) is answered from the same programs (`usageGraph.ts`). A request is `{ id, storyFile }`, the answer `{ id, result }`, `extractStoryComponent`'s result as
|
|
19
19
|
* it is (plain data), with the files it can change with (`docsDependencies.ts`), which the dev server keeps it on disk
|
|
20
|
-
* with.
|
|
20
|
+
* with. Unasked, it says `{ kind: "loaded" }` once its modules are loaded.
|
|
21
21
|
*
|
|
22
22
|
* @module
|
|
23
23
|
*/
|
|
@@ -55,6 +55,11 @@ if (isWorkerData(workerData) && !workerData.readsEverything)
|
|
|
55
55
|
readAskedFilesOnly();
|
|
56
56
|
/** The framework's docs module, loaded once, before the first answer. */
|
|
57
57
|
const docsModule = loadDocsModule();
|
|
58
|
+
// the extractor waits for this before it ends the thread: `endOnceLoaded` says why
|
|
59
|
+
void docsModule.then(() => {
|
|
60
|
+
const loaded = { kind: "loaded" };
|
|
61
|
+
parentPort?.postMessage(loaded);
|
|
62
|
+
});
|
|
58
63
|
/** The extractor's result, or why it threw: a throw would end the worker and every request waiting on it. */
|
|
59
64
|
const extracted = async (storyFile) => {
|
|
60
65
|
const module = await docsModule;
|
|
@@ -19,8 +19,13 @@ export declare const isFileRefused: (config: ResolvedConfig, file: string) => bo
|
|
|
19
19
|
export declare const isQueriedFileReadable: (config: ResolvedConfig, id: string) => boolean;
|
|
20
20
|
/** What a plugin's dev server lets a client read: `devServerAccess` makes it. */
|
|
21
21
|
export type DevServerAccess = {
|
|
22
|
-
/**
|
|
23
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Starts on a dev server; until then (and in a build, which has none) nothing is refused.
|
|
24
|
+
*
|
|
25
|
+
* @param server - the dev server.
|
|
26
|
+
* @param sources - the module that imports the configuration's sources; none without a configuration.
|
|
27
|
+
*/
|
|
28
|
+
readonly serve: (server: ViteDevServer, sources: string | undefined) => void;
|
|
24
29
|
/**
|
|
25
30
|
* Whether a resolver's importer is a module, not the stand-in Vite gives an address asked for directly.
|
|
26
31
|
*
|
|
@@ -36,16 +41,35 @@ export type DevServerAccess = {
|
|
|
36
41
|
/** Whether a file a group lists may be read; says so once when not, as nothing else would tell why it is missing. */
|
|
37
42
|
readonly isKept: IsFileKept;
|
|
38
43
|
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
44
|
+
* Holds a request for a group no import has named until the configuration's sources have been read.
|
|
45
|
+
*
|
|
46
|
+
* @param url - the request's address.
|
|
47
|
+
*/
|
|
48
|
+
readonly settle: (url: string) => Promise<void>;
|
|
49
|
+
/**
|
|
50
|
+
* What the plugin's load answers for a file's query `server.fs` refuses: an empty module and not Vite's 404, as Vite
|
|
51
|
+
* would then read a path with a `..` as written, past the check that refused it. Asked of every id, since an address
|
|
52
|
+
* can name an id directly, past the plugin's `resolveId`.
|
|
41
53
|
*
|
|
42
54
|
* @param id - the module's id.
|
|
55
|
+
* @returns the module, or none when the client may read the id.
|
|
56
|
+
* @throws for a group no module of the project imports, with Vite's code for a missing file.
|
|
43
57
|
*/
|
|
44
|
-
readonly
|
|
58
|
+
readonly refusal: (id: string) => {
|
|
59
|
+
readonly code: string;
|
|
60
|
+
readonly moduleType: "js";
|
|
61
|
+
} | undefined;
|
|
45
62
|
};
|
|
63
|
+
/** What a plugin's dev server lets a client read. */
|
|
64
|
+
export declare const devServerAccess: () => DevServerAccess;
|
|
46
65
|
/**
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* @param renew - renews modules Vite keeps: a group refused before its first import would otherwise stay empty.
|
|
66
|
+
* The plugin's hook for Vitest (`configureVitest`), whose module cache keeps each module's transform on disk between
|
|
67
|
+
* runs: a module read from it resolves no import, so a group it names would stay refused from the second run on.
|
|
50
68
|
*/
|
|
51
|
-
export declare const
|
|
69
|
+
export declare const VITEST_HOOKS: {
|
|
70
|
+
configureVitest: (context: {
|
|
71
|
+
readonly defineCacheKeyGenerator?: (generator: (module: {
|
|
72
|
+
readonly sourceCode: string;
|
|
73
|
+
}) => false | undefined) => void;
|
|
74
|
+
}) => void;
|
|
75
|
+
};
|
|
@@ -12,12 +12,23 @@
|
|
|
12
12
|
* A group's id holds its patterns, so an id alone must never choose what is read: any address could name a folder to
|
|
13
13
|
* list and read, and the imports the group's module writes would make Vite serve every file listed, past `allow`.
|
|
14
14
|
*
|
|
15
|
+
* A group is named by a module's import, which the plugin sees when Vite transforms that module. A client may ask before
|
|
16
|
+
* that: a tab left open across a restart holds its modules and asks for a search index first. So a request for a group
|
|
17
|
+
* no import has named waits, before Vite starts on it, for the server to read what the configuration's sources import,
|
|
18
|
+
* the same modules a page would load: inside the group's load the reading could wait for a request that waits for it. A
|
|
19
|
+
* cache of transforms would hide the import for good, and Vitest keeps one on disk between runs: a module that names a
|
|
20
|
+
* group stays out of it.
|
|
21
|
+
*
|
|
22
|
+
* A group no module names answers as a missing file does: Vite answers 404, keeps no module to renew and raises no
|
|
23
|
+
* overlay on the pages open. A module's `export default null` would read as a group, and fail far from here in whatever
|
|
24
|
+
* reads it.
|
|
25
|
+
*
|
|
15
26
|
* @module
|
|
16
27
|
*/
|
|
17
28
|
import { realpathSync } from "node:fs";
|
|
18
29
|
import { join } from "node:path";
|
|
19
|
-
import { isFileLoadingAllowed } from "vite";
|
|
20
|
-
import { isGroupId } from "./pluginIds.js";
|
|
30
|
+
import { isCSSRequest, isFileLoadingAllowed } from "vite";
|
|
31
|
+
import { isGroupId, namesGroup, namingGroupOf } from "./pluginIds.js";
|
|
21
32
|
import { CODE_QUERY, DOCS_PAGE_QUERY, DOCS_QUERY, HOME_QUERY, MARKDOWN_QUERY, PAGE_QUERY } from "./textQueries.js";
|
|
22
33
|
import { VITE_PATHS } from "./vitePaths.js";
|
|
23
34
|
/** The queries the plugin answers with a file's text, so each read keeps to what the dev server may serve. */
|
|
@@ -69,25 +80,81 @@ export const isQueriedFileReadable = (config, id) => {
|
|
|
69
80
|
return VITE_PATHS.normalize(file) === file && isFileLoadingAllowed(config, file);
|
|
70
81
|
};
|
|
71
82
|
/**
|
|
72
|
-
*
|
|
83
|
+
* Whether reading a module can show an import of a group: not a group (reading one waits for this reading) nor a file's
|
|
84
|
+
* query, which reads no import; not a style sheet or a pre-bundled dependency, which import none.
|
|
85
|
+
*
|
|
86
|
+
* @param environment - the environment the module is read in.
|
|
87
|
+
* @param module - the module.
|
|
88
|
+
*/
|
|
89
|
+
const mayNameGroup = (environment, module) => {
|
|
90
|
+
const { id, url } = module;
|
|
91
|
+
return (id !== null &&
|
|
92
|
+
!isCSSRequest(url) &&
|
|
93
|
+
!isGroupId(id) &&
|
|
94
|
+
!FILE_QUERIES.some((query) => id.endsWith(query)) &&
|
|
95
|
+
environment.depsOptimizer?.isOptimizedDepUrl(url) !== true);
|
|
96
|
+
};
|
|
97
|
+
/**
|
|
98
|
+
* Reads a module and what it imports, for the imports the plugin sees on the way.
|
|
99
|
+
*
|
|
100
|
+
* @param environment - the environment to read them in.
|
|
101
|
+
* @param entry - the module's address.
|
|
102
|
+
* @returns whether each could be read: one that could not may name a group once it is fixed.
|
|
103
|
+
*/
|
|
104
|
+
const readImports = async (environment, entry) => {
|
|
105
|
+
const seen = new Set([entry]);
|
|
106
|
+
const read = async (url) => {
|
|
107
|
+
try {
|
|
108
|
+
await environment.transformRequest(url);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
const module = await environment.moduleGraph.getModuleByUrl(url);
|
|
114
|
+
const next = [...(module?.importedModules ?? [])].filter((imported) => !seen.has(imported.url) && mayNameGroup(environment, imported));
|
|
115
|
+
for (const imported of next)
|
|
116
|
+
seen.add(imported.url);
|
|
117
|
+
const reads = await Promise.all(next.map((imported) => read(imported.url)));
|
|
118
|
+
return reads.every(Boolean);
|
|
119
|
+
};
|
|
120
|
+
return read(entry);
|
|
121
|
+
};
|
|
122
|
+
/**
|
|
123
|
+
* The id of the group a request's address names, when it names one: any spelling this misses reaches the plugin's load,
|
|
124
|
+
* which refuses it all the same.
|
|
73
125
|
*
|
|
74
|
-
* @param
|
|
126
|
+
* @param url - the request's address.
|
|
75
127
|
*/
|
|
76
|
-
|
|
128
|
+
const groupIdOf = (url) => {
|
|
129
|
+
try {
|
|
130
|
+
const path = decodeURI(url.split("?")[0] ?? "");
|
|
131
|
+
const at = path.indexOf("virtual:forgebook-");
|
|
132
|
+
return at === -1 ? undefined : `\0${path.slice(at)}`;
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
/** Vite's code for a file it could not load: its server answers 404, with no overlay, and a runner fails with the text. */
|
|
139
|
+
const MISSING_MODULE = "ERR_LOAD_URL";
|
|
140
|
+
/** What a plugin's dev server lets a client read. */
|
|
141
|
+
export const devServerAccess = () => {
|
|
77
142
|
let devServer;
|
|
78
143
|
let addressImporter;
|
|
144
|
+
let sourcesEntry;
|
|
145
|
+
/** The reading of the sources: once, as an edit that names a new group is seen when Vite transforms it. */
|
|
146
|
+
let sourcesRead;
|
|
79
147
|
const imported = new Set();
|
|
80
|
-
const refused = new Set();
|
|
81
148
|
return {
|
|
82
|
-
serve: (server) => {
|
|
149
|
+
serve: (server, sources) => {
|
|
83
150
|
devServer = server;
|
|
84
151
|
addressImporter = join(server.config.root, "index.html");
|
|
152
|
+
sourcesEntry = sources;
|
|
153
|
+
sourcesRead = undefined;
|
|
85
154
|
},
|
|
86
155
|
isModule: (importer) => importer !== undefined && importer !== addressImporter,
|
|
87
156
|
noteImported: (id) => {
|
|
88
|
-
imported.add(id);
|
|
89
|
-
if (refused.delete(id) && devServer !== undefined)
|
|
90
|
-
renew(devServer, [id]);
|
|
157
|
+
imported.add(namingGroupOf(id));
|
|
91
158
|
},
|
|
92
159
|
isKept: (file) => {
|
|
93
160
|
if (devServer === undefined || !isFileRefused(devServer.config, file))
|
|
@@ -95,15 +162,38 @@ export const devServerAccess = (renew) => {
|
|
|
95
162
|
devServer.config.logger.warnOnce(`Forgebook leaves out ${file}: Vite's server.fs refuses it`);
|
|
96
163
|
return false;
|
|
97
164
|
},
|
|
98
|
-
|
|
165
|
+
settle: async (url) => {
|
|
166
|
+
const id = groupIdOf(url);
|
|
167
|
+
if (id === undefined || devServer === undefined || sourcesEntry === undefined)
|
|
168
|
+
return;
|
|
169
|
+
if (imported.has(namingGroupOf(id)))
|
|
170
|
+
return;
|
|
171
|
+
sourcesRead ??= readImports(devServer.environments.client, sourcesEntry).then((isRead) => {
|
|
172
|
+
if (!isRead)
|
|
173
|
+
sourcesRead = undefined;
|
|
174
|
+
});
|
|
175
|
+
await sourcesRead;
|
|
176
|
+
},
|
|
177
|
+
refusal: (id) => {
|
|
99
178
|
if (devServer === undefined)
|
|
100
|
-
return
|
|
179
|
+
return undefined;
|
|
101
180
|
if (!isGroupId(id))
|
|
102
|
-
return
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
181
|
+
return FILE_QUERIES.some((query) => id.endsWith(query)) &&
|
|
182
|
+
!isQueriedFileReadable(devServer.config, id)
|
|
183
|
+
? { code: "export default null;", moduleType: "js" }
|
|
184
|
+
: undefined;
|
|
185
|
+
if (imported.has(namingGroupOf(id)))
|
|
186
|
+
return undefined;
|
|
187
|
+
throw Object.assign(new Error(`Forgebook serves a group of files only when a module imports it: no module of the project imports ${id.slice(1)}`), { code: MISSING_MODULE });
|
|
107
188
|
}
|
|
108
189
|
};
|
|
109
190
|
};
|
|
191
|
+
/**
|
|
192
|
+
* The plugin's hook for Vitest (`configureVitest`), whose module cache keeps each module's transform on disk between
|
|
193
|
+
* runs: a module read from it resolves no import, so a group it names would stay refused from the second run on.
|
|
194
|
+
*/
|
|
195
|
+
export const VITEST_HOOKS = {
|
|
196
|
+
configureVitest: (context) => {
|
|
197
|
+
context.defineCacheKeyGenerator?.(({ sourceCode }) => (namesGroup(sourceCode) ? false : undefined));
|
|
198
|
+
}
|
|
199
|
+
};
|
|
@@ -33,7 +33,7 @@ import { currentCloseWatch } from "./closeWatch.js";
|
|
|
33
33
|
import { findConfigFile, loadConfigFile, packageResolver } from "./configFile.js";
|
|
34
34
|
import { codeStamp, diskCache } from "./diskCache.js";
|
|
35
35
|
import { createDocsExtractor, NO_DOCS_EXTRACTOR } from "./docsExtractor.js";
|
|
36
|
-
import { devServerAccess } from "./fileAccess.js";
|
|
36
|
+
import { devServerAccess, VITEST_HOOKS } from "./fileAccess.js";
|
|
37
37
|
import { FORCED_STATES_VISITOR } from "./forcedStates.js";
|
|
38
38
|
import { builtPlacement } from "./framesConfig.js";
|
|
39
39
|
import { serveFrames } from "./framesServer.js";
|
|
@@ -137,7 +137,7 @@ export const forgebookPlugin = (options = {}) => {
|
|
|
137
137
|
/** The groups' search indexes: any edit may change what a component's docs say. */
|
|
138
138
|
const searchModules = new Set();
|
|
139
139
|
/** What a client may read of a dev server. */
|
|
140
|
-
const access = devServerAccess(
|
|
140
|
+
const access = devServerAccess();
|
|
141
141
|
/** The errors the open frames report, for `forgebook errors` and the agents. */
|
|
142
142
|
const live = createLiveErrors();
|
|
143
143
|
/** Where the frames run: the dev server's second port once it listens, the configuration's choice in a build. */
|
|
@@ -392,7 +392,10 @@ export const forgebookPlugin = (options = {}) => {
|
|
|
392
392
|
resolved.logger.warn(`Forgebook's shell theme: ${problem}`);
|
|
393
393
|
},
|
|
394
394
|
configureServer: (server) => {
|
|
395
|
-
access.serve(server);
|
|
395
|
+
access.serve(server, config === undefined ? undefined : pageModulePath(config.dir, AGENT_SOURCES_FILE));
|
|
396
|
+
server.middlewares.use((request, _response, next) => {
|
|
397
|
+
access.settle(request.url ?? "/").then(() => next(), next);
|
|
398
|
+
});
|
|
396
399
|
// no person waits for a page in a test run
|
|
397
400
|
watching.serve(isTestRun ? undefined : server);
|
|
398
401
|
server.httpServer?.on("close", () => void docs?.close());
|
|
@@ -466,13 +469,14 @@ export const forgebookPlugin = (options = {}) => {
|
|
|
466
469
|
await docs?.close();
|
|
467
470
|
},
|
|
468
471
|
...storyFileHotHooks(isRedrawing, served.changedBy, watching),
|
|
472
|
+
...VITEST_HOOKS,
|
|
469
473
|
async load(id) {
|
|
470
474
|
const generated = await pagesModule(id);
|
|
471
475
|
if (generated !== undefined)
|
|
472
476
|
return generated;
|
|
473
|
-
|
|
474
|
-
if (
|
|
475
|
-
return
|
|
477
|
+
const refusal = access.refusal(id);
|
|
478
|
+
if (refusal !== undefined)
|
|
479
|
+
return refusal;
|
|
476
480
|
// in Vite's form, the text Vite's module graph knows a file by: a Node path on Windows (`C:\app\x.md`) is another
|
|
477
481
|
const watch = (file) => this.addWatchFile(VITE_PATHS.normalize(file));
|
|
478
482
|
const resolveImport = async (specifier, importer) => {
|