A static file router that falls back to Bun.file
Published April 9, 2027
import { serve, file } from "bun";
const PUBLIC_DIR = `${import.meta.dir}/public`;
const server = serve({
routes: {
"/api/health": new Response("ok"),
},
async fetch(req) {
const pathname = new URL(req.url).pathname.replace(/\.\./g, "");
const asset = file(`${PUBLIC_DIR}${pathname === "/" ? "/index.html" : pathname}`);
return (await asset.exists()) ? new Response(asset) : new Response("Not found", { status: 404 });
},
});
console.log(`Serving ${PUBLIC_DIR} at ${server.url}`);What
A Bun.serve() app can declare a handful of API routes and let everything unmatched fall
through to a fetch handler that resolves the request path against a directory on disk with
Bun.file(), serving static assets without a separate static-file-serving package.
Why it matters
Express-style apps typically reach for express.static (or an equivalent middleware) to serve a
public directory, layered in front of or alongside API routes. With Bun.serve(), routes
handles the known API paths, and the fetch fallback — which already has to exist to catch
anything routes doesn't match — can just as easily resolve the leftover path against the
filesystem instead of returning a bare 404. Bun.file() streams the result the same way it
would from a dedicated static-file route, so there's no meaningful capability gap versus a
purpose-built static middleware.
How it works
routeshandles the API surface first; only requests that don't match anything there reachfetchat all.safePathstrips..sequences from the requested path before it's used to build a disk path — a minimal guard against a request walking outsidePUBLIC_DIRvia path traversal.- A request for
/is mapped to/index.htmlexplicitly, sinceBun.file("./public/")would point at a directory, not a file. asset.exists()decides between the streamed file and a 404 — the same lazy-file pattern as serving a single static file, just resolved against a variable path instead of a fixed one.
Gotchas
- The
..-stripping here is intentionally minimal for the example; a production router should resolve the final absolute path and verify it's still insidePUBLIC_DIR(e.g. viapath.resolve+startsWith) rather than trusting a single regex replace to catch every traversal trick (encoded slashes, symlinks, etc.). - This fallback has no notion of directory listings or index-file resolution beyond the root
path — a request for
/docs/won't automatically resolve to/docs/index.htmlunless that logic is added explicitly. - Every unmatched request touches the filesystem via
.exists(), even ones that will never match a real file — fine for a small app, but worth caching or rate-limiting in front of if the server is public and 404s are cheap to spam.
Related
- Passing
Bun.file(path)directly as aroutesvalue serves a single known file with nofetchfallback logic at all — reach for that when the set of static files is small and fixed. - The Bun.file streaming post covers the single-file version of this same lazy-serving pattern in more depth.