@webjsdev/server 0.8.91 → 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/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. The others stay
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.map((u) => `<${u}>; rel=modulepreload`),
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,
@@ -1,22 +1,18 @@
1
1
  /**
2
- * On-request regeneration of a stale build output in dev (#967).
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
- * prod that static file is built once by `start.before`. In DEV the classic
7
- * shape was a long-lived `tailwindcss --watch` in `dev.parallel`, but a watch
8
- * that dies mid-session, lags, or never starts (an app-dir relocation, a killed
9
- * child) then serves STALE or MISSING CSS with no error: a newly added utility
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, instead of relying on a live watch. It is deliberately GENERIC
15
- * and styling-agnostic: it runs whatever command the app declares, keyed by the
16
- * output path, so the framework stays BYO-styling (the Tailwind knowledge lives
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
- * When `/public/tailwind.css` is requested and the newest mtime under any of
29
- * `inputs` is newer than the output (or the output is missing), `command` is run
30
- * to completion BEFORE the file is served, so the response is always fresh.
31
- * Concurrent requests coalesce onto one in-flight compile.
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 { readFile, stat, readdir } from 'node:fs/promises';
36
- import { delimiter, dirname, join } from 'node:path';
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-freshness check: build output,
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
- * Newest mtime (ms) of any FILE under a path: the file's own mtime, or the max
79
- * over the files in a directory tree (skipping IGNORE_DIRS and dotfiles). A
80
- * missing path is 0, so it never makes the output look stale.
81
- *
82
- * Directory-node mtimes are deliberately NOT counted: a directory's mtime bumps
83
- * on a file add/remove but NOT on a content edit, so a content edit (the case
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
- * @returns {Promise<number>}
107
+ * @param {string[]} lines
108
+ * @returns {Promise<void>}
93
109
  */
94
- async function maxMtimeMs(abs) {
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
- return 0;
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 newest;
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
- const m = await maxMtimeMs(join(abs, e.name));
117
- if (m > newest) newest = m;
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
- return newest;
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. Never rejects (a compile failure is
144
- * logged by the tool's own inherited stderr; the caller then serves whatever is
145
- * on disk, degrading to the previous behaviour rather than 500ing the request).
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<void>}
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
- child.on('exit', () => resolve());
156
- child.on('error', () => resolve());
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
- // In-flight compiles, keyed by appDir + output, so concurrent requests for the
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 stale (older than the
167
- * newest input, or missing), run the rule's command to completion before
168
- * returning, so the caller serves a fresh file. A no-op when no rule matches or
169
- * the output is already fresh. Concurrent calls for the same output coalesce.
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 {{ spawn?: typeof nodeSpawn }} [opts] injectable spawn for tests
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 outAbs = join(appDir, rule.output);
187
- let outMtime = 0;
188
- try {
189
- outMtime = (await stat(outAbs)).mtimeMs;
190
- } catch {
191
- outMtime = 0; // missing output: always stale
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`. `fs.watch` reports a path
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
  *