bunsrv
v0.0.5
Published
Middleware-enabled HTMLBundle server for Bun
Maintainers
Readme
bunsrv
bunsrv is a middleware-aware server layer for Bun.
It combines:
- Bun
HTMLBundleserving - static directory serving
- middleware around bundles, APIs, and opted-in static routes
- memory, disk, and hybrid bundle storage
- byte ranges and conditional responses for bundle assets
- dynamic bundle registration and replacement
- configurable static index behavior
- optional generated directory listings
By default, static files use Bun's native { dir } resolver. An explicit staticMode:"file" route instead uses confined path resolution followed by Bun.file().
Install
bun add bunsrvQuick start
import {
createBundledServer,
} from "bunsrv";
import {
basicAuth,
logger,
} from "bunsrv/middleware";
import mainBundle from "./index.html";
const app =
await createBundledServer({
port: 3000,
routes: {
// HTMLBundle
"/main":
mainBundle,
// Fast path: direct Bun-native static serving
"/static/*": {
dir: "./public",
},
// Static through bunsrv middleware
"/private/*": {
dir: "./private",
middleware: true,
},
// Browseable directory:
// index.html first, listing when no index exists
"/files/*": {
dir: "./files",
directoryListing: true,
},
},
fetch(req) {
const url =
new URL(req.url);
if (
url.pathname ===
"/api/hello"
) {
return Response.json({
message: "hello",
});
}
return new Response(
"Not found",
{
status: 404,
},
);
},
});
app.use(logger());
app.use(
basicAuth({
username: "admin",
password: "secret",
routes: ["/private"],
}),
);The routes above use different paths internally:
| Route | Middleware | Static strategy |
|---|---:|---|
| /main | yes | bunsrv bundle cache |
| /static/* | no | direct Bun-native { dir } |
| /private/* | yes | bunsrv → HTTP over the resolved private transport → private Bun-native { dir } |
| /files/* | no | bunsrv listing/index logic → loopback → private Bun-native { dir } |
| /api/hello | yes | config.fetch |
A static route can be bunsrv-managed without running middleware. directoryListing, topLevelIndex:false, index:"bunsrv", index:false, or a custom indexFile require bunsrv-managed routing; middleware only runs when middleware:true.
HTML bundles
Register an imported Bun HTML bundle:
import appBundle from "./index.html";
const app =
await createBundledServer({
routes: {
"/app":
appBundle,
},
});Or pass an HTML entry path and let bunsrv build it:
const app =
await createBundledServer({
routes: {
"/app":
"./index.html",
},
});Bundle entrypoints and generated assets always pass through the bunsrv middleware pipeline.
app.use(
basicAuth({
username: "admin",
password: "secret",
routes: ["/app"],
}),
);Protecting /app also protects generated JavaScript, CSS, images, and other bundle outputs below that prefix.
For storage modes, ranges, conditional requests, hot replacement, and dynamic bundle registration, see README-bundles.md.
Static directories
Direct Bun-native static
The simplest static route is:
routes: {
"/static/*": {
dir: "./public",
},
}This is the fastest path. Bun handles the directory route directly and bunsrv middleware is not involved.
Static serving modes
Each static route can choose an implementation:
type DirectoryStaticMode =
| "auto"
| "native"
| "loopback"
| "file";"auto" is the default and preserves the current behavior:
plain static route
-> bun-native
route needing middleware/listing/bunsrv index behavior
-> managed loopbackForce a mode per route:
routes: {
"/native/*": {
dir: "./public",
staticMode: "native",
},
"/loopback/*": {
dir: "./public",
staticMode: "loopback",
},
"/file/*": {
dir: "./public",
staticMode: "file",
},
}Or set a server default:
const app =
await createBundledServer({
staticMode: "file",
});A route-level staticMode overrides the server default.
staticMode:"file" performs confined path resolution inside the configured root and serves regular files with Bun.file(). It supports middleware, indexes, listings, conditional requests, and Bun's native file range handling without creating a private static server.
Static routes can also override the server range policy:
"/downloads/*": {
dir: "./downloads",
staticMode: "loopback",
ranges: false,
}Resolution is:
const ranges =
route.ranges ??
server.ranges ??
true;ranges:false is enforceable by loopback routes. In staticMode:"auto",
it promotes a plain route to loopback. Explicit "native" and "file"
routes reject ranges:false because Bun owns range handling there.
Static with middleware
Opt in with:
routes: {
"/private/*": {
dir: "./private",
middleware: true,
},
}With automatic mode, middleware:true selects the managed loopback
implementation:
public request
↓
bunsrv middleware
↓
loopback HTTP over AF_UNIX/TCP
↓
private Bun native { dir }A route can explicitly use:
staticMode: "file"to run middleware and then serve the regular file with Bun.file()
instead. Bun 1.4's Server.fetch() does not dispatch native { dir }
routes, which is why the loopback implementation uses real HTTP.
Index behavior
type DirectoryIndexMode =
| "bun"
| "bunsrv"
| false;Example:
routes: {
"/docs/*": {
dir: "./docs",
middleware: true,
index: "bunsrv",
indexFile: "index.html",
},
}"bun"leaves nested directory index behavior to Bun."bunsrv"explicitly selects the configured index file, then delegates the actual file read to Bun.falsedisables index files for directories.
Top-level index
Control the static mount root independently:
routes: {
"/files/*": {
dir: "./files",
index: "bunsrv",
topLevelIndex: false,
directoryListing: true,
},
}Now:
/files/
-> generated directory listing
/files/docs/
-> docs/index.html when it exists
-> generated listing when it does nottopLevelIndex defaults to true.
Directory listings
Directory listings are off by default.
routes: {
"/files/*": {
dir: "./files",
directoryListing: true,
},
}With the normal index behavior, bunsrv serves the index file first and generates a listing only when the index is missing.
To always list directories:
routes: {
"/files/*": {
dir: "./files",
index: false,
directoryListing: true,
},
}Generated listing HTML can also pass through middleware:
routes: {
"/private-files/*": {
dir: "./private-files",
middleware: true,
directoryListing: true,
},
}See README-static.md for the full static routing, index, listing, security, and caching behavior.
Static route options
type StaticTransport =
| "auto"
| "tcp"
| "unix"
| {
type: "unix";
path?: string;
};
interface DirectoryRouteConfig {
dir: string;
staticMode?: "auto" | "native" | "loopback" | "file";
ranges?: boolean;
statCache?: boolean;
middleware?: boolean;
index?: "bun" | "bunsrv" | false;
indexFile?: string;
topLevelIndex?: boolean;
directoryListing?: boolean;
}Server-level managed-static transport:
const app =
await createBundledServer({
staticTransport:
"auto",
});"auto" tries AF_UNIX first and falls back to 127.0.0.1 TCP if the current Bun/platform cannot use the socket. Use "tcp" or "unix" to require a specific transport. A custom socket path is supported with { type:"unix", path:"..." }.
Important defaults:
| Option | Default |
|---|---|
| route staticMode | server staticMode or "auto" |
| server staticMode | "auto" |
| route ranges | server ranges or true |
| server ranges | true |
| middleware | false |
| direct static index | "bun" |
| managed static index | "bunsrv" |
| indexFile | "index.html" |
| topLevelIndex | true |
| directoryListing | false |
| server staticTransport | "auto" |
Middleware
Middleware uses around-style async composition:
import type {
Middleware,
} from "bunsrv";
const timing: Middleware =
async (req, next) => {
const started =
performance.now();
const response =
await next();
console.log(
req.method,
new URL(req.url).pathname,
response.status,
performance.now() -
started,
);
return response;
};
app.use(timing);Built-in middleware includes:
import {
basicAuth,
bodyLimit,
cacheControl,
cors,
errorHandler,
headers,
logger,
mutateHtml,
mutateJson,
requestId,
rewrite,
securityHeaders,
} from "bunsrv/middleware";Direct Bun-native static routes do not run the bunsrv middleware stack. Static routes run middleware only when configured with middleware:true.
For built-in options, ordering, request replacement, response mutation, and custom middleware, see README-middleware.md.
Bundle storage
Global storage:
const app =
await createBundledServer({
storage: {
type: "hybrid",
maxMemoryAssetSize:
512 * 1024,
},
});Supported modes:
type BundleStorage =
| "memory"
| "disk"
| {
type: "hybrid";
maxMemoryAssetSize?: number;
};Hybrid storage is the default. Small assets stay memory-backed and larger assets remain disk-backed.
Per-bundle storage is also supported:
routes: {
"/media": {
source:
"./media.html",
storage:
"disk",
},
}Dynamic bundles
Add or replace a bundle:
await app.addBundle(
"./admin.html",
"/admin",
);Choose storage for that registration:
await app.addBundle(
"./media.html",
"/media",
{
storage: "disk",
},
);Remove a bundle:
app.removeBundle(
"/admin",
);Bundle replacement is staged before the live route map is changed, and generation tracking prevents an older slow build from replacing a newer generation.
Fallback handler
Use fetch for APIs or other unmatched requests:
const app =
await createBundledServer({
fetch(req) {
if (
new URL(req.url)
.pathname ===
"/api/health"
) {
return Response.json({
ok: true,
});
}
return new Response(
"Not found",
{
status: 404,
},
);
},
});The fallback handler runs through middleware.
Startup route table
Unless quiet:true is set, bunsrv prints the configured route mounts and serving behavior:
bunsrv http://localhost:3000/
Static delegate:
requested: auto
transport: unix
socket: /tmp/bunsrv-ab12cd34.sock
routes: 2
ROUTE TYPE MIDDLEWARE STRATEGY INDEX TOP-INDEX LISTING RANGES TARGET
------------ ------ ---------- ------------- ------------------ --------- ------- ------ ------------
/main bundle yes bunsrv - - - yes hybrid
/static/* static no bun-native bun yes no bun ./public
/private/* static yes loopback:unix bunsrv:index.html yes no yes ./private
/files/* static no loopback:unix bunsrv:index.html yes yes yes ./files
/downloads/* static yes bun-file bunsrv:index.html yes no bun ./downloads
<unmatched> fetch yes bunsrv - - - - config.fetchSuppress it:
const app =
await createBundledServer({
quiet: true,
});CLI applications can map --quiet / -q to this option.
Shutdown
Use:
await app.close();This stops the public server, stops the private loopback static server when one exists, and removes bunsrv-owned temporary bundle build output.
To close active connections immediately:
await app.close(true);Public API
import {
createBundledServer,
} from "bunsrv";
import type {
AssetStorage,
BundleRegistrationOptions,
BundleRoute,
BundleRouteConfig,
BundleServeConfig,
BundleSource,
BundleStorage,
BundledServer,
CachedAsset,
DirectoryIndexMode,
DirectoryRouteConfig,
DirectoryStaticMode,
FallbackHandler,
HybridStorageOptions,
Middleware,
Next,
StaticTransport,
StaticUnixTransportOptions,
StorageStats,
} from "bunsrv";Main server methods:
app.use(middleware);
await app.addBundle(
source,
prefix,
options,
);
app.removeBundle(prefix);
app.storageStats();
await app.close();