Skip to content

Repository files navigation

🐰 Bunny — Lightweight Web Framework (Bun & Deno)

中文 | 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.

Installation

# Bun — via JSR
bunx jsr add @focal/bunny

# Deno — via JSR
deno add @focal/bunny
# or import directly:
# import { Bunny } from "jsr:@focal/bunny"

Quick Start

// server.ts
import { Bunny } from "@focal/bunny";

const app = new Bunny();
app.get("/", async (c) => "Hello World!");

Bun

bun run server.ts

Add 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

deno run -A server.ts

Deno 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.

Routing

HTTP Methods

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"}

Template Rendering

Pass a template filename as the second argument (see Template Engine):

app.get("/hello", "hello.html", async (c) => ({ name: "World" }));

Priority

Static paths > :param(regex) paths > :param paths > * wildcards, regardless of registration order.

Response

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 header

Chaining:

app.get("/created", async (c) => {
    return c.status(201).header("X-Version", "1.0").json({ message: "created" });
});

Middleware

Global Middleware

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);

Scoped Middleware

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.

Route Grouping

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/users

Middleware and error handlers from the sub-instance are only inherited if the parent has not set one.

Session

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.

Cookies

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

Static Assets

app.static("/assets", "./public");
// GET /assets/test.txt → ./public/test.txt

Automatic ETag, 304 cache negotiation, 206 Partial Content (Range requests for video seeking), directory index (index.html), and path traversal protection (.. and ~ blocked).

Template Engine

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 }); // Both

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.

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>
  {{?}}
{{<}}

Error Handling

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.

Full Example

// 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;

API Reference

Bunny

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

Context

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>

About

A web framework built on Bun native APIs with zero external dependencies.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages