中文 | English
A web framework built on Bun native APIs with zero external dependencies. Supports routing groups, middleware, template engine, static files, and error handling.
# Install via JSR
bunx jsr add @focal/bunnyimport { Bunny } from "@focal/bunny";
const app = new Bunny();
app.get("/", async (c) => "Hello World!");
export default app;Run:
bun run server.tsBun detects the exported fetch handler on the Bunny instance and calls Bun.serve() automatically. No explicit server startup is needed.
To customize server options (port, hostname, TLS, etc.), use Bun.serve() directly:
// Option A: Export a config object with port
export default { fetch: app.fetch, port: 3000 };
// Option B: Call Bun.serve explicitly
Bun.serve({ fetch: app.fetch, port: 3000, hostname: "0.0.0.0" });app.get("/path", handler);
app.post("/path", handler);
app.put("/path", handler);
app.delete("/path", handler);
app.patch("/path", handler);
app.options("/path", handler);
app.head("/path", handler);Path parameters (:param) via c.params:
app.get("/users/:id", async (c) => {
return { id: c.params.id };
});
// GET /users/42 → {"id":42}Query parameters via c.query:
app.get("/search", async (c) => {
return { q: c.query.q, page: c.query.page };
});
// GET /search?q=bunny&page=1 → {"q":"bunny","page":"1"}Pass a template filename as the second argument (requires app.engine() — see Template Engine):
app.get("/hello", "hello.html", async (c) => ({ name: "World" }));Static paths > :param(regex) paths > :param paths > * wildcards, regardless of registration order.
Return any value directly from the route handler — no need to call context methods:
app.get("/text", async (c) => "Hello World"); // text/html
app.get("/json", async (c) => ({ key: "value" })); // application/json
app.get("/null", async (c) => null); // 204 No Content
app.get("/response", async (c) => new Response("ok")); // Raw Response
app.get("/image", async (c) => Bun.file("./photo.png")); // Blob → auto Content-Type
app.get("/video", async (c) => {
const file = Bun.file("./video.mp4");
return file.stream(); // ReadableStream
});Or use the Context API for full control:
c.text("ok"); // Plain text
c.html("<h1>Title</h1>"); // HTML
c.json({ key: "value" }); // JSON
c.redirect("/login"); // 307 redirect
c.redirect("/new-url", 301); // Permanent redirect
c.redirect("/new-url", 308); // Permanent + preserve method
c.status(201); // Set status code
c.header("X-Version", "1.0"); // Set response headerChaining:
app.get("/created", async (c) => {
return c.status(201).header("X-Version", "1.0").json({ message: "created" });
});import { type Context } from "@focal/bunny";
async function logger(c: Context, next: () => Promise<void>) {
const start = Date.now();
await next();
console.log(`${Date.now() - start}ms`);
}
app.use(logger);app.use("/admin/*", auth);Not calling next() in a middleware stops the chain — the route handler (and subsequent middlewares) will not execute. This is how you implement auth guards, rate limiting, etc.
Create a separate Bunny instance and mount it with route():
const api = new Bunny();
api.get("/users", async (c) => ([{ id: 1, name: "Alice" }]));
app.route("/v1", api); // → /v1/usersMiddleware and error handlers from the sub-instance are only inherited if the parent has not set one.
In-memory session support:
app.get("/login", async (c) => {
c.session.set("user", { id: 1, name: "Alice" });
c.session.set("lang", "en");
return "Logged in";
});
app.get("/profile", async (c) => {
const user = c.session.get("user");
return user ? user : "Not logged in";
});
app.get("/logout", async (c) => {
c.session.remove("user"); // Remove a single key
c.session.destroy(); // Clear all data & expire cookie
return "Logged out";
});| Method | Description |
|---|---|
get(key) |
Get value by key |
set(key, value) |
Set value |
remove(key) |
Remove a single key |
destroy() |
Clear all data and expire the session cookie |
Session ID is stored in a SESS_ID cookie (HttpOnly, SameSite=Lax). Data is held in memory by the default SessionStore — restarting the server clears all sessions.
Read and write cookies via c.cookies — a CookieJar that works like Bun's routes API CookieMap. Changes are automatically applied to the response as Set-Cookie headers.
app.get("/cookies", async (c) => {
// Read
const token = c.cookies.get("token");
// Check
if (c.cookies.has("theme")) { /* ... */ }
// Write (httpOnly defaults to true)
c.cookies.set("session", "abc123");
c.cookies.set("token", "xyz", { httpOnly: false });
c.cookies.set({ name: "theme", value: "dark", path: "/" });
c.cookies.set(new Bun.Cookie("visit", "1", { maxAge: 3600 }));
// Delete
c.cookies.delete("token");
c.cookies.delete({ name: "old", path: "/admin" });
return "OK";
});| Method | Description |
|---|---|
get(name) |
Get cookie value (string | null) |
has(name) |
Check if cookie exists |
set(name, value, options?) |
Set cookie (options: httpOnly, secure, sameSite, maxAge, path, etc.) |
set(options) |
Set cookie via CookieInit object |
set(cookie) |
Set cookie via Bun.Cookie instance |
delete(name) |
Delete cookie |
delete(options) |
Delete cookie with specific domain/path |
size |
Number of cookies |
app.static("/assets", "./public");
// GET /assets/test.txt → ./public/test.txtAutomatic ETag, 304 cache negotiation, 206 Partial Content (Range requests for video seeking), directory index (index.html), and path traversal protection (.. and ~ blocked).
app.engine("./templates", { appName: "MyApp", year: 2026 });
app.get("/hello", "hello.html", async (c) => ({ name: "World" }));The root path is relative to the current working directory. app.engine() must be called before any route that uses a template — the engine is not configured otherwise.
When a template is specified as the second route argument, the object returned by the handler is merged with the global variables and exposed to the template. Each property becomes a template variable by its name.
| Syntax | Meaning | Example |
|---|---|---|
{{=expr}} |
Output expression | {{=user.name}} |
{{? expr}} |
if | {{? user.loggedIn}} |
{{?? expr}} |
else if | {{?? user.role === "admin"}} |
{{?}} |
end if | |
{{~ arr: val}} |
for loop | {{~ items: item}} |
{{~ arr: val : idx}} |
for loop with index | {{~ items: item : i}} |
{{@ file}} |
Include partial | {{@ header.html}} |
{{> name}} |
Insert a defined block | {{> sidebar}} |
{{< name}}...{{<}} |
Define a reusable block | {{< sidebar}}...{{<}} |
{{code}} |
Execute JavaScript statement (no var/let/const — the engine auto-declares variables) |
{{total = price * qty;}} |
Example template — a layout page with partials and a content block:
{{@ file}}supports both partial includes and parent template inheritance. Include a parent layout with{{@ layout.html}}, define blocks with{{< name}}...{{<}}inside it, then pass content for those blocks from the child. This enables a reusable layout pattern, ideal for pages sharing a common structure.
<!-- layout.html — the outer shell -->
<html>
<head><title>{{=title}}</title></head>
<body>
{{@ header.html}}
<main>{{> content}}</main>
{{@ footer.html}}
</body>
</html>
<!-- index.html — fills the content block and applies the layout -->
{{@ layout.html}}
{{< content}}
{{? user.loggedIn}}
<h1>Welcome {{=user.name}}</h1>
{{~ cart: item : i}}
<p>{{=i + 1}}. {{=item.name}} — ${{=item.price}}</p>
{{~}}
{{total = cart.reduce((s, i) => s + i.price, 0);}}
<strong>Total: ${{=total}}</strong>
{{?? user.role === "guest"}}
<a href="/login">Login</a>
{{?}}
{{<}}import { HttpError } from "@focal/bunny";
app.get("/error", async (c) => {
throw new HttpError(400, "Bad request");
});Register an error handler:
// With template — error template is only rendered if the errored route also had a template
app.error("error.html", async (e, c) => {
const status = e instanceof HttpError ? e.status : 500;
return { status, message: e.message };
});
// Without template — always returns the raw result
app.error(async (e, c) => {
return { error: e.message };
});Error response behavior: if the original route that caused the error had a template, the error template is rendered (HTML). Otherwise, the error handler's return value is returned as-is (JSON for objects, text for strings). Framework-level errors (404, 405, static file 403/404) also flow through the error handler.
// server.ts
import { Bunny, HttpError, type Context } from "@focal/bunny";
const app = new Bunny();
app.static("/assets", "./assets");
app.engine("./templates", { appName: "Bunny", year: 2026 });
// Logger middleware
app.use(async (c: Context, next: () => Promise<void>) => {
const start = Date.now();
await next();
console.log(`${c.req.method} ${c.req.url} — ${Date.now() - start}ms`);
});
// Auth guard — stops the chain by throwing before next()
app.use("/admin/*", async (c: Context, next: () => Promise<void>) => {
if (!c.session.get("user")) throw new HttpError(401);
await next();
});
app.get("/", async (c) => "Hello World!");
// Sub-router
const api = new Bunny();
api.get("/users", async (c) => [{ id: 1, name: "Alice" }]);
app.route("/v1", api);
// Error handler
app.error("error.html", async (e, c) => {
const status = e instanceof HttpError ? e.status : 500;
return { status, message: e.message || "Internal Server Error" };
});
export default app;| Method | Description |
|---|---|
get / post / put / delete / patch / options / head |
HTTP route registration |
use(handler) |
Global middleware |
use(pattern, handler) |
Scoped middleware |
error(handler) |
Error handler |
error(template, handler) |
Error handler with template |
route(prefix, sub) |
Mount sub-router |
static(webPath, localPath) |
Static file serving |
engine(tmplRoot, globalVars?) |
Template engine config |
| Method / Property | Description |
|---|---|
c.req |
Raw Request object |
c.cookies |
Cookie get/has/set/delete (see Cookies) |
c.params |
Path parameters |
c.query |
Query string parameters |
c.session |
Session get/set (.get<T>(key), .set(key, value)) |
c.text(str) |
Plain text response |
c.json(obj) |
JSON response |
c.html(str) |
HTML response |
c.redirect(url, code?) |
Redirect (default 307, also 301/302/308) |
c.status(code) |
Set status code (chainable) |
c.header(name, value) |
Set response header (chainable) |