A TypeScript web framework built directly for the Bun runtime, with first-class DI.
- Built for Bun. Uses
Bun.servefor HTTP/WebSocket,Bun.filefor static bodies, and Web StandardRequest/Response. - 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-web/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.
To consume the repository directly, pin a full commit SHA as a git dependency:
{
"dependencies": {
"@zebra-web/source": "github:zhy0216/zebra#<full-commit-sha>"
}
}The git dependency exposes the same sources through three entry points:
import { Zebra } from "@zebra-web/source/core"; // server (Bun)
import { zc } from "@zebra-web/source/contract"; // browser-safe
import { createClient } from "@zebra-web/source/client"; // browser-safereflect-metadata is declared as a runtime dependency of the root package, so a
plain bun install provides it transitively. contract and client do not
import core or any Bun server code. No install scripts or prebuilt dist/
artifacts are involved, and npm subpackage publishing is unchanged; update the
pinned SHA explicitly to move to a newer revision.
- Bun ≥ 1.4.0 for server packages (
packageManagerisbun@1.4.0; CI selects the Bun1.4line, so minimum-version checks run separately). - Typecheck via
tsgo— the native TypeScript compiler (@typescript/native-preview), configured in the root devDependencies. reflect-metadataimported once at the entry point, andexperimentalDecorators+emitDecoratorMetadataenabled (see Install).
Session signing uses Bun.CryptoHasher for HMAC-SHA256 and retains
node:crypto.timingSafeEqual for verification. Static-file safety checks retain
Bun's node:fs / node:path APIs for metadata, path containment and realpaths.
Targeting Bun does not mean removing every node: import. JSON responses and
bounded request-body merging retain their existing implementations after native
candidates failed the compatibility or performance criteria; see the
benchmark evaluation.
@zebra-web/client and @zebra-web/contract remain browser-safe: they use Web APIs
and pure TypeScript without Bun runtime references. Keep server packages out of
browser bundles.
import "reflect-metadata";
import { Zebra } from "@zebra-web/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-web/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-web/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. Automatic SIGINT/SIGTERM handling defaults to on;new Zebra({ signalHandlers: false })lets an embedding application own signals and awaitstop()plus its own cleanup (see Lifecycle). - Events — unified async
EventBus(on/once/off/emit, single payload per event, type-safe via a globalZebraEventsinterface you extend), plus built-in request (before.request/after.request/request.error) and middleware (before.middleware/after.middleware/middleware.error) events. - Session-scoped DI — session-id resolution, idle TTL, explicit
disposeSession(), and request-local anonymous sessions. - Cookie sessions —
@zebra-web/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). Cookies areHttpOnly+SameSite=Laxby default;cookie: { preset: "plain" }restores a flag-free cookie. - CORS —
@zebra-web/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-web/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): DI-resolved upgrades with path params, customResponserejections,wsUpgrade(data, { headers })for handshake headers, and typedlisten({ websocket })transport limits. Existing false/throw decisions retain 401/500 semantics. Callbacks align to Bun, withws.data.sessionfromwsSession. Upgrades bypassapp.use; see WebSocket for negotiation, scope and transport behavior. - Testing — public
app.prepare()boots and freezes an app for in-processdispatch()without opening sockets.@zebra-web/testingcreateTestAppwraps this flow;createTestClientgives a typed contract client over that app. - Contract-first —
@zebra-web/contract(Standard Schema V1 builder + protocol),app.implementwith input/output validation,@zebra-web/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-web/zebra |
Public facade — re-exports core, CORS, session, and rate-limit APIs; MemoryStore / MemoryStoreOptions use RateLimitMemoryStore / RateLimitMemoryStoreOptions aliases |
@zebra-web/core |
App, DI container, router, HTTP, middleware, implement, event bus |
@zebra-web/contract |
Contract builder + protocol (Standard Schema V1, zero deps) |
@zebra-web/client |
Derived type-safe client (zero deps) |
@zebra-web/session |
Cookie sessions: HMAC-signed sid, pluggable store, fixation-safe |
@zebra-web/cors |
CORS middleware: preflight, origin allowlists, credentials echo |
@zebra-web/rate-limit |
Fixed-window rate limiting: 429 Problem+Json, X-RateLimit-* headers, pluggable store |
@zebra-web/testing |
createTestApp / createTestClient in-process |
@zebra-web/observability |
Request IDs, access logs, error reporting, metrics, and health probes |
@zebra-web/redis |
Redis adapters for session and rate-limit stores |
@zebra-web/mcp |
Expose contract procedures as MCP tools through the HTTP dispatch pipeline |
@zebra-web/schema-zod |
Zod input JSON Schema adapter for MCP tool discovery |
The repository contains 12 packages at version 1.0.0, versioned in lockstep. docs/api-freeze.md records the frozen v1 surfaces and SemVer policy, including the observability and Redis packages. MCP and its Zod adapter are documented in the MCP guide.
The bilingual VitePress docs site, local benchmark regression gate, and lockstep
release workflow are implemented. Use bun run docs:build to build the site and
bun run bench:check to compare performance against the machine-specific baseline.
CI runs typechecking, lint, build, tests, package verification, and core coverage;
the benchmark gate runs locally. The docs deployment workflow publishes the site
from master, and the npm workflow runs when a GitHub Release is published.
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 consumers who need local Bun-targeted artifacts, but dist/ is not
part of the published tarball (files: ["src"] excludes it). Browser consumers
bundle the client/contract source entry points for their browser target.
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 tsgo
typecheck of the installed packages. It guards the src-direct strategy above.
Versions are bumped in lockstep across all packages by
scripts/release.ts. For a public npm release, pass the
official registry explicitly:
bun run release -- --version X.Y.Z --registry https://registry.npmjs.org
bun run release -- --version X.Y.Z --prepare
bun run release -- --version X.Y.Z --registry https://registry.npmjs.org --publishFor the recommended GitHub flow, run --prepare, push the commit and tag with
git push origin master --follow-tags, then create and publish a GitHub Release
for that tag. The Publish npm packages workflow runs the checks and publishes
all @zebra-web/* packages automatically. Configure the repository secret
NPM_TOKEN with a granular npm token that has package read/write access for the
@zebra-web scope and 2FA bypass enabled. See CONTRIBUTING.md
and SECURITY.md.
MIT