@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.
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 *:
constassets=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: {
readonlyreq:Request& {
readonlycookies?:Bun.CookieMap;
};
readonlyserver:Bun.Server<unknown>;
readonlyout:Outgoing;
readonlyroute:RouteInfo;
readonlystartedAt:number;
readonlyparams: {};
}) =>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;
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:
constsignedIn= 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)) throwhttpError(401);
});
constapp=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: {
readonlyreq:Request& {
readonlycookies?:Bun.CookieMap;
};
readonlyserver:Bun.Server<unknown>;
readonlyout:Outgoing;
readonlyroute:RouteInfo;
readonlystartedAt:number;
readonlyparams: {};
}) =>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.
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:
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.
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
constapp: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:
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
constfiles: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" });
constdownloads=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: {
readonlyreq:Request& {
readonlycookies?:Bun.CookieMap;
};
readonlyserver:Bun.Server<unknown>;
readonlyout:Outgoing;
readonlyroute:RouteInfo;
readonlystartedAt:number;
readonlyparams: {};
}) =>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");
returnfiles(
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.
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.