@webjsdev/server 0.8.57 → 0.8.58

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/server",
3
- "version": "0.8.57",
3
+ "version": "0.8.58",
4
4
  "type": "module",
5
5
  "description": "webjs dev/prod server: SSR, router, API, server actions, live reload",
6
6
  "main": "index.js",
@@ -28,7 +28,7 @@
28
28
  "README.md"
29
29
  ],
30
30
  "dependencies": {
31
- "@webjsdev/core": "^0.7.1",
31
+ "@webjsdev/core": "^0.7.46",
32
32
  "ws": "^8.20.0"
33
33
  },
34
34
  "publishConfig": {
package/src/actions.js CHANGED
@@ -563,8 +563,10 @@ export async function invokeAction(idx, hash, fnName, req, onError, allowedOrigi
563
563
  * Build a GET action's response (#488): the serialized body plus a weak ETag
564
564
  * (content hash), the `Cache-Control` from the `cache` config (else `no-store`),
565
565
  * and `X-Webjs-Tags`. Answers `If-None-Match` with a 304 itself rather than
566
- * relying on the conditional-GET funnel, which EXCLUDES `private` responses (the
567
- * default here); a per-user browser cache may still revalidate to a 304.
566
+ * deferring to the conditional-GET funnel: this branch already hashed the
567
+ * serialized body, so it can answer without the funnel re-reading it. (The
568
+ * funnel would now accept these too, since #1140 stopped excluding `private`,
569
+ * but the short-circuit here still runs first and is cheaper.)
568
570
  * @param {unknown} result
569
571
  * @param {Record<string, unknown>} mod
570
572
  * @param {unknown[]} args
package/src/asset-hash.js CHANGED
@@ -159,10 +159,32 @@ export function assetHashFor(absPath) {
159
159
  const bytes = readFileSync(absPath);
160
160
  const h = createHash('sha256').update(bytes);
161
161
  // An app module's served body is elision-transformed, so fold the verdict
162
- // fingerprint in (see `_elisionFp`). Core / public files are never
163
- // transformed, so they hash over their bytes alone. Empty fp leaves an app
164
- // module's hash at exactly `sha256(bytes)`.
165
- if (_elisionFp && _appDir && absPath.startsWith(_appDir + sep)) {
162
+ // fingerprint in (see `_elisionFp`). Core and `public/` files are never
163
+ // transformed, so they hash over their bytes alone, which the two
164
+ // exclusions below enforce rather than merely assume. Empty fp leaves an
165
+ // app module's hash at exactly `sha256(bytes)`.
166
+ //
167
+ // Both exclusions are EXPLICIT because the containment test alone admits
168
+ // them, and the intent above is otherwise silently false:
169
+ //
170
+ // - `public/` sits under `_appDir`. Inert while only module specifiers
171
+ // reached here, but `asset()` (#1194) routes public paths through this
172
+ // function.
173
+ // - `_coreDir` sits under `_appDir` too in a REAL install, where it
174
+ // resolves to `<appDir>/node_modules/@webjsdev/core`. It escapes only
175
+ // in this monorepo, where the workspace symlink lands outside, which
176
+ // is exactly why no in-repo test ever caught it.
177
+ //
178
+ // Neither file set is elision-transformed (core is served verbatim by
179
+ // `fileResponse`), so folding the verdict in buys nothing and costs
180
+ // correctness: any deploy that merely flips an UNRELATED component between
181
+ // elidable and interactive would move their urls, re-downloading the whole
182
+ // core runtime plus every marked stylesheet, image, and script even though
183
+ // not one of those bytes changed. That is the precise cost a content hash
184
+ // exists to avoid.
185
+ if (_elisionFp && _appDir && absPath.startsWith(_appDir + sep)
186
+ && !absPath.startsWith(join(_appDir, 'public') + sep)
187
+ && !(_coreDir && absPath.startsWith(_coreDir + sep))) {
166
188
  h.update('\0');
167
189
  h.update(_elisionFp);
168
190
  }
@@ -267,6 +289,46 @@ export function withAssetHash(url, basePath = '') {
267
289
  return `${url}${sepChar}v=${hash}`;
268
290
  }
269
291
 
292
+ /**
293
+ * Resolve one author-marked asset path to its fingerprinted url. This is the
294
+ * body behind the isomorphic `asset()` helper (#1194); `dev.js` installs it
295
+ * via `setAssetUrlProvider` at boot.
296
+ *
297
+ * The `public/` gate is defensive, not decorative. `resolveUrlToFile` maps any
298
+ * same-origin path under the PROJECT ROOT, so an app that passed request- or
299
+ * user-derived data in (`asset(user.avatarPath)`) would otherwise read and
300
+ * publish a content hash for `/.env`, `/db/app.db`, or a `.server.ts`, leaking
301
+ * both that the file exists and a fingerprint of its exact bytes for files the
302
+ * serve path deliberately 404s. Mirror the static-asset route instead: under
303
+ * `public/`, no traversal. Anything else returns unchanged WITHOUT touching
304
+ * the disk, so a rejected path costs nothing and reveals nothing.
305
+ *
306
+ * A fragment is split before hashing. `withAssetHash` appends the query, so
307
+ * passing the whole thing would emit `/x.svg#icon?v=H`: the browser then
308
+ * requests `/x.svg` with no query (losing the immutable caching this exists
309
+ * for) and the fragment becomes `icon?v=H`, matching no element id.
310
+ *
311
+ * @param {string} p the author-supplied path
312
+ * @param {string} [bp] the active base path
313
+ * @returns {string} the fingerprinted url, or `p` unchanged
314
+ */
315
+ export function resolveAssetUrl(p, bp = '') {
316
+ if (typeof p !== 'string' || p[0] !== '/' || p[1] === '/') return p;
317
+ let probe = p;
318
+ if (bp && probe.startsWith(bp + '/')) probe = probe.slice(bp.length);
319
+ const cuts = [probe.indexOf('?'), probe.indexOf('#')].filter((i) => i !== -1);
320
+ let decoded = probe.slice(0, cuts.length ? Math.min(...cuts) : probe.length);
321
+ try { decoded = decodeURIComponent(decoded); } catch { /* keep raw */ }
322
+ if (decoded.includes('..') || !decoded.startsWith('/public/')) return p;
323
+ const h = p.indexOf('#');
324
+ const path = h === -1 ? p : p.slice(0, h);
325
+ const frag = h === -1 ? '' : p.slice(h);
326
+ // An author-supplied query is left alone: it may carry meaning we do not
327
+ // own, and appending would merge with `&`.
328
+ if (path.includes('?')) return p;
329
+ return withAssetHash(path, bp) + frag;
330
+ }
331
+
270
332
  /**
271
333
  * Static `import` specifier with positional indices. Mirrors module-graph.js's
272
334
  * `IMPORT_RE` (side-effect / default / namespace / named imports), but captures
@@ -10,9 +10,15 @@
10
10
  * uniformly.
11
11
  *
12
12
  * What is EXCLUDED, and why:
13
- * - `no-store` / `private` responses (the default for dynamic, per-user
14
- * pages). Never enable a cross-session 304 on private content: a shared
15
- * cache keyed on the URL could serve one user's validator to another.
13
+ * - `no-store` responses (the default for a dynamic page), which forbid
14
+ * storage outright, so there is nothing to validate. `private` is NOT
15
+ * excluded: it forbids SHARED storage, not validation, and the ETag hashes
16
+ * THIS response's body, so two users with different bodies get different
17
+ * ETags and neither can match the other's, while two users with identical
18
+ * bodies are asking about identical bytes, where a 304 discloses nothing
19
+ * (#1140).
20
+ * That is what lets the client router's partial responses stay `private`
21
+ * without losing their 304s.
16
22
  * - Any body the framework did not positively mark as fully buffered. The
17
23
  * funnel only hashes a response that ALREADY carries an `ETag` (a serve
18
24
  * branch hashed its own bytes) OR carries the internal `X-Webjs-Buffered`
@@ -77,11 +83,28 @@ const STRIP_ON_304 = ['content-length', 'content-encoding', 'content-type'];
77
83
 
78
84
  /**
79
85
  * Is this response cacheable enough to carry a validator? Cacheable means a
80
- * 200 whose `Cache-Control` is present and does not forbid storage. A
81
- * `no-store` or `private` response is excluded (private / per-user content
82
- * must never get a cross-session 304). `no-cache` is INCLUDED: it means
83
- * "revalidate before reuse", and a 304 is exactly that revalidation answer,
84
- * so dev's `no-cache` assets still benefit.
86
+ * 200 whose `Cache-Control` is present and does not forbid STORAGE. Only
87
+ * `no-store` is excluded. `no-cache` is INCLUDED: it means "revalidate before
88
+ * reuse", and a 304 is exactly that revalidation answer, so dev's `no-cache`
89
+ * assets still benefit.
90
+ *
91
+ * `private` is INCLUDED too (#1140). It was excluded on the theory that
92
+ * per-user content must never get a "cross-session" 304, but that cannot
93
+ * happen: the ETag is a hash of THIS response's body, and a 304 is returned
94
+ * only when the client's own `If-None-Match` matches it. Two users with
95
+ * different bodies get different ETags, so neither can ever match the other's;
96
+ * two users with identical bodies are asking about identical bytes, where a
97
+ * 304 is the correct answer and carries no body either way. `private` already
98
+ * forbids the one real hazard, a SHARED cache storing the response.
99
+ *
100
+ * Widening this is what makes #1140's fragment downgrade safe to ship. Before
101
+ * that change a reduced fragment INHERITED the page's public header, so it
102
+ * already passed this check and already carried a validator; marking fragments
103
+ * `private` would have taken that away, and since the router fetches them with
104
+ * `cache: 'no-cache'` (#1131), every prefetch and soft navigation would have
105
+ * re-downloaded the whole fragment. The exclusion is dropped rather than
106
+ * special-cased because it was never justified on its own terms either, per
107
+ * the reasoning above.
85
108
  *
86
109
  * @param {Response} res
87
110
  * @returns {boolean}
@@ -90,7 +113,7 @@ function isCacheable(res) {
90
113
  if (res.status !== 200) return false;
91
114
  const cc = res.headers.get('cache-control');
92
115
  if (!cc) return false;
93
- return !/(?:^|,)\s*(?:no-store|private)\s*(?:,|$)/i.test(cc);
116
+ return !/(?:^|,)\s*no-store\s*(?:,|$)/i.test(cc);
94
117
  }
95
118
 
96
119
  /**
package/src/dev.js CHANGED
@@ -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 { setAssetRoots, clearAssetHashCache, setElisionFingerprint, withAssetHash, assetHashFor, versionModuleImports } from './asset-hash.js';
96
+ import { setAssetUrlProvider } from '@webjsdev/core';
97
+ import { setAssetRoots, clearAssetHashCache, setElisionFingerprint, withAssetHash, assetHashFor, versionModuleImports, resolveAssetUrl } from './asset-hash.js';
97
98
  import { urlFromRequest } from './forwarded.js';
98
99
  import { compileHeaderRules, applySecurityHeaders, webRequestIsHttps } from './headers.js';
99
100
  import {
@@ -624,6 +625,21 @@ export async function createRequestHandler(opts) {
624
625
  // id is a stable deploy fingerprint independent of per-file content hashes.
625
626
  setAssetRoots({ appDir, coreDir, enabled: !dev });
626
627
 
628
+ // Install the resolver behind the isomorphic `asset()` helper (#1194), the
629
+ // same provider seam `cspNonce()` uses. An author writes
630
+ // `href=${asset('/public/app.css')}` and gets the fingerprinted url on the
631
+ // server; the browser has no provider and returns the path unchanged.
632
+ //
633
+ // The `public/` gate is defensive rather than decorative. `resolveUrlToFile`
634
+ // maps any same-origin path under the PROJECT ROOT, so an app that passed
635
+ // request- or user-derived data here (`asset(user.avatarPath)`) would
636
+ // otherwise read and publish a content hash for `/.env`, `/db/app.db`, or a
637
+ // `.server.ts`, leaking both existence and a fingerprint of the bytes for
638
+ // files the serve path deliberately 404s. Mirror the static-asset route:
639
+ // under `public/`, no traversal. Everything else returns unchanged, having
640
+ // never touched the disk.
641
+ setAssetUrlProvider((p) => resolveAssetUrl(p, basePath()));
642
+
627
643
  // SSR action-result seeding (#472). Install the process-global module load
628
644
  // hook NOW, at boot, before any `'use server'` action module is imported (ESM
629
645
  // caches by URL, so a module loaded before the hook would never be faceted).
@@ -1263,8 +1279,9 @@ export async function createRequestHandler(opts) {
1263
1279
  // into a 304 Not Modified with no body. Applied LAST, after every header
1264
1280
  // (X-Webjs-Build, X-Request-Id, Set-Cookie, CSP) is on the response, so a
1265
1281
  // 304 carries the validators a shared cache and the client router need.
1266
- // A no-store / per-user response, a non-GET/HEAD, and a streaming Suspense
1267
- // body are all skipped (see conditional-get.js). Logged with the final
1282
+ // A no-store response, a non-GET/HEAD, and a streaming Suspense body are
1283
+ // all skipped (see conditional-get.js). A `private` response is NOT
1284
+ // skipped: private forbids shared storage, not validation (#1140). Logged with the final
1268
1285
  // (possibly 304) status. Best-effort: a failure leaves the 200 untouched.
1269
1286
  let conditioned = merged;
1270
1287
  try {
package/src/ssr.js CHANGED
@@ -164,6 +164,11 @@ export async function ssrPage(route, params, url, opts) {
164
164
  frameRes.headers.append('vary', 'X-Webjs-Frame');
165
165
  // #1009: a subtree sliced from a REDUCED render inherits its variance.
166
166
  if (reduced) frameRes.headers.append('vary', 'X-Webjs-Have');
167
+ // No privateFragment call here on purpose: this response is built
168
+ // WITHOUT page metadata, so it is already `no-store` and the call
169
+ // would be dead code with nothing to assert against. The property is
170
+ // locked by a test instead, which fails if the frame response ever
171
+ // starts carrying the page's cacheControl.
167
172
  return frameRes;
168
173
  }
169
174
  }
@@ -304,15 +309,25 @@ export async function ssrPage(route, params, url, opts) {
304
309
  );
305
310
  // REDUCED response (#1009): the X-Webjs-Have short-circuit omitted the
306
311
  // outer-layout chrome, so these bytes are only valid for a request that
307
- // sent a matching `have`. Without `Vary: X-Webjs-Have`, a shared cache
308
- // (CDN edge) could store the reduced body under the URL and serve a
309
- // chrome-less fragment to a fresh full-page navigation (measured live:
310
- // GET / was 73,534 bytes, GET / + have was 57,035, byte-identical headers
311
- // otherwise). Scoped to genuinely reduced responses so a normal page's
312
- // cache key is unchanged. The internal #241 revalidate cache is already
313
- // safe by construction: `cacheEligible` excludes any request that carries
314
- // x-webjs-have, so a reduced body is never stored under the URL-only key.
315
- if (reduced) res.headers.append('vary', 'X-Webjs-Have');
312
+ // sent a matching `have`. Left shared-cacheable, a CDN edge could store the
313
+ // reduced body under the URL and serve a chrome-less fragment to a fresh
314
+ // full-page navigation (measured live: GET / was 73,534 bytes, GET / + have
315
+ // was 57,035, byte-identical headers otherwise).
316
+ //
317
+ // TWO markings, and the order of trust matters (#1140). `privateFragment`
318
+ // is the guarantee: it forbids SHARED storage outright, so no CDN can hold
319
+ // this body at all. `Vary` is belt-and-braces for caches that honour it,
320
+ // NOT the protection, because Cloudflare and others honour only
321
+ // `Accept-Encoding`. Both are scoped to genuinely reduced responses, so a
322
+ // normal page's headers and cache key are unchanged.
323
+ //
324
+ // The internal #241 revalidate cache is already safe by construction:
325
+ // `cacheEligible` excludes any request that carries x-webjs-have, so a
326
+ // reduced body is never stored under the URL-only key.
327
+ if (reduced) {
328
+ res.headers.append('vary', 'X-Webjs-Have');
329
+ privateFragment(res);
330
+ }
316
331
  // Server HTML cache write (#241). The page opted in via `revalidate`, so
317
332
  // FLAG this candidate for the response funnel rather than writing here: the
318
333
  // store decision must see the FINAL response (after segment middleware,
@@ -465,6 +480,82 @@ export async function ssrUnauthorized(route, opts) {
465
480
  return htmlResponse(html, 401, opts.req, opts.url);
466
481
  }
467
482
 
483
+ /**
484
+ * Downgrade a PARTIAL response so no shared cache can store it (#1140).
485
+ *
486
+ * A reduced `X-Webjs-Have` body is sliced by a REQUEST header, so it is valid
487
+ * only for a client that sent it. It is marked `Vary` for that header, but
488
+ * `Vary` is not a guarantee in practice: Cloudflare honours only
489
+ * `Accept-Encoding` and several other shared caches are just as selective.
490
+ * Since a reduced body otherwise INHERITS the page's `Cache-Control`, a page
491
+ * that opted into public caching was handing CDNs a chrome-less fragment under
492
+ * the full page's URL, to be served to whoever navigated there next. Measured
493
+ * on webjs.dev: 71,759 bytes for the document versus 47,375 for the fragment,
494
+ * identical `Cache-Control` on both.
495
+ *
496
+ * The reduced path is the ONLY caller. A `<webjs-frame>` subtree is sliced by a
497
+ * request header too, but its response is built without page metadata, so it is
498
+ * already `no-store` and needs no downgrade; that property is locked by a test
499
+ * rather than by a call here. A NEW partial-response shape does not get this
500
+ * treatment for free: call this from its response site.
501
+ *
502
+ * `private` fixes that at the source instead of relying on `Vary` being
503
+ * respected: no shared cache may store it, while the browser that asked for it
504
+ * still may. The freshness directives are preserved, so a returning client
505
+ * keeps whatever reuse the page declared. `Vary` stays too, as belt-and-braces
506
+ * for caches that do honour it.
507
+ *
508
+ * The response KEEPS its validator. `private` forbids shared storage, not
509
+ * validation, so the funnel still attaches an ETag (see conditional-get.js,
510
+ * which stopped excluding `private` for exactly this reason). That matters:
511
+ * the router fetches partials with `cache: 'no-cache'` (#1131), so without a
512
+ * validator every prefetch and soft navigation would re-download the whole
513
+ * fragment instead of being answered 304.
514
+ *
515
+ * @param {Response} res
516
+ */
517
+ export function privateFragment(res) {
518
+ const cc = res.headers.get('cache-control') || '';
519
+ // No header at all is not "nothing to do": a response with no Cache-Control
520
+ // is heuristically storable by a shared cache (RFC 9111), which is precisely
521
+ // the hazard here. Fail CLOSED.
522
+ if (!cc) {
523
+ res.headers.set('cache-control', 'private');
524
+ return;
525
+ }
526
+ // Split on commas that are NOT inside a quoted argument: a directive may
527
+ // carry one (`no-cache="Set-Cookie, X-Foo"`, `private="x-user"`), and a naive
528
+ // split tears those apart.
529
+ const directives = [];
530
+ let buf = '';
531
+ let inQuotes = false;
532
+ for (const ch of cc) {
533
+ if (ch === '"') inQuotes = !inQuotes;
534
+ if (ch === ',' && !inQuotes) { directives.push(buf.trim()); buf = ''; continue; }
535
+ buf += ch;
536
+ }
537
+ directives.push(buf.trim());
538
+
539
+ // `name` is everything before the first `=`, trimmed, so `s-maxage = 600`
540
+ // and `private="x-user"` are recognised as the directives they are.
541
+ const nameOf = (d) => d.split('=')[0].trim().toLowerCase();
542
+ // Already unshareable: leave it exactly as the author wrote it. Only a BARE
543
+ // `private` counts. The qualified form (`private="x-user"`) marks just the
544
+ // NAMED header fields private per RFC 9111, leaving the response itself
545
+ // storable by a shared cache, so it must still be downgraded.
546
+ const isBare = (d) => !d.includes('=');
547
+ if (directives.some((d) => nameOf(d) === 'no-store' || (nameOf(d) === 'private' && isBare(d)))) return;
548
+
549
+ // `private` joins the drop list because a bare one is prepended below: a
550
+ // qualified `private="x-user"` left in place would emit the directive twice,
551
+ // and a cache that resolves a repeat by taking the last occurrence would read
552
+ // the qualified form, which per RFC 9111 leaves the response shared-storable.
553
+ const SHARED_ONLY = new Set(['public', 's-maxage', 'proxy-revalidate', 'private']);
554
+ const kept = directives.filter((d) => d && !SHARED_ONLY.has(nameOf(d)));
555
+ kept.unshift('private');
556
+ res.headers.set('cache-control', kept.join(', '));
557
+ }
558
+
468
559
  /**
469
560
  * Build an HTML Response. Sets no cookie: action CSRF is an Origin /
470
561
  * Sec-Fetch-Site check, so the page response is cookieless (CDN-cacheable).
@@ -476,8 +567,10 @@ export async function ssrUnauthorized(route, opts) {
476
567
  */
477
568
  function htmlResponse(html, status, req, url, metadata) {
478
569
  const headers = new Headers({ 'content-type': 'text/html; charset=utf-8' });
479
- // Default: no caching. Pages are dynamic by default: the developer
480
- // opts in to caching explicitly via metadata.cacheControl.
570
+ // Default: no caching. Pages are dynamic by default: the developer opts in
571
+ // explicitly via metadata.cacheControl. No non-200 guard here, unlike
572
+ // streamingHtmlResponse: every caller of THIS builder passes no metadata, so
573
+ // the value is already the no-store default and a guard would be dead code.
481
574
  headers.set('cache-control', metadata?.cacheControl || 'no-store');
482
575
  // X-Webjs-Build carries the published build id so the client
483
576
  // router can detect post-deploy importmap changes on EVERY
@@ -487,10 +580,11 @@ function htmlResponse(html, status, req, url, metadata) {
487
580
  // applySwap and publishedBuildId() in importmap.js.
488
581
  headers.set('x-webjs-build', publishedBuildId());
489
582
  headers.set('x-webjs-src', appSourceId());
490
- // Buffered (string) body: opt into the conditional-GET funnel so a
491
- // PUBLIC-cacheable page (metadata.cacheControl) gets a weak ETag + 304.
492
- // The funnel still excludes the no-store default, so a private page is
493
- // never ETagged. See conditional-get.js.
583
+ // Buffered (string) body: opt into the conditional-GET funnel.
584
+ // A cacheable page (metadata.cacheControl) gets a weak ETag + 304. The
585
+ // funnel excludes only the no-store default; a `private` page IS validated,
586
+ // which is what keeps the router's partial responses cheap (#1140).
587
+ // See conditional-get.js.
494
588
  headers.set(BUFFERED_MARKER, '1');
495
589
  return new Response(html, { status, headers });
496
590
  }
@@ -650,8 +744,10 @@ async function renderChain(route, ctx, dev, suspenseCtx, have, pageModule) {
650
744
  const body = await renderToString(tree, { ssr: true, suspenseCtx });
651
745
  // REDUCED response (#1009): the outer layouts were skipped, so these
652
746
  // bytes are only valid for a client that already HAS them. The caller
653
- // marks the response `Vary: X-Webjs-Have` so no shared cache can serve
654
- // this fragment to a fresh full-page navigation.
747
+ // marks the response `private` so no shared cache can store it, and
748
+ // additionally `Vary: X-Webjs-Have` for caches that honour it. The
749
+ // `private` is the guarantee, not the Vary: Cloudflare and others honour
750
+ // only `Accept-Encoding` (#1140).
655
751
  return { html: body + (await loadingTemplates(route, ctx, dev)), reduced: true };
656
752
  }
657
753
  const mod = await loadModule(route.layouts[i], dev);
@@ -1955,8 +2051,11 @@ function streamingHtmlResponse(prefix, bodyHtml, closer, ctx, status, req, url,
1955
2051
  const encoder = new TextEncoder();
1956
2052
  const headers = new Headers({ 'content-type': 'text/html; charset=utf-8' });
1957
2053
  // Default: no caching. Pages are dynamic by default: the developer
1958
- // opts in to caching explicitly via metadata.cacheControl.
1959
- headers.set('cache-control', metadata?.cacheControl || 'no-store');
2054
+ // opts in to caching explicitly via metadata.cacheControl. A non-200 does
2055
+ // NOT inherit it (#1140): the page-action re-render is a 422 carrying the
2056
+ // submitter's own field values and errors, which must never be handed to a
2057
+ // shared cache just because the page opted into public caching.
2058
+ headers.set('cache-control', status === 200 ? (metadata?.cacheControl || 'no-store') : 'no-store');
1960
2059
  // See htmlResponse: published build id on every response for the
1961
2060
  // client router's importmap-mismatch detection on partial swaps.
1962
2061
  headers.set('x-webjs-build', publishedBuildId());