Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
7d8f790
Bun.serve: support directory tree routes via { dir: "..." }
robobun Jul 27, 2026
e212640
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 27, 2026
d5911d5
DirectoryRoute: address review feedback
robobun Jul 28, 2026
3f6ebb2
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 28, 2026
5139769
DirectoryRoute: statCache option, cache-exhaustion and adversarial tests
robobun Jul 28, 2026
11b07d3
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 28, 2026
c432c4d
DirectoryRoute: 404 on miss, single fstat, PATH_MAX bound, precedence…
robobun Jul 28, 2026
61d8e79
DirectoryRoute tests: hoist raw() helper, shrink cache test; FileRout…
robobun Jul 28, 2026
c5ac6bf
DirectoryRoute: reject :, tighten subpath bound, fix clippy redundant…
robobun Jul 28, 2026
5975480
DirectoryRoute: store the full path in stat-cache entries
robobun Jul 28, 2026
077ce7b
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 28, 2026
b1bafad
Scope NO_MAGICLINKS to openat2_in_root; restore precondition why-comm…
robobun Jul 28, 2026
5255d0b
DirectoryRoute: track stat-cache path heap bytes for memory_cost()
robobun Jul 28, 2026
302861d
DirectoryRoute: Windows fixes, provenance fix, docs
robobun Jul 28, 2026
4c7c633
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 28, 2026
e16de34
DirectoryRoute: 256 cache slots, reject :params, clippy, POSIX-only N…
robobun Jul 28, 2026
920ba66
DirectoryRoute: accept absolute-form request-targets
robobun Jul 28, 2026
1e703e6
DirectoryRoute: case-insensitive scheme match for absolute-form
robobun Jul 28, 2026
0029592
DirectoryRoute: strip ? before absolute-form authority (route precede…
robobun Jul 28, 2026
64f62f7
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 28, 2026
c1804c6
DirectoryRoute: require canonical paths (route-precedence parity)
robobun Jul 28, 2026
0941cf5
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 28, 2026
b01e966
DirectoryRoute: close remaining route-precedence bypasses
robobun Jul 28, 2026
ff44afe
DirectoryRoute: reject trailing slash on regular files
robobun Jul 28, 2026
d4e5ad8
DirectoryRoute: drop scratch buffer; reject // in route path
robobun Jul 28, 2026
2266474
Merge branch 'main' into farm/a0d36689/serve-directory-routes
Jarred-Sumner Jul 29, 2026
5276989
docs: note case-insensitive filesystem caveat for directory routes
robobun Jul 29, 2026
a28e6c9
DirectoryRoute: reject lone-slash suffix; skip alt-svc on H3
robobun Jul 29, 2026
f4bebf5
test: reduce stat-cache exhaustion batch size to 32
robobun Jul 29, 2026
72cd1b5
DirectoryRoute: drop redundant Content-Length on 301 redirect
robobun Jul 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions docs/runtime/http/routing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,34 @@ Bun.serve({
- **Memory efficient** - Only buffers small chunks during transfer, not entire file
- **Best for**: Large files, dynamic content, user uploads, files that change frequently

### Directory routes

To serve an entire directory tree at a URL prefix, pass `{ dir }` as the route value. The route path must end in `/*`.

```ts
Bun.serve({
routes: {
"/static/*": { dir: "./public" },
},
});
```

The part of the request URL after the prefix is percent-decoded once and opened relative to `dir`. Non-canonical paths (those containing `.`, `..`, empty segments, `%2F`, or a `%XX` sequence encoding a character that may appear literally in a path segment) are rejected with `404`, so the served path is always the path the router matched. On Linux the open uses `openat2(RESOLVE_IN_ROOT)`, so symlinks that would escape `dir` are clamped by the kernel.

{% callout %}
Routing is case-sensitive but filesystems on macOS and Windows are case-insensitive by default, so a case-varied URL (`/static/Admin/secret.txt`) will route to the directory wildcard rather than a sibling `/static/admin/*` handler and still open `admin/secret.txt`. As with nginx, Caddy, and other static file servers, do not place access-controlled content inside `dir` and rely on an overlapping route to gate it.
{% /callout %}

Directory routes share the response path with file routes:

- **Content-Type** is set from the file extension.
- **Last-Modified** and a weak `ETag` (`W/"<size>-<mtime>"`) are sent on every response, and `If-Modified-Since` / `If-None-Match` are honored with `304 Not Modified`.
- **Range requests** are supported with `Accept-Ranges: bytes` and `Content-Range`.
- A request that resolves to a directory without a trailing `/` is answered with a `301` redirect to the trailing-slash URL; with the trailing slash, `index.html` from that directory is served.
- Missing files return `404`.

Pass `statCache: false` to disable the per-path `Last-Modified` cache (saves roughly 20 KB per route).

---

## Streaming files
Expand Down
43 changes: 42 additions & 1 deletion packages/bun-types/serve.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -579,7 +579,48 @@ declare module "bun" {

type Handler<Req extends Request, S, Res> = (request: Req, server: S) => MaybePromise<Res>;

type BaseRouteValue = Response | false | HTMLBundle | BunFile;
/**
* Serve a directory tree at a URL prefix.
*
* The route path **must** end in `/*`. The part of the request URL after
* the prefix is percent-decoded once and opened relative to `dir`.
* Non-canonical paths (containing `.`, `..`, empty segments, `%2F`, or a
* `%XX` sequence encoding a character that may appear literally in a path
* segment) are rejected with `404` so the served path is always the path
* the router matched. On Linux the open uses `openat2(RESOLVE_IN_ROOT)`,
* so symlinks that would escape `dir` are clamped by the kernel. Routing
* is case-sensitive but filesystems on macOS and Windows are not by
* default: do not place access-controlled content inside `dir` and rely
* on an overlapping route to gate it.
*
* Responses carry `Content-Type` (from the file extension),
* `Last-Modified`, a weak `ETag`, and support single-range `Range`
* requests. A request that resolves to a directory without a trailing
* `/` is redirected (`301`) to the trailing-slash URL; with the trailing
* slash, `index.html` from that directory is served. Missing files
* return `404`.
*
* @example
* ```ts
* Bun.serve({
* routes: {
* "/static/*": { dir: "./public" },
* },
* });
* ```
*/
interface DirectoryRouteOptions {
/** Path to the directory to serve. */
dir: string;
/**
* Cache formatted `Last-Modified` strings per path so repeated requests
* for an unchanged file skip the date formatter. Uses ~20 KB per route.
* @default true
*/
statCache?: boolean;
}

type BaseRouteValue = Response | false | HTMLBundle | BunFile | DirectoryRouteOptions;
Comment thread
robobun marked this conversation as resolved.

type Routes<WebSocketData, R extends string> = {
[Path in R]:
Expand Down
2 changes: 1 addition & 1 deletion src/http_types/MimeType.rs
Original file line number Diff line number Diff line change
Expand Up @@ -432,7 +432,7 @@ pub fn by_extension(ext_without_leading_dot: &[u8]) -> MimeType {
}

pub fn by_extension_no_default(ext_without_leading_dot: &[u8]) -> Option<MimeType> {
if let Some(entry) = EXTENSIONS.get(ext_without_leading_dot) {
if let Some(entry) = EXTENSIONS.get_ascii_case_insensitive(ext_without_leading_dot) {
return Some(Compact::from(*entry).to_mime_type());
}
None
Expand Down
2 changes: 1 addition & 1 deletion src/paths/resolve_path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ fn tl_buf_mut<const N: usize>(b: &UnsafeCell<[u8; N]>) -> &'static mut [u8; N] {
}

pub fn z<'a>(input: &[u8], output: &'a mut PathBuffer) -> &'a ZStr {
if input.len() > MAX_PATH_BYTES {
if input.len() >= MAX_PATH_BYTES {
if cfg!(debug_assertions) {
panic!("path too long");
}
Expand Down
Loading
Loading