@gxcloud/router
v0.0.2
Published
An ultra-fast, zero-dependency Node.js HTTP router and controller framework with compile-time type-safety, lazy body parsing, and automatic OpenAPI generation via Valibot.
Maintainers
Readme
@gxcloud/router
An ultra-fast, zero-dependency Node.js HTTP router and controller framework with compile-time type-safety, lazy body parsing, and automatic OpenAPI generation via Valibot.
Personal project: This is not a competitor to Express, Fastify, Hono, or Elysia. It is purpose-built for the author's TypeScript backend projects — prioritizing control, simplicity, low overhead, and strong type inference.
Features
- Single source of truth — Route definitions drive routing, validation, handler typing, and OpenAPI generation. No duplication.
- Fluent builder API — Declarative chaining:
.get(path).params(s).query(s).body(s).meta(m).handle(h). - Built-in server — Start serving with a single
.listen()call. Nonode:httpboilerplate. - Structured logging — Optional NDJSON request logging via
.logger(). - Radix Tree router — O(k) path matching. No linear scans, no regex-per-route.
- Compile-time type safety — Handler context types are inferred from Valibot schemas.
- Lazy body parsing — Request bodies are never read unless the route declares a body schema.
- Valibot validation — Zero-dependency schema validation with excellent TypeScript inference.
- Automatic OpenAPI 3.1 — Generated from route definitions. No separate YAML/JSON files.
- Scalar UI support — Interactive API documentation with one flag.
- Sub-router mounting — Compose APIs from isolated controllers. Flattened at compile time.
- Route-scoped before-hooks — Execute logic before handlers with short-circuit support.
- Response helpers —
ctx.json(),ctx.text(),ctx.reply.created(),ctx.reply.noContent(). - Custom error handler — Override error formatting globally.
- Testing helper —
app.inject()for zero-server HTTP testing. - No middleware stack — Route-scoped validation replaces global middleware.
- No decorators — No reflect-metadata, no class scanning, no DI containers.
- No dependency injection — Import services manually into controller modules.
Quick Start
import * as v from "valibot";
import { createRouter } from "@gxcloud/router";
createRouter()
.get("/")
.handle(() => ({ message: "Hello, world!" }))
.get("/hello/:name")
.params(v.object({ name: v.string() }))
.query(v.object({ title: v.optional(v.string()) }))
.meta({ summary: "Greet someone", tags: ["Greetings"] })
.handle((ctx) => ({
greeting: `Hello, ${ctx.params.name}!`,
title: ctx.query.title,
}))
.post("/echo")
.body(v.object({ message: v.string() }))
.handle((ctx) => ({ youSaid: ctx.body.message }))
.openapi("/openapi.json", { scalar: true })
.logger()
.listen(3000);Core Concepts
Route as Source of Truth
A single .get("/users/:id").params(...).query(...).meta(...).handle(handler) call defines the path, validation, metadata, and handler. Everything derives from this declaration — the router, the TypeScript types, and the OpenAPI schema.
Build → Auto-Compile → Runtime
- Build — Declare routes with the fluent builder. Mutable, flexible.
- Auto-Compile — The first time a request arrives (via
nodeHandler(),.listen(), or.match()) is called, the builder freezes itself, builds the Radix Tree, and generates OpenAPI. No explicit.compile()needed. - Runtime — The compiled router serves requests with minimal overhead.
You can still call .compile() explicitly if you want to freeze the router early or access the CompiledRouter directly:
const compiled = app.compile();
console.log(compiled.openapiDocument); // already generatedRoute Precedence
The Radix Tree enforces strict precedence:
- Static segments (
/users/me) beat parameter segments (/users/:id) - Parameter segments beat wildcard segments (
/users/*)
Lazy Body Parsing
Bodies are parsed only when the route declares a body schema. GET routes, health checks, and metadata endpoints never pay the cost of body parsing.
Validation
Inbound data (params, query, body) is validated with Valibot. Outbound data is not validated at runtime — TypeScript provides the safety. This keeps responses fast.
API Reference
createRouter()
Returns a RouterBuilder.
Route Methods
Each returns a RouteBuilder for chaining:
.get(path).post(path).put(path).delete(path).patch(path).head(path).options(path)
Schema Methods
.params(schema)— Valibot schema for route parameters.query(schema)— Valibot schema for query parameters.body(schema)— Valibot schema for request body (enables body parsing).meta(metadata)— Route metadata for OpenAPI (summary,description,tags,deprecated,operationId).before(hook)— Register a before-hook (can modify context or short-circuit with a response).response(statusCode, description?, schema?)— Document response schemas for OpenAPI
Server (.listen())
Starts an HTTP server. No node:http imports required.
createRouter()
.get("/hello")
.handle(() => ({ message: "world" }))
.listen(3000, () => console.log("running"));Accepts the same arguments as http.Server.listen():
.listen(port, callback?).listen(port, hostname, callback?).listen(port, hostname, backlog, callback?)
Returns the http.Server instance for lifecycle control.
Logger (.logger())
Enables structured NDJSON request logging to stdout.
createRouter()
.get("/hello")
.handle(() => ({ message: "world" }))
.logger()
.listen(3000);Each request produces a log line:
{"time":"2026-06-10T21:00:00.000Z","level":"info","method":"GET","path":"/hello","status":200,"duration":1.23}Customize logged fields:
.logger({ fields: ["method", "path", "status"] })Disable logging:
.logger({ enabled: false })Default fields: time, level, method, path, status, duration, error.
Body Size Limit (.bodyLimit())
Controls the maximum request body size (in bytes). Defaults to 1 MB.
createRouter()
.post("/upload")
.body(v.object({ data: v.string() }))
.handle((ctx) => ctx.body)
.bodyLimit(5_242_880); // 5 MBCustom Error Handler (.errorHandler())
Override error formatting for all error responses (404, 405, validation, internal errors).
createRouter()
.get("/secure")
.handle(() => { throw new Error("boom"); })
.errorHandler((err) => ({
status: 500,
body: { custom: true, detail: err instanceof Error ? err.message : String(err) },
}));Testing Helper (.inject())
Test routes without starting a server, using a lightweight mock request/response:
const app = createRouter()
.get("/ping")
.handle(() => ({ pong: true }));
const res = await app.inject({ method: "GET", path: "/ping" });
// res.status, res.body, res.headersWorks with POST bodies, headers, query strings, and all route features.
Handler
.handle((ctx) => {
// ctx.params — validated params (or raw Record<string, string>)
// ctx.query — validated query (or raw Record<string, string | string[]>)
// ctx.body — validated body (or undefined)
// ctx.raw — { req, method, path }
// ctx.json — (data, status?) => Response (JSON helper)
// ctx.text — (data, status?) => Response (text helper)
// ctx.reply.created — (data?) => Response (201)
// ctx.reply.noContent — () => Response (204)
return { result: "ok" };
})Sub-Router Mounting
const users = createRouter();
users.get("/:id").handle((ctx) => fetchUser(ctx.params.id));
const app = createRouter().mount("/users", users);Mounted routes are flattened at compile time — no runtime overhead.
Sub-routers are guarded: calling .listen() or .logger() on a mounted router throws an error.
OpenAPI
const app = createRouter()
.get("/users/:id")
.meta({ summary: "Get user", tags: ["Users"] })
.params(v.object({ id: v.string() }))
.handle(() => ({}))
.openapi("/openapi.json", { scalar: true }); // Enable Scalar UICustomize the document title and version:
.openapi("/openapi.json", { title: "My API", version: "2.0.0" })Document response schemas per route:
.get("/items")
.response(200, "List of items", v.object({ items: v.array(v.string()) }))
.response(404, "Not found")
.handle(() => ({}))The document is available at app.openapiDocument (triggers auto-compile if needed).
Explicit Compilation
.compile() is optional — the router auto-compiles on first use. Call it explicitly when you need to pass a CompiledRouter directly or enforce an early freeze:
const compiled = app.compile();
compiled.match("GET", "/hello");Response Values
Handlers can return:
- Objects → JSON
{ "Content-Type": "application/json" } - Strings → Text
{ "Content-Type": "text/plain" } - null / undefined → 204 No Content
- Buffer → Binary
{ "Content-Type": "application/octet-stream" } Response— Custom status/headers:new Response(body, status, headers)
Response helpers are available on the context for common patterns:
ctx.json({ data }, 201); // Content-Type: application/json
ctx.text("created", 201); // Content-Type: text/plain
ctx.reply.created({ id: 1 }); // 201 Created
ctx.reply.noContent(); // 204 No ContentError Handling
Errors produce consistent JSON:
{
"error": {
"type": "validation_error",
"status": 400,
"message": "Request validation failed",
"issues": [
{
"path": ["params", "id"],
"message": "Invalid value",
"code": "invalid_type"
}
]
}
}Error types: validation_error (400), not_found (404), method_not_allowed (405), body_parse_error (400), unsupported_media_type (415), internal_error (500).
Use .errorHandler() to customize error formatting.
Examples
The examples/ directory contains:
basic.ts— Simple greeting and echo APIusers-api.ts— CRUD users API with sub-router mounting
Run with: npx tsx examples/basic.ts
Benchmarks
Run with: npm run bench
The Radix Tree provides O(k) matching where k is the number of path segments — significantly faster than linear route scanning.
Philosophy
- Explicit over magic — No decorators, no file scanning, no DI.
- Simple over flexible — One way to do things. One validation library. One routing approach.
- Fast over convenient — Lazy body parsing, compiled router, no middleware stacks.
- Typed over dynamic — Types flow from schemas through handlers. Compile-time safety is a feature.
License
MIT
