ORVOX
1.0.0 Bun 1.4+
Handbook

Compile-first HTTP framework · Bun

Write a framework.
Ship one file.

ORVOX does not run at request time. It reads your app.ts at build time and writes out a plain Bun.serve file you can open and read — routes become a literal object, schemas become if statements, middleware becomes straight-line code. Nothing is imported into what you deploy.

What the build actually does
src/app.ts — what you write

      
.orvox/server.generated.ts — what comes out

      

Abridged from real output — the full validator is longer, and looks exactly like this.

0
dependencies in what you deploy
1
file to put in production
4
artifacts written to .orvox/
3.1
OpenAPI, from the same IR as the validators

01 — Getting started

Four commands to a running server

@orvox/core is a set of typed compile markers, not a runtime. The CLI is a dev dependency.

terminalbash
bun add @orvox/core
bun add -d @orvox/cli

Write src/app.ts:

src/app.tssource
import { orvox, t } from "@orvox/core";

const app = orvox();

app.get("/", () => "hello");
app.get("/users/:id", ({ params }) => ({ id: params.id }));

app.post("/users", {
  body: t.object({ name: t.string({ min: 1 }), age: t.int({ min: 0 }) }),
  handler: ({ body }) => Response.json(body, { status: 201 }),
});

export default app;

Compile it, then run what came out:

terminalbash
bunx orvox build src/app.ts
bun .orvox/server.generated.ts
Read this once

bun src/app.ts starts nothing. app.ts is a declaration the compiler reads — the server is .orvox/server.generated.ts.

02 — CLI commands

build / inspect / dev

Every command takes the entry as its second argument. Omit it and you get src/app.ts, written to .orvox.

CommandWhat it does
orvox build [entry]Compiles once, writes all four artifacts, and prints how many routes it built
orvox inspect [entry]Same build, but prints manifest, analysis, and openapi as JSON on stdout — pipe it somewhere
orvox dev [entry]Watches every .ts under the entry's directory, rebuilds and restarts the process on save
--outdir <path>Writes artifacts somewhere other than .orvox

A failed build throws a CompileError carrying its ORVOX_* code. In dev, the error prints and the watcher keeps going — the last good server stays up until the next save.

03 — Routing

Every route has to be readable at compile time

get, post, put, patch, delete, and raw. Path params are written :id, and the type of params follows from the path.

the shapes that worksource
// bare handler — return a string, an object, or a Response
app.get("/health", () => ({ ok: true }));

// params are typed from the path
app.get("/users/:id", ({ params }) => params.id);

// anything with a body schema uses the options form
app.put("/users/:id", {
  body: UpdateUser,
  handler: ({ params, body }) => Response.json({ ...body, id: params.id }),
});

// raw = you get the Request. No middleware, no validation, no help.
app.raw("GET", "/metrics", (req, server) => new Response(server.port + ""));

What a handler can pull off the context

  • params — only the path params the route actually declares
  • query — the compiler emits new URL() only if you touch it
  • headers — a plain object keyed by header name (headers.authorization), not a Headers instance; only the keys you name are read off the request
  • body, cookies, request, server — plus any key a derive() added

Reading a key the route never declared fails the build with ORVOX_UNSUPPORTED_CONTEXT. Accessing the context dynamically does not fail, but it forces the compiler to materialize the whole request, and it records ORVOX_CONSERVATIVE_CONTEXT in analysis.json so you can see what it cost.

Colliding routes

/users/:id and /users/:userId are the same pattern to Bun. The compiler stops with ORVOX_AMBIGUOUS_ROUTE rather than letting one of them quietly disappear.

04 — Schemas & validation

t is not a validator library

It is a description of a shape. The compiler reads it and writes if statements into the output — no validator code is imported or shipped.

BuilderNotes
t.string({ min, max })Compiles to a typeof check and a .length check
t.int({ min, max })Integers only, and the bounds must be integer literals too
t.number({ min, max })Finite numbers, fractional bounds allowed
t.boolean()
t.literal(value)Exactly that string, number, or boolean
t.enum([...])One of the listed values; the error names them all
t.union(tag, [...])Branches told apart by a literal tag — compiles to a switch
t.optional(inner)The property may be absent — not present-and-undefined
t.array(item, { min, max })Bounds count elements
t.object({ ... })Closed. An undeclared key is a 400
a schema and the type it gives yousource
import { orvox, t, type Infer } from "@orvox/core";

const CreateUser = t.object({
  name: t.string({ min: 1, max: 100 }),
  age: t.int({ min: 0, max: 150 }),
  tags: t.optional(t.array(t.string({ min: 1 }), { max: 8 })),
});

type CreateUser = Infer<typeof CreateUser>;

Unions name their tag

A union declares the property that tells its branches apart, rather than leaving the compiler to guess. That is what lets it compile to a switch — and what makes a failure point at the branch the tag selected instead of reporting that nothing matched.

a tagged unionsource
const Event = t.union("type", [
  t.object({ type: t.literal("click"), x: t.int(), y: t.int() }),
  t.object({ type: t.literal("key"), code: t.string({ min: 1 }) }),
]);

// { "type": "key", "code": "" } fails at $.code, not "no branch matched"
// { "type": "scroll" }          fails at $.type: Expected one of "click", "key"

Every branch must be a t.object() that sets the tag to a distinct literal. Anything else fails the build naming the branch, because a tag that cannot select one branch is a union the compiler cannot write a switch for.

query and params convert, not just validate

Those values arrive as strings, so a t.int() declared there parses rather than rejects — the handler receives a number. The same builder in a body still refuses a string, because JSON carries its own types. Position decides the meaning; there is no second set of builders to learn.

declaring inputsource
const Search = t.object({
  q: t.string({ min: 1 }),
  page: t.optional(t.int({ min: 1, max: 100 })),
});

app.get("/users/:id/posts", {
  params: t.object({ id: t.int({ min: 1 }) }),
  query: Search,
  handler: ({ params, query }) => ({
    id: params.id,        // number
    page: query.page,     // number | undefined
  }),
});
  • Query maps stay open. Undeclared parameters are ignored, not rejected — nothing undeclared reaches the handler either way, and closing it would break on every utm_source in the wild. Bodies stay closed
  • Booleans accept only true and false. Guessing at yes or 1 is how a query string quietly means something else
  • Path params cannot be optional, and naming one the path never declares fails the build — a matched route always supplied every segment
  • Only strings, integers, and booleans — a flat string map has nowhere to put an object

Declaring what comes back

A response: schema constrains what the handler may return, so the document and the code cannot drift apart without the build saying so. Nothing is re-checked per request — validating your own output on every response is exactly the runtime cost this compiler exists to remove, so the check happens once, at build time, in the type system.

a checked responsesource
const User = t.object({ id: t.string(), age: t.int() });

app.get("/me", {
  response: User,
  handler: () => ({ id: "a", age: 1 }),     // ok
});

app.get("/broken", {
  response: User,
  handler: () => ({ id: "a" }),             // build fails: age is required
});

Returning a Response directly stays legal — statuses and headers are not something a body schema can describe. The schema also becomes the 200 body in openapi.json, which is what makes that document good enough to generate a client from.

Where the check stops

A missing or mistyped field fails the build. An extra one does not: excess-property checking needs a fresh object literal assigned to a single type, and the return position here is a union with Response, which drops it. Closing that gap would mean checking responses at runtime, which is a throughput decision rather than a typing one.

What the client gets back

Every rejection is a 400 with the same shape:

HTTP 400response
{ "error": "VALIDATION_FAILED",
  "issues": [{ "path": "$.name", "code": "invalid_type", "message": "Expected a string." }] }

Issue codes you will see: invalid_content_type, invalid_json, invalid_type, required, unknown_property, plus the min/max cases per kind. Because objects are closed, Infer<> is exactly what arrives in the handler — nothing can smuggle extra fields in — and the generated OpenAPI mirrors it with additionalProperties: false.

05 — Middleware & groups

Three primitives that disappear into the handler

There is no array to iterate at request time. All three are flattened into the handler at compile time, global → group → route.

  • header(name, value) — baked into the response, and it covers the whole route: handler results, guard short-circuits, and validation 400s alike
  • guard(fn) — returning a Response becomes an early return; returning void falls through
  • derive(fn) — the returned object becomes a named local, and its type flows into the context of the routes it covers
global · per-route · groupsource
import { derive, guard, header, orvox } from "@orvox/core";

const app = orvox({ maxRequestBodySize: 16384 });

// global — applies to the routes declared AFTER this line
app.use(header("x-powered-by", "orvox"));

// headers is a plain object keyed by header name, not a Headers instance
const auth = guard(({ headers }) =>
  headers.authorization ? undefined : new Response("Unauthorized", { status: 401 }));

const user = derive(({ headers }) => ({ userId: headers["x-user"] ?? "anon" }));

// one route
app.get("/me", { use: [auth, user], handler: ({ userId }) => ({ userId }) });

// a whole group — prefix and middleware are declared together
app.group("/admin", { use: [auth] }, admin => {
  admin.get("/stats", () => ({ ok: true }));   // → GET /admin/stats
});
Order is the API

app.use() is positional. Declared at the bottom of the file, it covers nothing above it — and the compiler raises ORVOX_LATE_GLOBAL_MIDDLEWARE in analysis.json rather than reaching backwards. Groups nest, and middleware accumulates outward-in — a nested group runs everything its ancestors declared, then its own. group.use() still fails the build; put the middleware in the group's options where it is visible.

06 — Hooks, WebSockets, runtime

The parts you need in production

hooks and socketssource
app.onRequest(req => { console.log(req.method, req.url); });
app.onError(error => Response.json({ error: error.message }, { status: 500 }));
app.onStop(server => { console.log("bye", server.port); });

app.ws("/echo", {
  open: socket => socket.send("ready"),
  message: (socket, message) => socket.send(message),
  close: (socket, code, reason) => console.log(code, reason),
});

Each hook is registered once — a second one is ORVOX_DUPLICATE_HOOK — and all of them must be top-level statements. A ws() path must be a string literal, because the compiler turns it into route-id dispatch onto Bun's native socket handler.

Runtime configuration

SettingDefaultNotes
PORT3000Read from the environment when the generated file starts
HOST127.0.0.1Most containers need 0.0.0.0
maxRequestBodySize1 MiBSet at compile time via orvox({ ... }), not by env var
openapi.titleORVOX APITitle in the emitted openapi.json
openapi.version0.0.0Your API's version — the placeholder means you have not set one yet

The generated server already handles graceful shutdown and answers with a bounded 500 that leaks no stack.

07 — Build output

Four files in .orvox/

FileWhat it is
server.generated.tsThe server. The only file you deploy and run
openapi.jsonDeterministic OpenAPI 3.1, generated from the same IR as the validators. Its info describes your API — set it with orvox({ openapi: { title, version } })
routes.manifest.jsonEvery route with its params, flattened middleware, response mode, and a needs block
analysis.jsonCompiler warnings — dynamic context access, block-handler fallbacks, late global middleware

Read needs often. It tells you precisely what each route pulls off the request, which is usually where a surprise is hiding — like discovering you materialize the whole query object to read one key.

Want it smaller? Run the output through bun build --minify. The compiler only erases helpers and declarations nothing references any more; dead code you wrote is copied through verbatim rather than guessed at.

08 — Rules that bite

Constraints, on purpose

  • Everything must be statically readable — which is not the same as everything in one file. Schemas, middleware, and handlers can be top-level consts, imported from other modules, and reached through a chain of them; paths and group prefixes can come from a const too. What is refused is what the compiler would have to run to know: a route in a loop, behind an if, or inside a function
  • A top-level const app = orvox() is required, along with export default app
  • Infer<typeof X> costs nothing. It is written out as the shape it means, so the schema behind it is compiled away like any other and the output never imports @orvox/core for a type that is erased before the file runs
  • Bodies are closed — an extra field is always a 400
  • Groups nest, but there is still no group.use() — declare it in the group options
  • raw routes skip middleware and validation entirely — that request is yours
  • Schema bounds are integers, in the compiler and in the runtime descriptors
  • Block-bodied handlers fall back to the conservative response adapter and get logged in analysis.json; an arrow that returns directly compiles tighter

09 — Build errors

The ORVOX_* codes you will actually hit

Red fails the build. Amber is a warning that lands in analysis.json.

CodeMeaningFix
ORVOX_APP_REQUIREDNo top-level const app = orvox() foundMove it out of the function or branch it is hiding in
ORVOX_INLINE_HANDLER_REQUIREDThe handler is a variable or a function declared elsewhereInline it at the route registration
ORVOX_STATIC_DSL_REQUIREDA DSL call sits somewhere other than a top-level statementLift app.use(), group(), and hooks to the outermost scope
ORVOX_STATIC_SCHEMA_REQUIREDA schema is built from variables, a spread, or non-literal optionsDeclare it as a top-level const using t.* and literal numbers
ORVOX_UNSUPPORTED_CONTEXTA handler reads a context key the route never declaredAdd the body schema, or a derive() that supplies the key
ORVOX_AMBIGUOUS_ROUTETwo paths reduce to the same patternMerge them, or change one of the shapes
ORVOX_DUPLICATE_ROUTEThe same method and path twiceDelete the extra registration
ORVOX_SCHEMA_BOUNDSA bound is not an integer, or min > maxUse integer literals, in the right order
ORVOX_LATE_GLOBAL_MIDDLEWAREapp.use() was called after routes were declaredMove it above the routes it should cover
ORVOX_CONSERVATIVE_CONTEXTDynamic context access forces the whole request to be materializedDestructure the keys you actually read
ORVOX_BLOCK_HANDLER_FALLBACKA block-bodied handler uses the conservative response adapterLeave it, or return directly from an arrow

10 — How it compares

An architectural comparison, not a benchmark result

ORVOXElysiaHonoRaw Bun.serve
Route matchingNative Bun route table, built at compile timeRuntime dynamic treeRuntime trieHand-written
ValidationInlined if statementsRuntime JIT (TypeBox)Runtime parsing (Zod)Hand-written
MiddlewareFlattened into the handlerRuntime chainRuntime chainNone
Deployed dependenciesNone — the output imports nothingFramework + validatorFramework + validatorNone
InspectabilityThe whole server is one readable fileFramework internalsFramework internalsFull
OpenAPIBuild artifactRuntime schema walkManualManual

For numbers, run the lab yourself in benchmarks/. pnpm bench:smoke verifies all four frameworks serve identical responses before any timing is worth trusting.