Skip to content

feat(static): cache-bust frontend assets with versioned paths#299

Merged
lstein merged 3 commits into
masterfrom
lstein/feature/cache-bust-static-assets
Jun 5, 2026
Merged

feat(static): cache-bust frontend assets with versioned paths#299
lstein merged 3 commits into
masterfrom
lstein/feature/cache-bust-static-assets

Conversation

@lstein
Copy link
Copy Markdown
Owner

@lstein lstein commented Jun 5, 2026

Problem

The frontend is served straight off disk with no bundler, so a browser — iOS Safari especially — can serve a stale cached module or stylesheet after an upgrade. This previously masqueraded as a phantom slideshow regression when the on-disk file was actually byte-identical to the working release.

Approach: path-based versioning

Assets are now referenced under a content-versioned path segment:

static/v1.0.6.<hash>/css/base.css     (was: static/css/base.css)
static/v1.0.6.<hash>/main.js          (was: static/main.js)

A path segment is required rather than a ?v= query string: the relative import URLs inside an ES module resolve against the importing module's own URL with the query stripped, so a query would only bust the entry point. A path segment is preserved through relative resolution, so main.js loaded from /static/<v>/main.js pulls its entire import graph (and the @import-free CSS) from the same versioned prefix automatically — no per-file rewriting needed.

What changed

  • static_assets.py
    • VersionedStaticFiles(StaticFiles) strips the leading <version> segment so the file still resolves on disk, and stamps versioned responses Cache-Control: public, max-age=31536000, immutable. Plain /static/... still works (the hardcoded unsupported-browser.html fallback relies on it).
    • compute_asset_version() = sha1 of the served text assets (.js/.css/.html/.webmanifest) + package version. It changes only when the assets change, so caches stay warm across restarts and identical deploys; binary icons don't perturb it.
  • photomap_server.py — computes the version once at startup, mounts VersionedStaticFiles, and exposes a static_url('css/base.css') Jinja helper.
  • main.html — CSS links, the entry module, and the favicon now go through static_url(...).

Tests

tests/backend/test_static_assets.py (8 tests): version stability, content/version sensitivity, binary-asset insensitivity, home page renders versioned URLs, versioned asset serves with immutable (incl. a nested module under the same segment), unversioned still serves without immutable, and a wrong version segment 404s.

Also verified with a live server: home page emits static/v1.0.6.<hash>/..., the versioned main.js and a deep module (javascript/state.js) both serve 200 under the same segment, and the unversioned fallback path still works. Full suite green (backend 343, frontend 349); ruff clean.

🤖 Generated with Claude Code

lstein and others added 2 commits June 5, 2026 07:35
Browsers — iOS Safari in particular — could serve a stale ES6 module or
stylesheet after an upgrade, since the frontend is served straight off
disk with no bundler. This once looked like a phantom slideshow
regression when the on-disk file was actually byte-identical to the
working release.

Assets are now referenced under a content-versioned path segment,
static/<version>/css/base.css instead of static/css/base.css. A path
segment (not a ?v= query) is required because the relative import URLs
inside a module resolve against the module's own URL with the query
stripped — only a path segment is preserved through that resolution, so
main.js loaded from /static/<v>/main.js pulls its whole import graph
(and @import-free CSS) from the same versioned prefix automatically.

- VersionedStaticFiles strips the leading <version> segment back off so
  the file resolves on disk, and stamps versioned responses immutable so
  caches can hold them forever (a new release changes the URL). Plain
  /static/... still works for the hardcoded unsupported-browser fallback.
- The version is a sha1 of the served text assets + package version, so
  it changes only when the assets do — caches stay warm across restarts
  and identical deploys.
- Templates pick up the version via a static_url() Jinja helper.

Adds tests for the version computation and the versioned/unversioned/
immutable/404 serving behaviour.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
StaticFiles.get_path normalises the request path with os.path.normpath,
so on Windows get_response receives backslash separators (v1.2.3\main.js).
The version-prefix check compared against a forward-slash prefix, so the
segment was never stripped and every versioned asset 404'd on Windows
(CI: run_tests windows-latest). Normalise to forward slashes before the
comparison.

Also assert on the served body rather than the .js content-type, which
varies by OS/registry, and add a regression test that feeds a backslash
path straight into get_response so the failure is reproducible on any
platform.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@lstein lstein enabled auto-merge (squash) June 5, 2026 11:52
@lstein lstein merged commit b2f96aa into master Jun 5, 2026
9 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