Skip to content

fix(server): serve contract webapp HTML with Cache-Control: no-store - #5886

Merged
sanity merged 8 commits into
mainfrom
fix-5323
Oct 14, 2026
Merged

sanity merged 8 commits into
mainfrom
fix-5323

Conversation

@sanity

@sanity sanity commented Oct 14, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

A contract's bundle is mutable at a stable URL: fdev website update changes what /v{1,2}/contract/web/<key>/… returns while every URL stays the same. Contract web responses carried no cache directive, so a browser could keep running a superseded build after a republish. The app cannot fix this itself, because the stale document is the one that would have carried the fix.

What this changes

Responses under /{key}/ and /{key}/{*path} (v1 and v2) whose Content-Type is HTML (text/html, application/xhtml+xml) are now sent with Cache-Control: no-store. That covers the shell page, the sandboxed contract HTML, and raw HTML that ServeFile serves when an app frames its own pages. Nothing else changes. It is a SetResponseHeaderLayer::overriding route layer (no_store_html in client_api.rs) rather than a header set in each handler, because HTML leaves through several unrelated pipelines and a header wired into only some of them reproduces the bug. It uses route_layer so the sub-router's 404 fallback is never wrapped, and the no-trailing-slash 308 stays outside (#4019).

Assets are deliberately left alone. An earlier revision of this PR set no-store on everything. The skeptical review found that this regresses try.freenet.org: its reverse proxy adds Cache-Control: public, max-age=31536000, immutable to files directly under assets/ and passes the node's header through alongside, so the browser would honour the node's no-store and re-download River's ~1.87 MB (gzipped) wasm on every load. The JS rewrite branch is also left alone. It sends no Last-Modified and no validator, so browsers give it no heuristic freshness and refetch it on normal loads anyway, and a no-store on it would cancel the same proxy caching for hashed JS.

This enables tower-http's set-header feature (no new crate; Cargo.lock is unchanged). .claude/rules/browser-assets.md §6 records the invariant and the reason assets are excluded.

How much of #5323 this fixes

I measured against the node on nova. The shell and sandboxed HTML responses never carried Last-Modified or a validator, so a normal reload already refetched them. That means for apps with content-hashed assets, the "a reload doesn't help" symptom did not come from the HTML. This PR fixes the HTML half:

  • History navigations: back/forward, session restore and discarded-tab reloads reuse cached documents without revalidating. no-store also makes the shell ineligible for the back/forward cache in some browsers, so Back now reloads it instead of restoring it. The shell's live WebSockets already made that mostly the case, and the existing Back-navigation Playwright test (feat: integrate browser history with gateway iframe navigation #3839) passes.
  • Raw HTML served by ServeFile: it carries Last-Modified, so it was heuristically cacheable.
  • The shell itself: it embeds a freshly minted auth token and sets a cookie, and it is no longer kept in any cache.

The asset half remains, and it is tracked in #5888. ServeFile assets carry Last-Modified and an ETag, so browsers treat them as heuristically fresh for about 10% of their age. A normal reload keeps using them. #5323's statement that assets have no validators was wrong for these: the validators exist, but variable_content drives ServeFile with a synthesized request, so conditional requests are never evaluated. First-party apps, checked on 2026-10-14:

This PR fixes the HTML half of #5323, and #5888 carries the asset half with the evidence above. A commit message in this branch links #5323, so merging will close it. That is intentional: #5888 is the single open issue for what remains.

Testing

  • client_api::tests::contract_html_is_served_no_store_and_assets_are_not drives the real composed router (as_router_with_origin_contracts) against a stub node that serves a real packed bundle. For v1 and v2 it requests seven URLs, one per pipeline: shell, deep-link shell, sandboxed root, sandboxed sub-page, raw ServeFile HTML, the JS rewrite branch and a ServeFile CSS asset. Each row asserts a 200, a pipeline-specific body, the expected Cache-Control (no-store for HTML, none for assets), and whether Last-Modified is present. That last check pins the premises above and tells the raw-HTML row apart from the sandbox rows.
    • It also covers HEAD, the sandbox-as-document 303 and the 308.
    • As a control outside the subtree, the node homepage, which is HTML, must not get no-store, and the notification worker keeps max-age=3600.
  • client_api::tests::no_store_html_overrides_only_html_and_wraps_only_its_routes checks that HTML and XHTML with max-age=3600 become no-store, that CSS is untouched, that an HTML route outside the wrapper is untouched, and that an HTML fallback inside the wrapper is untouched.
  • Mutation checks, each confirmed red then reverted:
    • Forcing is_html_response to true fails both tests.
    • Forcing it to false also fails both.
    • Swapping route_layer for layer fails the unit test.
    • Wrapping the whole router fails the router test at the homepage control.
  • crates/core/tests/playwright/tests/contract-cache-headers.spec.ts checks in a real browser that the shell and the sandboxed iframe documents are no-store and the 308 is not. I ran the full suite locally at 11da5f89b: 180 passed across chromium, firefox and webkit. At an earlier revision, removing the layer failed the spec in all three engines.
  • cargo test -p freenet --lib server:: passes, and cargo clippy -p freenet --all-targets -- -D warnings is clean.

Review

Several Claude lenses reviewed this. Code-first, testing, skeptical and big-picture ran on the first revision. Skeptical and code-first ran again on the narrowed revision d18c4ba84, and a skeptical read covered the test fix-up 11da5f89b. The findings are addressed. The external pass did not run: codex review --base origin/main failed twice with a usage-limit error, so these Claude lenses stand in for it.

[AI-assisted - Claude]

https://claude.ai/code/session_01GKBuAzByqMPegsXcUYJb7h

Contract web responses carried no cache directive or validator, so a
browser that had a webapp open kept running the superseded bundle after
`fdev website update` until a hard refresh. The app cannot fix this
itself: the stale document is the one that would carry the fix.

Apply `Cache-Control: no-store` as a layer over the
`/v{1,2}/contract/web/{key}/` and `/{key}/{*path}` routes, so all four
pipelines that emit contract bytes (shell page, sandboxed HTML, the JS
rewrite branch, ServeFile) get it from the route tree rather than per
handler. The no-trailing-slash 308 stays outside the layer so it remains
cacheable (#4019).

Adds a router-level test that drives every pipeline through the real
composed router for v1 and v2 against a stub node, asserting a 200, a
pipeline-specific body, and the header, plus its absence on the 308.

Closes #5323

Claude-Session: https://claude.ai/code/session_01GKBuAzByqMPegsXcUYJb7h
Adds a Playwright spec checking that the shell and the sandboxed app
document carry Cache-Control: no-store through the node's production
composition, and that the no-slash 308 does not. Also replaces
let _ = send(..) in the router test's stub node with expect, which
clippy's let_underscore_must_use requires.

Claude-Session: https://claude.ai/code/session_01GKBuAzByqMPegsXcUYJb7h
@github-actions

github-actions Bot commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Claude Rule Review: Failed to run

The Claude rule review step failed (likely an authentication or API issue). Check the workflow logs for details.


Advisory review against .claude/rules/. Critical patterns are enforced by the Rule Lint CI job.

- route_layer instead of layer, so the content sub-router's default 404
  fallback is never wrapped (merged fallbacks replace each other, so a
  wrapped one could become the node-wide 404 depending on merge order).
- New unit test pins no_store's semantics: it overrides a cacheable header
  a handler set, wraps only the routes it is given, and leaves the 404
  fallback alone.
- Router test: HEAD and the sandbox-as-document 303 are no-store; routes
  outside the subtree (notify worker max-age=3600, /v1/version, unmatched
  paths) are not. Stub node no longer panics inside its task when a
  handler has dropped its receiver.
- Reword path_handlers comments that said #5323 was unfixed.

Claude-Session: https://claude.ai/code/session_01GKBuAzByqMPegsXcUYJb7h
Re-review of the follow-up: the controls asserted no Cache-Control at all
without checking status, and their comment overstated what the 404 case
discriminates. Now they assert 200/404 and only that no-store did not
leak. Also reword the asset_not_found_response comment.

Claude-Session: https://claude.ai/code/session_01GKBuAzByqMPegsXcUYJb7h
@sanity

sanity commented Oct 14, 2026

Copy link
Copy Markdown
Collaborator Author

Review summary

Tier and lenses. Light by the risk list, but this touches every locally served webapp, so it got four independent Claude lenses at cafe9f963: code-first, testing, skeptical and big-picture. A skeptical re-review of the follow-up delta ran at 52e764d53. The external pass did not run. codex review --base origin/main failed twice with "You've hit your usage limit", so the extra Claude lenses stand in for it.

Blocking findings: none.

Addressed in this PR

  • The #5323 ... this is not that fix comment in path_handlers.rs was stale. Reworded, along with the ServeFile 404 comment.
  • The sub-router's default 404 fallback was wrapped by layer, and merged fallbacks replace one another, so depending on merge order it could have become the node-wide 404. Switched to route_layer.
  • overriding was not pinned by any test. Added no_store_overrides_handler_headers_and_wraps_only_its_routes. It fails if overriding becomes if_not_present, and also if route_layer becomes layer.
  • The router test had no negative controls. It now checks that /freenet-notify-sw.js keeps max-age=3600 and that /v1/version and unmatched paths are not no-store, with their statuses asserted. It also covers HEAD and the sandbox-as-document 303.
  • The stub node could panic inside its spawned task, where the panic would be swallowed. It now ignores a send to a handler that has gone away.
  • The wording in the Playwright spec comment and the PR description was fixed. The description now records the costs: assets are re-downloaded on every load, the perf(server): gateway serves ~106 KB of uncompressed JS per contract page; no CompressionLayer anywhere #5111 interaction, the back/forward cache, and rollout.
  • .claude/rules/browser-assets.md §6 now records the invariant that new contract-content routes go inside no_store(...).

Not code defects, recorded in the description

  • try.freenet.org: nova's Caddy adds immutable caching on assets/*, and the node's no-store will override it in browsers. This needs a Caddy >Cache-Control change before the hosted node runs a release containing this PR. That is ops work outside this repo.
  • Follow-up: validators plus no-cache, to bring back 304s for assets.
  • Not tested: a 405 on a content path. It is covered by construction, since route_layer wraps the method router's fallback.

[AI-assisted - Claude]

…alone

no-store on every contract-web response cancelled try.freenet.org's
reverse-proxy immutable caching of hashed assets/* (Caddy adds its header
and passes the node's through; browsers honour no-store), so every hosted
River load would re-download its wasm. Narrow the route layer to responses
whose Content-Type is text/html: the shell, the sandboxed HTML and raw HTML
served through ServeFile. The HTML names the assets, so this keeps
content-hashed apps fresh.

The JS rewrite branch is left alone: it sends no Last-Modified or
validator, so browsers never treat it as heuristically fresh, and a
no-store there would cancel the same proxy caching for hashed JS.

Unhashed ServeFile assets stay heuristically cacheable through
Last-Modified; validators for them are a follow-up.

Claude-Session: https://claude.ai/code/session_01GKBuAzByqMPegsXcUYJb7h
Re-review of the HTML-only scope found controls that could no longer
fail: the 308, /v1/version and the 404 are not HTML, so the layer leaves
them alone wherever it is applied. Now:
- the out-of-subtree control is the node homepage (HTML), which catches
  the layer being widened to the whole router;
- no_store_html's unit test gives the sub-router an HTML fallback, so
  wrapping the fallback (layer instead of route_layer) shows;
- each router-test row asserts whether Last-Modified is present, pinning
  that the JS rewrite branch has none and ServeFile assets do, and
  telling raw ServeFile HTML apart from the sandbox rows;
- application/xhtml+xml counts as HTML;
- the Playwright spec requires both documents among the HTML responses.
Comments now cite #5888 and describe the Caddy rule accurately.

Claude-Session: https://claude.ai/code/session_01GKBuAzByqMPegsXcUYJb7h
Comment-only: the try.freenet.org @assets matcher covers files directly
under assets/, and the no-store match includes application/xhtml+xml.

Claude-Session: https://claude.ai/code/session_01GKBuAzByqMPegsXcUYJb7h
@sanity sanity changed the title fix(server): serve contract webapp content with Cache-Control: no-store fix(server): serve contract webapp HTML with Cache-Control: no-store Oct 14, 2026
@sanity

sanity commented Oct 14, 2026

Copy link
Copy Markdown
Collaborator Author

Review update: scope narrowed to HTML documents

The first review's skeptical lens showed that no-store on every contract-web response would cancel try.freenet.org's reverse-proxy immutable caching of assets/*. The scope is now HTML documents only (d18c4ba84), and the asset half moved to #5888.

Re-review of the narrowed head

Mutation checks at the new head (each red, then reverted): forcing the HTML predicate to true, forcing it to false, swapping route_layer for layer, and wrapping the whole router.

Codex was unavailable (usage limit, retried once), so these Claude lenses stand in for it.

[AI-assisted - Claude]

@sanity
sanity added this pull request to the merge queue Oct 14, 2026
Merged via the queue into main with commit e0809f7 Oct 14, 2026
18 checks passed
@sanity
sanity deleted the fix-5323 branch October 14, 2026 15:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant