@webjsdev/server 0.8.90 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.d.ts +41 -0
- package/index.js +1 -0
- package/package.json +3 -3
- package/src/action-input.js +208 -0
- package/src/actions.js +17 -2
- package/src/check/helpers.js +85 -0
- package/src/check/rules-components.js +33 -2
- package/src/check/rules.js +5 -0
- package/src/check/runner.js +2 -0
- package/src/component-elision.js +64 -88
- package/src/dev/dev-status.js +122 -0
- package/src/dev/handler.js +131 -1
- package/src/dev/serve.js +18 -8
- package/src/dev/server.js +17 -4
- package/src/dev/vendor-retry.js +12 -14
- package/src/dev-regenerate.js +320 -76
- package/src/dev-styles.js +64 -6
- package/src/form-dispatch.js +10 -4
- package/src/ssr/document.js +12 -4
- package/src/ssr/head.js +150 -2
- package/src/ssr/preloads.js +5 -0
- package/src/ssr/render.js +18 -29
- package/src/ssr/shipped-modules.js +50 -4
- package/src/testing.d.ts +1 -1
- package/src/testing.js +7 -2
- package/webjs-config.schema.json +2 -2
package/src/dev/server.js
CHANGED
|
@@ -18,6 +18,7 @@ import { stripBasePath } from '../base-path.js';
|
|
|
18
18
|
import { basePath } from '../importmap.js';
|
|
19
19
|
import { attachWebSocket } from '../websocket.js';
|
|
20
20
|
import { toWebRequest, sendWebResponse } from './helpers.js';
|
|
21
|
+
import { earlyHintLinks } from '../ssr/shipped-modules.js';
|
|
21
22
|
import { isAddrInUse, portInUseError, reusePortRequested } from '../port-in-use.js';
|
|
22
23
|
|
|
23
24
|
/** This copy of `@webjsdev/server`'s version, so a hot re-run can tell the framework itself changed. */
|
|
@@ -58,14 +59,21 @@ function debounce(fn, ms) {
|
|
|
58
59
|
* (drizzle-kit output) churn during db:migrate. The `db/dev.db` branch is
|
|
59
60
|
* prefix-only (no trailing separator) so the `-journal` / `-wal` sidecars match
|
|
60
61
|
* too, while staying anchored to `db/dev.db` so a SOURCE file like
|
|
61
|
-
* `db/schema.server.ts` still triggers a reload.
|
|
62
|
+
* `db/schema.server.ts` still triggers a reload. Beyond that, under the app's
|
|
63
|
+
* root `db/` only JS / TS source counts (#1664): a database file anywhere
|
|
64
|
+
* below it, whatever its name or sidecar (a `DATABASE_URL` pointing at
|
|
65
|
+
* `db/data/app.db`, libSQL's `-info` / `-client_wal_index` files), changes on
|
|
66
|
+
* every write and is skipped. Rails' reloader takes the same approach,
|
|
67
|
+
* watching a directory for source extensions only, rather than listing data
|
|
68
|
+
* files. The CLI's `shouldIgnoreRestartPath` carries the same pattern, and a
|
|
69
|
+
* test asserts the two agree over a corpus. The others stay
|
|
62
70
|
* separator-anchored so an unrelated name like `node_modules.bak/foo` does not.
|
|
63
71
|
*
|
|
64
72
|
* @param {string} filename relative path from an fs.watch `event.filename`
|
|
65
73
|
* @returns {boolean} true when the change should be ignored
|
|
66
74
|
*/
|
|
67
75
|
export function shouldIgnoreWatchPath(filename) {
|
|
68
|
-
return /(?:^|[\\/])(?:node_modules|\.git|\.webjs)(?:[\\/]|$)|(?:^|[\\/])db[\\/](?:dev\.db|migrations)/.test(filename || '');
|
|
76
|
+
return /(?:^|[\\/])(?:node_modules|\.git|\.webjs)(?:[\\/]|$)|(?:^|[\\/])db[\\/](?:dev\.db|migrations)|^db[\\/](?!.*\.[cm]?[jt]s$)/.test(filename || '');
|
|
69
77
|
}
|
|
70
78
|
|
|
71
79
|
/**
|
|
@@ -149,7 +157,10 @@ async function startNodeListener(ctx) {
|
|
|
149
157
|
// 103 Early Hints: before running SSR, send preload hints for the
|
|
150
158
|
// page's module URLs so the browser can begin fetching them while
|
|
151
159
|
// the server is still computing the body. Skipped in dev (file churn
|
|
152
|
-
// would send stale URLs after rebuilds) and for non-GET/HEAD.
|
|
160
|
+
// would send stale URLs after rebuilds) and for non-GET/HEAD. Plain
|
|
161
|
+
// preloads, never modulepreload: a module hint arrives before the
|
|
162
|
+
// page's import map, and Firefox then refuses the map (see
|
|
163
|
+
// earlyHintLinks in ../ssr/shipped-modules.js).
|
|
153
164
|
if (
|
|
154
165
|
!dev &&
|
|
155
166
|
(req.method === 'GET' || req.method === 'HEAD') &&
|
|
@@ -159,7 +170,7 @@ async function startNodeListener(ctx) {
|
|
|
159
170
|
if (match && match.moduleUrls.length) {
|
|
160
171
|
try {
|
|
161
172
|
res.writeEarlyHints({
|
|
162
|
-
link: match.moduleUrls
|
|
173
|
+
link: earlyHintLinks(match.moduleUrls),
|
|
163
174
|
});
|
|
164
175
|
} catch (e) {
|
|
165
176
|
logger.warn('writeEarlyHints failed', { err: String(e) });
|
|
@@ -368,6 +379,8 @@ export async function startServer(opts) {
|
|
|
368
379
|
if (app.isRegenerateOutput(filename)) return;
|
|
369
380
|
pendingVerdict = strongerVerdict(pendingVerdict, app.classifyWatchPath(filename, root));
|
|
370
381
|
if (hotKey && needsHotReset(filename, (f) => !app.needsRegistryReset(f, root))) pendingPoke = true;
|
|
382
|
+
// A status wait (#1664) holds until the rebuild this event schedules.
|
|
383
|
+
app.noteChange?.();
|
|
371
384
|
rebuild();
|
|
372
385
|
},
|
|
373
386
|
onError: onWatchError,
|
package/src/dev/vendor-retry.js
CHANGED
|
@@ -39,23 +39,21 @@ export function createRetryBackoff(opts = {}) {
|
|
|
39
39
|
const now = opts.now ?? Date.now;
|
|
40
40
|
let windowMs = 0;
|
|
41
41
|
let notBefore = 0;
|
|
42
|
+
// Closures over the state, not methods reading `this`, so a handle can be
|
|
43
|
+
// destructured or passed as a callback (`onRebuild(backoff.reset)`).
|
|
44
|
+
const succeeded = () => {
|
|
45
|
+
windowMs = 0;
|
|
46
|
+
notBefore = 0;
|
|
47
|
+
};
|
|
42
48
|
return {
|
|
43
|
-
allow()
|
|
44
|
-
|
|
45
|
-
},
|
|
46
|
-
failed() {
|
|
49
|
+
allow: () => windowMs === 0 || now() >= notBefore,
|
|
50
|
+
failed: () => {
|
|
47
51
|
windowMs = windowMs === 0 ? initialMs : Math.min(maxMs, windowMs * 2);
|
|
48
52
|
notBefore = now() + windowMs;
|
|
49
53
|
},
|
|
50
|
-
succeeded
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
reset() {
|
|
55
|
-
this.succeeded();
|
|
56
|
-
},
|
|
57
|
-
windowMs() {
|
|
58
|
-
return windowMs;
|
|
59
|
-
},
|
|
54
|
+
succeeded,
|
|
55
|
+
// A rebuild forgets the same thing a success does.
|
|
56
|
+
reset: succeeded,
|
|
57
|
+
windowMs: () => windowMs,
|
|
60
58
|
};
|
|
61
59
|
}
|
package/src/dev-regenerate.js
CHANGED
|
@@ -1,22 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Build outputs that rebuild themselves when stale (#967), and the record of
|
|
3
|
+
* what each one was built from (#1672).
|
|
3
4
|
*
|
|
4
5
|
* A UI scaffold compiles a STATIC `public/tailwind.css` (#947) so the app is
|
|
5
6
|
* fully styled with JavaScript OFF (progressive enhancement, invariant 1). In
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* class has no backing rule, so the app renders unstyled locally while prod is
|
|
11
|
-
* fine, a confusing, hard-to-attribute regression.
|
|
7
|
+
* DEV the classic shape was a long-lived `tailwindcss --watch` in
|
|
8
|
+
* `dev.parallel`, but a watch that dies mid-session, lags, or never starts then
|
|
9
|
+
* serves STALE or MISSING CSS with no error: a newly added utility class has no
|
|
10
|
+
* backing rule, so the app renders unstyled locally while prod is fine.
|
|
12
11
|
*
|
|
13
12
|
* This module removes that foot-gun by regenerating the output ON REQUEST when
|
|
14
|
-
* it is stale
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* in the scaffold's `package.json` config, not here). It is DEV-ONLY; prod keeps
|
|
18
|
-
* the static build, so dev and prod resolve classes through the exact SAME
|
|
19
|
-
* command (`tailwindcss -i input.css -o tailwind.css`) and cannot diverge.
|
|
13
|
+
* it is stale. It is deliberately GENERIC and styling-agnostic: it runs whatever
|
|
14
|
+
* command the app declares, keyed by the output path, so the framework stays
|
|
15
|
+
* BYO-styling (the Tailwind knowledge lives in the app's `package.json`).
|
|
20
16
|
*
|
|
21
17
|
* Declared as `webjs.dev.regenerate` in the app's `package.json`:
|
|
22
18
|
* "webjs": { "dev": { "regenerate": [
|
|
@@ -25,20 +21,44 @@
|
|
|
25
21
|
* "inputs": ["app", "components", "modules", "lib", "public/input.css"] }
|
|
26
22
|
* ] } }
|
|
27
23
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
24
|
+
* FRESHNESS IS A RECORD, NOT AN MTIME ORDERING (#1672). Every build this module
|
|
25
|
+
* runs records, in `.webjs/regenerate.json`, the command it ran, a fingerprint
|
|
26
|
+
* of its inputs taken BEFORE the command started, and a fingerprint of the
|
|
27
|
+
* output it produced. An output is fresh exactly when all three still match.
|
|
28
|
+
* This is the shape of Ninja's `.ninja_log` (which records per output the
|
|
29
|
+
* command hash and the mtime it was built against, so a `restat` output a rule
|
|
30
|
+
* left untouched is not rebuilt) and of Vite's dep optimizer
|
|
31
|
+
* (`node_modules/.vite/deps/_metadata.json`, a lockfile and config hash compared
|
|
32
|
+
* on startup). Comparing mtimes instead, as Make does, fails three ways that all
|
|
33
|
+
* bit here: a compiler that skips writing unchanged bytes (the Tailwind CLI)
|
|
34
|
+
* leaves the output older than its inputs forever, an input dated in the future
|
|
35
|
+
* hides every later edit, and a deleted input is invisible. Equality of a
|
|
36
|
+
* recorded fingerprint has none of those failure modes.
|
|
37
|
+
*
|
|
38
|
+
* The same rules run outside the dev server through `webjs regenerate` (the
|
|
39
|
+
* CLI), which an app puts in `dev.before` / `start.before` in place of a raw
|
|
40
|
+
* compile, so every build path writes the one record and `webjs doctor` reads
|
|
41
|
+
* it. A build by hand (`npm run css:build`) writes no record, so the output
|
|
42
|
+
* reads as unrecorded and is rebuilt once on its next request.
|
|
32
43
|
*/
|
|
33
44
|
|
|
34
45
|
import { spawn as nodeSpawn } from 'node:child_process';
|
|
35
|
-
import {
|
|
36
|
-
import {
|
|
46
|
+
import { createHash } from 'node:crypto';
|
|
47
|
+
import { copyFile, mkdir, readFile, readdir, rename, rm, stat, unlink, writeFile } from 'node:fs/promises';
|
|
48
|
+
import { delimiter, dirname, join, relative, sep } from 'node:path';
|
|
37
49
|
|
|
38
50
|
/**
|
|
39
51
|
* @typedef {{ output: string, command: string, inputs: string[] }} RegenerateRule
|
|
52
|
+
* @typedef {{ command: string, inputs: string, output: string }} RegenerateRecord
|
|
53
|
+
* @typedef {{ version: 1, outputs: Record<string, RegenerateRecord> }} RegenerateManifest
|
|
54
|
+
* @typedef {'fresh' | 'stale' | 'missing' | 'unrecorded'} RegenerateState
|
|
55
|
+
* @typedef {{ ok: true } | { ok: false, reason: 'failed' | 'no-output' }} BuildResult
|
|
56
|
+
* @typedef {{ spawn?: typeof nodeSpawn, warn?: (msg: string) => void }} BuildOptions
|
|
40
57
|
*/
|
|
41
58
|
|
|
59
|
+
/** The record of every build, relative to the app root. */
|
|
60
|
+
export const REGENERATE_MANIFEST = '.webjs/regenerate.json';
|
|
61
|
+
|
|
42
62
|
/**
|
|
43
63
|
* Read + normalize the `webjs.dev.regenerate` rules from an app's
|
|
44
64
|
* `package.json`. Defensive: a missing/malformed block yields `[]` (a plain app
|
|
@@ -69,54 +89,175 @@ export async function readRegenerateRules(appDir, read) {
|
|
|
69
89
|
}));
|
|
70
90
|
}
|
|
71
91
|
|
|
72
|
-
// Directories never worth walking for a source
|
|
92
|
+
// Directories never worth walking for a source fingerprint: build output,
|
|
73
93
|
// dependencies, VCS + framework caches. Skipping them keeps the walk cheap and
|
|
74
94
|
// avoids treating a dependency's mtime as an app-source edit.
|
|
75
95
|
const IGNORE_DIRS = new Set(['node_modules', '.git', '.webjs', 'dist', '.next', 'coverage']);
|
|
76
96
|
|
|
77
97
|
/**
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
* Directory-node mtimes are
|
|
83
|
-
*
|
|
84
|
-
* that matters, a new utility class in an existing file) is only visible through
|
|
85
|
-
* the FILE mtime. Counting a new file's own mtime still catches an addition;
|
|
86
|
-
* only a pure deletion is missed, which is harmless (stale rules self-heal on
|
|
87
|
-
* the next real edit) and keeps the freshness check deterministic (a directory
|
|
88
|
-
* mtime is a moving target that made the comparison flaky across runtimes).
|
|
89
|
-
* Pure I/O, no throw.
|
|
98
|
+
* Append one line per FILE under `abs` to `lines`: its appDir-relative path,
|
|
99
|
+
* size and mtime. A missing path contributes a `missing` line, so creating it
|
|
100
|
+
* later changes the fingerprint. Skips IGNORE_DIRS, dotfiles and symlinks (a
|
|
101
|
+
* symlink can cycle into unbounded recursion or escape into node_modules).
|
|
102
|
+
* Directory-node mtimes are not counted: an addition or a deletion already
|
|
103
|
+
* shows as a line appearing or disappearing. Pure I/O, no throw.
|
|
90
104
|
*
|
|
105
|
+
* @param {string} appDir
|
|
91
106
|
* @param {string} abs
|
|
92
|
-
* @
|
|
107
|
+
* @param {string[]} lines
|
|
108
|
+
* @returns {Promise<void>}
|
|
93
109
|
*/
|
|
94
|
-
async function
|
|
110
|
+
async function collectFiles(appDir, abs, lines) {
|
|
111
|
+
const rel = relative(appDir, abs).split(sep).join('/');
|
|
95
112
|
let st;
|
|
96
113
|
try {
|
|
97
114
|
st = await stat(abs);
|
|
98
115
|
} catch {
|
|
99
|
-
|
|
116
|
+
lines.push(`${rel}\0missing`);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (!st.isDirectory()) {
|
|
120
|
+
lines.push(`${rel}\0${st.size}\0${st.mtimeMs}`);
|
|
121
|
+
return;
|
|
100
122
|
}
|
|
101
|
-
if (!st.isDirectory()) return st.mtimeMs;
|
|
102
|
-
let newest = 0;
|
|
103
123
|
let entries;
|
|
104
124
|
try {
|
|
105
125
|
entries = await readdir(abs, { withFileTypes: true });
|
|
106
126
|
} catch {
|
|
107
|
-
return
|
|
127
|
+
return;
|
|
108
128
|
}
|
|
109
129
|
for (const e of entries) {
|
|
110
130
|
if (e.name.startsWith('.') || IGNORE_DIRS.has(e.name)) continue;
|
|
111
|
-
// Skip symlinks entirely: following one can cycle (a link back to an
|
|
112
|
-
// ancestor) into unbounded recursion, or escape into node_modules via a
|
|
113
|
-
// renamed link. A symlinked source file is rare in an app tree; not
|
|
114
|
-
// counting its mtime is a safe tradeoff for a walk that cannot hang.
|
|
115
131
|
if (e.isSymbolicLink()) continue;
|
|
116
|
-
|
|
117
|
-
|
|
132
|
+
await collectFiles(appDir, join(abs, e.name), lines);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* A fingerprint of every file under a rule's `inputs`: a hash over each file's
|
|
138
|
+
* path, size and mtime, so an edit, an addition, a deletion and a rename all
|
|
139
|
+
* change it. Compared for EQUALITY, never ordering, so a file dated in the
|
|
140
|
+
* future is just another value and later edits still change the result.
|
|
141
|
+
*
|
|
142
|
+
* The rule's own `output` is left out even when an input directory contains it
|
|
143
|
+
* (`inputs: ["public"]` around `public/tailwind.css`): every build rewrites the
|
|
144
|
+
* output, so counting it would make each build invalidate itself.
|
|
145
|
+
*
|
|
146
|
+
* A command must not rewrite files under its own `inputs` with unchanged
|
|
147
|
+
* bytes, for the same reason: a prepare step inside the command (a copy into
|
|
148
|
+
* an input directory) has to compare before writing. One that writes a file
|
|
149
|
+
* whose content did change costs a single extra rebuild, which then settles.
|
|
150
|
+
*
|
|
151
|
+
* @param {string} appDir
|
|
152
|
+
* @param {RegenerateRule} rule
|
|
153
|
+
* @returns {Promise<string>}
|
|
154
|
+
*/
|
|
155
|
+
export async function inputFingerprint(appDir, rule) {
|
|
156
|
+
/** @type {string[]} */
|
|
157
|
+
const lines = [];
|
|
158
|
+
for (const inp of rule.inputs) await collectFiles(appDir, join(appDir, inp), lines);
|
|
159
|
+
const self = rule.output + '\0';
|
|
160
|
+
const own = lines.filter((l) => !l.startsWith(self)).sort();
|
|
161
|
+
return createHash('sha256').update(own.join('\n')).digest('hex');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The output's size and mtime, read right after a build and compared on every
|
|
166
|
+
* check, so a hand edit, a delete, or a different build replacing the file all
|
|
167
|
+
* read as stale. `null` when the output does not exist.
|
|
168
|
+
*
|
|
169
|
+
* @param {string} abs
|
|
170
|
+
* @returns {Promise<string | null>}
|
|
171
|
+
*/
|
|
172
|
+
async function outputFingerprint(abs) {
|
|
173
|
+
try {
|
|
174
|
+
const st = await stat(abs);
|
|
175
|
+
return st.isFile() ? `${st.size}:${st.mtimeMs}` : null;
|
|
176
|
+
} catch {
|
|
177
|
+
return null;
|
|
118
178
|
}
|
|
119
|
-
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Read the build record. A missing, malformed, or other-version file reads as
|
|
183
|
+
* empty, which makes every output unrecorded and rebuilds it once: the safe
|
|
184
|
+
* direction.
|
|
185
|
+
*
|
|
186
|
+
* @param {string} appDir
|
|
187
|
+
* @returns {Promise<RegenerateManifest>}
|
|
188
|
+
*/
|
|
189
|
+
export async function readRegenerateManifest(appDir) {
|
|
190
|
+
try {
|
|
191
|
+
const m = JSON.parse(await readFile(join(appDir, REGENERATE_MANIFEST), 'utf8'));
|
|
192
|
+
if (m && m.version === 1 && m.outputs && typeof m.outputs === 'object') return m;
|
|
193
|
+
} catch {
|
|
194
|
+
// fall through
|
|
195
|
+
}
|
|
196
|
+
return { version: 1, outputs: {} };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Writes to one manifest, chained per path. Parallel requests for different
|
|
200
|
+
// outputs (the website's /ui page links nine stylesheets) each record a build;
|
|
201
|
+
// unserialized, those read-modify-writes would drop each other's entries. A
|
|
202
|
+
// dev server and a concurrent `webjs regenerate` are separate processes and can
|
|
203
|
+
// still lose one entry, which costs one extra rebuild: the safe direction.
|
|
204
|
+
/** @type {Map<string, Promise<void>>} */
|
|
205
|
+
const manifestWrites = new Map();
|
|
206
|
+
let tmpCounter = 0;
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Record one build, read-modify-write, written to a temp file and renamed so a
|
|
210
|
+
* reader never sees a half-written manifest. Never throws: a read-only
|
|
211
|
+
* filesystem (a locked-down prod image) must still serve, it only loses the
|
|
212
|
+
* record and rebuilds again.
|
|
213
|
+
*
|
|
214
|
+
* @param {string} appDir
|
|
215
|
+
* @param {string} output
|
|
216
|
+
* @param {RegenerateRecord} record
|
|
217
|
+
* @param {(msg: string) => void} warn
|
|
218
|
+
* @returns {Promise<void>}
|
|
219
|
+
*/
|
|
220
|
+
function recordBuild(appDir, output, record, warn) {
|
|
221
|
+
const path = join(appDir, REGENERATE_MANIFEST);
|
|
222
|
+
const prev = manifestWrites.get(path) || Promise.resolve();
|
|
223
|
+
const next = prev.then(async () => {
|
|
224
|
+
try {
|
|
225
|
+
const manifest = await readRegenerateManifest(appDir);
|
|
226
|
+
manifest.outputs[output] = record;
|
|
227
|
+
await mkdir(dirname(path), { recursive: true });
|
|
228
|
+
const tmp = `${path}.${process.pid}.${++tmpCounter}.tmp`;
|
|
229
|
+
await writeFile(tmp, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
|
|
230
|
+
await rename(tmp, path);
|
|
231
|
+
} catch (err) {
|
|
232
|
+
warn(`[webjs] regenerate: could not record the build of ${output} in ${REGENERATE_MANIFEST} (${/** @type {Error} */ (err).message}); it will be rebuilt on its next request.`);
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
manifestWrites.set(path, next);
|
|
236
|
+
return next.finally(() => {
|
|
237
|
+
if (manifestWrites.get(path) === next) manifestWrites.delete(path);
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Whether a rule's output is fresh against the record. Cheapest test first: a
|
|
243
|
+
* missing output or a missing record needs no input walk at all, and neither
|
|
244
|
+
* does a changed command or an output something else rewrote. Only a record
|
|
245
|
+
* that matches on all of those walks the inputs.
|
|
246
|
+
*
|
|
247
|
+
* @param {string} appDir
|
|
248
|
+
* @param {RegenerateRule} rule
|
|
249
|
+
* @param {RegenerateManifest} [manifest]
|
|
250
|
+
* @returns {Promise<{ state: RegenerateState, inputs?: string }>} `inputs` is the
|
|
251
|
+
* fingerprint when the check computed it, so a build can record it unwalked.
|
|
252
|
+
*/
|
|
253
|
+
export async function regenerateState(appDir, rule, manifest) {
|
|
254
|
+
const out = await outputFingerprint(join(appDir, rule.output));
|
|
255
|
+
if (out === null) return { state: 'missing' };
|
|
256
|
+
const record = (manifest || await readRegenerateManifest(appDir)).outputs[rule.output];
|
|
257
|
+
if (!record) return { state: 'unrecorded' };
|
|
258
|
+
if (record.command !== rule.command || record.output !== out) return { state: 'stale' };
|
|
259
|
+
const inputs = await inputFingerprint(appDir, rule);
|
|
260
|
+
return { state: record.inputs === inputs ? 'fresh' : 'stale', inputs };
|
|
120
261
|
}
|
|
121
262
|
|
|
122
263
|
/**
|
|
@@ -140,38 +281,108 @@ function envWithLocalBin(cwd) {
|
|
|
140
281
|
}
|
|
141
282
|
|
|
142
283
|
/**
|
|
143
|
-
* Run a regenerate command to completion
|
|
144
|
-
*
|
|
145
|
-
*
|
|
284
|
+
* Run a regenerate command to completion and resolve its exit code: `null` for
|
|
285
|
+
* a spawn error or a child killed by a signal, so an interrupted compile is
|
|
286
|
+
* never recorded. Never rejects: a compile failure is logged by the tool's own
|
|
287
|
+
* inherited stderr.
|
|
146
288
|
*
|
|
147
289
|
* @param {string} command
|
|
148
290
|
* @param {string} cwd
|
|
149
291
|
* @param {typeof nodeSpawn} spawn
|
|
150
|
-
* @returns {Promise<
|
|
292
|
+
* @returns {Promise<number | null>}
|
|
151
293
|
*/
|
|
152
294
|
function runCommand(command, cwd, spawn) {
|
|
153
295
|
return new Promise((resolve) => {
|
|
154
296
|
const child = spawn(command, { shell: true, stdio: 'inherit', cwd, env: envWithLocalBin(cwd) });
|
|
155
|
-
|
|
156
|
-
child.on('
|
|
297
|
+
// A Promise settles once, so whichever of these fires second is ignored.
|
|
298
|
+
child.on('exit', (code) => resolve(typeof code === 'number' ? code : null));
|
|
299
|
+
child.on('error', () => resolve(null));
|
|
157
300
|
});
|
|
158
301
|
}
|
|
159
302
|
|
|
160
|
-
|
|
303
|
+
/**
|
|
304
|
+
* Move a file, falling back to copy + delete across filesystems (`.webjs` may
|
|
305
|
+
* be a link onto another volume). Resolves false when there is nothing to move.
|
|
306
|
+
*
|
|
307
|
+
* @param {string} from
|
|
308
|
+
* @param {string} to
|
|
309
|
+
* @returns {Promise<boolean>}
|
|
310
|
+
*/
|
|
311
|
+
async function moveFile(from, to) {
|
|
312
|
+
try {
|
|
313
|
+
await rename(from, to);
|
|
314
|
+
return true;
|
|
315
|
+
} catch (err) {
|
|
316
|
+
if (/** @type {NodeJS.ErrnoException} */ (err).code === 'ENOENT') return false;
|
|
317
|
+
await copyFile(from, to);
|
|
318
|
+
await unlink(from);
|
|
319
|
+
return true;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Run a rule's command and record what it built.
|
|
325
|
+
*
|
|
326
|
+
* The output is moved aside into `.webjs/regenerate/` first, so the command has
|
|
327
|
+
* to WRITE it: a compiler that skips an unchanged write (the Tailwind CLI reads
|
|
328
|
+
* the existing file and returns early) writes a missing one. That makes the
|
|
329
|
+
* outcome decisive. Exit 0 with the output present is recorded. Exit 0 with no
|
|
330
|
+
* output means the rule's `output` is not the path its command writes, so the
|
|
331
|
+
* previous output is restored, nothing is recorded, and a warning names the
|
|
332
|
+
* rule, which repeats on every request until the rule is fixed. A failed or
|
|
333
|
+
* interrupted command restores the previous output (a last good stylesheet
|
|
334
|
+
* beats a partial one) and records nothing, so the next request retries.
|
|
335
|
+
*
|
|
336
|
+
* @param {string} appDir
|
|
337
|
+
* @param {RegenerateRule} rule
|
|
338
|
+
* @param {BuildOptions & { inputs?: string }} [opts] `inputs`: a fingerprint
|
|
339
|
+
* already taken by `regenerateState`, so the build does not walk twice
|
|
340
|
+
* @returns {Promise<BuildResult>}
|
|
341
|
+
*/
|
|
342
|
+
export async function buildRegenerateOutput(appDir, rule, opts = {}) {
|
|
343
|
+
const spawn = opts.spawn || nodeSpawn;
|
|
344
|
+
const warn = opts.warn || ((m) => console.warn(m));
|
|
345
|
+
// The snapshot is taken BEFORE the command runs, so a file saved during the
|
|
346
|
+
// compile no longer matches the record and the next check rebuilds.
|
|
347
|
+
const inputs = opts.inputs ?? await inputFingerprint(appDir, rule);
|
|
348
|
+
const outAbs = join(appDir, rule.output);
|
|
349
|
+
const backupDir = join(appDir, '.webjs', 'regenerate');
|
|
350
|
+
const backup = join(backupDir, encodeURIComponent(rule.output));
|
|
351
|
+
await mkdir(backupDir, { recursive: true });
|
|
352
|
+
await rm(backup, { force: true }); // a leftover from an interrupted process
|
|
353
|
+
const hadOutput = await moveFile(outAbs, backup);
|
|
354
|
+
|
|
355
|
+
const code = await runCommand(rule.command, appDir, spawn);
|
|
356
|
+
const output = code === 0 ? await outputFingerprint(outAbs) : null;
|
|
357
|
+
if (output !== null) {
|
|
358
|
+
await rm(backup, { force: true });
|
|
359
|
+
await recordBuild(appDir, rule.output, { command: rule.command, inputs, output }, warn);
|
|
360
|
+
return { ok: true };
|
|
361
|
+
}
|
|
362
|
+
if (hadOutput) await moveFile(backup, outAbs);
|
|
363
|
+
if (code === 0) {
|
|
364
|
+
warn(`[webjs] regenerate: \`${rule.command}\` exited 0 but did not write ${rule.output}. Check that the rule's "output" is the path its command writes.`);
|
|
365
|
+
return { ok: false, reason: 'no-output' };
|
|
366
|
+
}
|
|
367
|
+
return { ok: false, reason: 'failed' };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// In-flight builds, keyed by appDir + output, so concurrent requests for the
|
|
161
371
|
// same stale output share ONE compile instead of racing N of them. Module-scope
|
|
162
372
|
// (not per-handler): the filesystem is the shared resource being guarded.
|
|
163
373
|
const inFlight = new Map();
|
|
164
374
|
|
|
165
375
|
/**
|
|
166
|
-
* If a regenerate rule matches `relPath` and its output is
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
376
|
+
* If a regenerate rule matches `relPath` and its output is not fresh against
|
|
377
|
+
* the record, build it before returning, so the caller serves a fresh file. A
|
|
378
|
+
* no-op when no rule matches or the output is fresh. Concurrent calls for the
|
|
379
|
+
* same output coalesce. Never rejects: a failed build leaves the previous
|
|
380
|
+
* output on disk to be served.
|
|
170
381
|
*
|
|
171
382
|
* @param {string} appDir
|
|
172
383
|
* @param {string} relPath appDir-relative served path, no leading slash (e.g. `public/tailwind.css`)
|
|
173
384
|
* @param {RegenerateRule[]} rules
|
|
174
|
-
* @param {
|
|
385
|
+
* @param {BuildOptions} [opts] injectable spawn and warning sink for tests
|
|
175
386
|
* @returns {Promise<void>}
|
|
176
387
|
*/
|
|
177
388
|
export async function maybeRegenerate(appDir, relPath, rules, opts = {}) {
|
|
@@ -181,25 +392,13 @@ export async function maybeRegenerate(appDir, relPath, rules, opts = {}) {
|
|
|
181
392
|
const pending = inFlight.get(key);
|
|
182
393
|
if (pending) return pending;
|
|
183
394
|
|
|
184
|
-
const spawn = opts.spawn || nodeSpawn;
|
|
185
395
|
const run = (async () => {
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
}
|
|
193
|
-
if (outMtime !== 0) {
|
|
194
|
-
let newestSrc = 0;
|
|
195
|
-
for (const inp of rule.inputs) {
|
|
196
|
-
const m = await maxMtimeMs(join(appDir, inp));
|
|
197
|
-
if (m > newestSrc) newestSrc = m;
|
|
198
|
-
}
|
|
199
|
-
if (newestSrc <= outMtime) return; // fresh, nothing to do
|
|
200
|
-
}
|
|
201
|
-
await runCommand(rule.command, appDir, spawn);
|
|
202
|
-
})();
|
|
396
|
+
const { state, inputs } = await regenerateState(appDir, rule);
|
|
397
|
+
if (state === 'fresh') return;
|
|
398
|
+
await buildRegenerateOutput(appDir, rule, { ...opts, inputs });
|
|
399
|
+
})().catch((err) => {
|
|
400
|
+
(opts.warn || console.warn)(`[webjs] regenerate: building ${rule.output} failed (${err && err.message}); serving what is on disk.`);
|
|
401
|
+
});
|
|
203
402
|
inFlight.set(key, run);
|
|
204
403
|
try {
|
|
205
404
|
await run;
|
|
@@ -208,13 +407,58 @@ export async function maybeRegenerate(appDir, relPath, rules, opts = {}) {
|
|
|
208
407
|
}
|
|
209
408
|
}
|
|
210
409
|
|
|
410
|
+
/**
|
|
411
|
+
* Build every rule whose output is not fresh, one at a time, and report each.
|
|
412
|
+
* The engine behind `webjs regenerate`, which an app runs in `dev.before` /
|
|
413
|
+
* `start.before` (or a CI step) so a build outside the dev server records its
|
|
414
|
+
* freshness the same way a dev request does. `force` rebuilds every rule.
|
|
415
|
+
*
|
|
416
|
+
* @param {string} appDir
|
|
417
|
+
* @param {{ force?: boolean, rules?: RegenerateRule[] } & BuildOptions} [opts]
|
|
418
|
+
* @returns {Promise<Array<{ output: string, state: RegenerateState, built: boolean } & ({ ok: true } | { ok: false, reason: 'failed' | 'no-output' })>>}
|
|
419
|
+
*/
|
|
420
|
+
export async function regenerateOutputs(appDir, opts = {}) {
|
|
421
|
+
const rules = opts.rules || await readRegenerateRules(appDir);
|
|
422
|
+
const results = [];
|
|
423
|
+
for (const rule of rules) {
|
|
424
|
+
const { state, inputs } = await regenerateState(appDir, rule);
|
|
425
|
+
if (state === 'fresh' && !opts.force) {
|
|
426
|
+
results.push({ output: rule.output, state, built: false, ok: /** @type {const} */ (true) });
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
const r = await buildRegenerateOutput(appDir, rule, { ...opts, inputs });
|
|
430
|
+
results.push({ output: rule.output, state, built: true, ...r });
|
|
431
|
+
}
|
|
432
|
+
return results;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Every rule's state against the record, without building anything. What
|
|
437
|
+
* `webjs doctor` reads, so the doctor and the dev server share one definition
|
|
438
|
+
* of fresh.
|
|
439
|
+
*
|
|
440
|
+
* @param {string} appDir
|
|
441
|
+
* @returns {Promise<Array<{ output: string, command: string, state: RegenerateState }>>}
|
|
442
|
+
*/
|
|
443
|
+
export async function regenerateStatus(appDir) {
|
|
444
|
+
const rules = await readRegenerateRules(appDir);
|
|
445
|
+
const manifest = await readRegenerateManifest(appDir);
|
|
446
|
+
const out = [];
|
|
447
|
+
for (const rule of rules) {
|
|
448
|
+
const { state } = await regenerateState(appDir, rule, manifest);
|
|
449
|
+
out.push({ output: rule.output, command: rule.command, state });
|
|
450
|
+
}
|
|
451
|
+
return out;
|
|
452
|
+
}
|
|
453
|
+
|
|
211
454
|
/**
|
|
212
455
|
* Whether a dev-watcher filename is a regenerate OUTPUT (#967). A regenerate
|
|
213
456
|
* output is a build product the dev server itself writes on request, so a write
|
|
214
457
|
* to it must NOT trigger a rebuild + SSE reload: that would spuriously reload
|
|
215
458
|
* the page mid-navigation (breaking a soft-nav / streaming interaction) and,
|
|
216
459
|
* worse, could reload -> refetch -> recompile -> reload. Same rationale as the
|
|
217
|
-
* `db/dev.db` carve-out in `shouldIgnoreWatchPath
|
|
460
|
+
* `db/dev.db` carve-out in `shouldIgnoreWatchPath`, which also skips `.webjs/`,
|
|
461
|
+
* where the record and the moved-aside output live. `fs.watch` reports a path
|
|
218
462
|
* relative to the watched root with OS separators, so it is normalized to `/`
|
|
219
463
|
* before matching the rule's already-normalized `output`.
|
|
220
464
|
*
|