feat(static): cache-bust frontend assets with versioned paths#299
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
A path segment is required rather than a
?v=query string: the relativeimportURLs 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, somain.jsloaded from/static/<v>/main.jspulls its entire import graph (and the@import-free CSS) from the same versioned prefix automatically — no per-file rewriting needed.What changed
static_assets.pyVersionedStaticFiles(StaticFiles)strips the leading<version>segment so the file still resolves on disk, and stamps versioned responsesCache-Control: public, max-age=31536000, immutable. Plain/static/...still works (the hardcodedunsupported-browser.htmlfallback 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, mountsVersionedStaticFiles, and exposes astatic_url('css/base.css')Jinja helper.main.html— CSS links, the entry module, and the favicon now go throughstatic_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 withimmutable(incl. a nested module under the same segment), unversioned still serves withoutimmutable, and a wrong version segment 404s.Also verified with a live server: home page emits
static/v1.0.6.<hash>/..., the versionedmain.jsand 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