@webjsdev/server 0.8.50 → 0.8.51

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/server",
3
- "version": "0.8.50",
3
+ "version": "0.8.51",
4
4
  "type": "module",
5
5
  "description": "webjs dev/prod server: SSR, router, API, server actions, live reload",
6
6
  "main": "index.js",
package/src/check.js CHANGED
@@ -122,7 +122,7 @@ export const RULES = [
122
122
  {
123
123
  name: 'no-server-import-in-browser-module',
124
124
  description:
125
- 'A page / layout / component module that SHIPS to the browser (the build does NOT elide it) must not transitively import a server-only `.server.{ts,js}` module. The server module is replaced by a stub in the browser, so the import is fine while the page is display-only and elided (its server import is stripped), but the moment the page also does client work (it imports a component to register, enables the client router, uses a reactive primitive, …) it stops being elided and must load in the browser, dragging the server import with it. The stub then throws (or a server-only export like `auth` is missing) the instant the module loads, a runtime browser crash that `webjs typecheck` and the rest of `webjs check` miss. The rule reuses the build\'s own elision verdict, so it ONLY fires on modules that genuinely ship; a display-only page the framework elides is never flagged. The fix is to keep the server call off the browser-shipped module: gate the route in `middleware.ts`, call the server through a `\'use server\'` action, or register the component in a layout so the page elides again. Server-to-server imports (`.server.ts` importing `.server.ts`) and `middleware.ts` / `route.ts` (never shipped) are never flagged.',
125
+ 'A page / layout / component module that SHIPS to the browser (the build does NOT elide it) must not transitively import a server-only `.server.{ts,js}` module. The server module is replaced by a stub in the browser, so the import is fine while the module never loads client-side: a display-only page is elided, and a page whose only client relevance is importing shipping components is import-only (#605/#963), dropped from the boot in favour of its components. But the moment the page does its OWN client work (the client router, a reactive primitive, a module-scope browser-global access, a client-effecting non-component util) it ships whole, dragging the server import with it. The stub then throws (or a server-only export like `auth` is missing) the instant the module loads, a runtime browser crash that `webjs typecheck` and the rest of `webjs check` miss. The rule reuses the build\'s own elision verdict, so it ONLY fires on modules that genuinely ship; an elided, inert, or import-only page is never flagged. The fix is to keep the server call off the browser-shipped module: gate the route in `middleware.ts`, call the server through a `\'use server\'` action, or move the module\'s own client work into a component so the page is dropped again. Server-to-server imports (`.server.ts` importing `.server.ts`) and `middleware.ts` / `route.ts` (never shipped) are never flagged.',
126
126
  },
127
127
  {
128
128
  name: 'one-action-per-configured-file',
@@ -1219,11 +1219,12 @@ export async function checkConventions(appDir) {
1219
1219
  // --- Rule: no-server-import-in-browser-module ---
1220
1220
  // A page / layout / component module that SHIPS to the browser must not
1221
1221
  // transitively import a server-only `.server.{ts,js}` module. The browser
1222
- // gets a stub for the server file, so the import is harmless while the page
1223
- // is display-only and the framework ELIDES it (its server import is stripped
1224
- // from the served source). But the moment the page also does client work
1225
- // (imports a component to register, enables the client router, uses a
1226
- // reactive primitive, …) it stops being elided, must load in the browser,
1222
+ // gets a stub for the server file, so the import is harmless while the
1223
+ // module never loads client-side: a display-only page is elided, and an
1224
+ // import-only page (#605/#963) is dropped from the boot in favour of its
1225
+ // components. But the moment the page does its OWN client work (the client
1226
+ // router, a reactive primitive, module-scope code, a client-effecting util
1227
+ // on a component-free path) it ships whole, must load in the browser,
1227
1228
  // and drags the server import with it: the stub throws (or a server-only
1228
1229
  // export like `auth` is missing) the instant the module loads. That crash
1229
1230
  // only surfaces at runtime; typecheck and every other check pass.
@@ -1425,21 +1426,25 @@ async function checkServerImportInBrowserModule(appDir, violations) {
1425
1426
  // rule treats every candidate as shipping (which is correct: with elision
1426
1427
  // off, a display-only page really does ship its server import too).
1427
1428
  const elideEnabled = await readElideEnabledForCheck(appDir);
1428
- const { elidableComponents, inertRouteModules } = elideEnabled
1429
+ const { elidableComponents, inertRouteModules, importOnlyRouteModules } = elideEnabled
1429
1430
  ? await analyzeElision(components, routeModules, moduleGraph, (f) => readFile(f, 'utf8'), appDir)
1430
- : { elidableComponents: new Set(), inertRouteModules: new Set() };
1431
+ : { elidableComponents: new Set(), inertRouteModules: new Set(), importOnlyRouteModules: new Map() };
1431
1432
 
1432
1433
  // Candidate browser-shipped modules: components that are NOT elided, plus
1433
- // route modules that are NOT inert. A `.server.*` file is never a component
1434
- // (the scanner skips it) nor a route module the browser loads, so it cannot
1435
- // enter this set; server-to-server imports are excluded by construction.
1434
+ // route modules that are NOT inert and NOT import-only (an import-only
1435
+ // module is dropped from the boot in favour of its component frontier,
1436
+ // #605/#963, so its own imports never load in a browser and a bare
1437
+ // server-only import in it is harmless). A `.server.*` file is never a
1438
+ // component (the scanner skips it) nor a route module the browser loads, so
1439
+ // it cannot enter this set; server-to-server imports are excluded by
1440
+ // construction.
1436
1441
  /** @type {Map<string, { kind: string }>} relFile is keyed by ABS path */
1437
1442
  const candidates = new Map();
1438
1443
  for (const c of components) {
1439
1444
  if (!elidableComponents.has(c.file)) candidates.set(c.file, { kind: 'component' });
1440
1445
  }
1441
1446
  for (const file of routeModules) {
1442
- if (inertRouteModules.has(file)) continue;
1447
+ if (inertRouteModules.has(file) || importOnlyRouteModules.has(file)) continue;
1443
1448
  const base = basename(file);
1444
1449
  const kind = /^layout\./.test(base) ? 'layout' : 'page';
1445
1450
  candidates.set(file, { kind });
@@ -1496,17 +1501,20 @@ async function checkServerImportInBrowserModule(appDir, violations) {
1496
1501
  const importer = chainFiles.length >= 2 ? chainFiles[chainFiles.length - 2] : null;
1497
1502
  const viaTypesModule = importer && /(^|\/)types(\.m?[jt]s$|\/)/.test(relative(appDir, importer));
1498
1503
 
1499
- // The "register the component in a layout so it elides again" remedy only
1500
- // applies to a page / layout, which CAN elide (it became browser-bound by
1501
- // importing a component). The error / loading / not-found boundaries always
1502
- // ship and are never elided, so offering them an "elides again" fix is
1503
- // wrong. Branch the fix text on whether the kind can elide.
1504
+ // The "elides again" remedy only applies to a page / layout: since the
1505
+ // path-aware import-only verdict (#963), a page importing a component to
1506
+ // register is dropped from the boot, so a page/layout on this rule ships
1507
+ // because of its OWN client work (or a client-effecting non-component in
1508
+ // its closure); moving that work into a component makes it a dropped
1509
+ // carrier again and the server import never loads. The error / loading /
1510
+ // not-found boundaries always ship and are never elided, so offering
1511
+ // them an "elides again" fix is wrong. Branch the fix text on kind.
1504
1512
  const canElide = kind === 'page' || kind === 'layout';
1505
1513
  const typesHint = viaTypesModule
1506
1514
  ? `The edge enters via a types-shaped module (${relative(appDir, importer)}); if it re-exports a runtime VALUE from a \`.server.{ts,js}\` file, relocate that to a browser-safe typedef (a plain \`interface\` / JSDoc, or an \`import type\` which the stripper erases) so the type is shared without pinning the module. `
1507
1515
  : '';
1508
1516
  const fixText = canElide
1509
- ? `${typesHint}Keep the server call off this browser-shipped ${kind}. Options: (1) gate the route in \`middleware.ts\` (runs server-side, never ships); (2) move the server-only call behind a \`'use server'\` action in a \`.server.{ts,js}\` file and call it as an RPC; or (3) if this ${kind} only became browser-bound because it imports a component to register, register that component in a \`layout.{ts,js}\` instead so the ${kind} elides again and its server import is stripped.`
1517
+ ? `${typesHint}Keep the server call off this browser-shipped ${kind}. Options: (1) gate the route in \`middleware.ts\` (runs server-side, never ships); (2) move the server-only call behind a \`'use server'\` action in a \`.server.{ts,js}\` file and call it as an RPC; or (3) move this ${kind}'s own client work (the module-scope call, browser-global access, or client-effecting util import that pins it) into a component, so the ${kind} elides again as a dropped carrier and its server import never loads.`
1510
1518
  : `${typesHint}Keep the server call off this browser-shipped ${kind} (it always ships and is never elided). Options: (1) gate the route in \`middleware.ts\` (runs server-side, never ships); or (2) move the server-only call behind a \`'use server'\` action in a \`.server.{ts,js}\` file and call it as an RPC.`;
1511
1519
 
1512
1520
  violations.push({
@@ -141,18 +141,21 @@ export const CLIENT_METHOD_CALLS = ['addController', 'removeController', 'reques
141
141
  * component that arrives via a client DOM insertion (a soft-nav swap, a
142
142
  * streamed <webjs-suspense> boundary's replaceWith) would never re-attach
143
143
  * its shadow root if elided. Context-free, so any shadow component ships.
144
- * - `refresh`: `static refresh = true` is the explicit opt-in to ship a bare
145
- * async component so its stale-while-revalidate refresh runs after SSR;
146
- * eliding drops that on-load re-fetch (moot for request-stable data).
144
+ * - `interactive`: `static interactive = true` is the explicit author
145
+ * override that forces a component to ship even when the analyser would
146
+ * elide it. It is the escape hatch for interactivity the analyser cannot
147
+ * see statically (a dynamically-computed tag string, a `:defined` rule in
148
+ * an external stylesheet outside the module graph). Context-free: any value
149
+ * other than `false` ships.
147
150
  *
148
151
  * @type {readonly string[]}
149
152
  */
150
- export const INTERACTIVITY_STATIC_FIELDS = ['shadow', 'refresh'];
153
+ export const INTERACTIVITY_STATIC_FIELDS = ['shadow', 'interactive'];
151
154
 
152
155
  /** Why each INTERACTIVITY_STATIC_FIELDS entry forces a ship (analyser reason). */
153
156
  const STATIC_FIELD_REASONS = {
154
157
  shadow: 'declares static shadow (DSD must re-attach on a client swap)',
155
- refresh: 'declares static refresh = true (keeps the on-load re-fetch)',
158
+ interactive: 'declares static interactive = true (author-declared ship override)',
156
159
  };
157
160
 
158
161
  /**
@@ -163,14 +166,14 @@ const STATIC_FIELD_REASONS = {
163
166
  * module download plus a redundant on-hydration re-fetch. It ships only when
164
167
  * it ALSO carries an independent signal: an `@event`, a non-`state` reactive
165
168
  * prop, a reactive import, a lifecycle hook (`renderFallback()` included, via
166
- * CLIENT_LIFECYCLE_HOOKS), a `<slot>`, `static shadow = true`, `static refresh
167
- * = true`, cross-module observation, or a transitively-reachable interactive
168
- * dep / child (the fixpoint's import + render rules). The two genuinely new
169
- * carve-outs, both handled per-class below, are `static shadow` (Declarative
170
- * Shadow DOM only attaches during HTML parsing, so a streamed / soft-navigated
171
- * shadow component needs its module to re-run `attachShadow`) and the explicit
172
- * `static refresh = true` opt-in (keeps the stale-while-revalidate on-load
173
- * re-fetch that eliding would drop).
169
+ * CLIENT_LIFECYCLE_HOOKS), a `<slot>`, `static shadow = true`, `static
170
+ * interactive = true`, cross-module observation, or a transitively-reachable
171
+ * interactive dep / child (the fixpoint's import + render rules). The two
172
+ * per-class static-field carve-outs handled below are `static shadow`
173
+ * (Declarative Shadow DOM only attaches during HTML parsing, so a streamed or
174
+ * soft-navigated shadow component needs its module to re-run `attachShadow`)
175
+ * and the explicit `static interactive = true` override (forces a ship when
176
+ * the analyser cannot see a component's interactivity statically).
174
177
  */
175
178
 
176
179
  /**
@@ -564,8 +567,8 @@ export function analyzeComponentSource(src) {
564
567
 
565
568
  for (const { body, factoryArg } of bodies) {
566
569
  // Interactivity-signal static conventions (`static shadow` / `static
567
- // refresh = true`), driven by the INTERACTIVITY_STATIC_FIELDS registry so a
568
- // new convention is added in one place (see its doc for why each ships).
570
+ // interactive = true`), driven by the INTERACTIVITY_STATIC_FIELDS registry
571
+ // so a new convention is added in one place (see its doc for why each ships).
569
572
  for (const field of INTERACTIVITY_STATIC_FIELDS) {
570
573
  if (declaresStaticTrue(body, field)) {
571
574
  return { interactive: true, reason: STATIC_FIELD_REASONS[field] };
@@ -598,23 +601,23 @@ export function analyzeComponentSource(src) {
598
601
  /**
599
602
  * True if a class body declares a `static <name>` whose value is not the
600
603
  * literal `false`. Backs the `static shadow` (DSD-on-client-swap) and
601
- * `static refresh = true` (ship-the-async-refetch opt-in) ship signals (#474).
604
+ * `static interactive = true` (author-declared ship override) ship signals.
602
605
  *
603
606
  * Conservative on anything it cannot evaluate: a getter form
604
607
  * (`static get <name>()`) ships, and a non-`false` value of any shape
605
608
  * (`true`, a variable, an expression) ships. Only an absent declaration or a
606
609
  * literal `= false` is cleared as inert, matching the light-DOM /
607
- * no-refresh defaults.
610
+ * not-declared defaults.
608
611
  *
609
612
  * @param {string} classBody redacted class body
610
- * @param {string} name the static field name (`shadow` / `refresh`)
613
+ * @param {string} name the static field name (`shadow` / `interactive`)
611
614
  * @returns {boolean}
612
615
  */
613
616
  function declaresStaticTrue(classBody, name) {
614
617
  // A getter cannot be evaluated statically; ship.
615
618
  if (new RegExp(`\\bstatic\\s+get\\s+${name}\\b`).test(classBody)) return true;
616
619
  const m = new RegExp(`\\bstatic\\s+${name}\\b\\s*=\\s*([^;\\n]*)`).exec(classBody);
617
- if (!m) return false; // not declared: the default (light DOM / no refresh)
620
+ if (!m) return false; // not declared: the default (light DOM / not forced)
618
621
  return !/^false\b/.test(m[1].trim()); // `= false` is inert; anything else ships
619
622
  }
620
623
 
@@ -1135,16 +1138,24 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
1135
1138
  // - INERT: neither the module nor its closure does any client work. Dropped
1136
1139
  // from the boot script entirely (#179).
1137
1140
  // - IMPORT-ONLY (#605): the module itself does no client work, and the ONLY
1138
- // client work its closure reaches is SHIPPING COMPONENTS. Such a page /
1139
- // layout module is just the import-graph carrier for its components; the
1140
- // boot can emit those component modules directly and drop the module. The
1141
- // condition is a positive subset test (every client-effecting closure
1142
- // member is a component), NOT a hand-listed block list: any client-
1143
- // effecting NON-component in the closure (a self-executing helper, a
1144
- // client-router import, a reactive helper) keeps the whole module, since
1145
- // dropping it would lose that side effect.
1141
+ // client work its closure reaches is SHIPPING COMPONENTS (or modules
1142
+ // those components carry). Such a page / layout module is just the
1143
+ // import-graph carrier for its components; the boot can emit those
1144
+ // component modules directly and drop the module.
1146
1145
  // - SHIP: anything else (the module itself is client-effecting, or its
1147
- // closure reaches a client-effecting non-component). Unchanged behaviour.
1146
+ // closure reaches a client-effecting non-component through a path that
1147
+ // does NOT pass through a shipping component). Unchanged behaviour.
1148
+ //
1149
+ // The walk is PATH-AWARE (#963): it stops descending at a shipping
1150
+ // component, because that component is emitted in the module's place, so
1151
+ // its whole subtree loads regardless of whether the page module ships.
1152
+ // Consequently a client-effecting non-component reachable ONLY through
1153
+ // shipping components (the module-scope-signal-in-its-own-file idiom of
1154
+ // invariant 5, imported by a component the page renders) does not block
1155
+ // import-only: dropping the page loses nothing, the emitted component
1156
+ // still imports it. A client-effecting non-component reachable through a
1157
+ // component-free path still ships the whole module, since dropping it
1158
+ // would lose that side effect.
1148
1159
  /** @type {Set<string>} */
1149
1160
  const skip = new Set([...elidableComponents, ...serverFiles]);
1150
1161
  /** @type {Set<string>} */
@@ -1163,28 +1174,57 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
1163
1174
  shippedRouteModules.set(file, { blocker: null, reason: clientEffectReason(file) });
1164
1175
  continue;
1165
1176
  }
1166
- const closure = appDir ? transitiveDeps(moduleGraph, [file], appDir, skip) : [];
1167
- const effecting = closure.filter(isClientEffecting);
1168
- if (effecting.length === 0) { inertRouteModules.add(file); continue; }
1169
- // Import-only iff EVERY client-effecting closure member is a (shipping)
1170
- // component. isClientEffecting for a component already implies mustShip, so
1171
- // `effecting` is exactly the shipping components reachable from this module:
1172
- // re-emit that STATIC set (what loading the module would have registered, so
1173
- // a component imported but only conditionally rendered still registers).
1177
+ // Truncated walk (path-aware, #963): BFS the module's import graph, but
1178
+ // do NOT descend into shipping components (they are emitted in the
1179
+ // module's place, so their subtrees load regardless). Collect those
1180
+ // frontier components as the emit set: what loading the module would
1181
+ // have registered, so a component imported but only conditionally
1182
+ // rendered still registers. Skip elided components and server files
1183
+ // exactly like transitiveDeps (they never load client-side; a stub is
1184
+ // served instead). Non-appDir deps (core, vendor) are traversed but
1185
+ // never classified, matching the old closure's appDir restriction.
1174
1186
  //
1175
- // A `static lazy` component is NOT special-cased here: it only appears in
1176
- // this STATIC closure when the route statically imports it, and in that case
1177
- // loading the module already eager-loaded it before elision, so emitting it
1178
- // directly preserves that exact behaviour. A normally-used lazy component is
1179
- // tag-referenced (never statically imported), so it is absent from this
1180
- // closure and still loads via the IntersectionObserver `observeLazy` path.
1181
- if (effecting.every((f) => componentFiles.has(f))) {
1182
- importOnlyRouteModules.set(file, effecting);
1183
- } else {
1184
- // A client-effecting non-component is reachable; ship the whole module.
1185
- // Name the FIRST such non-component as the blocker for the advisory.
1186
- const blocker = effecting.find((f) => !componentFiles.has(f));
1187
+ // A `static lazy` component is NOT special-cased here: it only appears
1188
+ // on this STATIC walk when the route statically imports it, and in that
1189
+ // case loading the module already eager-loaded it before elision, so
1190
+ // emitting it directly preserves that exact behaviour. A normally-used
1191
+ // lazy component is tag-referenced (never statically imported), so it
1192
+ // is absent from this walk and still loads via the IntersectionObserver
1193
+ // `observeLazy` path.
1194
+ /** @type {Set<string>} shipping components to emit in the module's place */
1195
+ const frontier = new Set();
1196
+ /** @type {string|null} first client-effecting non-component on a component-free path */
1197
+ let blocker = null;
1198
+ if (appDir) {
1199
+ const visited = new Set([file]);
1200
+ const queue = [file];
1201
+ while (queue.length) {
1202
+ const cur = /** @type {string} */ (queue.shift());
1203
+ const deps = moduleGraph.get(cur);
1204
+ if (!deps) continue;
1205
+ for (const dep of deps) {
1206
+ if (visited.has(dep)) continue;
1207
+ visited.add(dep);
1208
+ // Elided components and appDir server files are in `skip`;
1209
+ // SERVER_FILE_RE additionally stops at a `.server.*` OUTSIDE appDir
1210
+ // (a relative import escaping the app dir), exactly like
1211
+ // transitiveDeps: the browser only ever sees its stub, so its
1212
+ // subtree never loads and must not contribute a blocker.
1213
+ if (skip.has(dep) || SERVER_FILE_RE.test(dep)) continue;
1214
+ if (componentFiles.has(dep)) { frontier.add(dep); continue; } // emitted; subtree carried
1215
+ if (dep.startsWith(appDir) && isClientEffecting(dep)) blocker ??= dep;
1216
+ queue.push(dep);
1217
+ }
1218
+ }
1219
+ }
1220
+ if (blocker !== null) {
1221
+ // A client-effecting non-component is reachable on a component-free
1222
+ // path; ship the whole module and name the blocker for the advisory.
1187
1223
  shippedRouteModules.set(file, { blocker, reason: clientEffectReason(blocker) });
1224
+ } else if (frontier.size === 0) {
1225
+ inertRouteModules.add(file);
1226
+ } else {
1227
+ importOnlyRouteModules.set(file, [...frontier]);
1188
1228
  }
1189
1229
  }
1190
1230
 
@@ -0,0 +1,234 @@
1
+ /**
2
+ * On-request regeneration of a stale build output in dev (#967).
3
+ *
4
+ * A UI scaffold compiles a STATIC `public/tailwind.css` (#947) so the app is
5
+ * 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.
12
+ *
13
+ * 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.
20
+ *
21
+ * Declared as `webjs.dev.regenerate` in the app's `package.json`:
22
+ * "webjs": { "dev": { "regenerate": [
23
+ * { "output": "public/tailwind.css",
24
+ * "command": "tailwindcss -i ./public/input.css -o ./public/tailwind.css --minify",
25
+ * "inputs": ["app", "components", "modules", "lib", "public/input.css"] }
26
+ * ] } }
27
+ *
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.
32
+ */
33
+
34
+ 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';
37
+
38
+ /**
39
+ * @typedef {{ output: string, command: string, inputs: string[] }} RegenerateRule
40
+ */
41
+
42
+ /**
43
+ * Read + normalize the `webjs.dev.regenerate` rules from an app's
44
+ * `package.json`. Defensive: a missing/malformed block yields `[]` (a plain app
45
+ * runs unchanged). Each rule's `output` is normalized to an appDir-relative path
46
+ * with no leading slash so it matches a served `/public/...` path after the
47
+ * slash is stripped.
48
+ *
49
+ * @param {string} appDir
50
+ * @param {(p: string) => Promise<string>} [read] injectable reader for tests
51
+ * @returns {Promise<RegenerateRule[]>}
52
+ */
53
+ export async function readRegenerateRules(appDir, read) {
54
+ const readFn = read || ((p) => readFile(p, 'utf8'));
55
+ let pkg;
56
+ try {
57
+ pkg = JSON.parse(await readFn(join(appDir, 'package.json')));
58
+ } catch {
59
+ return [];
60
+ }
61
+ const rules = pkg && pkg.webjs && pkg.webjs.dev ? pkg.webjs.dev.regenerate : null;
62
+ if (!Array.isArray(rules)) return [];
63
+ return rules
64
+ .filter((r) => r && typeof r.output === 'string' && typeof r.command === 'string')
65
+ .map((r) => ({
66
+ output: r.output.replace(/^\/+/, ''),
67
+ command: r.command,
68
+ inputs: Array.isArray(r.inputs) ? r.inputs.filter((i) => typeof i === 'string') : [],
69
+ }));
70
+ }
71
+
72
+ // Directories never worth walking for a source-freshness check: build output,
73
+ // dependencies, VCS + framework caches. Skipping them keeps the walk cheap and
74
+ // avoids treating a dependency's mtime as an app-source edit.
75
+ const IGNORE_DIRS = new Set(['node_modules', '.git', '.webjs', 'dist', '.next', 'coverage']);
76
+
77
+ /**
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.
90
+ *
91
+ * @param {string} abs
92
+ * @returns {Promise<number>}
93
+ */
94
+ async function maxMtimeMs(abs) {
95
+ let st;
96
+ try {
97
+ st = await stat(abs);
98
+ } catch {
99
+ return 0;
100
+ }
101
+ if (!st.isDirectory()) return st.mtimeMs;
102
+ let newest = 0;
103
+ let entries;
104
+ try {
105
+ entries = await readdir(abs, { withFileTypes: true });
106
+ } catch {
107
+ return newest;
108
+ }
109
+ for (const e of entries) {
110
+ 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
+ if (e.isSymbolicLink()) continue;
116
+ const m = await maxMtimeMs(join(abs, e.name));
117
+ if (m > newest) newest = m;
118
+ }
119
+ return newest;
120
+ }
121
+
122
+ /**
123
+ * Build a PATH that prepends every ancestor `node_modules/.bin` (mirrors
124
+ * `lib/run-tasks.js`'s `envWithLocalBin`), so a command naming a LOCAL-only
125
+ * binary (`tailwindcss`) resolves the same way `npm run` would.
126
+ *
127
+ * @param {string} cwd
128
+ * @returns {NodeJS.ProcessEnv}
129
+ */
130
+ function envWithLocalBin(cwd) {
131
+ const bins = [];
132
+ let dir = cwd;
133
+ for (;;) {
134
+ bins.push(join(dir, 'node_modules', '.bin'));
135
+ const parent = dirname(dir);
136
+ if (parent === dir) break;
137
+ dir = parent;
138
+ }
139
+ return { ...process.env, PATH: [...bins, process.env.PATH || ''].join(delimiter) };
140
+ }
141
+
142
+ /**
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).
146
+ *
147
+ * @param {string} command
148
+ * @param {string} cwd
149
+ * @param {typeof nodeSpawn} spawn
150
+ * @returns {Promise<void>}
151
+ */
152
+ function runCommand(command, cwd, spawn) {
153
+ return new Promise((resolve) => {
154
+ const child = spawn(command, { shell: true, stdio: 'inherit', cwd, env: envWithLocalBin(cwd) });
155
+ child.on('exit', () => resolve());
156
+ child.on('error', () => resolve());
157
+ });
158
+ }
159
+
160
+ // In-flight compiles, keyed by appDir + output, so concurrent requests for the
161
+ // same stale output share ONE compile instead of racing N of them. Module-scope
162
+ // (not per-handler): the filesystem is the shared resource being guarded.
163
+ const inFlight = new Map();
164
+
165
+ /**
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.
170
+ *
171
+ * @param {string} appDir
172
+ * @param {string} relPath appDir-relative served path, no leading slash (e.g. `public/tailwind.css`)
173
+ * @param {RegenerateRule[]} rules
174
+ * @param {{ spawn?: typeof nodeSpawn }} [opts] injectable spawn for tests
175
+ * @returns {Promise<void>}
176
+ */
177
+ export async function maybeRegenerate(appDir, relPath, rules, opts = {}) {
178
+ const rule = rules && rules.find((r) => r.output === relPath);
179
+ if (!rule) return;
180
+ const key = appDir + '\0' + rule.output;
181
+ const pending = inFlight.get(key);
182
+ if (pending) return pending;
183
+
184
+ const spawn = opts.spawn || nodeSpawn;
185
+ 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
+ })();
203
+ inFlight.set(key, run);
204
+ try {
205
+ await run;
206
+ } finally {
207
+ inFlight.delete(key);
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Whether a dev-watcher filename is a regenerate OUTPUT (#967). A regenerate
213
+ * output is a build product the dev server itself writes on request, so a write
214
+ * to it must NOT trigger a rebuild + SSE reload: that would spuriously reload
215
+ * the page mid-navigation (breaking a soft-nav / streaming interaction) and,
216
+ * worse, could reload -> refetch -> recompile -> reload. Same rationale as the
217
+ * `db/dev.db` carve-out in `shouldIgnoreWatchPath`. `fs.watch` reports a path
218
+ * relative to the watched root with OS separators, so it is normalized to `/`
219
+ * before matching the rule's already-normalized `output`.
220
+ *
221
+ * @param {string} filename `event.filename` from the dev watcher (root-relative)
222
+ * @param {RegenerateRule[]} rules
223
+ * @returns {boolean}
224
+ */
225
+ export function isRegenerateOutputPath(filename, rules) {
226
+ if (!filename || !rules || !rules.length) return false;
227
+ const norm = String(filename).replace(/\\/g, '/');
228
+ return rules.some((r) => r.output === norm);
229
+ }
230
+
231
+ /** Test-only: clear the in-flight coalescing map between cases. */
232
+ export function _resetInFlight() {
233
+ inFlight.clear();
234
+ }
package/src/dev.js CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  hashFile,
28
28
  } from './actions.js';
29
29
  import { registerSeedHooks } from './action-seed.js';
30
+ import { readRegenerateRules, maybeRegenerate, isRegenerateOutputPath } from './dev-regenerate.js';
30
31
  import { stripTypeScript, ensureStripper } from './ts-strip.js';
31
32
  import { defaultLogger } from './logger.js';
32
33
  import { assertNodeVersion } from './node-version.js';
@@ -773,6 +774,11 @@ export async function createRequestHandler(opts) {
773
774
  // SSE channel for the live overlay and replayed to a freshly-connected tab.
774
775
  // Dev-only (never populated when !dev); cleared on a successful rebuild.
775
776
  lastDevError: null,
777
+ // On-request regeneration rules (#967): `webjs.dev.regenerate` from the app's
778
+ // package.json. Dev-only (prod builds its outputs via `start.before`). Loaded
779
+ // now and refreshed on each rebuild so a config edit takes effect without a
780
+ // restart; empty for a plain app, so the serving path is unaffected.
781
+ regenerateRules: dev ? await readRegenerateRules(appDir) : [],
776
782
  };
777
783
 
778
784
  /**
@@ -1121,6 +1127,10 @@ export async function createRequestHandler(opts) {
1121
1127
  readyDone = false;
1122
1128
  readyError = null;
1123
1129
  readinessFn = undefined; // reload readiness.{js,ts} after a rebuild
1130
+ // Refresh the on-request regenerate rules (#967) so a `webjs.dev.regenerate`
1131
+ // config edit takes effect without a restart, mirroring the elide/router
1132
+ // re-reads above. Dev-only; a no-op array in prod.
1133
+ if (dev) state.regenerateRules = await readRegenerateRules(appDir);
1124
1134
  // Optimistically clear the dev error (#264): the rebuild itself only
1125
1135
  // re-scans the route table and INVALIDATES the lazy analysis (the real
1126
1136
  // re-parse / re-strip / re-render happens on the next request), so we do
@@ -1522,6 +1532,11 @@ export async function createRequestHandler(opts) {
1522
1532
  * startServer to a freshly-connected SSE client so the overlay shows even
1523
1533
  * after a navigation, not only on the breaking edit. Always null in prod. */
1524
1534
  getLastDevError: () => state.lastDevError,
1535
+ /** Whether a dev-watcher filename is a `webjs.dev.regenerate` OUTPUT (#967),
1536
+ * so `startServer`'s file watcher (a different scope, with no access to
1537
+ * `state`) can skip a build product the server itself writes and avoid a
1538
+ * spurious reload. Reads the live, rebuild-refreshed rules. */
1539
+ isRegenerateOutput: (filename) => isRegenerateOutputPath(filename, state.regenerateRules),
1525
1540
  appDir,
1526
1541
  dev,
1527
1542
  logger,
@@ -1646,6 +1661,12 @@ export async function startServer(opts) {
1646
1661
  for await (const event of events) {
1647
1662
  const filename = event.filename || '';
1648
1663
  if (shouldIgnoreWatchPath(filename)) continue;
1664
+ // A regenerate output (#967) is a build product the server itself
1665
+ // writes on request; ignoring it stops a spurious rebuild + reload
1666
+ // (and a reload -> refetch -> recompile -> reload cycle), same as the
1667
+ // db/dev.db carve-out above. `app` exposes the check because `state`
1668
+ // lives in createRequestHandler's scope, not here.
1669
+ if (app.isRegenerateOutput(filename)) continue;
1649
1670
  rebuild();
1650
1671
  }
1651
1672
  } catch (err) {
@@ -1984,6 +2005,15 @@ async function handleCore(req, ctx) {
1984
2005
  if (!abs.startsWith(publicRoot)) {
1985
2006
  return new Response(null, { status: 404 });
1986
2007
  }
2008
+ // On-request regeneration (#967): in dev, if a `webjs.dev.regenerate` rule
2009
+ // matches this output and it is stale (a source is newer, or it is missing),
2010
+ // rebuild it to completion BEFORE serving, so a newly added utility class is
2011
+ // never served stale. No-op when no rule matches or the output is fresh, and
2012
+ // never runs in prod (rules are empty there). This replaces the fragile
2013
+ // `tailwindcss --watch` that could die mid-session and serve stale CSS.
2014
+ if (dev && state.regenerateRules.length) {
2015
+ await maybeRegenerate(appDir, p.replace(/^\/+/, ''), state.regenerateRules);
2016
+ }
1987
2017
  // A `?v=<hash>` public asset is content-addressed -> immutable (#243).
1988
2018
  if (await exists(abs)) {
1989
2019
  const res = await fileResponse(abs, { dev, immutable: versioned });
@@ -6,8 +6,9 @@
6
6
  * run at dev-server start and re-derived after each fs.watch rebuild). It
7
7
  * builds the module graph, runs `analyzeElision`, and returns the page / layout
8
8
  * route modules that SHIP WHOLE to the browser, each with the first
9
- * client-effecting blocker that pins it (or its own signal when the module
10
- * itself is the cause).
9
+ * client-effecting blocker that pins it (a non-component on a component-free
10
+ * path from the module, #963, or its own signal when the module itself is
11
+ * the cause).
11
12
  *
12
13
  * Consumed by `webjs doctor` to ADVISE why a page/layout is not elided (an
13
14
  * import-only #605 / inert #179 carrier stays out of the browser; a module that
package/src/ssr.js CHANGED
@@ -155,11 +155,13 @@ export async function ssrPage(route, params, url, opts) {
155
155
  // Import-only route modules (#605) go one step further: a page / layout
156
156
  // whose only client relevance is importing shipping components is itself
157
157
  // dead weight on the client (it never hydrates), so it is dropped and its
158
- // component modules are emitted directly in its place. The component set is
159
- // the STATIC closure the analyser computed (what loading the module would
160
- // have registered), so a component imported but only conditionally rendered
161
- // still registers. Dedup so a component shared across the page and a layout
162
- // (or two layouts) is emitted once.
158
+ // component modules are emitted directly in its place. The component set
159
+ // is the FRONTIER of the analyser's path-aware walk (#963): the shipping
160
+ // components the module reaches without passing through another shipping
161
+ // component. A component imported but only conditionally rendered still
162
+ // registers; one nested behind an emitted component is absent here and
163
+ // loads via its importer's own imports. Dedup so a component shared
164
+ // across the page and a layout (or two layouts) is emitted once.
163
165
  const inert = opts.inertRouteModules;
164
166
  const importOnly = opts.importOnlyRouteModules;
165
167
  const moduleUrls = [];
@@ -329,8 +331,28 @@ export async function ssrPage(route, params, url, opts) {
329
331
  if (!mod.default) continue;
330
332
  const tree = await mod.default({ ...ctx, error: err });
331
333
  const body = await renderToString(tree, { ssr: true, dev: opts.dev });
332
- const moduleUrls = [route.file, ...route.layouts].map((f) => toUrlPath(f, opts.appDir));
333
- const html = wrapInDocument(body, { metadata, moduleUrls, dev: opts.dev, nonce: errNonce });
334
+ // Apply the SAME inert / import-only substitution as the happy-path
335
+ // boot (#963): the error page must not ship a page/layout module the
336
+ // normal render drops. Pre-substitution, an import-only page with a
337
+ // bare `.server.*` import (legal, it never loads client-side) would
338
+ // load here whole and crash the error page's boot on the throw-at-load
339
+ // stub, killing the sibling component registrations in the same
340
+ // module script.
341
+ const errModuleUrls = [];
342
+ {
343
+ const seen = new Set();
344
+ const push = (abs) => {
345
+ const u = toUrlPath(abs, opts.appDir);
346
+ if (!seen.has(u)) { seen.add(u); errModuleUrls.push(u); }
347
+ };
348
+ for (const f of [route.file, ...route.layouts]) {
349
+ if (opts.inertRouteModules && opts.inertRouteModules.has(f)) continue;
350
+ const emit = opts.importOnlyRouteModules && opts.importOnlyRouteModules.get(f);
351
+ if (emit) emit.forEach(push);
352
+ else push(f);
353
+ }
354
+ }
355
+ const html = wrapInDocument(body, { metadata, moduleUrls: errModuleUrls, dev: opts.dev, nonce: errNonce });
334
356
  return htmlResponse(html, 500, opts.req, url);
335
357
  } catch (nested) {
336
358
  // fall through to next error boundary
@@ -160,7 +160,7 @@
160
160
  "default": 5000
161
161
  },
162
162
  "dev": {
163
- "description": "Dev task orchestration (#550). Read by the CLI (readAppTasks in packages/cli/lib/app-tasks.js), NOT the server. webjs dev runs `before` (one-shot, e.g. webjs db migrate) to completion, then `parallel` (long-lived watchers, e.g. tailwindcss --watch) alongside the server. Replaces the old predev hook + concurrently watchers.",
163
+ "description": "Dev task orchestration (#550). `before`/`parallel` are read by the CLI (readAppTasks in packages/cli/lib/app-tasks.js); `regenerate` is read by the server (readRegenerateRules in dev-regenerate.js). webjs dev runs `before` (one-shot, e.g. webjs db migrate) to completion, then `parallel` (long-lived watchers) alongside the server. Replaces the old predev hook + concurrently watchers.",
164
164
  "type": "object",
165
165
  "additionalProperties": false,
166
166
  "properties": {
@@ -174,6 +174,30 @@
174
174
  "type": "array",
175
175
  "items": { "type": "string" }
176
176
  },
177
+ "regenerate": {
178
+ "description": "On-request build-output regeneration (#967), DEV-ONLY. Each rule rebuilds a stale `output` before the server serves it, instead of relying on a live watcher that can die and serve stale output. The scaffold uses this to keep the static public/tailwind.css fresh (#947) with no `tailwindcss --watch`. Read by the server (readRegenerateRules in dev-regenerate.js).",
179
+ "type": "array",
180
+ "items": {
181
+ "type": "object",
182
+ "additionalProperties": false,
183
+ "required": ["output", "command"],
184
+ "properties": {
185
+ "output": {
186
+ "description": "The appDir-relative served output file this rule rebuilds (e.g. public/tailwind.css). A leading slash is stripped.",
187
+ "type": "string"
188
+ },
189
+ "command": {
190
+ "description": "The shell command that rebuilds `output`. Run to completion before serving when the output is stale. The same command prod uses in start.before, so dev and prod outputs cannot diverge.",
191
+ "type": "string"
192
+ },
193
+ "inputs": {
194
+ "description": "Source paths (files or directories) whose newest mtime is compared to `output`'s. If any is newer (or the output is missing), the command runs. For Tailwind: the @source dirs plus public/input.css.",
195
+ "type": "array",
196
+ "items": { "type": "string" }
197
+ }
198
+ }
199
+ }
200
+ },
177
201
  "watch": {
178
202
  "description": "Extra directories the dev live-reload watcher follows in addition to the appDir (#894), for content the app reads from outside its own tree (e.g. blog markdown in a repo-root blog/ dir). Each entry is resolved relative to the app root and may escape it (../blog). Read by the server (readDevWatchPathsFromApp).",
179
203
  "type": "array",