@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/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 {
|
|
18
|
+
import { runFormAction, reportFormSubmittedAsGet } from './form-dispatch.js';
|
|
19
19
|
import { handleApi } from './api.js';
|
|
20
20
|
import {
|
|
21
21
|
buildActionIndex,
|
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
hasUseServerDirective,
|
|
27
27
|
hashFile,
|
|
28
28
|
} from './actions.js';
|
|
29
|
-
import {
|
|
29
|
+
import { registerActionHooks } from './action-seed.js';
|
|
30
30
|
import { readRegenerateRules, maybeRegenerate, isRegenerateOutputPath } from './dev-regenerate.js';
|
|
31
31
|
import { stripTypeScript, ensureStripper } from './ts-strip.js';
|
|
32
32
|
import { defaultLogger } from './logger.js';
|
|
@@ -93,7 +93,8 @@ import { setVendorEntries, setCoreInstall, publishBuildId, setAppSourceId, setBa
|
|
|
93
93
|
import { readBasePath, stripBasePath, withBasePath } from './base-path.js';
|
|
94
94
|
import { propagateTrustedRemoteIp } from './rate-limit.js';
|
|
95
95
|
import { readAllowedOrigins } from './csrf.js';
|
|
96
|
-
import { setAssetUrlProvider } from '@webjsdev/core';
|
|
96
|
+
import { setAssetUrlProvider, setFormActionResolver } from '@webjsdev/core';
|
|
97
|
+
import { resolveActionIdentity } from './form-action-identity.js';
|
|
97
98
|
import { setAssetRoots, clearAssetHashCache, setElisionFingerprint, withAssetHash, assetHashFor, versionModuleImports, resolveAssetUrl } from './asset-hash.js';
|
|
98
99
|
import { urlFromRequest } from './forwarded.js';
|
|
99
100
|
import { compileHeaderRules, applySecurityHeaders, webRequestIsHttps } from './headers.js';
|
|
@@ -640,13 +641,17 @@ export async function createRequestHandler(opts) {
|
|
|
640
641
|
// never touched the disk.
|
|
641
642
|
setAssetUrlProvider((p) => resolveAssetUrl(p, basePath()));
|
|
642
643
|
|
|
643
|
-
//
|
|
644
|
-
//
|
|
645
|
-
//
|
|
646
|
-
//
|
|
647
|
-
//
|
|
648
|
-
//
|
|
649
|
-
|
|
644
|
+
// The `'use server'` load hook. Install it NOW, at boot, before any action
|
|
645
|
+
// module is imported (ESM caches by URL, so a module loaded before the hook
|
|
646
|
+
// would never be wrapped). Read once (not per-rebuild): the hook is global
|
|
647
|
+
// and cannot be cleanly un-installed, so toggling needs a restart.
|
|
648
|
+
//
|
|
649
|
+
// It installs UNCONDITIONALLY, and only seed collection (#472) follows the
|
|
650
|
+
// `webjs.seed` switch. Action identity (#1155) rides the same hook, and it is
|
|
651
|
+
// what a bound `<form action=${action}>` resolves through, so gating the hook
|
|
652
|
+
// on seeding would mean `webjs.seed: false` silently broke every no-JS form.
|
|
653
|
+
await registerActionHooks({ seed: await readSeedEnabled(appDir), dev });
|
|
654
|
+
|
|
650
655
|
|
|
651
656
|
// When an app commits a vendor pin (.webjs/vendor/importmap.json) it carries a
|
|
652
657
|
// deterministic vendor map that is cheap to read (one file, no analysis, no
|
|
@@ -757,7 +762,7 @@ export async function createRequestHandler(opts) {
|
|
|
757
762
|
// package.json `webjs.maxBodyBytes` / `webjs.maxMultipartBytes` plus the env
|
|
758
763
|
// overrides. The secure defaults (1 MiB JSON/RPC, 10 MiB form) apply when
|
|
759
764
|
// unconfigured. Stamped on every request via `setBodyLimits` so `readBody`
|
|
760
|
-
// (used inside route handlers) enforces the same cap the RPC and
|
|
765
|
+
// (used inside route handlers) enforces the same cap the RPC and form-dispatch
|
|
761
766
|
// paths do.
|
|
762
767
|
const bodyLimits = await readBodyLimitsFromApp(appDir);
|
|
763
768
|
|
|
@@ -797,14 +802,27 @@ export async function createRequestHandler(opts) {
|
|
|
797
802
|
regenerateRules: dev ? await readRegenerateRules(appDir) : [],
|
|
798
803
|
};
|
|
799
804
|
|
|
805
|
+
// Teach the renderers how to turn a real server-action function into the
|
|
806
|
+
// `<hash>/<fn>` identity a form submits (#1155). The hook path is
|
|
807
|
+
// process-global and needs no index; `state.actionIndex` is consulted only as
|
|
808
|
+
// a hash memo, and by the scan fallback for a runtime with no hook. In a
|
|
809
|
+
// process running several handlers the fallback therefore resolves against
|
|
810
|
+
// the most recently booted one, which is the same scope the asset-url
|
|
811
|
+
// provider already has.
|
|
812
|
+
setFormActionResolver((fn) => (state.actionIndex ? resolveActionIdentity(state.actionIndex, fn) : null));
|
|
813
|
+
|
|
800
814
|
/**
|
|
801
815
|
* Report a dev error (#264): build a frame and push it to the open tab via
|
|
802
816
|
* the SSE overlay channel. DEV-ONLY and best-effort, so it can never affect a
|
|
803
817
|
* response or crash the server (a frame-build failure is swallowed). No file
|
|
804
818
|
* path or source is ever built in prod (the early return), so nothing leaks.
|
|
805
819
|
*
|
|
820
|
+
* `info.url` stamps a `render` frame with the URL that produced it (#1047), so
|
|
821
|
+
* the browser overlay can refuse a frame for a page the tab is not viewing.
|
|
822
|
+
* A `ts-strip` / `rebuild` frame passes none and stays unscoped.
|
|
823
|
+
*
|
|
806
824
|
* @param {unknown} error
|
|
807
|
-
* @param {{ kind?: 'render'|'ts-strip'|'rebuild', file?: string, line?: number, column?: number, hint?: string }} [info]
|
|
825
|
+
* @param {{ kind?: 'render'|'ts-strip'|'rebuild', file?: string, line?: number, column?: number, hint?: string, url?: string }} [info]
|
|
808
826
|
*/
|
|
809
827
|
function reportDevError(error, info = {}) {
|
|
810
828
|
if (!dev) return;
|
|
@@ -891,10 +909,14 @@ export async function createRequestHandler(opts) {
|
|
|
891
909
|
// Client-router opt-out (#629): re-read each pass so toggling
|
|
892
910
|
// `webjs.clientRouter` takes effect on rebuild without a restart.
|
|
893
911
|
setClientRouterEnabled(await readClientRouterEnabled(appDir));
|
|
894
|
-
|
|
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
|
|
895
916
|
? await analyzeElision(components, collectRouteModules(state.routeTable),
|
|
896
917
|
state.moduleGraph, (f) => readFile(f, 'utf8'), appDir)
|
|
897
|
-
: { 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() };
|
|
898
920
|
state.elidableComponents = r.elidableComponents;
|
|
899
921
|
state.inertRouteModules = r.inertRouteModules;
|
|
900
922
|
state.importOnlyRouteModules = r.importOnlyRouteModules;
|
|
@@ -964,12 +986,40 @@ export async function createRequestHandler(opts) {
|
|
|
964
986
|
}
|
|
965
987
|
t.elision = now() - m;
|
|
966
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.
|
|
967
997
|
for (const { className, file } of await findOrphanComponents(appDir)) {
|
|
968
998
|
logger.warn?.(
|
|
969
|
-
`[webjs] ${className} extends WebComponent but
|
|
970
|
-
`
|
|
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.`,
|
|
971
1005
|
);
|
|
972
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
|
+
);
|
|
973
1023
|
}
|
|
974
1024
|
analysisDone = true;
|
|
975
1025
|
ranAnalysis = true;
|
|
@@ -1181,7 +1231,7 @@ export async function createRequestHandler(opts) {
|
|
|
1181
1231
|
|
|
1182
1232
|
// Make the resolved body-size limits (issue #237) readable from the
|
|
1183
1233
|
// request scope, so `readBody` inside a route handler enforces the same
|
|
1184
|
-
// cap the framework's own RPC /
|
|
1234
|
+
// cap the framework's own RPC / form-dispatch body reads do.
|
|
1185
1235
|
setBodyLimits(state.bodyLimits);
|
|
1186
1236
|
|
|
1187
1237
|
let pathname = '/';
|
|
@@ -1298,12 +1348,18 @@ export async function createRequestHandler(opts) {
|
|
|
1298
1348
|
// root-mounted `/__webjs/*`. The logged `path` stays the RAW client URL.
|
|
1299
1349
|
if (shouldAccessLog(headerPathname)) {
|
|
1300
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;
|
|
1301
1356
|
logger.info?.('request', {
|
|
1302
1357
|
requestId: reqId,
|
|
1303
1358
|
method: req.method,
|
|
1304
1359
|
path: pathname,
|
|
1305
1360
|
status: conditioned.status,
|
|
1306
1361
|
durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
|
|
1362
|
+
...(seed ? { seed } : {}),
|
|
1307
1363
|
});
|
|
1308
1364
|
} catch { /* never let logging crash the response */ }
|
|
1309
1365
|
}
|
|
@@ -1349,7 +1405,7 @@ export async function createRequestHandler(opts) {
|
|
|
1349
1405
|
// to the inbound `x-webjs-remote-ip` header that the copied
|
|
1350
1406
|
// `req.headers` still carries, which a client can spoof. So strip
|
|
1351
1407
|
// that header on the rebuild and propagate the framework-trusted IP
|
|
1352
|
-
// across the new object, the same pattern
|
|
1408
|
+
// across the new object, the same pattern form-dispatch.js uses.
|
|
1353
1409
|
const headers = new Headers(req.headers);
|
|
1354
1410
|
headers.delete('x-webjs-remote-ip');
|
|
1355
1411
|
const hasBody = req.method !== 'GET' && req.method !== 'HEAD';
|
|
@@ -1461,7 +1517,7 @@ export async function createRequestHandler(opts) {
|
|
|
1461
1517
|
// Build all whole-app analysis on the first request (memoized), before
|
|
1462
1518
|
// any SSR, module serve, gate check, action dispatch, or middleware runs.
|
|
1463
1519
|
await ensureReady();
|
|
1464
|
-
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 });
|
|
1465
1521
|
if (state.middleware) {
|
|
1466
1522
|
try {
|
|
1467
1523
|
return await state.middleware(req, next);
|
|
@@ -1839,7 +1895,7 @@ function startNodeListener(ctx) {
|
|
|
1839
1895
|
// Catch-all process handlers: log, but don't tear the process down on a
|
|
1840
1896
|
// single mishandled promise. Uncaught exceptions are different: state may be
|
|
1841
1897
|
// corrupted, so log + start an orderly shutdown rather than continuing.
|
|
1842
|
-
installProcessHandlers(logger, () => shutdown('uncaughtException'));
|
|
1898
|
+
installProcessHandlers(logger, () => shutdown('uncaughtException', { fatal: true }));
|
|
1843
1899
|
|
|
1844
1900
|
return {
|
|
1845
1901
|
server,
|
|
@@ -1958,7 +2014,7 @@ async function tryServeFrameworkStatic(path, method, ctx) {
|
|
|
1958
2014
|
}
|
|
1959
2015
|
|
|
1960
2016
|
async function handleCore(req, ctx) {
|
|
1961
|
-
const { state, appDir, coreDir, dev, reportError, reportDevError, cspEnabled, allowedOrigins } = ctx;
|
|
2017
|
+
const { state, appDir, coreDir, dev, reportError, reportDevError, hasOnError, logger, cspEnabled, allowedOrigins } = ctx;
|
|
1962
2018
|
const url = new URL(req.url);
|
|
1963
2019
|
// Decode percent-encoded characters so filesystem lookups match real
|
|
1964
2020
|
// filenames. Dynamic route segments like `[slug]` and route groups like
|
|
@@ -2179,14 +2235,30 @@ async function handleCore(req, ctx) {
|
|
|
2179
2235
|
}
|
|
2180
2236
|
|
|
2181
2237
|
// Page route. GET/HEAD render the page. A NON-GET/HEAD method (POST/PUT/…)
|
|
2182
|
-
// is
|
|
2183
|
-
// action runs inside the page's segment middleware,
|
|
2184
|
-
// (303)
|
|
2185
|
-
//
|
|
2186
|
-
//
|
|
2238
|
+
// is a form submission (#1155): the `__webjs_action` hidden field names the
|
|
2239
|
+
// server action to run, it runs inside the page's segment middleware, and the
|
|
2240
|
+
// result either PRG-redirects (303), re-renders the same page (422) with
|
|
2241
|
+
// field errors, or honors a thrown redirect()/notFound(). A non-GET carrying
|
|
2242
|
+
// no action identity is a 405 on a path that exists but only renders.
|
|
2187
2243
|
{
|
|
2188
2244
|
const page = matchPage(state.routeTable, path);
|
|
2189
2245
|
if (page) {
|
|
2246
|
+
// The URL this render is FOR, in the form the browser sees it (#1047), so
|
|
2247
|
+
// the overlay's scope gate can compare it against `location.pathname +
|
|
2248
|
+
// location.search`. Two details are load-bearing: the RAW `url.pathname`
|
|
2249
|
+
// (not the decoded `path`) is used, because `location.pathname` is
|
|
2250
|
+
// percent-encoded too; and the base path is put back, because the ingress
|
|
2251
|
+
// strip already removed it from `url` while the browser's location still
|
|
2252
|
+
// carries it, so without this the gate would suppress every overlay on a
|
|
2253
|
+
// sub-path deploy.
|
|
2254
|
+
const devErrorUrl = withBasePath(url.pathname, basePath()) + url.search;
|
|
2255
|
+
// A speculative link prefetch must never raise an overlay (#1047): the
|
|
2256
|
+
// user is only hovering a link to a page that throws, and the page they
|
|
2257
|
+
// are actually looking at is fine. Dropping the hook also keeps the throw
|
|
2258
|
+
// out of `state.lastDevError`, so the SSE replay cannot hand it to a
|
|
2259
|
+
// freshly-connected tab either. The APM `onError` sink below still fires,
|
|
2260
|
+
// because the render really did throw.
|
|
2261
|
+
const isPrefetch = req.headers.get('x-webjs-prefetch') === '1';
|
|
2190
2262
|
const ssrOpts = {
|
|
2191
2263
|
dev, appDir, moduleGraph: state.moduleGraph,
|
|
2192
2264
|
serverFiles: state.actionIndex.fileToHash,
|
|
@@ -2213,17 +2285,58 @@ async function handleCore(req, ctx) {
|
|
|
2213
2285
|
// Dev error overlay (#264): a render crash pushes a frame to the open
|
|
2214
2286
|
// tab. Dev-only (reportDevError early-returns in prod), so no source
|
|
2215
2287
|
// leaks. Distinct from onError (the APM sink), which always fires.
|
|
2216
|
-
onDevError: dev
|
|
2288
|
+
onDevError: dev && !isPrefetch
|
|
2289
|
+
? (e) => reportDevError(e, { kind: 'render', url: devErrorUrl })
|
|
2290
|
+
: undefined,
|
|
2217
2291
|
};
|
|
2218
2292
|
if (method === 'GET' || method === 'HEAD') {
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
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
|
+
);
|
|
2307
|
+
// A successful render of URL U supersedes a RETAINED render error for
|
|
2308
|
+
// that same URL (#1047), so a reconnecting tab is not handed a frame the
|
|
2309
|
+
// page has since recovered from. Keyed on BOTH frame identity and url:
|
|
2310
|
+
// identity so a frame this very render just reported is never wiped, url
|
|
2311
|
+
// so a good render of an unrelated page cannot erase an error that is
|
|
2312
|
+
// still current for the page the user is looking at (the #893 gap the
|
|
2313
|
+
// retention exists to close). Dev-only, and skipped for a prefetch,
|
|
2314
|
+
// which is not the user's view of anything.
|
|
2315
|
+
const handler = dev && !isPrefetch
|
|
2316
|
+
? async () => {
|
|
2317
|
+
const before = state.lastDevError;
|
|
2318
|
+
const resp = await ssrPage(page.route, page.params, url, { ...ssrOpts, req });
|
|
2319
|
+
if (
|
|
2320
|
+
before && state.lastDevError === before
|
|
2321
|
+
&& before.kind === 'render' && before.url === devErrorUrl
|
|
2322
|
+
) state.lastDevError = null;
|
|
2323
|
+
return resp;
|
|
2324
|
+
}
|
|
2325
|
+
: () => ssrPage(page.route, page.params, url, { ...ssrOpts, req });
|
|
2225
2326
|
return runWithSegmentMiddleware(req, page.route.middlewares, handler, dev);
|
|
2226
2327
|
}
|
|
2328
|
+
// Every non-GET/HEAD to a page runs the page's segment middleware, and
|
|
2329
|
+
// the dispatcher always answers with a Response (a 405 when there is no
|
|
2330
|
+
// action to run), so a middleware that post-processes `await next()`
|
|
2331
|
+
// never has to handle an absent one.
|
|
2332
|
+
const deps = {
|
|
2333
|
+
actionIndex: state.actionIndex,
|
|
2334
|
+
allowedOrigins,
|
|
2335
|
+
onError: hasOnError ? (e) => reportError(e, req, 'action') : undefined,
|
|
2336
|
+
logger,
|
|
2337
|
+
};
|
|
2338
|
+
const handler = () => runFormAction(page.route, page.params, url, req, ssrOpts, deps);
|
|
2339
|
+
return runWithSegmentMiddleware(req, page.route.middlewares, handler, dev);
|
|
2227
2340
|
}
|
|
2228
2341
|
}
|
|
2229
2342
|
|
|
@@ -2845,6 +2958,11 @@ function __webjsApplyError(data) {
|
|
|
2845
2958
|
let f; try { f = JSON.parse(data); } catch (_) { return; }
|
|
2846
2959
|
renderDevOverlay(f);
|
|
2847
2960
|
}
|
|
2961
|
+
// Keep the overlay tracking the page actually on screen (#1047): a render frame
|
|
2962
|
+
// is scoped to the URL that produced it, so navigating away takes it down, and a
|
|
2963
|
+
// frame that arrived before the URL advanced goes up once it does. The gate
|
|
2964
|
+
// itself lives inside renderDevOverlay, so __webjsApplyError needs no change.
|
|
2965
|
+
installDevOverlayNavSync();
|
|
2848
2966
|
// Never reload INTO a server that is still restarting (Node's node --watch
|
|
2849
2967
|
// briefly kills the process on an edit), which would paint a style-less,
|
|
2850
2968
|
// half-rendered page (#893). Probe the lightweight /__webjs/version endpoint
|
|
@@ -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. */
|