Static files

Static files

@chttp/static serves files and single page apps from a directory or from bytes baked into the binary. It depends on @chttp/router only and is authored in the scriptc subset, so it compiles into the same native ELF as the rest of your app.

import { createRouter } from "@chttp/router";
import { DiskAssets, serveStatic } from "@chttp/static";

const app = createRouter();
app.get("/api/ping", (c) => c.json({ ok: true }));
serveStatic(app, "/", new DiskAssets("./web/dist"), { spa: true });
app.serve({ port: 3000 });

Sources

AssetSource is the port: one method, get(path): Asset | null, where path is decoded, relative to the root and has no leading slash. Strategies are subclasses.

Source Reads from Notes
DiskAssets(root) the filesystem, as Buffers stats on every request, re-reads only when mtime or size changed, SHA-1 ETag computed on first request of a path
EmbeddedAssets bytes held in the program add(path, asset) or load(records) with a baked array of EmbedRecord

An Asset is a plain record: body (Buffer), type, etag, size, mtimeMs, immutable and encodings (precompressed variants, each with its own enc, body and etag).

Baked data must be an array of records, not a path-keyed object: the subset cannot index a string-keyed JSON object. The planned assets slice of @chttp/unplugin will emit a subclass of EmbeddedAssets whose constructor calls load() with such an array (path, type, b64, etag, mtimeMs, immutable, encs).

serveStatic

serveStatic(app, prefix, src, opts?) registers a GET splat route (HEAD comes from the router’s GET fallback). "/" serves from the root, "/assets" serves /assets/*.

Behaviour Detail
Traversal The splat is already percent-decoded, so %2e%2e and ..%2f arrive as .. segments and answer 403. Backslash, NUL and absolute tails are refused too
Dotfiles Any segment starting with . answers 404, except .well-known
Directories /docs/ and /docs serve docs/index.html
MIME Built-in table by extension, application/octet-stream otherwise
Validators Quoted ETag on every response, Last-Modified when the mtime is known. If-None-Match (including W/ and *) answers 304 with no body
Cache-Control public, max-age=31536000, immutable for hashed filenames, no-cache for HTML, opts.cacheControl (default no-cache) for the rest
Compression If app.js.br or app.js.gz sit next to app.js (or are in encodings), the client’s accept-encoding picks br over gzip; Vary: accept-encoding is always set for such assets. q-values are not parsed
SPA fallback With spa: true: GET or HEAD, accept contains text/html, and the path has no extension serves the index file. Missing assets with an extension stay 404
API prefixes Paths under opts.api (default ["/api"]) never reach the filesystem or the fallback, and an unmatched one answers {"error":"not_found"} with status 404

Hashed filenames are detected by name: the last - or . separated token of the stem is 8 to 32 alphanumerics mixing letters and digits (index-Ab3dE9fG.js, chunk.3f9a1c2e.css). Date-like tokens such as report-20241014.pdf are not hashed.

Options

Option Default Meaning
spa false enable the SPA fallback
api ["/api"] prefixes owned by the API
index "index.html" directory index and SPA shell
cacheControl "no-cache" for non-hashed, non-HTML assets

Compiling to an ELF

The package is covered at 100% by scriptc coverage packages/static/gate.ts, and scriptc build produces a binary that serves images byte for byte (sha256 verified through curl). Two subset notes from authoring it: reading a missing key from a string-keyed record throws in a native binary, so presence is tested with in and keys are prefixed to avoid prototype names; and httpDate() is hand-rolled from the getUTC* getters until toUTCString lowers.

Not included: range requests, If-Modified-Since, ISR caching.

Next: Architecture.