Skip to content

Repository files navigation

Zebra

A TypeScript web framework built directly for the Bun runtime, with first-class DI.

Why Zebra

  • Built for Bun. Uses Bun.serve for HTTP/WebSocket, Bun.file for static bodies, and Web Standard Request/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).

Documentation

  • 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

Install

bun add @zebra-web/zebra reflect-metadata

Decorator support is required in your tsconfig.json:

{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}

Import reflect-metadata once at your entry point, before anything else.

Install from Git

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-safe

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

Requirements

  • Bun ≥ 1.4.0 for server packages (packageManager is bun@1.4.0; CI selects the Bun 1.4 line, so minimum-version checks run separately).
  • Typecheck via tsgo — the native TypeScript compiler (@typescript/native-preview), configured in the root devDependencies.
  • reflect-metadata imported once at the entry point, and experimentalDecorators + emitDecoratorMetadata enabled (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.

Quick start

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, world

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

Advanced: bring your own Container

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.

Features

  • DI container — @injectable classes, 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 — ZebraRequest with lazy body parsing, content-type-aware body parser with size limits, request helpers (json() / text() / form() / stream()), response helpers (json / text / html / redirect / stream), HttpError for 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 await stop() plus its own cleanup (see Lifecycle).
  • Events — unified async EventBus (on / once / off / emit, single payload per event, type-safe via a global ZebraEvents interface 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/session middleware: HMAC-SHA256 signed sid cookies, req.ctx.session read/write with getSession(req), pluggable SessionStore (in-memory default), rolling TTL renewal, and session-fixation protection (destroyed/expired ids are never revived). Cookies are HttpOnly + SameSite=Lax by default; cookie: { preset: "plain" } restores a flag-free cookie.
  • CORS — @zebra-web/cors middleware: origin allowlists (string/array/RegExp/predicate), preflight handling (204 + full header set), credentials with exact-origin echo, Vary: Origin on dynamic matches.
  • Rate limiting — @zebra-web/rate-limit middleware: fixed-window per-key counters (lazy window rotation, atomic increments), pluggable RateLimitStore (in-memory default), 429 Problem+Json with X-RateLimit-* / Retry-After headers. Keys default to the socket peer IP (req.ip); x-forwarded-for is only trusted with trustProxy: 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, custom Response rejections, wsUpgrade(data, { headers }) for handshake headers, and typed listen({ websocket }) transport limits. Existing false/throw decisions retain 401/500 semantics. Callbacks align to Bun, with ws.data.session from wsSession. Upgrades bypass app.use; see WebSocket for negotiation, scope and transport behavior.
  • Testing — public app.prepare() boots and freezes an app for in-process dispatch() without opening sockets. @zebra-web/testing createTestApp wraps this flow; createTestClient gives a typed contract client over that app.
  • Contract-first — @zebra-web/contract (Standard Schema V1 builder + protocol), app.implement with input/output validation, @zebra-web/client (derived typed client, zero deps).

Examples

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

Packages

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

Status

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.

Release & packaging

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 --publish

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

License

MIT

About

a typescript web framework

Resources

Contributing

Security policy

Stars

2 stars

Watchers

2 watching

Forks

Releases

Packages

Used by

Contributors

Languages