serveStatic() serves files from a directory (default public/) before your
routes run. If a file matches the request path it's sent with caching headers;
otherwise the request falls through to your routes.
Enable it
Add the middleware to your HTTP kernel:
import { HttpKernel, serveStatic } from "@shaferllc/keel/core";
export class Kernel extends HttpKernel {
constructor(app: Application) {
super(app);
this.use(serveStatic()); // serves ./public
}
}
Now ./public/css/style.css is served at /css/style.css, and ./public/index.html
at /.
How a request is matched
For each request the middleware:
- Skips non-reads. Only
GETandHEADare handled; any other method falls straight through to your routes. - Decodes and guards the path. The URL pathname is
decodeURIComponent'd, then any path containing..is rejected (falls through) — so percent-encoded traversal (%2e%2e) is caught too. - Applies the dot-file policy (see below).
- Resolves the file. It looks for
root + urlPathon disk. If that's a directory, it appendsindex(index.html) and looks again. If nothing resolves to a real file, the request falls through. - Sends the file with
Content-Type,Last-Modified,ETag, and — when configured —Cache-Controlheaders.
Because the middleware calls next() (rather than returning a 404) whenever it
can't serve a file, a missing asset is handled by your routes, not by the static
server. That's what lets a client-side app fall back to an SPA catch-all route.
Options
serveStatic({
root: "./public", // directory to serve
index: "index.html", // directory index file
dotFiles: "ignore", // "ignore" (404) · "deny" (403) · "allow"
maxAge: 86400, // Cache-Control: public, max-age=…
immutable: true, // add the immutable directive (hashed filenames)
headers: (path) => // extra per-file headers
path.endsWith(".html") ? { "X-Frame-Options": "DENY" } : undefined,
});
Every response carries an ETag and Last-Modified, and a matching
If-None-Match returns a 304. Dot-files (.env, .git) are 404'd by default
so secrets aren't exposed. Path traversal (..) is blocked.
Caching & conditional requests
The ETag is weak — derived from the file's byte size and modified time
(W/"<size>-<mtime>") — so it changes whenever the file changes without hashing
its contents. On a repeat request the browser echoes it back in If-None-Match;
if it still matches, the middleware short-circuits with 304 Not Modified and an
empty body, saving the read and the transfer.
Cache-Control is only sent when you set maxAge; omit it and the response has
no Cache-Control header at all (the browser falls back to its heuristic
freshness). Add immutable: true for content-hashed filenames so conditional
revalidation is skipped entirely for the cache lifetime:
// Long-lived, fingerprinted build assets: cache hard, never revalidate.
this.use(serveStatic({ root: "./dist", maxAge: 31536000, immutable: true }));
HEAD requests get the full header set with an empty body, so clients can probe
an asset's ETag/Last-Modified without downloading it.
Per-file headers
The headers callback runs for every file about to be served and merges its
result into the response. It receives the resolved filesystem path (root
included, e.g. ./public/app.js), not the URL path — match on the extension or
suffix rather than a leading slash:
serveStatic({
headers: (path) => {
if (path.endsWith(".html")) return { "X-Frame-Options": "DENY" };
if (path.endsWith(".wasm")) return { "Cross-Origin-Embedder-Policy": "require-corp" };
return undefined; // no extra headers
},
});
Returning undefined (or an empty object) adds nothing. These headers are set
after the built-ins, so a Cache-Control you return here overrides the one
derived from maxAge.
Dot-files & traversal
Any path segment that starts with . — not just the last one — is a "dot
segment", so /.git/config and /assets/.env both trip the policy:
"ignore"(default) — falls through to your routes, so it reads as a 404."deny"— responds403 Forbidden."allow"— serves the file like any other.
Separately, any decoded path containing .. is always rejected regardless of the
dot-file policy, so ../ traversal can't escape root.
Error behavior
The file-resolution block is wrapped in a try/catch that swallows errors by
calling next(). A permission error, a mid-request delete, or a malformed path
never becomes a 500 — it falls through to your routes exactly like a miss. The
trade-off: genuine filesystem faults are invisible here, so don't rely on this
middleware to surface disk problems.
Edge note
serveStatic() reads from the filesystem (via a dynamically-imported node:fs),
so it's for Node apps. On Cloudflare Workers, serve assets through the
platform's static-assets binding instead — the framework core still imports
cleanly either way (the node:fs import is deferred until the first request the
middleware actually handles).
Production
For high-traffic sites, prefer a CDN or reverse proxy (Nginx, Caddy, Cloudflare) in front of static assets rather than serving them from the Node process.
API reference
serveStatic(options?)
serveStatic(options?: StaticOptions): MiddlewareHandler
Builds a Hono middleware that serves files from options.root before the request
reaches your routes, falling through to next() on any miss.
import { serveStatic } from "@shaferllc/keel/core";
const assets = serveStatic({ root: "./public", maxAge: 86400 });
this.use(assets);
Notes: all options are optional (serveStatic() serves ./public). Only
GET/HEAD are handled — other methods pass through untouched. Returns the
middleware synchronously; node:fs/promises is imported lazily on the first
handled request, so importing this on a non-Node runtime is safe until a request
hits it. Sends Content-Type (via Hono's getMimeType, defaulting to
application/octet-stream), Last-Modified, and a weak ETag; honors
If-None-Match with a 304. A trailing slash on root is stripped. The ..
guard is a plain substring check, so a (rare) legitimate filename containing ..
is also rejected.
Interfaces & types
StaticOptions
interface StaticOptions {
root?: string;
index?: string;
dotFiles?: "ignore" | "deny" | "allow";
maxAge?: number;
immutable?: boolean;
headers?: (path: string) => Record<string, string> | undefined;
}
The configuration bag for serveStatic(). Pass it to tune the served directory,
directory index, dot-file policy, and caching. Every field has a default, so an
empty object (or no argument) is valid.
const options: StaticOptions = {
root: "./dist",
index: "index.html",
dotFiles: "deny",
maxAge: 31536000,
immutable: true,
headers: (path) => (path.endsWith(".html") ? { "X-Frame-Options": "DENY" } : undefined),
};
serveStatic(options);
Field defaults & behavior:
root— directory to serve from. Default"./public"; trailing slashes are stripped.index— file served for a directory request. Default"index.html".dotFiles— policy for paths with a.-prefixed segment:"ignore"(fall through, reads as 404),"deny"(403 Forbidden), or"allow"(serve). Default"ignore".maxAge—Cache-Control: public, max-age=<seconds>. Omit for noCache-Controlheader at all.immutable— appends, immutabletoCache-Control(only meaningful alongsidemaxAge). Defaultfalse.headers— called with the resolved filesystem path (root included) for each served file; return extra headers to merge, orundefinedfor none. These are applied last, so they can override the built-in headers.