Static assets

A content hash in the URL lets browsers cache a file forever and still see every change.

Every web app ships files that do not change between requests: a stylesheet, a script, a logo. You want the browser to cache them as long as possible, and you want a deploy to invalidate that cache the moment a file changes. The two wishes pull against each other until you put the file’s content hash in its URL. Then style.css becomes style.e05d0bf79209a63b.css, the browser may keep it forever, and the next deploy hands out a new name.

Assets does that. You build it once, ask it for URLs, and attach it to the app. Files is its sibling for files that change while the app runs, such as uploads. This page sets both up; the HTTP details are on Files and assets.

1. Build Assets where the module loads#

Point Assets at a directory. Ask for URLs at import time, so views get plain strings and no view needs the app object.

from pathlib import Path
 
from stario import Assets
 
ASSETS = Assets(Path(__file__).resolve().parent / "static")
STYLE_CSS = ASSETS.href("css/style.css")
APP_JS = ASSETS.href("js/app.js")

href() reads the file, hashes it, and returns /static/css/style.<hash>.css. It does not keep the file in memory. The directory has to exist when the module imports, and a path that does not exist raises StarioError right there, which is the moment you want to hear about a typo.

In a project with an app/ package, static/ usually sits beside it, so app/assets.py walks one level up:

PROJECT_ROOT = Path(__file__).resolve().parents[1]
ASSETS = Assets(PROJECT_ROOT / "static")

Hidden files and dot-directories are skipped unless you pass include_hidden=True. Symlinks are skipped unless you pass follow_symlinks=True.

2. Attach it in bootstrap#

attach(app) registers the GET route under the prefix, which also answers HEAD, and then loads the tree: it hashes every file, holds the small ones in memory, and precompresses them. It returns a few counts, which are worth putting on the startup span.

from stario import App, Route, Span
 
from app.assets import ASSETS
 
 
async def bootstrap(app: App, span: Span):
    with span.step("static_assets") as s:
        s.attrs(await ASSETS.attach(app))
 
    app.add(Route("GET /"), index)
    yield

From here, every hashed URL is served with Cache-Control: immutable headers. Small files come from memory, already compressed as br, zstd, or gzip. Large files stream from disk and honour Range requests. A request for the logical path without a hash gets a 307 to the hashed URL, so a hand-typed /static/css/style.css still works.

Tuning lives in two places: url_prefix= and content_types= when you construct Assets, and precompress= and cache_control= when you call attach().

3. Use the URLs in views#

Views take the module-level strings. Nothing here knows about the app or the request.

from stario import h
 
from app.assets import APP_JS, STYLE_CSS
 
 
def layout(*children):
    return h.HtmlDocument(
        {"lang": "en"},
        h.Head(
            h.Link({"rel": "stylesheet", "href": STYLE_CSS}),
            h.Script({"type": "module", "src": APP_JS}),
        ),
        h.Body(*children),
    )

When a path depends on runtime data, call ASSETS.href("…") inside the handler instead. Links to your own routes are a different thing: those come from Route constants and .href(), as Structuring apps shows.

4. Files for a tree that changes#

Files serves a directory as it is on disk, with no hash in the URL. A file written a second ago is served on the next request. GET answers with a strong ETag and 304, so repeat visits are cheap, and nothing is precompressed unless you ask.

from stario import App, Files, Span
 
UPLOADS = Files("var/uploads", "/uploads")
 
 
async def bootstrap(app: App, span: Span):
    await UPLOADS.attach(app)
    yield

UPLOADS.href("avatars/42.png") returns /uploads/avatars/42.png and raises if the file is not there. Writing the file is still your handler’s job; User uploads covers that. Do not put visitor uploads under Assets: href() would lazily pin the file, but the pin goes stale on overwrite, and uploads get the immutable cache headers meant for build output. Files re-reads the directory on every request.

5. Test the URLs#

Use the production bootstrap so attach() runs, then fetch a hashed URL through the client.

from app.assets import ASSETS
 
 
async def test_static_asset(client):
    url = ASSETS.href("css/style.css")
    assert url.startswith("/static/")
    r = await client.get(url)
    assert r.status_code == 200

By the way#

  • Finish writing to static/ before the first href() or attach(). A file that changes while it is being hashed raises StarioRuntime. After you replace a file in a running app, restart.

  • register() and load() are the two halves of attach(), for the rare case where routes must exist before the tree is loaded.

  • The order you register Assets relative to other routes only matters for app.use on the same prefix.

The complete example is examples/chat-room: app/assets.py builds Assets, bootstrap attaches it, and tests/test_assets.py checks the fingerprints.