Skip to content

IngeniumFramework/Ingenium

Ingenium

Express ergonomics, Hono/Fastify-class throughput. A typed HTTP framework for Node 20+ and Bun 1.1+.

  ___                       _                 
 |_ _|_ __   __ _  ___ _ __ (_)_   _ _ __ ___  
  | || '_ \ / _` |/ _ \ '_ \| | | | | '_ ` _ \ 
  | || | | | (_| |  __/ | | | | |_| | | | | | |
 |___|_| |_|\__, |\___|_| |_|_|\__,_|_| |_| |_|
             |___/                               

Ingenium is what happens if you fix Express's three structural problems — linear routing, untyped req/res, and per-request allocation — without forcing developers to learn a new mental model. It's the same shape (app.get, app.use, mountable routers, drop-in middleware), with a typed ctx instead of (req, res, next), and a router/dispatcher built for current-decade Node throughput.

Status: alpha (v0.0.5). API is mostly settled but still subject to change before 0.1.0. Use it for side projects and internal tools; revisit for production once 1.0 lands.


Table of contents


Show me the code

import { ingenium } from 'ingenium'

const app = ingenium()

app.use(async (ctx, next) => {
  const start = Date.now()
  await next()
  console.log(`${ctx.method} ${ctx.path} -> ${ctx._statusCode} ${Date.now() - start}ms`)
})

app.get('/', () => 'hello')
app.get('/users/:id', (ctx) => ({ id: ctx.params.id }))
app.post('/echo', async (ctx) => ctx.body.json())

const server = await app.listen(3000)
console.log(`listening on http://localhost:${server.port}`)

That's a full server. No res.send. No body-parser. No app.set('case sensitive routing', true). Return a value and Ingenium reflects it to the wire — object → JSON, string → text/html, Buffer → octet-stream, Readable → stream, undefined → 204. Call ctx.json(...) when you want explicit control over status or headers.


Why Ingenium

Pain point Express Hono / Fastify Ingenium
Router speed at 1000 routes O(n) linear scan O(k) trie O(k) radix trie + wildcard backtrack
req / res types any in practice strict, but unfamiliar surface strict, Express-shaped
Per-request allocation new req/res/next each request varies pooled IngeniumContext, lazy getters
Middleware composition re-walked per request compose-on-register lazy compose with dirty-bit recompose
Body parsing body-parser middleware always runs always-on parsing lazy via ctx.body.json()
Default body size limit 100 KB (body-parser) varies 100 KB (matches Express)
Bun support community shim varies first-class adapter
Migration cost from Express n/a high low

The pitch in one sentence: the shortest path from a working Express app to throughput competitive with Hono and Fastify.


Production hardening

Native primitives an API team actually needs in prod, all opt-in:

Concern Surface Why it matters
Per-request timeout ceiling ingenium({ requestTimeoutMs: 30_000 })IngeniumTimeoutError (503) A handler that never resolves leaks the context, socket, and pool slot forever.
Hard request-body cap ingenium({ maxRequestBytes: 2_000_000 }) enforced at the transport layer Default-100KB per-call check doesn't help if the handler reads via ctx.body.stream(). Cap is enforced before any consumer touches a byte.
Header injection guard ctx.set(name, value) rejects \r\n immediately → IngeniumHeaderInjectionError Catches CRLF injection at the call site instead of deep inside Node's wire path.
ctx.json() safety on circular refs / BigInt Throws IngeniumUnserializableError (500) with the structural reason No more useless TypeError: Converting circular... bubbling up as a generic 500. safeJsonStringify(value) exported for lenient mode.
Idempotency-Key — skip caching 5xx ingenium.idempotency({ cacheable: (s) => s < 500 }) (default) A transient 500 no longer gets replayed for the entire TTL.
Compat shim — real-stream Express drop-in expressCompat(mw) runs (req, res, next) middleware on real Node streams (req is a Readable, res a Writable) body-parser, multer, compression, express-session, morgan all work end-to-end; cost is opt-in per wrapped middleware.
Asymmetric JWT (RS/PS/ES + JWKS) jwtMiddleware({ algorithms: ['RS256'], jwksUrl: '...' }) from ingenium-auth Required for any IdP with a JWKS endpoint (Auth0, Okta, Cognito, Clerk, Supabase). Algorithm-confusion attacks blocked at the allowlist. 'none' rejected unconditionally.
Late-write protection _epoch counter on IngeniumContext — orphaned-handler writes after a timeout are detected and discarded Stops cross-request response corruption when the pool recycles the context.

Wire all of these in production:

import {
  ingenium, sessionMiddleware, gracefulShutdown,
  IdempotencyMemoryStore,
} from 'ingenium'
import { jwtMiddleware } from 'ingenium-auth'   // JWT/API-key live in their own package

const app = ingenium({
  trustProxy: 'loopback',                  // behind nginx / Caddy / etc.
  requestTimeoutMs: 30_000,                // hung-handler protection
  maxRequestBytes: 2 * 1024 * 1024,        // 2 MiB body ceiling
  poolSize: 4096,
})

app.use(ingenium.cors({ origin: 'https://app.example.com', credentials: true }))
app.use(ingenium.csrf({ secret: process.env.CSRF_SECRET! }))
app.use(sessionMiddleware({ secret: [process.env.SESSION_SECRET!] }))
app.use(ingenium.rateLimit({ windowMs: 60_000, limit: 100 }))
app.use(ingenium.idempotency({ store: new IdempotencyMemoryStore() }))   // swap for RedisIdempotencyStore (ingenium-redis) for multi-instance
app.use(ingenium.problemDetails({ typeBaseUrl: 'https://api.example.com/errors/' }))
app.use(jwtMiddleware({                          // from ingenium-auth
  algorithms: ['RS256'],
  jwksUrl: 'https://example.auth0.com/.well-known/jwks.json',
  issuer: 'https://example.auth0.com/',
  audience: 'https://api.example.com',
}))

const server = await app.listen(cfg.PORT, '0.0.0.0')
gracefulShutdown(server, { gracefulTimeoutMs: 10_000, onShutdown: () => db.close() })

Multi-instance deploys: swap the in-memory stores for the Redis-backed ones in ingenium-redis. One createClient() instance, four drop-in stores (sessions, idempotency, rate-limit, job queue), no API changes elsewhere. See the package README for the wire-in.


Install

npm install ingenium

Optional packages by use case:

# JWT + API-key authentication middleware
npm install ingenium ingenium-auth

# Bun.serve adapter
npm install ingenium ingenium-bun

# Express middleware compatibility (cors, helmet, etc.)
npm install ingenium ingenium-compat

# Redis stores for multi-instance prod (sessions, idempotency, rate-limit)
npm install ingenium ingenium-redis redis

# Project scaffolder
npm install -g ingenium-cli
ingenium new my-api

Requirements: Node.js 20+. Node 24 LTS is recommended (see .nvmrc). Bun 1.1+ for the Bun adapter. WebSocket support requires installing ws as a peer dep.


The 5-minute Express → Ingenium diff

// Express                                      // Ingenium
import express from 'express'                   import { ingenium } from 'ingenium'
const app = express()                           const app = ingenium()

app.use(express.json())                         app.use(ingenium.json())  // (no-op, parsing is lazy)

app.use((req, res, next) => {                   app.use(async (ctx, next) => {
  req.startedAt = Date.now()                      ctx.state.startedAt = Date.now()
  next()                                          await next()
})                                              })

app.get('/users/:id', (req, res) => {           app.get('/users/:id', (ctx) =>
  res.json({ id: req.params.id })                 ({ id: ctx.params.id }))
})

app.post('/users', (req, res) => {              app.post('/users', async (ctx) => {
  const body = req.body                           const body = await ctx.body.json()
  // ...                                          // ...
  res.status(201).json(user)                      return ctx.json(user, 201)
})                                              })

const router = express.Router()                 const router = ingenium.Router()
router.get('/health', (req, res) =>             router.get('/health', () => ({ ok: 1 }))
  res.json({ok:1}))
app.use('/api', router)                         app.use('/api', router)

app.use((err, req, res, next) => {              app.onError((err, ctx) => {
  res.status(500).json({err: err.message})        ctx.json({ err: err.message }, 500)
})                                              })

app.listen(3000)                                await app.listen(3000)

Breakable changes:

  1. Handlers may return values. return obj is res.json(obj); return 'text' is res.text(...). Calling ctx.json(...) explicitly still works.
  2. Body parsing is lazy. app.use(ingenium.json()) is a no-op stub for ergonomics; the actual parse happens in ctx.body.json() inside your handler.
  3. ctx.state is the per-request scratch space, not ctx.user = ... directly (though plugins can decorate ctx to enable that).

That's the whole list. Everything else from the Express mental model carries over verbatim.


Core concepts

App + Router

import { ingenium, Router } from 'ingenium'

const app = ingenium({ poolSize: 1024, trustProxy: false })

// HTTP methods — same surface as Express
app.get('/', handler)
app.post('/users', handler)
app.put('/users/:id', handler)
app.patch('/users/:id', handler)
app.delete('/users/:id', handler)
app.head('/users/:id', handler)
app.options('/users/:id', handler)
app.method('OPTIONS', '/anywhere', handler)  // any method by string

// Mountable routers
const api = Router()
api.get('/health', () => ({ ok: true }))
api.use('/notes', notesRouter)               // routers can mount routers
app.use('/api', api)

// Middleware
app.use(globalMiddleware)
app.use('/admin', adminOnlyMiddleware)
app.use('/admin', adminRouter)               // mount middleware OR router

// Lifecycle
await app.compose()                          // pre-warm; runs lazily on first request otherwise
const server = await app.listen(3000, '0.0.0.0')
await server.close({ gracefulTimeoutMs: 10_000 })

Path syntax. :param (required), :param? (optional), *wildcard (greedy tail). Static segments win over params, params win over wildcards, but the matcher backtracks one level to a wildcard if the param branch dead-ends.

Composition timing. Registration is journaled, not eagerly composed. The trie + composed handlers are built on first request (or via app.compose()). Adding routes after listen() sets a dirty bit and triggers recompose on the next request — tests that register routes per-test work without ceremony.

IngeniumContext

class IngeniumContext<Params = Record<string, string>> {
  // Request
  method: HttpMethod              // 'GET' | 'POST' | ...
  url: string                     // path + ?query
  path: string                    // no query
  rawQuery: string
  query: URLSearchParams          // lazy
  params: Params                  // route params
  headers: IncomingHttpHeaders    // lowercased per Node convention
  body: IngeniumBody                   // lazy parsers
  state: Record<string, unknown>  // per-request scratch

  // Network info (trust-proxy aware)
  ip: string                      // client IP (XFF-aware if trustProxy enabled)
  ips: readonly string[]          // full forwarded chain
  protocol: 'http' | 'https'
  secure: boolean
  hostname: string
  remoteAddress: string           // immediate socket peer
  baseProtocol: 'http' | 'https'  // underlying transport

  // Response setters (chainable)
  status(code: number): this
  set(name: string, value: string | string[]): this
  setHeader(name: string, value: string | string[]): this
  getHeader(name: string): string | string[] | undefined

  // Response writers
  json(body: unknown, status?: number): void
  text(body: string, status?: number): void
  html(body: string, status?: number): void
  send(body: Buffer | string, status?: number): void
  redirect(location: string, status?: number): void  // default 302
  stream(readable: Readable, contentType?: string): void
}

The class is pool-bound: one instance per pool slot, reused across requests. reset() zeros every field by reassignment to keep the V8 hidden class stable.

Middleware

type IngeniumMiddleware = (ctx: IngeniumContext, next: () => Promise<void>) => unknown | Promise<unknown>

Same dispatch model as Koa: await next() in the middle, do work before/after. Errors thrown anywhere in the chain bubble up to app.onError.

Body parsing

ctx.body.json<T>(schema?, maxBytes?: number): Promise<T>
ctx.body.text(maxBytes?: number): Promise<string>
ctx.body.urlencoded(maxBytes?: number): Promise<Record<string, string>>
ctx.body.buffer(maxBytes?: number): Promise<Buffer>
ctx.body.stream(): Readable
ctx.body.multipart(opts?: MultipartOptions): Promise<MultipartResult>

Default maxBytes is 100,000 (matches Express's body-parser default). Override per-call. The schema arg accepts:

  1. Standard Schema v1 (any validator that exposes ["~standard"])
  2. Zod-like safeParse(input)
  3. Plain parse(input): T

Validation failures throw IngeniumValidationError with a fields map. Body-too-large throws IngeniumPayloadTooLargeError mid-stream (no post-buffer rejection).

Response reflection

Return value Wire output
undefined / null 204 No Content
string starting < 200 text/html
other string 200 text/plain
Buffer / Uint8Array 200 application/octet-stream
Readable 200 streamed
any object/array 200 application/json

If a ctx.json/text/html/send/redirect/stream helper has been called, the return value is ignored.

Errors

import {
  IngeniumError,
  IngeniumNotFoundError,        // 404
  IngeniumUnauthorizedError,    // 401
  IngeniumMethodNotAllowedError,// 405 (auto-thrown on path match + method miss)
  IngeniumPayloadTooLargeError, // 413
  IngeniumValidationError,      // 422 with .fields
  IngeniumBadRequestError,      // 400
} from 'ingenium'

app.onError((err, ctx) => {
  if (err instanceof IngeniumValidationError) {
    return ctx.json({ error: err.message, fields: err.fields }, 422)
  }
  if (err instanceof IngeniumError) throw err  // delegate to default boundary
  ctx.json({ error: 'internal' }, 500)
})

The default boundary serializes any IngeniumError as { error, code, fields? } with the right status. Unknown errors become 500s. IngeniumMethodNotAllowedError writes the Allow response header automatically.

Plugins

import { ingenium, type IngeniumPlugin } from 'ingenium'

interface User { id: string; email: string }

const auth: IngeniumPlugin<{ secret: string }> = (app, opts) => {
  app.decorate('user', async (ctx) => {
    const token = ctx.headers.authorization?.split(' ')[1]
    if (!token) throw new IngeniumUnauthorizedError()
    return verifyToken(token, opts.secret) as User
  })
  app.hooks.onRequest((ctx) => {
    ctx.state.requestId = crypto.randomUUID()
  })
}

const app = ingenium()
await app.register(auth, { secret: process.env.JWT_SECRET! })

declare module 'ingenium' {
  interface IngeniumContext {
    user: User
  }
}

app.get('/me', (ctx) => ctx.user)  // typed, lazily resolved on first access

Lifecycle hooks: onRoute, onCompose, onRequest, onResponse, onError. Decorators come in two flavors: lazy (decoratedefineProperty self-replacing getter, computed on first access) and eager (decorateRequest — assigned at request start). Hot-path checks hooks.hasAny() and decorators.hasAny() so plugin-free apps pay zero overhead.

Trust-proxy

const app = ingenium({ trustProxy: 'loopback' })

// Then in handlers:
ctx.ip          // real client IP after walking the X-Forwarded-For chain
ctx.protocol    // 'https' if X-Forwarded-Proto: https
ctx.hostname    // X-Forwarded-Host (if set), else Host header

Mirrors Express's app.set('trust proxy', ...):

Value Behavior
false (default) Never trust XFF — ctx.ip is the socket peer
true Trust the entire chain — leftmost XFF entry wins
number n Trust n upstream hops
'loopback' Trust 127.0.0.0/8, ::1
'linklocal' Trust 169.254.0.0/16, fe80::/10
'uniquelocal' Trust 10/8, 172.16/12, 192.168/16, fc00::/7
'10.0.0.0/8' (CIDR) Trust matching addresses
string[] Multiple of any of the above
(ip, hopIdx) => boolean Custom predicate

Cookies

First-class cookie API on ctx.cookies, lazily allocated — routes that never read or write a cookie pay nothing.

app.get('/prefs', (ctx) => {
  const theme = ctx.cookies.get('theme') ?? 'light'
  ctx.cookies.set('seen', '1', { maxAge: 86_400, httpOnly: true, sameSite: 'lax' })
  return { theme }
})
Method Behavior
ctx.cookies.get(name, { signed? }) Read one cookie; null if absent. { signed: true } verifies the HMAC suffix, returning null on tamper.
ctx.cookies.all() Snapshot of every parsed cookie (signed values keep their value.sig suffix).
ctx.cookies.set(name, value, opts?) Append a Set-Cookie (calls accumulate). Supports domain, path, expires, maxAge, httpOnly, secure, sameSite, priority, partitioned, signed.
ctx.cookies.clear(name, { domain?, path? }) Expire a cookie (Max-Age=0 + a past Expires, mirroring domain/path so the browser removes the right one).

Signed cookies are HMAC-SHA-256'd. Configure the app with ingenium({ cookieSecrets: [process.env.COOKIE_SECRET!] }) — index 0 signs, every entry verifies (rotation-safe). Calling .set(..., { signed: true }) with no secrets configured throws IngeniumError(500, 'COOKIE_SECRET_MISSING').


Built-in middleware

ingenium.json / ingenium.urlencoded

Stub middleware for Express compatibility. Body parsing is lazy via ctx.body.json() / ctx.body.urlencoded(), so these are no-ops. They exist so existing Express migration code (app.use(express.json())) compiles and reads naturally.

ingenium.static

app.use(ingenium.static('./public', {
  index: 'index.html',     // default; set false to disable
  maxAge: 60_000,          // ms — sets Cache-Control: public, max-age=60
  extensions: ['html'],    // try /foo + /foo.html when /foo not found
  dotfiles: 'ignore',      // 'allow' | 'deny' | 'ignore' (default: ignore → next())
}))

Ships with weak ETags (W/"size-mtime"), conditional GET (If-None-Match → 304), range requests (Range: bytes=N-M → 206), MIME from extension (extensible map), and path-traversal protection (../etc/passwd → 403).

ingenium.cors

app.use(ingenium.cors({
  origin: 'https://app.example.com',  // or true | string[] | RegExp | (origin, ctx) => boolean | string | Promise<>
  methods: ['GET', 'POST', 'PUT'],    // default: GET HEAD PUT PATCH POST DELETE
  allowedHeaders: ['x-trace-id'],     // default: mirror Access-Control-Request-Headers
  exposedHeaders: ['x-trace-id'],
  credentials: true,                   // throws at construction with origin: '*'
  maxAge: 3600,                        // preflight cache seconds
  optionsSuccessStatus: 204,
}))

Handles simple requests, preflights (responds 204 with negotiated methods/headers, does NOT call next()), and Vary: Origin whenever the origin is reflected from the request.

ingenium.sse (Server-Sent Events)

import { ingenium, sse, startKeepAlive } from 'ingenium'

app.get('/events', (ctx) => {
  const stream = sse(ctx)
  startKeepAlive(stream, 15_000)

  let n = 0
  const timer = setInterval(() => {
    stream.send({ event: 'tick', id: String(n), data: { n: n++ } })
    if (n >= 10) {
      clearInterval(timer)
      stream.close()
    }
  }, 1000)
})

SseStream API: send(event | string), comment(text), close(), closed: boolean. Multi-line data is split per spec. Object data is JSON-stringified.

ingenium.rateLimit

app.use(ingenium.rateLimit({
  windowMs: 60_000,
  limit: 100,
  // default keygen reads X-Forwarded-For — make sure trustProxy is set!
  keyGenerator: (ctx) => ctx.ip,
  skip: (ctx) => ctx.path.startsWith('/health'),
}))

Fixed-window in-memory store (MemoryStore) by default, with cleanup interval unref()'d so it never holds the event loop alive. Pluggable via the RateLimitStore interface (Promise-returning so a Redis-backed store fits cleanly). Sets X-RateLimit-{Limit,Remaining,Reset} on every response and Retry-After on 429s.

ingenium.csrf

import { ingenium } from 'ingenium'

const app = ingenium()
app.use(ingenium.csrf({
  secret: process.env.CSRF_SECRET!,    // required for cookie storage
  storage: 'cookie',                    // 'cookie' (default) | 'session'
  cookie: { sameSite: 'lax', secure: true },
  ignoreMethods: ['GET', 'HEAD', 'OPTIONS', 'TRACE'],
  // skip: (ctx) => ctx.path.startsWith('/api/webhooks/'),  // opt-out
}))

app.get('/form', (ctx) => {
  // ctx.csrfToken() returns the current token to embed in HTML / send to a JS client.
  return `<form method="POST" action="/submit">
    <input type="hidden" name="_csrf" value="${ctx.csrfToken()}">
    <button>Submit</button>
  </form>`
})

app.post('/submit', async (ctx) => {
  // CSRF middleware already validated; if we got here the token is good.
  const body = await ctx.body.json()
  return { ok: true, body }
})

Two storage modes:

  • cookie (default, no session needed) — double-submit cookie pattern with HMAC-signed tokens. The token is written to a non-HttpOnly cookie on safe requests; the client must echo it back via X-CSRF-Token (or X-XSRF-Token for Angular, or ?_csrf= query param) on unsafe requests. Same-origin policy + HMAC verification together prevent forgery.
  • session — synchronizer pattern. Token stored on ctx.session.csrfToken; submitted token compared against it. Requires sessionMiddleware to run first; throws a clear developer error if missing.

Verification uses crypto.timingSafeEqual. Secret rotation supported (secret: ['new', 'old']). Failures throw IngeniumCsrfError (HTTP 403, code CSRF_FAILED) which the default error boundary serializes; catch in app.onError for custom handling.

Sessioned apps should opt in to storage: 'session'. The default is 'cookie' because it's self-contained, but if you're already running sessionMiddleware the synchronizer pattern is simpler (one cookie instead of two, and rotating the session secret rotates CSRF protection automatically). We don't auto-detect because middleware order shouldn't change semantics. See docs/api/csrf.md.

sessionMiddleware

import { sessionMiddleware, type Session } from 'ingenium'

app.use(sessionMiddleware({
  secret: [process.env.SESSION_SECRET!, ...rotatedSecrets],
  cookieName: 'ingenium.sid',
  maxAgeSeconds: 7 * 86_400,
  rolling: false,
  cookie: { secure: true, sameSite: 'lax', httpOnly: true },
}))

declare module 'ingenium' {
  interface IngeniumContext { session: Session }
}

app.post('/login', async (ctx) => {
  const { user } = await ctx.body.json()
  await ctx.session.regenerate()       // new id, fresh against fixation
  ctx.session.set('userId', user.id)
  return { ok: true }
})

app.post('/logout', async (ctx) => {
  await ctx.session.destroy()
  return { ok: true }
})

HMAC-SHA256-signed cookies, 18-byte (144-bit) ids, crypto.timingSafeEqual verification, secret rotation (index 0 signs, all entries verify), regenerate() for post-login fixation defense, pluggable SessionStore interface (default SessionMemoryStore).

ingenium.idempotency

import { ingenium, IdempotencyMemoryStore } from 'ingenium'

app.use(ingenium.idempotency({
  store: new IdempotencyMemoryStore(),   // swap for RedisIdempotencyStore in prod
  ttlSeconds: 86_400,                    // how long a key's response is replayed
  cacheable: (status) => status < 500,   // default: cache 2xx/3xx/4xx, never 5xx
  // scope: (ctx) => ctx.state.user.id,  // isolate keys per client (see below)
}))

Replays the stored response for any request that repeats an Idempotency-Key header, so a retried POST doesn't double-charge. Keys are scoped by the Authorization header by default; an unauthenticated cacheable route with no Authorization is bypassed (not cached) with a dev warning — one client's response must never leak to another, so supply an explicit scope for public endpoints. A transient 5xx is never cached, so it isn't replayed for the whole TTL.

ingenium.problemDetails

app.use(ingenium.problemDetails({ typeBaseUrl: 'https://api.example.com/errors/' }))

Serializes thrown IngeniumErrors as RFC 7807 application/problem+json ({ type, title, status, detail, ... }) instead of the default { error, code } shape, with type derived from the error code against typeBaseUrl. toProblemDetails(err, opts) is exported if you want to build the object yourself.


Content negotiation

Express-style Accept negotiation, parameterized so it works on any { headers }:

import { accepts, formatResponse } from 'ingenium'

app.get('/data', (ctx) => {
  switch (accepts(ctx, 'json', 'html')) {     // best match, or false
    case 'json': return { ok: true }
    case 'html': return '<b>ok</b>'
    default:     return ctx.json({ ok: true })
  }
})

// Or branch declaratively, res.format-style:
app.get('/report', (ctx) => formatResponse(ctx, {
  'application/json': () => ({ ok: true }),
  'text/html':        () => '<h1>Report</h1>',
  default:            () => 'ok',             // omit → 406 Not Acceptable on no match
}))

accepts(ctx) returns the full preference-ordered list; accepts(ctx, ...types) returns the best match (shorthand like 'json' or a full mime) or false. acceptsCharsets, acceptsLanguages, and acceptsEncodings cover the other Accept-* headers. For conditional GETs, isFresh(reqHeaders, resHeaders), computeEtag(body), and respondJsonWithEtag(ctx, body) give you ETag / If-None-Match → 304 handling.

Background jobs

In-process FIFO queues with a worker pool, retries, and a dead-letter list. Register a worker on the app; enqueue from any handler via ctx.queue(name):

const app = ingenium()

app.queue('emails', { concurrency: 4, retries: 3 }, async (job) => {
  await sendEmail(job.data)              // throw → retried per the backoff policy
})

app.post('/signup', async (ctx) => {
  const user = await createUser(await ctx.body.json())
  await ctx.queue('emails').add({ to: user.email, body: 'Welcome!' })
  return ctx.json({ ok: true }, 201)     // returns now; the email runs in the background
})

retries is { attempts, backoffMs(attempt) } or a number shorthand (default 3 attempts at 100 ms / 400 ms / 1.6 s, exponential). A job that exhausts its retries moves to the dead-letter list and fires onFailed. The default MemoryQueueStore keeps everything in process; implement the QueueStore interface — or use RedisQueueStore from ingenium-redis — to persist across instances.

Cron scheduling

app.cron('0 */15 * * *', () => refreshCaches())                 // every 15 minutes (UTC)
app.cron('0 0 * * 0', { timezone: 'America/Los_Angeles' }, weeklyReport)

Standard 5-field cron with an IANA timezone (default UTC). Handlers receive { firedAt, now } so they can detect drift. overlap: 'skip' (default) drops a tick while the previous run is still in flight; 'queue' holds exactly one pending run. The wake timer is unref()'d, so a cron alone never keeps the event loop alive — an app with an HTTP listener runs normally, a standalone script exits when its other work finishes.

OpenAPI 3.1 generation

Generate a spec straight from the registration journal — no decorators, no separate route table:

import { ingenium, generateOpenApi } from 'ingenium'

const spec = generateOpenApi(app, {
  info: { title: 'Notes API', version: '1.0.0' },
  servers: [{ url: 'https://api.example.com' }],
  tagsByPrefix: { '/users': 'users', '/notes': 'notes' },   // auto-tag by path prefix
})

// …or serve it lazily (cache invalidates as routes are added):
app.get('/openapi.json', ingenium.openapiHandler({
  info: { title: 'Notes API', version: '1.0.0' },
}))

Path params (:id{id}, *rest{rest}) and operations are derived automatically. Request/response schemas exposing a toJsonSchema() method (Zod 3.24+, ArkType, Effect Schema) are converted inline; Standard Schema validators that can't be introspected emit an x-schema-source TODO marker. excludePaths hides internal routes.

Sinatra-style top-level

Skip the app object entirely — import the verbs and listen directly. Every call routes to a single lazy default app:

import { get, post, listen } from 'ingenium'

get('/', () => 'hi')
get('/users/:id', (ctx) => ({ id: ctx.params.id }))
post('/echo', async (ctx) => ctx.body.json())

await listen(3000)

get/post/put/patch/delete/head/options, plus use, onError, before, after, and listen, are exported as top-level functions with the same signatures as their app.* counterparts. Handy for scripts and tiny services; reach for an explicit ingenium() once you need more than one app or want to pass it around.


Transports

Node http (default)

const app = ingenium()
const server = await app.listen(3000)

Uses node:http directly. No translation layer to WinterCG Request/Response — adapter writes straight from IncomingMessage to the IngeniumContext, and the IngeniumContext straight to the ServerResponse.

Bun.serve

import { ingenium } from 'ingenium'
import { BunAdapter } from 'ingenium-bun'

const app = ingenium({ transport: new BunAdapter() })
await app.listen(3000)

Wraps Bun.serve() with a Web-Streams ↔ node:stream bridge so existing IngeniumBody parsers work unchanged. Lazy body — request body is not materialized unless ctx.body.* is called.

HTTP/2 (h2 + h2c)

import { ingenium, Http2Adapter, Http2cAdapter } from 'ingenium'
import { readFileSync } from 'node:fs'

// h2c (cleartext HTTP/2)
const app = ingenium({ transport: new Http2cAdapter() })
await app.listen(3000)

// h2 (TLS)
const tlsApp = ingenium({
  transport: new Http2Adapter({
    cert: readFileSync('cert.pem'),
    key: readFileSync('key.pem'),
    allowHttp1: true,           // ALPN fallback to HTTP/1.1
  }),
})
await tlsApp.listen(443)

Built on node:http2. Pseudo-headers (:method, :path, :status, :scheme, :authority) handled internally; user code reads regular headers. Transfer-Encoding: chunked is stripped from responses (HTTP/2 has implicit framing).

WebSocket

WebSocket support is opt-in via the ws peer dependency.

npm install ws @types/ws
import { ingenium, enableWebSockets } from 'ingenium'

const app = ingenium()
enableWebSockets(app)

app.ws('/echo', (sock) => {
  sock.on('message', (msg) => sock.send(msg))
})

// Or hand the underlying http.Server to your own integrator:
app.upgradeWith((httpServer) => {
  // wire up `ws`, socket.io, etc.
})

await app.listen(3000)

Uses WebSocketServer({ noServer: true }) and hooks the upgrade event on the underlying http.Server. Per-path handlers are registered up front; unknown paths get the socket destroyed cleanly.

Graceful shutdown

import { ingenium, gracefulShutdown } from 'ingenium'

const app = ingenium()
const server = await app.listen(3000)

gracefulShutdown(server, {
  gracefulTimeoutMs: 10_000,                  // force-close idle keep-alives after 10s
  signals: ['SIGTERM', 'SIGINT'],
  onShutdown: async () => {
    await db.close()
    await queue.flush()
  },
})

A second signal during shutdown → immediate exit(1) (force quit). Without graceful shutdown wired, your server dies immediately on SIGTERM and in-flight requests are dropped. Every production deployment needs this.


Express compatibility shim

npm install ingenium ingenium-compat cors helmet
import { ingenium } from 'ingenium'
import { expressCompat } from 'ingenium-compat'
import cors from 'cors'
import helmet from 'helmet'

const app = ingenium()
app.use(expressCompat(cors({ origin: 'https://app.example.com' })))
app.use(expressCompat(helmet()))

The shim wraps (req, res, next) middleware so it can run inside an Ingenium middleware chain. The shims are real Node streamsreq extends stream.Readable, res extends stream.Writable (a real EventEmitter) — wired to the IngeniumContext, so body-reading and response-transforming middleware are genuine drop-ins. Header/status proxy live to the context and the request body is lazy, so header-only middleware pay near-zero overhead.

Compatibility status (validated end-to-end in packages/ingenium-compat/test/e2e.test.ts):

Middleware Status Notes
cors supported full feature parity
helmet supported full feature parity
cookie-parser supported req.cookies populated, mirrored to ctx.state.cookies
morgan supported end-of-request tokens (:status, :response-time) fire on res finish
express-rate-limit supported req.ip populated — no custom keyGenerator needed
compression supported downstream response replayed through the patched res; body gzipped, Content-Encoding set
body-parser supported reads the real request stream; req.bodyctx.state.body
passport.initialize supported req._passport propagates to ctx.state
passport.authenticate partial res.redirect/cookie writes work; session-backed strategies need a session store
express-session supported Set-Cookie via on-headers + save on res.end
multer supported req.pipe(busboy) works; req.filectx.state

Full matrix and internals in packages/ingenium-compat/COMPATIBILITY.md.


CLI scaffolder

npm install -g ingenium-cli

ingenium new my-api                      # default template
ingenium new my-bun-api --bun            # uses BunAdapter
ingenium new tiny --minimal              # 10-line hello world
ingenium new my-api --force              # overwrite existing dir
ingenium --version
ingenium --help

Templates ship with: package.json, tsconfig.json, .gitignore, src/index.ts, README.md. Zero runtime dependencies (only Node built-ins). Requires Node 22+ (uses --experimental-strip-types).


Schema validation

ctx.body.json(schema) accepts three validator shapes, detected in this order:

// 1. Standard Schema v1 (any validator with ["~standard"])
import { type } from 'arktype'
const User = type({ name: 'string', email: 'string' })
app.post('/users', async (ctx) => ctx.body.json(User))

// 2. Zod-like safeParse
import { z } from 'zod'
const User = z.object({ name: z.string(), email: z.string().email() })
app.post('/users', async (ctx) => ctx.body.json(User))

// 3. Plain { parse(input): T }
const User = {
  parse(input: unknown): { name: string } {
    if (typeof input !== 'object') throw new Error('expected object')
    return input as { name: string }
  },
}
app.post('/users', async (ctx) => ctx.body.json(User))

All three throw IngeniumValidationError with a fields: Record<string, string> map on failure. Standard Schema v1 issues with structured paths are dot-joined (['user', 'email']'user.email').


Testing with app.inject()

Drop the ephemeral-port dance for unit tests — dispatch a synthetic request straight through the framework:

const app = ingenium()
app.post('/users', async (ctx) => ctx.json(await ctx.body.json(), 201))

const res = await app.inject({
  method: 'POST',
  url: '/users',
  body: { name: 'Ada' },
})

expect(res.status).toBe(201)
expect(res.json()).toEqual({ name: 'Ada' })

Body conversion: string → UTF-8 buffer, Buffer / Uint8Array → verbatim, plain object/array → JSON.stringify + auto content-type: application/json. Streams are drained into a UTF-8 string for res.body. Same composed middleware + hooks + error boundary as the wire path; ~10× faster than ephemeral-port tests.


Plugin scoping with app.scope()

Mount plugins (and their middleware) onto a subtree:

app.scope('/api/v2', (scope) => {
  scope.use(requireAuth)           // only /api/v2/*
  scope.register(metricsPlugin)    // plugin's use() / get() / etc. are prefix-relative
  scope.get('/users', listUsers)   // → /api/v2/users
})

Compose-time resolution — the per-request hot path is unchanged. The same IngeniumPlugin shape works on both the root app and a scope (both implement PluginTarget). Decorators called from inside a scope still register globally and emit a dev-mode warning explaining why (the lazy decorator runs before the route is matched). For per-route auth, prefer scope.use(authMw) over scope.decorate(...).


Reference application

apps/notes-api/ is a small but realistic notes service that exercises the full feature surface:

  • Bearer-token auth via a plugin (app.register(authPlugin))
  • app.decorate('user', ...) lazy decorator + requireAuth middleware
  • Pino logger plugin with onRequest / onResponse hooks
  • SQLite persistence (better-sqlite3) with FTS5 full-text search
  • Mounted routers (/api/users, /api/notes, /api/health)
  • Zod-validated request bodies via ctx.body.json(Schema)
  • Custom error boundary with status-aware responses
  • Real HTTP integration tests on ephemeral ports

Run it:

cd apps/notes-api
npm install
npm run dev

Packages

Package Description
ingenium Core framework — ingenium(), Router, IngeniumContext, plugins, static, CORS, SSE, rate-limit, sessions, multipart, cookies, jobs, cron, OpenAPI, transports
ingenium-auth jwtMiddleware, apiKeyMiddleware — JWT (HMAC / asymmetric / JWKS) and API-key authentication
ingenium-compat expressCompat(mw) shim for (req, res, next) middleware
ingenium-bun BunAdapter — drop-in transport for Bun.serve()
ingenium-cli ingenium new <name> [--bun|--minimal] scaffolder
ingenium-redis RedisSessionStore, RedisIdempotencyStore, RedisRateLimitStore, RedisQueueStore — required for multi-instance deployments

Each package is independently publishable to npm.


Examples

Example Demonstrates
examples/learn Start here. 8-step progressive tutorial — one concept per file, ~30 minutes end-to-end
examples/basic Hello world, params, body, error handler, graceful shutdown, static files, decorator
examples/migrate-from-express Express version + Ingenium version side by side, identical routes
examples/with-plugin Custom auth plugin, decorator, hooks, module augmentation
examples/with-bun BunAdapter for Bun.serve()

Architecture and design notes

Five Architecture Decision Records document the load-bearing choices:

  1. ADR 0001 — Radix trie router: why we chose a radix trie over Express's linear scan or Hono's smart router
  2. ADR 0002 — Lazy composition with a dirty bit: how registration is journaled and composed lazily, and why we don't freeze on listen
  3. ADR 0003 — Handler return-value reflection: why handlers return values instead of calling res.send
  4. ADR 0004 — Context object pool: per-request pooling for GC pressure, V8 hidden class stability
  5. ADR 0005 — Express compat shim scope: what's in, what's out, and why

Repo layout

ingenium/
├── packages/
│   ├── ingenium/              # core (router, context, middleware, jobs, cron, OpenAPI)
│   ├── ingenium-auth/         # JWT + API-key authentication
│   ├── ingenium-compat/       # Express middleware shim
│   ├── ingenium-bun/          # Bun.serve adapter
│   ├── ingenium-redis/        # Redis stores (sessions, idempotency, rate-limit, job queue)
│   └── ingenium-cli/          # ingenium new scaffolder
├── apps/
│   └── notes-api/                # reference CRUD service
├── examples/
│   ├── basic/
│   ├── migrate-from-express/
│   ├── with-plugin/
│   └── with-bun/
├── benchmarks/
│   ├── scenarios/                # v1 — in-process (deprecated)
│   └── scenarios/v2/             # separate-process bench vs Express/Fastify/Hono
├── docs/
│   ├── migration-guide.md
│   ├── plugins.md
│   ├── roadmap.md
│   └── adr/                      # 0001-0005
├── .github/workflows/
│   ├── ci.yml                    # Node 20/22/24 × ubuntu/windows
│   ├── bench.yml                 # nightly bench, artifact upload
│   ├── audit.yml                 # banned-packages scan
│   ├── publish.yml               # manual, alpha-gated npm publish
│   └── release.yml               # tag-driven GitHub Release
├── README.md, API.md, CHANGELOG.md, CONTRIBUTING.md, SECURITY.md, LICENSE
├── tsconfig.base.json, tsconfig.json
├── vitest.config.ts
└── package.json (npm workspaces)

Development

git clone https://github.com/IngeniumFramework/Ingenium.git
cd ingenium
npm install

npm run typecheck          # tsc --noEmit across all workspaces
npm test                   # vitest run

# build the publishable core to dist/
npm run build --workspace packages/ingenium

# run a benchmark scenario
cd benchmarks
npx tsx scenarios/v2/hello.ts

CI runs typecheck + tests on every push, matrix Node 20/22/24 × ubuntu-latest/windows-latest. Bench runs nightly on Node 22 ubuntu-latest, output uploaded as a workflow artifact.


Roadmap and known gaps

See docs/roadmap.md for the full breakdown. Highlights:

Shipped in v0.0.1:

  • All deliverables 1–10 from the original session plan
  • HTTP/2, WebSocket, SSE, rate limit, session, multipart, trust-proxy
  • CI matrix, ADRs, reference app, governance bundle

Known issues:

  • ExtractParams doesn't narrow constrained params (:id(\\d+) stays string). Unconstrained params (:id) now narrow correctly through the verb overloads.
  • ctx.query.parse(schema) uses a fixed shallow-array-aware coercion model — see the Schema validation section for the trade-offs

Deferred to next session:

  • TypeBox-specific bridge (Standard Schema covers it but a tighter integration could be cleaner)
  • Scoped decorators (app.scope() scopes middleware today; decorators remain global — see app.scope() JSDoc for the rationale)

Contributing

Read CONTRIBUTING.md and the relevant ADR before opening a PR that changes a load-bearing design choice. Bug reports and design feedback welcome via GitHub Issues. Use SECURITY.md for vulnerability reports — do not file them as public issues.


License

MIT © Ingenium contributors.

About

A typed HTTP framework for Node 20+ and Bun 1.1+. Express-shaped API, radix-trie router, pooled context, lazy body parsing, return-value reflection, and built-in CORS / CSRF / sessions / rate-limit / SSE / HTTP/2 / WebSocket. Migrate from Express in an afternoon.

Resources

License

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors