@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/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
- * Double-submit cookie CSRF protection for `/__webjs/action/*` RPC endpoints.
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
- * Flow:
10
- * 1. Every SSR response that lacks the cookie issues a fresh `webjs_csrf`
11
- * cookie with `SameSite=Lax; Path=/` (and `Secure` when over HTTPS).
12
- * The cookie is readable by JS (not HttpOnly) so the auto-generated
13
- * action stub can echo it.
14
- * 2. The action-stub `fetch` sends the token back in `x-webjs-csrf`.
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
- * This protects against classic CSRF (a malicious site triggering a POST
19
- * from a victim browser): cross-origin requests cannot read the cookie, so
20
- * they cannot set the header to the matching value.
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
- * Notes on scope:
23
- * - Applies to internal RPC only. A `route.ts` REST endpoint (hand-written
24
- * or via the `route()` adapter) is *not* CSRF-protected because it is
25
- * intended for external consumers; such an endpoint should carry its own
26
- * auth (bearer token, signed request, API key) the app provides via
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
- /** @param {Request} req */
64
- export function readToken(req) {
65
- return parseCookies(req)[CSRF_COOKIE] || null;
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
- * Serialise a Set-Cookie value for the CSRF token.
70
- * @param {string} token
71
- * @param {{ secure?: boolean }} [opts]
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 cookieHeader(token, opts = {}) {
74
- const parts = [
75
- `${CSRF_COOKIE}=${encodeURIComponent(token)}`,
76
- 'Path=/',
77
- 'SameSite=Lax',
78
- `Max-Age=${COOKIE_MAX_AGE_SECONDS}`,
79
- ];
80
- if (opts.secure) parts.push('Secure');
81
- return parts.join('; ');
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
- * Constant-time verification of the double-submit.
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 verify(req) {
89
- const cookie = readToken(req);
90
- const header = req.headers.get(CSRF_HEADER);
91
- if (!cookie || !header) return false;
92
- if (cookie.length !== header.length) return false;
93
- let diff = 0;
94
- for (let i = 0; i < cookie.length; i++) diff |= cookie.charCodeAt(i) ^ header.charCodeAt(i);
95
- return diff === 0;
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
- const moduleUrls = [page.route.file, ...page.route.layouts].map((f) => {
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 non-framework Set-Cookie. A page that sets a session / per-user
194
- * cookie is per-user output and must not be shared. The framework's own
195
- * CSRF cookie (`webjs_csrf`) is allowed and re-minted per response on a
196
- * cache hit, so its presence does not block caching.
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 (hasNonFrameworkSetCookie(res)) return false;
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