ingenium 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +943 -0
- package/dist/index.cjs +7078 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +4262 -0
- package/dist/index.d.ts +4262 -0
- package/dist/index.js +6963 -0
- package/dist/index.js.map +1 -0
- package/package.json +47 -0
- package/src/api-key/middleware.ts +157 -0
- package/src/api-key/types.ts +37 -0
- package/src/app/scope.ts +392 -0
- package/src/app.ts +1752 -0
- package/src/body/limit.ts +21 -0
- package/src/body/middleware.ts +30 -0
- package/src/body/multipart-types.ts +40 -0
- package/src/body/multipart.ts +254 -0
- package/src/context/body.ts +324 -0
- package/src/context/context.ts +650 -0
- package/src/context/cookies.ts +282 -0
- package/src/context/pool.ts +32 -0
- package/src/cors/middleware.ts +182 -0
- package/src/cors/types.ts +79 -0
- package/src/cron/parser.ts +311 -0
- package/src/cron/registry.ts +49 -0
- package/src/cron/scheduler.ts +153 -0
- package/src/csrf/middleware.ts +224 -0
- package/src/csrf/types.ts +65 -0
- package/src/errors.ts +148 -0
- package/src/idempotency/middleware.ts +197 -0
- package/src/idempotency/store.ts +70 -0
- package/src/idempotency/types.ts +87 -0
- package/src/index.ts +328 -0
- package/src/jobs/queue.ts +306 -0
- package/src/jobs/registry.ts +82 -0
- package/src/jobs/store-memory.ts +113 -0
- package/src/jobs/types.ts +135 -0
- package/src/jwt/jwks.ts +143 -0
- package/src/jwt/middleware.ts +313 -0
- package/src/jwt/types.ts +137 -0
- package/src/jwt/verify.ts +370 -0
- package/src/middleware/compose.ts +94 -0
- package/src/middleware/types.ts +37 -0
- package/src/negotiation/accept.ts +159 -0
- package/src/negotiation/etag.ts +30 -0
- package/src/negotiation/format.ts +88 -0
- package/src/negotiation/fresh.ts +89 -0
- package/src/negotiation/json-etag.ts +122 -0
- package/src/negotiation/negotiate.ts +97 -0
- package/src/openapi/describe.ts +79 -0
- package/src/openapi/extract-params.ts +62 -0
- package/src/openapi/generate.ts +251 -0
- package/src/openapi/handler.ts +73 -0
- package/src/openapi/types.ts +145 -0
- package/src/plugin/decorators.ts +100 -0
- package/src/plugin/hooks.ts +114 -0
- package/src/plugin/types.ts +189 -0
- package/src/problem/middleware.ts +55 -0
- package/src/problem/serialize.ts +121 -0
- package/src/problem/types.ts +68 -0
- package/src/proxy/trust.ts +247 -0
- package/src/rate-limit/middleware.ts +72 -0
- package/src/rate-limit/store.ts +129 -0
- package/src/rate-limit/types.ts +60 -0
- package/src/response/reflect.ts +93 -0
- package/src/router/router.ts +284 -0
- package/src/router/trie.ts +309 -0
- package/src/router/types.ts +54 -0
- package/src/schema/standard.ts +67 -0
- package/src/session/middleware.ts +379 -0
- package/src/session/store-memory.ts +79 -0
- package/src/session/types.ts +95 -0
- package/src/sinatra/filters.ts +129 -0
- package/src/sinatra/top-level.ts +151 -0
- package/src/sse/keep-alive.ts +52 -0
- package/src/sse/sse.ts +115 -0
- package/src/static/middleware.ts +254 -0
- package/src/static/types.ts +31 -0
- package/src/transport/http2-helpers.ts +242 -0
- package/src/transport/http2.ts +316 -0
- package/src/transport/node.ts +261 -0
- package/src/transport/shutdown.ts +86 -0
- package/src/transport/types.ts +72 -0
- package/src/util/safe-json.ts +66 -0
- package/src/ws/index.ts +164 -0
- package/src/ws/middleware.ts +178 -0
- package/src/ws/types.ts +52 -0
- package/src/ws/ws-node-adapter.ts +162 -0
package/README.md
ADDED
|
@@ -0,0 +1,943 @@
|
|
|
1
|
+
# Ingenium
|
|
2
|
+
|
|
3
|
+
> Express ergonomics, Hono/Fastify-class throughput. A typed HTTP framework for Node 20+ and Bun 1.1+.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
___ _
|
|
7
|
+
|_ _|_ __ __ _ ___ _ __ (_)_ _ _ __ ___
|
|
8
|
+
| || '_ \ / _` |/ _ \ '_ \| | | | | '_ ` _ \
|
|
9
|
+
| || | | | (_| | __/ | | | | |_| | | | | | |
|
|
10
|
+
|___|_| |_|\__, |\___|_| |_|_|\__,_|_| |_| |_|
|
|
11
|
+
|___/
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
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.
|
|
15
|
+
|
|
16
|
+
**Status: alpha (v0.0.1).** 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.
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Table of contents
|
|
21
|
+
|
|
22
|
+
- [Show me the code](#show-me-the-code)
|
|
23
|
+
- [Why Ingenium](#why-ingenium)
|
|
24
|
+
- [Install](#install)
|
|
25
|
+
- [The 5-minute Express → Ingenium diff](#the-5-minute-express--ingenium-diff)
|
|
26
|
+
- [Core concepts](#core-concepts)
|
|
27
|
+
- [App + Router](#app--router)
|
|
28
|
+
- [IngeniumContext](#ingeniumcontext)
|
|
29
|
+
- [Middleware](#middleware)
|
|
30
|
+
- [Body parsing](#body-parsing)
|
|
31
|
+
- [Response reflection](#response-reflection)
|
|
32
|
+
- [Errors](#errors)
|
|
33
|
+
- [Plugins](#plugins)
|
|
34
|
+
- [Trust-proxy](#trust-proxy)
|
|
35
|
+
- [Built-in middleware](#built-in-middleware)
|
|
36
|
+
- [`ingenium.json` / `ingenium.urlencoded`](#ingeniumjson--ingeniumurlencoded)
|
|
37
|
+
- [`ingenium.static`](#ingeniumstatic)
|
|
38
|
+
- [`ingenium.cors`](#ingeniumcors)
|
|
39
|
+
- [`ingenium.sse` (Server-Sent Events)](#ingeniumsse-server-sent-events)
|
|
40
|
+
- [`ingenium.rateLimit`](#ingeniumratelimit)
|
|
41
|
+
- [`ingenium.csrf`](#ingeniumcsrf)
|
|
42
|
+
- [`sessionMiddleware`](#sessionmiddleware)
|
|
43
|
+
- [Transports](#transports)
|
|
44
|
+
- [Node `http` (default)](#node-http-default)
|
|
45
|
+
- [Bun.serve](#bunserve)
|
|
46
|
+
- [HTTP/2 (h2 + h2c)](#http2-h2--h2c)
|
|
47
|
+
- [WebSocket](#websocket)
|
|
48
|
+
- [Graceful shutdown](#graceful-shutdown)
|
|
49
|
+
- [Express compatibility shim](#express-compatibility-shim)
|
|
50
|
+
- [CLI scaffolder](#cli-scaffolder)
|
|
51
|
+
- [Schema validation](#schema-validation)
|
|
52
|
+
- [Reference application](#reference-application)
|
|
53
|
+
- [Packages](#packages)
|
|
54
|
+
- [Examples](#examples)
|
|
55
|
+
- [Architecture and design notes](#architecture-and-design-notes)
|
|
56
|
+
- [Repo layout](#repo-layout)
|
|
57
|
+
- [Development](#development)
|
|
58
|
+
- [Roadmap and known gaps](#roadmap-and-known-gaps)
|
|
59
|
+
- [Contributing](#contributing)
|
|
60
|
+
- [License](#license)
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## Show me the code
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
import { ingenium } from 'ingenium'
|
|
68
|
+
|
|
69
|
+
const app = ingenium()
|
|
70
|
+
|
|
71
|
+
app.use(async (ctx, next) => {
|
|
72
|
+
const start = Date.now()
|
|
73
|
+
await next()
|
|
74
|
+
console.log(`${ctx.method} ${ctx.path} -> ${ctx._statusCode} ${Date.now() - start}ms`)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
app.get('/', () => 'hello')
|
|
78
|
+
app.get('/users/:id', (ctx) => ({ id: ctx.params.id }))
|
|
79
|
+
app.post('/echo', async (ctx) => ctx.body.json())
|
|
80
|
+
|
|
81
|
+
const server = await app.listen(3000)
|
|
82
|
+
console.log(`listening on http://localhost:${server.port}`)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
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.
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## Why Ingenium
|
|
90
|
+
|
|
91
|
+
| Pain point | Express | Hono / Fastify | Ingenium |
|
|
92
|
+
|---|---|---|---|
|
|
93
|
+
| Router speed at 1000 routes | O(n) linear scan | O(k) trie | O(k) radix trie + wildcard backtrack |
|
|
94
|
+
| `req` / `res` types | `any` in practice | strict, but unfamiliar surface | strict, Express-shaped |
|
|
95
|
+
| Per-request allocation | new `req`/`res`/`next` each request | varies | pooled `IngeniumContext`, lazy getters |
|
|
96
|
+
| Middleware composition | re-walked per request | compose-on-register | lazy compose with dirty-bit recompose |
|
|
97
|
+
| Body parsing | `body-parser` middleware always runs | always-on parsing | lazy via `ctx.body.json()` |
|
|
98
|
+
| Default body size limit | 100 KB (`body-parser`) | varies | 100 KB (matches Express) |
|
|
99
|
+
| Bun support | community shim | varies | first-class adapter |
|
|
100
|
+
| Migration cost from Express | n/a | high | low |
|
|
101
|
+
|
|
102
|
+
The pitch in one sentence: **the shortest path from a working Express app to throughput competitive with Hono and Fastify.**
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## Production hardening
|
|
107
|
+
|
|
108
|
+
Native primitives an API team actually needs in prod, all opt-in:
|
|
109
|
+
|
|
110
|
+
| Concern | Surface | Why it matters |
|
|
111
|
+
|---|---|---|
|
|
112
|
+
| Per-request timeout ceiling | `ingenium({ requestTimeoutMs: 30_000 })` → `IngeniumTimeoutError` (503) | A handler that never resolves leaks the context, socket, and pool slot forever. |
|
|
113
|
+
| 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. |
|
|
114
|
+
| 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. |
|
|
115
|
+
| `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. |
|
|
116
|
+
| Idempotency-Key — skip caching 5xx | `ingenium.idempotency({ cacheable: (s) => s < 500 })` (default) | A transient 500 no longer gets replayed for the entire TTL. |
|
|
117
|
+
| 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. |
|
|
118
|
+
| Asymmetric JWT (RS/PS/ES + JWKS) | `ingenium.jwt({ algorithms: ['RS256'], jwksUrl: '...' })` | Required for any IdP with a JWKS endpoint (Auth0, Okta, Cognito, Clerk, Supabase). Algorithm-confusion attacks blocked at the allowlist. `'none'` rejected unconditionally. |
|
|
119
|
+
| 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. |
|
|
120
|
+
|
|
121
|
+
Wire all of these in production:
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
import {
|
|
125
|
+
ingenium, sessionMiddleware, gracefulShutdown,
|
|
126
|
+
IdempotencyMemoryStore,
|
|
127
|
+
} from 'ingenium'
|
|
128
|
+
|
|
129
|
+
const app = ingenium({
|
|
130
|
+
trustProxy: 'loopback', // behind nginx / Caddy / etc.
|
|
131
|
+
requestTimeoutMs: 30_000, // hung-handler protection
|
|
132
|
+
maxRequestBytes: 2 * 1024 * 1024, // 2 MiB body ceiling
|
|
133
|
+
poolSize: 4096,
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
app.use(ingenium.cors({ origin: 'https://app.example.com', credentials: true }))
|
|
137
|
+
app.use(ingenium.csrf({ secret: process.env.CSRF_SECRET! }))
|
|
138
|
+
app.use(sessionMiddleware({ secret: [process.env.SESSION_SECRET!] }))
|
|
139
|
+
app.use(ingenium.rateLimit({ windowMs: 60_000, limit: 100 }))
|
|
140
|
+
app.use(ingenium.idempotency({ store: new IdempotencyMemoryStore() })) // swap for RedisIdempotencyStore (ingenium-redis) for multi-instance
|
|
141
|
+
app.use(ingenium.problemDetails({ typeBaseUrl: 'https://api.example.com/errors/' }))
|
|
142
|
+
app.use(ingenium.jwt({
|
|
143
|
+
algorithms: ['RS256'],
|
|
144
|
+
jwksUrl: 'https://example.auth0.com/.well-known/jwks.json',
|
|
145
|
+
issuer: 'https://example.auth0.com/',
|
|
146
|
+
audience: 'https://api.example.com',
|
|
147
|
+
}))
|
|
148
|
+
|
|
149
|
+
const server = await app.listen(cfg.PORT, '0.0.0.0')
|
|
150
|
+
gracefulShutdown(server, { gracefulTimeoutMs: 10_000, onShutdown: () => db.close() })
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
> **Multi-instance deploys:** swap the in-memory stores for the Redis-backed ones in [`ingenium-redis`](packages/ingenium-redis). One `createClient()` instance, four drop-in stores (sessions, idempotency, rate-limit, job queue), no API changes elsewhere. See the package [README](packages/ingenium-redis/README.md) for the wire-in.
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
## Install
|
|
158
|
+
|
|
159
|
+
```sh
|
|
160
|
+
npm install ingenium
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Optional packages by use case:
|
|
164
|
+
|
|
165
|
+
```sh
|
|
166
|
+
# Bun.serve adapter
|
|
167
|
+
npm install ingenium ingenium-bun
|
|
168
|
+
|
|
169
|
+
# Express middleware compatibility (cors, helmet, etc.)
|
|
170
|
+
npm install ingenium ingenium-compat
|
|
171
|
+
|
|
172
|
+
# Redis stores for multi-instance prod (sessions, idempotency, rate-limit)
|
|
173
|
+
npm install ingenium ingenium-redis redis
|
|
174
|
+
|
|
175
|
+
# Project scaffolder
|
|
176
|
+
npm install -g ingenium-cli
|
|
177
|
+
ingenium new my-api
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
**Requirements:** Node 20+. Bun 1.1+ for the Bun adapter. WebSocket support requires installing `ws` as a peer dep.
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
## The 5-minute Express → Ingenium diff
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
// Express // Ingenium
|
|
188
|
+
import express from 'express' import { ingenium } from 'ingenium'
|
|
189
|
+
const app = express() const app = ingenium()
|
|
190
|
+
|
|
191
|
+
app.use(express.json()) app.use(ingenium.json()) // (no-op, parsing is lazy)
|
|
192
|
+
|
|
193
|
+
app.use((req, res, next) => { app.use(async (ctx, next) => {
|
|
194
|
+
req.startedAt = Date.now() ctx.state.startedAt = Date.now()
|
|
195
|
+
next() await next()
|
|
196
|
+
}) })
|
|
197
|
+
|
|
198
|
+
app.get('/users/:id', (req, res) => { app.get('/users/:id', (ctx) =>
|
|
199
|
+
res.json({ id: req.params.id }) ({ id: ctx.params.id }))
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
app.post('/users', (req, res) => { app.post('/users', async (ctx) => {
|
|
203
|
+
const body = req.body const body = await ctx.body.json()
|
|
204
|
+
// ... // ...
|
|
205
|
+
res.status(201).json(user) return ctx.json(user, 201)
|
|
206
|
+
}) })
|
|
207
|
+
|
|
208
|
+
const router = express.Router() const router = ingenium.Router()
|
|
209
|
+
router.get('/health', (req, res) => router.get('/health', () => ({ ok: 1 }))
|
|
210
|
+
res.json({ok:1}))
|
|
211
|
+
app.use('/api', router) app.use('/api', router)
|
|
212
|
+
|
|
213
|
+
app.use((err, req, res, next) => { app.onError((err, ctx) => {
|
|
214
|
+
res.status(500).json({err: err.message}) ctx.json({ err: err.message }, 500)
|
|
215
|
+
}) })
|
|
216
|
+
|
|
217
|
+
app.listen(3000) await app.listen(3000)
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Breakable changes:
|
|
221
|
+
1. **Handlers may return values.** `return obj` is `res.json(obj)`; `return 'text'` is `res.text(...)`. Calling `ctx.json(...)` explicitly still works.
|
|
222
|
+
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.
|
|
223
|
+
3. **`ctx.state` is the per-request scratch space**, not `ctx.user = ...` directly (though plugins can decorate `ctx` to enable that).
|
|
224
|
+
|
|
225
|
+
That's the whole list. Everything else from the Express mental model carries over verbatim.
|
|
226
|
+
|
|
227
|
+
---
|
|
228
|
+
|
|
229
|
+
## Core concepts
|
|
230
|
+
|
|
231
|
+
### App + Router
|
|
232
|
+
|
|
233
|
+
```ts
|
|
234
|
+
import { ingenium, Router } from 'ingenium'
|
|
235
|
+
|
|
236
|
+
const app = ingenium({ poolSize: 1024, trustProxy: false })
|
|
237
|
+
|
|
238
|
+
// HTTP methods — same surface as Express
|
|
239
|
+
app.get('/', handler)
|
|
240
|
+
app.post('/users', handler)
|
|
241
|
+
app.put('/users/:id', handler)
|
|
242
|
+
app.patch('/users/:id', handler)
|
|
243
|
+
app.delete('/users/:id', handler)
|
|
244
|
+
app.head('/users/:id', handler)
|
|
245
|
+
app.options('/users/:id', handler)
|
|
246
|
+
app.method('OPTIONS', '/anywhere', handler) // any method by string
|
|
247
|
+
|
|
248
|
+
// Mountable routers
|
|
249
|
+
const api = Router()
|
|
250
|
+
api.get('/health', () => ({ ok: true }))
|
|
251
|
+
api.use('/notes', notesRouter) // routers can mount routers
|
|
252
|
+
app.use('/api', api)
|
|
253
|
+
|
|
254
|
+
// Middleware
|
|
255
|
+
app.use(globalMiddleware)
|
|
256
|
+
app.use('/admin', adminOnlyMiddleware)
|
|
257
|
+
app.use('/admin', adminRouter) // mount middleware OR router
|
|
258
|
+
|
|
259
|
+
// Lifecycle
|
|
260
|
+
await app.compose() // pre-warm; runs lazily on first request otherwise
|
|
261
|
+
const server = await app.listen(3000, '0.0.0.0')
|
|
262
|
+
await server.close({ gracefulTimeoutMs: 10_000 })
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
**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.
|
|
266
|
+
|
|
267
|
+
**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.
|
|
268
|
+
|
|
269
|
+
### IngeniumContext
|
|
270
|
+
|
|
271
|
+
```ts
|
|
272
|
+
class IngeniumContext<Params = Record<string, string>> {
|
|
273
|
+
// Request
|
|
274
|
+
method: HttpMethod // 'GET' | 'POST' | ...
|
|
275
|
+
url: string // path + ?query
|
|
276
|
+
path: string // no query
|
|
277
|
+
rawQuery: string
|
|
278
|
+
query: URLSearchParams // lazy
|
|
279
|
+
params: Params // route params
|
|
280
|
+
headers: IncomingHttpHeaders // lowercased per Node convention
|
|
281
|
+
body: IngeniumBody // lazy parsers
|
|
282
|
+
state: Record<string, unknown> // per-request scratch
|
|
283
|
+
|
|
284
|
+
// Network info (trust-proxy aware)
|
|
285
|
+
ip: string // client IP (XFF-aware if trustProxy enabled)
|
|
286
|
+
ips: readonly string[] // full forwarded chain
|
|
287
|
+
protocol: 'http' | 'https'
|
|
288
|
+
secure: boolean
|
|
289
|
+
hostname: string
|
|
290
|
+
remoteAddress: string // immediate socket peer
|
|
291
|
+
baseProtocol: 'http' | 'https' // underlying transport
|
|
292
|
+
|
|
293
|
+
// Response setters (chainable)
|
|
294
|
+
status(code: number): this
|
|
295
|
+
set(name: string, value: string | string[]): this
|
|
296
|
+
setHeader(name: string, value: string | string[]): this
|
|
297
|
+
getHeader(name: string): string | string[] | undefined
|
|
298
|
+
|
|
299
|
+
// Response writers
|
|
300
|
+
json(body: unknown, status?: number): void
|
|
301
|
+
text(body: string, status?: number): void
|
|
302
|
+
html(body: string, status?: number): void
|
|
303
|
+
send(body: Buffer | string, status?: number): void
|
|
304
|
+
redirect(location: string, status?: number): void // default 302
|
|
305
|
+
stream(readable: Readable, contentType?: string): void
|
|
306
|
+
}
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
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.
|
|
310
|
+
|
|
311
|
+
### Middleware
|
|
312
|
+
|
|
313
|
+
```ts
|
|
314
|
+
type IngeniumMiddleware = (ctx: IngeniumContext, next: () => Promise<void>) => unknown | Promise<unknown>
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
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`.
|
|
318
|
+
|
|
319
|
+
### Body parsing
|
|
320
|
+
|
|
321
|
+
```ts
|
|
322
|
+
ctx.body.json<T>(schema?, maxBytes?: number): Promise<T>
|
|
323
|
+
ctx.body.text(maxBytes?: number): Promise<string>
|
|
324
|
+
ctx.body.urlencoded(maxBytes?: number): Promise<Record<string, string>>
|
|
325
|
+
ctx.body.buffer(maxBytes?: number): Promise<Buffer>
|
|
326
|
+
ctx.body.stream(): Readable
|
|
327
|
+
ctx.body.multipart(opts?: MultipartOptions): Promise<MultipartResult>
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
Default `maxBytes` is **100,000** (matches Express's `body-parser` default). Override per-call. The schema arg accepts:
|
|
331
|
+
1. Standard Schema v1 (any validator that exposes `["~standard"]`)
|
|
332
|
+
2. Zod-like `safeParse(input)`
|
|
333
|
+
3. Plain `parse(input): T`
|
|
334
|
+
|
|
335
|
+
Validation failures throw `IngeniumValidationError` with a `fields` map. Body-too-large throws `IngeniumPayloadTooLargeError` mid-stream (no post-buffer rejection).
|
|
336
|
+
|
|
337
|
+
### Response reflection
|
|
338
|
+
|
|
339
|
+
| Return value | Wire output |
|
|
340
|
+
|------------------------|------------------------------|
|
|
341
|
+
| `undefined` / `null` | 204 No Content |
|
|
342
|
+
| `string` starting `<` | 200 text/html |
|
|
343
|
+
| other `string` | 200 text/plain |
|
|
344
|
+
| `Buffer` / `Uint8Array`| 200 application/octet-stream |
|
|
345
|
+
| `Readable` | 200 streamed |
|
|
346
|
+
| any object/array | 200 application/json |
|
|
347
|
+
|
|
348
|
+
If a `ctx.json/text/html/send/redirect/stream` helper has been called, the return value is ignored.
|
|
349
|
+
|
|
350
|
+
### Errors
|
|
351
|
+
|
|
352
|
+
```ts
|
|
353
|
+
import {
|
|
354
|
+
IngeniumError,
|
|
355
|
+
IngeniumNotFoundError, // 404
|
|
356
|
+
IngeniumUnauthorizedError, // 401
|
|
357
|
+
IngeniumMethodNotAllowedError,// 405 (auto-thrown on path match + method miss)
|
|
358
|
+
IngeniumPayloadTooLargeError, // 413
|
|
359
|
+
IngeniumValidationError, // 422 with .fields
|
|
360
|
+
IngeniumBadRequestError, // 400
|
|
361
|
+
} from 'ingenium'
|
|
362
|
+
|
|
363
|
+
app.onError((err, ctx) => {
|
|
364
|
+
if (err instanceof IngeniumValidationError) {
|
|
365
|
+
return ctx.json({ error: err.message, fields: err.fields }, 422)
|
|
366
|
+
}
|
|
367
|
+
if (err instanceof IngeniumError) throw err // delegate to default boundary
|
|
368
|
+
ctx.json({ error: 'internal' }, 500)
|
|
369
|
+
})
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
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.
|
|
373
|
+
|
|
374
|
+
### Plugins
|
|
375
|
+
|
|
376
|
+
```ts
|
|
377
|
+
import { ingenium, type IngeniumPlugin } from 'ingenium'
|
|
378
|
+
|
|
379
|
+
interface User { id: string; email: string }
|
|
380
|
+
|
|
381
|
+
const auth: IngeniumPlugin<{ secret: string }> = (app, opts) => {
|
|
382
|
+
app.decorate('user', async (ctx) => {
|
|
383
|
+
const token = ctx.headers.authorization?.split(' ')[1]
|
|
384
|
+
if (!token) throw new IngeniumUnauthorizedError()
|
|
385
|
+
return verifyToken(token, opts.secret) as User
|
|
386
|
+
})
|
|
387
|
+
app.hooks.onRequest((ctx) => {
|
|
388
|
+
ctx.state.requestId = crypto.randomUUID()
|
|
389
|
+
})
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const app = ingenium()
|
|
393
|
+
await app.register(auth, { secret: process.env.JWT_SECRET! })
|
|
394
|
+
|
|
395
|
+
declare module 'ingenium' {
|
|
396
|
+
interface IngeniumContext {
|
|
397
|
+
user: User
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
app.get('/me', (ctx) => ctx.user) // typed, lazily resolved on first access
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
Lifecycle hooks: `onRoute`, `onCompose`, `onRequest`, `onResponse`, `onError`. Decorators come in two flavors: lazy (`decorate` — `defineProperty` 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.
|
|
405
|
+
|
|
406
|
+
### Trust-proxy
|
|
407
|
+
|
|
408
|
+
```ts
|
|
409
|
+
const app = ingenium({ trustProxy: 'loopback' })
|
|
410
|
+
|
|
411
|
+
// Then in handlers:
|
|
412
|
+
ctx.ip // real client IP after walking the X-Forwarded-For chain
|
|
413
|
+
ctx.protocol // 'https' if X-Forwarded-Proto: https
|
|
414
|
+
ctx.hostname // X-Forwarded-Host (if set), else Host header
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
Mirrors Express's `app.set('trust proxy', ...)`:
|
|
418
|
+
|
|
419
|
+
| Value | Behavior |
|
|
420
|
+
|---|---|
|
|
421
|
+
| `false` (default) | Never trust XFF — `ctx.ip` is the socket peer |
|
|
422
|
+
| `true` | Trust the entire chain — leftmost XFF entry wins |
|
|
423
|
+
| `number n` | Trust `n` upstream hops |
|
|
424
|
+
| `'loopback'` | Trust 127.0.0.0/8, ::1 |
|
|
425
|
+
| `'linklocal'` | Trust 169.254.0.0/16, fe80::/10 |
|
|
426
|
+
| `'uniquelocal'` | Trust 10/8, 172.16/12, 192.168/16, fc00::/7 |
|
|
427
|
+
| `'10.0.0.0/8'` (CIDR) | Trust matching addresses |
|
|
428
|
+
| `string[]` | Multiple of any of the above |
|
|
429
|
+
| `(ip, hopIdx) => boolean` | Custom predicate |
|
|
430
|
+
|
|
431
|
+
---
|
|
432
|
+
|
|
433
|
+
## Built-in middleware
|
|
434
|
+
|
|
435
|
+
### `ingenium.json` / `ingenium.urlencoded`
|
|
436
|
+
|
|
437
|
+
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.
|
|
438
|
+
|
|
439
|
+
### `ingenium.static`
|
|
440
|
+
|
|
441
|
+
```ts
|
|
442
|
+
app.use(ingenium.static('./public', {
|
|
443
|
+
index: 'index.html', // default; set false to disable
|
|
444
|
+
maxAge: 60_000, // ms — sets Cache-Control: public, max-age=60
|
|
445
|
+
extensions: ['html'], // try /foo + /foo.html when /foo not found
|
|
446
|
+
dotfiles: 'ignore', // 'allow' | 'deny' | 'ignore' (default: ignore → next())
|
|
447
|
+
}))
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
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).
|
|
451
|
+
|
|
452
|
+
### `ingenium.cors`
|
|
453
|
+
|
|
454
|
+
```ts
|
|
455
|
+
app.use(ingenium.cors({
|
|
456
|
+
origin: 'https://app.example.com', // or true | string[] | RegExp | (origin, ctx) => boolean | string | Promise<>
|
|
457
|
+
methods: ['GET', 'POST', 'PUT'], // default: GET HEAD PUT PATCH POST DELETE
|
|
458
|
+
allowedHeaders: ['x-trace-id'], // default: mirror Access-Control-Request-Headers
|
|
459
|
+
exposedHeaders: ['x-trace-id'],
|
|
460
|
+
credentials: true, // throws at construction with origin: '*'
|
|
461
|
+
maxAge: 3600, // preflight cache seconds
|
|
462
|
+
optionsSuccessStatus: 204,
|
|
463
|
+
}))
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
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.
|
|
467
|
+
|
|
468
|
+
### `ingenium.sse` (Server-Sent Events)
|
|
469
|
+
|
|
470
|
+
```ts
|
|
471
|
+
import { ingenium, sse, startKeepAlive } from 'ingenium'
|
|
472
|
+
|
|
473
|
+
app.get('/events', (ctx) => {
|
|
474
|
+
const stream = sse(ctx)
|
|
475
|
+
startKeepAlive(stream, 15_000)
|
|
476
|
+
|
|
477
|
+
let n = 0
|
|
478
|
+
const timer = setInterval(() => {
|
|
479
|
+
stream.send({ event: 'tick', id: String(n), data: { n: n++ } })
|
|
480
|
+
if (n >= 10) {
|
|
481
|
+
clearInterval(timer)
|
|
482
|
+
stream.close()
|
|
483
|
+
}
|
|
484
|
+
}, 1000)
|
|
485
|
+
})
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
`SseStream` API: `send(event | string)`, `comment(text)`, `close()`, `closed: boolean`. Multi-line `data` is split per spec. Object data is JSON-stringified.
|
|
489
|
+
|
|
490
|
+
### `ingenium.rateLimit`
|
|
491
|
+
|
|
492
|
+
```ts
|
|
493
|
+
app.use(ingenium.rateLimit({
|
|
494
|
+
windowMs: 60_000,
|
|
495
|
+
limit: 100,
|
|
496
|
+
// default keygen reads X-Forwarded-For — make sure trustProxy is set!
|
|
497
|
+
keyGenerator: (ctx) => ctx.ip,
|
|
498
|
+
skip: (ctx) => ctx.path.startsWith('/health'),
|
|
499
|
+
}))
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
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.
|
|
503
|
+
|
|
504
|
+
### `ingenium.csrf`
|
|
505
|
+
|
|
506
|
+
```ts
|
|
507
|
+
import { ingenium } from 'ingenium'
|
|
508
|
+
|
|
509
|
+
const app = ingenium()
|
|
510
|
+
app.use(ingenium.csrf({
|
|
511
|
+
secret: process.env.CSRF_SECRET!, // required for cookie storage
|
|
512
|
+
storage: 'cookie', // 'cookie' (default) | 'session'
|
|
513
|
+
cookie: { sameSite: 'lax', secure: true },
|
|
514
|
+
ignoreMethods: ['GET', 'HEAD', 'OPTIONS', 'TRACE'],
|
|
515
|
+
// skip: (ctx) => ctx.path.startsWith('/api/webhooks/'), // opt-out
|
|
516
|
+
}))
|
|
517
|
+
|
|
518
|
+
app.get('/form', (ctx) => {
|
|
519
|
+
// ctx.csrfToken() returns the current token to embed in HTML / send to a JS client.
|
|
520
|
+
return `<form method="POST" action="/submit">
|
|
521
|
+
<input type="hidden" name="_csrf" value="${ctx.csrfToken()}">
|
|
522
|
+
<button>Submit</button>
|
|
523
|
+
</form>`
|
|
524
|
+
})
|
|
525
|
+
|
|
526
|
+
app.post('/submit', async (ctx) => {
|
|
527
|
+
// CSRF middleware already validated; if we got here the token is good.
|
|
528
|
+
const body = await ctx.body.json()
|
|
529
|
+
return { ok: true, body }
|
|
530
|
+
})
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
Two storage modes:
|
|
534
|
+
- **`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.
|
|
535
|
+
- **`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.
|
|
536
|
+
|
|
537
|
+
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.
|
|
538
|
+
|
|
539
|
+
> **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](docs/api/csrf.md#recommendation).
|
|
540
|
+
|
|
541
|
+
### `sessionMiddleware`
|
|
542
|
+
|
|
543
|
+
```ts
|
|
544
|
+
import { sessionMiddleware, type Session } from 'ingenium'
|
|
545
|
+
|
|
546
|
+
app.use(sessionMiddleware({
|
|
547
|
+
secret: [process.env.SESSION_SECRET!, ...rotatedSecrets],
|
|
548
|
+
cookieName: 'ingenium.sid',
|
|
549
|
+
maxAgeSeconds: 7 * 86_400,
|
|
550
|
+
rolling: false,
|
|
551
|
+
cookie: { secure: true, sameSite: 'lax', httpOnly: true },
|
|
552
|
+
}))
|
|
553
|
+
|
|
554
|
+
declare module 'ingenium' {
|
|
555
|
+
interface IngeniumContext { session: Session }
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
app.post('/login', async (ctx) => {
|
|
559
|
+
const { user } = await ctx.body.json()
|
|
560
|
+
await ctx.session.regenerate() // new id, fresh against fixation
|
|
561
|
+
ctx.session.set('userId', user.id)
|
|
562
|
+
return { ok: true }
|
|
563
|
+
})
|
|
564
|
+
|
|
565
|
+
app.post('/logout', async (ctx) => {
|
|
566
|
+
await ctx.session.destroy()
|
|
567
|
+
return { ok: true }
|
|
568
|
+
})
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
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`).
|
|
572
|
+
|
|
573
|
+
---
|
|
574
|
+
|
|
575
|
+
## Transports
|
|
576
|
+
|
|
577
|
+
### Node `http` (default)
|
|
578
|
+
|
|
579
|
+
```ts
|
|
580
|
+
const app = ingenium()
|
|
581
|
+
const server = await app.listen(3000)
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
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`.
|
|
585
|
+
|
|
586
|
+
### Bun.serve
|
|
587
|
+
|
|
588
|
+
```ts
|
|
589
|
+
import { ingenium } from 'ingenium'
|
|
590
|
+
import { BunAdapter } from 'ingenium-bun'
|
|
591
|
+
|
|
592
|
+
const app = ingenium({ transport: new BunAdapter() })
|
|
593
|
+
await app.listen(3000)
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
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.
|
|
597
|
+
|
|
598
|
+
### HTTP/2 (h2 + h2c)
|
|
599
|
+
|
|
600
|
+
```ts
|
|
601
|
+
import { ingenium, Http2Adapter, Http2cAdapter } from 'ingenium'
|
|
602
|
+
import { readFileSync } from 'node:fs'
|
|
603
|
+
|
|
604
|
+
// h2c (cleartext HTTP/2)
|
|
605
|
+
const app = ingenium({ transport: new Http2cAdapter() })
|
|
606
|
+
await app.listen(3000)
|
|
607
|
+
|
|
608
|
+
// h2 (TLS)
|
|
609
|
+
const tlsApp = ingenium({
|
|
610
|
+
transport: new Http2Adapter({
|
|
611
|
+
cert: readFileSync('cert.pem'),
|
|
612
|
+
key: readFileSync('key.pem'),
|
|
613
|
+
allowHttp1: true, // ALPN fallback to HTTP/1.1
|
|
614
|
+
}),
|
|
615
|
+
})
|
|
616
|
+
await tlsApp.listen(443)
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
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).
|
|
620
|
+
|
|
621
|
+
### WebSocket
|
|
622
|
+
|
|
623
|
+
WebSocket support is opt-in via the `ws` peer dependency.
|
|
624
|
+
|
|
625
|
+
```sh
|
|
626
|
+
npm install ws @types/ws
|
|
627
|
+
```
|
|
628
|
+
|
|
629
|
+
```ts
|
|
630
|
+
import { ingenium, enableWebSockets } from 'ingenium'
|
|
631
|
+
|
|
632
|
+
const app = ingenium()
|
|
633
|
+
enableWebSockets(app)
|
|
634
|
+
|
|
635
|
+
app.ws('/echo', (sock) => {
|
|
636
|
+
sock.on('message', (msg) => sock.send(msg))
|
|
637
|
+
})
|
|
638
|
+
|
|
639
|
+
// Or hand the underlying http.Server to your own integrator:
|
|
640
|
+
app.upgradeWith((httpServer) => {
|
|
641
|
+
// wire up `ws`, socket.io, etc.
|
|
642
|
+
})
|
|
643
|
+
|
|
644
|
+
await app.listen(3000)
|
|
645
|
+
```
|
|
646
|
+
|
|
647
|
+
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.
|
|
648
|
+
|
|
649
|
+
### Graceful shutdown
|
|
650
|
+
|
|
651
|
+
```ts
|
|
652
|
+
import { ingenium, gracefulShutdown } from 'ingenium'
|
|
653
|
+
|
|
654
|
+
const app = ingenium()
|
|
655
|
+
const server = await app.listen(3000)
|
|
656
|
+
|
|
657
|
+
gracefulShutdown(server, {
|
|
658
|
+
gracefulTimeoutMs: 10_000, // force-close idle keep-alives after 10s
|
|
659
|
+
signals: ['SIGTERM', 'SIGINT'],
|
|
660
|
+
onShutdown: async () => {
|
|
661
|
+
await db.close()
|
|
662
|
+
await queue.flush()
|
|
663
|
+
},
|
|
664
|
+
})
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
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.
|
|
668
|
+
|
|
669
|
+
---
|
|
670
|
+
|
|
671
|
+
## Express compatibility shim
|
|
672
|
+
|
|
673
|
+
```sh
|
|
674
|
+
npm install ingenium ingenium-compat cors helmet
|
|
675
|
+
```
|
|
676
|
+
|
|
677
|
+
```ts
|
|
678
|
+
import { ingenium } from 'ingenium'
|
|
679
|
+
import { expressCompat } from 'ingenium-compat'
|
|
680
|
+
import cors from 'cors'
|
|
681
|
+
import helmet from 'helmet'
|
|
682
|
+
|
|
683
|
+
const app = ingenium()
|
|
684
|
+
app.use(expressCompat(cors({ origin: 'https://app.example.com' })))
|
|
685
|
+
app.use(expressCompat(helmet()))
|
|
686
|
+
```
|
|
687
|
+
|
|
688
|
+
The shim wraps `(req, res, next)` middleware so it can run inside an Ingenium middleware chain. The shims are **real Node streams** — `req` 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.
|
|
689
|
+
|
|
690
|
+
**Compatibility status** (validated end-to-end in `packages/ingenium-compat/test/e2e.test.ts`):
|
|
691
|
+
|
|
692
|
+
| Middleware | Status | Notes |
|
|
693
|
+
|---|---|---|
|
|
694
|
+
| `cors` | supported | full feature parity |
|
|
695
|
+
| `helmet` | supported | full feature parity |
|
|
696
|
+
| `cookie-parser` | supported | `req.cookies` populated, mirrored to `ctx.state.cookies` |
|
|
697
|
+
| `morgan` | supported | end-of-request tokens (`:status`, `:response-time`) fire on `res` `finish` |
|
|
698
|
+
| `express-rate-limit` | supported | `req.ip` populated — no custom `keyGenerator` needed |
|
|
699
|
+
| `compression` | supported | downstream response replayed through the patched `res`; body gzipped, `Content-Encoding` set |
|
|
700
|
+
| `body-parser` | supported | reads the real request stream; `req.body` → `ctx.state.body` |
|
|
701
|
+
| `passport.initialize` | supported | `req._passport` propagates to `ctx.state` |
|
|
702
|
+
| `passport.authenticate` | partial | `res.redirect`/cookie writes work; session-backed strategies need a session store |
|
|
703
|
+
| `express-session` | supported | `Set-Cookie` via `on-headers` + save on `res.end` |
|
|
704
|
+
| `multer` | supported | `req.pipe(busboy)` works; `req.file` → `ctx.state` |
|
|
705
|
+
|
|
706
|
+
Full matrix and internals in [`packages/ingenium-compat/COMPATIBILITY.md`](packages/ingenium-compat/COMPATIBILITY.md).
|
|
707
|
+
|
|
708
|
+
---
|
|
709
|
+
|
|
710
|
+
## CLI scaffolder
|
|
711
|
+
|
|
712
|
+
```sh
|
|
713
|
+
npm install -g ingenium-cli
|
|
714
|
+
|
|
715
|
+
ingenium new my-api # default template
|
|
716
|
+
ingenium new my-bun-api --bun # uses BunAdapter
|
|
717
|
+
ingenium new tiny --minimal # 10-line hello world
|
|
718
|
+
ingenium new my-api --force # overwrite existing dir
|
|
719
|
+
ingenium --version
|
|
720
|
+
ingenium --help
|
|
721
|
+
```
|
|
722
|
+
|
|
723
|
+
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`).
|
|
724
|
+
|
|
725
|
+
---
|
|
726
|
+
|
|
727
|
+
## Schema validation
|
|
728
|
+
|
|
729
|
+
`ctx.body.json(schema)` accepts three validator shapes, detected in this order:
|
|
730
|
+
|
|
731
|
+
```ts
|
|
732
|
+
// 1. Standard Schema v1 (any validator with ["~standard"])
|
|
733
|
+
import { type } from 'arktype'
|
|
734
|
+
const User = type({ name: 'string', email: 'string' })
|
|
735
|
+
app.post('/users', async (ctx) => ctx.body.json(User))
|
|
736
|
+
|
|
737
|
+
// 2. Zod-like safeParse
|
|
738
|
+
import { z } from 'zod'
|
|
739
|
+
const User = z.object({ name: z.string(), email: z.string().email() })
|
|
740
|
+
app.post('/users', async (ctx) => ctx.body.json(User))
|
|
741
|
+
|
|
742
|
+
// 3. Plain { parse(input): T }
|
|
743
|
+
const User = {
|
|
744
|
+
parse(input: unknown): { name: string } {
|
|
745
|
+
if (typeof input !== 'object') throw new Error('expected object')
|
|
746
|
+
return input as { name: string }
|
|
747
|
+
},
|
|
748
|
+
}
|
|
749
|
+
app.post('/users', async (ctx) => ctx.body.json(User))
|
|
750
|
+
```
|
|
751
|
+
|
|
752
|
+
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'`).
|
|
753
|
+
|
|
754
|
+
---
|
|
755
|
+
|
|
756
|
+
## Testing with `app.inject()`
|
|
757
|
+
|
|
758
|
+
Drop the ephemeral-port dance for unit tests — dispatch a synthetic request straight through the framework:
|
|
759
|
+
|
|
760
|
+
```ts
|
|
761
|
+
const app = ingenium()
|
|
762
|
+
app.post('/users', async (ctx) => ctx.json(await ctx.body.json(), 201))
|
|
763
|
+
|
|
764
|
+
const res = await app.inject({
|
|
765
|
+
method: 'POST',
|
|
766
|
+
url: '/users',
|
|
767
|
+
body: { name: 'Ada' },
|
|
768
|
+
})
|
|
769
|
+
|
|
770
|
+
expect(res.status).toBe(201)
|
|
771
|
+
expect(res.json()).toEqual({ name: 'Ada' })
|
|
772
|
+
```
|
|
773
|
+
|
|
774
|
+
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.
|
|
775
|
+
|
|
776
|
+
---
|
|
777
|
+
|
|
778
|
+
## Plugin scoping with `app.scope()`
|
|
779
|
+
|
|
780
|
+
Mount plugins (and their middleware) onto a subtree:
|
|
781
|
+
|
|
782
|
+
```ts
|
|
783
|
+
app.scope('/api/v2', (scope) => {
|
|
784
|
+
scope.use(requireAuth) // only /api/v2/*
|
|
785
|
+
scope.register(metricsPlugin) // plugin's use() / get() / etc. are prefix-relative
|
|
786
|
+
scope.get('/users', listUsers) // → /api/v2/users
|
|
787
|
+
})
|
|
788
|
+
```
|
|
789
|
+
|
|
790
|
+
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(...)`.
|
|
791
|
+
|
|
792
|
+
---
|
|
793
|
+
|
|
794
|
+
## Reference application
|
|
795
|
+
|
|
796
|
+
[`apps/notes-api/`](apps/notes-api) is a small but realistic notes service that exercises the full feature surface:
|
|
797
|
+
|
|
798
|
+
- Bearer-token auth via a plugin (`app.register(authPlugin)`)
|
|
799
|
+
- `app.decorate('user', ...)` lazy decorator + `requireAuth` middleware
|
|
800
|
+
- Pino logger plugin with `onRequest` / `onResponse` hooks
|
|
801
|
+
- SQLite persistence (better-sqlite3) with FTS5 full-text search
|
|
802
|
+
- Mounted routers (`/api/users`, `/api/notes`, `/api/health`)
|
|
803
|
+
- Zod-validated request bodies via `ctx.body.json(Schema)`
|
|
804
|
+
- Custom error boundary with status-aware responses
|
|
805
|
+
- Real HTTP integration tests on ephemeral ports
|
|
806
|
+
|
|
807
|
+
Run it:
|
|
808
|
+
|
|
809
|
+
```sh
|
|
810
|
+
cd apps/notes-api
|
|
811
|
+
npm install
|
|
812
|
+
npm run dev
|
|
813
|
+
```
|
|
814
|
+
|
|
815
|
+
---
|
|
816
|
+
|
|
817
|
+
## Packages
|
|
818
|
+
|
|
819
|
+
| Package | Description |
|
|
820
|
+
|---|---|
|
|
821
|
+
| [`ingenium`](packages/ingenium) | Core framework — `ingenium()`, `Router`, `IngeniumContext`, plugins, static, CORS, SSE, rate-limit, sessions, multipart, transports |
|
|
822
|
+
| [`ingenium-compat`](packages/ingenium-compat) | `expressCompat(mw)` shim for `(req, res, next)` middleware |
|
|
823
|
+
| [`ingenium-bun`](packages/ingenium-bun) | `BunAdapter` — drop-in transport for `Bun.serve()` |
|
|
824
|
+
| [`ingenium-cli`](packages/ingenium-cli) | `ingenium new <name> [--bun\|--minimal]` scaffolder |
|
|
825
|
+
| [`ingenium-redis`](packages/ingenium-redis) | `RedisSessionStore`, `RedisIdempotencyStore`, `RedisRateLimitStore`, `RedisQueueStore` — required for multi-instance deployments |
|
|
826
|
+
|
|
827
|
+
Each package is independently publishable to npm.
|
|
828
|
+
|
|
829
|
+
---
|
|
830
|
+
|
|
831
|
+
## Examples
|
|
832
|
+
|
|
833
|
+
| Example | Demonstrates |
|
|
834
|
+
|---|---|
|
|
835
|
+
| [`examples/learn`](examples/learn) | **Start here.** 8-step progressive tutorial — one concept per file, ~30 minutes end-to-end |
|
|
836
|
+
| [`examples/basic`](examples/basic) | Hello world, params, body, error handler, graceful shutdown, static files, decorator |
|
|
837
|
+
| [`examples/migrate-from-express`](examples/migrate-from-express) | Express version + Ingenium version side by side, identical routes |
|
|
838
|
+
| [`examples/with-plugin`](examples/with-plugin) | Custom auth plugin, decorator, hooks, module augmentation |
|
|
839
|
+
| [`examples/with-bun`](examples/with-bun) | `BunAdapter` for `Bun.serve()` |
|
|
840
|
+
|
|
841
|
+
---
|
|
842
|
+
|
|
843
|
+
## Architecture and design notes
|
|
844
|
+
|
|
845
|
+
Five [Architecture Decision Records](docs/adr/) document the load-bearing choices:
|
|
846
|
+
|
|
847
|
+
1. [ADR 0001 — Radix trie router](docs/adr/0001-radix-trie-router.md): why we chose a radix trie over Express's linear scan or Hono's smart router
|
|
848
|
+
2. [ADR 0002 — Lazy composition with a dirty bit](docs/adr/0002-lazy-composition-with-dirty-bit.md): how registration is journaled and composed lazily, and why we don't freeze on listen
|
|
849
|
+
3. [ADR 0003 — Handler return-value reflection](docs/adr/0003-handler-return-value-reflection.md): why handlers return values instead of calling `res.send`
|
|
850
|
+
4. [ADR 0004 — Context object pool](docs/adr/0004-context-pool.md): per-request pooling for GC pressure, V8 hidden class stability
|
|
851
|
+
5. [ADR 0005 — Express compat shim scope](docs/adr/0005-express-compat-shim-scope.md): what's in, what's out, and why
|
|
852
|
+
|
|
853
|
+
---
|
|
854
|
+
|
|
855
|
+
## Repo layout
|
|
856
|
+
|
|
857
|
+
```
|
|
858
|
+
ingenium/
|
|
859
|
+
├── packages/
|
|
860
|
+
│ ├── ingenium/ # core
|
|
861
|
+
│ ├── ingenium-compat/ # Express middleware shim
|
|
862
|
+
│ ├── ingenium-bun/ # Bun.serve adapter
|
|
863
|
+
│ ├── ingenium-redis/ # Redis stores (sessions, idempotency, rate-limit, job queue)
|
|
864
|
+
│ └── ingenium-cli/ # ingenium new scaffolder
|
|
865
|
+
├── apps/
|
|
866
|
+
│ └── notes-api/ # reference CRUD service
|
|
867
|
+
├── examples/
|
|
868
|
+
│ ├── basic/
|
|
869
|
+
│ ├── migrate-from-express/
|
|
870
|
+
│ ├── with-plugin/
|
|
871
|
+
│ └── with-bun/
|
|
872
|
+
├── benchmarks/
|
|
873
|
+
│ ├── scenarios/ # v1 — in-process (deprecated)
|
|
874
|
+
│ └── scenarios/v2/ # separate-process bench vs Express/Fastify/Hono
|
|
875
|
+
├── docs/
|
|
876
|
+
│ ├── migration-guide.md
|
|
877
|
+
│ ├── plugins.md
|
|
878
|
+
│ ├── roadmap.md
|
|
879
|
+
│ └── adr/ # 0001-0005
|
|
880
|
+
├── .github/workflows/
|
|
881
|
+
│ ├── ci.yml # Node 20/22/24 × ubuntu/windows
|
|
882
|
+
│ ├── bench.yml # nightly bench, artifact upload
|
|
883
|
+
│ ├── audit.yml # banned-packages scan
|
|
884
|
+
│ ├── publish.yml # manual, alpha-gated npm publish
|
|
885
|
+
│ └── release.yml # tag-driven GitHub Release
|
|
886
|
+
├── README.md, API.md, CHANGELOG.md, CONTRIBUTING.md, SECURITY.md, LICENSE
|
|
887
|
+
├── tsconfig.base.json, tsconfig.json
|
|
888
|
+
├── vitest.config.ts
|
|
889
|
+
└── package.json (npm workspaces)
|
|
890
|
+
```
|
|
891
|
+
|
|
892
|
+
---
|
|
893
|
+
|
|
894
|
+
## Development
|
|
895
|
+
|
|
896
|
+
```sh
|
|
897
|
+
git clone https://github.com/Contra-Collective/ingenium.git
|
|
898
|
+
cd ingenium
|
|
899
|
+
npm install
|
|
900
|
+
|
|
901
|
+
npm run typecheck # tsc --noEmit across all workspaces
|
|
902
|
+
npm test # vitest run
|
|
903
|
+
|
|
904
|
+
# build the publishable core to dist/
|
|
905
|
+
npm run build --workspace packages/ingenium
|
|
906
|
+
|
|
907
|
+
# run a benchmark scenario
|
|
908
|
+
cd benchmarks
|
|
909
|
+
npx tsx scenarios/v2/hello.ts
|
|
910
|
+
```
|
|
911
|
+
|
|
912
|
+
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.
|
|
913
|
+
|
|
914
|
+
---
|
|
915
|
+
|
|
916
|
+
## Roadmap and known gaps
|
|
917
|
+
|
|
918
|
+
See [docs/roadmap.md](docs/roadmap.md) for the full breakdown. Highlights:
|
|
919
|
+
|
|
920
|
+
**Shipped in v0.0.1:**
|
|
921
|
+
- All deliverables 1–10 from the original session plan
|
|
922
|
+
- HTTP/2, WebSocket, SSE, rate limit, session, multipart, trust-proxy
|
|
923
|
+
- CI matrix, ADRs, reference app, governance bundle
|
|
924
|
+
|
|
925
|
+
**Known issues:**
|
|
926
|
+
- `ExtractParams` doesn't narrow **constrained** params (`:id(\\d+)` stays `string`). Unconstrained params (`:id`) now narrow correctly through the verb overloads.
|
|
927
|
+
- `ctx.query.parse(schema)` uses a fixed shallow-array-aware coercion model — see the [Schema validation](#schema-validation) section for the trade-offs
|
|
928
|
+
|
|
929
|
+
**Deferred to next session:**
|
|
930
|
+
- TypeBox-specific bridge (Standard Schema covers it but a tighter integration could be cleaner)
|
|
931
|
+
- Scoped decorators (`app.scope()` scopes middleware today; decorators remain global — see `app.scope()` JSDoc for the rationale)
|
|
932
|
+
|
|
933
|
+
---
|
|
934
|
+
|
|
935
|
+
## Contributing
|
|
936
|
+
|
|
937
|
+
Read [CONTRIBUTING.md](CONTRIBUTING.md) and the relevant [ADR](docs/adr/) before opening a PR that changes a load-bearing design choice. Bug reports and design feedback welcome via GitHub Issues. Use [SECURITY.md](SECURITY.md) for vulnerability reports — do not file them as public issues.
|
|
938
|
+
|
|
939
|
+
---
|
|
940
|
+
|
|
941
|
+
## License
|
|
942
|
+
|
|
943
|
+
[MIT](LICENSE) © Ingenium contributors.
|