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.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +943 -0
  3. package/dist/index.cjs +7078 -0
  4. package/dist/index.cjs.map +1 -0
  5. package/dist/index.d.cts +4262 -0
  6. package/dist/index.d.ts +4262 -0
  7. package/dist/index.js +6963 -0
  8. package/dist/index.js.map +1 -0
  9. package/package.json +47 -0
  10. package/src/api-key/middleware.ts +157 -0
  11. package/src/api-key/types.ts +37 -0
  12. package/src/app/scope.ts +392 -0
  13. package/src/app.ts +1752 -0
  14. package/src/body/limit.ts +21 -0
  15. package/src/body/middleware.ts +30 -0
  16. package/src/body/multipart-types.ts +40 -0
  17. package/src/body/multipart.ts +254 -0
  18. package/src/context/body.ts +324 -0
  19. package/src/context/context.ts +650 -0
  20. package/src/context/cookies.ts +282 -0
  21. package/src/context/pool.ts +32 -0
  22. package/src/cors/middleware.ts +182 -0
  23. package/src/cors/types.ts +79 -0
  24. package/src/cron/parser.ts +311 -0
  25. package/src/cron/registry.ts +49 -0
  26. package/src/cron/scheduler.ts +153 -0
  27. package/src/csrf/middleware.ts +224 -0
  28. package/src/csrf/types.ts +65 -0
  29. package/src/errors.ts +148 -0
  30. package/src/idempotency/middleware.ts +197 -0
  31. package/src/idempotency/store.ts +70 -0
  32. package/src/idempotency/types.ts +87 -0
  33. package/src/index.ts +328 -0
  34. package/src/jobs/queue.ts +306 -0
  35. package/src/jobs/registry.ts +82 -0
  36. package/src/jobs/store-memory.ts +113 -0
  37. package/src/jobs/types.ts +135 -0
  38. package/src/jwt/jwks.ts +143 -0
  39. package/src/jwt/middleware.ts +313 -0
  40. package/src/jwt/types.ts +137 -0
  41. package/src/jwt/verify.ts +370 -0
  42. package/src/middleware/compose.ts +94 -0
  43. package/src/middleware/types.ts +37 -0
  44. package/src/negotiation/accept.ts +159 -0
  45. package/src/negotiation/etag.ts +30 -0
  46. package/src/negotiation/format.ts +88 -0
  47. package/src/negotiation/fresh.ts +89 -0
  48. package/src/negotiation/json-etag.ts +122 -0
  49. package/src/negotiation/negotiate.ts +97 -0
  50. package/src/openapi/describe.ts +79 -0
  51. package/src/openapi/extract-params.ts +62 -0
  52. package/src/openapi/generate.ts +251 -0
  53. package/src/openapi/handler.ts +73 -0
  54. package/src/openapi/types.ts +145 -0
  55. package/src/plugin/decorators.ts +100 -0
  56. package/src/plugin/hooks.ts +114 -0
  57. package/src/plugin/types.ts +189 -0
  58. package/src/problem/middleware.ts +55 -0
  59. package/src/problem/serialize.ts +121 -0
  60. package/src/problem/types.ts +68 -0
  61. package/src/proxy/trust.ts +247 -0
  62. package/src/rate-limit/middleware.ts +72 -0
  63. package/src/rate-limit/store.ts +129 -0
  64. package/src/rate-limit/types.ts +60 -0
  65. package/src/response/reflect.ts +93 -0
  66. package/src/router/router.ts +284 -0
  67. package/src/router/trie.ts +309 -0
  68. package/src/router/types.ts +54 -0
  69. package/src/schema/standard.ts +67 -0
  70. package/src/session/middleware.ts +379 -0
  71. package/src/session/store-memory.ts +79 -0
  72. package/src/session/types.ts +95 -0
  73. package/src/sinatra/filters.ts +129 -0
  74. package/src/sinatra/top-level.ts +151 -0
  75. package/src/sse/keep-alive.ts +52 -0
  76. package/src/sse/sse.ts +115 -0
  77. package/src/static/middleware.ts +254 -0
  78. package/src/static/types.ts +31 -0
  79. package/src/transport/http2-helpers.ts +242 -0
  80. package/src/transport/http2.ts +316 -0
  81. package/src/transport/node.ts +261 -0
  82. package/src/transport/shutdown.ts +86 -0
  83. package/src/transport/types.ts +72 -0
  84. package/src/util/safe-json.ts +66 -0
  85. package/src/ws/index.ts +164 -0
  86. package/src/ws/middleware.ts +178 -0
  87. package/src/ws/types.ts +52 -0
  88. 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.