zmdbzero-maintenance data layer
Docs Benchmarks Anti-patterns OpenAPI
Docs / Server framework

Serving Static FilesSupported

createStaticHandler() serves a directory through ordinary WebResponse values without letting a request escape that directory. It streams from one open file descriptor, generates validators, handles one byte range, and makes every refused path indistinguishable from a missing file.

For production assets, a CDN, object store, or reverse proxy is still the better layer. The application handler is for development and deployments that genuinely have no static edge.

Security boundary#

Before any convenience option, the handler fixes these rules:

The uniform 404 avoids turning the handler into a filesystem oracle. Refusal details go only to the required onError callback. Do not serve a directory the application also writes to: confinement does not make attacker-supplied HTML safe to host on the application's origin.

Create the handler once#

import { createStaticHandler } from '@zmdb/web';

const assets = await createStaticHandler({
  root: '/var/www/assets',
  index: 'index.html',
  cacheControl: 'public, max-age=31536000, immutable',
  onError: error => log.error({ error }),
});

Construction is asynchronous because root is resolved to its real path once. A missing root, a regular file, a FIFO, or anything else that is not a directory rejects immediately rather than failing on the first request.

serve(pathname, headers) expects a path relative to that root, with no leading slash:

@Controller('/assets')
class AssetController {
  @Get('/:file')
  asset(ctx: Ctx<{ file: string }>) {
    return assets.serve(ctx.params.file, ctx.headers);
  }
}

The current router has no wildcard route parameter. The example therefore serves one path segment; nested asset paths need explicit routes or a prefix branch in a custom adapter until wildcard routing ships. The static handler itself accepts safe nested paths such as css/app.css.

How confinement is enforced#

The handler applies all of these checks before returning bytes:

A traversal attempt, dotfile, directory, FIFO, escaping symlink, unreadable file, and ordinary miss all return the same empty 404 with x-content-type-options: nosniff. There is no 403 and no client-visible reason: different answers would form a filesystem oracle.

onError receives refused paths, unreadable files, and symlink escapes. An ordinary missing asset does not call it, so a favicon miss does not become an alert.

Do not point the handler at the project root, and do not serve a directory the application also writes to. Serving uploaded HTML or JavaScript from the same origin is stored XSS even when path confinement is perfect.

Directory and index behavior#

Directory listing cannot be enabled. A directory is always a 404.

index is a separate opt-in used only when serve() receives the empty path. It does not add a single-page-application fallback, and it does not make unmatched paths return HTML. Keep SPA fallback policy in the controller or adapter where the API/app boundary is visible.

Validators and conditional requests#

Every successful response carries:

The ETag is deliberately weak. Size plus modification time does not prove byte-for-byte identity, while a strong validator would require hashing the whole file and defeat streaming.

If-None-Match is evaluated before If-Modified-Since. A match returns 304 with validators and no body or content-length.

Byte ranges#

One byte range is supported without buffering:

RequestResult
bytes=0-499 or bytes=500-206 with the exact streamed window
bytes=-500last 500 bytes, clamped to the file size
end past EOFclamped to the last byte
start at or past EOF416 with content-range: bytes */<size>
malformed rangeignored; the whole file is returned as 200
multiple rangesignored; no multipart/byteranges implementation
any If-Rangeignored; the whole file is returned because ETag weak

Ignoring a malformed or multi-range request is a valid HTTP response and avoids a partial multipart implementation. A range response reads only its requested window from the descriptor.

Content types#

Types come from a fixed extension allow-list. Unknown extensions use application/octet-stream; the handler never sniffs bytes or trusts a request header. Extend or override the map explicitly:

const assets = await createStaticHandler({
  root: '/var/www/assets',
  contentTypes: { '.wasm': 'application/wasm' },
  onError: error => log.error({ error }),
});

The handler does not set content-disposition. Use file() with an explicit, properly escaped filename for downloads.

Prefer the edge for production assets#

location /assets/ {
  root /var/www;
  expires 1y;
  add_header cache-control "public, immutable";
}
location / {
  proxy_pass http://127.0.0.1:3000;
}

A long immutable lifetime is safe only for content-hashed filenames. Without hashed names, use revalidation or a short lifetime.

---

See also: Streaming Files · Compression · Deployment