@webjsdev/server 0.8.50 → 0.8.52

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.52",
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,18 +122,13 @@ 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',
129
129
  description:
130
130
  'A `\'use server\'` action file that declares HTTP-verb config (any of `method` / `cache` / `tags` / `invalidates` / `validate`, #488) must export exactly ONE callable action function. The config is file-level (it applies to the action in the file), so a second exported function would silently inherit the same verb / cache, which is almost never intended and makes the contract ambiguous. Move the extra function to its own `.server.ts` file (the one-function-per-file convention), or, if it is a private helper, do not export it. Files with no verb config are unaffected.',
131
131
  },
132
- {
133
- name: 'no-scaffold-placeholder',
134
- description:
135
- 'The one sentinel-based check, and the deliberate exception to the "objectively broken code" framing of the other rules: it flags scaffold example content that has not been replaced, so a delivered app contains only what the user intended and never leftover scaffold code. `webjs create` marks its example/demo files (the example homepage in app/page.ts, the example app chrome in app/layout.ts) with a one-line marker comment carrying the literal token `webjs-scaffold-placeholder`. The rule scans raw source (the marker lives in a comment, which the redacted scan view would hide) and fails for every file that still carries the token. A freshly scaffolded app fails this rule BY DESIGN until its placeholders are addressed: replace the example content, or deliberately keep it, and in either case delete the marker line. No finished app legitimately ships a literal remove-me marker, so this still qualifies as a check rather than a convention. The marker is acknowledge-and-remove, so keeping a piece is a one-line deletion rather than a forced rewrite.',
136
- },
137
132
  {
138
133
  name: 'no-redirect-in-api-route',
139
134
  description:
@@ -668,27 +663,6 @@ export async function checkConventions(appDir) {
668
663
  }
669
664
  }
670
665
 
671
- // --- Rule: no-scaffold-placeholder ---
672
- // `webjs create` marks its example/demo files (app/page.ts, app/layout.ts)
673
- // with a one-line marker comment so a delivered app cannot silently ship
674
- // leftover scaffold content. Scan RAW `content`: the marker lives in a
675
- // comment, which the `scan` view redacts to whitespace. One violation per
676
- // still-marked file. The token is assembled here so this source does not
677
- // itself carry the contiguous literal.
678
- {
679
- const MARKER = 'webjs-scaffold-' + 'placeholder';
680
- for (const { rel, content } of files) {
681
- if (!content.includes(MARKER)) continue;
682
- violations.push({
683
- rule: 'no-scaffold-placeholder',
684
- file: rel,
685
- message:
686
- 'Scaffold placeholder marker still present. This file is unmodified example content from `webjs create`, and the delivered app should contain only what the user intended, not leftover scaffold code.',
687
- fix: `Replace the example content (or deliberately keep it), then delete the marker comment line carrying the ${MARKER} token.`,
688
- });
689
- }
690
- }
691
-
692
666
  // --- Rule: no-redirect-in-api-route ---
693
667
  // `redirect()` from `@webjsdev/core` throws a control-flow signal designed
694
668
  // for the SSR page renderer. In a `route.ts` API handler it goes uncaught
@@ -1219,11 +1193,12 @@ export async function checkConventions(appDir) {
1219
1193
  // --- Rule: no-server-import-in-browser-module ---
1220
1194
  // A page / layout / component module that SHIPS to the browser must not
1221
1195
  // 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,
1196
+ // gets a stub for the server file, so the import is harmless while the
1197
+ // module never loads client-side: a display-only page is elided, and an
1198
+ // import-only page (#605/#963) is dropped from the boot in favour of its
1199
+ // components. But the moment the page does its OWN client work (the client
1200
+ // router, a reactive primitive, module-scope code, a client-effecting util
1201
+ // on a component-free path) it ships whole, must load in the browser,
1227
1202
  // and drags the server import with it: the stub throws (or a server-only
1228
1203
  // export like `auth` is missing) the instant the module loads. That crash
1229
1204
  // only surfaces at runtime; typecheck and every other check pass.
@@ -1425,21 +1400,25 @@ async function checkServerImportInBrowserModule(appDir, violations) {
1425
1400
  // rule treats every candidate as shipping (which is correct: with elision
1426
1401
  // off, a display-only page really does ship its server import too).
1427
1402
  const elideEnabled = await readElideEnabledForCheck(appDir);
1428
- const { elidableComponents, inertRouteModules } = elideEnabled
1403
+ const { elidableComponents, inertRouteModules, importOnlyRouteModules } = elideEnabled
1429
1404
  ? await analyzeElision(components, routeModules, moduleGraph, (f) => readFile(f, 'utf8'), appDir)
1430
- : { elidableComponents: new Set(), inertRouteModules: new Set() };
1405
+ : { elidableComponents: new Set(), inertRouteModules: new Set(), importOnlyRouteModules: new Map() };
1431
1406
 
1432
1407
  // 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.
1408
+ // route modules that are NOT inert and NOT import-only (an import-only
1409
+ // module is dropped from the boot in favour of its component frontier,
1410
+ // #605/#963, so its own imports never load in a browser and a bare
1411
+ // server-only import in it is harmless). A `.server.*` file is never a
1412
+ // component (the scanner skips it) nor a route module the browser loads, so
1413
+ // it cannot enter this set; server-to-server imports are excluded by
1414
+ // construction.
1436
1415
  /** @type {Map<string, { kind: string }>} relFile is keyed by ABS path */
1437
1416
  const candidates = new Map();
1438
1417
  for (const c of components) {
1439
1418
  if (!elidableComponents.has(c.file)) candidates.set(c.file, { kind: 'component' });
1440
1419
  }
1441
1420
  for (const file of routeModules) {
1442
- if (inertRouteModules.has(file)) continue;
1421
+ if (inertRouteModules.has(file) || importOnlyRouteModules.has(file)) continue;
1443
1422
  const base = basename(file);
1444
1423
  const kind = /^layout\./.test(base) ? 'layout' : 'page';
1445
1424
  candidates.set(file, { kind });
@@ -1496,17 +1475,20 @@ async function checkServerImportInBrowserModule(appDir, violations) {
1496
1475
  const importer = chainFiles.length >= 2 ? chainFiles[chainFiles.length - 2] : null;
1497
1476
  const viaTypesModule = importer && /(^|\/)types(\.m?[jt]s$|\/)/.test(relative(appDir, importer));
1498
1477
 
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.
1478
+ // The "elides again" remedy only applies to a page / layout: since the
1479
+ // path-aware import-only verdict (#963), a page importing a component to
1480
+ // register is dropped from the boot, so a page/layout on this rule ships
1481
+ // because of its OWN client work (or a client-effecting non-component in
1482
+ // its closure); moving that work into a component makes it a dropped
1483
+ // carrier again and the server import never loads. The error / loading /
1484
+ // not-found boundaries always ship and are never elided, so offering
1485
+ // them an "elides again" fix is wrong. Branch the fix text on kind.
1504
1486
  const canElide = kind === 'page' || kind === 'layout';
1505
1487
  const typesHint = viaTypesModule
1506
1488
  ? `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
1489
  : '';
1508
1490
  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.`
1491
+ ? `${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
1492
  : `${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
1493
 
1512
1494
  violations.push({
@@ -93,7 +93,7 @@ export const REACTIVE_IMPORTS = new Set([
93
93
  * defines `render`, and the SSR walker calls it directly. `constructor`
94
94
  * is absent for the same reason (it runs during SSR to seed first
95
95
  * paint). Keep this list in lockstep with the lifecycle table in
96
- * agent-docs/components.md.
96
+ * the skill's references/components.md.
97
97
  *
98
98
  * @type {readonly string[]}
99
99
  */
@@ -134,25 +134,28 @@ export const CLIENT_METHOD_CALLS = ['addController', 'removeController', 'reques
134
134
  * `lifecycle-coverage.test.js` via prototype introspection), there is no
135
135
  * enumerable runtime source of "all static conventions", so the contract is
136
136
  * a documented one: a new interactivity static field MUST be added here AND
137
- * to the lifecycle table in agent-docs/components.md. `sigil-coverage.test.js`
137
+ * to the lifecycle table in the skill's references/components.md. `sigil-coverage.test.js`
138
138
  * asserts each entry is honoured as a ship signal.
139
139
  *
140
140
  * - `shadow`: Declarative Shadow DOM attaches ONLY during HTML parsing, so a
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
  /**
@@ -445,7 +448,7 @@ function importsClientRouter(src, literals) {
445
448
  * `customElements.define` at module top level) imported via a binding clause
446
449
  * is NOT caught here, so eliding the importer drops that registration and the
447
450
  * element silently does not upgrade. This is the cross-module-registration
448
- * caveat documented in agent-docs/components.md and server AGENTS invariant 7;
451
+ * caveat documented in the skill's references/components.md and server AGENTS invariant 7;
449
452
  * the fix is `.server.{js,ts}` for genuinely server-only deps, or an
450
453
  * interactivity signal on the consumer. (It is not caught by an SSR crash:
451
454
  * the SSR `customElements` shim makes `define` a no-op server-side.)
@@ -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",