Static files

EDRI's API Listener serves files from a directory on disk — stylesheets, scripts, images, fonts — for any request URL that no @api(...) event claims. This page describes how a request reaches a static file, what the response contains, and where the limits are.


Quick start

Point EDRI_ASSETS_PATH at a directory (default: assets, relative to the process working directory):

export EDRI_ASSETS_PATH=assets
assets/
└── static/
    ├── css/
    │   └── style.css
    ├── js/
    │   └── app.js
    └── images/
        └── logo.png

The URL path maps one-to-one onto the directory:

URL

File

/static/css/style.css

assets/static/css/style.css

/static/js/app.js

assets/static/js/app.js

/static/images/logo.png

assets/static/images/logo.png

Reference them from templates with root-absolute paths:

<link rel="stylesheet" href="/static/css/style.css">
<script src="/static/js/app.js" defer></script>
<img src="/static/images/logo.png" alt="Logo">

Relative paths (css/style.css) resolve against the current page URL and break on nested routes such as /user/group/456/12. There is no template tag for static URLs; {% url %} only generates URLs for events.


How a request reaches a static file

        flowchart TD
    Req["HTTP request<br/>GET /static/css/style.css"] --> Route{"URL matches an<br/>@api route?<br/>(any method)"}
    Route -->|yes| Event["event handling<br/>(405 if method not registered)"]
    Route -->|no| Json{"JSON client?<br/>X-Requested-With: XMLHttpRequest<br/>or Accept lists application/json"}
    Json -->|yes| J404["404 (JSON)"]
    Json -->|no| Path{"path valid and<br/>inside EDRI_ASSETS_PATH?"}
    Path -->|no| E400["400 (HTML)"]
    Path -->|yes| File{"regular file?"}
    File -->|no| E404["404 (HTML)"]
    File -->|yes| Method{"GET or HEAD?"}
    Method -->|yes| Serve["200 + file contents<br/>(headers only for HEAD)"]
    Method -->|no| E405["405 (HTML)<br/>Allow: GET, HEAD"]
    

The lookup happens in the Listener, before any event is created. Routes always win, and the static fallback has three consequences:

  • A route on the path shadows the file. If an event is registered at /css for POST, a GET /css returns 405 Method Not Allowed, not a file named assets/css.

  • A dynamic segment shadows a whole level. With an event at /docs/{slug}, the URL /docs/readme.txt is routed to that event with slug="readme.txt"; assets/docs/readme.txt is never served. Keep assets under a prefix no event uses (for example /static/).

  • JSON clients never get static files. A request carrying X-Requested-With: XMLHttpRequest, or whose Accept header lists application/json (including application/json, text/plain, */*, the default of axios), receives a JSON 404 even when the file exists. A browser navigation, <link>, <script>, <img>, or a plain fetch() without these headers is served normally. To load a JSON data file from JavaScript, send Accept: */* or expose the data through a REST event.


Path rules

  • The path comes from the ASGI path (already percent-decoded by the server); the query string is ignored, so /static/app.js?v=42 serves app.js.

  • Only regular files are served. A directory — including / — returns 404; there is no index.html fallback and no directory listing.

  • The resolved path must stay inside EDRI_ASSETS_PATH. The following return 400 Bad Request:

    • paths that escape the directory (/../secret.txt, /a%2F..%2F..%2Fsecret.txt);

    • symbolic links whose target lies outside the directory;

    • paths containing a backslash.

  • Symbolic links whose target stays inside the directory are followed.

  • A relative EDRI_ASSETS_PATH is resolved against the process working directory on every request. Use an absolute path when the working directory of the deployed process is not under your control. The variable itself is read once, when edri.config.setting is imported, so set it before importing EDRI.


Response

Static files accept GET and HEAD; other methods on an existing file return 405 Method Not Allowed with Allow: GET, HEAD. A served file returns 200 OK with:

Header

Value

Content-Type

from the file extension (see below)

Content-Length

file size in bytes

X-Content-Type-Options

nosniff

CORS headers

the same Access-Control-* headers as other API responses (Environment Variables)

HEAD returns the same headers without a body. For GET, the body is streamed in 1 MiB chunks; file reads run in a worker thread and do not block the Listener's event loop.

Content-Type is fixed for common web formats, independent of the Python version and the host's /etc/mime.types:

Extension

Content-Type

.css

text/css

.js, .mjs

text/javascript

.json, .map

application/json

.html

text/html

.svg

image/svg+xml

.png

image/png

.jpg, .jpeg

image/jpeg

.gif

image/gif

.webp

image/webp

.avif

image/avif

.ico

image/vnd.microsoft.icon

.woff

font/woff

.woff2

font/woff2

.wasm

application/wasm

Other extensions are looked up with Python's mimetypes. A file mimetypes does not recognise, or one with a compression suffix such as .gz, is served as application/octet-stream. Because of nosniff, browsers do not guess a different type: a stylesheet or script with an unrecognised extension is rejected rather than executed.

Missing features to account for:

  • No caching headers. No Cache-Control, ETag, or Last-Modified is sent and conditional requests (If-None-Match, If-Modified-Since) are not evaluated; every request transfers the full file. EDRI_API_CACHE_CONTROL applies to event responses, not static files. Use versioned file names or query strings (app.js?v=42) and set cache headers at a reverse proxy.

  • No Range support. Partial content and resumable downloads are available only for file-download events — see Downloading files.

  • No compression. Files are sent as stored. A pre-compressed style.css.gz is served as a separate application/octet-stream file without Content-Encoding.


Access control

Static files bypass the Broker, so middleware never sees these requests. Authentication and authorization middleware (Middleware) does not protect anything under EDRI_ASSETS_PATH; every file there is public to anyone who can reach the Listener.

Serve protected files through an event instead: authorize the request in middleware or in the Manager, then return the file as a download response (Downloading files). That path also provides Range, ETag, and If-Range support.


Production

The built-in static serving is suitable for development and for small applications. For production traffic, let a reverse proxy or CDN serve the static prefix directly and forward everything else to EDRI. That adds caching headers, compression, and Range support without involving the Listener:

location /static/ {
    alias /srv/app/assets/static/;
    expires 30d;
}

location / {
    proxy_pass http://127.0.0.1:8080;
}

Keeping assets under a dedicated prefix such as /static/ makes this split a single location block.


Errors

Situation

Status

Format

File not found, or path is a directory

404

HTML error page

Path escapes EDRI_ASSETS_PATH or contains a backslash

400

HTML error page

Method other than GET/HEAD on an existing file

405

HTML error page, Allow: GET, HEAD

JSON client (X-Requested-With / Accept: application/json)

404

JSON error body

Path claimed by an event route for another method

405

HTML error page, Allow header


See also