Skip to content

Latest commit

 

History

33 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Velox: @coderbuzz/velox

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.

npm version npm downloads MIT License GitHub Stars CI Codecov

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.


Why Velox over Elysia, Hono, or Express?

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

Benchmarks

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


Key Features

  • 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 via apply()
  • Ecosystem: @coderbuzz/velox-ws-wire* for binary WebSocket protocol with 80-93% bandwidth reduction, fault-tolerant client, and server-side handler

Velox Ecosystem

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();

Installation

# 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" in package.json, or use the .mjs extension. Node.js 18+ required. For TypeScript, use tsx or tsc.


Quick Start

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.


App vs AppServer

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();

Cloudflare Workers

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);
// wrangler.jsonc
{
  "name": "my-api",
  "main": "src/index.ts",
  "compatibility_date": "2025-09-15",
  "compatibility_flags": ["nodejs_compat"]
}

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

Routing

Static Routes

app.get("/", "Hello Velox!"); // string → text/plain
app.get("/health", "OK");
app.get("/version", { version: "1.0.0" }); // object → JSON-serialized

Dynamic Params

app.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}`),
);

Optional Params & Wildcards

app.get(
  "/optional/:id?",
  (ctx) => new Response(`ID: ${ctx.params.id ?? "none"}`),
);

app.get("/files/*", (ctx) => new Response(`File: ${ctx.params["*"]}`));

HTTP Methods

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);
  • HEAD is automatic for every GET route (same status and headers, no body); an explicit app.head() wins.
  • A wrong method is 405 Method Not Allowed with an Allow header, 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).

Route Introspection

const routes = app.getRoutes(); // RouteInfo[]: { method, path }[]
app.printRoutes();
// ┌──────────┬────────────────────┐
// │  Method  │ Path               │
// ├──────────┼────────────────────┤
// │  GET     │ /                  │
// │  POST    │ /users             │
// │  WS      │ /chat              │
// └──────────┴────────────────────┘

Schema Validation

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";

Params

app.get("/products/:id", {
  params: { id: coerce(number()) },
}, (ctx) => Response.json({ productId: ctx.params.id }));
// ctx.params.id is typed as number

Query

app.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 }));

Headers

app.get("/api/resource", {
  headers: { "x-api-key": string({ min: 10 }) },
}, (ctx) => Response.json({ key: ctx.headers["x-api-key"] }));

Cookies

app.get(
  "/api/profile",
  {
    cookies: {
      sessionId: string({ min: 5 }),
      premium: optional(coerce(boolean())),
    },
  },
  (ctx) =>
    Response.json({ session: ctx.cookies.sessionId, isPremium: ctx.cookies.premium }),
);

JSON Body

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

Text Body

app.post("/api/echo", {
  text: string({ min: 5 }),
}, async (ctx) => new Response(await ctx.text));

Form Body

app.post("/api/submit", {
  form: { field: string({ min: 3 }) },
}, async (ctx) => {
  const data = await ctx.form;
  return new Response(data.field);
});

Date Validation

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

Response Validation

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

Type Inference

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 though extra isn't in the schema. Use as const or satisfies for stricter checking.


Middleware & State

Middleware runs before the handler and returns typed state accessible via ctx.state.

Per-Route 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 }));

define(): Scoped Middleware with Full Type Inference

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

apply(): Global Middleware

// 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" });

use(): Mount Sub-Apps

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

Built-in Middleware (16+)

Authentication

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

Security

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())

Performance & Observability

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

Request Handling

Middleware Description
bodyLimit() Limit request body size
requestId() X-Request-Id header generation
logger() Request logging with customizable format

Body Limit

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 route

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

CORS

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 }); // correct

JWT

import { 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.

Session

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

Secure Headers

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"));

Logger

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)`,
}));

Combining Middleware

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

WebSocket

Basic Echo

app.ws("/echo", {
  message(peer, message) { peer.send(message); },
});

With Pub/Sub

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");
  },
});

Typed Upgrade Data

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}`); },
});

WsTopicHub (Cross-Topic Broadcast)

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

Backpressure

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.

WsOptions

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

Request Validation

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.


Error Handling

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

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

Streaming Response

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

File Utilities

Serve a File

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.

Receive Uploads

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

List Directory

import { listDirectory } from "@coderbuzz/velox";

app.get("/files", async () =>
  listDirectory("./uploads", { recursive: true, stats: true }) // FileEntry[] → JSON
);

Utilities

Encryption (AES-GCM)

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.

Compression

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

Memoization

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

Client IP behind a proxy

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 v6

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

Cookies

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.

CSRF

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" }).

WebSocket handler errors

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 accident

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

Ambient Request Context

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.

Runtime Detection

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.


Context API Reference

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

Node.js Performance Tip

Install uWebSockets.js for maximum throughput on Node.js:

npm install uWebSockets.js
UWS=1 node --import tsx/esm server.ts

License

MIT © 2026 Indra Gunawan

About

@coderbuzz/velox - The modern standard for TypeScript backends — #1 fastest HTTP framework

Resources

Contributing

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages