A Bun-first TypeScript web framework with first-class DI.
v2 is a rewrite. v1 (2019) is archived on the
v1-archivetag.
- Bun-first. Uses
Bun.serve,Bun.file, and Web StandardRequest/Response. No Node compat layer. - DI is mandatory, not bolted on. Every app is built around a
Container. Routes and middleware declare their dependencies; the container validates the full graph at boot. - Named-object route DI.
app.get(path, { svc: Service }, (req, { svc }) => ...)— explicit, type-safe, no string-parsing tricks. - Structured errors. Default error responses follow RFC 9457 (Problem+Json).
- Contract-first (oRPC style). Define a contract once (
zc.get(path).params(s).query(s).body(s).output(s).status(n).errors(e).meta(m)), implement it on the server with full type inference + runtime validation (app.implement), and derive a type-safe client from the same contract (createClient/createTestClient).
- Docs — guides: getting started, routing, DI, middleware, HTTP, lifecycle, sessions, CORS, rate limiting, WebSocket, contract-first, testing, observability, Redis, production
- API freeze — the frozen v1.0 surface and SemVer policy
bun add zebra reflect-metadataDecorator support is required in your tsconfig.json:
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}Import reflect-metadata once at your entry point, before anything else.
- Bun ≥ 1.1.30 at runtime (tested on 1.3.x). The repository's test suite
uses
bun:testAPIs added in Bun 1.3 (expectTypeOf, WebSocket client helpers), so tests and CI run on Bun ≥ 1.3. - TypeScript ≥ 5.6 (root devDependency).
reflect-metadataimported once at the entry point, andexperimentalDecorators+emitDecoratorMetadataenabled (see Install).
import "reflect-metadata";
import { Zebra } from "zebra";
const z = new Zebra();
z.get("/hello/:name", async (req) => new Response(`hello, ${req.params.name}`));
await z.listen({ port: 3000 });bun run src/main.ts
curl http://localhost:3000/hello/world
# hello, worldWith dependencies, register them on the Zebra instance and pull them into routes by name:
import "reflect-metadata";
import { Zebra, injectable } from "zebra";
@injectable() class Greeter { greet(n: string) { return `hi, ${n}`; } }
const z = new Zebra();
z.injectSingleton(Greeter);
z.get("/hi/:name", { g: Greeter }, async (req, { g }) => g.greet(req.params.name));
await z.listen({ port: 3000 });For tests that mock specific bindings or apps that share a container, construct one explicitly:
import { Container, Zebra } from "zebra";
const container = new Container();
container.bind(IRepo).to(MockRepo);
const z = new Zebra({ container });z.inject* methods write to whichever container the Zebra instance owns.
- DI container —
@injectableclasses, token bindings, four scopes (singleton / request / session / transient), boot-time circular-dependency and scope checks. - Routing — radix-tree router with params (
/:id) and wildcards;app.get/post/put/patch/delete. - Groups —
app.group("/blogs", g => { ... })with prefix and per-group middleware scoping. - Middleware — Koa-style compose, dep-aware
middleware()helper, default Problem+Json error middleware. - HTTP —
ZebraRequestwith lazy body parsing, content-type-aware body parser with size limits, request helpers (json()/text()/form()/stream()), response helpers (json/text/html/redirect/stream),HttpErrorfor structured failures. - Static files —
app.static()with path-traversal and symlink-escape defense (realpath containment), weak ETags, conditional requests, and byte ranges. - Lifecycle — boot/ready/shutdown hooks, graceful draining, and disposable cleanup wired to
Bun.serve. - Session-scoped DI — session-id resolution, idle TTL, explicit
disposeSession(), and request-local anonymous sessions. - Cookie sessions —
@zebra/sessionmiddleware: HMAC-SHA256 signedsidcookies,req.ctx.sessionread/write withgetSession(req), pluggableSessionStore(in-memory default), rolling TTL renewal, and session-fixation protection (destroyed/expired ids are never revived). Opt-incookie: { preset: "secure" }(orSECURE_COOKIE) forHttpOnly+SameSite=Lax; the default plain cookie is frozen v1 behavior. - CORS —
@zebra/corsmiddleware: origin allowlists (string/array/RegExp/predicate), preflight handling (204 + full header set), credentials with exact-origin echo,Vary: Originon dynamic matches. - Rate limiting —
@zebra/rate-limitmiddleware: fixed-window per-key counters (lazy window rotation, atomic increments), pluggableRateLimitStore(in-memory default), 429 Problem+Json withX-RateLimit-*/Retry-Afterheaders. Keys default to the socket peer IP (req.ip);x-forwarded-foris only trusted withtrustProxy: true(required behind a proxy that overwrites it — otherwise clients can spoof their own budget). - WebSocket —
app.ws(path, handler): upgrade path wired intoBun.servewith radix-router params, DI-resolved upgrade decision (onUpgrade+upgrade()→ 401/500 on rejection),open/message/closealigned to Bun semantics,ws.data.sessionreachable via the session middleware'swsSessionhook. Note: upgrade requests bypassapp.useglobal middleware (upgrade runs before the composed middleware chain). - Testing —
@zebra/testingcreateTestAppruns requests in-process without opening sockets;createTestClientgives a typed contract client over that app. - Contract-first —
@zebra/contract(Standard Schema V1 builder + protocol),app.implementwith input/output validation,@zebra/client(derived typed client, zero deps).
examples/hello— minimal Zebra app — http://localhost:3000examples/blog— DI services, route groups, structured errors — http://localhost:3001examples/contract-blog— contract-first: shared contract,app.implement, typed client round-trip — http://localhost:3001examples/forum— full-featured: contract-first API, DI, signed-cookie sessions, per-user rate limiting, CORS, WebSocket live feed, static frontend, integration tests — http://localhost:3002examples/better-auth— Better Auth integration: one middleware mounts/api/auth/*, protected routes via server-side session checks,bun:sqlitestorage, integration tests — http://localhost:3003
Run an example from the repo root:
bun --filter example-hello start
bun --filter example-blog start
bun --filter example-contract-blog start # contract-first server
bun --filter example-contract-blog client # typed client round-trip
bun --filter example-forum start # forum: http://localhost:3002
bun --filter example-forum client # typed client round-trip
bun --filter example-forum test # in-process integration tests
bun --filter example-better-auth start # better-auth: http://localhost:3003
bun --filter example-better-auth test # in-process integration tests| Package | What it is |
|---|---|
zebra |
Public facade — re-exports @zebra/core, @zebra/session |
@zebra/core |
App, DI container, router, HTTP, middleware, implement |
@zebra/contract |
Contract builder + protocol (Standard Schema V1, zero deps) |
@zebra/client |
Derived type-safe client (zero deps) |
@zebra/session |
Cookie sessions: HMAC-signed sid, pluggable store, fixation-safe |
@zebra/cors |
CORS middleware: preflight, origin allowlists, credentials echo |
@zebra/rate-limit |
Fixed-window rate limiting: 429 Problem+Json, X-RateLimit-* headers, pluggable store |
@zebra/testing |
createTestApp / createTestClient in-process |
v1.0.0 is in preparation: API freeze is complete. The public API surface of
all packages (zebra facade, @zebra/core, @zebra/contract, @zebra/client,
@zebra/testing, @zebra/session, @zebra/cors, @zebra/rate-limit) is frozen
as of docs/api-freeze.md — that document defines the v1
stability promise and the SemVer version policy (what requires a major). The
framework includes DI (singleton / request / session / transient scopes), radix
router, middleware, lifecycle, static files, WebSocket (app.ws() with DI
upgrade decision), contract-first (@zebra/contract, app.implement,
@zebra/client, createTestClient), cookie sessions, CORS, rate limiting, and
testing helpers. Final v1.0.0 release tracks the remaining C2–C4 items (docs
site, benchmarks, release pipeline).
All packages publish src directly: main, types, and exports["."]
point at ./src/index.ts, and the tarball ships only src/ (files: ["src"]).
No build step runs on publish — consumers get the TypeScript sources and Bun's
native TS support runs them directly (bundler-resolution consumers get the
same files).
bun run build produces dist/ bundles (--target bun --packages external)
for bundler/edge consumers who prefer prebuilt artifacts, but dist/ is not
part of the published tarball (files: ["src"] excludes it).
bun run verify:packages packs every publishable package into a tarball and
smoke-tests each one from a fresh install: contents (src/index.ts present,
no dist/ leakage), exports/types resolution, runtime imports, and a tsc
typecheck of the installed packages. It guards the src-direct strategy above.
Versions are bumped in lockstep across all packages by
scripts/release.ts (bun run release -- --version X.Y.Z),
which also writes the CHANGELOG section from Conventional
Commits. See CONTRIBUTING.md and
SECURITY.md.
MIT