Unify static asset cache-busting with the auto-bumped app version
DevPlace CI / test (push) Failing after 27m37s

config.STATIC_VERSION becomes f"{APP_VERSION}-{BOOT_ID}": APP_VERSION is
read live from pyproject.toml's version (auto-bumped on every commit by
.githooks/pre-commit), BOOT_ID is the same per-process launch marker as
before (DEVPLACE_STATIC_VERSION env or a wall-clock fallback). The static
asset URL /static/v<version>/... now names the actual commit's version
alongside the boot marker, instead of a bare timestamp.

Fixes nginx/nginx.conf.template's versioned-mount location regex, which
matched digits only (^/static/v\d+/) and would have silently dropped the
immutable, max-age=31536000 cache header for every asset in production
once the version segment carried a dot or hyphen. Verified live against a
running instance that the header still applies to the new URL shape.

Updates README, devplacepy/static/js/CLAUDE.md, and the
/docs/static-caching.html page to describe the new APP_VERSION/BOOT_ID
composition.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TwLhnueWrsK15wrieXE5m7
This commit is contained in:
2026-09-07 16:27:28 +02:00
co-authored by Claude Sonnet 5
parent 6b9c48661a
commit 85bd8fad47
7 changed files with 41 additions and 27 deletions
+2 -2
View File
@@ -267,7 +267,7 @@ The log is **administrator-only**. `/admin/audit-log` is a paginated, filterable
| `DEVPLACE_INTERNAL_BASE_URL` | `http://localhost:10500` | Base URL the platform's own services dial for the AI gateway | | `DEVPLACE_INTERNAL_BASE_URL` | `http://localhost:10500` | Base URL the platform's own services dial for the AI gateway |
| `DEVPLACE_XMLRPC_PORT` | `10550` | Loopback port the forking XML-RPC bridge binds; the app and nginx reverse-proxy `/xmlrpc` to it | | `DEVPLACE_XMLRPC_PORT` | `10550` | Loopback port the forking XML-RPC bridge binds; the app and nginx reverse-proxy `/xmlrpc` to it |
| `DEVPLACE_XMLRPC_BIND` | `127.0.0.1` | Bind address for the XML-RPC bridge (loopback; the app and nginx are the intended front doors) | | `DEVPLACE_XMLRPC_BIND` | `127.0.0.1` | Bind address for the XML-RPC bridge (loopback; the app and nginx are the intended front doors) |
| `DEVPLACE_STATIC_VERSION` | server boot unix timestamp | Cache-busting version stamped into every static asset URL (`/static/v<version>/...`). Set it at launch so multiple workers agree (the `prod` target and Docker image do this); leave unset in dev to refresh on each reload. See [Static asset caching](#static-asset-caching) | | `DEVPLACE_STATIC_VERSION` | server boot unix timestamp | The boot-id half of the cache-busting version stamped into every static asset URL (`/static/v<app-version>-<boot-id>/...`, e.g. `/static/v1.0.1-1718040000/...`). Set it at launch so multiple workers agree (the `prod` target and Docker image do this); leave unset in dev to refresh on each reload. See [Static asset caching](#static-asset-caching) |
| `DEEPSEEK_API_KEY` / `OPENROUTER_API_KEY` | unset | Upstream provider keys; migrated into the gateway settings on first boot | | `DEEPSEEK_API_KEY` / `OPENROUTER_API_KEY` | unset | Upstream provider keys; migrated into the gateway settings on first boot |
| `DEVPLACE_PRESENCE_TIMEOUT_SECONDS` | `60` | Online-presence window: a user counts as online for this many seconds after their last activity. `last_seen` is refreshed by a throttled in-place update at most once per half this interval per worker (no per-load inserts, no data growth) | | `DEVPLACE_PRESENCE_TIMEOUT_SECONDS` | `60` | Online-presence window: a user counts as online for this many seconds after their last activity. `last_seen` is refreshed by a throttled in-place update at most once per half this interval per worker (no per-load inserts, no data growth) |
| `DEVPLACE_PRESENCE_ONLINE_LIMIT` | `30` | Maximum avatars shown in the feed's live "Online now" panel (ordered alphabetically by username) | | `DEVPLACE_PRESENCE_ONLINE_LIMIT` | `30` | Maximum avatars shown in the feed's live "Online now" panel (ordered alphabetically by username) |
@@ -1202,7 +1202,7 @@ reverse_proxy localhost:10500 {
### Static asset caching ### Static asset caching
Static assets (CSS, JS, vendored libraries) are served with a **one-year immutable cache** for the best Lighthouse "efficient cache policy" score, while deploys still take effect immediately. Every app-owned static URL carries a boot-time version path segment, `/static/v<timestamp>/...`, where `<timestamp>` is the unix time the server process started (`config.STATIC_VERSION`). A restart changes the segment, so every asset URL changes and returning browsers refetch on their next page load - no cache purge, no hashing build step. Static assets (CSS, JS, vendored libraries) are served with a **one-year immutable cache** for the best Lighthouse "efficient cache policy" score, while deploys still take effect immediately. Every app-owned static URL carries a version path segment, `/static/v<app-version>-<boot-id>/...` (`config.STATIC_VERSION`) - `<app-version>` is `pyproject.toml`'s `version` (auto-bumped on every commit, see "Version bumping" in `CLAUDE.md`) and `<boot-id>` is the unix time the server process started. A restart changes the segment, so every asset URL changes and returning browsers refetch on their next page load - no cache purge, no hashing build step.
The version sits in the **path**, not a query string, because the frontend is unbundled ES6 modules wired with relative imports: a path segment is inherited automatically by every transitively imported module and relative CSS `url()`, so the whole graph busts on deploy. Templates emit URLs through the `static_url` Jinja global and runtime JavaScript through the `assetUrl` helper (`static/js/assetVersion.js`, reading `<meta name="asset-version">`). User uploads under `/static/uploads/` and the `service-worker.js` route are excluded. Set `DEVPLACE_STATIC_VERSION` at launch so multiple workers share one value (the `prod` target and Docker image do this). Full detail: `/docs/static-caching.html`. The version sits in the **path**, not a query string, because the frontend is unbundled ES6 modules wired with relative imports: a path segment is inherited automatically by every transitively imported module and relative CSS `url()`, so the whole graph busts on deploy. Templates emit URLs through the `static_url` Jinja global and runtime JavaScript through the `assetUrl` helper (`static/js/assetVersion.js`, reading `<meta name="asset-version">`). User uploads under `/static/uploads/` and the `service-worker.js` route are excluded. Set `DEVPLACE_STATIC_VERSION` at launch so multiple workers share one value (the `prod` target and Docker image do this). Full detail: `/docs/static-caching.html`.
+4 -1
View File
@@ -1,6 +1,7 @@
# retoor <retoor@molodetz.nl> # retoor <retoor@molodetz.nl>
import time import time
import tomllib
from pathlib import Path from pathlib import Path
from dotenv import load_dotenv from dotenv import load_dotenv
from os import environ from os import environ
@@ -68,7 +69,9 @@ PRESENCE_ONLINE_MARGIN_SECONDS = int(
XMLRPC_BIND = environ.get("DEVPLACE_XMLRPC_BIND", "127.0.0.1") XMLRPC_BIND = environ.get("DEVPLACE_XMLRPC_BIND", "127.0.0.1")
XMLRPC_PORT = int(environ.get("DEVPLACE_XMLRPC_PORT", "10550")) XMLRPC_PORT = int(environ.get("DEVPLACE_XMLRPC_PORT", "10550"))
STATIC_VERSION = environ.get("DEVPLACE_STATIC_VERSION") or str(int(time.time())) APP_VERSION = tomllib.loads((BASE_DIR / "pyproject.toml").read_text())["project"]["version"]
BOOT_ID = environ.get("DEVPLACE_STATIC_VERSION") or str(int(time.time()))
STATIC_VERSION = f"{APP_VERSION}-{BOOT_ID}"
TEMPLATE_AUTO_RELOAD = environ.get("DEVPLACE_TEMPLATE_AUTO_RELOAD", "1") != "0" TEMPLATE_AUTO_RELOAD = environ.get("DEVPLACE_TEMPLATE_AUTO_RELOAD", "1") != "0"
+3 -3
View File
@@ -6,10 +6,10 @@ This file documents JS module organization, custom web components, and shared fr
- Javascript files must be in module (ES6) format and imported as module format. It must be as object oriented as possible and a file per class. There must always be a main application class called `Application`, instantiated as `app`, accessible everywhere. - Javascript files must be in module (ES6) format and imported as module format. It must be as object oriented as possible and a file per class. There must always be a main application class called `Application`, instantiated as `app`, accessible everywhere.
- Never use JavaScript 3rd party frameworks unless specified. - Never use JavaScript 3rd party frameworks unless specified.
- CDN scripts referenced from `base.html` (marked, highlight.js, emoji-picker-element) MUST use `defer` or `type="module"` - otherwise `wait_until="domcontentloaded"` in Playwright tests will time out. - CDN scripts referenced from `base.html` (marked, highlight.js, emoji-picker-element) MUST use `defer` or `type="module"` - otherwise `wait_until="domcontentloaded"` in Playwright tests will time out.
- **Static asset URLs are boot-versioned for cache-busting.** Assets are served `public, immutable, max-age=31536000` (1 year, for the Lighthouse "efficient cache policy" score) while a restart still busts every browser. Never hardcode a bare `/static/...` href/src: templates wrap it in the `static_url(path)` Jinja global (`templating.py`) and runtime JS that builds an absolute static URL uses `assetUrl(path)` (`static/js/assetVersion.js`, reads `<meta name="asset-version">` rendered in `base.html`). Both emit a `/static/v<STATIC_VERSION>/...` path segment (`config.STATIC_VERSION` = `DEVPLACE_STATIC_VERSION` env or `int(time.time())` at process start), so a deploy = a restart = a new timestamp = a new URL for every asset. Both helpers no-op for non-`/static/` paths and for `/static/uploads/`, so they are safe to wrap around dynamic values. - **Static asset URLs are boot-versioned for cache-busting.** Assets are served `public, immutable, max-age=31536000` (1 year, for the Lighthouse "efficient cache policy" score) while a restart still busts every browser. Never hardcode a bare `/static/...` href/src: templates wrap it in the `static_url(path)` Jinja global (`templating.py`) and runtime JS that builds an absolute static URL uses `assetUrl(path)` (`static/js/assetVersion.js`, reads `<meta name="asset-version">` rendered in `base.html`). Both emit a `/static/v<STATIC_VERSION>/...` path segment where `config.STATIC_VERSION` = `f"{APP_VERSION}-{BOOT_ID}"` - `APP_VERSION` is read straight from `pyproject.toml`'s `version` (see root `CLAUDE.md` "Version bumping" - it moves on every commit via the tracked `pre-commit` hook) and `BOOT_ID` is `DEVPLACE_STATIC_VERSION` env or `int(time.time())` at process start - so a deploy = a restart = a new URL for every asset, and the version segment itself tells you which commit is live. Both helpers no-op for non-`/static/` paths and for `/static/uploads/`, so they are safe to wrap around dynamic values.
- **Path segment, never a query string - do NOT switch this to `?v=`.** The frontend is unbundled ES6 with ~130 relative imports (`./Http.js`) and zero absolute ones. A `?v=` only versions the entry `<script>`; its transitive relative imports would resolve to unversioned URLs and stay frozen under `immutable`, so a deploy would not propagate JS for a year. A path segment is inherited automatically by the entire module graph and by relative CSS `url()`. - **Path segment, never a query string - do NOT switch this to `?v=`.** The frontend is unbundled ES6 with ~130 relative imports (`./Http.js`) and zero absolute ones. A `?v=` only versions the entry `<script>`; its transitive relative imports would resolve to unversioned URLs and stay frozen under `immutable`, so a deploy would not propagate JS for a year. A path segment is inherited automatically by the entire module graph and by relative CSS `url()`.
- **Serving:** `main.py` mounts a `CachedStaticFiles` at `/static/v{STATIC_VERSION}` (immutable 1y; `service-worker.js` -> `no-cache`) plus the plain `/static` mount as the unversioned fallback; nginx mirrors this with `location ~ ^/static/v\d+/` (immutable 1y) and a short `max-age=3600` on the unversioned `/static/`. `/static/uploads/` (user content, stable DB paths) is never versioned; `service-worker.js` keeps a stable unversioned URL with `no-cache` so its registration scope is stable and a new worker always deploys. - **Serving:** `main.py` mounts a `CachedStaticFiles` at `/static/v{STATIC_VERSION}` (immutable 1y; `service-worker.js` -> `no-cache`) plus the plain `/static` mount as the unversioned fallback; nginx mirrors this with `location ~ ^/static/v[\w.-]+/` (immutable 1y, matches the `APP_VERSION-BOOT_ID` shape, not pure digits) and a short `max-age=3600` on the unversioned `/static/`. `/static/uploads/` (user content, stable DB paths) is never versioned; `service-worker.js` keeps a stable unversioned URL with `no-cache` so its registration scope is stable and a new worker always deploys.
- **Multi-worker:** the version is captured **once at launch** and shared via `DEVPLACE_STATIC_VERSION` (set in `make prod` = `$(date +%s)` and the Dockerfile's `sh -c` CMD) - otherwise two workers could compute timestamps a second apart and serve mismatched versioned mounts (a 404 on the other worker). Unset in dev (`--reload`) so each reload refreshes it. - **Multi-worker:** `BOOT_ID` is captured **once at launch** and shared via `DEVPLACE_STATIC_VERSION` (set in `make prod` = `$(date +%s)` and the Dockerfile's `sh -c` CMD) - otherwise two workers could compute timestamps a second apart and serve mismatched versioned mounts (a 404 on the other worker); `APP_VERSION` is read from the same `pyproject.toml` by every worker so it never needs pinning. Unset `DEVPLACE_STATIC_VERSION` in dev (`--reload`) so each reload refreshes `BOOT_ID`.
## Custom web components (`static/js/components/`) ## Custom web components (`static/js/components/`)
@@ -93,5 +93,5 @@ The server block sets `X-Content-Type-Options`, `X-XSS-Protection`, and `Referre
- **A WebSocket reports `connection failed` with no close code** - the route has no dedicated upgrade `location` and fell through to `location /`, which strips the upgrade headers. Add a matching `location` block (see WebSockets above) and reload nginx. This is what breaks the SEO Diagnostics live progress if the `location ~ ^/tools/seo/[^/]+/ws$` block is missing. - **A WebSocket reports `connection failed` with no close code** - the route has no dedicated upgrade `location` and fell through to `location /`, which strips the upgrade headers. Add a matching `location` block (see WebSockets above) and reload nginx. This is what breaks the SEO Diagnostics live progress if the `location ~ ^/tools/seo/[^/]+/ws$` block is missing.
- **Uploads fail with 413** - `NGINX_MAX_BODY_SIZE` is below `max_upload_size_mb`; raise it and restart nginx. - **Uploads fail with 413** - `NGINX_MAX_BODY_SIZE` is below `max_upload_size_mb`; raise it and restart nginx.
- **Uploaded file renders inline instead of downloading** - the `/static/uploads/` location lost its `Content-Disposition` rule. - **Uploaded file renders inline instead of downloading** - the `/static/uploads/` location lost its `Content-Disposition` rule.
- **Static assets stale after deploy** - every app-owned asset URL carries the boot-version path segment (`/static/v<timestamp>/...`), so a restart busts the cache automatically; if assets still look stale, confirm the app actually restarted (the `<meta name="asset-version">` value in page source changed). See [Static asset caching](/docs/static-caching.html). - **Static assets stale after deploy** - every app-owned asset URL carries the version path segment (`/static/v<app-version>-<boot-id>/...`), so a restart busts the cache automatically; if assets still look stale, confirm the app actually restarted (the `<meta name="asset-version">` value in page source changed). See [Static asset caching](/docs/static-caching.html).
</div> </div>
+28 -17
View File
@@ -15,12 +15,15 @@ See also [nginx and networking](/docs/production-nginx.html) and
Every static URL the app emits carries a version path segment: Every static URL the app emits carries a version path segment:
``` ```
/static/css/base.css -> /static/v1718040000/css/base.css /static/css/base.css -> /static/v1.0.1-1718040000/css/base.css
/static/js/Application.js -> /static/v1718040000/js/Application.js /static/js/Application.js -> /static/v1.0.1-1718040000/js/Application.js
``` ```
`v1718040000` is the unix timestamp captured once when the server process starts `v1.0.1-1718040000` is `config.STATIC_VERSION`, an `<app-version>-<boot-id>` pair:
(`config.STATIC_VERSION`). Because the value is fixed for the life of the process: `1.0.1` is read straight from `pyproject.toml`'s `version` (auto-bumped on every commit by
the tracked `pre-commit` hook - see "Version bumping" in the root `CLAUDE.md`), and
`1718040000` is the unix timestamp captured once when the server process starts. Because the
value is fixed for the life of the process:
- **Heavy caching is safe.** Each versioned URL is unique per deploy, so the browser may - **Heavy caching is safe.** Each versioned URL is unique per deploy, so the browser may
cache it for a full year (`Cache-Control: public, immutable, max-age=31536000`). cache it for a full year (`Cache-Control: public, immutable, max-age=31536000`).
@@ -38,8 +41,8 @@ URLs and stay frozen under the immutable cache - a deploy would not propagate Ja
changes for up to a year. changes for up to a year.
Putting the version in the path solves this for free: a module loaded from Putting the version in the path solves this for free: a module loaded from
`/static/v1718040000/js/Application.js` resolves `./Http.js` to `/static/v1.0.1-1718040000/js/Application.js` resolves `./Http.js` to
`/static/v1718040000/js/Http.js`, so the **entire module graph and every relative CSS `/static/v1.0.1-1718040000/js/Http.js`, so the **entire module graph and every relative CSS
`url()`** inherits the version automatically with no per-file rewriting. `url()`** inherits the version automatically with no per-file rewriting.
## The helpers ## The helpers
@@ -70,10 +73,10 @@ importing module's URL.
## Serving and cache headers ## Serving and cache headers
| Layer | Versioned `/static/v<n>/...` | Unversioned `/static/...` | | Layer | Versioned `/static/v<app-version>-<boot-id>/...` | Unversioned `/static/...` |
|-------|------------------------------|---------------------------| |-------|----------------------------------------------------|---------------------------|
| App (dev / `make dev`) | `CachedStaticFiles` mount sets `public, immutable, max-age=31536000` | plain mount, validator-based revalidation | | App (dev / `make dev`) | `CachedStaticFiles` mount sets `public, immutable, max-age=31536000` | plain mount, validator-based revalidation |
| nginx (prod) | regex `location ~ ^/static/v\d+/` sets `public, immutable, max-age=31536000` | `public, max-age=3600` (one hour, not immutable) | | nginx (prod) | regex `location ~ ^/static/v[\w.-]+/` sets `public, immutable, max-age=31536000` | `public, max-age=3600` (one hour, not immutable) |
The unversioned `/static/` location stays a short, non-immutable cache because a few The unversioned `/static/` location stays a short, non-immutable cache because a few
unversioned URLs still exist (the service worker's precache icons, direct hits); they must unversioned URLs still exist (the service worker's precache icons, direct hits); they must
@@ -90,22 +93,30 @@ cache and stable stored paths; it is never boot-versioned.
## Multi-worker consistency ## Multi-worker consistency
In production the app runs multiple uvicorn workers. Each worker is a separate process, so In production the app runs multiple uvicorn workers. Each worker is a separate process, so
if every worker computed its own timestamp they could disagree by a second and emit `config.STATIC_VERSION` is split into two halves that need different treatment:
mismatched asset URLs. The version is therefore fixed **once at launch** and shared through
the `DEVPLACE_STATIC_VERSION` environment variable:
- `make prod` prefixes the command with `DEVPLACE_STATIC_VERSION=$(date +%s)`. - **`APP_VERSION`** (the `pyproject.toml` `version`, e.g. `1.0.1`) is read from the same file
- The Docker image launches uvicorn through `sh -c` so a single `date +%s` is captured and by every worker, so it agrees automatically with no coordination needed.
- **`BOOT_ID`** (a unix timestamp) would NOT agree automatically - if every worker computed
its own `time.time()` at import they could disagree by a second and emit mismatched asset
URLs. It is therefore fixed **once at launch** and shared through the
`DEVPLACE_STATIC_VERSION` environment variable:
- `make prod` prefixes the command with `DEVPLACE_STATIC_VERSION=$(date +%s)`.
- The Docker image launches uvicorn through `sh -c` so a single `date +%s` is captured and
exported before the workers fork. exported before the workers fork.
When `DEVPLACE_STATIC_VERSION` is unset (dev with `--reload`), each process boot computes a When `DEVPLACE_STATIC_VERSION` is unset (dev with `--reload`), each process boot computes a
fresh timestamp, so a reload after editing a file naturally produces a new version. You can fresh `BOOT_ID`, so a reload after editing a file naturally produces a new version even
also pin the variable to a build id or git sha in CI for reproducible URLs. without a new commit. You can also pin the variable to a build id or git sha in CI for
reproducible URLs.
## Verify ## Verify
``` ```
curl -sI http://localhost:10500/static/v$(date +%s)/css/base.css | grep -i cache-control curl -s http://localhost:10500/ | grep -o 'name="asset-version" content="[^"]*"'
# -> name="asset-version" content="1.0.1-1718040000"
curl -sI http://localhost:10500/static/v1.0.1-1718040000/css/base.css | grep -i cache-control
# -> Cache-Control: public, max-age=31536000, immutable # -> Cache-Control: public, max-age=31536000, immutable
curl -sI http://localhost:10500/service-worker.js | grep -i cache-control curl -sI http://localhost:10500/service-worker.js | grep -i cache-control
+1 -1
View File
@@ -42,7 +42,7 @@ server {
log_not_found off; log_not_found off;
} }
location ~ ^/static/v\d+/(?<asset>.+)$ { location ~ ^/static/v[\w.-]+/(?<asset>.+)$ {
alias /app/static/$asset; alias /app/static/$asset;
add_header X-Content-Type-Options nosniff; add_header X-Content-Type-Options nosniff;
expires 1y; expires 1y;
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "devplacepy" name = "devplacepy"
version = "1.0.2" version = "1.0.3"
description = "DevPlace - The Developer Social Network" description = "DevPlace - The Developer Social Network"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [