TypeScript HTTP framework that ties Elysia on simple GETs on Bun and is ahead of Hono and Express (benchmarks below). Runtime-agnostic with full type safety. AI agents: see AI_KNOWLEDGE.md for expert context.
On Bun, Velox serves ~152K req/s for a simple GET with a static value, level with Elysia and about 2× Hono, and ~34K req/s for a validated POST, where Elysia currently leads by 1.21× (see below). Runtime-agnostic (Node.js, Deno, Bun, Cloudflare Workers) with full type inference, schema validation with any validator function (examples use @coderbuzz/veta), built-in WebSocket with pub/sub, and 16+ production middleware, all in one framework.
| Pain Point | Elysia | Hono | Express | Velox |
|---|---|---|---|---|
| Performance (simple GET, Bun) | ~146K req/s | ~75K req/s | ~37K req/s | ~152K req/s (tie with Elysia) |
| Schema validation | TypeBox (heavy, complex) | Zod (no coercion) | Manual | Any validator function; examples use Veta (<5 KB gzip, coercion built-in) |
| Type inference through middleware | Good | Partial | None | Full: define() scopes typed state |
| WebSocket | Bun-only | Partial | Via socket.io | Built-in with pub/sub, binary protocol, client SDK |
| Runtime support | Bun, Node, Deno | Bun, Node, Deno, Workers | Node only | Bun, Node (+uWebSockets.js), Deno, Cloudflare Workers |
| Built-in middleware | Limited | Via third-party | Via third-party | 16+: JWT, CORS, sessions, CSRF, secure headers, etc. |
| File utilities | Limited | None | Via middleware | Built-in: sendFile, receiveFiles, listDirectory, MIME detection |
| Encrypted cookies | Not built-in | Not built-in | Not built-in | Built-in AES-GCM encryption utilities |
| Binary WebSocket protocol | No | No | No | Wire Protocol (@coderbuzz/velox-ws-wire): 80-93% bandwidth reduction over JSON |
Full benchmark results at github.com/coderbuzz/benchmarks.
Measured on the benchmarks reference machine (Linux x64, Intel Xeon @ 2.10GHz, 4 cores; Bun 1.4.2; Velox 0.7.1) on
2026-10-02. oha -c 100, 3 s warmup, best of 3 × 10 s runs, req/s:
| Scenario | Velox | Elysia | Hono | Express | Result |
|---|---|---|---|---|---|
| Simple GET, static value | 152,152 | 145,875 | 75,379 | 37,005 | tie with Elysia; 2.0× Hono |
| Simple GET, handler | 90,936 | 97,632 | 76,805 | 35,313 | tie with Elysia; 1.18× Hono |
| Validation POST (body + query + params + headers) | 34,429 | 41,780 | 33,003 | 17,057 | Elysia 1.21× |
Gaps under 10% count as a tie: repeat runs on that machine move a result by up to ~8%. Current numbers, for
agents and scripts: results/latest.json.
Run them yourself (needs
oha):git clone https://github.com/coderbuzz/benchmarks && cd benchmarks && bun install && bun run velox:static
- Runtime Agnostic: Bun, Deno, Node.js (with optional uWebSockets.js for max perf), Cloudflare Workers
- TypeScript Native: Full type inference through routes, middleware, and schemas
- Schema Validation: Validate params, query, headers, cookies, body with inline validator functions (e.g. from
@coderbuzz/veta) - Built-in Middleware: JWT, JWK/JWKS, CORS, sessions, compression, secure headers, CSRF, ETag, IP restriction, and more
- WebSocket: Real-time connections with pub/sub, ping/pong, binary protocol, typed upgrade data
- Performance-Driven: Minimal overhead, engineered for high throughput
- Modular & Extensible: Sub-apps, scoped middleware via
define(), global middleware viaapply() - Ecosystem:
@coderbuzz/velox-ws-wire*for binary WebSocket protocol with 80-93% bandwidth reduction, fault-tolerant client, and server-side handler
Velox is the HTTP framework core. Binary WebSocket protocol utilities live in separate packages to keep velox lean:
| Package | Description | Requires Velox? |
|---|---|---|
@coderbuzz/velox-ws-wire |
Binary Wire Protocol codec, 80-93% bandwidth reduction over JSON | No |
@coderbuzz/velox-ws-wire-client |
Fault-tolerant WebSocket client with auto-reconnect, heartbeat, pub/sub, request-response | No |
@coderbuzz/velox-ws-wire-server |
Server-side Wire Protocol handler: mount via app.use("/ws", wireProtocol({...})) |
Yes |
// Server: mount binary protocol handler
import { wireProtocol } from "@coderbuzz/velox-ws-wire-server";
app.use("/ws", wireProtocol({
message(peer, msg) { peer.send(`echo: ${msg}`); },
}));
// Client: standalone, not from velox
import { WireClient } from "@coderbuzz/velox-ws-wire-client";
const client = new WireClient("wss://api.example.com/ws", {
heartbeatInterval: 30_000,
});
await client.connect();
client.send("hello");
await client.close();# Bun
bun add @coderbuzz/velox
# npm
npm install @coderbuzz/velox
# Deno
import { AppServer } from "npm:@coderbuzz/velox";Velox has no runtime dependencies. The schema examples below use @coderbuzz/veta,
which is a separate install (bun add @coderbuzz/veta); any validator function works.
Node.js: The package ships as ESM. Your project must have
"type": "module"inpackage.json, or use the.mjsextension. Node.js 18+ required. For TypeScript, use tsx ortsc.
import { AppServer } from "@coderbuzz/velox";
const app = new AppServer({ port: 3000 });
app.get("/", "Hello, Velox!");
const { hostname, port } = await app.run();
console.log(`Listening on ${hostname}:${port}`);That's it: a full HTTP server in 6 lines.
| Class | Purpose |
|---|---|
App |
Pure router, no server lifecycle. Used for sub-apps and modular composition. |
AppServer |
App + run() / stop(). The entry point for a server process. |
import { App, AppServer } from "@coderbuzz/velox";
const app = new AppServer({ port: 3000, hostname: "0.0.0.0" });
const api = new App();
api.get("/users", handler);
app.use("/api/v1", api);
await app.run();A Worker does not listen on a port, so instead of AppServer.run() you export
the app. The same routes, schemas and middleware run unchanged.
// src/index.ts
import { App, cloudflare, getEnv, getExecutionContext } from "@coderbuzz/velox";
interface Env {
KV: KVNamespace;
API_KEY: string;
}
const app = new App();
app.get("/", "Hello from the edge");
app.get("/kv/:key", (ctx) => getEnv<Env>(ctx).KV.get(ctx.params.key));
app.post("/events", async (ctx) => {
const event = await ctx.json;
getExecutionContext(ctx).waitUntil(fetch("https://collector.example", {
method: "POST",
body: JSON.stringify(event),
}));
return { queued: true };
});
export default cloudflare(app);nodejs_compat is required. Velox imports node:async_hooks, node:fs
and node:path. With wrangler, any compatibility_date from 2024-09-23 works.
If you bundle without wrangler, use 2025-09-15 or later, the first date on
which workerd itself provides node:fs.
getEnv<Env>(ctx) returns the Worker's bindings and getExecutionContext(ctx)
returns its ExecutionContext. Context has the same type on every runtime,
so a handler compiles the same for Bun and for Workers. On any other runtime
both helpers throw.
To add scheduled, queue or other handlers, spread the result:
export default { ...cloudflare(app), scheduled(...) { ... } }. It satisfies
ExportedHandler<Env> from @cloudflare/workers-types.
What differs on Workers:
| Feature | On Workers |
|---|---|
app.ws() routes |
Not served: an upgrade on that path answers 501. Use a Durable Object. |
sendFile, listDirectory, saveFile |
Import fine, but a Worker has no project filesystem to serve from. |
AppServer.run() |
Throws, naming export default cloudflare(app). |
ctx.remoteInfo |
Address from cf-connecting-ip; port is always 0. |
| Ambient request context | Works (nodejs_compat provides AsyncLocalStorage). |
app.get("/", "Hello Velox!"); // string → text/plain
app.get("/health", "OK");
app.get("/version", { version: "1.0.0" }); // object → JSON-serializedapp.get("/users/:id", (ctx) => new Response(`User ${ctx.params.id}`));
app.get(
"/posts/:postId/comments/:commentId",
(ctx) =>
new Response(`Post ${ctx.params.postId}, Comment ${ctx.params.commentId}`),
);app.get(
"/optional/:id?",
(ctx) => new Response(`ID: ${ctx.params.id ?? "none"}`),
);
app.get("/files/*", (ctx) => new Response(`File: ${ctx.params["*"]}`));app.get("/items", handler);
app.post("/items", handler);
app.put("/items/:id", handler);
app.patch("/items/:id", handler);
app.delete("/items/:id", handler);
app.head("/items", handler);
app.options("/items", handler);HEADis automatic for everyGETroute (same status and headers, no body); an explicitapp.head()wins.- A wrong method is
405 Method Not Allowedwith anAllowheader, not a 404. The route's middleware (CORS, logging, guards) still runs for it. - Trailing slashes are strict:
/items/does not match/items(as in Hono and Fastify by default).
const routes = app.getRoutes(); // RouteInfo[]: { method, path }[]
app.printRoutes();
// ┌──────────┬────────────────────┐
// │ Method │ Path │
// ├──────────┼────────────────────┤
// │ GET │ / │
// │ POST │ /users │
// │ WS │ /chat │
// └──────────┴────────────────────┘Validate request data inline via the schema object. A validator is any function
(value, ctx?) => T that returns the parsed value or throws. The examples use
@coderbuzz/veta (installed
separately; velox does not depend on it), which has built-in coercion.
import {
boolean,
coerce,
date,
number,
object,
optional,
string,
} from "@coderbuzz/veta";app.get("/products/:id", {
params: { id: coerce(number()) },
}, (ctx) => Response.json({ productId: ctx.params.id }));
// ctx.params.id is typed as numberapp.get("/search", {
query: {
q: string({ min: 1 }),
page: coerce(number({ min: 1, max: 100 })),
limit: optional(coerce(number({ min: 10, max: 100 }))),
},
}, (ctx) => Response.json({ search: ctx.query.q, page: ctx.query.page }));app.get("/api/resource", {
headers: { "x-api-key": string({ min: 10 }) },
}, (ctx) => Response.json({ key: ctx.headers["x-api-key"] }));app.get(
"/api/profile",
{
cookies: {
sessionId: string({ min: 5 }),
premium: optional(coerce(boolean())),
},
},
(ctx) =>
Response.json({ session: ctx.cookies.sessionId, isPremium: ctx.cookies.premium }),
);app.post("/api/users", {
json: object({
name: string({ min: 2 }),
age: number({ min: 18 }),
active: boolean(),
email: optional(string()),
}),
}, async (ctx) => {
const body = await ctx.json;
return Response.json({ name: body.name, age: body.age });
});app.post("/api/echo", {
text: string({ min: 5 }),
}, async (ctx) => new Response(await ctx.text));app.post("/api/submit", {
form: { field: string({ min: 3 }) },
}, async (ctx) => {
const data = await ctx.form;
return new Response(data.field);
});app.post("/api/register", {
json: object({
born: coerce(
date({ min: new Date("1900-01-01"), max: new Date("2025-12-31") }),
),
}),
}, async (ctx) => {
const { born } = await ctx.json;
return Response.json({ born: born.toISOString() });
});Validate response body, status code, and headers against a schema:
app.post("/api/users", {
params: { id: coerce(number()) },
response: {
status: 200,
headers: {
"x-request-id": string({ min: 1 }),
},
body: object({
id: number(),
name: string(),
email: string(),
}),
},
}, (ctx) => {
return { id: ctx.params.id, name: "John", email: "john@example.com" };
});| Return Type | Body Validation | Status Validation | Headers Validation |
|---|---|---|---|
| Object/String/null | Validated ✅ | Schema's status used as default for toResponse() ✅ |
Skipped |
instanceof Response |
Skipped | Validated against response.status ✅ |
Validated against response.headers ✅ |
Body validation runs on the raw handler return value before toResponse(). Status and header validation run after onFinish callbacks (cookies already applied), on the final Response object, ensuring everything is validated before it reaches the client.
Throw a Response to bypass validation entirely (e.g., early returns from middleware).
When response.body is specified, the handler's return type is automatically inferred from the validator: no need to annotate.
app.post("/api/users", {
response: {
body: object({
id: number(),
name: string(),
}),
},
}, (ctx) => {
// return type is inferred as { id: number; name: string }
return { id: 1, name: "John" }; // ✅
return { id: "1", name: "John" }; // ❌ string !== number
return { name: "John" }; // ❌ missing 'id'
});Routes without response.body keep the return type as any, fully backward compatible.
Note: TypeScript structural typing allows extra properties in return positions.
return { id: 1, name: "John", extra: true }will not error even thoughextraisn't in the schema. Useas constorsatisfiesfor stricter checking.
Middleware runs before the handler and returns typed state accessible via ctx.state.
app.get("/protected", {
state: {
auth: (ctx) => {
const token = ctx.headers["authorization"];
if (token !== "Bearer valid-token") {
throw new Response("Unauthorized", { status: 401 });
}
return { userId: "user123", role: "admin" };
},
},
}, (ctx) => Response.json({ user: ctx.state.auth.userId }));Apply middleware to a group of routes. Routes inside the callback automatically inherit the state type:
app.define(
{
userId: (ctx) => ctx.headers["x-user-id"] || "guest",
isAdmin: (ctx) => ctx.headers["x-role"] === "admin",
},
(app) => {
app.get("/me", (ctx) =>
Response.json({ userId: ctx.state.userId, isAdmin: ctx.state.isAdmin })
);
app.get("/dashboard", (ctx) => {
if (!ctx.state.isAdmin) throw new Response("Forbidden", { status: 403 });
return Response.json({ admin: true });
});
},
);define() can be nested for layered composition:
app.define({ requestId: () => crypto.randomUUID() }, (app) => {
app.define({ timestamp: () => Date.now() }, (app) => {
app.get("/meta", (ctx) =>
Response.json({ id: ctx.state.requestId, ts: ctx.state.timestamp }));
});
});// Side-effect middleware (logging, metrics), no state produced
app.apply("/*", (ctx) => { console.log(ctx.method, ctx.url); });
// State-producing middleware
app.apply("/*", { auth: (ctx) => verifyAuth(ctx) });
// Scoped to a prefix
app.apply("/api/*", { apiVersion: () => "v1" });const api = new App();
api.get("/users", handler);
api.get("/posts", handler);
app.use("/api/v1", api);
app.use(api); // without prefix, routes merged at root| Middleware | Description | Example usage |
|---|---|---|
jwt() |
JWT verification with HS256/HS384/HS512, claims validation | state: { auth: jwt({ secret, issuer, audience }) } |
jwk() |
JWK/JWKS (RSA, ECDSA): Auth0, Cognito, custom | state: { auth: jwk({ jwksUrl, issuer }) } |
basicAuth() |
HTTP Basic auth with static or custom verification | state: { auth: basicAuth({ username, password }) } |
bearerAuth() |
Bearer token auth with single/multiple/verified tokens | state: { auth: bearerAuth({ token: [...] }) } |
session() |
Cookie-based session with custom validation | state: { session: session({ cookieName, validate }) } |
| Middleware | Description |
|---|---|
cors() |
CORS with dynamic origin resolver, custom headers, credentials |
csrf() |
CSRF protection: checks Origin on every unsafe request |
secureHeaders() |
Helmet-inspired security headers (13 headers) |
ipRestriction() |
Allow/deny list by IP address or CIDR range (socket peer unless trustProxy()) |
| Middleware | Description |
|---|---|
compress() |
Content-encoding negotiation (gzip, deflate, br; honours q). Negotiates only: it does not compress the body |
cache() |
Cache-Control headers (CDN-friendly) |
etag() |
Puts the If-None-Match request header in state (you set ETag and answer 304) |
timing() |
Server-Timing header |
timeout() |
Deadline: after duration ms answers onTimeout(ctx) (default 504) and aborts the signal in state |
| Middleware | Description |
|---|---|
bodyLimit() |
Limit request body size |
requestId() |
X-Request-Id header generation |
logger() |
Request logging with customizable format |
import { bodyLimit } from "@coderbuzz/velox";
app.post("/upload", {
state: { limit: bodyLimit({ maxSize: 1024 * 1024 }) },
}, handler);
// Custom error response
app.post("/upload", {
state: { limit: bodyLimit({ maxSize: 100, onError: (ctx) => new Response("Too big!", { status: 413 }) }) },
}, handler);Body limit only applies to POST, PUT, PATCH, and DELETE methods. A declared
Content-Length over the limit is refused up front; a chunked body (no
Content-Length) is counted while it is read and refused with 413 once it passes
the limit.
Every route has a limit, even without this middleware: 10 MiB by default.
import { setDefaultBodyLimit } from "@coderbuzz/velox";
setDefaultBodyLimit(2 * 1024 * 1024); // process-wide default
app.post("/import", { bodyLimit: 50 * 1024 * 1024 }, handler); // one routeA body over the limit is 413 Payload Too Large, sent once the upload has ended
(so the client reliably receives it) unless it is more than 1 MiB over, in which
case it is refused at once and the connection closed. There used to be no limit:
on Node a 200 MB POST took the process to ~660 MB of memory.
import { cors } from "@coderbuzz/velox";
// Mount at root (simplest): handles preflight + headers for all routes
const corsApp = cors({ origin: "https://example.com", credentials: true });
corsApp.get("/data", () => Response.json({ data: 1 }));
app.use(corsApp); // no prefix needed
// Mount at prefix: scoped to sub-path
const apiCors = cors({ origin: "https://example.com", credentials: true });
apiCors.get("/data", () => Response.json({ data: 1 }));
app.use("/api", apiCors);
// Array origin: allow specific origins
const arrayCors = cors({ origin: ["https://a.com", "https://b.com"] });
// Function origin: dynamic resolution
const dynamicCors = cors({
origin: (requestOrigin, ctx) => {
const allowed = ["https://app.example.com", "https://admin.example.com"];
return allowed.includes(requestOrigin) ? requestOrigin : "";
},
});CORS automatically adds Vary: Origin to responses.
credentials: true cannot be combined with the wildcard origin. cors()
throws where it is written. Browsers reject Access-Control-Allow-Origin: * on
credentialed requests, and reflecting the request origin to satisfy them means
any site your logged-in users visit can read authenticated responses. Since
origin defaults to '*', cors({ credentials: true }) alone is refused too:
list the origins.
cors({ credentials: true }); // throws
cors({ origin: "*", credentials: true }); // throws
cors({ origin: ["https://app.example.com"], credentials: true }); // correctimport { jwt, signJwt, verifyJwt } from "@coderbuzz/velox";
// Sign: every token needs an expiry
app.get("/token", async () => {
const token = await signJwt(
{ sub: "user123", iss: "my-app", aud: "my-api" },
"secret",
{ algorithm: "HS256", expiresIn: 3600 },
);
return Response.json({ token });
});
// Protect
app.get("/secure", {
state: { auth: jwt({ secret: "secret", issuer: "my-app", audience: "my-api" }) },
}, (ctx) => Response.json({ payload: ctx.state.auth }));
// HS384 / HS512
app.get("/secure-384", {
state: { auth: jwt({ secret: "secret", algorithm: "HS384" }) },
}, handler);
// Clock tolerance for slightly expired tokens
app.get("/tolerant", {
state: { auth: jwt({ secret: "secret", clockTolerance: 10 }) },
}, handler);Supports HS256 (default), HS384, and HS512. Optional clockTolerance (seconds)
allows small clock skew when validating exp and nbf claims.
Tokens must expire. signJwt() refuses a payload with no exp unless you
pass expiresIn, and jwt()/verifyJwt() reject a token that carries no exp
claim. A JWT cannot be revoked without rotating the secret (which signs every
other session out at the same time), so a token that never expires is a
credential you cannot take back. Pass requireExp: false only when the
lifetime is bounded somewhere else.
To inspect a token you are debugging, there is
unsafeDecodeJwtWithoutVerification(). The name is the warning: it checks no
signature, so anyone can hand you any payload. Never read identity, tenant or
permissions from its result: use verifyJwt() or ctx.state.
import { session } from "@coderbuzz/velox";
const userSession = session({
cookieName: "_sid",
validate: (cookieValue) => {
const user = db.getUser(cookieValue);
if (!user?.active) throw new Response("Unauthorized", { status: 401 });
return user;
},
});
app.get("/dashboard", {
state: { session: userSession },
}, (ctx) => Response.json({ user: ctx.state.session }));import { secureHeaders } from "@coderbuzz/velox";
// Helmet-inspired defaults
app.define({ sec: secureHeaders() }, (app) => {
app.get("/", () => new Response("secure"));
});
// Custom
app.get("/page", {
state: {
sec: secureHeaders({
xFrameOptions: "DENY",
contentSecurityPolicy: "default-src 'self'",
}),
},
}, () => new Response("secure"));import { logger } from "@coderbuzz/velox";
app.use(logger());
// Custom format
app.use(logger({
format: ({ method, url, status, duration }) =>
`[${new Date().toISOString()}] ${method} ${url} → ${status} (${duration}ms)`,
}));import { cache, requestId, secureHeaders, timing } from "@coderbuzz/velox";
app.get("/combined", {
state: {
reqId: requestId(),
perf: timing(),
sec: secureHeaders(),
caching: cache({ maxAge: 3600, public: true }),
},
}, (ctx) => Response.json({ id: ctx.state.reqId }));app.ws("/echo", {
message(peer, message) { peer.send(message); },
});app.ws("/chat", {
open(peer) {
peer.subscribe("chat");
peer.publish("chat", "someone joined");
},
message(peer, message) {
peer.publish("chat", message); // broadcast to all except sender
peer.send(`you: ${message}`); // echo to sender
},
close(peer) {
peer.unsubscribe("chat");
peer.publish("chat", "someone left");
},
});app.ws<{ userId: string }>("/auth", {
upgrade(req) {
const url = new URL(req.url);
const userId = url.searchParams.get("userId");
if (!userId) return new Response("Unauthorized", { status: 401 });
return { userId }; // becomes peer.data
},
open(peer) { peer.send(`Hello ${peer.data.userId}`); },
message(peer, message) { peer.send(`${peer.data.userId}: ${message}`); },
});import { WsTopicHub } from "@coderbuzz/velox";
const hub = new WsTopicHub();
app.ws("/notifications", {
open(peer) { hub.subscribe(peer, "alerts", (peer, msg) => { /* per-topic handler */ }); },
message(peer, msg) { hub.dispatch(peer, msg); }, // routes to the peer's topic handlers
close(peer) { hub.leave(peer); }, // remove from all topics
});
// Broadcast from any route (includes every subscriber)
app.post("/broadcast", async (ctx) => {
const { message } = await ctx.json;
hub.publish("alerts", message);
return Response.json({ sent: true });
});send() returns a positive byte count on success, -1 when the write is queued under backpressure, and 0 when dropped/failed. Inspect the current buffer and resume application-level delivery from drain:
app.ws("/stream", {
message(peer, message) {
const sent = peer.send(message);
if (sent === -1) console.log("buffered bytes", peer.getBufferedAmount());
},
drain(peer) {
console.log("writable again", peer.getBufferedAmount());
},
}, {
backpressureLimit: 4 * 1024 * 1024,
closeOnBackpressureLimit: true,
});On Bun and uWebSockets.js, drain is native. Node forwards the socket's drain event, and Deno polls bufferedAmount to provide the same portable hook.
| Option | Type | Default | Description |
|---|---|---|---|
maxPayloadLength |
number |
16_777_216 |
Max message size in bytes (16 MB) |
backpressureLimit |
number |
16_777_216 |
Max send buffer size (16 MB) |
closeOnBackpressureLimit |
boolean |
false |
Close when the runtime backpressure limit is exceeded |
pingInterval |
number |
30 |
Seconds between server ping frames |
pongTimeout |
number |
10 |
Seconds to wait for pong before closing |
perMessageDeflate |
boolean |
false |
Enable per-message compression |
idleTimeout |
number |
120 |
Seconds before idle connections are closed |
A declared body schema runs, whether or not the handler reads it:
app.post("/jurnal", { json: JournalSchema }, async (ctx) => {
// Even a handler that never touches ctx.json gets a validated request:
// the schema is awaited before the handler is called.
return postJournal(await ctx.json);
});The body getters are still lazy and memoised, so reading ctx.json twice parses
once. What changed is that declaring { json: … } is a contract rather than a
suggestion: a handler that read the body some other way, or forwarded ctx.req
elsewhere, used to be served an unvalidated request and answer 200, with nothing
reporting that the declared schema had gone unused.
The cost falls only on routes that declare a body schema, which are exactly the routes that wanted the check.
Malformed bodies are 400, not null. A body that is not valid JSON used to
become null, so the handler read null.amount, that TypeError became a 500,
and the real cause was never named anywhere. This holds with or without a json
schema, and for unparseable form bodies too.
Query and form decoding match URLSearchParams on every runtime. + is a
space (?q=john+smith → "john smith"), a malformed escape never becomes a 500,
and await ctx.form is a real FormData (with getAll) on Bun, Node, uWS and
Deno alike; values containing = are kept whole.
An unhandled error becomes { "status": 500, "message": "Internal Server Error", "errorId": "..." }. The thrown error's own message is never sent to the
client. A driver error carries constraint names, column names and the
conflicting values themselves, which in a multi-tenant system is another
tenant's data handed to whoever made the request. The real error is logged
server-side under the same errorId, so a user's report points straight at it.
Anything the client should see is an explicit decision: an onError handler, or
a thrown Response. Both are passed through untouched. An onError must return a
Response: one that returns nothing is answered 500 and logged, not sent as 204.
// App-level error handler
app.onError((error, ctx) => {
console.error(ctx.method, ctx.url, error);
// Map error types you own; do not pass `error.message` through.
if (error instanceof MyValidationError) {
return Response.json({ status: 400, issues: error.issues }, { status: 400 });
}
return Response.json({ message: "Internal Server Error" }, { status: 500 });
});
// Custom 404
app.notFound((ctx) => {
return Response.json({ error: "Not Found", path: ctx.url }, { status: 404 });
});
// Route-level onError, takes priority
app.get("/validate", {
onError: (error, ctx) => Response.json({ custom: true, message: String(error) }, { status: 422 }),
}, () => { throw new Error("validation failed"); });
// Throw a Response to short-circuit
app.get("/secret", () => { throw new Response("Forbidden", { status: 403 }); });HttpError carries the status it should be answered with, and unlike an
arbitrary error its message is sent to the client: you wrote it, so it is
an answer, not a leak.
import { HttpError } from "@coderbuzz/velox";
throw new HttpError(404, "Journal not found");
// → 404 { "status": 404, "message": "Journal not found" }
throw new HttpError(403);
// → 403 { "status": 403, "message": "Forbidden" } ← default reason phrase
throw new HttpError(409, "Reference already used", { ref: "INV-001" });
// → 409 { "status": 409, "message": "Reference already used", "ref": "INV-001" }A 5xx HttpError is answered as written and also logged: at that point
something is wrong on your side, and the log is the only record of it.
Validation → 400. Velox has no runtime dependency on a validation library, so it cannot recognise a failed schema by itself. The mapping lives in your application, in one place you can review:
import { safeParse } from "@coderbuzz/veta";
import { HttpError } from "@coderbuzz/velox";
app.post("/jurnal", async (ctx) => {
const parsed = safeParse(journalSchema, await ctx.json);
if (!parsed.ok) {
throw new HttpError(400, "Validation failed", { issues: parsed.issues });
}
return postJournal(parsed.value);
});Or once, app-wide:
import { VetaError } from "@coderbuzz/veta";
app.onError((error, ctx) => {
// An onError handler replaces the default one, so it has to keep handling
// what the default did.
if (error instanceof HttpError) return error.toResponse();
if (error instanceof VetaError) {
return Response.json(
{ status: 400, message: error.message, path: error.path },
{ status: 400 },
);
}
console.error(ctx.method, ctx.url, error);
return Response.json({ message: "Internal Server Error" }, { status: 500 });
});app.get("/stream", () => {
const stream = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode("chunk 1"));
controller.enqueue(new TextEncoder().encode("chunk 2"));
controller.close();
},
});
return new Response(stream);
});import { sendFile } from "@coderbuzz/velox";
app.get("/download/:name", (ctx) =>
sendFile(ctx.params.name, {
root: "./uploads", // REQUIRED for user input — see below
download: true,
cacheControl: "public, max-age=3600",
reqHeaders: ctx.headers, // enables ETag/Range/If-Modified-Since
}),
);Pass root whenever the path comes from the request. Without it there is no
containment: sendFile("./uploads/" + ctx.params.name) serves ../../etc/passwd
for name = "../../etc/passwd". With root, the final path is resolved and a
request that escapes it (or carries a NUL byte) is answered 404 — the file is
never opened. filePath is resolved against root, so passing the raw sub-path
(sendFile(ctx.params.name, { root })) or an already-joined path
(sendFile(join(root, name), { root })) are both checked.
sendFile supports ETag, Range requests (→ 206 Partial Content), and
Last-Modified. On Bun, uses Bun.file() for zero-copy sendfile.
import { receiveFiles, saveFile } from "@coderbuzz/velox";
app.post("/upload", async (ctx) => {
const files = await receiveFiles(await ctx.form, {
maxFileSize: 5_000_000,
maxFiles: 10,
allowedTypes: ["image/png", "image/jpeg"],
});
for (const file of files) {
// file.fileName comes from the client: do not use it as a path
await saveFile(`./uploads/${crypto.randomUUID()}`, file.data);
}
return Response.json({ count: files.length });
});import { listDirectory } from "@coderbuzz/velox";
app.get("/files", async () =>
listDirectory("./uploads", { recursive: true, stats: true }) // FileEntry[] → JSON
);import { generateSecretKey, encryptString, decryptString } from "@coderbuzz/velox";
const key = generateSecretKey(); // base64 256-bit key
const encrypted = await encryptString("hello world", key);
const original = await decryptString(encrypted, key);The key must be a base64 32-byte key. Anything else is rejected. If what you have is a human-chosen passphrase from an env file, stretch it first:
import { generateSalt, deriveKeyFromPassphrase } from "@coderbuzz/velox";
const salt = generateSalt(); // store it; a salt is not a secret, but it must be stable
const key = await deriveKeyFromPassphrase(process.env.SESSION_PASSPHRASE!, salt);SESSION_SECRET="erp-rahasia-2026" used as a key directly was a single SHA-256
away from being guessed, minutes of GPU work from one captured cookie, and
whoever guesses it can mint a valid session for any user in any tenant. PBKDF2
at 600,000 iterations (the default) makes each guess expensive. Derive once at
startup, not per request.
To read data encrypted before this changed, pass
decryptString(value, oldSecret, { legacyKeyDerivation: true }), long enough
to re-encrypt it. There is no matching option on encryptString.
import { compressString, decompressString } from "@coderbuzz/velox";
const compressed = await compressString("large text payload...");
const original = await decompressString(compressed);decompressString refuses output over 10 MiB by default
({ maxOutputSize: bytes }, or Infinity): compressed input is often
attacker-supplied, and deflate expands ~1000×.
import { memoize } from "@coderbuzz/velox";
const fetchUser = memoize(
async (id: string) => db.users.findById(id),
{ ttl: 30_000, maxSize: 500 },
);
// A function that returns a Promise without being written `async` is not
// detected: pass `async: true` to get in-flight deduplication for it.
const fetchOrg = memoize((id: string) => db.orgs.findById(id), { async: true });ctx.remoteInfo (and so ipRestriction()) uses the socket peer.
X-Forwarded-For and friends are ordinary request headers that any client can
send, so they are only read from a proxy you declare:
import { trustProxy } from "@coderbuzz/velox";
trustProxy(["10.0.0.0/8"]); // your load balancers; IPs or CIDR ranges, v4 or v6Without it, behind a load balancer every client looks like the load balancer.
On Cloudflare Workers nothing is needed: CF-Connecting-IP is set by Cloudflare.
ctx.setCookie(name, value, options) rejects an invalid name, and percent-encodes
a value that is not a plain token (so a value cannot inject ; Domain=…);
ctx.cookies decodes it back. Tokens, JWTs and base64 are written unchanged.
import { csrf } from "@coderbuzz/velox";
app.post("/transfer", {
state: { protection: csrf({ origin: ["https://app.example.com"] }) },
}, handler);Origin is validated on every unsafe request (POST, PUT, PATCH, DELETE),
whatever the content type. An earlier version skipped the check for JSON, on
the premise that a browser cannot send a cross-origin JSON POST without a CORS
preflight. That premise holds only while CORS is configured correctly. An
over-permissive CORS setup silently left every JSON endpoint unprotected, with
both lines looking like good practice.
A request with neither Origin nor Referer is rejected when its content type
is one an HTML form can produce, and allowed otherwise: a non-browser client
(curl, a mobile app, a service call) sends no Origin, and it is not what CSRF
protects against.
csrf() with no origin option compares against the origin of ctx.url. Behind
a proxy that terminates TLS, the server sees http:// while the browser sends
Origin: https://…, so list your public origin explicitly there:
csrf({ origin: "https://app.example.com" }).
A throwing WebSocket handler is isolated from the others on the same socket, and the error is reported rather than swallowed:
import { onWsHandlerError } from "@coderbuzz/velox";
onWsHandlerError((error, source) => metrics.increment("ws.handler_error", { source }));
onWsHandlerError(null); // silence them: a decision, not an accidentThe default writes to console.error. This covers every handler (open,
message, close, ping, pong, drain, error, upgrade) on every runtime,
and async handlers too: a rejecting async message() used to become an
unhandled rejection that ended the whole process on Bun and Node. close is
called exactly once per connection.
Off by default. Turn it on once at startup, before serving:
import { enableRequestContext, getRequestContext } from "@coderbuzz/velox";
enableRequestContext();Then any code running during a request can reach it, without every caller in between passing it down:
// repository.ts: no ctx parameter anywhere
import { getRequestContext } from "@coderbuzz/velox";
export function currentTenant(): string {
return getRequestContext().state.tenantId;
}A tenantId threaded by hand through five layers is a string among strings.
When the sixth endpoint forgets to pass it, nothing fails to compile and nothing
fails at runtime, the query simply runs against the wrong tenant. That is not a
problem discipline solves in a codebase mostly written by agents; it needs a
mechanism.
enableRequestContext() |
Turn it on. Call once, at startup |
getRequestContext() |
The current Context. Throws when there is none |
tryGetRequestContext() |
The current Context, or undefined |
isRequestContextEnabled() |
Whether it is on |
disableRequestContext() |
Turn it back off (mainly for tests) |
getRequestContext() throws rather than returning undefined on purpose: code
that reads a tenant id from it is deciding which rows someone may see, and it
must stop rather than carry on with undefined. Use tryGetRequestContext()
where absence is genuinely fine.
It is built on AsyncLocalStorage, so it survives await and keeps concurrent
requests apart. It does not reach code that escaped the request's async
scope: a callback stored in a module-level array and invoked later, a
setInterval, a queue worker. Pass the value explicitly there.
Why opt-in: AsyncLocalStorage has a real per-request cost, and velox is
built for throughput. With it off, the cost is one boolean test per request.
import { isBun, isDeno, isNode, isWorkers } from "@coderbuzz/velox";
if (isBun) console.log("Running on Bun");isWorkers is true on Cloudflare Workers. There isNode is false, even though
nodejs_compat gives a Worker a process.versions.node.
| Property | Type | Description |
|---|---|---|
ctx.url |
string |
Full request URL (scheme, host, path, query), on every runtime |
ctx.path |
string |
Request path, without query string |
ctx.method |
string |
HTTP method |
ctx.params |
Record<string, string> (or typed) |
Route params |
ctx.query |
Record<string, string> (or typed) |
Query string |
ctx.headers |
Record<string, string> (or typed) |
Request headers (lowercase) |
ctx.cookies |
Record<string, string> (or typed) |
Request cookies |
ctx.json |
Promise<any> (or typed) |
Parsed JSON body |
ctx.text |
Promise<string> (or typed) |
Raw text body |
ctx.form |
Promise<FormData> (or typed) |
Form data body |
ctx.body |
any |
Raw body stream |
ctx.state |
typed | Middleware state |
ctx.remoteInfo |
{ address: string; port: number } |
Client IP and port: the socket peer, unless trustProxy() names it as your proxy |
ctx.setCookie(name, value, opts?) |
void |
Set a response cookie |
ctx.onFinish(cb) |
void |
Post-response callback |
Install uWebSockets.js for maximum throughput on Node.js:
npm install uWebSockets.js
UWS=1 node --import tsx/esm server.tsMIT © 2026 Indra Gunawan