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.
bun add @orvox/core bun add -d @orvox/cli
Write src/app.ts:
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:
bunx orvox build src/app.ts bun .orvox/server.generated.ts
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.
| Command | What 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.
// 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 aHeadersinstance; 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.
/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.
| Builder | Notes |
|---|---|
| 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 |
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.
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.
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_sourcein the wild. Bodies stay closed - Booleans accept only
trueandfalse. Guessing atyesor1is 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.
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.
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:
{ "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
Responsebecomes an earlyreturn; returningvoidfalls through - derive(fn) — the returned object becomes a named local, and its type flows into the context of the routes it covers
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
});
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
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
| Setting | Default | Notes |
|---|---|---|
| PORT | 3000 | Read from the environment when the generated file starts |
| HOST | 127.0.0.1 | Most containers need 0.0.0.0 |
| maxRequestBodySize | 1 MiB | Set at compile time via orvox({ ... }), not by env var |
| openapi.title | ORVOX API | Title in the emitted openapi.json |
| openapi.version | 0.0.0 | Your 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/
| File | What it is |
|---|---|
| server.generated.ts | The server. The only file you deploy and run |
| openapi.json | Deterministic 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.json | Every route with its params, flattened middleware, response mode, and a needs block |
| analysis.json | Compiler 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 aconsttoo. What is refused is what the compiler would have to run to know: a route in a loop, behind anif, or inside a function - A top-level
const app = orvox()is required, along withexport 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/corefor 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.
| Code | Meaning | Fix |
|---|---|---|
| ORVOX_APP_REQUIRED | No top-level const app = orvox() found | Move it out of the function or branch it is hiding in |
| ORVOX_INLINE_HANDLER_REQUIRED | The handler is a variable or a function declared elsewhere | Inline it at the route registration |
| ORVOX_STATIC_DSL_REQUIRED | A DSL call sits somewhere other than a top-level statement | Lift app.use(), group(), and hooks to the outermost scope |
| ORVOX_STATIC_SCHEMA_REQUIRED | A schema is built from variables, a spread, or non-literal options | Declare it as a top-level const using t.* and literal numbers |
| ORVOX_UNSUPPORTED_CONTEXT | A handler reads a context key the route never declared | Add the body schema, or a derive() that supplies the key |
| ORVOX_AMBIGUOUS_ROUTE | Two paths reduce to the same pattern | Merge them, or change one of the shapes |
| ORVOX_DUPLICATE_ROUTE | The same method and path twice | Delete the extra registration |
| ORVOX_SCHEMA_BOUNDS | A bound is not an integer, or min > max | Use integer literals, in the right order |
| ORVOX_LATE_GLOBAL_MIDDLEWARE | app.use() was called after routes were declared | Move it above the routes it should cover |
| ORVOX_CONSERVATIVE_CONTEXT | Dynamic context access forces the whole request to be materialized | Destructure the keys you actually read |
| ORVOX_BLOCK_HANDLER_FALLBACK | A block-bodied handler uses the conservative response adapter | Leave it, or return directly from an arrow |
10 — How it compares
An architectural comparison, not a benchmark result
| ORVOX | Elysia | Hono | Raw Bun.serve | |
|---|---|---|---|---|
| Route matching | Native Bun route table, built at compile time | Runtime dynamic tree | Runtime trie | Hand-written |
| Validation | Inlined if statements | Runtime JIT (TypeBox) | Runtime parsing (Zod) | Hand-written |
| Middleware | Flattened into the handler | Runtime chain | Runtime chain | None |
| Deployed dependencies | None — the output imports nothing | Framework + validator | Framework + validator | None |
| Inspectability | The whole server is one readable file | Framework internals | Framework internals | Full |
| OpenAPI | Build artifact | Runtime schema walk | Manual | Manual |
For numbers, run the lab yourself in benchmarks/. pnpm bench:smoke verifies all four frameworks serve identical responses before any timing is worth trusting.