Static assets¶
jero serves small static assets the way it does everything else: all the work happens
at wiring, and nothing is added to the request path. _include_assets reads a
directory once, at startup. Every file becomes an exact route with its bytes,
content type, and a strong ETag baked in; requests never touch the filesystem.
from pathlib import Path
from tempfile import mkdtemp
from jero import BaseApp
# Demo stand-in for your project's static/ directory, so this example is runnable
# as-is. In a real app, point _include_assets at a directory in your repo.
assets = Path(mkdtemp())
(assets / "logo.svg").write_bytes(b"<svg xmlns='http://www.w3.org/2000/svg'/>")
(assets / "app.css").write_bytes(b"body { margin: 0 }")
class App(BaseApp):
async def wire(self) -> None:
self._include_assets(assets, path="/assets", cache_control="public, max-age=3600")
app = App()
GET /assets/logo.svg and GET /assets/app.css now serve from memory.
Subdirectories keep their relative paths (static/img/logo.svg mounts at
/assets/img/logo.svg), and an unknown path under the mount is an ordinary 404: there
is no catch-all.
What you get¶
- Conditional requests. Each file's
ETagis computed once at wiring; a matchingIf-None-Matchanswers304with no body, and no hashing or filesystem work happens per request. - Compression, also at wiring. With
gzip=True(the default), compressible files (CSS, JS, HTML, SVG, JSON, and friends) are gzipped once at startup and kept only when meaningfully smaller. A request withAccept-Encoding: gzipgets the prebaked variant with its ownETagand aVary: Accept-Encodingheader. Already-compressed formats (PNG, WebP, WOFF2) are never re-compressed. - Cache headers.
cache_control="public, max-age=3600"is emitted verbatim on both the200and the304. - The usual semantics for free.
HEADis served from theGETroute with the body suppressed; asset routes are covered by the app-default CORS policy and app-wide middleware; none of it appears in the OpenAPI document.
Selecting files¶
include and exclude are glob patterns (fnmatch)
matched against each file's path relative to the directory. * matches across /
too, so "*.map" excludes a .map file at any depth — there's no syntax for "this
one directory level only"; list exact relative paths for that:
from pathlib import Path
from tempfile import mkdtemp
from jero import BaseApp
assets = Path(mkdtemp()) # demo stand-in, as above
(assets / "app.js").write_bytes(b"console.log('app-js')")
(assets / "app.js.map").write_bytes(b"{}")
class App(BaseApp):
async def wire(self) -> None:
self._include_assets(assets, exclude=("*.map",))
app = App()
Dotfiles are always skipped. A file whose suffix isn't in the supported set is a
WiringError naming the file; exclude it or serve it elsewhere.
Loud at wiring, silent at runtime¶
Every failure mode is a WiringError at startup, never a request-time surprise: a
missing directory, an unsupported suffix, an unreadable file, globs that match
nothing, too many files, a collision with an existing route, and a total size over
the cap. A file already bigger than the remaining budget is rejected by its on-disk
size before it's ever read or compressed — a single huge file can't be fully read
just to discover it doesn't fit.
The two caps (max_files, 10,000; max_total_bytes, 10 MiB) exist because assets
are held in memory, per worker. That is the honest scope of this feature: an SPA
shell, stylesheets, some images. Raise a cap deliberately if you mean to; for large
files, Range requests, or catch-all SPA fallbacks, use your reverse proxy or CDN,
which do that job better than a Python worker ever will (see
Deployment). Files added or changed on disk appear on restart, like
everything else wired at startup.
Symlinks are never served, file or directory. A symlinked file would otherwise be
read straight through, serving whatever it points at — anywhere on disk the process
can read — as if it lived under the served directory; a symlinked directory is never
descended into. Point _include_assets at a directory you trust the real contents
of.