Files
orion/docs/proposals/static-asset-cache-busting.md
Samir Boulahtit 54247ca4f0
All checks were successful
CI / ruff (push) Successful in 18s
CI / pytest (push) Successful in 2h50m43s
CI / validate (push) Successful in 33s
CI / dependency-scanning (push) Successful in 33s
CI / docs (push) Successful in 50s
CI / deploy (push) Successful in 1m15s
feat(static-assets): cache-bust JS/CSS via ?v=<commit-sha>, immutable in prod
Adds a `static_v(request, name, path=...)` Jinja helper that appends
?v=<commit-sha> from app.core.build_info, plus a CachedStaticFiles
subclass that serves Cache-Control: public, max-age=31536000, immutable
in production and no-cache in development. Browsers refetch JS/CSS
automatically on every deploy without the user having to hard-reload.

- New: app/core/static_files.py (CachedStaticFiles)
- Updated: app/templates_config.py (static_v helper)
- Updated: main.py (use CachedStaticFiles for *_static mounts)
- Codemod: 143 url_for('*_static', path='*.js'|'*.css') → static_v(...)
  across 123 templates. Images/fonts/JSON locales intentionally
  unchanged (out of scope).
- Arch rule: FE-024 (warning) flags raw url_for on JS/CSS to prevent
  drift. Note: FE-008 was already taken by the number_stepper rule.
- docs/proposals/static-asset-cache-busting.md marked Done.

Closes plan from docs/proposals/static-asset-cache-busting.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 19:35:59 +02:00

123 lines
5.0 KiB
Markdown

# Static Asset Cache-Busting Plan
**Date:** 2026-05-17
**Implemented:** 2026-05-18
**Status:** Done
**Motivation:** Users must hard-reload after every release to pick up new JS/CSS. We need versioned asset URLs that flip automatically on deploy so browsers refetch without manual intervention.
## Implementation summary
- `static_v(request, name, *, path)` Jinja global in `app/templates_config.py` — appends `?v=<commit-sha>` from `build_info`.
- `CachedStaticFiles` in `app/core/static_files.py` — subclass of `fastapi.staticfiles.StaticFiles` that sets `Cache-Control: public, max-age=31536000, immutable` in production and `no-cache` in development. Wired into the JS/CSS-bearing mounts in `main.py` (module statics + `/static`). Uploads and module locales keep default headers.
- Codemod migrated 143 `url_for('*_static', path='*.js'|'*.css')` callsites across 123 templates to `static_v(...)`.
- Architecture rule `FE-024` (warning) flags raw `url_for(...)` on JS/CSS so the pattern can't drift back. (Renamed from the proposed `FE-008`, which was already taken by the `number_stepper` rule.)
- Verified: `mkdocs build --strict` clean, `validate-architecture` reports no new findings, smoke test renders `?v=<sha>`.
---
## Existing Infrastructure (Already in Place)
- `app/core/build_info.py` reads `.build-info` (written by `scripts/deploy.sh`) for the deploy commit SHA, falling back to `git rev-parse --short=8 HEAD` for local dev.
- `app/templates_config.py:102-105` exposes `config.commit` and `config.deployed_at` as Jinja globals.
- All static refs use the `url_for('<module>_static', path='...')` pattern — **143 occurrences** across templates.
The version source is solved. The remaining work is: surface it on every static URL, and let browsers cache aggressively.
---
## Phase 1 — Versioned URL helper (~30 min)
Add one Jinja global in `app/templates_config.py`:
```python
def _asset_version() -> str:
info = get_build_info()
return info.get("commit") or info.get("deployed_at", "dev")[:14]
def static_v(request, name: str, *, path: str) -> str:
return f"{request.url_for(name, path=path)}?v={_asset_version()}"
templates.env.globals["static_v"] = static_v
```
Templates change from:
```jinja
<script src="{{ url_for('dev_tools_static', path='admin/js/sql-query.js') }}"></script>
```
to:
```jinja
<script src="{{ static_v(request, 'dev_tools_static', path='admin/js/sql-query.js') }}"></script>
```
`request` is already in scope in every Jinja2Templates render. No new params to thread.
---
## Phase 2 — Migrate the 143 callsites (~1 hr)
Codemod sweep. Decision point: **bust everything, or only JS/CSS?**
**Recommendation: JS/CSS only.** Images/fonts/icons rarely change in ways that matter mid-session, and the migration is smaller. Keep raw `url_for` for those.
Regex pattern (scoped to `.js` and `.css`):
```
url_for\(('[a-z_]+_static'), path=('[^']+\.(?:js|css))'\)
→ static_v(request, \1, path=\2)
```
Add an architecture rule `FE-008` that warns on raw `url_for('*_static', path='*.js'|'*.css')` to prevent drift. Severity: **warning** initially, **error** once we've cleared the codebase.
---
## Phase 3 — Server-side aggressive caching (~15 min)
With the version in the query string, static responses can cache forever:
```python
StaticFiles(directory=..., html=False)
# Wrap with middleware adding:
# Cache-Control: public, max-age=31536000, immutable
# for /static/... paths
```
This is the payoff. Without it, the version query string only forces a conditional GET; with it, the browser doesn't even hit the server until the version flips.
---
## Phase 4 — Verification
- Manual: hard-reload → soft-reload → confirm 304 or memory-cache.
- Smoke test: render any template, assert script tags contain `?v=`.
- Sanity: `scripts/deploy.sh` writes `.build-info` on every deploy (already does — confirm in CI).
---
## Open Decisions
1. **Bust everything, or just JS/CSS?** → Recommend JS/CSS only.
2. **Version source: commit SHA or deploy timestamp?** → SHA primary, timestamp fallback (already implemented by `build_info.py`).
3. **All 143 callsites in one PR, or incremental?** → All at once via codemod. Cleaner than maintaining two styles.
4. **`FE-008` severity at introduction?** → Warning, escalate to error after migration.
---
## Files Touched
- `app/templates_config.py` — add `static_v` helper.
- `app/templates/**/*.html` and `app/modules/*/templates/**/*.html` — codemod (JS/CSS refs only).
- `app/main.py` (or wherever `StaticFiles` is mounted) — add cache headers middleware.
- `.architecture-rules/frontend.yaml` (or equivalent) — `FE-008` rule.
- `docs/development/troubleshooting.md` — note the new `?v=` query string in case it confuses anyone debugging static loading.
---
## Out of Scope
- Per-file hashed filenames (Webpack-style). Not worth the build complexity given Jinja-rendered templates.
- CDN integration. Same `?v=` strategy works behind a CDN; revisit if/when one is introduced.
- Service worker caching. None today; revisit if PWA support is added.