@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/index.d.ts +125 -12
- package/index.js +2 -0
- package/package.json +3 -3
- package/src/action-seed-bun.js +2 -2
- package/src/action-seed.js +438 -70
- package/src/actions.js +10 -2
- package/src/base-path.js +4 -1
- package/src/body-limit.js +4 -4
- package/src/check.js +123 -2
- package/src/component-elision.js +75 -16
- package/src/component-scanner.js +82 -26
- package/src/context.js +1 -1
- package/src/csp.js +22 -6
- package/src/csrf.js +33 -12
- package/src/dev-error.js +13 -2
- package/src/dev-overlay.js +218 -6
- package/src/dev.js +151 -33
- package/src/elision-differential.js +101 -0
- package/src/elision-report.js +133 -30
- package/src/form-action-identity.js +290 -0
- package/src/form-dispatch.js +666 -0
- package/src/forwarded.js +68 -10
- package/src/headers.js +9 -7
- package/src/html-cache.js +117 -13
- package/src/js-scan.js +41 -13
- package/src/listener-bun.js +1 -1
- package/src/listener-core.js +35 -5
- package/src/rate-limit.js +31 -3
- package/src/route-types.js +1 -1
- package/src/ssr.js +93 -17
- package/src/stream.js +1 -1
- package/src/testing.d.ts +19 -0
- package/src/testing.js +113 -0
- package/src/vendor.js +85 -22
- package/webjs-config.schema.json +19 -3
- package/src/page-action.js +0 -259
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
493
|
-
* build/type-strip failure), so
|
|
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
|
|
package/src/component-elision.js
CHANGED
|
@@ -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 (
|
|
149
|
-
* an external stylesheet outside the module graph
|
|
150
|
-
*
|
|
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)
|
|
993
|
-
|
|
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). */
|
package/src/component-scanner.js
CHANGED
|
@@ -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
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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`
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
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(
|
|
181
|
-
if (declared.size === 0) continue;
|
|
231
|
+
while ((m = classRe.exec(redacted)) !== null) declared.add(m[1]);
|
|
182
232
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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 (!
|
|
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
|
|
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
|
|
54
|
-
*
|
|
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
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
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:",
|