@webjsdev/server 0.8.59 → 0.8.61
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 +99 -10
- package/index.js +1 -0
- package/package.json +2 -2
- package/src/action-seed.js +75 -7
- package/src/actions.js +1 -1
- package/src/check.js +1 -1
- package/src/component-elision.js +82 -18
- package/src/component-scanner.js +82 -26
- package/src/dev.js +62 -9
- package/src/elision-differential.js +101 -0
- package/src/elision-report.js +133 -30
- package/src/form-dispatch.js +169 -3
- package/src/js-scan.js +41 -13
- package/src/ssr.js +50 -14
package/src/dev.js
CHANGED
|
@@ -15,7 +15,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
15
15
|
import { buildRouteTable, matchPage, matchApi } from './router.js';
|
|
16
16
|
import { generateRouteTypes } from './route-types.js';
|
|
17
17
|
import { ssrPage, ssrNotFound, setClientRouterEnabled } from './ssr.js';
|
|
18
|
-
import { runFormAction } from './form-dispatch.js';
|
|
18
|
+
import { runFormAction, reportFormSubmittedAsGet } from './form-dispatch.js';
|
|
19
19
|
import { handleApi } from './api.js';
|
|
20
20
|
import {
|
|
21
21
|
buildActionIndex,
|
|
@@ -650,7 +650,7 @@ export async function createRequestHandler(opts) {
|
|
|
650
650
|
// `webjs.seed` switch. Action identity (#1155) rides the same hook, and it is
|
|
651
651
|
// what a bound `<form action=${action}>` resolves through, so gating the hook
|
|
652
652
|
// on seeding would mean `webjs.seed: false` silently broke every no-JS form.
|
|
653
|
-
await registerActionHooks({ seed: await readSeedEnabled(appDir) });
|
|
653
|
+
await registerActionHooks({ seed: await readSeedEnabled(appDir), dev });
|
|
654
654
|
|
|
655
655
|
|
|
656
656
|
// When an app commits a vendor pin (.webjs/vendor/importmap.json) it carries a
|
|
@@ -909,10 +909,14 @@ export async function createRequestHandler(opts) {
|
|
|
909
909
|
// Client-router opt-out (#629): re-read each pass so toggling
|
|
910
910
|
// `webjs.clientRouter` takes effect on rebuild without a restart.
|
|
911
911
|
setClientRouterEnabled(await readClientRouterEnabled(appDir));
|
|
912
|
-
|
|
912
|
+
// Read the switch ONCE: the dev summary below reports it too, and
|
|
913
|
+
// calling readElideEnabled twice per warm re-reads package.json.
|
|
914
|
+
const elideOn = await readElideEnabled(appDir);
|
|
915
|
+
const r = elideOn
|
|
913
916
|
? await analyzeElision(components, collectRouteModules(state.routeTable),
|
|
914
917
|
state.moduleGraph, (f) => readFile(f, 'utf8'), appDir)
|
|
915
|
-
: { elidableComponents: new Set(), inertRouteModules: new Set(), importOnlyRouteModules: new Map()
|
|
918
|
+
: { elidableComponents: new Set(), inertRouteModules: new Set(), importOnlyRouteModules: new Map(),
|
|
919
|
+
shippedRouteModules: new Map(), componentVerdicts: new Map() };
|
|
916
920
|
state.elidableComponents = r.elidableComponents;
|
|
917
921
|
state.inertRouteModules = r.inertRouteModules;
|
|
918
922
|
state.importOnlyRouteModules = r.importOnlyRouteModules;
|
|
@@ -982,12 +986,40 @@ export async function createRequestHandler(opts) {
|
|
|
982
986
|
}
|
|
983
987
|
t.elision = now() - m;
|
|
984
988
|
if (dev) {
|
|
989
|
+
// An orphan is EITHER shape `findOrphanComponents` reports: no
|
|
990
|
+
// registration call at all, or one whose tag is computed. The
|
|
991
|
+
// scanner matches only a literal tag, so it sees neither, and
|
|
992
|
+
// what is ALWAYS lost is the verdict, the registry entry, and the
|
|
993
|
+
// preload hint. The upgrade is the part that differs, and a
|
|
994
|
+
// computed tag only survives when its importer ships WHOLE (see
|
|
995
|
+
// findOrphanComponents for why that is the exception). The doctor
|
|
996
|
+
// check and `webjs elision` say the same thing.
|
|
985
997
|
for (const { className, file } of await findOrphanComponents(appDir)) {
|
|
986
998
|
logger.warn?.(
|
|
987
|
-
`[webjs] ${className} extends WebComponent but
|
|
988
|
-
`
|
|
999
|
+
`[webjs] ${className} extends WebComponent but is never registered with a literal tag in ${file} ` +
|
|
1000
|
+
`(either there is no registration call, or its tag is computed), so the scanner cannot see it: ` +
|
|
1001
|
+
`no elision verdict, no registry entry, no preload hint. ` +
|
|
1002
|
+
`<${kebab(className)}> tags never upgrade with no registration call, and a computed tag ` +
|
|
1003
|
+
`upgrades only while its importing module ships whole. ` +
|
|
1004
|
+
`Add \`${className}.register('<tag-name>');\` with a literal tag.`,
|
|
989
1005
|
);
|
|
990
1006
|
}
|
|
1007
|
+
// The elision summary (#1308), one server-console line. NOT a
|
|
1008
|
+
// browser push: an inert route ships zero application JS, and the
|
|
1009
|
+
// most useful manual check an author has is opening the network
|
|
1010
|
+
// tab on that route and seeing nothing, which a dev-only boot
|
|
1011
|
+
// script would corrupt on exactly the pages this proves. The fact
|
|
1012
|
+
// is also app-wide while a browser channel is per-tab, and the
|
|
1013
|
+
// per-page detail already has a home in `webjs elision`.
|
|
1014
|
+
// Re-emitted after each fs.watch rebuild, because doRebuild sets
|
|
1015
|
+
// analysisDone = false and this sits inside that stage.
|
|
1016
|
+
logger.info?.(
|
|
1017
|
+
elideOn
|
|
1018
|
+
? `[webjs] elision: ${r.elidableComponents.size}/${new Set(components.map((c) => c.file)).size} components elided, ` +
|
|
1019
|
+
`${r.inertRouteModules.size} route modules inert, ${r.importOnlyRouteModules.size} import-only, ` +
|
|
1020
|
+
`${r.shippedRouteModules.size} ship whole. Run \`webjs elision\` for the per-module verdict.`
|
|
1021
|
+
: `[webjs] elision: disabled (WEBJS_ELIDE / webjs.elide), every module ships.`,
|
|
1022
|
+
);
|
|
991
1023
|
}
|
|
992
1024
|
analysisDone = true;
|
|
993
1025
|
ranAnalysis = true;
|
|
@@ -1316,12 +1348,18 @@ export async function createRequestHandler(opts) {
|
|
|
1316
1348
|
// root-mounted `/__webjs/*`. The logged `path` stays the RAW client URL.
|
|
1317
1349
|
if (shouldAccessLog(headerPathname)) {
|
|
1318
1350
|
try {
|
|
1351
|
+
// #1309: fold the dev-only seeding counters into the ONE access-log
|
|
1352
|
+
// line rather than adding a second one. Present only on a response
|
|
1353
|
+
// that carried the header, so only page renders gain the field and
|
|
1354
|
+
// production is unchanged.
|
|
1355
|
+
const seed = dev ? conditioned.headers.get('x-webjs-seed') : null;
|
|
1319
1356
|
logger.info?.('request', {
|
|
1320
1357
|
requestId: reqId,
|
|
1321
1358
|
method: req.method,
|
|
1322
1359
|
path: pathname,
|
|
1323
1360
|
status: conditioned.status,
|
|
1324
1361
|
durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
|
|
1362
|
+
...(seed ? { seed } : {}),
|
|
1325
1363
|
});
|
|
1326
1364
|
} catch { /* never let logging crash the response */ }
|
|
1327
1365
|
}
|
|
@@ -1479,7 +1517,7 @@ export async function createRequestHandler(opts) {
|
|
|
1479
1517
|
// Build all whole-app analysis on the first request (memoized), before
|
|
1480
1518
|
// any SSR, module serve, gate check, action dispatch, or middleware runs.
|
|
1481
1519
|
await ensureReady();
|
|
1482
|
-
const next = () => handleCore(req, { state, appDir, coreDir, dev, reportError, reportDevError, cspEnabled: cspConfig.enabled, allowedOrigins: allowedOriginsValue });
|
|
1520
|
+
const next = () => handleCore(req, { state, appDir, coreDir, dev, reportError, reportDevError, hasOnError, logger, cspEnabled: cspConfig.enabled, allowedOrigins: allowedOriginsValue });
|
|
1483
1521
|
if (state.middleware) {
|
|
1484
1522
|
try {
|
|
1485
1523
|
return await state.middleware(req, next);
|
|
@@ -1976,7 +2014,7 @@ async function tryServeFrameworkStatic(path, method, ctx) {
|
|
|
1976
2014
|
}
|
|
1977
2015
|
|
|
1978
2016
|
async function handleCore(req, ctx) {
|
|
1979
|
-
const { state, appDir, coreDir, dev, reportError, reportDevError, cspEnabled, allowedOrigins } = ctx;
|
|
2017
|
+
const { state, appDir, coreDir, dev, reportError, reportDevError, hasOnError, logger, cspEnabled, allowedOrigins } = ctx;
|
|
1980
2018
|
const url = new URL(req.url);
|
|
1981
2019
|
// Decode percent-encoded characters so filesystem lookups match real
|
|
1982
2020
|
// filenames. Dynamic route segments like `[slug]` and route groups like
|
|
@@ -2252,6 +2290,20 @@ async function handleCore(req, ctx) {
|
|
|
2252
2290
|
: undefined,
|
|
2253
2291
|
};
|
|
2254
2292
|
if (method === 'GET' || method === 'HEAD') {
|
|
2293
|
+
// #1307: `__webjs_action` in the QUERY STRING means a submission
|
|
2294
|
+
// carrying a bound action's identity went out as a GET, so the action
|
|
2295
|
+
// never ran. Nothing else in the framework puts that reserved field in
|
|
2296
|
+
// a url. Detect only, so the render below is unchanged: answering a GET
|
|
2297
|
+
// differently because of a query parameter would hand any visitor a way
|
|
2298
|
+
// to turn any page into an error.
|
|
2299
|
+
reportFormSubmittedAsGet(
|
|
2300
|
+
url, req,
|
|
2301
|
+
// Gated on hasOnError, not on `reportError`: that is always a
|
|
2302
|
+
// function here and no-ops internally, so passing it would spend a
|
|
2303
|
+
// dedupe slot on a report nobody receives.
|
|
2304
|
+
hasOnError ? (e) => reportError(e, req, 'action') : undefined,
|
|
2305
|
+
logger, dev, page.route,
|
|
2306
|
+
);
|
|
2255
2307
|
// A successful render of URL U supersedes a RETAINED render error for
|
|
2256
2308
|
// that same URL (#1047), so a reconnecting tab is not handed a frame the
|
|
2257
2309
|
// page has since recovered from. Keyed on BOTH frame identity and url:
|
|
@@ -2280,7 +2332,8 @@ async function handleCore(req, ctx) {
|
|
|
2280
2332
|
const deps = {
|
|
2281
2333
|
actionIndex: state.actionIndex,
|
|
2282
2334
|
allowedOrigins,
|
|
2283
|
-
onError:
|
|
2335
|
+
onError: hasOnError ? (e) => reportError(e, req, 'action') : undefined,
|
|
2336
|
+
logger,
|
|
2284
2337
|
};
|
|
2285
2338
|
const handler = () => runFormAction(page.route, page.params, url, req, ssrOpts, deps);
|
|
2286
2339
|
return runWithSegmentMiddleware(req, page.route.middlewares, handler, dev);
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The differential-elision primitives, shared by the framework's own guard and
|
|
3
|
+
* by the app-facing one (#1308).
|
|
4
|
+
*
|
|
5
|
+
* Elision's defining invariant is that removing the elided JS NEVER changes
|
|
6
|
+
* observable output. `packages/server/test/elision/differential-elision.test.js`
|
|
7
|
+
* proves that for the blog corpus by rendering every route with elision ON and
|
|
8
|
+
* OFF and diffing the bytes with the JS-loaded set masked out. `webjs elision
|
|
9
|
+
* --verify` runs the identical comparison over an arbitrary app's own route
|
|
10
|
+
* table, so every app gets the framework's own guard rather than inheriting a
|
|
11
|
+
* guarantee it cannot verify locally.
|
|
12
|
+
*
|
|
13
|
+
* `maskJsSet` therefore lives HERE and not in either consumer: it is the
|
|
14
|
+
* definition of "the JS-loaded set", and two copies is the one way the two
|
|
15
|
+
* guards could silently disagree about what the invariant even means.
|
|
16
|
+
*
|
|
17
|
+
* A leaf module: `node:path` is not even needed, and nothing here reads the
|
|
18
|
+
* filesystem or builds a graph.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Mask the JS-loaded set so the diff sees only observable output. The
|
|
23
|
+
* importmap, the boot module script, and the modulepreload hints are
|
|
24
|
+
* REMOVED (not placeheld) because their COUNT differs on vs off, and the
|
|
25
|
+
* build-id hash is derived from them; collapsing whitespace afterwards
|
|
26
|
+
* means the differing-length preload block in the head leaves no trace. The
|
|
27
|
+
* two responses come from the identical SSR template pipeline, so any
|
|
28
|
+
* legitimate text/whitespace is the same on both sides regardless.
|
|
29
|
+
*
|
|
30
|
+
* Because the whole JS-loaded set is masked BY CONSTRUCTION, only the
|
|
31
|
+
* DANGEROUS direction (elision changed what the SSR emits) can fail a diff
|
|
32
|
+
* built on this. The SAFE direction (over-ship) lives entirely inside the
|
|
33
|
+
* masked region and is invisible here, by design.
|
|
34
|
+
*
|
|
35
|
+
* @param {string} html
|
|
36
|
+
* @returns {string}
|
|
37
|
+
*/
|
|
38
|
+
export function maskJsSet(html) {
|
|
39
|
+
return html
|
|
40
|
+
.replace(/<script type="importmap"[\s\S]*?<\/script>/g, '')
|
|
41
|
+
.replace(/<script type="module"[\s\S]*?<\/script>/g, '')
|
|
42
|
+
.replace(/<link rel="modulepreload"[^>]*>/g, '')
|
|
43
|
+
// The auto vendor preconnect / dns-prefetch (#243) is a connection-warming
|
|
44
|
+
// HINT derived from the served vendor map, which legitimately differs on vs
|
|
45
|
+
// off (a vendor reachable only through an elided component is pruned on the
|
|
46
|
+
// ON side, so its preconnect drops too, exactly like its modulepreload). It
|
|
47
|
+
// is part of the same JS-loaded set, so mask it. The blog corpus declares
|
|
48
|
+
// no `metadata.preconnect` of its own, so every preconnect/dns-prefetch
|
|
49
|
+
// there is the auto vendor one.
|
|
50
|
+
.replace(/<link rel="preconnect"[^>]*>/g, '')
|
|
51
|
+
.replace(/<link rel="dns-prefetch"[^>]*>/g, '')
|
|
52
|
+
.replace(/ data-webjs-build="[^"]*"/g, '')
|
|
53
|
+
.replace(/ data-webjs-src="[^"]*"/g, '')
|
|
54
|
+
// Render-clock nondeterminism: a page that SSRs a live wall-clock time
|
|
55
|
+
// ("posts loaded · 3:10:10 AM") ticks between the on and off captures. This
|
|
56
|
+
// is unrelated to elision (elision never changes rendered text), so
|
|
57
|
+
// normalise it. The counterfactual in differential-elision.test.js still
|
|
58
|
+
// fails because a removed element is a structural change, not a clock tick.
|
|
59
|
+
.replace(/\b\d{1,2}:\d{2}:\d{2}\s?[AP]M\b/gi, 'TIME')
|
|
60
|
+
.replace(/\s+/g, ' ')
|
|
61
|
+
.trim();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The URL paths of every STATIC page route in a route table, sorted and
|
|
66
|
+
* deduped. The corpus `webjs elision --verify` renders on both sides.
|
|
67
|
+
*
|
|
68
|
+
* A DYNAMIC route is excluded rather than guessed at: rendering it would mean
|
|
69
|
+
* inventing param values, and a route that 404s or throws on invented params
|
|
70
|
+
* proves nothing. The command reports the excluded routes by name and takes
|
|
71
|
+
* `--routes` for an author who wants to cover them with real values.
|
|
72
|
+
*
|
|
73
|
+
* Normalization is `routePathFromDir`'s, restated here so this leaf does not
|
|
74
|
+
* depend on `@webjsdev/mcp`: a route group `(group)` and a `_private` segment
|
|
75
|
+
* drop out of the URL, and the root `.` is `/`.
|
|
76
|
+
*
|
|
77
|
+
* @param {{ pages?: Array<{ routeDir?: string, paramNames?: string[] }> }} table a `buildRouteTable(appDir)` result
|
|
78
|
+
* @returns {string[]}
|
|
79
|
+
*/
|
|
80
|
+
export function staticPageRoutes(table) {
|
|
81
|
+
const out = new Set();
|
|
82
|
+
for (const r of table?.pages || []) {
|
|
83
|
+
if (r.paramNames && r.paramNames.length) continue;
|
|
84
|
+
out.add(routePath(r.routeDir));
|
|
85
|
+
}
|
|
86
|
+
return [...out].sort();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* `blog` -> `/blog`, the root `.` -> `/`. Route groups and `_private` segments
|
|
91
|
+
* drop, the same normalization `buildRouteTable` uses for matching.
|
|
92
|
+
* @param {string|undefined} routeDir
|
|
93
|
+
* @returns {string}
|
|
94
|
+
*/
|
|
95
|
+
function routePath(routeDir) {
|
|
96
|
+
if (!routeDir || routeDir === '.') return '/';
|
|
97
|
+
const segs = routeDir
|
|
98
|
+
.split('/')
|
|
99
|
+
.filter((s) => !(s.startsWith('(') && s.endsWith(')')) && !s.startsWith('_'));
|
|
100
|
+
return segs.length ? '/' + segs.join('/') : '/';
|
|
101
|
+
}
|
package/src/elision-report.js
CHANGED
|
@@ -1,49 +1,82 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* App-level elision report (#646).
|
|
2
|
+
* App-level elision report (#646, extended in #1308).
|
|
3
3
|
*
|
|
4
4
|
* A reporting layer over the `analyzeElision` verdict, NOT a second analysis
|
|
5
5
|
* and NOT a build (WebJs is no-build; elision is the server's analysis pass,
|
|
6
6
|
* run at dev-server start and re-derived after each fs.watch rebuild). It
|
|
7
|
-
* builds the module graph, runs `analyzeElision
|
|
8
|
-
*
|
|
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).
|
|
7
|
+
* builds the module graph, runs `analyzeElision` ONCE, and projects the whole
|
|
8
|
+
* verdict into a sorted, app-relative, JSON-serializable object.
|
|
12
9
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
10
|
+
* It carries BOTH directions:
|
|
11
|
+
*
|
|
12
|
+
* - `components`: every component module with `elided` or `shipped`, and for
|
|
13
|
+
* a shipped one the evidence that forced it plus the module that did the
|
|
14
|
+
* forcing. This is the direction where a wrong verdict silently loses
|
|
15
|
+
* interactivity in production, and before #1308 an app had no way to ask
|
|
16
|
+
* "what did you drop from my page?".
|
|
17
|
+
* - `routeModules`: every page / layout as `inert` / `import-only` /
|
|
18
|
+
* `shipped`, a module that ships whole naming the first client-effecting
|
|
19
|
+
* blocker that pins it (a non-component on a component-free path from the
|
|
20
|
+
* module, #963, or its own signal when the module itself is the cause).
|
|
21
|
+
* - `orphans`: a `class X extends WebComponent` with no literal-tag
|
|
22
|
+
* registration, either because there is no registration call or because
|
|
23
|
+
* the tag is computed. The scanner never sees it (invariant 3 requires a
|
|
24
|
+
* literal tag), so it always loses its elision verdict, its tag-to-module
|
|
25
|
+
* registry entry, and its preload hint, and `static interactive = true`
|
|
26
|
+
* cannot rescue it. Whether the ELEMENT still upgrades differs by shape:
|
|
27
|
+
* with no registration call it never does, and with a computed tag only
|
|
28
|
+
* while its importing module ships WHOLE, which is the exception rather
|
|
29
|
+
* than the rule. It is the one shape the verdict cannot speak for.
|
|
30
|
+
*
|
|
31
|
+
* Consumed by `webjs elision` (the CLI report + `--json`), the MCP
|
|
32
|
+
* `list_elision` tool, and `webjs doctor`'s two elision checks, which share
|
|
33
|
+
* ONE call so the module graph is built once per doctor run. Advisory only: a
|
|
34
|
+
* page legitimately MAY ship, and the analyser is biased toward shipping by
|
|
35
|
+
* design (server AGENTS invariant 7), so this never fails anything.
|
|
36
|
+
*
|
|
37
|
+
* Nothing is cached here. Every out-of-process consumer runs the analysis once
|
|
38
|
+
* and exits, so a cache would never hit and would only add a staleness bug.
|
|
18
39
|
*/
|
|
19
40
|
import { readFile, stat } from 'node:fs/promises';
|
|
20
|
-
import { join } from 'node:path';
|
|
41
|
+
import { join, relative, sep } from 'node:path';
|
|
21
42
|
|
|
22
43
|
import { buildModuleGraph } from './module-graph.js';
|
|
23
|
-
import { scanComponents } from './component-scanner.js';
|
|
44
|
+
import { scanComponents, findOrphanComponents } from './component-scanner.js';
|
|
24
45
|
import { buildRouteTable } from './router.js';
|
|
25
46
|
import { analyzeElision } from './component-elision.js';
|
|
26
47
|
|
|
48
|
+
/**
|
|
49
|
+
* @typedef {'own'|'observed'|'closure'|'render'|'import'|'unreadable'} ElisionEvidence
|
|
50
|
+
* @typedef {{ file: string, tags: string[], verdict: 'elided'|'shipped', evidence: ElisionEvidence|null, reason: string|null, by: string|null }} ElisionComponentRow
|
|
51
|
+
* @typedef {{ file: string, verdict: 'inert'|'import-only'|'shipped', emits: string[], blocker: string|null, reason: string|null }} ElisionRouteModuleRow
|
|
52
|
+
* @typedef {{ file: string, className: string }} ElisionOrphanRow
|
|
53
|
+
* @typedef {{ components: number, elided: number, shipped: number, routeModules: number, inert: number, importOnly: number, shippedWhole: number, orphans: number }} ElisionSummary
|
|
54
|
+
* @typedef {{ analysed: boolean, skipped: 'no-app'|'elide-off'|'unanalysable'|null, components: ElisionComponentRow[], routeModules: ElisionRouteModuleRow[], orphans: ElisionOrphanRow[], summary: ElisionSummary }} ElisionReport
|
|
55
|
+
*/
|
|
56
|
+
|
|
27
57
|
/**
|
|
28
58
|
* @param {string} appDir
|
|
29
|
-
* @returns {Promise<
|
|
59
|
+
* @returns {Promise<ElisionReport>}
|
|
30
60
|
*/
|
|
31
61
|
export async function analyzeAppElision(appDir) {
|
|
32
62
|
// No `app/` means this is not a routable app (a bare component library, a
|
|
33
|
-
// lib-only fixture); nothing ships, so there is nothing to
|
|
34
|
-
if (!(await pathExists(join(appDir, 'app')))) return
|
|
35
|
-
// Elision off (opt-out) ships everything by design, so the
|
|
36
|
-
if (!(await readElideEnabled(appDir))) return
|
|
63
|
+
// lib-only fixture); nothing ships, so there is nothing to report on.
|
|
64
|
+
if (!(await pathExists(join(appDir, 'app')))) return empty('no-app');
|
|
65
|
+
// Elision off (opt-out) ships everything by design, so the verdict is moot.
|
|
66
|
+
if (!(await readElideEnabled(appDir))) return empty('elide-off');
|
|
37
67
|
|
|
38
|
-
let moduleGraph, components, routeTable;
|
|
68
|
+
let moduleGraph, components, routeTable, orphans;
|
|
39
69
|
try {
|
|
40
70
|
moduleGraph = await buildModuleGraph(appDir);
|
|
41
71
|
components = await scanComponents(appDir);
|
|
42
72
|
routeTable = await buildRouteTable(appDir);
|
|
73
|
+
// Not a second elision analysis: a lexical scan for the one shape elision
|
|
74
|
+
// never gets to judge, reusing what the dev server already runs.
|
|
75
|
+
orphans = await findOrphanComponents(appDir);
|
|
43
76
|
} catch {
|
|
44
|
-
// A malformed app the analysis cannot process degrades to no
|
|
77
|
+
// A malformed app the analysis cannot process degrades to no report (the
|
|
45
78
|
// dev server and `webjs check` surface the real problem).
|
|
46
|
-
return
|
|
79
|
+
return empty('unanalysable');
|
|
47
80
|
}
|
|
48
81
|
|
|
49
82
|
// Exactly the page + layout set the dev server feeds to analyzeElision, so
|
|
@@ -55,16 +88,86 @@ export async function analyzeAppElision(appDir) {
|
|
|
55
88
|
for (const f of page.layouts || []) routeModuleSet.add(f);
|
|
56
89
|
}
|
|
57
90
|
|
|
58
|
-
const { shippedRouteModules } =
|
|
59
|
-
components, [...routeModuleSet], moduleGraph, (f) => readFile(f, 'utf8'), appDir
|
|
60
|
-
|
|
91
|
+
const { inertRouteModules, importOnlyRouteModules, shippedRouteModules, componentVerdicts } =
|
|
92
|
+
await analyzeElision(components, [...routeModuleSet], moduleGraph, (f) => readFile(f, 'utf8'), appDir);
|
|
93
|
+
|
|
94
|
+
const rel = (f) => (f == null ? null : relative(appDir, f) || f);
|
|
95
|
+
const byFile = (a, b) => a.file.localeCompare(b.file);
|
|
96
|
+
|
|
97
|
+
/** @type {ElisionComponentRow[]} */
|
|
98
|
+
const componentRows = [...componentVerdicts.entries()].map(([file, v]) => ({
|
|
99
|
+
file: /** @type {string} */ (rel(file)),
|
|
100
|
+
tags: v.tags,
|
|
101
|
+
verdict: v.shipped ? 'shipped' : 'elided',
|
|
102
|
+
evidence: v.shipped ? /** @type {ElisionEvidence|null} */ (v.evidence) : null,
|
|
103
|
+
reason: v.shipped ? relativizeReason(v.reason, appDir) : null,
|
|
104
|
+
by: v.shipped ? rel(v.by) : null,
|
|
105
|
+
})).sort(byFile);
|
|
106
|
+
|
|
107
|
+
/** @type {ElisionRouteModuleRow[]} */
|
|
108
|
+
const routeRows = [
|
|
109
|
+
...[...inertRouteModules].map((f) => ({
|
|
110
|
+
file: /** @type {string} */ (rel(f)), verdict: /** @type {const} */ ('inert'), emits: [], blocker: null, reason: null,
|
|
111
|
+
})),
|
|
112
|
+
...[...importOnlyRouteModules].map(([f, emits]) => ({
|
|
113
|
+
file: /** @type {string} */ (rel(f)), verdict: /** @type {const} */ ('import-only'),
|
|
114
|
+
emits: emits.map((e) => /** @type {string} */ (rel(e))).sort(), blocker: null, reason: null,
|
|
115
|
+
})),
|
|
116
|
+
...[...shippedRouteModules].map(([f, v]) => ({
|
|
117
|
+
file: /** @type {string} */ (rel(f)), verdict: /** @type {const} */ ('shipped'), emits: [],
|
|
118
|
+
blocker: rel(v.blocker), reason: relativizeReason(v.reason, appDir),
|
|
119
|
+
})),
|
|
120
|
+
].sort(byFile);
|
|
121
|
+
|
|
122
|
+
/** @type {ElisionOrphanRow[]} */
|
|
123
|
+
const orphanRows = orphans
|
|
124
|
+
.map((o) => ({ file: /** @type {string} */ (rel(o.file)), className: o.className }))
|
|
125
|
+
.sort((a, b) => byFile(a, b) || a.className.localeCompare(b.className));
|
|
61
126
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
127
|
+
return {
|
|
128
|
+
analysed: true, skipped: null,
|
|
129
|
+
components: componentRows, routeModules: routeRows, orphans: orphanRows,
|
|
130
|
+
summary: {
|
|
131
|
+
components: componentRows.length,
|
|
132
|
+
elided: componentRows.filter((c) => c.verdict === 'elided').length,
|
|
133
|
+
shipped: componentRows.filter((c) => c.verdict === 'shipped').length,
|
|
134
|
+
routeModules: routeRows.length,
|
|
135
|
+
inert: inertRouteModules.size,
|
|
136
|
+
importOnly: importOnlyRouteModules.size,
|
|
137
|
+
shippedWhole: shippedRouteModules.size,
|
|
138
|
+
orphans: orphanRows.length,
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The no-verdict report, naming WHY nothing was analysed so a machine consumer
|
|
145
|
+
* can tell "elision is switched off" from "this is not an app".
|
|
146
|
+
* @param {'no-app'|'elide-off'|'unanalysable'} skipped
|
|
147
|
+
* @returns {ElisionReport}
|
|
148
|
+
*/
|
|
149
|
+
function empty(skipped) {
|
|
150
|
+
return {
|
|
151
|
+
analysed: false, skipped,
|
|
152
|
+
components: [], routeModules: [], orphans: [],
|
|
153
|
+
summary: { components: 0, elided: 0, shipped: 0, routeModules: 0, inert: 0, importOnly: 0, shippedWhole: 0, orphans: 0 },
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* `analyzeElision` bakes ABSOLUTE paths into its reason phrases (it works in
|
|
159
|
+
* absolute paths throughout). No absolute filesystem path may reach the JSON
|
|
160
|
+
* contract, so strip the app dir prefix wherever it appears in the sentence.
|
|
161
|
+
* A plain split/join rather than a regex, so an appDir containing regex
|
|
162
|
+
* metacharacters (a `(group)` directory, a `+`) is safe.
|
|
163
|
+
*
|
|
164
|
+
* @param {string|null} reason
|
|
165
|
+
* @param {string} appDir
|
|
166
|
+
* @returns {string|null}
|
|
167
|
+
*/
|
|
168
|
+
function relativizeReason(reason, appDir) {
|
|
169
|
+
if (reason == null) return null;
|
|
170
|
+
return reason.split(appDir.endsWith(sep) ? appDir : appDir + sep).join('');
|
|
68
171
|
}
|
|
69
172
|
|
|
70
173
|
/** Mirror of the dev server's elide flag: WEBJS_ELIDE override, then webjs.elide. */
|
package/src/form-dispatch.js
CHANGED
|
@@ -214,6 +214,159 @@ function looksLikeFormSubmission(req) {
|
|
|
214
214
|
return /multipart\/form-data|application\/x-www-form-urlencoded/i.test(ct);
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
+
/**
|
|
218
|
+
* Fingerprints already reported this process, keyed `CODE METHOD <route>`.
|
|
219
|
+
* Capped, and never cleared.
|
|
220
|
+
*
|
|
221
|
+
* Both signals below are reachable by anyone: an empty urlencoded POST to any
|
|
222
|
+
* page path, or `?__webjs_action=x` appended to any url. Reporting every hit
|
|
223
|
+
* would turn a public endpoint into a free amplifier into a paid APM sink, and
|
|
224
|
+
* an app needs to learn the SHAPE exists rather than count it (a real bug
|
|
225
|
+
* reproduces on the next boot).
|
|
226
|
+
*
|
|
227
|
+
* The key is the matched ROUTE PATTERN, not the request pathname, and that is
|
|
228
|
+
* load-bearing rather than cosmetic. Keyed on the pathname, a dynamic route
|
|
229
|
+
* yields unbounded distinct keys, so a few hundred crafted requests to
|
|
230
|
+
* `/blog/1`, `/blog/2`, … would fill the cap and permanently silence the
|
|
231
|
+
* diagnostics for the process, which is a worse failure than the amplification
|
|
232
|
+
* the cap exists to stop. Keyed on the pattern, all of those collapse to one
|
|
233
|
+
* entry and the key space is bounded by the app's own route count, which leaves
|
|
234
|
+
* the cap as a backstop no real app reaches. The code is part of the key too, so
|
|
235
|
+
* a flood of one signal can never silence the other.
|
|
236
|
+
*
|
|
237
|
+
* @type {Set<string>}
|
|
238
|
+
*/
|
|
239
|
+
const reportedFormFingerprints = new Set();
|
|
240
|
+
const FINGERPRINT_CAP = 256;
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* @param {string} key
|
|
244
|
+
* @returns {boolean} true the first time only, and false once the cap is hit
|
|
245
|
+
*/
|
|
246
|
+
function firstSighting(key) {
|
|
247
|
+
if (reportedFormFingerprints.has(key)) return false;
|
|
248
|
+
if (reportedFormFingerprints.size >= FINGERPRINT_CAP) return false;
|
|
249
|
+
reportedFormFingerprints.add(key);
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* The matched route's identity for the dedupe key: its page FILE, which is one
|
|
255
|
+
* per route and reads better in a report than a serialized RegExp (`pattern` is
|
|
256
|
+
* a RegExp, not a printable path). Falls back to the request pathname only when
|
|
257
|
+
* a caller has no route in hand.
|
|
258
|
+
*
|
|
259
|
+
* @param {{ file?: string } | null | undefined} route
|
|
260
|
+
* @param {URL} url
|
|
261
|
+
* @returns {string}
|
|
262
|
+
*/
|
|
263
|
+
function routeKeyOf(route, url) {
|
|
264
|
+
return (route && route.file) || url.pathname;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Reset the per-process report dedupe. Test seam only: the cap and the
|
|
269
|
+
* never-cleared set are the point in production.
|
|
270
|
+
*/
|
|
271
|
+
export function resetFormReportDedupe() {
|
|
272
|
+
reportedFormFingerprints.clear();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* A form posted to a page and carried no action identity, so nothing ran and
|
|
277
|
+
* the answer is a 405. Route it to the APM sink with a code an app can group
|
|
278
|
+
* on, instead of leaving an anonymous 405 in an access log.
|
|
279
|
+
*
|
|
280
|
+
* Carries the field NAMES, which are template constants and are what identify
|
|
281
|
+
* WHICH form posted nowhere. Never the values, which are user data. The form's
|
|
282
|
+
* own `action` attribute is not carried because a bound form has none (the
|
|
283
|
+
* renderer strips it so the form posts to its own url), so the request url
|
|
284
|
+
* already is that information.
|
|
285
|
+
*
|
|
286
|
+
* @param {URL} url
|
|
287
|
+
* @param {Request} req
|
|
288
|
+
* @param {FormData} formData
|
|
289
|
+
* @param {((error: unknown) => void) | undefined} onError
|
|
290
|
+
* @param {{ warn?: (msg: string, meta?: Record<string, unknown>) => void }} [logger]
|
|
291
|
+
* @param {boolean} [dev]
|
|
292
|
+
* @param {{ file?: string } | null} [route] the matched page route, for the dedupe key
|
|
293
|
+
*/
|
|
294
|
+
export function reportFormActionMissing(url, req, formData, onError, logger, dev, route) {
|
|
295
|
+
const willLog = !!(dev && logger && logger.warn);
|
|
296
|
+
const willReport = typeof onError === 'function';
|
|
297
|
+
// Nothing to say: do not spend a dedupe slot, which would let a run with no
|
|
298
|
+
// sink configured quietly consume the cap for one that does.
|
|
299
|
+
if (!willLog && !willReport) return;
|
|
300
|
+
if (!firstSighting(`WEBJS_FORM_ACTION_MISSING ${req.method} ${routeKeyOf(route, url)}`)) return;
|
|
301
|
+
const fields = [...new Set([...formData.keys()])];
|
|
302
|
+
if (willLog) {
|
|
303
|
+
logger.warn(
|
|
304
|
+
`[webjs] a form posted to ${url.pathname} carrying no action identity, so nothing ran (405). Bind the action: <form action=\${yourAction}>.`,
|
|
305
|
+
{ fields },
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
if (!willReport) return;
|
|
309
|
+
const err = new Error(
|
|
310
|
+
`A form submission to ${url.pathname} carried no \`${FORM_ACTION_FIELD}\` identity, so no server action ran and the request was answered with a 405.`,
|
|
311
|
+
);
|
|
312
|
+
/** @type {any} */ (err).code = 'WEBJS_FORM_ACTION_MISSING';
|
|
313
|
+
/** @type {any} */ (err).method = req.method;
|
|
314
|
+
/** @type {any} */ (err).pathname = url.pathname;
|
|
315
|
+
/** @type {any} */ (err).fields = fields;
|
|
316
|
+
onError(err);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* A page GET carrying `__webjs_action` in the QUERY STRING (#1307). Nothing in
|
|
321
|
+
* this framework ever puts the reserved field in a url, so a submission
|
|
322
|
+
* carrying a bound action's identity went out as a GET: the identity rode the
|
|
323
|
+
* query string (a browser does this natively, and `performSubmission` promotes
|
|
324
|
+
* a safe-method body the same way), no action ran, and the page is about to
|
|
325
|
+
* render as if nothing was submitted.
|
|
326
|
+
*
|
|
327
|
+
* A bound submitter no longer produces this by accident, because it carries its
|
|
328
|
+
* own `formmethod="post"`. What reaches here now is an explicit override the
|
|
329
|
+
* author wrote and the renderer deliberately honours: a `formmethod="get"` on
|
|
330
|
+
* the pressed button, or on the form, wins by native precedence. This is the
|
|
331
|
+
* production counterpart to the dev-time client guard, which reports the same
|
|
332
|
+
* shape at submit time.
|
|
333
|
+
*
|
|
334
|
+
* DETECTS ONLY. The GET keeps rendering its 200 page, because answering
|
|
335
|
+
* differently on a query parameter would hand any visitor a way to turn any
|
|
336
|
+
* page into an error.
|
|
337
|
+
*
|
|
338
|
+
* @param {URL} url
|
|
339
|
+
* @param {Request} req
|
|
340
|
+
* @param {((error: unknown) => void) | undefined} onError
|
|
341
|
+
* @param {{ warn?: (msg: string, meta?: Record<string, unknown>) => void }} [logger]
|
|
342
|
+
* @param {boolean} [dev]
|
|
343
|
+
* @param {{ file?: string } | null} [route] the matched page route, for the dedupe key
|
|
344
|
+
*/
|
|
345
|
+
export function reportFormSubmittedAsGet(url, req, onError, logger, dev, route) {
|
|
346
|
+
// Cheapest possible guard first, and it runs on EVERY page GET: a substring
|
|
347
|
+
// test on the already-parsed `search` string, so the common case never builds
|
|
348
|
+
// a URLSearchParams at all.
|
|
349
|
+
if (!url.search || !url.search.includes(FORM_ACTION_FIELD)) return;
|
|
350
|
+
if (!url.searchParams.has(FORM_ACTION_FIELD)) return;
|
|
351
|
+
const willLog = !!(dev && logger && logger.warn);
|
|
352
|
+
const willReport = typeof onError === 'function';
|
|
353
|
+
if (!willLog && !willReport) return;
|
|
354
|
+
if (!firstSighting(`WEBJS_FORM_SUBMITTED_AS_GET ${req.method} ${routeKeyOf(route, url)}`)) return;
|
|
355
|
+
if (willLog) {
|
|
356
|
+
logger.warn(
|
|
357
|
+
`[webjs] ${url.pathname} was requested with \`${FORM_ACTION_FIELD}\` in the query string, which means a submission carrying a bound action's identity went out as a GET. A GET sends no body, so the identity rode the url, the action never ran, and this page is simply re-rendering. Look for a formmethod="get" on the button that was pressed, or a method="get" on its form: a submitter's own formmethod wins by native precedence and WebJs honours it rather than refusing it.`,
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
if (!willReport) return;
|
|
361
|
+
const err = new Error(
|
|
362
|
+
`A form submission reached ${url.pathname} as a GET with the \`${FORM_ACTION_FIELD}\` identity in the query string, so no server action ran. The submitter's enclosing <form> binds no action.`,
|
|
363
|
+
);
|
|
364
|
+
/** @type {any} */ (err).code = 'WEBJS_FORM_SUBMITTED_AS_GET';
|
|
365
|
+
/** @type {any} */ (err).method = req.method;
|
|
366
|
+
/** @type {any} */ (err).pathname = url.pathname;
|
|
367
|
+
onError(err);
|
|
368
|
+
}
|
|
369
|
+
|
|
217
370
|
/**
|
|
218
371
|
* The submitted TEXT fields as a plain record, for repopulating a form the
|
|
219
372
|
* dispatcher could not run.
|
|
@@ -258,11 +411,12 @@ function methodNotAllowed() {
|
|
|
258
411
|
* actionIndex: import('./actions.js').ActionIndex,
|
|
259
412
|
* allowedOrigins?: string[],
|
|
260
413
|
* onError?: (error: unknown) => void,
|
|
414
|
+
* logger?: { warn?: (msg: string, meta?: Record<string, unknown>) => void },
|
|
261
415
|
* }} deps
|
|
262
416
|
* @returns {Promise<Response>}
|
|
263
417
|
*/
|
|
264
418
|
export async function runFormAction(route, params, url, req, ssrOpts, deps) {
|
|
265
|
-
const { actionIndex, allowedOrigins = [], onError } = deps;
|
|
419
|
+
const { actionIndex, allowedOrigins = [], onError, logger } = deps;
|
|
266
420
|
|
|
267
421
|
// Not a form body at all (a stray JSON POST, a probe): the page path exists
|
|
268
422
|
// and only renders. Answered before the body is touched.
|
|
@@ -289,8 +443,20 @@ export async function runFormAction(route, params, url, req, ssrOpts, deps) {
|
|
|
289
443
|
const actions = formData.getAll(FORM_ACTION_FIELD);
|
|
290
444
|
const id = actions.length ? actions[actions.length - 1] : null;
|
|
291
445
|
// A form body carrying no identity: a hand-written `<form method="post">`
|
|
292
|
-
// that binds no action. Nothing to run, and the page only renders.
|
|
293
|
-
|
|
446
|
+
// that binds no action. Nothing to run, and the page only renders. Reported
|
|
447
|
+
// before the 405 so the shape is not just an anonymous status in an access
|
|
448
|
+
// log.
|
|
449
|
+
//
|
|
450
|
+
// NOT the unparseable-enctype variant of #1307. An `enctype="text/plain"`
|
|
451
|
+
// submission arrives with that content type, so `looksLikeFormSubmission`
|
|
452
|
+
// answers it above, before the body is read and before this point. That is
|
|
453
|
+
// deliberate (buffering an attacker-chosen body to classify it is exactly what
|
|
454
|
+
// the early return avoids), and it means the text/plain variant is visible
|
|
455
|
+
// only as a bare 405. The dev-time client guard covers it instead.
|
|
456
|
+
if (typeof id !== 'string' || !id) {
|
|
457
|
+
reportFormActionMissing(url, req, formData, onError, logger, !!ssrOpts.dev, route);
|
|
458
|
+
return methodNotAllowed();
|
|
459
|
+
}
|
|
294
460
|
// The field is framework wire, not app data. Removing it keeps an action
|
|
295
461
|
// that iterates the FormData (building a record, echoing values back into a
|
|
296
462
|
// 422 re-render) from seeing a key it did not put there.
|