Lightweight, policy-driven execution runtime for Node.js applications.
npm install orvaxisInstall the HTTP adapter peer dependency you intend to use:
npm install express # Express adapter
npm install fastify # Fastify adapterIt is not a framework in the traditional sense. It is an execution orchestration layer designed to control, observe, and structure backend request flows in a predictable and composable way.
Minimal frameworks like Express are flexible but unstructured at scale. Opinionated frameworks like NestJS are structured but heavy. Orvaxis is a third option: a runtime execution layer that brings explicit ordering, declarative control, and built-in observability without replacing your framework.
See a concrete side-by-side comparison →
Every request passes through a clearly defined lifecycle:
- policies (decision layer)
- hooks (event layer)
- middleware (flow layer)
- route handler (business logic)
Policies define what is allowed, independently from implementation logic.
Routes are organized in groups with inheritance:
- shared middleware
- shared policies
- scoped execution context
Every request produces a trace:
- execution timeline
- performance metrics
- lifecycle events
- debug summary
System capabilities are extended through plugins that attach to lifecycle hooks.
Request
↓
Policy Engine (global → group → route)
↓
onRequest hook
↓
beforePipeline hook
↓
Global Pipeline (app.use() middleware)
↓
Group Middleware (inherited)
↓
Route Middleware (scoped)
↓
beforeHandler hook
↓
Route Handler
↓
afterHandler hook
↓
Trace finalization
↓
afterPipeline hook
↓
Debug output (if enabled)
The central execution engine responsible for orchestrating the full request lifecycle.
Handles route resolution and grouping:
- method + path matching
- group-based inheritance
- route metadata resolution
Logical grouping of routes:
- shared middleware
- shared policies
- prefix-based organization
Example:
app.group({
prefix: "/api",
middleware: [traceMiddleware()],
policies: [rateLimitPolicy],
routes: [...]
})Functions that participate in execution flow and can:
- mutate context
- control execution flow
- enrich request state
Pre-execution rules that determine whether a request is allowed.
- can block execution
- can modify context metadata
- can be scoped (route/group/global)
- can be prioritized
Example:
export const requireApiKey: Policy = {
name: "require-api-key",
priority: 100,
async evaluate(ctx) {
const key = ctx.req.headers["x-api-key"]
if (!key) return { allow: false, reason: "Missing X-API-Key header" }
return { allow: true, modify: { apiKey: key } }
}
}Lifecycle events that allow observation of execution:
onRequest— fired after policy evaluation, before middlewarebeforePipeline— fired before the global pipeline runsbeforeHandler— fired after all middleware, immediately before the route handlerafterHandler— fired immediately after the route handler completesafterPipeline— fired after the handler and trace finalizationonError— fired on any unhandled error
beforeHandler / afterHandler wrap only the handler itself, independent from the pipeline. Use them for per-handler timing, logging, or auditing without interfering with middleware. They do not fire when the handler throws — use onError for that case.
Hooks do not modify flow; they observe and react.
Use HttpError to throw errors with an explicit HTTP status code from anywhere in the lifecycle — handlers, middleware, policies, or hooks:
import { HttpError } from "orvaxis"
// in a handler
throw new HttpError(404, "User not found")
// in onError — check the type before accessing .status
app.on("onError", (ctx) => {
if (ctx.error instanceof HttpError) {
console.error(`[${ctx.error.status}] ${ctx.error.message}`)
}
})HttpError extends the native Error class and accepts an optional ErrorOptions third argument (e.g. { cause } for error chaining).
Plugins extend runtime capabilities by registering hooks, middleware, or policies.
Orvaxis ships with two built-in plugins:
loggerPlugin — logs incoming requests and unhandled errors to the console:
import { Orvaxis, loggerPlugin } from "orvaxis"
const app = new Orvaxis()
app.register(loggerPlugin)schemaValidationPlugin — validates body, params, query, and headers against a route.schema before the handler runs. Any library whose objects expose a .parse(data) method works (Zod, TypeBox, custom validators):
import { Orvaxis, schemaValidationPlugin } from "orvaxis"
import { z } from "zod"
const app = new Orvaxis()
app.register(schemaValidationPlugin)
app.group({
prefix: "/api",
routes: [
{
method: "POST",
path: "/users",
schema: {
body: z.object({ name: z.string(), age: z.number().int().min(0) }),
},
handler: async (ctx) => {
// ctx.req.body is the parsed, coerced value
ctx.res.status(201).json(ctx.req.body)
},
},
],
})On validation failure the plugin throws an error with status: 422, a field property indicating which part failed ("body", "params", "query", or "headers"), and the original validator error as cause. The plugin is opt-in — routes with a schema field are silently ignored unless schemaValidationPlugin is registered.
To write a custom plugin:
import type { Plugin } from "orvaxis"
const metricsPlugin: Plugin = {
name: "metrics",
apply(runtime) {
runtime.hooks.on("afterPipeline", (ctx) => {
const duration = ctx.meta.trace?.endTime - ctx.meta.trace?.startTime
recordMetric("request.duration", duration)
})
}
}
app.register(metricsPlugin)Registered plugins are tracked in runtime.plugins and applied immediately on registration. PluginManager is also exported for custom orchestration.
Each request generates a structured execution trace available as ctx.meta.trace:
requestId— unique identifier per requestevents— timestamped lifecycle events (TraceEvent[])startTime/endTime— wall-clock boundaries
Use traceMiddleware() to automatically record timing around middleware execution:
import { traceMiddleware } from "orvaxis"
app.group({ prefix: "/api", middleware: [traceMiddleware()], routes: [...] })Emit custom events from anywhere in the call chain with traceEvent() — no need to pass ctx:
import { traceEvent } from "orvaxis"
async function fetchUser(id: string) {
traceEvent("db:query", { table: "users", id })
// ...
}traceEvent is a no-op when called outside a request scope.
When enabled, the debugger records a structured timeline of every lifecycle step:
app.debugger.enable()Use buildExecutionSummary(ctx) to get a combined view of both the debug timeline and the trace:
import { buildExecutionSummary } from "orvaxis"
app.on("afterPipeline", (ctx) => {
const summary = buildExecutionSummary(ctx)
// summary.requestId — from ctx.meta.trace
// summary.duration — total ms
// summary.traceEvents — lifecycle events from ctx.meta.trace.events
// summary.debugSteps — grouped debug entries (requires debugger enabled)
// summary.route — matched route + group
})buildExecutionSummary always returns an object — traceEvents and duration are available even without the debugger enabled.
A request lifecycle is deterministic:
1 Policy evaluation global → group → route, sorted by priority
2 onRequest hook
3 beforePipeline hook
4 Global pipeline middleware registered via app.use()
5 Group middleware
6 Route middleware
7 beforeHandler hook
8 Route handler
9 afterHandler hook
10 Trace finalization ctx.meta.trace is set
11 afterPipeline hook
12 Debug output if app.debugger.enable() was called
OrvaxisContext accepts two optional type parameters to add compile-time types to ctx.state and ctx.meta:
type AppState = { user: { id: string; role: string } }
type AppMeta = { requestId: string }
type AppContext = OrvaxisContext<AppState, AppMeta>
const handler = async (ctx: AppContext) => {
ctx.state.user.role // string
ctx.meta.requestId // string
ctx.meta.tracer // TracerLike | undefined (always present from ContextMeta)
}The second parameter is intersected with ContextMeta, so all framework-internal fields remain typed.
getContext() returns the OrvaxisContext for the currently executing request, from anywhere in the async call chain — no need to thread ctx through every function:
import { getContext } from "orvaxis"
async function getCurrentUser() {
const ctx = getContext()
return ctx?.state.user
}Returns undefined when called outside a request scope. Backed by AsyncLocalStorage — concurrent requests are fully isolated.
Orvaxis is not tied to any specific HTTP framework. The core runtime is framework-agnostic — adapters are thin wrappers that normalize the incoming request and delegate to the runtime.
Two adapters are included out of the box:
| Adapter | Import | Peer dependency |
|---|---|---|
| Express | createExpressServer |
express ^4.20 || ^5 |
| Fastify | createFastifyServer |
fastify ^5 |
Install only the framework you intend to use — both peer dependencies are optional.
Any adapter needs to:
- Ensure
req.pathis a plain path string (no query string) - Call
app.handle(req, res)and catch thrown errors - Return
{ listen(port, onListen?) }to satisfy theServerAdapterinterface
testRequest runs the full execution cycle — policies, pipeline, middleware, handler — against an Orvaxis instance, with no HTTP server required.
import { Orvaxis, testRequest } from "orvaxis"
const app = new Orvaxis()
app.group({
prefix: "/api",
routes: [
{
method: "GET",
path: "/users/:id",
handler: async (ctx) => {
ctx.res.json({ id: ctx.meta.route?.params.id })
},
},
],
})
// successful request
const res = await testRequest(app, { path: "/api/users/42" })
// res.status → 200
// res.body → { id: "42" }
// res.ctx → full OrvaxisContext
// res.error → undefined
// route not found
const notFound = await testRequest(app, { path: "/api/missing" })
// notFound.status → 404
// notFound.error → Error("Not Found")TestRequestInit accepts path, method (defaults to "GET"), headers, id, and any additional field (e.g. body) which is forwarded directly onto req. testRequest never throws — errors thrown during execution are captured in result.error and their .status property (if present) is reflected in result.status.
app.routes() returns the flat list of all registered routes as RouteInfo[], useful for OpenAPI generation and admin tooling:
import { Orvaxis } from "orvaxis"
import type { RouteInfo } from "orvaxis"
const app = new Orvaxis()
app.group({
prefix: "/api",
routes: [
{ method: "GET", path: "/users", handler: async () => {} },
{ method: "POST", path: "/users", handler: async () => {} },
{ method: "GET", path: "/users/:id", handler: async () => {} },
],
})
const routes: RouteInfo[] = app.routes()
// [
// { method: "GET", path: "/api/users", prefix: "/api" },
// { method: "POST", path: "/api/users", prefix: "/api" },
// { method: "GET", path: "/api/users/:id", prefix: "/api" },
// ]- Why Orvaxis — side-by-side comparison with plain Express: auth, rate limiting, and observability with and without Orvaxis
- Cookbook — practical use cases with working examples (authentication, RBAC, rate limiting, tracing, feature flags, and more)
- Benchmarks — microbenchmark results for each execution layer, plus instructions to run them locally
import { Orvaxis, createExpressServer } from "orvaxis"
import type { Policy } from "orvaxis"
const app = new Orvaxis()
const requireApiKey: Policy = {
name: "require-api-key",
priority: 100,
evaluate(ctx) {
const key = ctx.req.headers["x-api-key"]
if (!key) return { allow: false, reason: "Missing X-API-Key header" }
return { allow: true }
}
}
app.policy(requireApiKey)
app.group({
prefix: "/api",
routes: [
{
method: "GET",
path: "/users",
handler: async (ctx) => {
ctx.res.json({ users: [] })
}
}
]
})
const server = createExpressServer(app)
server.listen(3000)import { Orvaxis, createFastifyServer } from "orvaxis"
const app = new Orvaxis()
app.group({
prefix: "/api",
routes: [
{
method: "GET",
path: "/users/:id",
handler: async (ctx) => {
ctx.res.send({ id: ctx.meta.route?.params.id })
}
}
]
})
const server = createFastifyServer(app)
server.listen(3000)orvaxis/
index.ts entry point, public API
core/
Orvaxis.ts public-facing class
Runtime.ts execution engine
Router.ts route matching, groups, and introspection (routes())
Pipeline.ts global middleware chain
PolicyEngine.ts policy evaluation
Hook.ts hook system
Tracer.ts per-request trace
Debugger.ts debug timeline
Context.ts context factory
contextStore.ts AsyncLocalStorage store (getContext)
HttpError.ts HttpError class (status + message + cause)
testHarness.ts testRequest helper for unit testing
utils.ts shared utilities (mergeSafe, UNSAFE_KEYS)
debug/
buildExecutionSummary.ts combined trace + debug summary
traceEvent.ts emit custom trace events without ctx
http/
expressAdapter.ts Express adapter
fastifyAdapter.ts Fastify adapter
middleware/
traceMiddleware.ts trace timing around middleware execution
plugins/
PluginManager.ts plugin registry (Plugin type + PluginManager class)
loggerPlugin.ts built-in logger plugin
schemaValidationPlugin.ts body/params/query/headers validation via route.schema
types/
index.ts all shared types
examples/
express-server.ts minimal Express setup
policy-server.ts global and route-level policies
hooks-and-plugins.ts lifecycle hooks and plugin registration
debug-trace.ts debugger, traceEvent, and buildExecutionSummary
typed-context.ts typed OrvaxisContext, getContext, traceEvent
fastify-server.ts Fastify adapter with policies and param routing
Orvaxis is built around a few key ideas:
- Separation of concerns at runtime level
- Declarative control of execution
- Transparent request lifecycle
- Composable system primitives instead of monolithic abstractions
It favors:
- explicitness over magic
- composition over inheritance
- observability over hidden behavior
The core execution model is stable, tested, and covered by 203 passing tests.
Not yet recommended for production. Known gaps before production use:
| Gap | Detail |
|---|---|
| No request timeout | Handlers that hang are never terminated. Wrap app.handle() in a timeout at the adapter level if needed. |
| API stability | Pre-1.0 — breaking changes may occur between minor versions. |
Graceful shutdown is supported via server.close() on the ServerAdapter.
- OpenTelemetry export — the trace system already produces structured spans; a plugin exporting to OTLP/Zipkin is a natural next step
- Response body interception — a middleware-level API to transform or wrap outgoing response bodies before they are sent
Contributions are welcome. Please read CONTRIBUTING.md for setup instructions, code conventions, and the PR process. To report a bug or propose a feature, use the GitHub issue templates.
MIT
