Skip to content

@tetsujs/static

@tetsujs/static serves the files of a directory: a built site next to the API, a single-page app, or a directory of assets. It is a handler, so it runs inside the pipeline, and the application’s hooks apply to files as to everything else: security headers, CORS, a request log.

Terminal window
bun add @tetsujs/static
import { staticFiles } from "@tetsujs/static";
const
const app: App<RoutesOf<object[]>>
app
= createApp({
routes: object[]

The topology: a group, a controller, or an array of either.

routes
,
fallback?: FallbackHandler | undefined

Answers requests whose path matched no route, replacing the default 404 response entirely. Runs with the application-level hook chains.

The result is serialized like a handler result: status 200 (or 204 for undefined) unless the fallback sets ctx.out.status or returns a Response. An SPA index wants exactly that; a custom 404 body must state its status itself.

fallback
: staticFiles({
root: string

The directory served.

A relative path is resolved against the working directory, as Bun.file resolves one, not against the module that calls staticFiles(): join(import.meta.dir, "public") is the module's. Checked when the handler is made: a root that does not exist, or is not a directory, is refused at startup rather than on the first request.

root
: "./dist",
notFound?: string | undefined

A file below the root, sent with 404 to a browser that asked for a path with no file: one whose Accept names text/html.

Any other client, fetch and curl among them, gets the application's 404, so a mistyped API address is not answered with a page. Both carry Vary: Accept. Without notFound, every client gets the application's 404. Checked when the handler is made, as root is.

notFound
: "404.html" }),
});

In fallback, it answers every path no route matched: /css/site.css is dist/css/site.css, and / is dist/index.html. A path with no file gets the application’s 404, the same JSON as any other missing path, and a browser gets 404.html (below).

On a route, it answers what follows the route’s *:

const assets = route({
method: "GET"

The method this route answers.

Kept as a literal rather than widened to

Method

: the method is half of a route's identity, and an application that remembers its routes — for a generated client, for tooling — needs to know which one this is.

method
: "GET",
path: "/assets/*"

Route path with :param segments, e.g. "/orders/:id/cancel".

Must start with /, contain no empty segments and no trailing slash; a malformed literal is a compile error.

path
: "/assets/*",
handler: (ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}) => Promise<Response>

The endpoint logic; ctx is fully inferred, never annotate it.

The return type is inferred rather than demanded, and checked twice over. Against the route's own contract, by the

HandlerResult

bound on R: answering with something response never declared is a compile error that says so, instead of a structural diff against Response. And against what the framework can serialize at all, by the intersected

ValidateResult

: a stream handed over bare is refused whether or not the route declared anything, because that is the case no contract covers — without a response schema HandlerResult is unknown and accepts every value there is.

handler
: staticFiles({
root: string

The directory served.

A relative path is resolved against the working directory, as Bun.file resolves one, not against the module that calls staticFiles(): join(import.meta.dir, "public") is the module's. Checked when the handler is made: a root that does not exist, or is not a directory, is refused at startup rather than on the first request.

root
: "./public" }),
});

/assets/app.css is public/app.css. A route without *, such as /robots.txt, serves its own path from the root. The route’s group, hooks and docs apply as to any other route, so files behind a sign-in are a group with the sign-in hook and a route /*, with a root of their own (below). A route /assets/* does not answer /assets itself: Bun’s router wants the slash, and the bare address goes to the fallback.

root is resolved against the working directory, as Bun.file resolves a path, not against the module that calls staticFiles(). For the module’s own directory, use join(import.meta.dir, "public"). staticFiles() checks the root when it is called, so a wrong one stops the application at startup.

Every file goes out with:

  • its content-type, on HEAD too;
  • ETag and Last-Modified, and a 304 when the client’s copy is still the file;
  • Cache-Control, no-cache unless cacheControl says otherwise;
  • Accept-Ranges: bytes: Bun answers a Range with 206, or 416 for one outside the file.

Bun resolves . and .. before the handler reads the path, so /css/../app.js is /app.js, and no .. climbs above the root. What is left is checked before the disk is touched, and refused rather than repaired. These get a 404:

  • a path that leaves a route’s prefix once resolved, such as /assets/../config.json under /assets/*;
  • an empty segment, as in //;
  • an encoded / or \, so ..%2f is no way out either, and a NUL;
  • a dotfile or a dot-directory, so .env and .git are never served. .well-known is the one exception.

A directory’s address without its trailing slash is redirected to it with 301, the query kept, so the relative links in its index.html work: /docs to /docs/. The Location always starts with exactly one /, so a path such as //evil.example/docs cannot send a browser to another site. index: false turns directories off.

Symbolic links are followed, as nginx, Caddy and Express follow them: what the root links to is served as part of it. What the root holds is yours to decide. Serve a build’s output, not a project’s directory.

Everything below a root is public, however its path is written. Bun’s router matches a path as it arrives, while the handler reads it resolved and decoded, and the disks of macOS and Windows ignore case. So /x/../reports/q3, /%72eports/q3 and, on such a disk, /REPORTS/q3 all miss a /reports/* route that requires a sign-in. They reach the fallback, or a broader route such as /*, and if that root holds reports/q3, the file goes out.

Files that need a guard live in a directory of their own, served by a route with the guard, never below a root that is served without it:

const signedIn = hook.beforeParse((ctx) => {
if (!isSignedIn(ctx.
req: Request & {
readonly cookies?: Bun.CookieMap;
}

The raw incoming request, always available as an escape hatch.

On a request that matched a route, Bun's router delivers its own request object carrying cookies — a Bun.CookieMap whose mutations are applied to the response as Set-Cookie automatically, with Bun's defaults (Path=/; SameSite=Lax). The field is optional because it does not exist where Bun's router was not involved: the 404 fallback and unit-tested handlers. ctx.out.headers.append("set-cookie", ...) is the fallback that works everywhere.

req
)) throw httpError(401);
});
const app = createApp({
routes: [
group("/reports", {
hooks: { beforeParse: [signedIn] },
children: [
route({
method: "GET"

The method this route answers.

Kept as a literal rather than widened to

Method

: the method is half of a route's identity, and an application that remembers its routes — for a generated client, for tooling — needs to know which one this is.

method
: "GET",
path: "/*"

Route path with :param segments, e.g. "/orders/:id/cancel".

Must start with /, contain no empty segments and no trailing slash; a malformed literal is a compile error.

path
: "/*",
handler: (ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}) => Promise<Response>

The endpoint logic; ctx is fully inferred, never annotate it.

The return type is inferred rather than demanded, and checked twice over. Against the route's own contract, by the

HandlerResult

bound on R: answering with something response never declared is a compile error that says so, instead of a structural diff against Response. And against what the framework can serialize at all, by the intersected

ValidateResult

: a stream handed over bare is refused whether or not the route declared anything, because that is the case no contract covers — without a response schema HandlerResult is unknown and accepts every value there is.

handler
: staticFiles({
root: string

The directory served.

A relative path is resolved against the working directory, as Bun.file resolves one, not against the module that calls staticFiles(): join(import.meta.dir, "public") is the module's. Checked when the handler is made: a root that does not exist, or is not a directory, is refused at startup rather than on the first request.

root
: "./reports" }) }),
],
}),
],
fallback?: FallbackHandler | undefined

Answers requests whose path matched no route, replacing the default 404 response entirely. Runs with the application-level hook chains.

The result is serialized like a handler result: status 200 (or 204 for undefined) unless the fallback sets ctx.out.status or returns a Response. An SPA index wants exactly that; a custom 404 body must state its status itself.

fallback
: staticFiles({
root: string

The directory served.

A relative path is resolved against the working directory, as Bun.file resolves one, not against the module that calls staticFiles(): join(import.meta.dir, "public") is the module's. Checked when the handler is made: a root that does not exist, or is not a directory, is refused at startup rather than on the first request.

root
: "./public" }),
});

./reports is not inside ./public, so no way of writing a path reaches a report through the fallback.

In fallback, GET and HEAD get the file, and OPTIONS gets 204 with Allow: GET, HEAD, OPTIONS, as a route answers it. Another method gets 405 with the same Allow where a file exists, and every method gets 404 where none does, so a POST to a mistyped API address is the usual 404. On a route, the core answers the other methods, as for any route.

cacheControl sets Cache-Control. The default, no-cache, lets a browser keep a file but makes it ask whether the file changed before using it, and the answer is a small 304 when it has not. Without the header, a browser guesses how long to keep a file from its age, and can go on running an old script after a deploy.

A file whose name changes with its content, as a bundler names what it puts in assets/, can be kept for good:

staticFiles({
root: string

The directory served.

A relative path is resolved against the working directory, as Bun.file resolves one, not against the module that calls staticFiles(): join(import.meta.dir, "public") is the module's. Checked when the handler is made: a root that does not exist, or is not a directory, is refused at startup rather than on the first request.

root
: "./dist",
cacheControl?: string | ((path: string) => string) | undefined

Cache-Control of every file: no-cache by default, or a function of the file's path below the root, such as assets/app.3f2a.js.

no-cache lets a browser keep a file and makes it ask whether the file changed before using it, which costs a 304 when it has not. Without the header, a browser guesses how long to keep a file from its Last-Modified, and goes on running an old script after a deploy. A file whose name changes with its content can be kept for good:

cacheControl: (path) =>
path.startsWith("assets/") ? "public, max-age=31536000, immutable" : "no-cache",

The path is the file's, not the address's. The shell of a single-page app is index.html at every address it answers, so a rule for .html covers it, where a rule by address would keep /orders/42 for a year.

cacheControl
: (
path: string
path
) =>
path: string
path
.startsWith("assets/") ? "public, max-age=31536000, immutable" : "no-cache",
});

The function gets the file’s path below the root, not the address. The shell of a single-page app is index.html at every address it answers, so a rule for .html covers it.

notFound names a file below the root, sent with 404 to a browser that asked for a path with no file. Only a request whose Accept names text/html, as a browser’s navigation does, gets the page. fetch, curl and every API client get the application’s 404, so a mistyped API address is not answered with a page. Both carry Vary: Accept, so a cache keeps them apart.

An app with a router of its own, such as React Router or Vue Router, reads the address in the browser. A reload on /orders/42 must load the app, though no file has that path. spa: true answers a browser’s request for a path with no file with the root’s index.html and 200:

const
const app: App<RoutesOf<object[]>>
app
= createApp({
routes: object[]

The topology: a group, a controller, or an array of either.

routes
,
fallback?: FallbackHandler | undefined

Answers requests whose path matched no route, replacing the default 404 response entirely. Runs with the application-level hook chains.

The result is serialized like a handler result: status 200 (or 204 for undefined) unless the fallback sets ctx.out.status or returns a Response. An SPA index wants exactly that; a custom 404 body must state its status itself.

fallback
: staticFiles({
root: string

The directory served.

A relative path is resolved against the working directory, as Bun.file resolves one, not against the module that calls staticFiles(): join(import.meta.dir, "public") is the module's. Checked when the handler is made: a root that does not exist, or is not a directory, is refused at startup rather than on the first request.

root
: "./dist",
spa: true

Answers a browser's request for a path with no file with the root's index file and 200, so a reload on /orders/42 loads the app and its router shows the order.

A request that does not accept HTML still gets the application's 404: a missing script, an API address. Both carry Vary: Accept. The app's router shows its own page for an address it does not know.

spa
: true }) });

A request that does not ask for HTML still gets the application’s 404: a script missing after a deploy, a mistyped API address. The app’s router shows its own page for an address it does not know. spa and notFound exclude each other.

The app must load its assets from absolute paths, /assets/app.js, which is what bundlers such as Vite produce by default. A relative assets/app.js on the page /orders/42 is fetched from /orders/assets/app.js.

Bun compresses nothing on its own, and a bundle is the largest response a site sends. With precompressed: true, a client whose Accept-Encoding takes it gets the copy beside a file, app.js.br, then app.js.gz, with Content-Encoding and the original’s type. The build makes the copies once, with a plugin of the bundler or a few lines of Bun after it:

import { brotliCompressSync } from "node:zlib";
for await (const
const path: string
path
of new Bun.Glob("dist/**/*.{html,js,css,svg,json}").scan()) {
const
const bytes: Uint8Array<ArrayBuffer>
bytes
= await Bun.file(
const path: string
path
).bytes();
await Bun.write(`${
const path: string
path
}.br`, brotliCompressSync(
const bytes: Uint8Array<ArrayBuffer>
bytes
));
await Bun.write(`${
const path: string
path
}.gz`, Bun.gzipSync(
const bytes: Uint8Array<ArrayBuffer>
bytes
));
}

A copy has its own ETag, Last-Modified and length, and a Range is cut from its bytes. A copy older than its original is left over from an earlier build and is not sent, so a build that forgot to compress again does not serve the old bundle. Every file then carries Vary: Accept-Encoding, so a cache keeps the copies apart. The page of notFound and the shell of spa have copies too.

The handler returns a Response, so headers of one route go on ctx.out.headers before it runs:

const
const files: StaticHandler
files
= staticFiles({
root: string

The directory served.

A relative path is resolved against the working directory, as Bun.file resolves one, not against the module that calls staticFiles(): join(import.meta.dir, "public") is the module's. Checked when the handler is made: a root that does not exist, or is not a directory, is refused at startup rather than on the first request.

root
: "./uploads" });
const downloads = route({
method: "GET"

The method this route answers.

Kept as a literal rather than widened to

Method

: the method is half of a route's identity, and an application that remembers its routes — for a generated client, for tooling — needs to know which one this is.

method
: "GET",
path: "/downloads/*"

Route path with :param segments, e.g. "/orders/:id/cancel".

Must start with /, contain no empty segments and no trailing slash; a malformed literal is a compile error.

path
: "/downloads/*",
docs?: RouteDocs | undefined

Documentation metadata for OpenAPI generation.

docs
: {
hidden?: boolean | undefined

Keeps the route out of the generated document.

For endpoints that exist but are nobody's business to call: an internal probe, an admin escape hatch, a route kept alive for one legacy client. The route is served exactly as before — this is a statement about the document, not about access, and a hidden route is as reachable as any other.

false says the opposite out loud: the route is in the document even when its handler was annotated to keep the routes it answers out of it — a package's handler serving files, say. Left out, the handler decides; written, the route does.

deprecated is the other half of the pair: an endpoint on its way out stays in the document and says so, an endpoint that was never public is simply absent.

hidden
: true },
handler: (ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}) => Promise<Response>

The endpoint logic; ctx is fully inferred, never annotate it.

The return type is inferred rather than demanded, and checked twice over. Against the route's own contract, by the

HandlerResult

bound on R: answering with something response never declared is a compile error that says so, instead of a structural diff against Response. And against what the framework can serialize at all, by the intersected

ValidateResult

: a stream handed over bare is refused whether or not the route declared anything, because that is the case no contract covers — without a response schema HandlerResult is unknown and accepts every value there is.

handler
: (
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}
ctx
) => {
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}
ctx
.
out: Outgoing

Response parameters for the serialized handler result.

out
.
headers: Headers

Headers applied to the outgoing response, whatever produced it — set-cookie values are appended, any other name overwrites.

A live standard Headers, created on first access. It is never assigned, only mutated — set() to own a header, append() to add to it — so two hooks writing headers compose instead of overwriting each other's whole set.

headers
.set("content-disposition", "attachment");
return files(
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}
ctx
);
},
});

The route mounts the arrow, not the handler, and the arrow tells the OpenAPI document nothing: without docs: { hidden: true }, the route would be in it, answering a 200.

Files your users uploaded are not the site’s own. An HTML page or an SVG served from the site’s domain runs its scripts as the site. Serve uploads from a domain of their own, or as attachments, as above.

A route of files is left out of the generated document. Nearly every one serves a site’s assets, and a generated client would get a method that cannot fetch a nested file: OpenAPI cannot say that the parameter of a * holds slashes, so a client encodes them. docs: { hidden: false } on the route shows it, and the handler describes what it answers: a file of any type with its headers, 206, 301, 304, 404 and 416. Show a flat directory, such as /downloads/*. fallback is not in the document at all, since it has no path.

Bun’s own { dir } routes serve a directory a few microseconds faster per request, as they skip the pipeline, but they skip what it does too. No hook runs for them, so security headers, CORS and logs do not apply, and they serve .env and .git like any other file, answer POST with the file, and send no Cache-Control. They suit a directory with nothing secret in it that needs none of that.

Option Default
root required the directory served, from the working directory
index "index.html" the file a directory is answered with; false for none
notFound none a file below the root, sent with 404 to a browser
spa false true sends the root’s index file with 200 to a browser at an address with no file
cacheControl "no-cache" a Cache-Control value, or a function of the file’s path below the root
precompressed false true sends the .br or .gz copy to a client that takes it

spa with notFound, and spa with index: false, are compile errors. staticFiles() throws on them too, on a root that is not a directory, and on a notFound file, or with spa the index file, that is missing, so a mistake stops the application at startup rather than on a request.

The package also exports the types StaticOptions, StaticHandler, NotFoundPage and SinglePageApp.