@webjsdev/server 0.8.57 → 0.8.59

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/html-cache.js CHANGED
@@ -27,8 +27,9 @@
27
27
  import { getStore } from './cache.js';
28
28
  import { STREAM_MARKER } from './conditional-get.js';
29
29
  import { createHash } from 'node:crypto';
30
- import { publishedBuildId } from './importmap.js';
31
- import { dynamicAccessed } from './context.js';
30
+ import { publishedBuildId, basePath } from './importmap.js';
31
+ import { stripBasePath } from './base-path.js';
32
+ import { dynamicAccessed, getRequest } from './context.js';
32
33
 
33
34
  /** Namespace prefix for every cached-HTML key, so a flush can target it. */
34
35
  const KEY_PREFIX = 'webjs:html:';
@@ -109,11 +110,37 @@ export function readRevalidate(pageModule) {
109
110
  }
110
111
 
111
112
  /**
112
- * The cache key for a request: the FULL URL (path + search string), since
113
- * `searchParams` change page output. Normalized to path + sorted query so
114
- * `?a=1&b=2` and `?b=2&a=1` share an entry. Two more discriminators are
115
- * folded into the namespace:
113
+ * The origin component of a cache key. `url.origin` is already the normalized
114
+ * lowercase `scheme://host[:port]` form for an http(s) URL; a non-special
115
+ * scheme serializes to the literal string `null`, which is a fine constant key
116
+ * component (such a URL never reaches a cacheable page render anyway).
116
117
  *
118
+ * @param {URL} url
119
+ * @returns {string}
120
+ */
121
+ function originKey(url) {
122
+ return url.origin;
123
+ }
124
+
125
+ /**
126
+ * The cache key for a request: the ORIGIN plus the full path + search string,
127
+ * since both `ctx.url.origin` and `searchParams` change page output.
128
+ * Normalized to path + sorted query so `?a=1&b=2` and `?b=2&a=1` share an
129
+ * entry. Four discriminators are folded into the namespace:
130
+ *
131
+ * - the origin (#1097). This cache is SHARED across visitors, and `ctx.url`
132
+ * is built from `X-Forwarded-Host` / `X-Forwarded-Proto`, which neither
133
+ * Cloudflare nor Railway strips from a client request. Without the origin
134
+ * in the key, one request carrying `X-Forwarded-Host: evil.example` baked
135
+ * an attacker-chosen origin into the shared body, and every later visitor
136
+ * to that path was served it until the entry expired: a poisoned
137
+ * `og:image`, canonical link, OAuth callback URL, or absolute asset URL.
138
+ * With the origin in the key, a clean request resolves its origin from the
139
+ * proxy-set `Host` and reads its OWN entry, so the hostile body is
140
+ * unreachable from any origin an attacker does not already control. A
141
+ * single-host deploy has exactly one origin and so exactly one key per URL,
142
+ * unchanged hit rate; a genuine multi-host deploy gets correctly separated
143
+ * entries instead of cross-serving one host's HTML to another;
117
144
  * - the in-process generation, so `revalidateAll()` (a generation bump)
118
145
  * makes every prior key unreachable in one step;
119
146
  * - the published build id (the importmap fingerprint), so a NEW DEPLOY
@@ -143,7 +170,7 @@ export function htmlCacheKey(url) {
143
170
  // the importmap-only build id misses. Empty (dev / no fingerprint) collapses
144
171
  // to the prior key shape, so an unconfigured app is byte-identical.
145
172
  const appfp = _appSourceFp ? `${_appSourceFp}:` : '';
146
- return `${KEY_PREFIX}${build}:${appfp}${_generation}:${url.pathname}${search}`;
173
+ return `${KEY_PREFIX}${build}:${appfp}${_generation}:${originKey(url)}:${url.pathname}${search}`;
147
174
  }
148
175
 
149
176
  /**
@@ -221,17 +248,59 @@ export function isCacheableResponse(res, guards = {}) {
221
248
  * ONLY the no-query entry; pass the exact `path?query` to target a
222
249
  * specific query variant, or call `revalidateAll()` to clear everything.
223
250
  *
224
- * @param {string} path e.g. '/blog' or '/blog?page=2'
251
+ * ORIGIN RESOLUTION (#1097). Cache keys carry the request's origin, so a bare
252
+ * path does not name one entry, it names one per origin the path was cached
253
+ * under. Two forms resolve it:
254
+ *
255
+ * 1. The CURRENT request's origin, when one is ambient. A mutation runs inside
256
+ * the request of the user who triggered it, and that user is on the same
257
+ * origin as the pages being evicted, so a bare path is exact for the
258
+ * ordinary "server action writes, then evicts" flow, which is every
259
+ * in-repo example and everything the docs show.
260
+ * 2. An ABSOLUTE url (`https://app.example/blog`) names its origin outright.
261
+ * This is the form for a caller with NO ambient request: a cron job, a
262
+ * queue worker, an `instrumentation.js` timer. Under `webjs.basePath` pass
263
+ * the PUBLIC url and the mount prefix is stripped for you, so it keys the
264
+ * same entry the write stored.
265
+ *
266
+ * A bare path with no ambient request cannot be resolved and WARNS ONCE naming
267
+ * the path, rather than silently evicting nothing.
268
+ *
269
+ * There is deliberately no process-local registry of "origins seen so far"
270
+ * standing in for case 2. The origin comes from `X-Forwarded-Host`, which is
271
+ * client-supplied and forwarded rather than stripped (the whole premise of
272
+ * #1097), so such a registry is attacker-writable: a flood of hostile hosts
273
+ * against a cold process either fills a bounded registry before the real origin
274
+ * is ever seen, or grows an unbounded one. Either way eviction silently stops
275
+ * working, which is worse than the explicit contract above.
276
+ *
277
+ * @param {string} path e.g. '/blog', '/blog?page=2', or 'https://app.example/blog'
225
278
  * @returns {Promise<void>}
226
279
  */
227
280
  export async function revalidatePath(path) {
228
281
  if (typeof path !== 'string' || !path) return;
229
- // Build the same normalized key readHtmlCache / writeHtmlCache produce.
230
282
  let url;
231
- try {
232
- url = new URL(path, 'http://internal.invalid');
233
- } catch {
234
- return;
283
+ if (/^https?:\/\//i.test(path)) {
284
+ try { url = new URL(path); } catch { return; }
285
+ // An absolute url is the PUBLIC one, so under `webjs.basePath` it carries
286
+ // the mount prefix. Cache entries key on the app-root-relative path (the
287
+ // write strips it in dev.js for exactly this reason), so strip it here too
288
+ // or the caller who was told to use this form, the one with no ambient
289
+ // request, computes a key nothing was ever stored under. A path that is
290
+ // not under the base path is left alone, matching the write's behaviour.
291
+ const base = basePath();
292
+ if (base) {
293
+ const stripped = stripBasePath(url.pathname, base);
294
+ if (stripped !== null) url.pathname = stripped;
295
+ }
296
+ } else {
297
+ const origin = ambientOrigin();
298
+ if (!origin) {
299
+ warnUnresolvedOriginOnce(path);
300
+ return;
301
+ }
302
+ // Build the same normalized key readHtmlCache / writeHtmlCache produce.
303
+ try { url = new URL(path, origin); } catch { return; }
235
304
  }
236
305
  try {
237
306
  await getStore().delete(htmlCacheKey(url));
@@ -240,6 +309,41 @@ export async function revalidatePath(path) {
240
309
  }
241
310
  }
242
311
 
312
+ /**
313
+ * The current request's origin, or '' when there is no ambient request. Never
314
+ * throws: an eviction helper must not take down the mutation that called it.
315
+ *
316
+ * @returns {string}
317
+ */
318
+ function ambientOrigin() {
319
+ try {
320
+ const req = getRequest();
321
+ return req ? new URL(req.url).origin : '';
322
+ } catch {
323
+ return '';
324
+ }
325
+ }
326
+
327
+ /**
328
+ * Paths already warned about an unresolvable bare-path eviction, so the warning
329
+ * fires once per offending path rather than once per invocation.
330
+ * @type {Set<string>}
331
+ */
332
+ const _warnedUnresolvedPaths = new Set();
333
+
334
+ /** @param {string} path */
335
+ function warnUnresolvedOriginOnce(path) {
336
+ if (_warnedUnresolvedPaths.has(path)) return;
337
+ _warnedUnresolvedPaths.add(path);
338
+ console.warn(
339
+ `[webjs] revalidatePath(${JSON.stringify(path)}) evicted nothing: HTML cache ` +
340
+ `keys carry the request origin, and this call has no ambient request to take ` +
341
+ `one from (a cron job, a queue worker, a boot-time timer). Pass an absolute ` +
342
+ `url instead, e.g. revalidatePath('https://your-host${path.startsWith('/') ? path : '/' + path}'), ` +
343
+ `or call it from a server action, where the triggering request supplies the origin.`
344
+ );
345
+ }
346
+
243
347
  /**
244
348
  * Response-funnel step (#241): if the response carries the HTML_CACHE_MARKER
245
349
  * (the SSR opted it into caching), re-check every guard against the FINAL
@@ -154,7 +154,7 @@ export function startBunListener(ctx) {
154
154
  const shutdown = makeShutdown({ closeServer, hub, logger });
155
155
  process.once('SIGINT', () => shutdown('SIGINT'));
156
156
  process.once('SIGTERM', () => shutdown('SIGTERM'));
157
- installProcessHandlers(logger, () => shutdown('uncaughtException'));
157
+ installProcessHandlers(logger, () => shutdown('uncaughtException', { fatal: true }));
158
158
 
159
159
  return {
160
160
  server,
@@ -383,7 +383,7 @@ export class SseHub {
383
383
  * Install once-only process error handlers. Idempotent across multiple
384
384
  * `startServer` calls in the same process. Runtime-neutral (plain `process.on`).
385
385
  * @param {import('./logger.js').Logger} logger
386
- * @param {() => void} onFatal
386
+ * @param {() => void} onFatal Starts the FATAL shutdown, which exits non-zero.
387
387
  */
388
388
  export function installProcessHandlers(logger, onFatal) {
389
389
  if (/** @type any */ (globalThis).__webjsProcHandlers) return;
@@ -405,12 +405,37 @@ export function installProcessHandlers(logger, onFatal) {
405
405
  * `closeServer()` thunk that resolves once the server has stopped accepting
406
406
  * connections (node `server.close`, Bun `server.stop(true)`). Closes the SSE hub,
407
407
  * then drains, then exits; hard-exits after 10s if the drain hangs.
408
+ *
409
+ * Exit code, in full. Three things can force a 1, and only their absence
410
+ * leaves a 0:
411
+ * - a drain that FAILS, so a rejected `closeServer()` and a drain still
412
+ * hanging at the 10s deadline both exit 1 whatever started them;
413
+ * - a FATAL start (the `uncaughtException` handler calling `onFatal`), which
414
+ * exits 1 even when the drain is perfectly clean;
415
+ * - a FATAL arriving MID-DRAIN, which upgrades a shutdown an operator signal
416
+ * had already begun (see the upgrade-only note on `code` below).
417
+ * So 0 does NOT mean "a signal started it", it means an operator asked for the
418
+ * stop AND it drained cleanly AND nothing crashed on the way out. A supervisor
419
+ * reads only this code (systemd `Restart=on-failure`, Docker, Railway, and a CI
420
+ * step running a `test/bun/*.mjs` proof script, whose failed top-level assertion
421
+ * arrives here as an uncaught exception, #1092), so every one of those has to
422
+ * come back non-zero.
408
423
  * @param {{ closeServer: () => Promise<unknown>, hub: SseHub, logger: import('./logger.js').Logger }} opts
409
- * @returns {(signal: string) => void}
424
+ * @returns {(signal: string, opts?: { fatal?: boolean }) => void}
410
425
  */
411
426
  export function makeShutdown({ closeServer, hub, logger }) {
412
427
  let shuttingDown = false;
413
- return (signal) => {
428
+ // Read at EXIT time, not at entry, so a fatal arriving mid-drain still counts.
429
+ // The drain window is up to 10s wide and in-flight requests are still running
430
+ // in it, so an uncaught exception landing there is an ordinary event, not an
431
+ // exotic race. Because the re-entrancy guard below drops that second call, a
432
+ // code captured at entry would stay 0 and the crashed process would report a
433
+ // successful stop, which is the exact failure this whole mechanism exists to
434
+ // prevent. Upgrade only (1 never falls back to 0), so a signal arriving during
435
+ // a fatal shutdown cannot launder the crash into a success either.
436
+ let code = 0;
437
+ return (signal, { fatal = false } = {}) => {
438
+ if (fatal) code = 1;
414
439
  if (shuttingDown) return;
415
440
  shuttingDown = true;
416
441
  logger.info(`received ${signal}, shutting down`);
@@ -420,11 +445,16 @@ export function makeShutdown({ closeServer, hub, logger }) {
420
445
  process.exit(1);
421
446
  }, 10_000);
422
447
  if (typeof hard.unref === 'function') hard.unref();
448
+ // Disarm before exiting. In production `process.exit` ends everything so this
449
+ // is redundant, but it keeps the deadline from outliving the decision it was
450
+ // guarding, which is what let a stubbed-exit test leave a live timer armed to
451
+ // kill an unrelated process 10s later.
452
+ const done = (c) => { clearTimeout(hard); process.exit(c); };
423
453
  Promise.resolve()
424
454
  .then(closeServer)
425
455
  .then(
426
- () => { logger.info('bye'); process.exit(0); },
427
- (err) => { logger.error('server close error', { err: String(err) }); process.exit(1); },
456
+ () => { logger.info('bye'); done(code); },
457
+ (err) => { logger.error('server close error', { err: String(err) }); done(1); },
428
458
  );
429
459
  };
430
460
  }
package/src/rate-limit.js CHANGED
@@ -23,6 +23,14 @@
23
23
  */
24
24
 
25
25
  import { getStore } from './cache.js';
26
+ // Aliased on purpose. `rateLimit()` binds a local `const trustProxy` from its
27
+ // own options, which would shadow a same-named import inside that function and
28
+ // leave the middleware path on the pre-override behaviour while the direct
29
+ // `clientIp` path changed. `forwarded.js` imports nothing, so this adds no cycle.
30
+ import { trustProxy as proxyIsTrusted } from './forwarded.js';
31
+
32
+ /** Module-scope latch so the override warns once per process, never per request. */
33
+ let warnedProxyOverride = false;
26
34
 
27
35
  /**
28
36
  * @param {{
@@ -126,8 +134,8 @@ function trustedRemoteIp(req) {
126
134
 
127
135
  /**
128
136
  * Carry the trusted remote IP from `src` to a freshly-rebuilt `dst` Request
129
- * (#756). When the framework re-wraps a request (the page-action body rebuild in
130
- * `page-action.js`), the out-of-band WeakMap key is a new object, so the trusted
137
+ * (#756). When the framework re-wraps a request (the form-submission body rebuild
138
+ * in `form-dispatch.js`), the out-of-band WeakMap key is a new object, so the trusted
131
139
  * IP would be lost and `clientIp(dst)` would fall back to the (spoofable) header
132
140
  * the rebuild copied over. Propagating the trusted value via the WeakMap keeps
133
141
  * `dst` authoritative on BOTH runtimes (it reads `trustedRemoteIp(src)`, which is
@@ -167,12 +175,32 @@ export function propagateTrustedRemoteIp(src, dst) {
167
175
  * trust-proxy reintroduces the spoofability this option exists to
168
176
  * defend against.
169
177
  *
178
+ * `WEBJS_NO_TRUST_PROXY=1` OVERRIDES `trustProxy: true` (#1254). The env
179
+ * switch is the operator's statement about the deployment topology and it can
180
+ * only ever SUBTRACT trust, so when it is set this call falls back to the
181
+ * default path above: the framework-stamped peer, or `_anon_` when there is
182
+ * none, and never a forwarded-IP header. The override logs once per process.
183
+ * An app that genuinely sits behind a trusted proxy must unset the flag; the
184
+ * two settings answer the same question about one socket and one topology, so
185
+ * leaving them in disagreement buckets every visitor behind that proxy onto
186
+ * one key.
187
+ *
170
188
  * @param {Request} req
171
189
  * @param {{ trustProxy?: boolean }} [opts]
172
190
  * @returns {string}
173
191
  */
174
192
  export function clientIp(req, opts = {}) {
175
- if (opts.trustProxy === true) {
193
+ if (opts.trustProxy === true && !proxyIsTrusted() && !warnedProxyOverride) {
194
+ warnedProxyOverride = true;
195
+ // No request data in this line, ever. It must not become a log of
196
+ // client-supplied header values.
197
+ console.warn(
198
+ '[webjs] WEBJS_NO_TRUST_PROXY=1 overrides trustProxy: true; ' +
199
+ 'forwarded-IP headers are ignored and the framework-stamped peer is used. ' +
200
+ 'Unset the env var if a trusted proxy really is in front of this process.',
201
+ );
202
+ }
203
+ if (opts.trustProxy === true && proxyIsTrusted()) {
176
204
  return (
177
205
  req.headers.get('x-forwarded-for')?.split(',')[0].trim() ||
178
206
  req.headers.get('cf-connecting-ip') ||
@@ -11,7 +11,7 @@
11
11
  * Design choices:
12
12
  * - PAGES ONLY. A `route.{js,ts}` handler is an API endpoint, not a
13
13
  * navigable HTML page, so its path is excluded from the navigable `Route`
14
- * union. Pages (including page-action pages) are what a valid href points
14
+ * union. Pages (including ones hosting a bound form) are what a valid href points
15
15
  * at.
16
16
  * - The route KEY is the literal pattern (`/blog/[slug]`), derived from the
17
17
  * page's directory with route groups `(group)` stripped and `_private`
package/src/ssr.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { pathToFileURL, fileURLToPath } from 'node:url';
2
2
  import { resolve } from 'node:path';
3
3
  import { renderToString, isNotFound, isRedirect, isForbidden, isUnauthorized, lookupModuleUrl, isLazy, cspNonce } from '@webjsdev/core';
4
- import { importMapTag, vendorIntegrityFor, publishedBuildId, appSourceId, basePath, vendorPreconnectOrigins, vendorPreloadTargets } from './importmap.js';
4
+ import { importMapTag, vendorIntegrityFor, publishedBuildId, appSourceId, basePath, vendorPreconnectOrigins, vendorPreloadTargets, buildImportMap } from './importmap.js';
5
5
  import { withBasePath } from './base-path.js';
6
6
  import { withAssetHash } from './asset-hash.js';
7
7
  import { jsonForScriptTag } from './script-tag-json.js';
@@ -41,7 +41,7 @@ export async function ssrPage(route, params, url, opts) {
41
41
  // and is threaded back through `opts.pageModule` so renderChain reuses the
42
42
  // same evaluation (no double-load). A cache HIT serves the stored HTML
43
43
  // without re-running the page function. Skipped entirely (no opt-in read,
44
- // no double behaviour) for the page-action re-render (actionData / a non-200
44
+ // no double behaviour) for the form-action re-render (actionData / a non-200
45
45
  // status) and for a partial-nav request (X-Webjs-Have), whose bytes depend
46
46
  // on the request and must not be shared under the full-URL key.
47
47
  const cacheEligible =
@@ -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
  }
@@ -292,7 +297,7 @@ export async function ssrPage(route, params, url, opts) {
292
297
  outBody,
293
298
  closer,
294
299
  suspenseCtx,
295
- // Normally 200. After a failed page `action` submission the caller passes
300
+ // Normally 200. After a failed form-action submission the caller passes
296
301
  // 422 (or another 4xx) so the re-rendered page with field errors carries
297
302
  // the right status for both the no-JS reload and the enhanced swap (#244).
298
303
  opts.status || 200,
@@ -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,
@@ -331,7 +346,7 @@ export async function ssrPage(route, params, url, opts) {
331
346
  // navigation (an auth bounce, a gate). 302 Found is the conventional
332
347
  // code there, so it is the default when the caller did not pick one. An
333
348
  // explicit `redirect(url, status)` overrides it. (Action redirects, a
334
- // POST, default to 307 in page-action.js so the method is preserved.)
349
+ // POST, default to 307 in form-dispatch.js so the method is preserved.)
335
350
  return new Response(null, { status: e.status || 302, headers: { location: e.url } });
336
351
  }
337
352
  if (isNotFound(err)) {
@@ -444,7 +459,7 @@ export async function ssrNotFound(notFoundFile, opts) {
444
459
  /**
445
460
  * 403 response for a thrown forbidden() (#848). Renders the nearest
446
461
  * forbidden.{js,ts} in the page's chain, else a default 403 page. Shared by the
447
- * page-render catch (ssr.js) and the page-action write path (page-action.js) so
462
+ * page-render catch (ssr.js) and the form-action write path (form-dispatch.js) so
448
463
  * both behave identically.
449
464
  * @param {{ forbiddens?: string[] }} route
450
465
  * @param {{ dev: boolean, appDir: string, req?: Request, url?: URL }} opts
@@ -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
  }
@@ -580,10 +674,13 @@ async function ssrNotFoundHtml(notFoundFile, opts) {
580
674
  }
581
675
 
582
676
  async function renderChain(route, ctx, dev, suspenseCtx, have, pageModule) {
583
- // Reuse a caller-supplied page module when present (the page-action
584
- // re-render passes the exact module whose `action` just ran, so the
585
- // failure re-render shares that single evaluation instead of re-importing
586
- // and re-running the module's top-level side effects).
677
+ // Reuse a caller-supplied page module when present. The only producer is the
678
+ // HTML-cache path above, which loads the module to read `export const
679
+ // revalidate` and threads it back so this does not load it a second time.
680
+ // (The removed page `action` export used to be a second producer: it ran in
681
+ // the page module, so the 422 re-render could share that evaluation. A form
682
+ // action lives in a `.server.*` module instead, so the re-render loads the
683
+ // page module itself, exactly as a plain render does.)
587
684
  const page = pageModule || await loadModule(route.file, dev);
588
685
  if (!page.default) throw new Error(`Page ${route.file} must have a default export`);
589
686
  let tree = await page.default(ctx);
@@ -650,8 +747,10 @@ async function renderChain(route, ctx, dev, suspenseCtx, have, pageModule) {
650
747
  const body = await renderToString(tree, { ssr: true, suspenseCtx });
651
748
  // REDUCED response (#1009): the outer layouts were skipped, so these
652
749
  // 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.
750
+ // marks the response `private` so no shared cache can store it, and
751
+ // additionally `Vary: X-Webjs-Have` for caches that honour it. The
752
+ // `private` is the guarantee, not the Vary: Cloudflare and others honour
753
+ // only `Accept-Encoding` (#1140).
655
754
  return { html: body + (await loadingTemplates(route, ctx, dev)), reduced: true };
656
755
  }
657
756
  const mod = await loadModule(route.layouts[i], dev);
@@ -1561,6 +1660,36 @@ function wrapHead(opts) {
1561
1660
  // importmap-rails) applies nonce on every modulepreload tag for
1562
1661
  // the same reason.
1563
1662
  const noncePreload = opts.nonce ? ` nonce="${escapeAttr(opts.nonce)}"` : '';
1663
+ // Core runtime modulepreload (#1118). Every module the boot imports pulls
1664
+ // `@webjsdev/core`, but the boot script names only page and component URLs,
1665
+ // so without this hint the browser discovers core only after one of those is
1666
+ // fetched AND parsed: one full round trip into the load, which is exactly
1667
+ // where the pre-boot click window lives on a cold, throttled connection.
1668
+ //
1669
+ // The href comes STRAIGHT from the importmap target (no `fp()` rewrite: the
1670
+ // map's targets are already base-path-prefixed and content-hashed), so it is
1671
+ // byte-identical to what the import resolves to. A differing href makes the
1672
+ // browser treat the preload and the import as two resources and fetch core
1673
+ // twice. That is why this copies the vendor loop below, not the module loop
1674
+ // above. `vendorPreloadTargets` still excludes core deliberately; this hint
1675
+ // is emitted from the head builder, not the vendor path.
1676
+ //
1677
+ // Gated on the boot actually shipping something. A fully elided page ships no
1678
+ // boot module and must not be handed a preload for a runtime it never loads
1679
+ // (#780), which also keeps it off `global-error.{js,ts}`, whose document is
1680
+ // returned verbatim with no importmap and no boot script.
1681
+ if (opts.moduleUrls.length || lazyEntries) {
1682
+ const coreMap = buildImportMap();
1683
+ const coreHref = coreMap.imports['@webjsdev/core'];
1684
+ if (coreHref) {
1685
+ const raw = coreMap.integrity ? coreMap.integrity[coreHref] : undefined;
1686
+ const coreIntegrity = raw ? ` integrity="${escapeAttr(raw)}"` : '';
1687
+ linkTags.push(
1688
+ `<link rel="modulepreload" href="${escapeAttr(coreHref)}"` +
1689
+ `${preloadCrossOriginAttr(coreHref)}${coreIntegrity}${noncePreload}>`,
1690
+ );
1691
+ }
1692
+ }
1564
1693
  // Sub-path deployment (issue #256): the modulepreload href is prefixed with
1565
1694
  // the base path (a no-op when empty), but `crossorigin` / `integrity` are
1566
1695
  // decided on the ORIGINAL url, so the integrity lookup still keys on the
@@ -1943,7 +2072,7 @@ function reachedVendorSpecifiers(graph, entryFiles, componentUrls, appDir, elida
1943
2072
  * @param {string} prefix
1944
2073
  * @param {string} bodyHtml
1945
2074
  * @param {string} closer
1946
- * @param {{ pending: {id: string, promise: Promise<unknown>}[], nextId: number }} ctx
2075
+ * @param {{ pending: {id: string, promise: Promise<unknown>, formScope?: 'none'|'unbound'|'bound'|'unknown'}[], nextId: number }} ctx
1947
2076
  * @param {number} status
1948
2077
  * @param {Request | undefined} req
1949
2078
  * @param {URL | undefined} url
@@ -1955,8 +2084,11 @@ function streamingHtmlResponse(prefix, bodyHtml, closer, ctx, status, req, url,
1955
2084
  const encoder = new TextEncoder();
1956
2085
  const headers = new Headers({ 'content-type': 'text/html; charset=utf-8' });
1957
2086
  // 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');
2087
+ // opts in to caching explicitly via metadata.cacheControl. A non-200 does
2088
+ // NOT inherit it (#1140): the form-action re-render is a 422 carrying the
2089
+ // submitter's own field values and errors, which must never be handed to a
2090
+ // shared cache just because the page opted into public caching.
2091
+ headers.set('cache-control', status === 200 ? (metadata?.cacheControl || 'no-store') : 'no-store');
1960
2092
  // See htmlResponse: published build id on every response for the
1961
2093
  // client router's importmap-mismatch detection on partial swaps.
1962
2094
  headers.set('x-webjs-build', publishedBuildId());
@@ -1996,7 +2128,15 @@ function streamingHtmlResponse(prefix, bodyHtml, closer, ctx, status, req, url,
1996
2128
  try {
1997
2129
  const resolved = await p.promise;
1998
2130
  const sub = { pending: [], nextId: ctx.nextId, dev: ctx.dev };
1999
- const html = await renderToString(resolved, { ssr: true, suspenseCtx: sub });
2131
+ // Carry the boundary's form scope (#1207). This is a fresh scan
2132
+ // that cannot see the shell the boundary sits in, so without it
2133
+ // a `<button formaction=${fn}>` inside a bound form's boundary
2134
+ // reads as form-less and is refused, and the catch below turns
2135
+ // that into an empty boundary on a 200 in production. 'unknown'
2136
+ // when the entry predates the field: defer rather than refuse.
2137
+ const html = await renderToString(resolved, {
2138
+ ssr: true, suspenseCtx: sub, formScope: p.formScope || 'unknown',
2139
+ });
2000
2140
  ctx.nextId = sub.nextId;
2001
2141
  for (const n of sub.pending) ctx.pending.push(n);
2002
2142
  return { id: p.id, html };
@@ -2042,13 +2182,12 @@ function streamingHtmlResponse(prefix, bodyHtml, closer, ctx, status, req, url,
2042
2182
  * serves a single evaluation; in dev a cache-bust query forces a fresh
2043
2183
  * evaluation so source edits take effect (which also re-runs the module's
2044
2184
  * top-level side effects, the reason pages/layouts must keep their top level
2045
- * side-effect-free). Exported so page-action.js loads the page module the same
2046
- * way the SSR re-render does.
2185
+ * side-effect-free).
2047
2186
  *
2048
2187
  * @param {string} file
2049
2188
  * @param {boolean} dev
2050
2189
  */
2051
- export async function loadModule(file, dev) {
2190
+ async function loadModule(file, dev) {
2052
2191
  const url = pathToFileURL(file).toString();
2053
2192
  const bust = dev ? `?t=${Date.now()}-${Math.random().toString(36).slice(2)}` : '';
2054
2193
  return import(url + bust);