Skip to content

frontend: content-hash assets, correct cache headers, enable gzip#182

Merged
ExtraToast merged 1 commit into
mainfrom
fix-asset-paths
May 10, 2026
Merged

frontend: content-hash assets, correct cache headers, enable gzip#182
ExtraToast merged 1 commit into
mainfrom
fix-asset-paths

Conversation

@ExtraToast

Copy link
Copy Markdown
Contributor

Summary

Three related fixes that together stop the broken-deploy symptoms reported on v2.esa-blueshell.nl (NS_BINDING_ABORTED, Unable to preload CSS for /assets/VCard-…css, dynamic-import failures) and improve cache hit rate / serving cost.

1. Pure content-hash asset filenames (vite.config.mjs)

Vite/Rollup default to assets/[name]-[hash].js — the [name] is the source module's filename, so a rename like Events.vue → EventsPage.vue changes the URL even though the bytes are identical. Switched to assets/[hash].js and assets/[hash][extname]. Filenames now change iff content (or transitively imported content) changes, so unchanged chunks and assets keep their URLs across deploys and stay cached in browsers and at the edge.

2. Correct Cache-Control for index.html and unhashed root statics (nginx.conf)

The previous config had two latent bugs:

  • index.html was served with no Cache-Control header (verified cf-cache-status: DYNAMIC, no header). Browsers fall back to heuristic caching from Last-Modified. After a deploy, users hold a stale index.html referencing chunk hashes the new build no longer ships → NS_BINDING_ABORTED cascade and Unable to preload CSS …. This is the root cause of the reported symptoms.
  • The catch-all extension regex ~* \.(js|css|png|jpg|...|webp|...)$ matched both hashed /assets/* files and unhashed /banner.webp, /favicon.png from public/, applying Cache-Control: public, max-age=31536000, immutable to all of them. Unhashed files with stable URLs cannot be immutable — once cached, clients never see updates for a year. Confirmed in production:
    $ curl -sI https://v2.esa-blueshell.nl/banner.webp
    cache-control: public, max-age=31536000, immutable
    

Fix:

  • /assets/ location is now ^~ so the prefix wins over any regex (in nginx, regex ~* beats unmodified prefix). Long-immutable cache stays on this prefix only.
  • The catch-all extension regex is removed.
  • Default location / (SPA fallback + unhashed root statics) sends Cache-Control: no-cache. ETag/Last-Modified make 304 revalidations cheap.

3. Origin gzip (nginx.conf)

nginx had no gzip config, so cache misses at Cloudflare and direct origin hits were uncompressed. Enabled gzip on for js/css/json/svg/wasm/fonts and gzip_static on so any future precompressed .gz artefacts are picked up automatically. Cloudflare still does brotli at the edge for cache hits.

Why these are bundled

All three changes share the same domain (frontend caching/serving) and the same review surface (vite.config.mjs + nginx.conf). Splitting them would just create churn — and in particular, removing [name] from filenames without also fixing the stale-index.html problem would still leave deploys broken for users with a cached HTML.

Out of scope (possible follow-ups)

  • build.rollupOptions.output.manualChunks to split out vue, vuetify, etc. into vendor chunks that change only when those deps change. Real win for repeat visitors, but bundle-size analysis should drive it. Happy to do as a follow-up.
  • build.sourcemap: 'hidden' so anonymous chunk filenames are still debuggable from devtools / error trackers.
  • vite-plugin-compression to ship precompressed .gz/.br artefacts; gzip_static on is already in place to consume them.
  • The 30 s root-document load time observed in the original trace is unrelated to caching (origin/Cloudflare slowness on dynamic HTML); separate investigation.

Test plan

  • yarn build locally — verify dist/assets/ contains .js/.css/asset files named purely as <hash>.<ext> with no source-derived prefix.
  • Build & run the container: curl -sI http://localhost:3000/ returns cache-control: no-cache; curl -sI http://localhost:3000/assets/<hash>.js returns immutable, max-age=31536000; curl -sI http://localhost:3000/banner.webp returns no-cache.
  • curl -H 'Accept-Encoding: gzip' -sI http://localhost:3000/assets/<hash>.js returns content-encoding: gzip.
  • After deploy: load v2.esa-blueshell.nl, confirm no NS_BINDING_ABORTED / preload errors. Force-refresh and verify hashed assets return 200 from cache, index.html shows a 304 revalidation on subsequent loads.
  • Roll a no-op redeploy: confirm previously cached /assets/<hash>.* URLs are reused (304 / disk cache) and only changed chunks get new filenames.

🤖 Generated with Claude Code

…le gzip

- Vite output is now `assets/[hash].(js|ext)` (no source-derived `[name]`
  prefix). Filenames change only when bytes change, so unchanged chunks
  and assets keep their URLs across deploys and stay cached.
- nginx serves index.html and unhashed root statics (favicon.png,
  banner.webp) with `Cache-Control: no-cache`, fixing two bugs: stale
  index.html pinning users to vanished chunk URLs after a deploy
  (NS_BINDING_ABORTED, "Unable to preload CSS …"), and unhashed images
  being served `immutable, max-age=31536000` so updates never reach
  cached clients.
- `/assets/` location switched to `^~` so the prefix wins over regex
  location matching; the catch-all extension regex (which was masking
  the prefix and applying immutable to unhashed files) is removed.
- nginx gzip on for js/css/json/svg/wasm/fonts; `gzip_static on` so any
  future precompressed artefacts are served automatically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@ExtraToast ExtraToast merged commit 333c93d into main May 10, 2026
14 checks passed
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