Skip to content

fix(pages): restore the cache semantics four assets lost - #1200

Merged
TaprootFreak merged 2 commits into
developfrom
fix/pages-asset-cache-rules
Jul 28, 2026
Merged

fix(pages): restore the cache semantics four assets lost#1200
TaprootFreak merged 2 commits into
developfrom
fix/pages-asset-cache-rules

Conversation

@TaprootFreak

@TaprootFreak TaprootFreak commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Follow-up to #1198. That PR removed the Azure deploy branch; this one closes the gaps it made visible.

public/_headers opens with a promise:

Cloudflare Pages edge headers — replicate the cache/CORS semantics the previous CDN served, so the Pages deployment behaves identically.

Four assets break it. Measured against the deployed site rather than assumed:

Asset previous CDN today after this PR
/robots.txt no-cache, must-revalidate public, max-age=14400, must-revalidate see the correction in the comments — the rule does not take effect
/favicon.ico public, max-age=86400 public, max-age=14400, must-revalidate public, max-age=86400
/logo.png public, max-age=86400 public, max-age=14400, must-revalidate public, max-age=86400
the wasm chunk public, max-age=31536000, immutable public, max-age=0, must-revalidate public, max-age=31536000, immutable
/manifest.json no-cache, must-revalidate no-cache, must-revalidate unchanged

The previous values are the ones the removed upload step set: index.html, manifest.json, asset-manifest.json and robots.txt shared one no-cache loop, *.ico/*.png were uploaded with a day of cache, and *.wasm was uploaded as immutable through a pattern that matched the whole build tree.

The wasm chunk

This is the one worth reading closely. Before this PR, config-overrides.js set webassemblyModuleFilename only inside the branch guarded by CUSTOM_CHUNK_PATH, and only scripts/build-widget.sh sets that variable. The main build therefore fell back to the webpack default and wrote the chunk into the build root: asset-manifest.json on the deployed site lists "module.wasm": "/c2d852c5a3680f3096b5.module.wasm", and that URL answers with cache-control: public, max-age=0, must-revalidate — roughly 700 KB of hardware-wallet driver revalidating on every use, where the previous CDN served it as immutable.

The filename is content-hashed, so the file belongs where every other fingerprinted asset already lives. This PR sets the default output path to static/wasm/, which the existing /static/* rule covers, and the widget branch keeps overriding it with v<version>-chunks/ as before.

Moving the file rather than adding a /*.module.wasm rule is a judgement call, not a technical necessity — Cloudflare does support splat patterns of that shape. The reason to prefer the move: the file is fingerprinted, so it belongs in the bucket the existing rule already describes, and one rule keeps covering it instead of a second rule existing for a single asset.

Verified with a real build rather than argued: npm run build:dev now emits build/static/wasm/c2d852c5a3680f3096b5.module.wasm — same hash as the file live today, so same content in a covered location — and leaves no .wasm in the build root. npm run widget:dev still emits widget/v1.0-chunks/<hash>.module.wasm, which is the path the Copy widget step in both pipelines copies from. That /static/* matches across path segments is not an assumption either: /static/js/main.6f300bae.js answers with public, max-age=31536000, immutable on the deployed site.

The three root files

robots.txt matters in practice: a change to crawler directives should take effect on the next request. It now carries its own comment so that reason is visible in the file rather than only in this description.

favicon.ico and logo.png are restored to the day of cache the previous CDN gave them. Neither is fingerprinted — public/index.html references both by plain path — so this is a trade rather than a pure win: a replaced logo can sit in a browser cache for a day instead of four hours. That was the behaviour before the migration and the file claims that value, so it should hold it. If the shorter window is preferred, the right fix is to say so in _headers explicitly rather than to leave the entry missing.

What is deliberately left without a rule

version.json is written to public/version.json at build time by scripts/generate-version.js and is gitignored, so it never shows up in the committed tree — but it reaches the deployed root exactly like the other files in public/. It still gets no rule, because it does not need one: Cloudflare's default for it is already public, max-age=0, must-revalidate, which is what a version marker wants. With that, every file the deployed root contains is either covered by a rule or correct by default.

/manifest.json is the evidence that exact paths take effect at all: it has an exact rule and the deployed site returns exactly that value.

Scope

public/_headers and config-overrides.js. Existing rules keep their values and their order; the widget build path is untouched.

One correction that lives in the history

The first commit's message says the new comment makes visible "the reason it must not be cached". That is imprecise, in the same way the comment itself was: no-cache, must-revalidate does not forbid storing the response, it forbids reusing it without a successful revalidation — only no-store forbids storing. The second commit corrects the comment in the file and spells out the distinction, but the first commit's own prose cannot be corrected without rewriting a pushed commit, so it is flagged here instead. On a squash merge, the second commit's wording is the accurate one.

public/_headers promises to replicate what the previous CDN served, but four
assets fall through it. robots.txt, favicon.ico and logo.png have no rule at
all and take Cloudflare's default instead of the values the upload step used
to set. The WebAssembly chunk of the main build lands in the build root,
because config-overrides.js set webassemblyModuleFilename only in the branch
guarded by CUSTOM_CHUNK_PATH, which only the widget build sets; it is served
with max-age=0 today, where the previous step served it as immutable.

That chunk is content-hashed, so it belongs with the other fingerprinted
assets: the default output path now points at static/wasm/, which the
existing /static/* rule already covers, and the widget build keeps
overriding it with its own versioned path. A /*.module.wasm rule would work
as well, since Cloudflare does support splat patterns of that shape, but
letting one rule keep covering the whole fingerprinted bucket is the smaller
change.

robots.txt also gets its own comment, so the reason it must not be cached is
visible in the file itself rather than only in the pull request.
The comment above the robots.txt rule said "do not cache", but the value it
describes is no-cache, must-revalidate. That does not forbid storing the
response; it forbids reusing it without a successful revalidation, and only
no-store forbids storing. The entry-point comment a few lines above already
words it that way, so the two now match.

The header value itself is unchanged.
@TaprootFreak

Copy link
Copy Markdown
Collaborator Author

Six review passes over two lenses — project conformity and logic/correctness — until both came back with zero findings. They were worth it: the passes are what turned up the wasm chunk in the first place, and then three wrong statements of mine about it.

What changed along the way:

  • The first pass found that the main build's WebAssembly chunk sits in the build root, uncovered by any rule. The original version of this PR claimed it was already covered by /widget/*. It was not, and that claim is gone.
  • A later pass showed that the reason given for moving the file instead of adding a /*.module.wasm rule was wrong: Cloudflare does document splat patterns of that shape. The move is still the better option, but for a different reason, and the description now says so.
  • The last passes corrected the description of no-cache itself — it permits storing a response and forbids reusing it without revalidation; only no-store forbids storing.

Both build paths were run rather than reasoned about: npm run build:dev puts the chunk at build/static/wasm/<hash>.module.wasm with the same hash the deployed file carries today and leaves no .wasm in the build root, and npm run widget:dev still writes widget/v1.0-chunks/<hash>.module.wasm, which is the path the Copy widget step copies from. Every cache value in the description was measured against the deployed site, not assumed.

One practical note for merging. If this is squashed, GitHub prefills the message with both commit messages, and the first one still contains the imprecise "must not be cached" wording described at the end of the body. It cannot be corrected in place without rewriting a pushed commit. A message that reflects the final state:

fix(pages): restore the cache semantics four assets lost (#1200)

robots.txt, favicon.ico and logo.png had no rule in public/_headers and
fell through to Cloudflare's default instead of the values the previous
upload step set. The main build's WebAssembly chunk landed in the build
root, uncovered by any rule and served with max-age=0, because
config-overrides.js set webassemblyModuleFilename only in the branch the
widget build enables.

The chunk is content-hashed, so its default output path now points at
static/wasm/, which the existing /static/* rule covers; the widget build
keeps overriding it with its own versioned path. robots.txt carries its
own comment explaining that it must always revalidate.

@TaprootFreak
TaprootFreak marked this pull request as ready for review July 28, 2026 11:29
@TaprootFreak
TaprootFreak merged commit 2b23cbd into develop Jul 28, 2026
6 checks passed
@TaprootFreak
TaprootFreak deleted the fix/pages-asset-cache-rules branch July 28, 2026 12:08
@TaprootFreak

Copy link
Copy Markdown
Collaborator Author

Measured after the deploy. Three of the four assets are exactly as intended, one is not:

Path before now
/static/wasm/<hash>.module.wasm public, max-age=0, must-revalidate (in the build root) public, max-age=31536000, immutable
/favicon.ico public, max-age=14400, must-revalidate public, max-age=86400
/logo.png public, max-age=14400, must-revalidate public, max-age=86400
/robots.txt public, max-age=14400, must-revalidate max-age=14400, must-revalidaterule not applied

So the wasm chunk, which was the point of this PR, is served as immutable now, and the two root assets carry the day of cache they are supposed to.

robots.txt does not, and the reason is worth recording. The values that arrive depend on whether Cloudflare caches the file at all:

  • manifest.json and index.html come back DYNAMIC — not cached at the edge — and their no-cache, must-revalidate passes through untouched. That is why the rule looked proven.
  • favicon.ico, logo.png and robots.txt are cached (MISS on a fresh URL, so served from origin). For those, an explicit max-age is respected — both assets return the 86400 this PR set — but no-cache, which carries no max-age, is replaced by the zone's browser cache TTL of four hours.

A cache-busting query string returns the same value, so this is not a stale edge copy.

The fix is to state the same intent with an explicit number: public, max-age=0, must-revalidate means "always revalidate" just as no-cache does, but it gives the edge a value to respect rather than one to fill in. I will put that up as a follow-up rather than leave a rule in the file that reads as applied and is not — the exact failure mode this PR argued against when it chose to move the wasm file instead of guessing at a pattern.

The table in the description above is corrected accordingly; the after this PR value it claimed for robots.txt did not materialise.

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