@webjsdev/server 0.8.32 → 0.8.34
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 +15 -0
- package/index.js +1 -2
- package/package.json +1 -1
- package/src/actions.js +19 -13
- package/src/check.js +69 -0
- package/src/component-elision.js +265 -64
- package/src/component-scanner.js +15 -10
- package/src/csrf.js +101 -61
- package/src/dev.js +81 -7
- package/src/elision-report.js +89 -0
- package/src/html-cache.js +5 -31
- package/src/js-scan.js +177 -6
- package/src/listener-bun.js +1 -1
- package/src/ssr.js +44 -22
- package/src/testing.d.ts +8 -26
- package/src/testing.js +18 -72
- package/src/vendor.js +4 -1
- package/webjs-config.schema.json +11 -0
package/src/csrf.js
CHANGED
|
@@ -1,47 +1,36 @@
|
|
|
1
|
-
// Use Web Crypto (globalThis.crypto) for random + hash: works on Node >=20,
|
|
2
|
-
// Deno, Bun, Cloudflare Workers. Avoids the node:crypto import and keeps
|
|
3
|
-
// CSRF portable across runtimes.
|
|
4
|
-
const webCrypto = /** @type {Crypto} */ (globalThis.crypto);
|
|
5
|
-
|
|
6
1
|
/**
|
|
7
|
-
*
|
|
2
|
+
* Cross-origin (CSRF) protection for `/__webjs/action/*` RPC endpoints, via
|
|
3
|
+
* Fetch-Metadata + Origin verification. This is the model Remix 3's
|
|
4
|
+
* `cop-middleware` and Go 1.25's `http.CrossOriginProtection` use, and the
|
|
5
|
+
* spiritual sibling of Next.js / Astro's Origin-vs-Host check.
|
|
8
6
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* 3. `invokeAction` compares the header to the cookie with constant-time
|
|
16
|
-
* equality; mismatch → 403.
|
|
7
|
+
* Why not a token cookie: webjs previously issued a per-request `webjs_csrf`
|
|
8
|
+
* double-submit cookie on every SSR response. That made SSR HTML
|
|
9
|
+
* un-cacheable at a CDN (a CDN skips a response with `Set-Cookie`, and a
|
|
10
|
+
* cached one would share / poison the token across visitors). A header check
|
|
11
|
+
* needs nothing on the page, so SSR HTML carries no `Set-Cookie` and a page
|
|
12
|
+
* that opts into a public `Cache-Control` is edge-cacheable.
|
|
17
13
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
14
|
+
* The check, on every state-changing verb (a safe GET is exempt):
|
|
15
|
+
* 1. `Sec-Fetch-Site` is the primary signal. The browser sets it on every
|
|
16
|
+
* request and page JS cannot forge it. `same-origin` / `none` (a direct
|
|
17
|
+
* navigation with no initiator) pass; `same-site` / `cross-site` are
|
|
18
|
+
* rejected unless the source origin is in `webjs.allowedOrigins`.
|
|
19
|
+
* 2. When `Sec-Fetch-Site` is absent (an older browser), fall back to
|
|
20
|
+
* comparing the `Origin` host to the request host; an absent `Origin`
|
|
21
|
+
* can't be checked so it passes (a handcrafted / non-browser request
|
|
22
|
+
* can't carry a victim's SameSite cookies cross-site anyway).
|
|
21
23
|
*
|
|
22
|
-
*
|
|
23
|
-
* -
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* middleware.
|
|
24
|
+
* Scope:
|
|
25
|
+
* - Internal RPC only. A `route.ts` REST endpoint (hand-written or via the
|
|
26
|
+
* `route()` adapter) is intentionally NOT covered here; it is for external
|
|
27
|
+
* consumers and must carry its own auth.
|
|
28
|
+
* - Session / auth cookies stay `SameSite=Lax` as defense-in-depth.
|
|
28
29
|
*/
|
|
29
30
|
|
|
30
|
-
export const CSRF_COOKIE = 'webjs_csrf';
|
|
31
|
-
export const CSRF_HEADER = 'x-webjs-csrf';
|
|
32
|
-
const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 7; // 7 days
|
|
33
|
-
|
|
34
|
-
/** @returns {string} a 128-bit hex token */
|
|
35
|
-
export function newToken() {
|
|
36
|
-
const bytes = new Uint8Array(16);
|
|
37
|
-
webCrypto.getRandomValues(bytes);
|
|
38
|
-
let s = '';
|
|
39
|
-
for (const b of bytes) s += b.toString(16).padStart(2, '0');
|
|
40
|
-
return s;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
31
|
/**
|
|
44
|
-
* Parse cookies off a standard Request.
|
|
32
|
+
* Parse cookies off a standard Request. Retained as a general cookie reader
|
|
33
|
+
* (used by `context.js` for `cookies()`), independent of CSRF.
|
|
45
34
|
* @param {Request} req
|
|
46
35
|
* @returns {Record<string,string>}
|
|
47
36
|
*/
|
|
@@ -60,37 +49,88 @@ export function parseCookies(req) {
|
|
|
60
49
|
return out;
|
|
61
50
|
}
|
|
62
51
|
|
|
63
|
-
/**
|
|
64
|
-
|
|
65
|
-
|
|
52
|
+
/** Lower-cased host from a URL-or-origin string, or '' if unparseable. */
|
|
53
|
+
function hostOf(value) {
|
|
54
|
+
if (!value) return '';
|
|
55
|
+
try { return new URL(value).host.toLowerCase(); } catch { return ''; }
|
|
66
56
|
}
|
|
67
57
|
|
|
68
58
|
/**
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
59
|
+
* The host the request was addressed to. Honors `x-forwarded-host` first
|
|
60
|
+
* (set by a reverse proxy / CDN like the Cloudflare-in-front-of-Railway
|
|
61
|
+
* setup), then the `Host` header, then the request URL.
|
|
62
|
+
*
|
|
63
|
+
* Trust note: `x-forwarded-host` is only meaningful behind a proxy that
|
|
64
|
+
* overwrites it. This is NOT a CSRF weakness, because a CSRF attack is
|
|
65
|
+
* browser-driven and a browser cannot set `x-forwarded-host` (or `Origin`);
|
|
66
|
+
* only a direct non-browser client can, and such a client carries no victim
|
|
67
|
+
* SameSite cookies to abuse. The primary `Sec-Fetch-Site` path does not use
|
|
68
|
+
* this at all.
|
|
69
|
+
* @param {Request} req
|
|
72
70
|
*/
|
|
73
|
-
export function
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
71
|
+
export function requestHost(req) {
|
|
72
|
+
const xfh = req.headers.get('x-forwarded-host');
|
|
73
|
+
if (xfh) return xfh.split(',')[0].trim().toLowerCase();
|
|
74
|
+
const host = req.headers.get('host');
|
|
75
|
+
if (host) return host.toLowerCase();
|
|
76
|
+
return hostOf(req.url);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Is the request's `Origin` host in the configured allowlist? */
|
|
80
|
+
function originAllowed(req, allowedOrigins) {
|
|
81
|
+
const origin = req.headers.get('origin');
|
|
82
|
+
if (!origin || origin === 'null') return false;
|
|
83
|
+
const h = hostOf(origin);
|
|
84
|
+
if (!h) return false;
|
|
85
|
+
const allow = new Set(
|
|
86
|
+
// A full-origin entry (`https://x.example`) is host-parsed; a bare host
|
|
87
|
+
// (`x.example` or a copy-pasted `x.example/`) is lower-cased and stripped
|
|
88
|
+
// of a stray trailing slash so it still matches.
|
|
89
|
+
allowedOrigins.map((o) => (o.includes('://') ? hostOf(o) : o.toLowerCase().replace(/\/+$/, ''))),
|
|
90
|
+
);
|
|
91
|
+
return allow.has(h);
|
|
82
92
|
}
|
|
83
93
|
|
|
84
94
|
/**
|
|
85
|
-
*
|
|
95
|
+
* Cross-origin (CSRF) verification for a state-changing action request.
|
|
86
96
|
* @param {Request} req
|
|
97
|
+
* @param {string[]} [allowedOrigins] hosts or full origins allowed cross-site
|
|
98
|
+
* @returns {{ ok: boolean, reason?: string }}
|
|
99
|
+
*/
|
|
100
|
+
export function verifyOrigin(req, allowedOrigins = []) {
|
|
101
|
+
// Primary: the Sec-Fetch-Site fetch-metadata header (browser-set, not
|
|
102
|
+
// forgeable by page JS, sent on every request).
|
|
103
|
+
const secFetchSite = (req.headers.get('sec-fetch-site') || '').toLowerCase();
|
|
104
|
+
if (secFetchSite === 'same-origin' || secFetchSite === 'none') return { ok: true };
|
|
105
|
+
if (secFetchSite) {
|
|
106
|
+
// 'same-site' or 'cross-site': reject unless the source origin is trusted.
|
|
107
|
+
return originAllowed(req, allowedOrigins)
|
|
108
|
+
? { ok: true }
|
|
109
|
+
: { ok: false, reason: `cross-origin request (Sec-Fetch-Site: ${secFetchSite})` };
|
|
110
|
+
}
|
|
111
|
+
// Fallback (no Sec-Fetch-Site, older browser): compare Origin host to host.
|
|
112
|
+
const origin = req.headers.get('origin');
|
|
113
|
+
if (!origin) return { ok: true, reason: 'no-origin' };
|
|
114
|
+
const sourceHost = origin === 'null' ? 'null' : hostOf(origin);
|
|
115
|
+
const host = requestHost(req);
|
|
116
|
+
if (sourceHost && host && sourceHost === host) return { ok: true };
|
|
117
|
+
return originAllowed(req, allowedOrigins)
|
|
118
|
+
? { ok: true }
|
|
119
|
+
: { ok: false, reason: `origin ${sourceHost || '(none)'} does not match host ${host || '(none)'}` };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Read `webjs.allowedOrigins` (string[]) from a parsed package.json. Pure;
|
|
124
|
+
* the caller supplies the package.json read (mirrors `readBasePath`).
|
|
125
|
+
* @param {unknown} pkg
|
|
126
|
+
* @returns {string[]}
|
|
87
127
|
*/
|
|
88
|
-
export function
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
return
|
|
128
|
+
export function readAllowedOrigins(pkg) {
|
|
129
|
+
const raw =
|
|
130
|
+
pkg &&
|
|
131
|
+
typeof pkg === 'object' &&
|
|
132
|
+
/** @type {any} */ (pkg).webjs &&
|
|
133
|
+
/** @type {any} */ (pkg).webjs.allowedOrigins;
|
|
134
|
+
if (!Array.isArray(raw)) return [];
|
|
135
|
+
return raw.filter((x) => typeof x === 'string' && x.length > 0);
|
|
96
136
|
}
|
package/src/dev.js
CHANGED
|
@@ -13,7 +13,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
13
13
|
|
|
14
14
|
import { buildRouteTable, matchPage, matchApi } from './router.js';
|
|
15
15
|
import { generateRouteTypes } from './route-types.js';
|
|
16
|
-
import { ssrPage, ssrNotFound } from './ssr.js';
|
|
16
|
+
import { ssrPage, ssrNotFound, setClientRouterEnabled } from './ssr.js';
|
|
17
17
|
import { loadPageAction, runPageAction } from './page-action.js';
|
|
18
18
|
import { handleApi } from './api.js';
|
|
19
19
|
import {
|
|
@@ -87,6 +87,7 @@ function shouldAccessLog(pathname) {
|
|
|
87
87
|
}
|
|
88
88
|
import { setVendorEntries, setCoreInstall, publishBuildId, setBasePath, basePath, setImportAliasEntries, importAliasBrowserEntries } from './importmap.js';
|
|
89
89
|
import { readBasePath, stripBasePath, withBasePath } from './base-path.js';
|
|
90
|
+
import { readAllowedOrigins } from './csrf.js';
|
|
90
91
|
import { setAssetRoots, clearAssetHashCache, setElisionFingerprint, withAssetHash, assetHashFor, versionModuleImports } from './asset-hash.js';
|
|
91
92
|
import { urlFromRequest } from './forwarded.js';
|
|
92
93
|
import { compileHeaderRules, applySecurityHeaders, webRequestIsHttps } from './headers.js';
|
|
@@ -267,6 +268,28 @@ export async function readSeedEnabled(appDir) {
|
|
|
267
268
|
return true;
|
|
268
269
|
}
|
|
269
270
|
|
|
271
|
+
/**
|
|
272
|
+
* Read the client-router switch (`webjs.clientRouter`) from the app's
|
|
273
|
+
* package.json (#629). Default `true`: the client router auto-enables in the
|
|
274
|
+
* browser whenever `@webjsdev/core` loads (the automatic-navigation thesis).
|
|
275
|
+
* `{ "webjs": { "clientRouter": false } }` opts the WHOLE app out (pure MPA,
|
|
276
|
+
* full-page navigation), which the render path signals to the browser so the
|
|
277
|
+
* core bundle's module-end auto-enable is skipped. Any other value, or an
|
|
278
|
+
* absent key, keeps the router on. Re-read on every rebuild (alongside elide)
|
|
279
|
+
* so toggling it takes effect without a server restart.
|
|
280
|
+
* @param {string} appDir
|
|
281
|
+
* @returns {Promise<boolean>}
|
|
282
|
+
*/
|
|
283
|
+
export async function readClientRouterEnabled(appDir) {
|
|
284
|
+
try {
|
|
285
|
+
const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
|
|
286
|
+
if (pkg && pkg.webjs && pkg.webjs.clientRouter === false) return false;
|
|
287
|
+
} catch {
|
|
288
|
+
// No package.json, malformed JSON, or unreadable. Keep the default.
|
|
289
|
+
}
|
|
290
|
+
return true;
|
|
291
|
+
}
|
|
292
|
+
|
|
270
293
|
/**
|
|
271
294
|
* Read the per-path response-header config (`webjs.headers`) from the
|
|
272
295
|
* app's package.json and compile it to URLPattern rules. A missing,
|
|
@@ -340,6 +363,24 @@ export async function readBasePathFromApp(appDir) {
|
|
|
340
363
|
}
|
|
341
364
|
}
|
|
342
365
|
|
|
366
|
+
/**
|
|
367
|
+
* Read the cross-origin allowlist (`webjs.allowedOrigins`) from the app's
|
|
368
|
+
* package.json. These hosts / origins are accepted by the action CSRF check
|
|
369
|
+
* even when cross-site (reverse-proxy / multi-domain setups). A missing or
|
|
370
|
+
* unreadable config yields `[]`.
|
|
371
|
+
*
|
|
372
|
+
* @param {string} appDir
|
|
373
|
+
* @returns {Promise<string[]>}
|
|
374
|
+
*/
|
|
375
|
+
export async function readAllowedOriginsFromApp(appDir) {
|
|
376
|
+
try {
|
|
377
|
+
const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
|
|
378
|
+
return readAllowedOrigins(pkg);
|
|
379
|
+
} catch {
|
|
380
|
+
return [];
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
343
384
|
/**
|
|
344
385
|
* Read the CSP config (`webjs.csp`) from the app's package.json and
|
|
345
386
|
* normalize it (issue #233). A missing, malformed, or unreadable config
|
|
@@ -473,6 +514,15 @@ export async function createRequestHandler(opts) {
|
|
|
473
514
|
const basePathValue = await readBasePathFromApp(appDir);
|
|
474
515
|
await setBasePath(basePathValue);
|
|
475
516
|
|
|
517
|
+
// Cross-origin allowlist for the action CSRF check (#659), read once.
|
|
518
|
+
const allowedOriginsValue = await readAllowedOriginsFromApp(appDir);
|
|
519
|
+
|
|
520
|
+
// Client-router opt-out (#629): bind it eagerly at handler construction (the
|
|
521
|
+
// same timing as setBasePath above), so a handler's module-global is set
|
|
522
|
+
// before any render even if a request arrives before the first warm. The
|
|
523
|
+
// analysis pass re-reads it each rebuild so a dev toggle takes effect live.
|
|
524
|
+
setClientRouterEnabled(await readClientRouterEnabled(appDir));
|
|
525
|
+
|
|
476
526
|
const coreDir = locateCoreDir(appDir);
|
|
477
527
|
// Switch the importmap between dist/ bundles and src/ per-file
|
|
478
528
|
// URLs depending on whether the resolved @webjsdev/core install
|
|
@@ -638,6 +688,7 @@ export async function createRequestHandler(opts) {
|
|
|
638
688
|
moduleGraph: null,
|
|
639
689
|
elidableComponents: new Set(),
|
|
640
690
|
inertRouteModules: new Set(),
|
|
691
|
+
importOnlyRouteModules: new Map(),
|
|
641
692
|
browserBoundFiles: null,
|
|
642
693
|
// Transformed-source cache (stripped TS + applied elision). Per-handler,
|
|
643
694
|
// NOT module-global: the cached bytes bake in THIS handler's elision
|
|
@@ -742,12 +793,16 @@ export async function createRequestHandler(opts) {
|
|
|
742
793
|
t.actions = now() - m; m = now();
|
|
743
794
|
state.middleware = await loadMiddleware(appDir, dev, logger);
|
|
744
795
|
t.middleware = now() - m; m = now();
|
|
796
|
+
// Client-router opt-out (#629): re-read each pass so toggling
|
|
797
|
+
// `webjs.clientRouter` takes effect on rebuild without a restart.
|
|
798
|
+
setClientRouterEnabled(await readClientRouterEnabled(appDir));
|
|
745
799
|
const r = (await readElideEnabled(appDir))
|
|
746
800
|
? await analyzeElision(components, collectRouteModules(state.routeTable),
|
|
747
801
|
state.moduleGraph, (f) => readFile(f, 'utf8'), appDir)
|
|
748
|
-
: { elidableComponents: new Set(), inertRouteModules: new Set() };
|
|
802
|
+
: { elidableComponents: new Set(), inertRouteModules: new Set(), importOnlyRouteModules: new Map() };
|
|
749
803
|
state.elidableComponents = r.elidableComponents;
|
|
750
804
|
state.inertRouteModules = r.inertRouteModules;
|
|
805
|
+
state.importOnlyRouteModules = r.importOnlyRouteModules;
|
|
751
806
|
// Fold the elision verdict into app-module content hashes (#243): an
|
|
752
807
|
// app module's served body is elision-transformed, so a verdict flip
|
|
753
808
|
// must bust its `?v` even when its source is byte-identical. A stable
|
|
@@ -764,6 +819,10 @@ export async function createRequestHandler(opts) {
|
|
|
764
819
|
const elidedPaths = [
|
|
765
820
|
...state.elidableComponents,
|
|
766
821
|
...state.inertRouteModules,
|
|
822
|
+
// An import-only module is dropped from the boot (replaced by its
|
|
823
|
+
// component imports), so a flip in / out of that class must bust
|
|
824
|
+
// the importer's `?v` too.
|
|
825
|
+
...state.importOnlyRouteModules.keys(),
|
|
767
826
|
].map(rel).sort();
|
|
768
827
|
setElisionFingerprint(elidedPaths.length ? elidedPaths.join('\n') : '');
|
|
769
828
|
}
|
|
@@ -871,7 +930,7 @@ export async function createRequestHandler(opts) {
|
|
|
871
930
|
if (vendorResolveInFlight) return vendorResolveInFlight;
|
|
872
931
|
vendorResolveInFlight = (async () => {
|
|
873
932
|
try {
|
|
874
|
-
const scan = () => scanBareImports(appDir, new Set([...state.elidableComponents, ...state.inertRouteModules]));
|
|
933
|
+
const scan = () => scanBareImports(appDir, new Set([...state.elidableComponents, ...state.inertRouteModules, ...state.importOnlyRouteModules.keys()]));
|
|
875
934
|
const v = await resolveVendorImports(appDir, scan);
|
|
876
935
|
let { imports, integrity } = v;
|
|
877
936
|
if (bootVendorPinned) {
|
|
@@ -1269,7 +1328,7 @@ export async function createRequestHandler(opts) {
|
|
|
1269
1328
|
// Build all whole-app analysis on the first request (memoized), before
|
|
1270
1329
|
// any SSR, module serve, gate check, action dispatch, or middleware runs.
|
|
1271
1330
|
await ensureReady();
|
|
1272
|
-
const next = () => handleCore(req, { state, appDir, coreDir, dev, reportError, reportDevError, cspEnabled: cspConfig.enabled });
|
|
1331
|
+
const next = () => handleCore(req, { state, appDir, coreDir, dev, reportError, reportDevError, cspEnabled: cspConfig.enabled, allowedOrigins: allowedOriginsValue });
|
|
1273
1332
|
if (state.middleware) {
|
|
1274
1333
|
try {
|
|
1275
1334
|
return await state.middleware(req, next);
|
|
@@ -1303,7 +1362,21 @@ export async function createRequestHandler(opts) {
|
|
|
1303
1362
|
if (matchPathname === null) return null;
|
|
1304
1363
|
const page = matchPage(state.routeTable, matchPathname);
|
|
1305
1364
|
if (!page) return null;
|
|
1306
|
-
|
|
1365
|
+
// Mirror ssr.js's boot assembly EXACTLY: drop inert route modules, and
|
|
1366
|
+
// splice an import-only module's component imports in place of the module
|
|
1367
|
+
// (#605). Otherwise the 103 Early Hints would preload the page / layout
|
|
1368
|
+
// modules the body no longer imports (a wasted hint) and miss the component
|
|
1369
|
+
// modules it actually fetches (a double-fetch on the real module).
|
|
1370
|
+
const inert = state.inertRouteModules;
|
|
1371
|
+
const importOnly = state.importOnlyRouteModules;
|
|
1372
|
+
const files = [];
|
|
1373
|
+
const seenFiles = new Set();
|
|
1374
|
+
for (const f of [page.route.file, ...page.route.layouts]) {
|
|
1375
|
+
if (inert && inert.has(f)) continue;
|
|
1376
|
+
const emit = importOnly && importOnly.get(f);
|
|
1377
|
+
for (const t of emit || [f]) if (!seenFiles.has(t)) { seenFiles.add(t); files.push(t); }
|
|
1378
|
+
}
|
|
1379
|
+
const moduleUrls = files.map((f) => {
|
|
1307
1380
|
let rel = f.startsWith(appDir) ? f.slice(appDir.length) : f;
|
|
1308
1381
|
const url = rel.split('\\').join('/').replace(/^\/?/, '/');
|
|
1309
1382
|
// Mirror ssr.js's emit (basePath THEN `?v`) so the 103 Early Hints preload
|
|
@@ -1689,7 +1762,7 @@ async function tryServeFrameworkStatic(path, method, ctx) {
|
|
|
1689
1762
|
}
|
|
1690
1763
|
|
|
1691
1764
|
async function handleCore(req, ctx) {
|
|
1692
|
-
const { state, appDir, coreDir, dev, reportError, reportDevError, cspEnabled } = ctx;
|
|
1765
|
+
const { state, appDir, coreDir, dev, reportError, reportDevError, cspEnabled, allowedOrigins } = ctx;
|
|
1693
1766
|
const url = new URL(req.url);
|
|
1694
1767
|
// Decode percent-encoded characters so filesystem lookups match real
|
|
1695
1768
|
// filenames. Dynamic route segments like `[slug]` and route groups like
|
|
@@ -1727,7 +1800,7 @@ async function handleCore(req, ctx) {
|
|
|
1727
1800
|
// Pass the onError sink (issue #239): a server action that throws
|
|
1728
1801
|
// unexpectedly is reported to the APM hook before the sanitized 500.
|
|
1729
1802
|
const onActionError = reportError ? (e) => reportError(e, req, 'action') : undefined;
|
|
1730
|
-
return invokeAction(state.actionIndex, actMatch[1], actMatch[2], req, onActionError);
|
|
1803
|
+
return invokeAction(state.actionIndex, actMatch[1], actMatch[2], req, onActionError, allowedOrigins);
|
|
1731
1804
|
}
|
|
1732
1805
|
|
|
1733
1806
|
// Static: /public/*
|
|
@@ -1880,6 +1953,7 @@ async function handleCore(req, ctx) {
|
|
|
1880
1953
|
serverFiles: state.actionIndex.fileToHash,
|
|
1881
1954
|
elidableComponents: state.elidableComponents,
|
|
1882
1955
|
inertRouteModules: state.inertRouteModules,
|
|
1956
|
+
importOnlyRouteModules: state.importOnlyRouteModules,
|
|
1883
1957
|
notFoundFile: state.routeTable.notFound,
|
|
1884
1958
|
// Server HTML cache (#241): a CSP-enabled page emits a fresh
|
|
1885
1959
|
// per-request nonce into its body, so its bytes vary per request and
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App-level elision report (#646).
|
|
3
|
+
*
|
|
4
|
+
* A reporting layer over the `analyzeElision` verdict, NOT a second analysis
|
|
5
|
+
* and NOT a build (webjs is no-build; elision is the server's analysis pass,
|
|
6
|
+
* run at dev-server start and re-derived after each fs.watch rebuild). It
|
|
7
|
+
* builds the module graph, runs `analyzeElision`, and returns the page / layout
|
|
8
|
+
* route modules that SHIP WHOLE to the browser, each with the first
|
|
9
|
+
* client-effecting blocker that pins it (or its own signal when the module
|
|
10
|
+
* itself is the cause).
|
|
11
|
+
*
|
|
12
|
+
* Consumed by `webjs doctor` to ADVISE why a page/layout is not elided (an
|
|
13
|
+
* import-only #605 / inert #179 carrier stays out of the browser; a module that
|
|
14
|
+
* ships whole does not). Advisory only: a page legitimately MAY ship, and the
|
|
15
|
+
* analyser is biased toward shipping by design (server AGENTS invariant 7), so
|
|
16
|
+
* this never fails anything.
|
|
17
|
+
*/
|
|
18
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
|
|
21
|
+
import { buildModuleGraph } from './module-graph.js';
|
|
22
|
+
import { scanComponents } from './component-scanner.js';
|
|
23
|
+
import { buildRouteTable } from './router.js';
|
|
24
|
+
import { analyzeElision } from './component-elision.js';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {string} appDir
|
|
28
|
+
* @returns {Promise<{ analysed: boolean, shipped: Array<{ file: string, blocker: string|null, reason: string }> }>}
|
|
29
|
+
*/
|
|
30
|
+
export async function analyzeAppElision(appDir) {
|
|
31
|
+
// No `app/` means this is not a routable app (a bare component library, a
|
|
32
|
+
// lib-only fixture); nothing ships, so there is nothing to advise on.
|
|
33
|
+
if (!(await pathExists(join(appDir, 'app')))) return { analysed: false, shipped: [] };
|
|
34
|
+
// Elision off (opt-out) ships everything by design, so the advisory is moot.
|
|
35
|
+
if (!(await readElideEnabled(appDir))) return { analysed: false, shipped: [] };
|
|
36
|
+
|
|
37
|
+
let moduleGraph, components, routeTable;
|
|
38
|
+
try {
|
|
39
|
+
moduleGraph = await buildModuleGraph(appDir);
|
|
40
|
+
components = await scanComponents(appDir);
|
|
41
|
+
routeTable = await buildRouteTable(appDir);
|
|
42
|
+
} catch {
|
|
43
|
+
// A malformed app the analysis cannot process degrades to no advice (the
|
|
44
|
+
// dev server and `webjs check` surface the real problem).
|
|
45
|
+
return { analysed: false, shipped: [] };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Exactly the page + layout set the dev server feeds to analyzeElision, so
|
|
49
|
+
// the verdict matches (error / loading / not-found modules always ship and
|
|
50
|
+
// are never elision candidates, so they are not in scope here).
|
|
51
|
+
const routeModuleSet = new Set();
|
|
52
|
+
for (const page of routeTable.pages || []) {
|
|
53
|
+
if (page.file) routeModuleSet.add(page.file);
|
|
54
|
+
for (const f of page.layouts || []) routeModuleSet.add(f);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const { shippedRouteModules } = await analyzeElision(
|
|
58
|
+
components, [...routeModuleSet], moduleGraph, (f) => readFile(f, 'utf8'), appDir,
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const shipped = [...shippedRouteModules.entries()].map(([file, v]) => ({
|
|
62
|
+
file,
|
|
63
|
+
blocker: v.blocker,
|
|
64
|
+
reason: v.reason,
|
|
65
|
+
}));
|
|
66
|
+
return { analysed: true, shipped };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Mirror of the dev server's elide flag: WEBJS_ELIDE override, then webjs.elide. */
|
|
70
|
+
async function readElideEnabled(appDir) {
|
|
71
|
+
const raw = process.env.WEBJS_ELIDE;
|
|
72
|
+
if (raw != null) {
|
|
73
|
+
const v = raw.trim().toLowerCase();
|
|
74
|
+
if (v === '0' || v === 'false' || v === 'off' || v === 'no') return false;
|
|
75
|
+
if (v === '1' || v === 'true' || v === 'on' || v === 'yes') return true;
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
|
|
79
|
+
if (pkg && pkg.webjs && pkg.webjs.elide === false) return false;
|
|
80
|
+
} catch {
|
|
81
|
+
// No package.json, malformed JSON, or unreadable: keep the default (on).
|
|
82
|
+
}
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** @param {string} p @returns {Promise<boolean>} */
|
|
87
|
+
async function pathExists(p) {
|
|
88
|
+
try { await stat(p); return true; } catch { return false; }
|
|
89
|
+
}
|
package/src/html-cache.js
CHANGED
|
@@ -190,10 +190,10 @@ export async function writeHtmlCache(url, rec, revalidateSeconds) {
|
|
|
190
190
|
* - status 200 (an error / redirect / 404 is request-specific)
|
|
191
191
|
* - NOT a streamed Suspense body (it cannot be buffered cheaply, and an
|
|
192
192
|
* unflushed stream has no stable bytes to cache)
|
|
193
|
-
* - NO
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
193
|
+
* - NO Set-Cookie at all. A page that sets a session / per-user cookie is
|
|
194
|
+
* per-user output and must not be shared. SSR responses no longer carry a
|
|
195
|
+
* framework cookie (action CSRF is an Origin / Sec-Fetch-Site check, not a
|
|
196
|
+
* token cookie), so ANY Set-Cookie means per-user, do not cache.
|
|
197
197
|
* - CSP is OFF. With CSP enabled the inline boot script carries a fresh
|
|
198
198
|
* per-request nonce, so the body varies per request and a cached body
|
|
199
199
|
* would replay a stale nonce that the response's CSP header rejects.
|
|
@@ -206,36 +206,10 @@ export function isCacheableResponse(res, guards = {}) {
|
|
|
206
206
|
if (res.status !== 200) return false;
|
|
207
207
|
if (res.headers.has(STREAM_MARKER)) return false;
|
|
208
208
|
if (guards.cspEnabled) return false;
|
|
209
|
-
if (
|
|
209
|
+
if (res.headers.has('set-cookie')) return false;
|
|
210
210
|
return true;
|
|
211
211
|
}
|
|
212
212
|
|
|
213
|
-
/**
|
|
214
|
-
* True when the response carries a Set-Cookie OTHER than the framework's
|
|
215
|
-
* own CSRF cookie. Reads each cookie individually via `getSetCookie()`, the
|
|
216
|
-
* only correct way to enumerate multiple Set-Cookie values (a combined
|
|
217
|
-
* `get('set-cookie')` cannot be split safely, since a cookie value or an
|
|
218
|
-
* Expires date can contain a comma). When `getSetCookie` is unavailable
|
|
219
|
-
* (a runtime older than Node 24) this FAILS SAFE: it reports a
|
|
220
|
-
* non-framework cookie (do not cache) rather than parsing only the first of
|
|
221
|
-
* a combined header and wrongly judging it framework-only.
|
|
222
|
-
*
|
|
223
|
-
* @param {Response} res
|
|
224
|
-
* @returns {boolean}
|
|
225
|
-
*/
|
|
226
|
-
function hasNonFrameworkSetCookie(res) {
|
|
227
|
-
const h = res.headers;
|
|
228
|
-
if (typeof h.getSetCookie !== 'function') {
|
|
229
|
-
// No reliable per-cookie enumeration: fail safe (treat as per-user).
|
|
230
|
-
return h.has('set-cookie');
|
|
231
|
-
}
|
|
232
|
-
for (const c of h.getSetCookie()) {
|
|
233
|
-
const name = c.split('=', 1)[0].trim().toLowerCase();
|
|
234
|
-
if (name !== 'webjs_csrf') return true;
|
|
235
|
-
}
|
|
236
|
-
return false;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
213
|
/**
|
|
240
214
|
* Evict the cached HTML for one path (server-side, on-demand
|
|
241
215
|
* revalidation: the no-build ISR revalidation hook). A server action that
|