Xerus
Structured servers in Bun
Table of Contents
- Getting Started
- Routes
- Validators
- Services
- HTTPContext
- Responses
- Cookies
- Body Parsing
- WebSockets
- Errors
- Route Groups
- Static Files & Embed
- Plugins
- Built-in Services
- Globals (App-level Injectables)
- API Notes
Installation
bun add github:phillip-england/xerus#v0.0.71
Read the Docs
git clone https://github.com/phillip-england/xerus
cd xerus
bun install
make docs # bun ./www/html/**/*.html
What is Xerus?
Xerus is a small, structured HTTP + WebSocket framework for Bun. You define routes as classes, optionally attach validators (for typed input) and services (for shared logic + lifecycle hooks). Xerus runs validators first, then services, then your route handler.
Key ideas:
- Routes are classes (method + path + handle).
- Validators return values and are read via
c.validated(MyValidator). - Services are class-constructed per-request scope and read via
c.service(MyService). - Lifecycle hooks for services:
init,before,after,onError. - WebSockets reuse the same HTTPContext with per-event reset (OPEN / MESSAGE / CLOSE / DRAIN).
Getting Started
Create an app, mount routes, and listen.
// app.ts
import { Xerus } from "./src/Xerus";
import { XerusRoute } from "./src/XerusRoute";
import { Method } from "./src/Method";
import { json } from "./src/std/Response";
import type { HTTPContext } from "./src/HTTPContext";
class HomeRoute extends XerusRoute {
method = Method.GET;
path = "/";
async handle(c: HTTPContext) {
json(c, { message: "Hello, world!" });
}
}
const app = new Xerus();
app.mount(HomeRoute);
await app.listen(8080);
Notes:
- Routes are mounted with
app.mount(RouteCtor). - Each request creates / reuses an
HTTPContextfrom a pool. - Responses are built through
c.resor helpers instd/Response.
Routes
A route is a class extending XerusRoute: it declares a method, path, and implements handle(c).
import { XerusRoute } from "./src/XerusRoute";
import { Method } from "./src/Method";
import { json, text } from "./src/std/Response";
import type { HTTPContext } from "./src/HTTPContext";
class Hello extends XerusRoute {
method = Method.GET;
path = "/hello/:name";
async handle(c: HTTPContext) {
// path params are stored on c.params
const name = c.params["name"] ?? "world";
json(c, { hello: name });
}
}
class Ping extends XerusRoute {
method = Method.GET;
path = "/ping";
async handle(c: HTTPContext) {
text(c, "pong");
}
}
Route lifecycle hooks (optional):
onMount()— runs once at mount-time (during blueprint creation).validate(c)— runs after validators list has executed.preHandle(c)/postHandle(c)— runs aroundhandle.onFinally(c)— always runs (success or error).onErr(handler)— attach a per-route error handler.
Validators
Validators are constructors listed on routes/services as validators = [MyValidator]. Each validator must implement validate(c) and must return a value. That returned value is stored on the context and retrieved via c.validated(MyValidator).
import type { HTTPContext } from "./src/HTTPContext";
import type { XerusValidator } from "./src/XerusValidator";
import { Method } from "./src/Method";
import { XerusRoute } from "./src/XerusRoute";
import { query } from "./src/std/Request";
import { json } from "./src/std/Response";
class SearchQuery implements XerusValidator<{ search: string }> {
validate(c: HTTPContext) {
// std/Request helpers exist too:
const s = query(c, "search", "");
return { search: s };
}
}
class SearchRoute extends XerusRoute {
method = Method.GET;
path = "/search";
validators = [SearchQuery];
async handle(c: HTTPContext) {
const v = c.validated(SearchQuery);
json(c, { youSearchedFor: v.search });
}
}
Important validator rules:
- Validators run before services, and before route hooks.
- If a validator returns an object, Xerus can optionally deep-freeze it (enabled by default).
- If a validator throws, Xerus converts common validation errors (including Zod-style errors) into a 400 response.
Validator.Ctx()andValidate()are removed; use ctor lists.
Services
Services are constructors listed on routes as services = [MyService]. Xerus will instantiate services per-request (within the request scope), resolve dependencies, run lifecycle hooks, and store instances on the context so you can call c.service(MyService).
import type { HTTPContext } from "./src/HTTPContext";
import { Method } from "./src/Method";
import { XerusRoute } from "./src/XerusRoute";
import { json } from "./src/std/Response";
class MetricsService {
private start = 0;
async before(_c: HTTPContext) {
this.start = performance.now();
}
async after(_c: HTTPContext) {
const ms = performance.now() - this.start;
console.log("request ms:", ms.toFixed(2));
}
}
class UsersService {
users = ["ada", "grace", "linus"];
async init(_c: HTTPContext) {
// run once per request scope when service is first constructed
}
}
class UsersRoute extends XerusRoute {
method = Method.GET;
path = "/users";
services = [MetricsService, UsersService];
async handle(c: HTTPContext) {
const users = c.service(UsersService);
json(c, { users: users.users });
}
}
Service dependency graph
A service can declare its own dependencies:
class AService {
services = [BService, CService]; // service deps
validators = [InputValidator]; // validator deps
}
- Dependencies are resolved before
init(). before()hooks run in dependency order;after()runs in reverse order.- On error,
onError()runs in reverse order, then route error handling kicks in.
HTTPContext
The HTTPContext is the per-request (and per-WS-event) state container. Xerus pools contexts for performance, and resets them between uses.
Request fields
c.req— Bun Requestc.path— URL pathname (normalized)c.method— HTTP method (or WS event method)c.params— route params mapc.route— “METHOD /path” string
Response builder
c.res— MutResponse (status/headers/body/cookies)c.finalize()— stops handler chain after body is setc.ensureConfigurable()— guards header writesc.ensureBodyModifiable()— guards body writes
Context registries
c.validated(MyValidator)— read validated valuec.service(MyService)— read service instancec.global(MyGlobal)— read a global injectable registered on the appc.cookies.request/c.cookies.response— cookie access
Responses
You can write responses directly with c.res or use helpers from std/Response. Helpers finalize the response and prevent accidental double writes.
import { json, text, html, redirect, setHeader, setStatus } from "./src/std/Response";
json(c, { ok: true }); // Content-Type application/json
text(c, "hello"); // text/plain (if not already set)
html(c, "<h1>Hi</h1>"); // text/html
redirect(c, "/login"); // 302 + Location
setHeader(c, "X-Foo", "bar"); // header only
setStatus(c, 201); // status only
Streaming + Files
stream(c, readableStream)sets streaming mode (headers become immutable).await file(c, path)sends a file usingBun.fileand sets Content-Type.
Cookies
Xerus exposes request cookies and response cookie writers via c.cookies. Response cookies are written as Set-Cookie headers when the response is sent.
// Read cookie
const session = c.cookies.request.get("session");
// Set cookie
c.cookies.response.set("session", "abc123", {
path: "/",
httpOnly: true,
sameSite: "Lax",
});
// Clear cookie
c.cookies.response.clear("session", { path: "/" });
Body Parsing
Xerus includes a strict-ish body parser with guarded re-parsing rules. Parsing helpers live in std/Body.
import { jsonBody, textBody, formBody, multipartBody } from "./src/std/Body";
import { BodyType } from "./src/BodyType";
// JSON
const data = await jsonBody(c); // or parseBody(c, BodyType.JSON)
// TEXT
const s = await textBody(c);
// FORM (application/x-www-form-urlencoded)
const form = await formBody(c);
// MULTIPART (multipart/form-data)
const fd = await multipartBody(c);
Parsing rules (high level):
- JSON and FORM are mutually exclusive once parsed (no reparse across them).
- Multipart consumes the body and cannot be re-parsed into something else.
- Strict Content-Type enforcement is available via
opts.strict.
WebSockets
Xerus supports WebSocket upgrade routes automatically when you mount WS event routes on the same path. A GET route at a path will upgrade if the route trie contains WS handlers for that same path and the request includes Upgrade: websocket.
WS event methods
Method.WS_OPENMethod.WS_MESSAGEMethod.WS_CLOSEMethod.WS_DRAIN
import { XerusRoute } from "./src/XerusRoute";
import { Method } from "./src/Method";
import type { HTTPContext } from "./src/HTTPContext";
import { ws } from "./src/std/Request";
// Same path, different WS event methods:
class ChatOpen extends XerusRoute {
method = Method.WS_OPEN;
path = "/chat";
async handle(c: HTTPContext) {
ws(c).send("welcome");
}
}
class ChatMessage extends XerusRoute {
method = Method.WS_MESSAGE;
path = "/chat";
// validators/services work the same as HTTP:
// validators = [MessageValidator]
// services = [AuthService]
async handle(c: HTTPContext) {
const w = ws(c);
// message available on WSContext
w.send("echo: " + String(w.message));
}
}
class ChatClose extends XerusRoute {
method = Method.WS_CLOSE;
path = "/chat";
async handle(_c: HTTPContext) {
// cleanup per close
}
}
WSContext
Inside WS routes, use ws(c) to access the WebSocket wrapper:
ws(c).send(...),ping,pong,closews(c).messageis populated for MESSAGE eventsws(c).code/ws(c).reasonfor CLOSE events
Errors
Xerus uses SystemErr (with SystemErrCode) for framework-level errors. The framework maps these to JSON error responses via SystemErrRecord.
import { SystemErr } from "./src/SystemErr";
import { SystemErrCode } from "./src/SystemErrCode";
throw new SystemErr(SystemErrCode.ROUTE_NOT_FOUND, "Nope");
Custom handlers:
app.onNotFound(RouteCtor)— fallback route when nothing matches.app.onErr(RouteCtor)— app-wide error handler route.route.onErr(handler)— per-route error handler function.
Route Groups
Use RouteGroup to mount multiple routes under a prefix.
import { Xerus } from "./src/Xerus";
import { RouteGroup } from "./src/RouteGroup";
const app = new Xerus();
new RouteGroup(app, "/api")
.mount(UsersRoute, SearchRoute, PingRoute);
Static Files & Embed
Xerus can serve static files from disk or embed an in-memory file map.
Static directory
app.static("/www", "./www");
Requests to /www/* map into that directory. Paths are resolved and checked to prevent directory traversal.
Embed route
app.embed("/docs", {
"/index.html": { content: "<h1>Hi</h1>", type: "text/html" }
});
Useful for bundling documentation/assets. You can generate embedded maps using embedDir() in macros.ts.
Plugins
Plugins provide structured hooks for app lifecycle: connect, route registration, pre-listen, and shutdown.
import type { XerusPlugin } from "./src/XerusPlugin";
import type { Xerus } from "./src/Xerus";
class MyPlugin implements XerusPlugin {
onConnect(app: Xerus) {
// called when plugin is registered
}
onRegister(app: Xerus, route: any) {
// called when routes are mounted
}
onPreListen(app: Xerus) {
// called before listen()
}
onShutdown(app: Xerus) {
// called on SIGINT/SIGTERM shutdown
}
}
app.plugin(MyPlugin);
Built-in Services
Xerus ships a few opt-in services you can mount globally or per-route.
CORSService
Adds CORS headers and auto-handles OPTIONS preflight by finalizing the response.
import { CORSService } from "./src/CORSService";
app.use(class extends CORSService {
constructor() {
super({
origin: true, // reflect request origin
credentials: true,
methods: ["GET","POST","PUT","DELETE","OPTIONS"],
});
}
});
CSRFService
Sets a CSRF cookie and validates a matching header for non-safe methods.
import { CSRFService } from "./src/CSRFService";
app.use(class extends CSRFService {
constructor() {
super({
cookieName: "XSRF-TOKEN",
headerName: "X-XSRF-TOKEN",
});
}
});
RateLimitService
In-memory rate limiting (Map store) with standard rate headers.
import { RateLimitService } from "./src/RateLimitService";
app.use(class extends RateLimitService {
constructor() {
super({ limit: 120, windowMs: 60_000 });
}
});
LoggerService
Logs request method + path + duration using service hooks.
import { LoggerService } from "./src/LoggerService";
app.use(LoggerService);
Globals (App-level Injectables)
Xerus supports global singletons you can access via c.global(MyType). Register them with app.provide() or app.injectGlobal().
class Config {
storeKey = "Config";
apiKey = "secret";
}
const app = new Xerus();
app.provide(Config, new Config());
class Route extends XerusRoute {
method = Method.GET;
path = "/cfg";
async handle(c: HTTPContext) {
const cfg = c.global(Config);
json(c, { apiKey: cfg.apiKey });
}
}
API Notes
- Legacy helpers
Inject(),Validate(), andValidator.Ctx()are intentionally removed. Declare ctor lists instead:services = [...],validators = [...]. - If you write to the response body using helpers like
json()ortext(), they callc.finalize()and stop the handler chain. - WebSocket routes share a pooled
HTTPContext. Xerus resets per-event scope viaresetForWSEvent().
Xerus Documentation — generated from source layout. Add more examples as your app grows.