@webjsdev/server 0.8.49 → 0.8.51
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/check.js +191 -19
- package/src/component-elision.js +88 -48
- package/src/dev-regenerate.js +234 -0
- package/src/dev.js +30 -0
- package/src/elision-report.js +3 -2
- package/src/ssr.js +30 -7
- package/src/vendor.js +34 -5
- package/webjs-config.schema.json +25 -1
package/package.json
CHANGED
package/src/check.js
CHANGED
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
matchClosingBrace,
|
|
8
8
|
parsePropEntries,
|
|
9
9
|
} from './js-scan.js';
|
|
10
|
-
import { buildModuleGraph, transitiveDeps } from './module-graph.js';
|
|
10
|
+
import { buildModuleGraph, transitiveDeps, resolveImport } from './module-graph.js';
|
|
11
11
|
import { scanComponents } from './component-scanner.js';
|
|
12
12
|
import { buildRouteTable } from './router.js';
|
|
13
13
|
import { analyzeElision } from './component-elision.js';
|
|
@@ -122,7 +122,7 @@ export const RULES = [
|
|
|
122
122
|
{
|
|
123
123
|
name: 'no-server-import-in-browser-module',
|
|
124
124
|
description:
|
|
125
|
-
'A page / layout / component module that SHIPS to the browser (the build does NOT elide it) must not transitively import a server-only `.server.{ts,js}` module. The server module is replaced by a stub in the browser, so the import is fine while the
|
|
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',
|
|
@@ -144,6 +144,11 @@ export const RULES = [
|
|
|
144
144
|
description:
|
|
145
145
|
'Flags a template interpolation (`${...}`) placed as a child of a `<style>` or `<script>` element inside a COMPONENT `html` template. Raw-text elements are an SSR/client asymmetry trap: the server renderer emits the interpolated content, but the client renderer drops it (a raw-text hole is a `noop`, since the compile cache is keyed on the static strings), so the element renders correctly on the server and then wipes to empty on hydration. Scoped to components (files with a `WebComponent` class), which hydrate; pages and layouts render server-only and never hydrate, so a page interpolating a `css` result into a `<style>` is a legitimate pattern and is not flagged. In a component, author scoped CSS with `static styles` (shadow DOM) or a `css` template. Found dogfooding a tic-tac-toe app (#845): a `<style>${STYLE}</style>` painted at SSR then vanished on hydrate.',
|
|
146
146
|
},
|
|
147
|
+
{
|
|
148
|
+
name: 'no-missing-local-import',
|
|
149
|
+
description:
|
|
150
|
+
'Flags a NAMED value import of a symbol that a resolvable app-internal module does not export (`import { todos } from \'#db/schema.server.ts\'` when the schema no longer exports `todos`). The binding is `undefined` at runtime and crashes on first use, yet the rest of `webjs check` (elision-based, it does not compile types) misses it, so a schema swap that orphans a gallery module can pass `check` while `typecheck` is red. This is the runtime-crash class `check` exists to catch, filling that gap so an agent running only `check` cannot ship a broken import. Deliberately conservative to never false-positive on a valid app: it only inspects app-internal specifiers (a relative `./` path or a `#` alias) that resolve to a file in the app, only NAMED value imports (a `type` import, a default import, a namespace import, and any bare / `node:` / npm-package specifier are skipped), and it treats a module as UNKNOWABLE (never flags imports from it) when its exports are not fully enumerable: a `export * from ...` star re-export, a destructuring `export const { a } = ...`, or a multi-declarator `export const a = .., b = ..`. It reads names from `export function/class/const/let/var/type/interface/enum` and from `export { a, b as c }` / `export { x } from ...` clauses (the alias after `as` is the exported name). A re-export barrel therefore resolves correctly, and a `\'use server\'` action file exports its function names normally. Found dogfooding a tic-tac-toe app: dropping the example `todos` table left a gallery module importing it, green under `check` but red under `typecheck`.',
|
|
151
|
+
},
|
|
147
152
|
];
|
|
148
153
|
|
|
149
154
|
/** Set of all known rule names for fast lookup. */
|
|
@@ -379,6 +384,112 @@ function findBrowserMemberUses(code) {
|
|
|
379
384
|
return out;
|
|
380
385
|
}
|
|
381
386
|
|
|
387
|
+
/**
|
|
388
|
+
* True if any `export const/let/var` declares more than one binding
|
|
389
|
+
* (`export const a = 1, b = 2`), which the single-name collector would
|
|
390
|
+
* under-count. Depth-aware: a comma inside an initializer (`f(a, b)`, `[a, b]`,
|
|
391
|
+
* `{ a, b }`) does not count, only a top-level comma before the statement end.
|
|
392
|
+
* Bailing the whole module on this errs toward a false negative (safe), never a
|
|
393
|
+
* false positive. Runs on the string/comment-redacted `scan`.
|
|
394
|
+
* @param {string} scan
|
|
395
|
+
* @returns {boolean}
|
|
396
|
+
*/
|
|
397
|
+
function hasMultiDeclaratorExport(scan) {
|
|
398
|
+
const re = /\bexport\s+(?:const|let|var)\b/g;
|
|
399
|
+
let m;
|
|
400
|
+
while ((m = re.exec(scan))) {
|
|
401
|
+
let depth = 0; // () [] {} nesting
|
|
402
|
+
let angle = 0; // <> generics, tracked ONLY inside a type annotation
|
|
403
|
+
let seenEq = false; // passed this declarator's `=` (now in the initializer)
|
|
404
|
+
let inType = false; // inside a `: Type` annotation (before the `=`)
|
|
405
|
+
for (let i = m.index + m[0].length; i < scan.length; i++) {
|
|
406
|
+
const ch = scan[i];
|
|
407
|
+
if (ch === '(' || ch === '[' || ch === '{') { depth++; continue; }
|
|
408
|
+
if (ch === ')' || ch === ']' || ch === '}') { if (depth === 0) break; depth--; continue; }
|
|
409
|
+
if (depth !== 0) continue;
|
|
410
|
+
if (ch === ';') break;
|
|
411
|
+
// A plain `=` ends the type annotation and enters the initializer. `<=`,
|
|
412
|
+
// `>=`, `=>`, `==` are harmless here (seenEq only latches true).
|
|
413
|
+
if (ch === '=') { seenEq = true; inType = false; angle = 0; continue; }
|
|
414
|
+
// A `:` BEFORE the `=` opens the type annotation. A `:` after `=` (a
|
|
415
|
+
// ternary in the initializer) is NOT a type and must not suppress a real
|
|
416
|
+
// declarator comma.
|
|
417
|
+
if (ch === ':' && !seenEq) { inType = true; continue; }
|
|
418
|
+
// Inside a type annotation `<` / `>` are generic delimiters (a comparison
|
|
419
|
+
// only appears in the initializer, after `=`). Track their depth so a
|
|
420
|
+
// generic's comma (`Map<string, number>`) is skipped while a real
|
|
421
|
+
// declarator comma (`a: number, b: number`) still counts. A `>` that is
|
|
422
|
+
// part of a `=>` function-type arrow is not a generic close.
|
|
423
|
+
if (inType && ch === '<') { angle++; continue; }
|
|
424
|
+
if (inType && ch === '>' && scan[i - 1] !== '=') { if (angle > 0) angle--; continue; }
|
|
425
|
+
// A top-level comma starts a second declarator UNLESS it is a generic's
|
|
426
|
+
// comma inside a type annotation.
|
|
427
|
+
if (ch === ',' && !(inType && angle > 0)) return true;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return false;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Enumerate a module's provable named exports from its redacted `scan`, for the
|
|
435
|
+
* `no-missing-local-import` rule. Returns `null` when the export list is NOT
|
|
436
|
+
* fully enumerable (a `export *` star re-export, a destructuring export, or a
|
|
437
|
+
* multi-declarator `export const a = .., b = ..`), which tells the rule to treat
|
|
438
|
+
* the module as unknowable and flag nothing from it. Otherwise returns the set
|
|
439
|
+
* of exported names (the alias after `as` for a clause, and type/interface/enum
|
|
440
|
+
* names too, so a value import of a type is not falsely flagged; tsc owns that).
|
|
441
|
+
* @param {string} scan string/template/comment-redacted source
|
|
442
|
+
* @returns {Set<string>|null}
|
|
443
|
+
*/
|
|
444
|
+
function enumerableExports(scan) {
|
|
445
|
+
// Unknowable export shapes: bail so the rule never false-positives.
|
|
446
|
+
if (/\bexport\s*\*/.test(scan)) return null; // export * from
|
|
447
|
+
if (/\bexport\s+(?:const|let|var)\s*[{[]/.test(scan)) return null; // destructuring export
|
|
448
|
+
if (hasMultiDeclaratorExport(scan)) return null; // export const a=1, b=2
|
|
449
|
+
const names = new Set();
|
|
450
|
+
let m;
|
|
451
|
+
const collect = (re) => { while ((m = re.exec(scan))) names.add(m[1]); };
|
|
452
|
+
collect(/\bexport\s+(?:async\s+)?function\b\s*\*?\s*([A-Za-z_$][\w$]*)/g);
|
|
453
|
+
collect(/\bexport\s+(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)/g);
|
|
454
|
+
collect(/\bexport\s+(?:const|let|var)\s+([A-Za-z_$][\w$]*)/g);
|
|
455
|
+
collect(/\bexport\s+(?:type|interface|enum|namespace)\s+([A-Za-z_$][\w$]*)/g);
|
|
456
|
+
// A named-default import (`import { default as Foo }`) is legal against an
|
|
457
|
+
// `export default` module, so record the `default` name when present.
|
|
458
|
+
if (/\bexport\s+default\b/.test(scan)) names.add('default');
|
|
459
|
+
// `export { a, b as c }` / `export type { ... }` / `export { x } from '...'`:
|
|
460
|
+
// the EXPORTED name is the alias after `as`, else the bare name.
|
|
461
|
+
const reClause = /\bexport\s+(?:type\s+)?\{([^}]*)\}/g;
|
|
462
|
+
while ((m = reClause.exec(scan))) {
|
|
463
|
+
for (let part of m[1].split(',')) {
|
|
464
|
+
part = part.trim().replace(/^type\s+/, '');
|
|
465
|
+
if (!part) continue;
|
|
466
|
+
const name = part.split(/\s+as\s+/).pop().trim();
|
|
467
|
+
if (/^[A-Za-z_$][\w$]*$/.test(name)) names.add(name);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
return names;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* The NAMED VALUE names a single `import { ... } from` clause pulls in, for the
|
|
475
|
+
* `no-missing-local-import` rule. Returns `null` for an `import type { ... }`
|
|
476
|
+
* (all type, skip), and `[]` for a default / namespace / side-effect import
|
|
477
|
+
* (nothing named to verify). A per-specifier inline `type` marker is dropped,
|
|
478
|
+
* and for `a as b` the IMPORTED name is `a` (what the target must export).
|
|
479
|
+
* @param {string} clause the text between `import` and `from`
|
|
480
|
+
* @returns {string[]|null}
|
|
481
|
+
*/
|
|
482
|
+
function importedValueNames(clause) {
|
|
483
|
+
if (/^\s*type\b/.test(clause)) return null; // import type { ... }
|
|
484
|
+
const brace = clause.match(/\{([^}]*)\}/);
|
|
485
|
+
if (!brace) return []; // default / namespace only
|
|
486
|
+
return brace[1].split(',')
|
|
487
|
+
.map((s) => s.trim())
|
|
488
|
+
.filter((s) => s && !/^type\s/.test(s))
|
|
489
|
+
.map((s) => s.split(/\s+as\s+/)[0].trim())
|
|
490
|
+
.filter((n) => /^[A-Za-z_$][\w$]*$/.test(n));
|
|
491
|
+
}
|
|
492
|
+
|
|
382
493
|
/**
|
|
383
494
|
* Scan a WebJs app directory and report convention violations.
|
|
384
495
|
*
|
|
@@ -1108,11 +1219,12 @@ export async function checkConventions(appDir) {
|
|
|
1108
1219
|
// --- Rule: no-server-import-in-browser-module ---
|
|
1109
1220
|
// A page / layout / component module that SHIPS to the browser must not
|
|
1110
1221
|
// transitively import a server-only `.server.{ts,js}` module. The browser
|
|
1111
|
-
// gets a stub for the server file, so the import is harmless while the
|
|
1112
|
-
//
|
|
1113
|
-
//
|
|
1114
|
-
//
|
|
1115
|
-
// reactive primitive,
|
|
1222
|
+
// gets a stub for the server file, so the import is harmless while the
|
|
1223
|
+
// module never loads client-side: a display-only page is elided, and an
|
|
1224
|
+
// import-only page (#605/#963) is dropped from the boot in favour of its
|
|
1225
|
+
// components. But the moment the page does its OWN client work (the client
|
|
1226
|
+
// router, a reactive primitive, module-scope code, a client-effecting util
|
|
1227
|
+
// on a component-free path) it ships whole, must load in the browser,
|
|
1116
1228
|
// and drags the server import with it: the stub throws (or a server-only
|
|
1117
1229
|
// export like `auth` is missing) the instant the module loads. That crash
|
|
1118
1230
|
// only surfaces at runtime; typecheck and every other check pass.
|
|
@@ -1125,6 +1237,59 @@ export async function checkConventions(appDir) {
|
|
|
1125
1237
|
// directly, so it is not elided and ships the server import.
|
|
1126
1238
|
await checkServerImportInBrowserModule(appDir, violations);
|
|
1127
1239
|
|
|
1240
|
+
// --- Rule: no-missing-local-import ---
|
|
1241
|
+
// A named value import of a symbol an app-internal module does not export is a
|
|
1242
|
+
// runtime crash (the binding is undefined) that the elision-based checks miss,
|
|
1243
|
+
// so `check` can stay green while `typecheck` is red (a dropped schema table
|
|
1244
|
+
// orphaning a gallery module is the motivating dogfood case). Conservative by
|
|
1245
|
+
// construction: only app-internal specifiers resolving to a known app file,
|
|
1246
|
+
// only named value imports, and only when the target's exports are fully
|
|
1247
|
+
// enumerable (see enumerableExports / importedValueNames).
|
|
1248
|
+
{
|
|
1249
|
+
// Fully-blanked view (blankStrings=true): string AND template AND comment
|
|
1250
|
+
// bodies blank to spaces, position-preserving. The default `scan` keeps
|
|
1251
|
+
// plain-string bodies VERBATIM (so callers can read `register('tag')`),
|
|
1252
|
+
// which would let an `import`/`export` inside a string be matched, so use
|
|
1253
|
+
// the fully-blanked view for both the export map and the import scan. The
|
|
1254
|
+
// real specifier is read back from `content` at the same (length-preserved)
|
|
1255
|
+
// offset.
|
|
1256
|
+
const maskedByAbs = new Map();
|
|
1257
|
+
for (const f of files) maskedByAbs.set(f.abs, redactStringsAndTemplates(f.content, true));
|
|
1258
|
+
const exportsByAbs = new Map();
|
|
1259
|
+
for (const f of files) exportsByAbs.set(f.abs, enumerableExports(maskedByAbs.get(f.abs)));
|
|
1260
|
+
// `import\s+` excludes `import.meta` and a dynamic `import(`. The clause is
|
|
1261
|
+
// `[^'";]*?` so it cannot swallow a side-effect `import '...'` (its quote) or
|
|
1262
|
+
// bridge across a `;` into the next statement's `from`.
|
|
1263
|
+
const reImport = /\bimport\s+([^'";]*?)\bfrom\s*(['"])/g;
|
|
1264
|
+
for (const { abs, rel, content } of files) {
|
|
1265
|
+
const masked = maskedByAbs.get(abs);
|
|
1266
|
+
reImport.lastIndex = 0;
|
|
1267
|
+
let m;
|
|
1268
|
+
while ((m = reImport.exec(masked))) {
|
|
1269
|
+
const quote = m[2];
|
|
1270
|
+
const specStart = reImport.lastIndex; // just past the opening quote
|
|
1271
|
+
const specEnd = content.indexOf(quote, specStart);
|
|
1272
|
+
if (specEnd < 0) continue;
|
|
1273
|
+
const spec = content.slice(specStart, specEnd);
|
|
1274
|
+
if (!/^(?:\.|#)/.test(spec)) continue; // app-internal only (relative or #alias)
|
|
1275
|
+
const names = importedValueNames(m[1]);
|
|
1276
|
+
if (!names || names.length === 0) continue;
|
|
1277
|
+
const target = resolveImport(spec, abs, appDir);
|
|
1278
|
+
const exp = exportsByAbs.get(target);
|
|
1279
|
+
if (exp == null) continue; // not an app file, or exports not enumerable
|
|
1280
|
+
for (const name of names) {
|
|
1281
|
+
if (exp.has(name)) continue;
|
|
1282
|
+
violations.push({
|
|
1283
|
+
rule: 'no-missing-local-import',
|
|
1284
|
+
file: rel,
|
|
1285
|
+
message: `Imports \`${name}\` from \`${spec}\`, but that module does not export \`${name}\`. The binding is undefined at runtime and crashes on first use (often a renamed or removed export, e.g. a dropped schema table).`,
|
|
1286
|
+
fix: `Add \`${name}\` to \`${spec}\`, correct the imported name, or remove the import.`,
|
|
1287
|
+
});
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1128
1293
|
return violations;
|
|
1129
1294
|
}
|
|
1130
1295
|
|
|
@@ -1261,21 +1426,25 @@ async function checkServerImportInBrowserModule(appDir, violations) {
|
|
|
1261
1426
|
// rule treats every candidate as shipping (which is correct: with elision
|
|
1262
1427
|
// off, a display-only page really does ship its server import too).
|
|
1263
1428
|
const elideEnabled = await readElideEnabledForCheck(appDir);
|
|
1264
|
-
const { elidableComponents, inertRouteModules } = elideEnabled
|
|
1429
|
+
const { elidableComponents, inertRouteModules, importOnlyRouteModules } = elideEnabled
|
|
1265
1430
|
? await analyzeElision(components, routeModules, moduleGraph, (f) => readFile(f, 'utf8'), appDir)
|
|
1266
|
-
: { elidableComponents: new Set(), inertRouteModules: new Set() };
|
|
1431
|
+
: { elidableComponents: new Set(), inertRouteModules: new Set(), importOnlyRouteModules: new Map() };
|
|
1267
1432
|
|
|
1268
1433
|
// Candidate browser-shipped modules: components that are NOT elided, plus
|
|
1269
|
-
// route modules that are NOT inert
|
|
1270
|
-
//
|
|
1271
|
-
//
|
|
1434
|
+
// route modules that are NOT inert and NOT import-only (an import-only
|
|
1435
|
+
// module is dropped from the boot in favour of its component frontier,
|
|
1436
|
+
// #605/#963, so its own imports never load in a browser and a bare
|
|
1437
|
+
// server-only import in it is harmless). A `.server.*` file is never a
|
|
1438
|
+
// component (the scanner skips it) nor a route module the browser loads, so
|
|
1439
|
+
// it cannot enter this set; server-to-server imports are excluded by
|
|
1440
|
+
// construction.
|
|
1272
1441
|
/** @type {Map<string, { kind: string }>} relFile is keyed by ABS path */
|
|
1273
1442
|
const candidates = new Map();
|
|
1274
1443
|
for (const c of components) {
|
|
1275
1444
|
if (!elidableComponents.has(c.file)) candidates.set(c.file, { kind: 'component' });
|
|
1276
1445
|
}
|
|
1277
1446
|
for (const file of routeModules) {
|
|
1278
|
-
if (inertRouteModules.has(file)) continue;
|
|
1447
|
+
if (inertRouteModules.has(file) || importOnlyRouteModules.has(file)) continue;
|
|
1279
1448
|
const base = basename(file);
|
|
1280
1449
|
const kind = /^layout\./.test(base) ? 'layout' : 'page';
|
|
1281
1450
|
candidates.set(file, { kind });
|
|
@@ -1332,17 +1501,20 @@ async function checkServerImportInBrowserModule(appDir, violations) {
|
|
|
1332
1501
|
const importer = chainFiles.length >= 2 ? chainFiles[chainFiles.length - 2] : null;
|
|
1333
1502
|
const viaTypesModule = importer && /(^|\/)types(\.m?[jt]s$|\/)/.test(relative(appDir, importer));
|
|
1334
1503
|
|
|
1335
|
-
// The "
|
|
1336
|
-
//
|
|
1337
|
-
//
|
|
1338
|
-
//
|
|
1339
|
-
//
|
|
1504
|
+
// The "elides again" remedy only applies to a page / layout: since the
|
|
1505
|
+
// path-aware import-only verdict (#963), a page importing a component to
|
|
1506
|
+
// register is dropped from the boot, so a page/layout on this rule ships
|
|
1507
|
+
// because of its OWN client work (or a client-effecting non-component in
|
|
1508
|
+
// its closure); moving that work into a component makes it a dropped
|
|
1509
|
+
// carrier again and the server import never loads. The error / loading /
|
|
1510
|
+
// not-found boundaries always ship and are never elided, so offering
|
|
1511
|
+
// them an "elides again" fix is wrong. Branch the fix text on kind.
|
|
1340
1512
|
const canElide = kind === 'page' || kind === 'layout';
|
|
1341
1513
|
const typesHint = viaTypesModule
|
|
1342
1514
|
? `The edge enters via a types-shaped module (${relative(appDir, importer)}); if it re-exports a runtime VALUE from a \`.server.{ts,js}\` file, relocate that to a browser-safe typedef (a plain \`interface\` / JSDoc, or an \`import type\` which the stripper erases) so the type is shared without pinning the module. `
|
|
1343
1515
|
: '';
|
|
1344
1516
|
const fixText = canElide
|
|
1345
|
-
? `${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)
|
|
1517
|
+
? `${typesHint}Keep the server call off this browser-shipped ${kind}. Options: (1) gate the route in \`middleware.ts\` (runs server-side, never ships); (2) move the server-only call behind a \`'use server'\` action in a \`.server.{ts,js}\` file and call it as an RPC; or (3) move this ${kind}'s own client work (the module-scope call, browser-global access, or client-effecting util import that pins it) into a component, so the ${kind} elides again as a dropped carrier and its server import never loads.`
|
|
1346
1518
|
: `${typesHint}Keep the server call off this browser-shipped ${kind} (it always ships and is never elided). Options: (1) gate the route in \`middleware.ts\` (runs server-side, never ships); or (2) move the server-only call behind a \`'use server'\` action in a \`.server.{ts,js}\` file and call it as an RPC.`;
|
|
1347
1519
|
|
|
1348
1520
|
violations.push({
|
package/src/component-elision.js
CHANGED
|
@@ -141,18 +141,21 @@ export const CLIENT_METHOD_CALLS = ['addController', 'removeController', 'reques
|
|
|
141
141
|
* component that arrives via a client DOM insertion (a soft-nav swap, a
|
|
142
142
|
* streamed <webjs-suspense> boundary's replaceWith) would never re-attach
|
|
143
143
|
* its shadow root if elided. Context-free, so any shadow component ships.
|
|
144
|
-
* - `
|
|
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
|
/**
|
|
@@ -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
|
|
@@ -1528,6 +1550,7 @@ function wrapHead(opts) {
|
|
|
1528
1550
|
<html lang="en">
|
|
1529
1551
|
<head>
|
|
1530
1552
|
<meta charset="utf-8">
|
|
1553
|
+
<style${n}>@layer webjs-host{:where([data-wj-host]){display:block}:where([data-wj-host][hidden]:not([hidden='until-found'])){display:none}}</style>
|
|
1531
1554
|
${opts.nonce ? `<meta name="csp-nonce" content="${escapeAttr(opts.nonce)}">` : ''}
|
|
1532
1555
|
${metaTags.join('\n')}
|
|
1533
1556
|
<title>${escapeHtml(title)}</title>
|
package/src/vendor.js
CHANGED
|
@@ -1176,9 +1176,16 @@ async function pruneOrphans(appDir, expected) {
|
|
|
1176
1176
|
* instead. When the app has zero bare-specifier imports at all
|
|
1177
1177
|
* (scanned source produced nothing), returns
|
|
1178
1178
|
* `{ pins: [], pruned: [], downloaded: 0, noBareImports: true }`
|
|
1179
|
-
* WITHOUT writing the pin file.
|
|
1180
|
-
*
|
|
1181
|
-
*
|
|
1179
|
+
* WITHOUT writing the pin file. When the scan DID find specifiers but
|
|
1180
|
+
* every one was dropped because no local version resolved (the package
|
|
1181
|
+
* is not installed under `node_modules`), returns
|
|
1182
|
+
* `{ pins: [], pruned: [], downloaded: 0, droppedUnresolvable: [...] }`
|
|
1183
|
+
* (also WITHOUT a pin file), so the caller can name the found-but-
|
|
1184
|
+
* uninstalled specifiers instead of claiming there were none. A
|
|
1185
|
+
* partial pin (some resolved, some dropped) still writes the file and
|
|
1186
|
+
* carries `droppedUnresolvable` alongside `pins`. Callers that need to
|
|
1187
|
+
* surface a non-zero exit code key off `failed`, `noBareImports`, or an
|
|
1188
|
+
* all-dropped `droppedUnresolvable`; all are absent on a clean success.
|
|
1182
1189
|
*
|
|
1183
1190
|
* The `from` option mirrors importmap-rails's `bin/importmap pin foo
|
|
1184
1191
|
* --from jsdelivr`. Default `jspm` resolves to jspm.io; other values
|
|
@@ -1196,6 +1203,7 @@ async function pruneOrphans(appDir, expected) {
|
|
|
1196
1203
|
* provider?: string,
|
|
1197
1204
|
* failed?: boolean,
|
|
1198
1205
|
* noBareImports?: boolean,
|
|
1206
|
+
* droppedUnresolvable?: string[],
|
|
1199
1207
|
* attemptedInstalls?: string[],
|
|
1200
1208
|
* }>}
|
|
1201
1209
|
*/
|
|
@@ -1224,12 +1232,23 @@ export async function pinAll(appDir, opts = {}) {
|
|
|
1224
1232
|
* @type {Map<string, { pkg: string, version: string, subpath: string }>}
|
|
1225
1233
|
*/
|
|
1226
1234
|
const partsByInstall = new Map();
|
|
1235
|
+
/**
|
|
1236
|
+
* Bare specifiers the scan FOUND but that were dropped because no local
|
|
1237
|
+
* version could be resolved (the package is not installed under the app's
|
|
1238
|
+
* `node_modules`). Kept distinct from "scan found nothing" so the CLI can
|
|
1239
|
+
* name them and point at the remedy instead of claiming there were none.
|
|
1240
|
+
* @type {string[]}
|
|
1241
|
+
*/
|
|
1242
|
+
const droppedUnresolvable = [];
|
|
1227
1243
|
for (const spec of bare) {
|
|
1228
1244
|
if (BUILTIN.has(spec)) continue;
|
|
1229
1245
|
const pkg = extractPackageName(spec);
|
|
1230
1246
|
if (!pkg || BUILTIN.has(pkg) || FRAMEWORK_SERVER_ONLY.has(pkg)) continue;
|
|
1231
1247
|
const version = getPackageVersion(pkg, appDir);
|
|
1232
|
-
if (!version)
|
|
1248
|
+
if (!version) {
|
|
1249
|
+
droppedUnresolvable.push(spec);
|
|
1250
|
+
continue;
|
|
1251
|
+
}
|
|
1233
1252
|
const subpath = spec.slice(pkg.length);
|
|
1234
1253
|
const install = `${pkg}@${version}${subpath}`;
|
|
1235
1254
|
installs.push(install);
|
|
@@ -1350,12 +1369,22 @@ export async function pinAll(appDir, opts = {}) {
|
|
|
1350
1369
|
// -imports filter, so the file exists but does nothing. The CLI
|
|
1351
1370
|
// surfaces this as a clearer "no bare imports found" message.
|
|
1352
1371
|
if (installs.length === 0) {
|
|
1372
|
+
// Distinguish the two empty-set causes the CLI must report differently:
|
|
1373
|
+
// the scan genuinely found nothing (noBareImports) vs it found specifiers
|
|
1374
|
+
// that were all dropped for a missing local version (droppedUnresolvable).
|
|
1375
|
+
if (droppedUnresolvable.length > 0) {
|
|
1376
|
+
return { pins, pruned: [], downloaded, droppedUnresolvable, provider: from };
|
|
1377
|
+
}
|
|
1353
1378
|
return { pins, pruned: [], downloaded, noBareImports: true, provider: from };
|
|
1354
1379
|
}
|
|
1355
1380
|
|
|
1356
1381
|
await writePinFile(appDir, importmap, integrity, from);
|
|
1357
1382
|
const pruned = await pruneOrphans(appDir, expected);
|
|
1358
|
-
|
|
1383
|
+
// Some specifiers may have pinned while others were dropped for a missing
|
|
1384
|
+
// version; surface the dropped ones so a partial pin is not silent.
|
|
1385
|
+
return droppedUnresolvable.length > 0
|
|
1386
|
+
? { pins, pruned, downloaded, provider: from, droppedUnresolvable }
|
|
1387
|
+
: { pins, pruned, downloaded, provider: from };
|
|
1359
1388
|
}
|
|
1360
1389
|
|
|
1361
1390
|
/**
|
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",
|