@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 +1 -1
- package/src/check.js +26 -44
- package/src/component-elision.js +91 -51
- package/src/dev-regenerate.js +234 -0
- package/src/dev.js +30 -0
- package/src/elision-report.js +3 -2
- package/src/ssr.js +29 -7
- package/webjs-config.schema.json +25 -1
package/package.json
CHANGED
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
|
|
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
|
|
1223
|
-
//
|
|
1224
|
-
//
|
|
1225
|
-
//
|
|
1226
|
-
// reactive primitive,
|
|
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
|
|
1434
|
-
//
|
|
1435
|
-
//
|
|
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 "
|
|
1500
|
-
//
|
|
1501
|
-
//
|
|
1502
|
-
//
|
|
1503
|
-
//
|
|
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)
|
|
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({
|
package/src/component-elision.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
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
|
-
* - `
|
|
145
|
-
*
|
|
146
|
-
*
|
|
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', '
|
|
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
|
-
|
|
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
|
|
167
|
-
* = true`, cross-module observation, or a transitively-reachable
|
|
168
|
-
* dep / child (the fixpoint's import + render rules). The two
|
|
169
|
-
* carve-outs
|
|
170
|
-
* Shadow DOM only attaches during HTML parsing, so a streamed
|
|
171
|
-
* shadow component needs its module to re-run `attachShadow`)
|
|
172
|
-
* `static
|
|
173
|
-
*
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
-
*
|
|
610
|
+
* not-declared defaults.
|
|
608
611
|
*
|
|
609
612
|
* @param {string} classBody redacted class body
|
|
610
|
-
* @param {string} name the static field name (`shadow` / `
|
|
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 /
|
|
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
|
|
1139
|
-
//
|
|
1140
|
-
//
|
|
1141
|
-
//
|
|
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
|
|
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
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
//
|
|
1170
|
-
//
|
|
1171
|
-
//
|
|
1172
|
-
//
|
|
1173
|
-
//
|
|
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
|
|
1176
|
-
// this STATIC
|
|
1177
|
-
// loading the module already eager-loaded it before elision, so
|
|
1178
|
-
// directly preserves that exact behaviour. A normally-used
|
|
1179
|
-
// tag-referenced (never statically imported), so it
|
|
1180
|
-
//
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
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 });
|
package/src/elision-report.js
CHANGED
|
@@ -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 (
|
|
10
|
-
*
|
|
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
|
|
159
|
-
// the
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
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
|
-
|
|
333
|
-
|
|
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
|
package/webjs-config.schema.json
CHANGED
|
@@ -160,7 +160,7 @@
|
|
|
160
160
|
"default": 5000
|
|
161
161
|
},
|
|
162
162
|
"dev": {
|
|
163
|
-
"description": "Dev task orchestration (#550).
|
|
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",
|