diff --git a/.agents/skills/webjs/references/built-ins.md b/.agents/skills/webjs/references/built-ins.md index 6e1a8a9e5..e554513b8 100644 --- a/.agents/skills/webjs/references/built-ins.md +++ b/.agents/skills/webjs/references/built-ins.md @@ -82,7 +82,7 @@ export const revalidate = 60; // cache this page's HTML for 60s ### Content-hash asset URLs and conditional GET -Both are automatic, prod-focused, and need no config. In production every served module and `public/` asset gets a per-file `?v=` and `Cache-Control: public, max-age=31536000, immutable`, so a returning client fetches a changed file only when its bytes change. Every cacheable response also carries a weak `ETag`, and a repeat request with a matching `If-None-Match` gets a `304 Not Modified` with no body. Private (`no-store` / `private`) and streamed responses are excluded from the ETag path (no cross-session 304). Dev is byte-faithful (no hashing). +Both are automatic, prod-focused, and need no config. In production every served module and `public/` asset gets a per-file `?v=` and `Cache-Control: public, max-age=31536000, immutable`, so a returning client fetches a changed file only when its bytes change. Every cacheable response also carries a weak `ETag`, and a repeat request with a matching `If-None-Match` gets a `304 Not Modified` with no body. Unstorable (`no-store`) and streamed responses are excluded from the ETag path. A `private` response IS validated: `private` forbids SHARED storage, not validation, and the ETag hashes that response's own body, so two users with different bodies get different ETags and neither can match the other's, while two users with identical bodies are asking about identical bytes, where a 304 discloses nothing (#1140). That is what keeps the client router's partial responses cheap on a page that opted into caching; a default `no-store` page has nothing to validate either way. Dev is byte-faithful (no hashing). **A page's ETag is only useful if the page renders the same bytes twice.** The ETag is a hash of the response body, so any per-render-varying value anywhere in the document defeats it: a `Date.now()`, a `Math.random()`, an id from a module-scope counter (which never resets in a long-lived server), or a CSP nonce. The failure is silent and total. The page renders correctly, every content assertion still passes, the header is still present, and the only symptom is that no `If-None-Match` ever matches, so every revalidation ships the whole document instead of an empty 304. A page under CSP is excluded from the server HTML cache for exactly this reason (the nonce must differ per response). If a page opts into a public `Cache-Control`, guard it with a test that renders the page twice, through its layout, and asserts the two outputs are byte-identical. diff --git a/.agents/skills/webjs/references/client-router-and-streaming.md b/.agents/skills/webjs/references/client-router-and-streaming.md index 209adc979..d2107b9c6 100644 --- a/.agents/skills/webjs/references/client-router-and-streaming.md +++ b/.agents/skills/webjs/references/client-router-and-streaming.md @@ -78,7 +78,7 @@ document.addEventListener('webjs:navigation-fallback', (e) => { ## Link Prefetch -Same-origin in-app links prefetch speculatively so a click resolves from a warm cache. Router fetches (navigation and prefetch alike) are sent with `cache: 'no-cache'`, so a page cached in the browser with a `max-age` is revalidated rather than replayed: the deploy check reads `x-webjs-build` / `x-webjs-src` off these responses, and a cached response would hand it pre-deploy ids and hide a deploy for the whole freshness window (#1131). With a stable page ETag the revalidation is answered with a cheap 304, so the cost is a conditional round-trip, not a re-download. On by default, no per-link opt-in needed. The default strategy is DEVICE-ADAPTIVE, because one strategy cannot serve both input modalities. On a hover-capable fine pointer the default is `intent` (warm on hover/focus after a ~100ms dwell). On touch the default is `viewport` (warm as links settle on-screen), because touch has no hover. Modality is detected with `matchMedia('(hover: hover) and (pointer: fine)')`, never a UA sniff. +Same-origin in-app links prefetch speculatively so a click resolves from a warm cache. A reduced fragment is served `private`, so no shared cache can store it even if the CDN ignores `Vary` (Cloudflare honours only `Accept-Encoding`); the `Vary: X-Webjs-Have` marking stays as belt-and-braces rather than as the guarantee (#1140). A full document keeps whatever `metadata.cacheControl` declared, so page-level edge caching is unaffected. Router fetches (navigation and prefetch alike) are sent with `cache: 'no-cache'`, so a page cached in the browser with a `max-age` is revalidated rather than replayed: the deploy check reads `x-webjs-build` / `x-webjs-src` off these responses, and a cached response would hand it pre-deploy ids and hide a deploy for the whole freshness window (#1131). The revalidation is answered with a cheap 304, so the cost is a conditional round-trip rather than a re-download: on a page that opted into caching a fragment is `private` but still carries a validator, since `private` forbids only SHARED storage and has no bearing on whether a response can be validated (#1140); a default `no-store` page has nothing to validate either way. On by default, no per-link opt-in needed. The default strategy is DEVICE-ADAPTIVE, because one strategy cannot serve both input modalities. On a hover-capable fine pointer the default is `intent` (warm on hover/focus after a ~100ms dwell). On touch the default is `viewport` (warm as links settle on-screen), because touch has no hover. Modality is detected with `matchMedia('(hover: hover) and (pointer: fine)')`, never a UA sniff. Override per link with the `data-prefetch` attribute. diff --git a/.agents/skills/webjs/references/routing-and-pages.md b/.agents/skills/webjs/references/routing-and-pages.md index ea7103969..31da64858 100644 --- a/.agents/skills/webjs/references/routing-and-pages.md +++ b/.agents/skills/webjs/references/routing-and-pages.md @@ -110,7 +110,7 @@ export async function generateMetadata(ctx: MetadataContext): Promise } ``` -Common fields: `title` (string or `{ template, default, absolute }`), `description`, `keywords`, `metadataBase` (resolves relative URLs in `openGraph` / `twitter` / `alternates` / `icons`), `openGraph`, `twitter`, `robots`, `alternates.canonical`, `icons`, `manifest`, and `jsonLd` (schema.org structured data, single object or array, HTML-safe-escaped automatically). `viewport`, `themeColor`, and `colorScheme` may also be set via a split `export const viewport = { ... }`. `cacheControl` is emitted as a response HEADER (not a ``); pages default to `no-store`, and a `public` value enables conditional GET (a weak `ETag` + `304`). See https://webjs.dev/docs for the full field list. +Common fields: `title` (string or `{ template, default, absolute }`), `description`, `keywords`, `metadataBase` (resolves relative URLs in `openGraph` / `twitter` / `alternates` / `icons`), `openGraph`, `twitter`, `robots`, `alternates.canonical`, `icons`, `manifest`, and `jsonLd` (schema.org structured data, single object or array, HTML-safe-escaped automatically). `viewport`, `themeColor`, and `colorScheme` may also be set via a split `export const viewport = { ... }`. `cacheControl` is emitted as a response HEADER (not a ``); pages default to `no-store`, and any other value enables conditional GET (a weak `ETag` + `304`), `private` included. See https://webjs.dev/docs for the full field list. ## Control-flow throws diff --git a/AGENTS.md b/AGENTS.md index 771f85d22..5991b97d8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -345,7 +345,7 @@ type ActionResult = ## Client navigation: automatic, nothing to opt into -The router auto-enables when `@webjsdev/core` loads (any page with a component), so there is nothing to opt INTO. An app that wants plain full-page (MPA) navigation can opt OUT app-wide with `{ "webjs": { "clientRouter": false } }` (#629), or per-moment at runtime with `disableClientRouter()`. SSR auto-emits KEYED boundary comment pairs around each layout's children AND the page itself (open ``, close ``; the route-key is the resolved path with param values percent-encoded, #1015). The router strictly scans both DOMs (any truncated, mispaired, or duplicated boundary poisons the scan) and applies a two-tier swap with Next.js remount parity: a changed route-key REPLACES (remounts) at the PARENT of the shallowest changed boundary (the range that contains the changed layout's own markup, exact Next remount scope), an unchanged one MORPHS the deepest shared boundary in place (a searchParams-only nav preserves hydrated component state). A poisoned or disjoint scan degrades to a full page load, never a guessed recovery, so silent DOM corruption is structurally impossible; outer-layout DOM identity is preserved on every soft path. **Every degradation dispatches `webjs:navigation-fallback` on `document` in ALL environments** (detail `{ cause, href, willReload }`, not cancelable), so a full page load on a click is observable in production rather than silent (#1114). Form submissions ride the same pipeline (`data-no-router` opts out). Wire bytes are minimized via the `X-Webjs-Have` header (`segment:route-key` entries, so a dynamic layout held for OTHER params is re-rendered rather than short-circuited; the server returns only the divergent fragment, marked `Vary: X-Webjs-Have` so a shared cache can never serve the reduced body to a full-page navigation); scroll is restored on back/forward. **The link-prefetch cache is ANCHOR-VALIDATED** (#1114): a reduced fragment begins at the boundary the server short-circuited on, and on consume the router checks that boundary is still live with the same route-key. A root-anchored fragment therefore survives an unrelated navigation (still a cache hit), while one anchored deeper is discarded once that layout is gone, because applying it would share no boundary with the live DOM and force a full page load. The router also never prefetches the page it is already on (#1106): that request can never serve a later navigation and only occupies a capped cache slot. A non-GET `
` whose target page exports an `action` is the no-JS write-path (with JS the router applies the response in place: a `422` swaps without reload, a `303` is followed via fetch). A failed navigation recovers in place (a cancelable `webjs:navigation-error` event, else a minimal in-place alert), never a destructive full reload. +The router auto-enables when `@webjsdev/core` loads (any page with a component), so there is nothing to opt INTO. An app that wants plain full-page (MPA) navigation can opt OUT app-wide with `{ "webjs": { "clientRouter": false } }` (#629), or per-moment at runtime with `disableClientRouter()`. SSR auto-emits KEYED boundary comment pairs around each layout's children AND the page itself (open ``, close ``; the route-key is the resolved path with param values percent-encoded, #1015). The router strictly scans both DOMs (any truncated, mispaired, or duplicated boundary poisons the scan) and applies a two-tier swap with Next.js remount parity: a changed route-key REPLACES (remounts) at the PARENT of the shallowest changed boundary (the range that contains the changed layout's own markup, exact Next remount scope), an unchanged one MORPHS the deepest shared boundary in place (a searchParams-only nav preserves hydrated component state). A poisoned or disjoint scan degrades to a full page load, never a guessed recovery, so silent DOM corruption is structurally impossible; outer-layout DOM identity is preserved on every soft path. **Every degradation dispatches `webjs:navigation-fallback` on `document` in ALL environments** (detail `{ cause, href, willReload }`, not cancelable), so a full page load on a click is observable in production rather than silent (#1114). Form submissions ride the same pipeline (`data-no-router` opts out). Wire bytes are minimized via the `X-Webjs-Have` header (`segment:route-key` entries, so a dynamic layout held for OTHER params is re-rendered rather than short-circuited; the server returns only the divergent fragment, served `private` so a shared cache can never store the reduced body and serve it to a full-page navigation, and additionally marked `Vary: X-Webjs-Have` for caches that honour it (#1140; `Vary` alone was not enough, since Cloudflare honours only `Accept-Encoding`)); scroll is restored on back/forward. **The link-prefetch cache is ANCHOR-VALIDATED** (#1114): a reduced fragment begins at the boundary the server short-circuited on, and on consume the router checks that boundary is still live with the same route-key. A root-anchored fragment therefore survives an unrelated navigation (still a cache hit), while one anchored deeper is discarded once that layout is gone, because applying it would share no boundary with the live DOM and force a full page load. The router also never prefetches the page it is already on (#1106): that request can never serve a later navigation and only occupies a capped cache slot. A non-GET `` whose target page exports an `action` is the no-JS write-path (with JS the router applies the response in place: a `422` swaps without reload, a `303` is followed via fetch). A failed navigation recovers in place (a cancelable `webjs:navigation-error` event, else a minimal in-place alert), never a destructive full reload. The advanced client-router surface is in `references/client-router-and-streaming.md`: **link prefetch** (on by default, device-adaptive default: `intent` on a hover pointer, `viewport` (dwell-gated, cancel-on-scroll-out) on touch, per-link `data-prefetch` override), **``** partial-swap regions, **View Transitions** (opt-in via ``, plus `data-webjs-permanent` to persist a live element), **stream actions** (`` element-level updates, #248), and the **opt-in nav-loading indicator** (`` exposes a `data-navigating` attribute during a nav so you can style a CSS-only progress affordance, off by default because toggling a root attribute re-resolves `oklch()` tokens to a one-frame repaint flash on iOS WebKit, #610). Production benefits from HTTP/2 at the edge; `npm run start` speaks plain HTTP/1.1 (put a reverse proxy in front for TLS + HTTP/2). diff --git a/blog/server-side-caching-explained.md b/blog/server-side-caching-explained.md index 4c6c73e57..5449e423e 100644 --- a/blog/server-side-caching-explained.md +++ b/blog/server-side-caching-explained.md @@ -105,7 +105,7 @@ An ETag is a short fingerprint of a response, like a version stamp. WebJs puts o This is on by default and needs no code. It covers cacheable SSR pages, static assets in `public/`, your app's source modules, and the core and vendor runtime uniformly. The ETag is the hash of the response body, so an identical body always produces an identical ETag. -Crucially, it is careful about privacy. A `no-store` page (the default for dynamic and per-user pages) and any `private` response get no ETag and never 304, because a shared cache keyed on the URL could otherwise replay one user's validator to another. The same private-content instinct as the `revalidate` rule, applied one layer down. +Crucially, it distinguishes what cannot be stored from what merely cannot be SHARED. A `no-store` page (the default for dynamic and per-user pages) gets no ETag and never 304s, because `no-store` forbids storage outright and so leaves nothing to validate. A `private` response is validated normally: `private` forbids only SHARED storage, and the ETag hashes that response's own body: two users with different bodies get different ETags and neither can match the other's, while two users with identical bodies are asking about identical bytes, where a 304 discloses nothing. The same storability instinct as the `revalidate` rule, applied one layer down. # Layer 5: content-hash asset URLs diff --git a/packages/server/AGENTS.md b/packages/server/AGENTS.md index fe320fa07..384951e57 100644 --- a/packages/server/AGENTS.md +++ b/packages/server/AGENTS.md @@ -33,7 +33,7 @@ with metadata, Suspense, streaming) for HTML, or `api.js` / | `dev.js` | The request handler. File serving, TypeScript stripping (Node 24+ built-in `module.stripTypeScriptTypes`, backed by the `amaro` package; non-erasable syntax fails at strip time with a 500), **server-file guardrail**, live reload via SSE. Also the observability seam (#239): `handle()` mints / honors the per-request id (`X-Request-Id` + `setRequestId`), emits the one-line structured access log via `logger.info` after the response (suppressing `/__webjs/*` probe traffic), and routes unhandled errors to the app's `onError` sink (best-effort, threaded into the SSR error path, the action endpoint, middleware, metadata, and the top-level catch); applies conditional GET (#240, via `applyConditionalGet`) as the final funnel step so every cacheable response gets an ETag + honors If-None-Match -> 304; commits the server HTML cache (#241, via `commitHtmlCache`) just before conditional-GET so the store decision sees the final post-middleware response (threads `cspEnabled` into the page `ssrOpts` so a CSP page is never HTML-cached); `produce()` answers the `/__webjs/version` build-info probe. **Root middleware resolution:** `loadMiddleware` tries `middleware.ts` / `.js` / `.mts` / `.mjs` beside `appDir` in that order (`.ts` first, matching the dev supervisor's watch order). It used to look up the single literal `middleware.js`, so a root `middleware.ts`, what the scaffold writes and what the docs document, was silently never loaded: no error, no warning, indistinguishable from an app with no middleware. Tests: `test/dev/root-middleware-resolution.test.js` plus the cross-runtime `test/bun/root-middleware.mjs`. Dev error overlay (#264): `reportDevError(error, info)` builds a frame (via `dev-error.js`) and pushes it to the open tab over the SSE channel as a `webjs-error` event, fed by three sources (the SSR render catch via `ssrOpts.onDevError`, the `tsResponse` strip-failure, and the `rebuild` catch); a successful rebuild clears `state.lastDevError`; `startServer`'s SSE replays the current frame to a freshly-connected tab; `reloadClientJs` renders the dev-only plain-DOM overlay (`textContent` only). **Graceful reload (#893):** a reload never paints into a half-restarted server (Node's `node --watch` briefly kills the process on an app edit), so the client gates every reload on a `/__webjs/version` readiness probe (probe-then-reload, instant under an in-process reload, waits out a restart), and a reconnect after a drop is itself treated as an edit signal so an app edit whose in-process reload frame was killed with the old process still reloads without a manual refresh (the SSE `hello` carries a short `retry: 300` so the reconnect is prompt). The reconnect-reload lives in the `dev-reload-worker.js` relay (shared connection) and the per-tab `__webjsDirectEvents` fallback. **Extra watch roots (#894):** `startServer`'s recursive `fs.watch` also follows the dirs in `webjs.dev.watch` (`readDevWatchPathsFromApp`), for content the app reads from OUTSIDE its appDir (blog markdown in a repo-root `blog/`). Dev-only: `reportDevError` early-returns in prod and `/__webjs/reload.js` 404s. **Listener shell (#511):** `startServer` builds a shared `SseHub` + `ListenerContext`, then selects a shell by `serverRuntime()`: `startNodeListener` (in this file, the node:http path: `toWebRequest` -> `app.handle` -> `sendWebResponse`, 103 Early Hints, node WS via `attachWebSocket`, node:http timeouts) on Node, or the dynamically-imported `startBunListener` (`listener-bun.js`) on Bun. The SSE registry/fanout, the live-reload predicate, the WS module loader, and the lifecycle wiring live in `listener-core.js` so the two shells share them. `isCompressible` (used by `sendWebResponse`) also moved there | | `router.js` | Scans `app/` once, builds the route table, matches pages + APIs (`buildRouteTable`, `matchPage`, `matchApi`). Page order is set by `compareSpecificity` (#750): POSITIONAL specificity (per URL segment, static `0` < dynamic `1` < catch-all `2` via `segKind`, lexicographic on the kind arrays with shorter-prefix-first), so the catch-all kind is lowest AT ITS POSITION (a literal-prefixed catch-all like `docs/[[...slug]]` outranks an all-dynamic `[org]/[repo]`), NOT a global catch-all-last bucket, then a stable alphabetical `routeDir` tiebreak. This replaces the old coarse 3-bucket `dynScore` whose same-bucket ties resolved by fs-walk order (so `/[org]/[repo]` vs `/[user]/settings` could match the wrong page). `matchPage` returns the first pattern that matches in that deterministic order | | `route-types.js` | Route-types generator (#258). `generateRouteTypes(appDir)` reuses `buildRouteTable` to emit the `.d.ts` text that augments `@webjsdev/core` (the `WebjsRoutes` href union + `RouteParamMap` per-route params), backing `webjs types` and the dev-startup emit. Pages-only (a `route.{js,ts}` API path is not a navigable href); strips route groups, excludes `_private`; an optional catch-all `[[...x]]` emits both the without-segment and a normalized `[...x]` href key while keeping the doubled literal as the param-map key. Deterministic (sorted keys). Helpers `routeKeyFromDir` / `dynamicSegments` / `paramTypeForKey` / `webjsRoutesKeysForKey` are exported for unit tests | -| `ssr.js` | SSR pipeline: nested layouts, metadata → ``, Suspense streaming, error boundaries. `ssrPage` accepts `actionData` (put on `ctx.actionData` for the page + layouts) and `status` (default 200; the page-action re-render passes 422). Server HTML cache (#241): on a plain GET render it loads the page module once to read `export const revalidate`, serves a cache HIT via `cachedHtmlResponse` (re-minting the build id), and on a miss stamps the `HTML_CACHE_MARKER` so the funnel writes the final body. Skipped for the page-action re-render and partial-nav (`X-Webjs-Have`) requests. **Frame subtree render (#253):** after `renderChain`, when the request carries `x-webjs-frame: ` (a `` self-load or a click-driven frame nav) AND the render is non-streamed, it extracts the matching `` subtree from the rendered body via `frame-render.js` and returns ONLY that (byte-equivalent to the client's extraction from a full page, but far fewer bytes); an absent frame id falls through to the full page (the client's `webjs:frame-missing` handles it), and a request with no `x-webjs-frame` header is byte-identical to before. **Vendor modulepreload (#754):** `reachedVendorSpecifiers(graph, shippedEntryFiles, componentUrls, appDir, elidable, serverFiles)` collects the bare specifiers (`bareImports(graph)`) reached by the page's SHIPPED modules. Its walk ROOTS are the boot's actually-shipped set: the caller passes the absolute paths of `moduleUrls` (which already drops INERT page/layout modules and substitutes an IMPORT-ONLY page with its components) plus the rendered `componentUrls`, then it walks the non-elided transitive closure and collects each reached file's bare imports, skipping server files (the `.server.*` suffix AND the `serverFiles` action index). Because the roots are the shipped set, a vendor reached ONLY through a dropped module, a dropped page's SSR-only DIRECT vendor import OR its SSR-only RELATIVE HELPER's vendor, is never collected (pages/layouts are not importable, so nothing that ships reaches them), so there is no over-fetch. `vendorPreloadTargets` (importmap.js) maps that set to `{ href, integrity }`, and `wrapHead` emits one `` per target (no `fp()` rewrite, deduped against the app module/component preloads, with `crossorigin` + `integrity`), flattening the cross-origin CDN waterfall one level. Only reached + non-elided + pinned vendors are hinted. **App-module modulepreload shipped-roots (#780):** `deduplicatedPreloads` (the app-module + component preload walk) roots at the SAME shipped set as the vendor walk. `shippedRoots` (the absolute paths of `moduleUrls`) is computed once and passed to BOTH `deduplicatedPreloads` and `reachedVendorSpecifiers`, NOT the raw `[route.file, ...route.layouts]`. So a module reached ONLY through a dropped page/layout (its SSR-only DIRECT app import OR its SSR-only RELATIVE HELPER) gets no `modulepreload` hint, the app-module analog of the #754 vendor over-fetch. A module that also ships another way (a component shared with a live route, or one reached through an import-only page's substituted components) is still reached via a real shipped root, so its hint stays (no under-fetch). `seen = new Set(moduleUrls)` already excludes the shipped page/layout URLs themselves, so this closes the TRANSITIVE gap only | +| `ssr.js` | SSR pipeline: nested layouts, metadata → ``, Suspense streaming, error boundaries. `ssrPage` accepts `actionData` (put on `ctx.actionData` for the page + layouts) and `status` (default 200; the page-action re-render passes 422). Server HTML cache (#241): on a plain GET render it loads the page module once to read `export const revalidate`, serves a cache HIT via `cachedHtmlResponse` (re-minting the build id), and on a miss stamps the `HTML_CACHE_MARKER` so the funnel writes the final body. Skipped for the page-action re-render and partial-nav (`X-Webjs-Have`) requests. **Partial responses are never shared-cacheable (#1140):** a reduced `X-Webjs-Have` body is sliced by a request header, so `privateFragment(res)` rewrites its `Cache-Control` to `private` (stripping `public` / `s-maxage` / `proxy-revalidate`, and any qualified `private="field"`, which per RFC 9111 would leave the response shared-storable). `Vary: X-Webjs-Have` is still sent, but as belt-and-braces: it is not the guarantee, because Cloudflare and others honour only `Accept-Encoding`. The helper is quote-aware (a comma inside `no-cache="Set-Cookie,X-Foo"` is not a separator), fails CLOSED on an absent header (no `Cache-Control` is heuristically shared-storable), and is exported for unit testing (not re-exported from the package index, so it is not public API). The fragment KEEPS its ETag, which is why `conditional-get.js` no longer excludes `private`. A non-200 (the 422 page-action re-render, which carries the submitter's own field values) never inherits the page's `cacheControl`. **Frame subtree render (#253):** after `renderChain`, when the request carries `x-webjs-frame: ` (a `` self-load or a click-driven frame nav) AND the render is non-streamed, it extracts the matching `` subtree from the rendered body via `frame-render.js` and returns ONLY that (byte-equivalent to the client's extraction from a full page, but far fewer bytes); an absent frame id falls through to the full page (the client's `webjs:frame-missing` handles it), and a request with no `x-webjs-frame` header is byte-identical to before. **Vendor modulepreload (#754):** `reachedVendorSpecifiers(graph, shippedEntryFiles, componentUrls, appDir, elidable, serverFiles)` collects the bare specifiers (`bareImports(graph)`) reached by the page's SHIPPED modules. Its walk ROOTS are the boot's actually-shipped set: the caller passes the absolute paths of `moduleUrls` (which already drops INERT page/layout modules and substitutes an IMPORT-ONLY page with its components) plus the rendered `componentUrls`, then it walks the non-elided transitive closure and collects each reached file's bare imports, skipping server files (the `.server.*` suffix AND the `serverFiles` action index). Because the roots are the shipped set, a vendor reached ONLY through a dropped module, a dropped page's SSR-only DIRECT vendor import OR its SSR-only RELATIVE HELPER's vendor, is never collected (pages/layouts are not importable, so nothing that ships reaches them), so there is no over-fetch. `vendorPreloadTargets` (importmap.js) maps that set to `{ href, integrity }`, and `wrapHead` emits one `` per target (no `fp()` rewrite, deduped against the app module/component preloads, with `crossorigin` + `integrity`), flattening the cross-origin CDN waterfall one level. Only reached + non-elided + pinned vendors are hinted. **App-module modulepreload shipped-roots (#780):** `deduplicatedPreloads` (the app-module + component preload walk) roots at the SAME shipped set as the vendor walk. `shippedRoots` (the absolute paths of `moduleUrls`) is computed once and passed to BOTH `deduplicatedPreloads` and `reachedVendorSpecifiers`, NOT the raw `[route.file, ...route.layouts]`. So a module reached ONLY through a dropped page/layout (its SSR-only DIRECT app import OR its SSR-only RELATIVE HELPER) gets no `modulepreload` hint, the app-module analog of the #754 vendor over-fetch. A module that also ships another way (a component shared with a live route, or one reached through an import-only page's substituted components) is still reached via a real shipped root, so its hint stays (no under-fetch). `seen = new Set(moduleUrls)` already excludes the shipped page/layout URLs themselves, so this closes the TRANSITIVE gap only | | `dev-error.js` | Dev error overlay frame builder (#264). `buildDevErrorFrame(error, { kind, appDir, file?, line?, hint? })` returns a JSON-serializable frame (message, parsed `file`/`line`/`column`, a source `codeFrame`, an optional `hint`); `parseStackLocation(stack, appDir)` finds the first app frame (preferring non-`node_modules`, splitting off the dev loader's `?t=` cache-bust query); `readCodeFrame(file, line, column)` reads the source excerpt with a `>` line marker + a caret. PURE (the only side effect is a guarded source read) and DEV-ONLY by the caller's contract, so no path / source is built in prod | | `frame-render.js` | Server-side `` subtree extraction (#253). `requestedFrameId(req)` reads the `x-webjs-frame` header (null when absent, the normal full-page path); `extractFrameSubtree(html, id)` returns the `...` slice from rendered HTML verbatim (so byte-equivalent by construction), balancing nested `` tags and reading the `id` attribute (not a substring match), or null when the id is absent. Used by `ssr.js`'s frame-render branch | | `page-action.js` | Page server actions (#244): `loadPageAction` reads a page module's optional `action` export, `runPageAction` parses the form body, runs the action, and maps the `ActionResult` to a response (303 PRG on success, 422 re-render with `actionData` on failure, honoring thrown `redirect()`/`notFound()`). A page action that returns a `Response` DIRECTLY (e.g. a content-negotiated `streamResponse`, #248) is honored verbatim. `dev.js` routes a non-GET/HEAD page request here only when the page exports `action`, wrapped in the page's segment middleware | @@ -69,7 +69,7 @@ with metadata, Suspense, streaming) for HTML, or `api.js` / | `env-schema.js` | Boot-time env-var validation (#236). `validateEnv(schema, env)` is the PURE validator: it checks an env object against a schema (an object of `name -> type-name | options`, supporting `string`/`number`/`boolean`/`url`/`enum`, `required`/`optional`/`default`, `minLength`/`pattern`), collecting ALL errors at once and returning the coerced + defaulted values to write back. A schema may instead be a FUNCTION `(env) => void` (the escape hatch for zod etc.), whose throw becomes the single error. `loadEnvSchema(appDir)` reads the optional app-root `env.{js,ts}` (null when absent, so opt-in); `applyEnvValidation(appDir)` is the side-effecting boot wrapper called from `createRequestHandler` right after the `.env` auto-load: it validates `process.env`, applies coerced values back, and THROWS a clear aggregated Error on failure (CLI exits non-zero, embedded host rejects), consistent with the Node-version preflight. `formatEnvErrors` composes the aggregated message. | | `ts-strip.js` | Pluggable TypeScript stripper (#508), the seam that lets webjs run on both Node and Bun. `stripTypeScript(source)` erases types in place (position-preserving, no sourcemap) via the backend `ensureStripper()` resolves once at boot: Node 24+'s built-in `module.stripTypeScriptTypes` (zero deps, the default), or `amaro` on a runtime lacking it (Bun), lazily imported. Node's built-in is itself a wrapper over amaro's `strip-only` mode, so the output is BYTE-IDENTICAL (a unit test asserts it). `amaro` is an `optionalDependency` of this package: a Node-only install that prunes optionals still runs (it never loads amaro); a Bun install gets it. `WEBJS_TS_STRIPPER=builtin|amaro` forces a backend. Namespace-imports `node:module` (not a named `stripTypeScriptTypes` import) so the module LINKS on a runtime lacking the builtin (Bun, old Node) instead of link-failing before the runtime check (the PR #282 link-safety pattern, now living here). Also hosts the one-shot `stripTypeScriptTypes` ExperimentalWarning suppression, co-located with the call. `dev.js`'s `stripTs` delegates here and `createRequestHandler` calls `ensureStripper()` at boot. | | `node-version.js` | Node-version preflight guard (#238). `checkNodeVersion(current, requiredMajor)` is the PURE comparison; `assertNodeVersion({ onFail })` is the side-effecting wrapper that throws a clear Error (embedded server, called at the top of `createRequestHandler`) or exits non-zero (CLI). The minimum is sourced from this package's own `engines.node` via `requiredNodeMajor()` so it never drifts. Fails fast on an older Node with a message naming the found + required version (the built-in TS strip + recursive `fs.watch` need 24+), instead of a cryptic late failure. **Admits Bun (#508):** when `process.versions.bun` is set and no explicit `current` is passed, the gate is a no-op (Bun gets its TS strip from `amaro` via `ts-strip.js` and `node:*` from its compat layer, even though it reports a Node version string). The link-safety pattern (namespace-import `node:module`, not a named `stripTypeScriptTypes` import) now lives in `ts-strip.js` since that is where the built-in is reached (PR #282 reasoning; the CLI carries its own dependency-free inline guard in `cli/lib/node-preflight.js`). | -| `conditional-get.js` | RFC 7232 conditional GET (ETag + If-None-Match -> 304) (#240). `applyConditionalGet(req, res)` is the shared funnel: for a cacheable GET/HEAD response (status 200, `Cache-Control` present and NOT `no-store` / `private`) it attaches a WEAK content-hash `ETag` (`W/"..."`) over the response's OWN body bytes when one is absent, then returns a `304 Not Modified` (no body, validators + caching headers preserved) when the request's `If-None-Match` matches (weak comparison, `*` wildcard, comma lists). `ifNoneMatchSatisfied` is the pure matcher. The ETag is WEAK because it hashes the uncompressed body and `sendWebResponse` reuses it across identity / gzip / br codings, which a strong validator may not do (RFC 7232 2.3.3). **The funnel only reads a body that a serve branch positively marked buffered** via the internal `BUFFERED_MARKER` (`x-webjs-buffered`) header it stamps on a string / bytes body (`htmlResponse` + the non-streaming `streamingHtmlResponse`, `fileResponse`, `jsModuleResponse`, `tsResponse`, `serveDownloadedBundle`). Both internal markers are stripped at the funnel and never reach a client. EXCLUDED: `no-store` / `private` responses (no cross-session 304 on per-user content); non-GET/HEAD; non-200; a genuinely-streamed Suspense body (flagged with `STREAM_MARKER` / `x-webjs-stream` by `ssr.js`); and **any unmarked body, which is how a user `route.{js,ts}` handler returning a `ReadableStream` (incl. an SSE `text/event-stream` that never ends) is never buffered into memory or awaited forever**. A web `Response` exposes a `ReadableStream` body for a string and a live stream alike, so the explicit marker is the only safe discriminator. Wired once at the response funnel in `dev.js`'s `handle()` (AFTER `applySecurityHeaders` + the X-Request-Id / CSP header steps), so it covers SSR HTML pages, static assets, app source modules, and the core / vendor runtime uniformly. The serve branches no longer compute their own ETag; the funnel is the single ETag authority (dev + prod). | +| `conditional-get.js` | RFC 7232 conditional GET (ETag + If-None-Match -> 304) (#240). `applyConditionalGet(req, res)` is the shared funnel: for a cacheable GET/HEAD response (status 200, `Cache-Control` present and NOT `no-store`) it attaches a WEAK content-hash `ETag` (`W/"..."`) over the response's OWN body bytes when one is absent, then returns a `304 Not Modified` (no body, validators + caching headers preserved) when the request's `If-None-Match` matches (weak comparison, `*` wildcard, comma lists). `ifNoneMatchSatisfied` is the pure matcher. The ETag is WEAK because it hashes the uncompressed body and `sendWebResponse` reuses it across identity / gzip / br codings, which a strong validator may not do (RFC 7232 2.3.3). **The funnel only reads a body that a serve branch positively marked buffered** via the internal `BUFFERED_MARKER` (`x-webjs-buffered`) header it stamps on a string / bytes body (`htmlResponse` + the non-streaming `streamingHtmlResponse`, `fileResponse`, `jsModuleResponse`, `tsResponse`, `serveDownloadedBundle`). Both internal markers are stripped at the funnel and never reach a client. EXCLUDED: `no-store` responses (storage forbidden, so nothing to validate). `private` IS validated: it forbids SHARED storage, not validation, and the ETag hashes that response's own body (#1140); non-GET/HEAD; non-200; a genuinely-streamed Suspense body (flagged with `STREAM_MARKER` / `x-webjs-stream` by `ssr.js`); and **any unmarked body, which is how a user `route.{js,ts}` handler returning a `ReadableStream` (incl. an SSE `text/event-stream` that never ends) is never buffered into memory or awaited forever**. A web `Response` exposes a `ReadableStream` body for a string and a live stream alike, so the explicit marker is the only safe discriminator. Wired once at the response funnel in `dev.js`'s `handle()` (AFTER `applySecurityHeaders` + the X-Request-Id / CSP header steps), so it covers SSR HTML pages, static assets, app source modules, and the core / vendor runtime uniformly. The serve branches no longer compute their own ETag; the funnel is the single ETag authority (dev + prod). | | `body-limit.js` | Request body-size limits (413) + node:http server timeouts (#237). `readBodyLimits` resolves the JSON/RPC (`webjs.maxBodyBytes`, default 1 MiB) and form/multipart (`webjs.maxMultipartBytes`, default 10 MiB) caps from package.json + the `WEBJS_MAX_BODY_BYTES` / `WEBJS_MAX_MULTIPART_BYTES` env overrides (env wins, `0` disables). `computeServerTimeouts` resolves `requestTimeout` (30s) / `headersTimeout` (20s) / `keepAliveTimeout` (5s), clamping `headersTimeout` strictly under `requestTimeout` per node semantics. `readBytesBounded` / `readTextBounded` / `readFormDataBounded` are the single bounded-read funnel every body-read site (RPC in `actions.js`, `readBody` in `json.js`, the page-action form in `page-action.js`) routes through: a `Content-Length` over the limit is a fast reject, a chunked body is counted while streaming and abandoned past the cap, so an over-limit body is never buffered whole. `BodyLimitError` (caught and mapped to 413 by `api.js`) is how `readBody` inside a route handler signals over-limit; the RPC / page-action paths return `payloadTooLarge()` inline | | `testing.js` | handle() test-harness helpers (#267), exported from `index.js` AND the `./testing` subpath. THIN builders over `createRequestHandler(...).handle()`: `testRequest(handle, path, init?)` fires a native Request through the real pipeline; `loginAndGetCookies(handle, creds)` drives the REAL credentials login (`/api/auth/signin/credentials`) and captures the genuine signed session `Set-Cookie`; `actionEndpoint(appDir, file, fn)` computes the `/__webjs/action//` path via `hashFile` (the same scheme the stub uses); `invokeActionForTest(app, file, fn, args)` round-trips an action through that REAL endpoint (serializer + the Origin CSRF check + prod error sanitization), modelling a same-origin browser POST (`Sec-Fetch-Site: same-origin`); `rawActionRequest(...)` returns the raw Response (no throw, `crossOrigin: true` to model a cross-site request for the 403 case). No test-framework machinery; reuses the real serializer (`serializer.js` -> `@webjsdev/core`) and cookie/header names, never a fake. **Browser-test harness (#806):** `createBrowserTestHandler(appDir)` -> `{ handle, warmup, importmapHtml }` is what the scaffold's `web-test-runner.config.js` proxies module requests to, so a browser test can import a real `.ts` component that imports a `'use server'` action. It lazily builds `createRequestHandler({ appDir, dev: true, testMode: true })` (lazy import so the rest of `./testing` stays light of the full server + `ws`). `testMode` (a `state` flag read at the `dev.js` serve gate) relaxes the source-serve gate so ANY app file under appDir is servable (a component a test imports IS browser-bound, but a non-component helper/fixture it imports is not); the `.server.*` source guardrail is unchanged, so no server source leaks. Set ONLY here, never by `webjs dev` / `start`. `importmapHtml()` is `importMapTag()` (call after warmup). E2E-verified against real Chromium in `test/e2e/browser-harness.test.mjs` | | `serializer.js` | Default serializer + `setSerializer` / `getSerializer` for the RPC wire format | diff --git a/packages/server/src/actions.js b/packages/server/src/actions.js index 1a98ffe7e..ced1a7d92 100644 --- a/packages/server/src/actions.js +++ b/packages/server/src/actions.js @@ -563,8 +563,10 @@ export async function invokeAction(idx, hash, fnName, req, onError, allowedOrigi * Build a GET action's response (#488): the serialized body plus a weak ETag * (content hash), the `Cache-Control` from the `cache` config (else `no-store`), * and `X-Webjs-Tags`. Answers `If-None-Match` with a 304 itself rather than - * relying on the conditional-GET funnel, which EXCLUDES `private` responses (the - * default here); a per-user browser cache may still revalidate to a 304. + * deferring to the conditional-GET funnel: this branch already hashed the + * serialized body, so it can answer without the funnel re-reading it. (The + * funnel would now accept these too, since #1140 stopped excluding `private`, + * but the short-circuit here still runs first and is cheaper.) * @param {unknown} result * @param {Record} mod * @param {unknown[]} args diff --git a/packages/server/src/conditional-get.js b/packages/server/src/conditional-get.js index 01c57bfe1..c324a0172 100644 --- a/packages/server/src/conditional-get.js +++ b/packages/server/src/conditional-get.js @@ -10,9 +10,15 @@ * uniformly. * * What is EXCLUDED, and why: - * - `no-store` / `private` responses (the default for dynamic, per-user - * pages). Never enable a cross-session 304 on private content: a shared - * cache keyed on the URL could serve one user's validator to another. + * - `no-store` responses (the default for a dynamic page), which forbid + * storage outright, so there is nothing to validate. `private` is NOT + * excluded: it forbids SHARED storage, not validation, and the ETag hashes + * THIS response's body, so two users with different bodies get different + * ETags and neither can match the other's, while two users with identical + * bodies are asking about identical bytes, where a 304 discloses nothing + * (#1140). + * That is what lets the client router's partial responses stay `private` + * without losing their 304s. * - Any body the framework did not positively mark as fully buffered. The * funnel only hashes a response that ALREADY carries an `ETag` (a serve * 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']; /** * Is this response cacheable enough to carry a validator? Cacheable means a - * 200 whose `Cache-Control` is present and does not forbid storage. A - * `no-store` or `private` response is excluded (private / per-user content - * must never get a cross-session 304). `no-cache` is INCLUDED: it means - * "revalidate before reuse", and a 304 is exactly that revalidation answer, - * so dev's `no-cache` assets still benefit. + * 200 whose `Cache-Control` is present and does not forbid STORAGE. Only + * `no-store` is excluded. `no-cache` is INCLUDED: it means "revalidate before + * reuse", and a 304 is exactly that revalidation answer, so dev's `no-cache` + * assets still benefit. + * + * `private` is INCLUDED too (#1140). It was excluded on the theory that + * per-user content must never get a "cross-session" 304, but that cannot + * happen: the ETag is a hash of THIS response's body, and a 304 is returned + * only when the client's own `If-None-Match` matches it. Two users with + * different bodies get different ETags, so neither can ever match the other's; + * two users with identical bodies are asking about identical bytes, where a + * 304 is the correct answer and carries no body either way. `private` already + * forbids the one real hazard, a SHARED cache storing the response. + * + * Widening this is what makes #1140's fragment downgrade safe to ship. Before + * that change a reduced fragment INHERITED the page's public header, so it + * already passed this check and already carried a validator; marking fragments + * `private` would have taken that away, and since the router fetches them with + * `cache: 'no-cache'` (#1131), every prefetch and soft navigation would have + * re-downloaded the whole fragment. The exclusion is dropped rather than + * special-cased because it was never justified on its own terms either, per + * the reasoning above. * * @param {Response} res * @returns {boolean} @@ -90,7 +113,7 @@ function isCacheable(res) { if (res.status !== 200) return false; const cc = res.headers.get('cache-control'); if (!cc) return false; - return !/(?:^|,)\s*(?:no-store|private)\s*(?:,|$)/i.test(cc); + return !/(?:^|,)\s*no-store\s*(?:,|$)/i.test(cc); } /** diff --git a/packages/server/src/dev.js b/packages/server/src/dev.js index db2c84def..eea248df6 100644 --- a/packages/server/src/dev.js +++ b/packages/server/src/dev.js @@ -1263,8 +1263,9 @@ export async function createRequestHandler(opts) { // into a 304 Not Modified with no body. Applied LAST, after every header // (X-Webjs-Build, X-Request-Id, Set-Cookie, CSP) is on the response, so a // 304 carries the validators a shared cache and the client router need. - // A no-store / per-user response, a non-GET/HEAD, and a streaming Suspense - // body are all skipped (see conditional-get.js). Logged with the final + // A no-store response, a non-GET/HEAD, and a streaming Suspense body are + // all skipped (see conditional-get.js). A `private` response is NOT + // skipped: private forbids shared storage, not validation (#1140). Logged with the final // (possibly 304) status. Best-effort: a failure leaves the 200 untouched. let conditioned = merged; try { diff --git a/packages/server/src/ssr.js b/packages/server/src/ssr.js index f451990af..e4ecd651f 100644 --- a/packages/server/src/ssr.js +++ b/packages/server/src/ssr.js @@ -164,6 +164,11 @@ export async function ssrPage(route, params, url, opts) { frameRes.headers.append('vary', 'X-Webjs-Frame'); // #1009: a subtree sliced from a REDUCED render inherits its variance. if (reduced) frameRes.headers.append('vary', 'X-Webjs-Have'); + // No privateFragment call here on purpose: this response is built + // WITHOUT page metadata, so it is already `no-store` and the call + // would be dead code with nothing to assert against. The property is + // locked by a test instead, which fails if the frame response ever + // starts carrying the page's cacheControl. return frameRes; } } @@ -304,15 +309,25 @@ export async function ssrPage(route, params, url, opts) { ); // REDUCED response (#1009): the X-Webjs-Have short-circuit omitted the // outer-layout chrome, so these bytes are only valid for a request that - // sent a matching `have`. Without `Vary: X-Webjs-Have`, a shared cache - // (CDN edge) could store the reduced body under the URL and serve a - // chrome-less fragment to a fresh full-page navigation (measured live: - // GET / was 73,534 bytes, GET / + have was 57,035, byte-identical headers - // otherwise). Scoped to genuinely reduced responses so a normal page's - // cache key is unchanged. The internal #241 revalidate cache is already - // safe by construction: `cacheEligible` excludes any request that carries - // x-webjs-have, so a reduced body is never stored under the URL-only key. - if (reduced) res.headers.append('vary', 'X-Webjs-Have'); + // sent a matching `have`. Left shared-cacheable, a CDN edge could store the + // reduced body under the URL and serve a chrome-less fragment to a fresh + // full-page navigation (measured live: GET / was 73,534 bytes, GET / + have + // was 57,035, byte-identical headers otherwise). + // + // TWO markings, and the order of trust matters (#1140). `privateFragment` + // is the guarantee: it forbids SHARED storage outright, so no CDN can hold + // this body at all. `Vary` is belt-and-braces for caches that honour it, + // NOT the protection, because Cloudflare and others honour only + // `Accept-Encoding`. Both are scoped to genuinely reduced responses, so a + // normal page's headers and cache key are unchanged. + // + // The internal #241 revalidate cache is already safe by construction: + // `cacheEligible` excludes any request that carries x-webjs-have, so a + // reduced body is never stored under the URL-only key. + if (reduced) { + res.headers.append('vary', 'X-Webjs-Have'); + privateFragment(res); + } // Server HTML cache write (#241). The page opted in via `revalidate`, so // FLAG this candidate for the response funnel rather than writing here: the // store decision must see the FINAL response (after segment middleware, @@ -465,6 +480,82 @@ export async function ssrUnauthorized(route, opts) { return htmlResponse(html, 401, opts.req, opts.url); } +/** + * Downgrade a PARTIAL response so no shared cache can store it (#1140). + * + * A reduced `X-Webjs-Have` body is sliced by a REQUEST header, so it is valid + * only for a client that sent it. It is marked `Vary` for that header, but + * `Vary` is not a guarantee in practice: Cloudflare honours only + * `Accept-Encoding` and several other shared caches are just as selective. + * Since a reduced body otherwise INHERITS the page's `Cache-Control`, a page + * that opted into public caching was handing CDNs a chrome-less fragment under + * the full page's URL, to be served to whoever navigated there next. Measured + * on webjs.dev: 71,759 bytes for the document versus 47,375 for the fragment, + * identical `Cache-Control` on both. + * + * The reduced path is the ONLY caller. A `` subtree is sliced by a + * request header too, but its response is built without page metadata, so it is + * already `no-store` and needs no downgrade; that property is locked by a test + * rather than by a call here. A NEW partial-response shape does not get this + * treatment for free: call this from its response site. + * + * `private` fixes that at the source instead of relying on `Vary` being + * respected: no shared cache may store it, while the browser that asked for it + * still may. The freshness directives are preserved, so a returning client + * keeps whatever reuse the page declared. `Vary` stays too, as belt-and-braces + * for caches that do honour it. + * + * The response KEEPS its validator. `private` forbids shared storage, not + * validation, so the funnel still attaches an ETag (see conditional-get.js, + * which stopped excluding `private` for exactly this reason). That matters: + * the router fetches partials with `cache: 'no-cache'` (#1131), so without a + * validator every prefetch and soft navigation would re-download the whole + * fragment instead of being answered 304. + * + * @param {Response} res + */ +export function privateFragment(res) { + const cc = res.headers.get('cache-control') || ''; + // No header at all is not "nothing to do": a response with no Cache-Control + // is heuristically storable by a shared cache (RFC 9111), which is precisely + // the hazard here. Fail CLOSED. + if (!cc) { + res.headers.set('cache-control', 'private'); + return; + } + // Split on commas that are NOT inside a quoted argument: a directive may + // carry one (`no-cache="Set-Cookie, X-Foo"`, `private="x-user"`), and a naive + // split tears those apart. + const directives = []; + let buf = ''; + let inQuotes = false; + for (const ch of cc) { + if (ch === '"') inQuotes = !inQuotes; + if (ch === ',' && !inQuotes) { directives.push(buf.trim()); buf = ''; continue; } + buf += ch; + } + directives.push(buf.trim()); + + // `name` is everything before the first `=`, trimmed, so `s-maxage = 600` + // and `private="x-user"` are recognised as the directives they are. + const nameOf = (d) => d.split('=')[0].trim().toLowerCase(); + // Already unshareable: leave it exactly as the author wrote it. Only a BARE + // `private` counts. The qualified form (`private="x-user"`) marks just the + // NAMED header fields private per RFC 9111, leaving the response itself + // storable by a shared cache, so it must still be downgraded. + const isBare = (d) => !d.includes('='); + if (directives.some((d) => nameOf(d) === 'no-store' || (nameOf(d) === 'private' && isBare(d)))) return; + + // `private` joins the drop list because a bare one is prepended below: a + // qualified `private="x-user"` left in place would emit the directive twice, + // and a cache that resolves a repeat by taking the last occurrence would read + // the qualified form, which per RFC 9111 leaves the response shared-storable. + const SHARED_ONLY = new Set(['public', 's-maxage', 'proxy-revalidate', 'private']); + const kept = directives.filter((d) => d && !SHARED_ONLY.has(nameOf(d))); + kept.unshift('private'); + res.headers.set('cache-control', kept.join(', ')); +} + /** * Build an HTML Response. Sets no cookie: action CSRF is an Origin / * Sec-Fetch-Site check, so the page response is cookieless (CDN-cacheable). @@ -476,8 +567,10 @@ export async function ssrUnauthorized(route, opts) { */ function htmlResponse(html, status, req, url, metadata) { const headers = new Headers({ 'content-type': 'text/html; charset=utf-8' }); - // Default: no caching. Pages are dynamic by default: the developer - // opts in to caching explicitly via metadata.cacheControl. + // Default: no caching. Pages are dynamic by default: the developer opts in + // explicitly via metadata.cacheControl. No non-200 guard here, unlike + // streamingHtmlResponse: every caller of THIS builder passes no metadata, so + // the value is already the no-store default and a guard would be dead code. headers.set('cache-control', metadata?.cacheControl || 'no-store'); // X-Webjs-Build carries the published build id so the client // router can detect post-deploy importmap changes on EVERY @@ -487,10 +580,11 @@ function htmlResponse(html, status, req, url, metadata) { // applySwap and publishedBuildId() in importmap.js. headers.set('x-webjs-build', publishedBuildId()); headers.set('x-webjs-src', appSourceId()); - // Buffered (string) body: opt into the conditional-GET funnel so a - // PUBLIC-cacheable page (metadata.cacheControl) gets a weak ETag + 304. - // The funnel still excludes the no-store default, so a private page is - // never ETagged. See conditional-get.js. + // Buffered (string) body: opt into the conditional-GET funnel. + // A cacheable page (metadata.cacheControl) gets a weak ETag + 304. The + // funnel excludes only the no-store default; a `private` page IS validated, + // which is what keeps the router's partial responses cheap (#1140). + // See conditional-get.js. headers.set(BUFFERED_MARKER, '1'); return new Response(html, { status, headers }); } @@ -650,8 +744,10 @@ async function renderChain(route, ctx, dev, suspenseCtx, have, pageModule) { const body = await renderToString(tree, { ssr: true, suspenseCtx }); // REDUCED response (#1009): the outer layouts were skipped, so these // bytes are only valid for a client that already HAS them. The caller - // marks the response `Vary: X-Webjs-Have` so no shared cache can serve - // this fragment to a fresh full-page navigation. + // marks the response `private` so no shared cache can store it, and + // additionally `Vary: X-Webjs-Have` for caches that honour it. The + // `private` is the guarantee, not the Vary: Cloudflare and others honour + // only `Accept-Encoding` (#1140). return { html: body + (await loadingTemplates(route, ctx, dev)), reduced: true }; } const mod = await loadModule(route.layouts[i], dev); @@ -1955,8 +2051,11 @@ function streamingHtmlResponse(prefix, bodyHtml, closer, ctx, status, req, url, const encoder = new TextEncoder(); const headers = new Headers({ 'content-type': 'text/html; charset=utf-8' }); // Default: no caching. Pages are dynamic by default: the developer - // opts in to caching explicitly via metadata.cacheControl. - headers.set('cache-control', metadata?.cacheControl || 'no-store'); + // opts in to caching explicitly via metadata.cacheControl. A non-200 does + // NOT inherit it (#1140): the page-action re-render is a 422 carrying the + // submitter's own field values and errors, which must never be handed to a + // shared cache just because the page opted into public caching. + headers.set('cache-control', status === 200 ? (metadata?.cacheControl || 'no-store') : 'no-store'); // See htmlResponse: published build id on every response for the // client router's importmap-mismatch detection on partial swaps. headers.set('x-webjs-build', publishedBuildId()); diff --git a/packages/server/test/dev/conditional-get.test.js b/packages/server/test/dev/conditional-get.test.js index b67fe52f4..a5f00a8d3 100644 --- a/packages/server/test/dev/conditional-get.test.js +++ b/packages/server/test/dev/conditional-get.test.js @@ -7,8 +7,11 @@ * The headline behaviours: * - a cacheable page (metadata.cacheControl public) gets an ETag, and a * repeat request with a matching If-None-Match gets a 304 with no body; - * - a no-store page gets NO ETag and never 304s (no cross-session 304 on - * private content); + * - a no-store page gets NO ETag and never 304s (storage is forbidden, so + * there is nothing to validate); + * - a `private` page DOES get an ETag and 304s: private forbids SHARED + * storage, not validation, which is what keeps the client router's + * partial responses cheap (#1140); * - a static asset / app module gets an ETag and 304s on a match; * - a non-matching If-None-Match returns 200 + the full body; * - the ETag is stable for identical content across requests. @@ -112,6 +115,88 @@ test('cacheable page: a NON-matching If-None-Match returns 200 + full body', asy assert.ok(body.includes('cacheable'), 'full body is served on a mismatch'); }); +/* ---------------- private (router fragment): still validated ---------------- */ + +test('a PRIVATE response still gets an ETag and 304s (#1140)', async () => { + // What lets #1140 mark the router's partial responses `private` without + // costing them their validator. The router fetches them with + // `cache: 'no-cache'` (#1131) precisely so a revalidation is a cheap + // conditional request; if `private` were excluded here, that downgrade would + // turn every prefetch and soft navigation into a full re-download. + // + // The old worry was a "cross-session" 304 on per-user content. It cannot + // happen: the ETag hashes THIS response's body, so two users with different + // bodies get different ETags and neither can match the other's, while two + // users with identical bodies are asking about identical bytes. + const appDir = makeApp({ + 'app/page.js': + `import { html } from ${JSON.stringify(HTML_URL)};\n` + + `export const metadata = { cacheControl: 'private, max-age=60' };\n` + + `export default function P() { return html\`

private body

\`; }\n`, + }); + const app = await createRequestHandler({ appDir, dev: true }); + const first = await app.handle(new Request('http://x/')); + await first.text(); + assert.equal(first.headers.get('cache-control'), 'private, max-age=60'); + const etag = first.headers.get('etag'); + assert.ok(etag, 'a private response carries a validator'); + + const second = await app.handle(new Request('http://x/', { headers: { 'if-none-match': etag } })); + const body = await second.text(); + assert.equal(second.status, 304, 'a matching validator 304s'); + assert.equal(body.length, 0, 'a 304 has no body'); +}); + +test('a REDUCED router fragment is private AND still 304s (#1140)', async () => { + // The composition the two halves exist for, driven through the real handler. + // Asserting them separately (a fragment is private / a private page is + // validated) let the actual regression through once already: fragments + // silently lost their validator, so every prefetch and soft navigation + // re-downloaded the whole fragment while the suite stayed green. + const appDir = makeApp({ + 'app/layout.js': + `import { html } from ${JSON.stringify(HTML_URL)};\n` + + `export const metadata = { cacheControl: 'public, max-age=60, s-maxage=600' };\n` + + `export default function L({ children }) { return html\`
\${children}
\`; }\n`, + 'app/page.js': + `import { html } from ${JSON.stringify(HTML_URL)};\n` + + `export default function P() { return html\`

page body

\`; }\n`, + }); + const app = await createRequestHandler({ appDir, dev: true }); + + const have = { 'x-webjs-router': '1', 'x-webjs-have': '/:/' }; + const first = await app.handle(new Request('http://x/', { headers: have })); + const body = await first.text(); + assert.ok(!body.includes('OUTER'), 'sanity: the response really was reduced'); + + const cc = first.headers.get('cache-control') || ''; + assert.match(cc, /(^|,)\s*private\s*(,|$)/, `a fragment is unshareable, got: ${cc}`); + assert.doesNotMatch(cc, /s-maxage/i, `a fragment carries no shared TTL, got: ${cc}`); + + const etag = first.headers.get('etag'); + assert.ok(etag, 'a fragment still carries a validator, or every prefetch re-downloads it'); + + const replay = await app.handle(new Request('http://x/', { headers: { ...have, 'if-none-match': etag } })); + const replayBody = await replay.text(); + assert.equal(replay.status, 304, 'the router revalidation is answered 304, not a re-download'); + assert.equal(replayBody.length, 0, 'a 304 has no body'); +}); + +test('privateFragment fails CLOSED when a response carries no Cache-Control (#1140)', async () => { + // A response with no Cache-Control is heuristically storable by a shared + // cache (RFC 9111), so "no header" is not "nothing to do". Exercised as a + // unit because every current caller sets the header, which is exactly why a + // future change there could reintroduce a shareable fragment unnoticed. + const { privateFragment } = await import('../../src/ssr.js'); + assert.equal(typeof privateFragment, 'function', 'the helper is exported so this can be tested at all'); + const res = new Response('x'); + res.headers.delete('cache-control'); + assert.equal(res.headers.get('cache-control'), null, 'sanity: the response really has no header'); + privateFragment(res); + assert.match(res.headers.get('cache-control') || '', /(^|,)\s*private\s*(,|$)/, + 'an absent Cache-Control is replaced with private, not left alone'); +}); + /* ---------------- no-store page: no ETag, no 304 ---------------- */ test('a no-store (dynamic / per-user) page gets NO ETag and never 304s', async () => { @@ -126,7 +211,7 @@ test('a no-store (dynamic / per-user) page gets NO ETag and never 304s', async ( assert.equal(first.headers.get('x-webjs-buffered'), null, 'internal buffered marker never leaks'); // Even if a client replays the page's prior body hash, a no-store page must - // not 304 (no cross-session 304 on private content). + // not 304 (no-store forbids storage, so there is nothing to validate). const replay = await app.handle( new Request('http://x/', { headers: { 'if-none-match': '*' } }) ); diff --git a/test/ssr/ssr.test.js b/test/ssr/ssr.test.js index 992cf6c0a..c5c6f1ea4 100644 --- a/test/ssr/ssr.test.js +++ b/test/ssr/ssr.test.js @@ -978,6 +978,133 @@ test('ssrPage: X-Webjs-Have skips rendering layouts above the deepest match', as assert.ok(body.includes('page body'), 'page content present'); }); +test('ssrPage: a REDUCED response is never shared-cacheable, whatever the page declared (#1140)', async () => { + // Vary is not a guarantee in practice. Cloudflare honours only + // Accept-Encoding, so on a page that opted into public caching the reduced + // fragment was handed to CDNs under the full page's URL, to be served to + // whoever navigated there next. The fragment must therefore be + // non-shared-cacheable at the source, not merely Vary-scoped. + const { route, appDir } = await makeRoute({ + pageSrc: + `import { html } from ${JSON.stringify(HTML_MODULE_URL)};\n` + + `export const metadata = { cacheControl: 'public, max-age=60, s-maxage=600, stale-while-revalidate=86400' };\n` + + `export default function Page() { return html\`

page body

\`; }\n`, + layoutSrc: + `import { html } from ${JSON.stringify(HTML_MODULE_URL)};\n` + + `export default function Layout({ children }) { return html\`
\${children}
\`; }\n`, + metadata: + `export const metadata = { cacheControl: 'public, max-age=60, s-maxage=600, stale-while-revalidate=86400' };\n`, + }); + const url = new URL('http://localhost/'); + + const haveReq = new Request(url.toString(), { headers: { 'x-webjs-have': '/:/' } }); + const reduced = await ssrPage(route, {}, url, { dev: false, appDir, req: haveReq }); + const reducedBody = await reduced.text(); + assert.ok(!reducedBody.includes('OUTER'), 'sanity: the response really was reduced'); + + const cc = reduced.headers.get('cache-control') || ''; + assert.match(cc, /(^|,)\s*private\s*(,|$)/, `a reduced body must be private, got: ${cc}`); + assert.doesNotMatch(cc, /(^|,)\s*public\s*(,|$)/, `a reduced body must not be public, got: ${cc}`); + assert.doesNotMatch(cc, /s-maxage/i, `a reduced body must carry no shared-cache TTL, got: ${cc}`); + // The client-facing freshness the page declared survives: this is about + // SHARED caches, not about forbidding the requesting browser to reuse it. + assert.match(cc, /max-age=60/, `the client-facing freshness is preserved, got: ${cc}`); + // Belt-and-braces for caches that DO honour Vary. + assert.ok((reduced.headers.get('vary') || '').includes('X-Webjs-Have'), 'Vary is still sent'); + + // The full document is untouched: edge-cacheability is the whole point of + // metadata.cacheControl and a blanket downgrade would silently undo #1127. + const fullResp = await ssrPage(route, {}, url, { dev: false, appDir }); + assert.equal(fullResp.headers.get('cache-control'), + 'public, max-age=60, s-maxage=600, stale-while-revalidate=86400', + 'a full document keeps the page-declared Cache-Control byte for byte'); +}); + +test('ssrPage: a QUALIFIED private is still downgraded on a fragment (#1140)', async () => { + // `private="x-user"` marks only the NAMED header fields private per RFC 9111 + // and leaves the response itself storable by a shared cache, so it must not + // be mistaken for an already-unshareable value and passed through. + const { route, appDir } = await makeRoute({ + pageSrc: + `import { html } from ${JSON.stringify(HTML_MODULE_URL)};\n` + + `export const metadata = { cacheControl: 'private="x-user", public, max-age=60, s-maxage=600' };\n` + + `export default function Page() { return html\`

page body

\`; }\n`, + layoutSrc: + `import { html } from ${JSON.stringify(HTML_MODULE_URL)};\n` + + `export default function Layout({ children }) { return html\`
\${children}
\`; }\n`, + metadata: + `export const metadata = { cacheControl: 'private="x-user", public, max-age=60, s-maxage=600' };\n`, + }); + const url = new URL('http://localhost/'); + const req = new Request(url.toString(), { headers: { 'x-webjs-have': '/:/' } }); + const resp = await ssrPage(route, {}, url, { dev: false, appDir, req }); + const body = await resp.text(); + assert.ok(!body.includes('OUTER'), 'sanity: the response really was reduced'); + + const cc = resp.headers.get('cache-control') || ''; + assert.doesNotMatch(cc, /(^|,)\s*public\s*(,|$)/, `public must be stripped, got: ${cc}`); + assert.doesNotMatch(cc, /s-maxage/i, `the shared TTL must be stripped, got: ${cc}`); + assert.match(cc, /(^|,)\s*private\s*(,|$)/, `a bare private must be added, got: ${cc}`); + // The qualified form must be REPLACED, not joined by a bare one. A header + // carrying `private` twice is ambiguous: a cache resolving the repeat by + // last-occurrence reads the qualified form, which leaves the response + // shared-storable, which is the hazard this whole change removes. + assert.equal((cc.match(/(^|,)\s*private/g) || []).length, 1, + `private must appear exactly once, got: ${cc}`); + assert.doesNotMatch(cc, /private\s*=/, `the qualified form must not survive, got: ${cc}`); +}); + +test('ssrPage: a quoted directive argument survives the downgrade intact (#1140)', async () => { + // A quoted argument carries a comma INSIDE it. A splitter that is not + // quote-aware tears it in two, and re-joining reassembles it with DIFFERENT + // inner spacing, so the argument no longer round-trips byte for byte. The + // no-space form is what discriminates: with a space after the comma the + // naive split happens to rebuild the original and proves nothing. + const declared = 'public, max-age=60, s-maxage=600, no-cache="Set-Cookie,X-Foo"'; + const { route, appDir } = await makeRoute({ + pageSrc: + `import { html } from ${JSON.stringify(HTML_MODULE_URL)};\n` + + `export const metadata = { cacheControl: '${declared}' };\n` + + `export default function Page() { return html\`

page body

\`; }\n`, + layoutSrc: + `import { html } from ${JSON.stringify(HTML_MODULE_URL)};\n` + + `export default function Layout({ children }) { return html\`
\${children}
\`; }\n`, + metadata: + `export const metadata = { cacheControl: '${declared}' };\n`, + }); + const url = new URL('http://localhost/'); + const req = new Request(url.toString(), { headers: { 'x-webjs-have': '/:/' } }); + const resp = await ssrPage(route, {}, url, { dev: false, appDir, req }); + await resp.text(); + + const cc = resp.headers.get('cache-control') || ''; + assert.ok(cc.includes('no-cache="Set-Cookie,X-Foo"'), + `the quoted argument must survive byte for byte, got: ${cc}`); + assert.match(cc, /(^|,)\s*private\s*(,|$)/, `still downgraded, got: ${cc}`); + assert.doesNotMatch(cc, /s-maxage/i, `shared TTL stripped, got: ${cc}`); +}); + +test('ssrPage: a non-200 never inherits the page cacheControl (#1140)', async () => { + // The page-action re-render is a 422 carrying the submitter's own field + // values and errors. It must not go out shared-cacheable just because the + // page opted into public caching. + const { route, appDir } = await makeRoute({ + pageSrc: + `import { html } from ${JSON.stringify(HTML_MODULE_URL)};\n` + + `export const metadata = { cacheControl: 'public, max-age=60, s-maxage=600' };\n` + + `export default function Page() { return html\`

page body

\`; }\n`, + metadata: + `export const metadata = { cacheControl: 'public, max-age=60, s-maxage=600' };\n`, + }); + const url = new URL('http://localhost/'); + const ok = await ssrPage(route, {}, url, { dev: false, appDir }); + assert.equal(ok.headers.get('cache-control'), 'public, max-age=60, s-maxage=600', + 'a 200 still honours the page cacheControl'); + const errored = await ssrPage(route, {}, url, { dev: false, appDir, status: 422 }); + assert.equal(errored.headers.get('cache-control'), 'no-store', + 'a 422 re-render is never cacheable'); +}); + test('ssrPage: a REDUCED have-response carries Vary: X-Webjs-Have; a full one does not (#1009)', async () => { // A reduced response (outer chrome omitted) under a URL-only cache key is // latent cache poisoning: a shared cache could serve the fragment to a @@ -1072,6 +1199,37 @@ test('ssrPage: a frame-subtree response varies on X-Webjs-Frame (shared-cache sa assert.ok(body.includes('frame body'), 'sanity: the frame subtree was returned'); }); +test('ssrPage: a frame subtree is never shared-cacheable either (#1140)', async () => { + // Same reasoning as the reduced-have case: the subtree is sliced by a + // request header, so a CDN that ignores Vary could serve the lone frame to + // a full-page navigation. + const { route, appDir } = await makeRoute({ + pageSrc: + `import { html } from ${JSON.stringify(HTML_MODULE_URL)};\n` + + `export const metadata = { cacheControl: 'public, max-age=60, s-maxage=600' };\n` + + `export default function Page() { return html\`

frame body

\`; }\n`, + metadata: + `export const metadata = { cacheControl: 'public, max-age=60, s-maxage=600' };\n`, + }); + const url = new URL('http://localhost/'); + const req = new Request(url.toString(), { headers: { 'x-webjs-frame': 'panel' } }); + const resp = await ssrPage(route, {}, url, { dev: false, appDir, req }); + const body = await resp.text(); + assert.ok(body.includes('frame body'), 'sanity: the frame subtree was returned'); + + // The contract is "no shared cache may store this", which today the frame + // path already satisfies by not inheriting the page's cacheControl at all + // (it builds its response without metadata, so the default no-store applies). + // Asserted as the CONTRACT rather than as one spelling of it, so the test + // keeps holding if that response ever starts carrying page metadata. + const cc = resp.headers.get('cache-control') || ''; + assert.match(cc, /(^|,)\s*(?:no-store|private)\s*(,|$)/, + `a frame subtree must not be shared-cacheable, got: ${cc}`); + assert.doesNotMatch(cc, /s-maxage/i, `a frame subtree must carry no shared-cache TTL, got: ${cc}`); + assert.doesNotMatch(cc, /(^|,)\s*public\s*(,|$)/, `a frame subtree must not be public, got: ${cc}`); + assert.ok((resp.headers.get('vary') || '').includes('X-Webjs-Frame'), 'Vary is still sent'); +}); + test('ssrPage: X-Webjs-Have picks deepest match (not just any match)', async () => { // Two-level layout chain: root and docs. Client has both. // Server should match at /docs (deepest), not / (shallower). diff --git a/website/app/docs/cache/page.ts b/website/app/docs/cache/page.ts index 6e300800a..7525fcd83 100644 --- a/website/app/docs/cache/page.ts +++ b/website/app/docs/cache/page.ts @@ -94,10 +94,12 @@ export const metadata = {

This sets the standard Cache-Control header on the HTTP response. Browsers and CDNs cache the rendered page without any server-side state.

-

Setting cacheControl to anything other than no-store or private also opts the page into conditional GET: WebJs attaches a weak ETag and answers a matching If-None-Match with a 304, so a revalidation costs a few hundred bytes instead of the whole document. A bare max-age=60 is enough; the public keyword controls shared-cache storage, not whether you get an ETag.

+

Setting cacheControl to anything other than no-store also opts the page into conditional GET: WebJs attaches a weak ETag and answers a matching If-None-Match with a 304, so a revalidation costs a few hundred bytes instead of the whole document. A bare max-age=60 is enough, and so is private: the public and private keywords control shared-cache storage, not whether you get an ETag.

That path only works if the page renders the same bytes twice. The ETag is a hash of the response body, so any per-render-varying value anywhere in the document defeats it: a Date.now(), a Math.random(), an id from a module-scope counter (which never resets in a long-lived server), or a CSP nonce, which is why a page under CSP is excluded from the server HTML cache. Nothing errors when this happens. The page renders correctly, every content assertion still passes, and the only symptom is a caching layer that silently never engages, so it is worth a test that renders the page twice, through its layout, and asserts the two outputs are identical.

+

A public value shares one copy across every visitor, so set it only on a page that renders identically for all of them. This is the same rule as revalidate below, but with none of the same protection: revalidate auto-excludes a render that reads cookies(), a session, or auth(), whereas cacheControl is emitted verbatim. Put a signed-in user's name on a page marked public, s-maxage=600 and a CDN will serve it to the next visitor. Use private for anything per-user: it still gets browser caching and conditional GET, just never a shared copy. What you do NOT have to think about is cookies (the SSR response sets none, since action CSRF is an Origin / Sec-Fetch-Site check) or the client router's partial responses. The client router's partial responses are handled for you: a reduced fragment is served private so no shared cache can store it, which holds even on a CDN that ignores Vary (Cloudflare honours only Accept-Encoding). Without that, a CDN could serve a chrome-less fragment to a full-page navigation.

+

Note that max-age=0 is a common default worth thinking about. It keeps the browser revalidating on every view, which is right when deploys must be visible immediately, but it means a stored copy is never reused directly. A small non-zero value is what produces real browser cache hits on back/forward and repeat visits.

Server HTML Response Cache (export const revalidate)

diff --git a/website/app/docs/client-router/page.ts b/website/app/docs/client-router/page.ts index 33c21b01e..acfc86917 100644 --- a/website/app/docs/client-router/page.ts +++ b/website/app/docs/client-router/page.ts @@ -20,7 +20,7 @@ export default function ClientRouter() {
  • The <head> is add-only merged (preserves runtime-injected styles like Tailwind's), <script> tags re-execute, custom elements upgrade, URL updates via pushState.
  • A webjs:navigate event fires on document with the final URL.
  • -

    Wire-byte optimization: the router sends an X-Webjs-Have request header listing segment:route-key entries for the boundaries it already has (the key lets the server re-render a dynamic layout the client holds for different params instead of skipping it). The server walks the target page's layout chain innermost-to-outermost, short-circuits at the first match, and returns only the divergent fragment wrapped in that layout's boundary pair. Outer layouts are never re-serialized for same-shell navigations, and a reduced response carries Vary: X-Webjs-Have so a shared cache can never serve it to a full-page navigation.

    +

    Wire-byte optimization: the router sends an X-Webjs-Have request header listing segment:route-key entries for the boundaries it already has (the key lets the server re-render a dynamic layout the client holds for different params instead of skipping it). The server walks the target page's layout chain innermost-to-outermost, short-circuits at the first match, and returns only the divergent fragment wrapped in that layout's boundary pair. Outer layouts are never re-serialized for same-shell navigations, and a reduced response is served private so no shared cache can store it and serve it to a full-page navigation. It also carries Vary: X-Webjs-Have for caches that honour that header, but the guarantee does not depend on it: Cloudflare honours only Accept-Encoding. On a page that opted into caching via metadata.cacheControl, the fragment still carries an ETag, so the router's revalidating fetches stay cheap; on a default no-store page there is nothing to validate either way.

    Progressive streaming on navigation

    When the destination streams (it has a Suspense or <webjs-suspense> boundary), the router applies the response PROGRESSIVELY: it swaps the shell (with the fallbacks) in immediately and advances the URL, then streams each resolved boundary into the live DOM as it arrives, fast-before-slow. So a soft navigation to a streamed page matches the initial-load experience (fallback first, content streams in) instead of buffering the whole response before the swap. A non-streaming page is unaffected (the response is read to completion and applied once). A navigation superseded mid-stream stops applying, and a mid-stream transport failure leaves the applied boundaries in place with the rest showing their fallback (non-destructive).

    diff --git a/website/app/docs/deployment/page.ts b/website/app/docs/deployment/page.ts index db0f57f49..aa9fac412 100644 --- a/website/app/docs/deployment/page.ts +++ b/website/app/docs/deployment/page.ts @@ -37,9 +37,9 @@ npm run start -- --port 8080

    Static files are served with a SHA-1 ETag and a 1-hour max-age. Vendor npm packages resolve through importmap to jspm.io URLs (default) or to local /__webjs/vendor/<pkg>@<version>.js paths (after webjs vendor pin --download). Direct jspm.io URLs use jspm.io's own immutable headers; locally-served --download bundles use max-age=31536000, immutable. In dev, all files use Cache-Control: no-cache.

    Conditional GET (ETag + If-None-Match)

    -

    Every cacheable response carries a content-hash ETag, and a repeat request whose If-None-Match matches it gets a 304 Not Modified with no body (RFC 7232). A client holding an identical copy revalidates with a tiny 304 instead of re-downloading the whole body. This applies uniformly to static assets, app source modules, the core / vendor runtime, and to SSR HTML pages that opt into public caching via metadata.cacheControl. The ETag is a weak validator (W/"..."), since it is computed over the uncompressed body and reused across the identity, gzip, and Brotli encodings, which a strong validator may not do (RFC 7232 2.3.3).

    -

    Private content is excluded. A page with the default Cache-Control: no-store (every dynamic / per-user page) gets no ETag and never returns a 304, so a shared cache can never replay one user's validator to another. A private response is excluded for the same reason. Streaming responses are excluded too, both streamed Suspense pages and a route.{js,ts} handler that returns a ReadableStream (including an SSE text/event-stream). Their bodies are never buffered to be hashed, so a long-lived or never-ending stream is never read into memory and never stalls the response. A 304 preserves the validators and caching headers (ETag, Cache-Control, Vary) and drops only the body.

    -

    Set a page's metadata.cacheControl to a public value to enable conditional GET on it:

    +

    Every cacheable response carries a content-hash ETag, and a repeat request whose If-None-Match matches it gets a 304 Not Modified with no body (RFC 7232). A client holding an identical copy revalidates with a tiny 304 instead of re-downloading the whole body. This applies uniformly to static assets, app source modules, the core / vendor runtime, and to SSR HTML pages that opt into caching via metadata.cacheControl. The ETag is a weak validator (W/"..."), since it is computed over the uncompressed body and reused across the identity, gzip, and Brotli encodings, which a strong validator may not do (RFC 7232 2.3.3).

    +

    Unstorable content is excluded. A page with the default Cache-Control: no-store (every dynamic / per-user page) gets no ETag and never returns a 304: no-store forbids storage outright, so there is nothing to validate. A private response IS validated, because private forbids only SHARED storage and the ETag hashes that response's own body: two users with different bodies get different ETags and neither can match the other's, while two users with identical bodies are asking about identical bytes, where a 304 discloses nothing. Streaming responses are excluded too, both streamed Suspense pages and a route.{js,ts} handler that returns a ReadableStream (including an SSE text/event-stream). Their bodies are never buffered to be hashed, so a long-lived or never-ending stream is never read into memory and never stalls the response. A 304 preserves the validators and caching headers (ETag, Cache-Control, Vary) and drops only the body.

    +

    Set a page's metadata.cacheControl to any value other than no-store to enable conditional GET on it:

    export const metadata = { cacheControl: 'public, max-age=60' };

    Content Security Policy (CSP) and vendor packages

    diff --git a/website/test/ssr/conditional-get.test.ts b/website/test/ssr/conditional-get.test.ts index 505e5fdc8..6ad34724b 100644 --- a/website/test/ssr/conditional-get.test.ts +++ b/website/test/ssr/conditional-get.test.ts @@ -6,9 +6,9 @@ * This is the test of record for the whole fix, through the real request * pipeline rather than renderToString. It fails on either regression path: * - * - Revert the header to `max-age=0` (or any no-store / private value) and - * the max-age assertion fails. Nothing else in the suite reads the header, - * so without this a one-line revert of the fix ships green. + * - Revert the header to `max-age=0` (or to `no-store`) and the max-age + * assertion fails. Nothing else in the suite reads the header, so without + * this a one-line revert of the fix ships green. * - Reintroduce any per-render nondeterminism (the copy-cmd counter class) * and the replay fails: the second render hashes to a different ETag, the * recorded validator no longer matches, and the expected 304 comes back @@ -41,8 +41,11 @@ for (const route of ['/', '/docs/getting-started']) { const maxAge = Number(/(?:^|,)\s*max-age=(\d+)/.exec(cc)?.[1] ?? NaN); assert.ok(maxAge > 0, `max-age must be positive for the browser to ever reuse a stored copy, got: ${cc}`); - assert.ok(!/no-store|private/.test(cc), - `a no-store / private value opts the page out of the ETag path entirely, got: ${cc}`); + // Only `no-store` opts a page out of the ETag path; `private` is validated + // normally (#1140), so asserting against it here would over-constrain the + // site (a `private, max-age=60` page would still 304 correctly). + assert.ok(!/no-store/.test(cc), + `a no-store value opts the page out of the ETag path entirely, got: ${cc}`); const etag = first.headers.get('etag'); assert.ok(etag, 'a cacheable 200 carries a validator');