A lightweight, fast HTTP framework for Bun, built from scratch with TypeScript.
Inspired by Elysia, Hono, and Express — but designed to understand how frameworks actually work under the hood.
No external dependencies. Pure Bun APIs.
import { Eva } from './src/eva';
import { cors } from './src/cors';
const eva = new Eva();
// Middleware
eva.use(async (ctx, next) => {
console.log(`${ctx.req.method} ${ctx.req.url}`);
await next();
});
// CORS
eva.use(cors({ origin: '*' }));
// Routes
eva.get('/', () => {
return new Response('Hello from Eva!');
});
eva.get('/api/users/:id', (ctx) => {
return ctx.toJson({ id: ctx.params.id, name: 'Alex' });
});
eva.post('/api/users', async (ctx) => {
const body = await ctx.json();
return ctx.toJson({ created: true, data: body }, { status: 201 });
});
// Listen
eva.serve();- Trie routing — efficient route matching with param extraction
- Middleware — global and route-level
- Built-in CORS — configurable cross-origin support
- Error handling — custom error hierarchy
- HEAD method — automatic HEAD route generation
- 405 Method Not Allowed — proper response for unsupported methods
- No external dependencies — pure Bun, pure learning
const eva = new Eva();
eva.get(route, handler); // GET
eva.post(route, handler); // POST
eva.put(route, handler); // PUT
eva.patch(route, handler); // PATCH
eva.delete(route, handler); // DELETE
eva.options(route, handler); // OPTIONS
// HEAD is derived automatically from each GET route — no eva.head()
eva.use(middleware); // global middleware
eva.serve(port, callback); // start server// request
ctx.json<B>(); // parse JSON body (cached)
ctx.text(); // raw body text (cached)
ctx.form<B>(); // parse form body (cached)
ctx.params; // route parameters
ctx.query; // parsed query string
ctx.getHeader(name); // get request header
ctx.getCookies(); // parsed request cookies
ctx.req; // original Request
// response
ctx.toJson(data, options); // JSON response
ctx.toText(data, options); // text response
ctx.redirect(url, status); // redirect response
ctx.setHeader(name, value); // set response header
ctx.setCookie({ name, value, options }); // set response cookietype EvaMiddleware = (
ctx: EvaContext,
next: () => Promise<void>,
) => Promise<void>;Request
↓
Middleware Chain (global)
↓
Route Matching (Trie)
↓
Route Middleware (per-route)
↓
Handler
↓
Response
bun test # run the suite
bun test --watch # re-run on change
bun test --coverage # coverage reportTests live next to the code as *.test.ts and use bun:test (Jest-compatible API, zero config).
The order matters: phase 0 makes the framework testable, phase 1 locks current behavior in tests and fixes known bugs against those tests, and only then do new features land — always test-first.
- Extract the
fetchclosure inEva.serve()into a publichandle(req: Request): Promise<Response>method, so the whole framework can be tested withapp.handle(new Request(...))without binding a port (this is how Hono'sapp.fetchand Elysia'sapp.handlework) - Make
serve()usehandle()internally and return theBun.serveserver instance instead ofvoid(needed to callserver.stop()in e2e tests) - Set up the first
*.test.tsfile and abun testscript inpackage.json
- Characterization tests for what already works: static routes,
:params, query parsing, 404, 405 +Allowheader, automatic HEAD, middleware order (global → route → handler) - Design fix: allow middleware to short-circuit with a response (
EvaMiddlewarereturningResponse | void, or a response builder on the context). Today a middleware that doesn't callnext()leavesresponseasundefinedandserve()doesreturn response!— the CORS preflight path hits exactly this - Bug: invalid JSON body →
JSON.parseinsrc/context.tsthrows an unhandledSyntaxErrorand the client gets a 500. Expected: 400 Bad Request - Bug: CORS origin check in
src/cors.tsuses.includes(), which is substring matching whenoriginis a string —"https://example.com".includes("https://example.co")istrue, so a malicious origin passes. Handle the string case and the array case explicitly - Bug: CORS preflight (OPTIONS) is only short-circuited in the
"*"branch; specific-origin preflights fall through. Decide the preflight behavior once and test it for both branches - Bug:
toParent()copies handlers but drops route-level middlewares (addRouteis called withoutnode.middlewares) - Bug: registering
/users/:idand then/users/:userId/postssilently reuses the first param name (router.tskeeps the existingnode.param). Throw on conflicting param names at registration time - Guard the
return response!path: if a handler/middleware chain produces no response, return a deliberate 500 (or throw a descriptive error), neverundefined
- Trailing slash policy: STRICT (Fastify/Hono-style) —
/users/and/usersare different routes; params never capture empty segments. Documented in Router's JSDoc and pinned by tests - Empty segments (
//users), URL-encoded params (/users/a%20bshould decode toa b) - Static-over-param priority:
/users/memust win over/users/:id, including backtracking when a static prefix dead-ends - Wildcard routes (
/static/*) - Route-level middleware scope: today middleware registered on
/usersalso runs for/users/:idbecausematch()collects middlewares from intermediate nodes — decide if that's a feature (Express-style mounting) or a bug, then test the decision - Include OPTIONS/HEAD in the 405
Allowcalculation
- Content-type-aware body parsing:
application/x-www-form-urlencodedandmultipart/form-data, plus a body size limit - Cookies: parse on request, set on response (needed for any real auth)
- Full CORS options:
allowedHeaders,credentials,maxAge,exposeHeaders, per-config methods - Consolidate the route API:
get/post/...overloads vsroute().getWith()builder — one obvious way to do it
- Infer param types from the path string with template literal types:
eva.get("/users/:id/posts/:postId", ...)should typectx.paramsas{ id: string; postId: string }without manual generics (this is the Hono/Elysia trick) - Schema validation hook for body/query/params (bring-your-own validator, e.g. Standard Schema)
- Resolve the
toParent()API doubt: switch to Express-style compositionparent.mount(prefix, child)(see the comment insrc/eva.ts)
- GitHub Actions:
bun test+ typecheck on every push - Honest benchmarks vs Hono, Elysia and Express (and explain the results, especially the losses)
- Dogfood: build and deploy one small real API with Eva
- Publish to npm
MIT