<div class="docs-content" data-render>
{% raw %}
# Static asset caching and versioning

DevPlace serves its CSS and JavaScript with a one-year immutable cache for the best
possible "Serve static assets with an efficient cache policy" score, while still letting a
deploy take effect immediately. The two goals are reconciled by stamping a **boot-time
version** into every app-owned static URL.

See also [nginx and networking](/docs/production-nginx.html) and
[Deploy and update](/docs/production-deploy.html).

## How it works

Every static URL the app emits carries a version path segment:

```
/static/css/base.css        ->  /static/v1.0.1-1718040000/css/base.css
/static/js/Application.js    ->  /static/v1.0.1-1718040000/js/Application.js
```

`v1.0.1-1718040000` is `config.STATIC_VERSION`, an `<app-version>-<boot-id>` pair:
`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
  cache it for a full year (`Cache-Control: public, immutable, max-age=31536000`).
- **Deploys are instant.** Restarting the server changes the boot timestamp, so every
  asset URL changes, so every returning browser refetches on its next page load. No manual
  cache purge, no hashing build step, no waiting for a TTL to expire.

## Why a path segment, not a query string

DevPlace ships unbundled ES6 modules wired together with **relative imports**
(`import { Http } from "./Http.js"`). A relative import resolves against the URL of the
module that contains it. If only the entry `<script>` carried a version (for example a
`?v=` query string), its transitively imported modules would resolve to **unversioned**
URLs and stay frozen under the immutable cache - a deploy would not propagate JavaScript
changes for up to a year.

Putting the version in the path solves this for free: a module loaded from
`/static/v1.0.1-1718040000/js/Application.js` resolves `./Http.js` to
`/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.

## The helpers

- **Templates** use the `static_url` Jinja global (registered in `templating.py`):

```
<link rel="stylesheet" href="{{ static_url('/static/css/base.css') }}">
<script type="module" src="{{ static_url('/static/js/Application.js') }}"></script>
```

  It returns the path unchanged for anything outside `/static/` and for `/static/uploads/`
  (user content, addressed by stable database paths), so it is safe to wrap around dynamic
  values such as `{{ static_url(og_image or '/static/og-default.png') }}`.

- **JavaScript** that builds an absolute static URL at runtime (component CSS link
  injection, lazily loaded xterm/md-clippy modules) uses the `assetUrl` helper
  (`static/js/assetVersion.js`), which reads the version from the
  `<meta name="asset-version">` tag rendered in `base.html`:

```
import { assetUrl } from "../assetVersion.js";
link.href = assetUrl("/static/css/components.css");
```

Relative module imports never need either helper - they inherit the version from the
importing module's URL.

## Serving and cache headers

| 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 |
| 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
unversioned URLs still exist (the service worker's precache icons, direct hits); they must
not be frozen for a year.

**Service worker exception.** `service-worker.js` is served with `Cache-Control: no-cache`
(by its `/service-worker.js` route and, defensively, by `CachedStaticFiles`) so a new
worker always deploys. It keeps a stable, unversioned URL so its registration scope does
not change on every deploy.

**Uploads exclusion.** `/static/uploads/` is user-generated content with its own public
cache and stable stored paths; it is never boot-versioned.

## Multi-worker consistency

In production the app runs multiple uvicorn workers. Each worker is a separate process, so
`config.STATIC_VERSION` is split into two halves that need different treatment:

- **`APP_VERSION`** (the `pyproject.toml` `version`, e.g. `1.0.1`) is read from the same file
  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.

When `DEVPLACE_STATIC_VERSION` is unset (dev with `--reload`), each process boot computes a
fresh `BOOT_ID`, so a reload after editing a file naturally produces a new version even
without a new commit. You can also pin the variable to a build id or git sha in CI for
reproducible URLs.

## Verify

```
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

curl -sI http://localhost:10500/service-worker.js | grep -i cache-control
# -> Cache-Control: no-cache
```

In a rendered page, the `<meta name="asset-version">` value and every `/static/v.../`
URL change after a server restart.
{% endraw %}
</div>
