中文 | English
A web framework built on Bun native APIs with zero external dependencies. Also runs on Deno. Supports routing groups, middleware, template engine, static files, and error handling.
# Bun — via JSR
bunx jsr add @focal/bunny
# Deno — via JSR
deno add @focal/bunny
# or import directly:
# import { Bunny } from "jsr:@focal/bunny"// server.ts
import { Bunny } from "@focal/bunny";
const app = new Bunny();
app.get("/", async (c) => "Hello World!");bun run server.tsAdd export default app; at the end of server.ts. Bun detects the fetch handler and calls Bun.serve() automatically — no explicit startup needed.
Customize server options:
export default { fetch: app.fetch, port: 3000 };
// or
Bun.serve({ fetch: app.fetch, port: 3000, hostname: "0.0.0.0" });deno run -A server.tsDeno requires explicit server startup (no auto-detection):
Deno.serve({ port: 3000 }, app.fetch);All Bunny APIs (routing, middleware, sessions, cookies, template engine, static files) work identically across both runtimes.
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 (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 / Property | Description |
|---|---|
id |
Current session ID (UUID string) |
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, session cookie — cleared when the browser closes). Data is held in memory by the default SessionStore — restarting the server clears all sessions.
Sessions expire after 1 hour of inactivity (sliding TTL): every read or write refreshes the timer, so an actively-used session never times out, and a session is only dropped after an hour without any interaction. Expired sessions are swept from memory every 5 minutes, and destroy() removes the session entry immediately.
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 defaults to the current working directory. app.engine() accepts three call signatures:
app.engine("./views"); // Only set root
app.engine({ appName: "MyApp", year: 2026 }); // Only set globals
app.engine("./views", { appName: "MyApp", year: 2026 }); // BothWhen 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.
You can also render templates manually via c.render() (template string) and c.view() (template file) — both return the rendered HTML string and merge ctx.templateData (middleware-contributed data) automatically:
app.get("/manual", async (c) => {
const html = await c.view("hello.html", { name: "World" });
return c.html(html);
});
app.get("/inline", async (c) => {
const html = await c.render("<h1>Hello {{=name}}</h1>", { name: "World" });
return c.html(html);
});| 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) |
Set template root directory |
engine(globalVars) |
Set global template variables |
engine(tmplRoot, globalVars) |
Set both |
| Method / Property | Description |
|---|---|
c.req |
Raw Request object |
c.cookies |
Cookie get/has/set/delete (see Cookies) |
c.ip |
Remote client IP (auto-detected via server.requestIP() & proxy headers) |
c.params |
Path parameters |
c.query |
Query string parameters |
c.session |
Session get/set/remove/destroy (.get<T>(key), .set(key, value), .id) |
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) |
c.render(template, data?) |
Render a template string, returns Promise<string> |
c.view(file, data?) |
Render a template file, returns Promise<string> |