@webjsdev/server 0.8.58 → 0.8.60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/actions.js CHANGED
@@ -20,6 +20,7 @@ import { ifNoneMatchSatisfied } from './conditional-get.js';
20
20
  import { getBodyLimits } from './context.js';
21
21
  import { basePath } from './importmap.js';
22
22
  import { withBasePath } from './base-path.js';
23
+ import { FORM_ACTION_ID_KEY } from '@webjsdev/core';
23
24
 
24
25
  /**
25
26
  * The JSON / RPC body cap in effect for the current request: the per-request
@@ -417,11 +418,18 @@ function buildStubBody({ hash, method, fnNames, actionUrl }) {
417
418
  lines.push(` return __body(fn, key, __METHOD, sig);`);
418
419
  lines.push(`}`);
419
420
  }
421
+ // Each stub carries its own `<hash>/<fn>` identity (#1155), so a client
422
+ // re-render of `<form action=${action}>` can bind the live form
423
+ // synchronously. The server resolves the REAL function's identity through the
424
+ // load-hook registry instead; both produce the same string, which is what
425
+ // makes the SSR'd form and the re-rendered one submit the same value.
426
+ // Non-enumerable so `import * as actions` and a spread stay unchanged.
427
+ lines.push(`const __id = (fn, name) => { try { Object.defineProperty(fn, ${J(FORM_ACTION_ID_KEY)}, { value: __HASH + '/' + name }); } catch {} return fn; };`);
420
428
  for (const name of fnNames) {
421
429
  lines.push(
422
430
  name === 'default'
423
- ? `export default (...args) => __call('default', args);`
424
- : `export const ${name} = (...args) => __call(${J(name)}, args);`,
431
+ ? `export default __id((...args) => __call('default', args), 'default');`
432
+ : `export const ${name} = __id((...args) => __call(${J(name)}, args), ${J(name)});`,
425
433
  );
426
434
  }
427
435
  return lines.join('\n') + '\n';
package/src/base-path.js CHANGED
@@ -11,7 +11,10 @@
11
11
  * hydrates. `createRequestHandler` explicitly targets embedding, where a
12
12
  * sub-path mount is the norm.
13
13
  *
14
- * The model is strip-at-ingress + prefix-on-emit, two seams only:
14
+ * The model is strip-at-ingress + prefix-on-emit. Two seams carry it, plus two
15
+ * further `stripBasePath` consumers on the CACHE path (`dev.js`'s HTML-cache
16
+ * write and `html-cache.js`'s `revalidatePath`), which key on the
17
+ * app-root-relative path so a mounted app hits its own cache:
15
18
  *
16
19
  * 1. STRIP AT INGRESS. At the very start of request handling, when the
17
20
  * request pathname starts with the basePath, strip it so all
package/src/body-limit.js CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * webjs's prod server reads request bodies on three paths: the server-action
5
5
  * RPC endpoint (actions.js), `route.{js,ts}` handlers via `readBody` (json.js),
6
- * and the no-JS page-action form path (page-action.js). Without a cap, an
6
+ * and the no-JS form-submission path (form-dispatch.js). Without a cap, an
7
7
  * uncapped body is a memory-exhaustion vector. This module is the SINGLE place
8
8
  * that decides the limit and performs a bounded read, so every body-read site
9
9
  * enforces it uniformly.
@@ -31,7 +31,7 @@
31
31
  export const DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
32
32
 
33
33
  /**
34
- * Default form / multipart body cap: 10 MiB. A form submission (the page-action
34
+ * Default form / multipart body cap: 10 MiB. A form submission (the form-dispatch
35
35
  * path) may legitimately carry more than a JSON RPC call (a textarea, a small
36
36
  * upload), so it gets a separate, higher, still-bounded limit. Large file
37
37
  * uploads are a distinct concern (#247) with their own streaming story.
@@ -40,7 +40,7 @@ export const DEFAULT_MAX_MULTIPART_BYTES = 10 * 1024 * 1024;
40
40
 
41
41
  /**
42
42
  * Thrown by `readBody` (json.js) when a route handler's body exceeds the limit.
43
- * The RPC and page-action paths return a 413 Response inline, but `readBody`
43
+ * The RPC and form-dispatch paths return a 413 Response inline, but `readBody`
44
44
  * runs INSIDE a user route handler and returns parsed data, so it signals the
45
45
  * over-limit case by throwing. The API dispatcher (`handleApi`) catches this and
46
46
  * maps it to a 413, so a handler that just does `await readBody(req)` gets the
@@ -266,7 +266,7 @@ export async function readTextBounded(req, limit) {
266
266
  }
267
267
 
268
268
  /**
269
- * Read a request body as `FormData`, bounded by `limit`. Used by the page-action
269
+ * Read a request body as `FormData`, bounded by `limit`. Used by the form-dispatch
270
270
  * form path. Reconstructs a bounded Request from the already-read bytes and
271
271
  * defers to the platform `formData()` parser, so multipart and
272
272
  * urlencoded bodies are decoded exactly as before, just size-checked first.
package/src/check.js CHANGED
@@ -3,6 +3,7 @@ import { join, relative, sep, basename, dirname } from 'node:path';
3
3
  import { walk } from './fs-walk.js';
4
4
  import {
5
5
  redactStringsAndTemplates,
6
+ redactToPlaceholders,
6
7
  extractWebComponentClassBodies,
7
8
  matchClosingBrace,
8
9
  parsePropEntries,
@@ -134,6 +135,11 @@ export const RULES = [
134
135
  description:
135
136
  '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.',
136
137
  },
138
+ {
139
+ name: 'form-action-not-a-get-action',
140
+ description:
141
+ 'Flags `<form action=${someAction}>` bound to a `\'use server\'` action whose file declares `export const method = \'GET\'` (#488). A GET action is a READ: it is CSRF-exempt and rides its arguments in the url, while a form submission is a CSRF-checked POST carrying a body, so the two contracts contradict each other and the submission is answered with a 405 at runtime. The rule reads the imported action\'s own file, so it only fires when the binding really does resolve to a GET-declared action. Fix by dropping the `method` export (an action with no `method` is a POST, which is what a form wants) or by binding a different action; if the form really is a read, use a plain `<form method="get" action="/search">` with no bound action.',
142
+ },
137
143
  {
138
144
  name: 'no-redirect-in-api-route',
139
145
  description:
@@ -475,6 +481,36 @@ function enumerableExports(scan) {
475
481
  * @param {string} clause the text between `import` and `from`
476
482
  * @returns {string[]|null}
477
483
  */
484
+ /**
485
+ * The `{ local, imported }` pairs a named import clause binds, for a rule that
486
+ * has to match the LOCAL name against code (`action=${reader}`) while reporting
487
+ * the IMPORTED one (`readIt`, which is what the target module calls it).
488
+ *
489
+ * `importedValueNames` cannot serve here: it returns only the imported side,
490
+ * which is the wrong name to match against a template hole under `as`.
491
+ *
492
+ * @param {string} clause the text between `import` and `from`
493
+ * @returns {{ local: string, imported: string }[]}
494
+ */
495
+ function importedLocalNames(clause) {
496
+ if (/^\s*type\b/.test(clause)) return [];
497
+ const brace = clause.match(/\{([^}]*)\}/);
498
+ if (!brace) return [];
499
+ /** @type {{ local: string, imported: string }[]} */
500
+ const out = [];
501
+ for (const part of brace[1].split(',')) {
502
+ const seg = part.trim().replace(/^type\s+/, '');
503
+ if (!seg) continue;
504
+ const as = seg.split(/\s+as\s+/);
505
+ const imported = as[0].trim();
506
+ const local = (as[1] || as[0]).trim();
507
+ if (/^[A-Za-z_$][\w$]*$/.test(local) && /^[A-Za-z_$][\w$]*$/.test(imported)) {
508
+ out.push({ local, imported });
509
+ }
510
+ }
511
+ return out;
512
+ }
513
+
478
514
  function importedValueNames(clause) {
479
515
  if (/^\s*type\b/.test(clause)) return null; // import type { ... }
480
516
  const brace = clause.match(/\{([^}]*)\}/);
@@ -489,8 +525,9 @@ function importedValueNames(clause) {
489
525
  /**
490
526
  * Scan a WebJs app directory and report convention violations.
491
527
  *
492
- * Every rule is a correctness check (a crash, a security leak, or a
493
- * build/type-strip failure), so they all run unconditionally. There is no
528
+ * Every rule is a correctness check (a crash, a security leak, a reactive
529
+ * prop that silently stops re-rendering, or a build/type-strip failure), so
530
+ * they all run unconditionally. There is no
494
531
  * per-project disabling: project conventions (layout, style, process) live in
495
532
  * CONVENTIONS.md as guidance, not in this tool.
496
533
  *
@@ -1333,6 +1370,90 @@ export async function checkConventions(appDir) {
1333
1370
  }
1334
1371
  }
1335
1372
 
1373
+ // --- Rule: form-action-not-a-get-action (#1155) ---
1374
+ // `<form action=${fn}>` binds a server action to a POST submission. An action
1375
+ // declaring `method = 'GET'` cannot serve one: it is CSRF-exempt and takes its
1376
+ // args in the url, so the dispatcher answers 405. Catch it at edit time.
1377
+ {
1378
+ // A `'use server'` file's declared method, or absent when it declares none.
1379
+ const methodByAbs = new Map();
1380
+ for (const f of files) {
1381
+ if (!/\.server\.m?[jt]s$/.test(f.rel)) continue;
1382
+ if (!hasUseServerDirective(f.content)) continue;
1383
+ const m = /\bexport\s+const\s+method\s*(?::[^=]*)?=\s*['"`]([A-Za-z]+)['"`]/.exec(f.scan);
1384
+ if (m) methodByAbs.set(f.abs, m[1].toUpperCase());
1385
+ }
1386
+ if (methodByAbs.size) {
1387
+ const reImport = /\bimport\s+([^'";]*?)\bfrom\s*(['"])/g;
1388
+ for (const { abs, rel, content } of files) {
1389
+ // PLACEHOLDER redaction, not either mask, and the choice is forced.
1390
+ // The default mask blanks a tagged template outright, so a real
1391
+ // `html`<form action=${fn}>`` disappears and the rule could never fire;
1392
+ // the fully-blanked mask does the same. Placeholder mode keeps `${...}`
1393
+ // holes as REAL code while turning each literal BODY into one opaque
1394
+ // `__STR_n__` token, so a live binding is readable and a `<form
1395
+ // action=${x}>` shown as text inside a docs sample is a single token
1396
+ // with no hole in it. That distinction is the whole carve-out: the
1397
+ // framework's own website renders this exact shape as a code sample.
1398
+ const { redacted, literals } = redactToPlaceholders(content);
1399
+ // A hole binds an action when the literal segment IMMEDIATELY before it
1400
+ // ends inside a start tag at the attribute that tag can really bind:
1401
+ // `action=` on a `<form>` (#1155), or `formaction=` on a `<button>` /
1402
+ // `<input>` submitter (#1207). Reading the segment rather than the whole
1403
+ // template is what keeps `<div action=${x}>` out. The tag and the
1404
+ // attribute are matched as a PAIR, so `<form formaction=${x}>` and
1405
+ // `<button action=${x}>` stay out too: both are refused at render time,
1406
+ // and neither is this rule's failure mode.
1407
+ const bound = new Set();
1408
+ for (const m of redacted.matchAll(/__STR_(\d+)__\$\{\s*([A-Za-z_$][\w$]*)\s*\}/g)) {
1409
+ const before = literals[Number(m[1])] || '';
1410
+ // The last `<` opens the tag the hole sits in. Requiring no `>`
1411
+ // after it keeps the match inside that one start tag.
1412
+ const tagAt = before.lastIndexOf('<');
1413
+ if (tagAt < 0) continue;
1414
+ const tag = before.slice(tagAt);
1415
+ if (tag.includes('>')) continue;
1416
+ const bindsForm = /^<form\b/i.test(tag) && /\saction=$/i.test(tag);
1417
+ const bindsSubmitter = /^<(?:button|input)\b/i.test(tag) && /\sformaction=$/i.test(tag);
1418
+ if (!bindsForm && !bindsSubmitter) continue;
1419
+ bound.add(m[2]);
1420
+ }
1421
+ if (!bound.size) continue;
1422
+ reImport.lastIndex = 0;
1423
+ let im;
1424
+ while ((im = reImport.exec(redacted))) {
1425
+ // The specifier is a placeholder token in this view, so read the real
1426
+ // one back out of `literals`.
1427
+ const tok = /__STR_(\d+)__/.exec(redacted.slice(reImport.lastIndex, reImport.lastIndex + 24));
1428
+ if (!tok) continue;
1429
+ const spec = literals[Number(tok[1])] || '';
1430
+ if (!/^(?:\.|#)/.test(spec)) continue;
1431
+ const names = importedLocalNames(im[1]);
1432
+ if (!names.length) continue;
1433
+ const target = resolveImport(spec, abs, appDir);
1434
+ // ONLY a GET is contradictory, and the rule must say exactly what the
1435
+ // dispatcher does or it is a false positive. A browser form always
1436
+ // submits as a POST, and the dispatcher runs a PUT / PATCH / DELETE
1437
+ // action on that POST: the declared verb governs the RPC transport,
1438
+ // not whether the function can serve a form. A GET is different in
1439
+ // kind (CSRF-exempt, args in the url, no body), which is why it is
1440
+ // the one the dispatcher answers with a 405.
1441
+ const method = target ? methodByAbs.get(target) : undefined;
1442
+ if (method !== 'GET') continue;
1443
+ for (const { local, imported } of names) {
1444
+ if (!bound.has(local)) continue;
1445
+ violations.push({
1446
+ rule: 'form-action-not-a-get-action',
1447
+ file: rel,
1448
+ message: `Binds \`${imported}\` to a form, but \`${spec}\` declares \`method = 'GET'\`. A GET action rides its arguments in the url and skips the CSRF check, so it cannot answer a form POST; the submission is a 405.`,
1449
+ fix: `Drop the \`method\` export from \`${spec}\` (an action with no method is a POST, which is what a form submits), or bind an action that takes a POST.`,
1450
+ });
1451
+ }
1452
+ }
1453
+ }
1454
+ }
1455
+ }
1456
+
1336
1457
  return violations;
1337
1458
  }
1338
1459
 
@@ -145,9 +145,13 @@ export const CLIENT_METHOD_CALLS = ['addController', 'removeController', 'reques
145
145
  * - `interactive`: `static interactive = true` is the explicit author
146
146
  * override that forces a component to ship even when the analyser would
147
147
  * elide it. It is the escape hatch for interactivity the analyser cannot
148
- * see statically (a dynamically-computed tag string, a `:defined` rule in
149
- * an external stylesheet outside the module graph). Context-free: any value
150
- * other than `false` ships.
148
+ * see statically (an OBSERVER that computes the tag it waits for, a
149
+ * `:defined` rule in an external stylesheet outside the module graph, a
150
+ * consumer reaching the element through a string selector). It
151
+ * cannot rescue a component whose OWN registration tag is computed: the
152
+ * scanner requires a literal tag (invariant 3), so such a component is
153
+ * never in `components` at all and nothing consults this analyser for it.
154
+ * Context-free: any value other than `false` ships.
151
155
  *
152
156
  * @type {readonly string[]}
153
157
  */
@@ -904,12 +908,12 @@ export async function computeElidableComponents(components, moduleGraph, readFil
904
908
  * global (`window`, `document`, …), or a shipping component. Anything
905
909
  * ambiguous or unreadable keeps shipping.
906
910
  *
907
- * @param {Array<{ tag: string, file: string }>} components
911
+ * @param {Array<{ tag: string, file: string, className?: string }>} components
908
912
  * @param {string[]} routeModules absolute paths of page + layout files
909
913
  * @param {import('./module-graph.js').ModuleGraph} moduleGraph
910
914
  * @param {(file: string) => Promise<string>} readFileFn
911
915
  * @param {string} [appDir]
912
- * @returns {Promise<{ elidableComponents: Set<string>, inertRouteModules: Set<string>, importOnlyRouteModules: Map<string, string[]>, shippedRouteModules: Map<string, { blocker: string|null, reason: string }> }>}
916
+ * @returns {Promise<{ elidableComponents: Set<string>, inertRouteModules: Set<string>, importOnlyRouteModules: Map<string, string[]>, shippedRouteModules: Map<string, { blocker: string|null, reason: string }>, componentVerdicts: Map<string, { tags: string[], className: string|null, shipped: boolean, evidence: string|null, by: string|null, reason: string|null }> }>}
913
917
  */
914
918
  export async function analyzeElision(components, routeModules, moduleGraph, readFileFn, appDir) {
915
919
  /** @type {Set<string>} */
@@ -926,6 +930,23 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
926
930
 
927
931
  /** @type {Set<string>} */
928
932
  const mustShip = new Set();
933
+ /**
934
+ * WHY each shipping component ships (#1308), so the app-level report can name
935
+ * the evidence instead of only the verdict. Written alongside every
936
+ * `mustShip.add` of a COMPONENT file; FIRST write wins, matching the
937
+ * first-match convention the analyser uses everywhere else.
938
+ * @type {Map<string, { evidence: string, by: string|null, reason: string|null }>}
939
+ */
940
+ const shipEvidence = new Map();
941
+ /**
942
+ * @param {string} file the component file that ships
943
+ * @param {string} evidence one of own | observed | closure | render | import | unreadable
944
+ * @param {string|null} by the module that forced it, where one exists
945
+ * @param {string|null} [reason]
946
+ */
947
+ const noteShip = (file, evidence, by, reason) => {
948
+ if (!shipEvidence.has(file)) shipEvidence.set(file, { evidence, by, reason: reason ?? null });
949
+ };
929
950
  /** @type {Map<string, Set<string>>} */
930
951
  const fileTags = new Map();
931
952
  /** @type {Set<string>} modules importing a reactive primitive from core */
@@ -939,6 +960,8 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
939
960
  /** @type {Set<string>} component files forced to ship because some module
940
961
  * observes their registration (whenDefined / :defined / instanceof). */
941
962
  const observedComponentFiles = new Set();
963
+ /** @type {Map<string, string>} observed component file -> the module observing it (#1308) */
964
+ const observedBy = new Map();
942
965
 
943
966
  /** @type {Set<string>} */
944
967
  const allFiles = new Set(componentFiles);
@@ -957,7 +980,7 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
957
980
  catch {
958
981
  // A component file we cannot read ships conservatively; a helper we
959
982
  // cannot read simply contributes no tags.
960
- if (componentFiles.has(file)) mustShip.add(file);
983
+ if (componentFiles.has(file)) { mustShip.add(file); noteShip(file, 'unreadable', null, null); }
961
984
  continue;
962
985
  }
963
986
  if (typeof src !== 'string') continue;
@@ -989,8 +1012,9 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
989
1012
  hasModuleScopeSideEffect(redacted, literals)) {
990
1013
  clientGlobalOrBareFiles.add(file);
991
1014
  }
992
- if (componentFiles.has(file) && analyzeComponentSource(masked).interactive) {
993
- mustShip.add(file);
1015
+ if (componentFiles.has(file)) {
1016
+ const v = analyzeComponentSource(masked);
1017
+ if (v.interactive) { mustShip.add(file); noteShip(file, 'own', null, v.reason); }
994
1018
  }
995
1019
  // Cross-module registration observation (#169): if THIS module observes
996
1020
  // another component's tag, that component must register client-side, so
@@ -999,13 +1023,13 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
999
1023
  // (all components are known up front, but we collect here while we hold
1000
1024
  // each source). Verdict-safe: only ever forces MORE components to ship.
1001
1025
  for (const m of masked.matchAll(WHEN_DEFINED_RE)) {
1002
- const f = tagToFile.get(m[1]); if (f) observedComponentFiles.add(f);
1026
+ const f = tagToFile.get(m[1]); if (f) { observedComponentFiles.add(f); if (!observedBy.has(f)) observedBy.set(f, file); }
1003
1027
  }
1004
1028
  for (const m of masked.matchAll(TAG_DEFINED_RE)) {
1005
- const f = tagToFile.get(m[1]); if (f) observedComponentFiles.add(f);
1029
+ const f = tagToFile.get(m[1]); if (f) { observedComponentFiles.add(f); if (!observedBy.has(f)) observedBy.set(f, file); }
1006
1030
  }
1007
1031
  for (const m of masked.matchAll(INSTANCEOF_RE)) {
1008
- const f = classToFile.get(m[1]); if (f) observedComponentFiles.add(f);
1032
+ const f = classToFile.get(m[1]); if (f) { observedComponentFiles.add(f); if (!observedBy.has(f)) observedBy.set(f, file); }
1009
1033
  }
1010
1034
  }
1011
1035
 
@@ -1013,7 +1037,7 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
1013
1037
  // render/import rules propagate from it too. Dynamic tag strings and external
1014
1038
  // (non graph-reachable) stylesheets remain an author-facing caveat, since
1015
1039
  // static analysis cannot see them.
1016
- for (const f of observedComponentFiles) mustShip.add(f);
1040
+ for (const f of observedComponentFiles) { mustShip.add(f); noteShip(f, 'observed', observedBy.get(f) ?? null, null); }
1017
1041
 
1018
1042
  // Reverse import edges (who imports each file), built once from the graph.
1019
1043
  // Drives both the closure-client-work reachability below and the fixpoint's
@@ -1065,7 +1089,7 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
1065
1089
  if (!deps) continue;
1066
1090
  for (const dep of deps) {
1067
1091
  if (serverFiles.has(dep)) continue;
1068
- if (reachesClientWork.has(dep)) { mustShip.add(file); break; }
1092
+ if (reachesClientWork.has(dep)) { mustShip.add(file); noteShip(file, 'closure', dep, null); break; }
1069
1093
  }
1070
1094
  }
1071
1095
  }
@@ -1108,14 +1132,14 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
1108
1132
  if (tags) {
1109
1133
  for (const tag of tags) {
1110
1134
  const childFile = tagToFile.get(tag);
1111
- if (childFile && !mustShip.has(childFile)) { mustShip.add(childFile); queue.push(childFile); }
1135
+ if (childFile && !mustShip.has(childFile)) { mustShip.add(childFile); noteShip(childFile, 'render', node, null); queue.push(childFile); }
1112
1136
  }
1113
1137
  }
1114
1138
  const importers = importersOf.get(node);
1115
1139
  if (importers) {
1116
1140
  for (const imp of importers) {
1117
1141
  if (!componentFiles.has(imp)) continue; // import rule is component -> component
1118
- if (!mustShip.has(imp)) { mustShip.add(imp); queue.push(imp); }
1142
+ if (!mustShip.has(imp)) { mustShip.add(imp); noteShip(imp, 'import', node, null); queue.push(imp); }
1119
1143
  }
1120
1144
  }
1121
1145
  }
@@ -1146,6 +1170,41 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
1146
1170
  return 'does client work';
1147
1171
  };
1148
1172
 
1173
+ // Per-component verdict plus the evidence that produced it (#1308). Assembled
1174
+ // ENTIRELY from what the passes above already computed: nothing is re-read and
1175
+ // nothing is re-analysed, so this is a projection, not a second analysis. A
1176
+ // file may register more than one tag, so rows are keyed by FILE with a
1177
+ // sorted tag list.
1178
+ /** @type {Map<string, { tags: string[], className: string|null, shipped: boolean, evidence: string|null, by: string|null, reason: string|null }>} */
1179
+ const componentVerdicts = new Map();
1180
+ for (const c of components) {
1181
+ let row = componentVerdicts.get(c.file);
1182
+ if (!row) {
1183
+ const shipped = mustShip.has(c.file);
1184
+ const ev = shipped ? shipEvidence.get(c.file) : undefined;
1185
+ row = {
1186
+ tags: [], className: c.className ?? null, shipped,
1187
+ evidence: ev ? ev.evidence : null,
1188
+ by: ev ? ev.by : null,
1189
+ // An ELIDED component carries no reason on purpose: elision is the
1190
+ // ABSENCE of every signal, so there is no positive fact to report.
1191
+ // A shipping component with no evidence is only reachable if a future
1192
+ // rule adds to `mustShip` without calling `noteShip`, so report null
1193
+ // rather than a wrong claim (sigil-coverage guards against that drift).
1194
+ reason: !shipped || !ev ? null
1195
+ : ev.evidence === 'own' ? ev.reason
1196
+ : ev.evidence === 'observed' ? `its registration is observed by ${ev.by}`
1197
+ : ev.evidence === 'closure' ? `its import ${ev.by} ${clientEffectReason(/** @type {string} */ (ev.by))}`
1198
+ : ev.evidence === 'render' ? `${ev.by} ships and can render its tag`
1199
+ : ev.evidence === 'import' ? `${ev.by} ships and imports it`
1200
+ : 'its source could not be read (ships conservatively)',
1201
+ };
1202
+ componentVerdicts.set(c.file, row);
1203
+ }
1204
+ if (!row.tags.includes(c.tag)) row.tags.push(c.tag);
1205
+ }
1206
+ for (const row of componentVerdicts.values()) row.tags.sort();
1207
+
1149
1208
  // Route modules fall into three classes by their effective client closure
1150
1209
  // (skipping elided components and server stubs, which never load on the
1151
1210
  // client):
@@ -1242,7 +1301,7 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
1242
1301
  }
1243
1302
  }
1244
1303
 
1245
- return { elidableComponents, inertRouteModules, importOnlyRouteModules, shippedRouteModules };
1304
+ return { elidableComponents, inertRouteModules, importOnlyRouteModules, shippedRouteModules, componentVerdicts };
1246
1305
  }
1247
1306
 
1248
1307
  /** Match a whole-line side-effect import: `import './x.js';` (no bindings). */
@@ -7,15 +7,13 @@
7
7
  * renders a component tag, `lookupModuleUrl(tag)` already has the URL
8
8
  * ready for `<link rel="modulepreload">` hints.
9
9
  *
10
- * The convention WebJs uses is the web-standard one:
11
- *
12
- * class Counter extends WebComponent { … }
13
- * customElements.define('my-counter', Counter);
14
- *
15
- * The scanner looks for `customElements.define('<tag>', <ClassName>)`
16
- * calls: static text patterns that are cheap to regex-match without
17
- * a full TS parse. A full parse would be ~50× slower for no payoff;
18
- * we only need `{ tag, className, moduleUrl }` tuples.
10
+ * The idiomatic WebJs registration is `Class.register('my-counter')`; the
11
+ * web-standard `customElements.define('my-counter', Counter)` is equally
12
+ * supported. The scanner matches BOTH, as static text patterns that are cheap
13
+ * to regex-match without a full TS parse. A full parse would be ~50× slower
14
+ * for no payoff; we only need `{ tag, className, moduleUrl }` tuples. Either
15
+ * way the tag must be a LITERAL (invariant 3): a computed one is invisible
16
+ * here, which is what makes it an orphan (see `findOrphanComponents`).
19
17
  */
20
18
 
21
19
  import { readFile, stat } from 'node:fs/promises';
@@ -148,10 +146,36 @@ export async function primeComponentRegistry(appDir, components) {
148
146
  }
149
147
 
150
148
  /**
151
- * Find `class X extends WebComponent` (or its subclasses) declarations
152
- * that are NOT accompanied by a `customElements.define(tag, X)` call in
153
- * the same file. Lets the dev server warn authors early when they
154
- * forget the registration step.
149
+ * Find ORPHAN components: a `class X extends WebComponent` that NOTHING in the
150
+ * app registers with a LITERAL tag, via either `X.register('tag')` or
151
+ * `customElements.define('tag', X)`. The declaration is per-file, the
152
+ * registration cross-reference is app-wide.
153
+ *
154
+ * TWO shapes land here and they fail differently, so any message about this
155
+ * must cover both:
156
+ *
157
+ * - No registration call ANYWHERE in the app (the forgot-to-register case).
158
+ * Nothing ever registers the tag, so the element NEVER upgrades. The
159
+ * cross-reference is app-wide precisely so a class registered by a sibling
160
+ * module is not accused of this.
161
+ * - A registration whose tag is COMPUTED (`X.register(TAG)`). That call is
162
+ * ordinary code, so it runs IF the module reaches the browser, which
163
+ * requires the importing module to ship WHOLE. An inert, import-only, or
164
+ * elided importer is dropped from the boot and takes the import with it,
165
+ * and then the element does not upgrade either. Assume it does not. An
166
+ * orphan is not in `componentFiles`, so it never joins the frontier an
167
+ * import-only page emits in its place, and a page that renders a real
168
+ * component alongside the orphan is import-only unless it ALSO does its
169
+ * own client work (which ships it whole, #963). Shipping whole is the
170
+ * narrower case, so treat the upgrade as lost until proven otherwise.
171
+ *
172
+ * Lost in EVERY case, whichever shape: the elision verdict, the tag-to-module
173
+ * registry entry, and the modulepreload hint. That, not the upgrade, is the
174
+ * part that is always true, and it is what every message about this should
175
+ * lead with.
176
+ *
177
+ * Matches the literal `extends WebComponent` only, so a class extending a
178
+ * component SUBCLASS is not reported.
155
179
  *
156
180
  * @param {string} appDir
157
181
  * @returns {Promise<Array<{ className: string, file: string }>>}
@@ -164,30 +188,62 @@ export async function findOrphanComponents(appDir) {
164
188
  !/\.(test|spec)\.m?[jt]sx?$/.test(p) &&
165
189
  !/\.server\.m?[jt]s$/.test(p);
166
190
 
191
+ // TWO passes, because registration is an APP-WIDE fact while the declaration
192
+ // is per-file. A class may legitimately be declared in one module and
193
+ // registered by a sibling (`customElements.define('my-badge', Badge)` in a
194
+ // separate file), which the scanner header calls equally supported and which
195
+ // `extractComponents` already picks up as a real component. Reporting it as
196
+ // an orphan is a FALSE positive, and a false warning on a legitimate pattern
197
+ // is exactly what makes an author stop reading the warnings.
198
+ //
199
+ // Trade-off, deliberate: the cross-reference is by class NAME, so two
200
+ // same-named classes in different files, one registered and one genuinely
201
+ // orphaned, hide the real orphan. That is rarer than the sibling-registration
202
+ // pattern and errs toward silence rather than toward a wrong accusation.
203
+ /** @type {Array<{ file: string, declared: Set<string> }>} */
204
+ const declaredPerFile = [];
205
+ /** @type {Set<string>} every class name registered ANYWHERE in the app */
206
+ const registeredAnywhere = new Set();
207
+
167
208
  for await (const file of walk(appDir, filter)) {
168
209
  let src;
169
210
  try { src = await readFile(file, 'utf8'); } catch { continue; }
211
+ // Scan REDACTED source, the same way `extractComponents` above does. A
212
+ // `class X extends WebComponent` written inside an `html` template or a
213
+ // string is a CODE SAMPLE (every docs page is full of them), not a real
214
+ // declaration, and reporting it as an unregistered component is a false
215
+ // orphan. Redaction blanks comments and swaps each string / template body
216
+ // for a `__STR_<idx>__` placeholder, so a genuine top-level declaration
217
+ // still matches while a sample inside a template does not. It does NOT
218
+ // preserve offsets (a placeholder is a different length than the body it
219
+ // replaces), which is fine here because an orphan is reported by class
220
+ // name and file, never by position. If this scan ever needs a line or
221
+ // column, reach for `redactStringsAndTemplates(src, true)`, WITH the
222
+ // blank-strings argument: the default form keeps plain-string bodies and
223
+ // single-line untagged templates verbatim, so a sample written either way
224
+ // would match again and the false orphan would be back.
225
+ const { redacted } = redactToPlaceholders(src);
170
226
  // Find every class that extends WebComponent (exact name: we trust
171
227
  // the framework convention).
172
228
  const classRe = /\b(?:export\s+)?(?:default\s+)?class\s+([A-Z][A-Za-z0-9_$]*)\s+extends\s+WebComponent\b/g;
173
- // A class counts as "registered" if either Class.register('tag') or
174
- // customElements.define('tag', Class) appears in the file.
175
- const registerRe = /\b([A-Z][A-Za-z0-9_$]*)\.register\s*\(\s*['"][^'"]+['"]\s*\)/g;
176
- const defineRe = /\bcustomElements\.define\s*\(\s*['"][^'"]+['"]\s*,\s*([A-Z][A-Za-z0-9_$]*)\b/g;
177
-
178
229
  const declared = new Set();
179
230
  let m;
180
- while ((m = classRe.exec(src)) !== null) declared.add(m[1]);
181
- if (declared.size === 0) continue;
231
+ while ((m = classRe.exec(redacted)) !== null) declared.add(m[1]);
182
232
 
183
- const registered = new Set();
184
- while ((m = registerRe.exec(src)) !== null) registered.add(m[1]);
185
- while ((m = defineRe.exec(src)) !== null) registered.add(m[1]);
233
+ // "Registered" is whatever `extractComponents` accepts, reused rather than
234
+ // re-expressed. The two scans MUST agree on what a literal tag is: when
235
+ // this used its own looser pattern, a redacted interpolated template
236
+ // (`Class.register(\`${p}__STR_1__\`)`) satisfied the orphan scan but not
237
+ // `extractComponents`, so a computed template-literal tag was neither a
238
+ // component NOR an orphan and vanished from every surface, which is the
239
+ // exact shape the orphan report exists to catch.
240
+ for (const c of extractComponents(src)) registeredAnywhere.add(c.className);
241
+ if (declared.size) declaredPerFile.push({ file, declared });
242
+ }
186
243
 
244
+ for (const { file, declared } of declaredPerFile) {
187
245
  for (const cls of declared) {
188
- if (!registered.has(cls)) {
189
- orphans.push({ className: cls, file });
190
- }
246
+ if (!registeredAnywhere.has(cls)) orphans.push({ className: cls, file });
191
247
  }
192
248
  }
193
249
  return orphans;
package/src/context.js CHANGED
@@ -17,7 +17,7 @@ import { setCspNonceProvider, cspNonce } from '@webjsdev/core';
17
17
  *
18
18
  * `bodyLimits` holds the resolved request body-size caps (issue #237) so
19
19
  * `readBody` (used inside `route.{js,ts}` handlers, which have no handle to the
20
- * server state) can enforce the same limit the RPC and page-action paths do. The
20
+ * server state) can enforce the same limit the RPC and form-dispatch paths do. The
21
21
  * handler writes it per request via `setBodyLimits`.
22
22
  *
23
23
  * `requestId` holds the per-request correlation id (issue #239). The handler
package/src/csp.js CHANGED
@@ -50,8 +50,9 @@ const CONTROL_CHARS = /[\x00-\x1f\x7f-\x9f]/;
50
50
  * with webjs's own output, which is: nonce-signed inline `<script>` tags
51
51
  * (the boot script, the public-env shim, the importmap), nonce-signed
52
52
  * `<link rel="modulepreload">`, ES modules fetched same-origin and from
53
- * the configured vendor CDN, and Tailwind's runtime which injects a
54
- * `<style>` element (so inline styles must be allowed).
53
+ * the configured vendor CDN, and one inline `<style>` in the head. The
54
+ * nonce covers scripts only, so that rule and every app-authored style
55
+ * alike are admitted by `'unsafe-inline'` (see the `style-src` note).
55
56
  *
56
57
  * `script-src` uses `'strict-dynamic'` so a nonce-loaded module can pull
57
58
  * in its own dependencies (the importmap-driven per-file ESM graph)
@@ -67,10 +68,25 @@ const CONTROL_CHARS = /[\x00-\x1f\x7f-\x9f]/;
67
68
  const DEFAULT_DIRECTIVES = {
68
69
  'default-src': "'self'",
69
70
  'script-src': "'nonce-__NONCE__' 'strict-dynamic' 'self' https:",
70
- // Tailwind's browser runtime injects a <style> element, and webjs
71
- // emits an inline <style> for adopted component styles, so inline
72
- // styles must be permitted. Style elements are not a script-injection
73
- // vector, so this does not weaken the script protection.
71
+ // NOTE this directive carries no nonce source, unlike `script-src`, so a
72
+ // nonce attribute on a <style> is INERT here and every inline style is
73
+ // admitted by 'unsafe-inline' alone. That includes WebJs's own head rule
74
+ // (the @layer webjs-host block), which SSR does stamp with the nonce but
75
+ // which this policy never checks it against. Dropping 'unsafe-inline'
76
+ // would block that rule too, not only app style.
77
+ //
78
+ // What keeps the allowance necessary is inline style with no realistic
79
+ // nonce path, and it arrives in four shapes. A page or layout may write
80
+ // an inline <style>, which an author could nonce by hand but does not by
81
+ // default. A light-DOM component's <style> and a shadow component's
82
+ // `static styles`, serialized into its declarative shadow root, are both
83
+ // emitted by the core renderer with no nonce and no way to supply one.
84
+ // And every style="..." attribute is unfixable in principle, since a
85
+ // nonce applies to elements and never to attributes. No styling library
86
+ // is involved in any of them.
87
+ //
88
+ // Style elements are not a script-injection vector, so this does not
89
+ // weaken the script protection.
74
90
  'style-src': "'self' 'unsafe-inline'",
75
91
  'img-src': "'self' data: https:",
76
92
  'font-src': "'self' data: https:",