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/src/app.ts ADDED
@@ -0,0 +1,1752 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks'
2
+ import { Buffer } from 'node:buffer'
3
+ import { Readable } from 'node:stream'
4
+ import { IngeniumContext } from './context/context.ts'
5
+ import { IngeniumContextPool } from './context/pool.ts'
6
+ import {
7
+ IngeniumError,
8
+ IngeniumHaltError,
9
+ IngeniumMethodNotAllowedError,
10
+ IngeniumNotFoundError,
11
+ IngeniumTimeoutError,
12
+ } from './errors.ts'
13
+ import { composeWithHandler } from './middleware/compose.ts'
14
+ import type { IngeniumHandler, IngeniumMiddleware } from './middleware/types.ts'
15
+ import { DecoratorRegistry } from './plugin/decorators.ts'
16
+ import { HooksRegistry } from './plugin/hooks.ts'
17
+ import type {
18
+ EagerDecorator,
19
+ Hooks,
20
+ LazyDecorator,
21
+ IngeniumPlugin,
22
+ PluginTarget,
23
+ } from './plugin/types.ts'
24
+ import { RouteBuilder, Router, flattenRouter } from './router/router.ts'
25
+ import { ScopedApp } from './app/scope.ts'
26
+ import { registerAfter, registerBefore } from './sinatra/filters.ts'
27
+ import { EMPTY_PARAMS, RouterTrie, type MatchMiss } from './router/trie.ts'
28
+ import type { HttpMethod } from './router/types.ts'
29
+ import { NodeAdapter } from './transport/node.ts'
30
+ import type { ListeningServer, Transport } from './transport/types.ts'
31
+ import { descriptorKey, type RouteDescriptor } from './openapi/describe.ts'
32
+ import { isStandardSchema } from './schema/standard.ts'
33
+ import type { RequestBody, Response, Schema } from './openapi/types.ts'
34
+ import { QueueRegistry } from './jobs/registry.ts'
35
+ import type { JobHandle, QueueOptions, QueueWorker } from './jobs/types.ts'
36
+ import { CronRegistry } from './cron/registry.ts'
37
+ import type { CronHandler, CronOptions } from './cron/scheduler.ts'
38
+
39
+ /** Options accepted by `ingenium(...)` and `new IngeniumApp(...)`. */
40
+ export interface IngeniumAppOptions {
41
+ /** Max number of pooled `IngeniumContext` instances kept in the free list. Default 1024. */
42
+ poolSize?: number
43
+ /** Inject a custom transport (e.g. for tests). Default: `NodeAdapter`. */
44
+ transport?: Transport
45
+ /**
46
+ * Trust-proxy configuration — controls whether `X-Forwarded-For`,
47
+ * `X-Forwarded-Proto`, `X-Forwarded-Host` are honored when computing
48
+ * `ctx.ip`, `ctx.protocol`, `ctx.hostname`. Mirrors Express's
49
+ * `app.set('trust proxy', ...)` semantics. Default `false` (never trust).
50
+ * See `proxy/trust.ts` for the full type. Set to `true` only when running
51
+ * behind a reverse proxy you control.
52
+ */
53
+ trustProxy?: import('./proxy/trust.ts').TrustProxy
54
+ /**
55
+ * Maximum wall-clock time (ms) for a single request to complete from
56
+ * dispatch to response. When exceeded, throws `IngeniumTimeoutError(503)`
57
+ * and the response becomes 503 Service Unavailable.
58
+ *
59
+ * The handler that timed out is NOT cancelled — JavaScript can't safely
60
+ * cancel a Promise. The framework just stops waiting for it; the in-flight
61
+ * work continues until it naturally completes or the process exits. This
62
+ * means a slow handler can still leak compute (but not connections or
63
+ * pool slots, since the response is sent and the context is released).
64
+ *
65
+ * Scoped to HTTP request handling only — does NOT apply to upgraded
66
+ * connections (WebSocket, SSE) which are explicitly long-lived.
67
+ *
68
+ * Default: undefined (no timeout). Production deploys SHOULD set this.
69
+ */
70
+ requestTimeoutMs?: number
71
+ /**
72
+ * Hard ceiling (bytes) on the total request body, enforced at the
73
+ * transport layer — applies regardless of which `ctx.body.*` consumer
74
+ * reads the body, including `ctx.body.stream()`. Defaults to **2 MiB**
75
+ * (2_097_152) — high enough for typical JSON / form payloads,
76
+ * low enough that an unauthenticated attacker can't exhaust memory.
77
+ *
78
+ * Per-call limits on `ctx.body.json(schema, maxBytes)` etc. are still
79
+ * honored and apply WITHIN this ceiling. To allow larger uploads on a
80
+ * specific route, raise this AND use `ctx.body.stream()` with your own
81
+ * size accounting.
82
+ *
83
+ * Set to `Infinity` to disable (NOT recommended outside controlled deploys).
84
+ */
85
+ maxRequestBytes?: number
86
+
87
+ /**
88
+ * Default queue-drain timeout (ms) used when the listener closes. Per-queue
89
+ * timeouts can also be passed to `app.queues.drainAll(timeoutMs)`. Default
90
+ * `10_000`ms — matches `gracefulShutdown`'s default `gracefulTimeoutMs`.
91
+ */
92
+ queueDrainTimeoutMs?: number
93
+
94
+ /**
95
+ * Secret(s) used to HMAC-sign cookies written via
96
+ * `ctx.cookies.set(name, value, { signed: true })`.
97
+ *
98
+ * Accepts a single string or an array — the FIRST entry signs new cookies,
99
+ * ALL entries verify reads (so rotating a secret is: prepend the new key,
100
+ * keep the old key, deploy; remove the old key on the next deploy once all
101
+ * outstanding signed cookies have expired).
102
+ *
103
+ * If omitted, calling `ctx.cookies.set(..., { signed: true })` or
104
+ * `ctx.cookies.get(..., { signed: true })` throws
105
+ * `IngeniumError(500, 'COOKIE_SECRET_MISSING')`. The unsigned cookie API
106
+ * still works without any secrets configured.
107
+ */
108
+ cookieSecrets?: string | string[]
109
+ }
110
+
111
+ /** Default transport-layer body ceiling — see {@link IngeniumAppOptions.maxRequestBytes}. */
112
+ const DEFAULT_MAX_REQUEST_BYTES = 2_097_152
113
+
114
+ /** A user-supplied error handler. Return a non-error or call a `ctx` writer to recover. */
115
+ export type IngeniumErrorHandler = (err: unknown, ctx: IngeniumContext) => unknown | Promise<unknown>
116
+
117
+ /**
118
+ * Options for {@link IngeniumApp.inject} — the in-process test client. Mirrors
119
+ * the shape a real request would carry, minus a socket. `url` is the only
120
+ * required field; everything else defaults to a bare GET.
121
+ */
122
+ export interface InjectRequest {
123
+ /** HTTP method. Defaults to `GET`. */
124
+ method?: HttpMethod
125
+ /** Request URL including any query string (e.g. `/users/42?expand=posts`). */
126
+ url: string
127
+ /**
128
+ * Request headers. Names are lowercased before they reach the handler so
129
+ * `ctx.headers['x-custom']` works regardless of the casing passed here
130
+ * (matching node:http's lowercasing of inbound headers).
131
+ */
132
+ headers?: Record<string, string | string[]>
133
+ /**
134
+ * Request body. A `string` / `Buffer` / `Uint8Array` is sent verbatim; a
135
+ * plain object is JSON-serialized and (unless the caller set a
136
+ * `content-type`) tagged `application/json`.
137
+ */
138
+ body?: string | Buffer | Uint8Array | Record<string, unknown> | unknown[]
139
+ /** Socket peer address surfaced as `ctx.remoteAddress`. Defaults to `127.0.0.1`. */
140
+ remoteAddress?: string
141
+ }
142
+
143
+ /**
144
+ * Result of {@link IngeniumApp.inject}. A fully-materialized snapshot of the
145
+ * response — captured BEFORE the pooled context is released, so reading any
146
+ * field is safe even though the underlying context has been recycled.
147
+ */
148
+ export interface InjectResponse {
149
+ /** Final HTTP status code. */
150
+ status: number
151
+ /**
152
+ * Response headers, lowercased. A deep-ish copy of the context's header bag
153
+ * (the array values are cloned) so sequential `inject()` calls never share
154
+ * or bleed header state.
155
+ */
156
+ headers: Record<string, string | string[]>
157
+ /**
158
+ * Response body decoded as a UTF-8 string. Stream responses are drained to
159
+ * completion first; a bodyless response (`{ kind: 'none' }`) yields `''`.
160
+ */
161
+ body: string
162
+ /** Parse {@link InjectResponse.body} as JSON. Throws on invalid JSON, like `JSON.parse`. */
163
+ json<T = unknown>(): T
164
+ }
165
+
166
+ /**
167
+ * Per-route options object accepted as the second positional arg to a verb
168
+ * registration (`app.get(path, { auth: ['admin'] }, handler)`). Each key must
169
+ * match a registered declarator (see `app.declare(...)`); the value is passed
170
+ * to the declarator's factory at REGISTRATION time and the resulting
171
+ * middleware is prepended to the route's chain.
172
+ */
173
+ export type RouteOptions = Record<string, unknown>
174
+
175
+ /**
176
+ * Well-known route-options keys handled natively by the framework. Mixed with
177
+ * user-defined declarator keys in the same options object — the framework
178
+ * peels off the well-known ones at REGISTRATION time, hands them straight to
179
+ * `app.describe(method, path, ...)`, and forwards everything else to
180
+ * `translateRouteOptions()` for declarator resolution. Built-in keys never
181
+ * trigger the "unknown declarator" error because they're removed from the
182
+ * forwarded options before declarator lookup runs.
183
+ *
184
+ * Inline schema values for `response` / `requestBody` accept ONLY raw OpenAPI
185
+ * Schema objects today. Passing a Standard Schema or Zod validator throws at
186
+ * registration time — converting validators to JSON Schema is the OpenAPI
187
+ * generator's job and isn't wired through this seam yet. Use
188
+ * `app.describe(method, path, ...)` with a precomputed schema if you need
189
+ * that today.
190
+ */
191
+ const BUILTIN_ROUTE_OPTION_KEYS: ReadonlySet<string> = new Set([
192
+ 'response',
193
+ 'requestBody',
194
+ 'tags',
195
+ 'summary',
196
+ 'description',
197
+ 'parameters',
198
+ 'deprecated',
199
+ 'operationId',
200
+ 'security',
201
+ ])
202
+
203
+ /**
204
+ * Variadic arg shape accepted by `app.get/post/...` and `app.method(...)` after
205
+ * the leading `(method?, path)`. The tail is always one handler; everything
206
+ * before it is either positional middleware or the (optional) leading options
207
+ * object. We type it as `unknown[]` at the implementation seam because TS
208
+ * tuple-rest narrowing fights with the overload's union return type — the
209
+ * runtime validates structure (handler-is-function tail, plain-object head
210
+ * detection, function middleware) and the public overloads enforce the shape
211
+ * the caller actually sees.
212
+ */
213
+ export type VerbArgs = unknown[]
214
+
215
+ /**
216
+ * The Ingenium application. Combines a `Router` (registration journal),
217
+ * a `RouterTrie` (matched at request time), a context pool, and a
218
+ * transport. Composition is lazy: the trie's composed handlers are built
219
+ * on first request (or when `compose()` is called explicitly), and a dirty
220
+ * bit triggers recomposition if registrations are added later.
221
+ */
222
+ export class IngeniumApp implements PluginTarget {
223
+ private readonly pool: IngeniumContextPool
224
+ private readonly transport: Transport
225
+ private readonly router: Router = new Router()
226
+ private trie: RouterTrie = new RouterTrie()
227
+ private dirty = true
228
+ private errorHandler: IngeniumErrorHandler | null = null
229
+ private readonly _hooks: HooksRegistry = new HooksRegistry()
230
+ private readonly _decorators: DecoratorRegistry = new DecoratorRegistry()
231
+ /** @internal Per-route OpenAPI metadata. Keyed by `${method} ${path}`. */
232
+ private readonly _routeDescriptors: Map<string, RouteDescriptor> = new Map()
233
+ /** @internal Bumped on every `describe()` call so the OpenAPI handler's cache invalidates. */
234
+ private _routeDescriptorVersion = 0
235
+ /** @internal Carried onto each `IngeniumContext` so its `ip`/`protocol`/`hostname` getters can resolve. */
236
+ private readonly _trustProxy: import('./proxy/trust.ts').TrustProxy
237
+ /** @internal Wall-clock per-request ceiling. `undefined` disables the race entirely. */
238
+ private readonly _requestTimeoutMs: number | undefined
239
+ /**
240
+ * @internal Hard transport-layer ceiling on request body bytes. Passed to
241
+ * the transport via `TransportHooks.maxRequestBytes`. `Infinity` disables.
242
+ */
243
+ private readonly _maxRequestBytes: number
244
+ /** @internal Background job registry. Workers start at compose() / first request. */
245
+ private readonly _queues: QueueRegistry = new QueueRegistry()
246
+ /** @internal Cron job registry. Timers start at compose() / first request. */
247
+ private readonly _crons: CronRegistry = new CronRegistry()
248
+ /** @internal Default queue-drain timeout used when the listener closes. */
249
+ private readonly _queueDrainTimeoutMs: number
250
+ /**
251
+ * @internal Frozen list of cookie-signing secrets, normalized from the
252
+ * scalar/array input. Empty array when not configured. Stamped onto each
253
+ * dispatched context only when non-empty (per-request field write avoided
254
+ * for the common case of "no cookie secrets configured").
255
+ */
256
+ private readonly _cookieSecrets: readonly string[]
257
+ /** @internal Whether `cookieSecrets` was configured — caches the truthy check. */
258
+ private readonly _hasCookieSecrets: boolean
259
+
260
+ /**
261
+ * @internal Per-request dispatch booleans, recomputed at every `compose()`.
262
+ * Cached because their underlying `.hasAny()` / `.hasOnXxx()` calls walk
263
+ * arrays and we don't want to pay that on every request — the registries
264
+ * are frozen between composes.
265
+ *
266
+ * `_handleFast` is the hot-path closure: when ALL of these are off
267
+ * (`!_hasHooks && !_hasDecorators && !_hasTimeout && !_hasTrustProxy`)
268
+ * we route through a stripped dispatch that skips every conditional.
269
+ */
270
+ private _hasHooks = false
271
+ private _hasOnRequest = false
272
+ private _hasOnResponse = false
273
+ private _hasOnError = false
274
+ private _hasDecorators = false
275
+ private readonly _hasTimeout: boolean
276
+ private readonly _hasTrustProxy: boolean
277
+ private _useFastPath = false
278
+
279
+ /**
280
+ * @internal Whether `listen()` has bound a server that hasn't been closed
281
+ * yet. Guards against the double-listen footgun: a second `listen()` on the
282
+ * same app would silently bind a second server (two ports dispatching to one
283
+ * pool), almost always a copy-paste mistake. Cleared when the returned
284
+ * handle's `close()` resolves, so re-listening after a clean shutdown works.
285
+ */
286
+ private _listening = false
287
+
288
+ constructor(options: IngeniumAppOptions = {}) {
289
+ this.pool = new IngeniumContextPool(options.poolSize ?? 1024)
290
+ this.transport = options.transport ?? new NodeAdapter()
291
+ this._trustProxy = options.trustProxy ?? false
292
+ this._requestTimeoutMs = options.requestTimeoutMs
293
+ this._maxRequestBytes = options.maxRequestBytes ?? DEFAULT_MAX_REQUEST_BYTES
294
+ this._queueDrainTimeoutMs = options.queueDrainTimeoutMs ?? 10_000
295
+ // Normalize scalar/array secret input into a frozen list. Frozen because
296
+ // the array reference is shared across every request — accidental mutation
297
+ // anywhere would silently change the verify set for the whole app.
298
+ const rawSecrets = options.cookieSecrets
299
+ this._cookieSecrets = Object.freeze(
300
+ rawSecrets === undefined
301
+ ? []
302
+ : Array.isArray(rawSecrets)
303
+ ? rawSecrets.slice()
304
+ : [rawSecrets],
305
+ )
306
+ this._hasCookieSecrets = this._cookieSecrets.length > 0
307
+ // These two are stable for the lifetime of the app.
308
+ this._hasTimeout = options.requestTimeoutMs !== undefined
309
+ this._hasTrustProxy = (options.trustProxy ?? false) !== false
310
+
311
+ // Wire `ctx.queue(name)` as a lazy decorator. Returns a per-call handle
312
+ // that delegates straight to the registry. Lazy = zero overhead for
313
+ // routes that don't enqueue background work.
314
+ this._decorators.decorate('queue', (_ctx) => {
315
+ return <TData = unknown>(name: string): JobHandle<TData> => {
316
+ const q = this._queues.get<TData>(name)
317
+ return { add: (data: TData) => q.add(data) }
318
+ }
319
+ })
320
+ }
321
+
322
+ /**
323
+ * @internal Recompute the cached dispatch booleans. Called at the end of
324
+ * `compose()` (and `composeAsync()`) so per-request reads are O(1) field
325
+ * loads instead of `.hasAny()` array scans.
326
+ *
327
+ * The fast path is taken when an app uses zero opt-in features beyond the
328
+ * router itself: no plugins, no decorators, no request timeout, no
329
+ * trust-proxy. That's the typical "Sinatra-shape" / "Express-shape"
330
+ * register-routes-and-go app — the case we want to be the absolute fastest.
331
+ * Note `ctx.queue` registers a decorator at construction, so any app that
332
+ * uses background jobs is OFF the fast path. That's the correct semantics:
333
+ * if you want the queue ergonomic, you accept the per-request decorator
334
+ * apply cost.
335
+ */
336
+ private _recomputeDispatchFlags(): void {
337
+ this._hasHooks = this._hooks.hasAny()
338
+ this._hasOnRequest = this._hasHooks && this._hooks.hasOnRequest()
339
+ this._hasOnResponse = this._hasHooks && this._hooks.hasOnResponse()
340
+ this._hasOnError = this._hasHooks && this._hooks.hasOnError()
341
+ this._hasDecorators = this._decorators.hasAny()
342
+ this._useFastPath =
343
+ !this._hasHooks &&
344
+ !this._hasDecorators &&
345
+ !this._hasTimeout &&
346
+ !this._hasTrustProxy
347
+ }
348
+
349
+ // ───── Plugin system ────────────────────────────────────────────────────
350
+
351
+ /** Lifecycle hooks API — plugins call `app.hooks.onRequest(...)` etc. */
352
+ get hooks(): Hooks { return this._hooks }
353
+
354
+ /**
355
+ * Register a plugin. Plugins are invoked immediately and may be async;
356
+ * callers should `await app.register(...)` if the plugin returns a Promise.
357
+ * Plugins must be registered BEFORE `compose()` runs (i.e. before the
358
+ * first request); registering a plugin sets the dirty bit so the next
359
+ * request will recompose.
360
+ */
361
+ register<O>(plugin: IngeniumPlugin<O>, opts: O): Promise<this>
362
+ register(plugin: IngeniumPlugin<void>): Promise<this>
363
+ async register<O>(plugin: IngeniumPlugin<O>, opts?: O): Promise<this> {
364
+ await plugin(this, opts as O)
365
+ this.dirty = true
366
+ return this
367
+ }
368
+
369
+ /**
370
+ * Open a registration scope rooted at `prefix`. Routes, middleware, and
371
+ * sub-plugins registered through the `scope` parameter inside `registrar`
372
+ * are automatically prefix-qualified, so a plugin's `use(mw)` only applies
373
+ * to requests under `prefix` — not the whole app.
374
+ *
375
+ * This is the killer feature for sub-app affinity: today, plugins registered
376
+ * via `app.register(...)` decorate the WHOLE app (their `app.use(mw)` calls
377
+ * become global). Wrapping a `register(...)` call inside `app.scope(...)`
378
+ * confines the plugin's middleware to the scope's subtree, without forcing
379
+ * the user to manually prefix every route.
380
+ *
381
+ * The actual prefix-matching happens at compose time: scope translates
382
+ * `s.use(mw)` into `app.use(prefix, mw)` on the underlying router, which
383
+ * the existing `flattenRouter` / `RouterTrie` machinery already handles.
384
+ * Per-request dispatch sees no new work.
385
+ *
386
+ * @example
387
+ * app.scope('/api/v2', (s) => {
388
+ * s.use(authPlugin) // only runs for /api/v2/*
389
+ * s.get('/users', listUsers) // registers at /api/v2/users
390
+ * })
391
+ *
392
+ * Nested scopes compose:
393
+ *
394
+ * @example
395
+ * app.scope('/api', (s) => {
396
+ * s.scope('/v2', (s2) => {
397
+ * s2.get('/users', listUsers) // /api/v2/users
398
+ * })
399
+ * })
400
+ *
401
+ * # Caveat — decorators (V1)
402
+ *
403
+ * `scope.decorate(...)` decorates EVERY request, not just requests under
404
+ * `prefix`. Decorators install onto the pooled context at request start,
405
+ * before the route is matched. The first call from inside any scope emits
406
+ * a `process.emitWarning` in non-production environments to surface this.
407
+ * See `ScopedApp` for details.
408
+ *
409
+ * @returns `this` for chaining. If `registrar` is async, the returned
410
+ * promise is NOT awaited — callers who need async plugin registration
411
+ * inside a scope should await the `registrar` themselves (or use
412
+ * `scope.register(asyncPlugin)`, which returns a Promise).
413
+ */
414
+ scope(
415
+ prefix: string,
416
+ registrar: (scope: PluginTarget) => void,
417
+ ): this {
418
+ const scoped = new ScopedApp(this, prefix)
419
+ // Return is typed `void` so concise chainable arrows (`s => s.get(...)`)
420
+ // and async registrars both satisfy the signature; we still thenable-check
421
+ // the actual value to re-mark dirty if it resolves later.
422
+ const ret: unknown = registrar(scoped as unknown as PluginTarget)
423
+ // If the registrar is async, it's the caller's responsibility to await it
424
+ // (e.g. by awaiting `scope.register(asyncPlugin)` inside the body). We
425
+ // still mark dirty eagerly so synchronous registrations recompose next.
426
+ // Async paths additionally re-mark dirty when they resolve, just in case.
427
+ if (ret && typeof (ret as Promise<void>).then === 'function') {
428
+ ;(ret as Promise<void>).then(() => {
429
+ this.dirty = true
430
+ })
431
+ }
432
+ this.dirty = true
433
+ return this
434
+ }
435
+
436
+ /**
437
+ * @internal Mark the compose-cache dirty. Used by `ScopedApp` to invalidate
438
+ * after registering routes/middleware/plugins inside a scope. External
439
+ * callers should not depend on this — it's a friend-access seam for the
440
+ * scope facade.
441
+ */
442
+ _markDirty(): void {
443
+ this.dirty = true
444
+ }
445
+
446
+ /**
447
+ * Add a lazy decorator. The factory is invoked the first time `ctx[name]`
448
+ * is read; the result is cached on the context for the rest of the request.
449
+ *
450
+ * @example
451
+ * app.decorate('user', async (ctx) => loadUser(ctx.headers.authorization))
452
+ */
453
+ decorate<T>(name: string, factory: LazyDecorator<T>): this {
454
+ this._decorators.decorate(name, factory)
455
+ return this
456
+ }
457
+
458
+ /**
459
+ * Add an eager decorator. The factory runs at the start of every request,
460
+ * and the value is assigned directly to the context.
461
+ *
462
+ * @example
463
+ * app.decorateRequest('startedAt', () => Date.now())
464
+ */
465
+ decorateRequest<T>(name: string, factory: EagerDecorator<T>): this {
466
+ this._decorators.decorateRequest(name, factory)
467
+ return this
468
+ }
469
+
470
+ // ───── Registration (delegates to the inner Router) ─────────────────────
471
+
472
+ use(mw: IngeniumMiddleware): this
473
+ use(prefix: string, mw: IngeniumMiddleware | Router): this
474
+ use(arg1: string | IngeniumMiddleware, arg2?: IngeniumMiddleware | Router): this {
475
+ if (typeof arg1 === 'string') {
476
+ // Overload preserved by passing both args verbatim.
477
+ this.router.use(arg1, arg2 as IngeniumMiddleware | Router)
478
+ } else {
479
+ this.router.use(arg1)
480
+ }
481
+ this.dirty = true
482
+ return this
483
+ }
484
+
485
+ // ───── Verb registration ──────────────────────────────────────────────
486
+ // Three accepted shapes per verb:
487
+ // 1. (path, handler) — back-compat single arg
488
+ // 2. (path, ...inlineMiddleware, handler) — Express positional mw
489
+ // 3. (path, optionsObject, ...inlineMw, handler) — declarative middleware
490
+ // (see app.declare())
491
+ // Detection of shape (3) is by `isPlainOptionsObject(args[0])` — see the
492
+ // helper for the exact prototype-check rule. Translation from declarative
493
+ // options → middleware happens at REGISTRATION time, not request time, so
494
+ // the per-request hot path has zero declarative-middleware overhead.
495
+
496
+ get(path: string, handler: IngeniumHandler): this
497
+ get(
498
+ path: string,
499
+ optsOrFirst: RouteOptions | IngeniumMiddleware,
500
+ ...rest: [...IngeniumMiddleware[], IngeniumHandler]
501
+ ): this
502
+ get(path: string, ...args: VerbArgs): this {
503
+ return (this.method as (m: HttpMethod, p: string, ...a: VerbArgs) => this)('GET', path, ...args)
504
+ }
505
+
506
+ post(path: string, handler: IngeniumHandler): this
507
+ post(
508
+ path: string,
509
+ optsOrFirst: RouteOptions | IngeniumMiddleware,
510
+ ...rest: [...IngeniumMiddleware[], IngeniumHandler]
511
+ ): this
512
+ post(path: string, ...args: VerbArgs): this {
513
+ return (this.method as (m: HttpMethod, p: string, ...a: VerbArgs) => this)('POST', path, ...args)
514
+ }
515
+
516
+ put(path: string, handler: IngeniumHandler): this
517
+ put(
518
+ path: string,
519
+ optsOrFirst: RouteOptions | IngeniumMiddleware,
520
+ ...rest: [...IngeniumMiddleware[], IngeniumHandler]
521
+ ): this
522
+ put(path: string, ...args: VerbArgs): this {
523
+ return (this.method as (m: HttpMethod, p: string, ...a: VerbArgs) => this)('PUT', path, ...args)
524
+ }
525
+
526
+ patch(path: string, handler: IngeniumHandler): this
527
+ patch(
528
+ path: string,
529
+ optsOrFirst: RouteOptions | IngeniumMiddleware,
530
+ ...rest: [...IngeniumMiddleware[], IngeniumHandler]
531
+ ): this
532
+ patch(path: string, ...args: VerbArgs): this {
533
+ return (this.method as (m: HttpMethod, p: string, ...a: VerbArgs) => this)('PATCH', path, ...args)
534
+ }
535
+
536
+ delete(path: string, handler: IngeniumHandler): this
537
+ delete(
538
+ path: string,
539
+ optsOrFirst: RouteOptions | IngeniumMiddleware,
540
+ ...rest: [...IngeniumMiddleware[], IngeniumHandler]
541
+ ): this
542
+ delete(path: string, ...args: VerbArgs): this {
543
+ return (this.method as (m: HttpMethod, p: string, ...a: VerbArgs) => this)('DELETE', path, ...args)
544
+ }
545
+
546
+ head(path: string, handler: IngeniumHandler): this
547
+ head(
548
+ path: string,
549
+ optsOrFirst: RouteOptions | IngeniumMiddleware,
550
+ ...rest: [...IngeniumMiddleware[], IngeniumHandler]
551
+ ): this
552
+ head(path: string, ...args: VerbArgs): this {
553
+ return (this.method as (m: HttpMethod, p: string, ...a: VerbArgs) => this)('HEAD', path, ...args)
554
+ }
555
+
556
+ options(path: string, handler: IngeniumHandler): this
557
+ options(
558
+ path: string,
559
+ optsOrFirst: RouteOptions | IngeniumMiddleware,
560
+ ...rest: [...IngeniumMiddleware[], IngeniumHandler]
561
+ ): this
562
+ options(path: string, ...args: VerbArgs): this {
563
+ return (this.method as (m: HttpMethod, p: string, ...a: VerbArgs) => this)('OPTIONS', path, ...args)
564
+ }
565
+
566
+ /**
567
+ * Chainable per-path registration. Returns a `RouteBuilder` that stacks
568
+ * verb registrations on the same path without retyping it:
569
+ *
570
+ * @example
571
+ * app
572
+ * .route('/users/:id')
573
+ * .get((ctx) => loadUser(ctx.params.id))
574
+ * .put(requireAdmin, (ctx) => updateUser(ctx))
575
+ * .delete(requireAdmin, (ctx) => deleteUser(ctx))
576
+ *
577
+ * Pure registration sugar — every call delegates to `app.method(...)`, so
578
+ * declarative options, inline middleware, and typed params via
579
+ * `ExtractParams<P>` work exactly as they do on the bare verb form.
580
+ */
581
+ route<P extends string>(path: P): RouteBuilder<P> {
582
+ return new RouteBuilder<P>((method, args) =>
583
+ (this.method as (m: HttpMethod, p: string, ...a: VerbArgs) => this)(method, path, ...(args as VerbArgs)),
584
+ )
585
+ }
586
+
587
+ /**
588
+ * Register a route under any HTTP method. Accepts the variadic shape with
589
+ * an optional declarative-options object as the first arg (after `path`).
590
+ */
591
+ method(method: HttpMethod, path: string, handler: IngeniumHandler): this
592
+ method(
593
+ method: HttpMethod,
594
+ path: string,
595
+ optsOrFirst: RouteOptions | IngeniumMiddleware,
596
+ ...rest: [...IngeniumMiddleware[], IngeniumHandler]
597
+ ): this
598
+ method(method: HttpMethod, path: string, ...args: VerbArgs): this {
599
+ if (args.length === 0) {
600
+ throw new TypeError(`IngeniumApp.${method.toLowerCase()}('${path}'): handler is required`)
601
+ }
602
+ // Translate the optional declarative-options object (when present in
603
+ // position 0) into prepended middleware. The remaining args are passed
604
+ // through to the inner Router as plain positional middleware + handler.
605
+ let translatedHead: IngeniumMiddleware[] = []
606
+ let tail: unknown[] = args
607
+ if (isPlainOptionsObject(args[0])) {
608
+ const opts = args[0] as RouteOptions
609
+ // Peel off well-known OpenAPI keys → describe() at registration time.
610
+ // Forward everything else through the declarator pipeline. Splitting
611
+ // the bag in two means a single options object can legally mix
612
+ // built-in keys (`tags`, `summary`, ...) with user declarator keys
613
+ // (`auth`, `rateLimit`, ...) without the latter tripping over the
614
+ // former or vice versa.
615
+ const builtins: Record<string, unknown> = {}
616
+ const userOpts: RouteOptions = {}
617
+ let hasBuiltins = false
618
+ let hasUser = false
619
+ for (const key of Object.keys(opts)) {
620
+ if (BUILTIN_ROUTE_OPTION_KEYS.has(key)) {
621
+ builtins[key] = opts[key]
622
+ hasBuiltins = true
623
+ } else {
624
+ userOpts[key] = opts[key]
625
+ hasUser = true
626
+ }
627
+ }
628
+ if (hasBuiltins) {
629
+ const descriptor = buildDescriptorFromBuiltins(method, path, builtins)
630
+ this.describe(method, path, descriptor)
631
+ }
632
+ if (hasUser) {
633
+ translatedHead = this.translateRouteOptions(method, path, userOpts)
634
+ }
635
+ tail = args.slice(1)
636
+ }
637
+ if (tail.length === 0) {
638
+ throw new TypeError(`IngeniumApp.${method.toLowerCase()}('${path}'): handler is required`)
639
+ }
640
+ // Pass through to Router. The Router validates handler-is-function and
641
+ // each inline middleware-is-function — we don't duplicate those checks.
642
+ this.router.method(
643
+ method,
644
+ path,
645
+ ...(translatedHead.concat(tail as IngeniumMiddleware[]) as [...IngeniumMiddleware[], IngeniumHandler]),
646
+ )
647
+ this.dirty = true
648
+ return this
649
+ }
650
+
651
+ // ───── Declarative middleware (per-route via options object) ───────────
652
+
653
+ /**
654
+ * @internal Registry of declarators. Looked up at REGISTRATION time, not
655
+ * request time — so unknown-declarator errors fire eagerly and the per-
656
+ * request hot path stays clean.
657
+ */
658
+ private readonly _declarators: Map<string, (opts: unknown) => IngeniumMiddleware> = new Map()
659
+
660
+ /**
661
+ * Register a declarator: a name → middleware-factory mapping. When a route
662
+ * registration includes an options object with that name as a key, the value
663
+ * is passed to the factory and the resulting middleware is composed into
664
+ * the route's chain (in the same position as positional inline middleware,
665
+ * but BEFORE any positional middleware on the same call).
666
+ *
667
+ * Declarators are global to the app and survive across all subsequent route
668
+ * registrations until overridden by a second `declare(name, ...)` call.
669
+ * Lookup is REGISTRATION-time: a route registered before the matching
670
+ * `declare(...)` call throws at registration with a clear hint, not at
671
+ * request time. This trades flexibility for debuggability — the error is
672
+ * caught at boot, not under load.
673
+ *
674
+ * @example
675
+ * app.declare('auth', (roles: string[]) => requireRoles(roles))
676
+ * app.declare('rateLimit', (spec: string) => parseRateLimitSpec(spec))
677
+ * app.get('/admin', { auth: ['admin'], rateLimit: '10/min' }, handler)
678
+ */
679
+ declare<O>(name: string, factory: (opts: O) => IngeniumMiddleware): this {
680
+ this._declarators.set(name, factory as (opts: unknown) => IngeniumMiddleware)
681
+ return this
682
+ }
683
+
684
+ /**
685
+ * @internal Resolve a route's options object into a list of middleware by
686
+ * looking up each key in the declarator registry. Iterates keys in object
687
+ * insertion order (ES2015+ guarantee for string keys). Throws at REG TIME
688
+ * with a contextual message when a key has no registered declarator.
689
+ */
690
+ private translateRouteOptions(
691
+ method: HttpMethod,
692
+ path: string,
693
+ opts: RouteOptions,
694
+ ): IngeniumMiddleware[] {
695
+ const out: IngeniumMiddleware[] = []
696
+ for (const key of Object.keys(opts)) {
697
+ const factory = this._declarators.get(key)
698
+ if (!factory) {
699
+ throw new Error(
700
+ `app.${method.toLowerCase()}('${path}', { ${key}: ... }, ...): unknown declarator '${key}'. ` +
701
+ `Did you forget to call app.declare('${key}', ...)?`,
702
+ )
703
+ }
704
+ out.push(factory(opts[key]))
705
+ }
706
+ return out
707
+ }
708
+
709
+ /** Register a global error handler. Re-throw to delegate to the default boundary. */
710
+ onError(handler: IngeniumErrorHandler): this {
711
+ this.errorHandler = handler
712
+ return this
713
+ }
714
+
715
+ // ───── Sinatra-style filters ───────────────────────────────────────────────
716
+
717
+ /**
718
+ * Register a `before` filter — runs BEFORE the route handler resolves.
719
+ * The user writes only the body; `await next()` is called automatically
720
+ * after it returns. If the body writes a response (e.g. `ctx.json(...)`)
721
+ * the chain short-circuits and the route handler does not run.
722
+ *
723
+ * - `before(handler)` — runs for every request (global).
724
+ * - `before(pattern, handler)` — boundary-respecting prefix match
725
+ * (`/admin/*` and `/admin` both match `/admin` and `/admin/x`, neither
726
+ * matches `/administrator`). See `sinatra/filters.ts` for details.
727
+ */
728
+ before(handler: IngeniumMiddleware): this
729
+ before(pattern: string, handler: IngeniumMiddleware): this
730
+ before(arg1: string | IngeniumMiddleware, arg2?: IngeniumMiddleware): this {
731
+ if (typeof arg1 === 'string') registerBefore(this, arg1, arg2 as IngeniumMiddleware)
732
+ else registerBefore(this, arg1)
733
+ return this
734
+ }
735
+
736
+ /**
737
+ * Register an `after` filter — runs AFTER the route handler resolves but
738
+ * BEFORE the adapter writes the response to the wire. The filter sees the
739
+ * final ctx state (status, headers, body buffer) and may inspect or
740
+ * augment it. Errors thrown by the filter propagate to the error boundary.
741
+ *
742
+ * - `after(handler)` — runs for every request (global).
743
+ * - `after(pattern, handler)` — same prefix semantics as `before`.
744
+ */
745
+ after(handler: IngeniumMiddleware): this
746
+ after(pattern: string, handler: IngeniumMiddleware): this
747
+ after(arg1: string | IngeniumMiddleware, arg2?: IngeniumMiddleware): this {
748
+ if (typeof arg1 === 'string') registerAfter(this, arg1, arg2 as IngeniumMiddleware)
749
+ else registerAfter(this, arg1)
750
+ return this
751
+ }
752
+
753
+ /**
754
+ * Attach OpenAPI metadata to a route. The route must be registered separately
755
+ * via `app.get/post/...`. Multiple calls MERGE shallowly onto the existing
756
+ * descriptor for the same `(method, path)` pair — later keys overwrite
757
+ * earlier ones, but keys absent from `meta` are preserved. This is what lets
758
+ * the inline-options form (`app.get(p, { tags: [...] }, h)`) coexist with a
759
+ * later explicit `app.describe(method, p, { summary })` without one wiping
760
+ * the other. Reads via `generateOpenApi(app)`.
761
+ */
762
+ describe(method: HttpMethod, path: string, meta: RouteDescriptor): this {
763
+ const key = descriptorKey(method, path)
764
+ const existing = this._routeDescriptors.get(key)
765
+ const merged: RouteDescriptor = existing ? { ...existing, ...meta } : meta
766
+ this._routeDescriptors.set(key, merged)
767
+ this._routeDescriptorVersion++
768
+ return this
769
+ }
770
+
771
+ /** @internal Read-only view of route descriptors — used by the OpenAPI generator. */
772
+ get routeDescriptors(): ReadonlyMap<string, RouteDescriptor> {
773
+ return this._routeDescriptors
774
+ }
775
+
776
+ /** @internal Bumps on every `describe()` call so the OpenAPI handler can cache-bust. */
777
+ get routeDescriptorVersion(): number {
778
+ return this._routeDescriptorVersion
779
+ }
780
+
781
+ /** @internal Read-only view of the registration journal — used by the OpenAPI generator. */
782
+ get routerJournal(): Router {
783
+ return this.router
784
+ }
785
+
786
+ // ───── Background jobs + cron ───────────────────────────────────────────
787
+
788
+ /**
789
+ * Register a background queue with a worker. The worker pool starts when
790
+ * the app is composed (first request, `app.compose()`, or `app.listen()`).
791
+ *
792
+ * @example
793
+ * app.queue<{ to: string; body: string }>('emails',
794
+ * { concurrency: 4, retries: 5 },
795
+ * async (job) => sendEmail(job.data),
796
+ * )
797
+ *
798
+ * // From a route handler:
799
+ * app.post('/signup', async (ctx) => {
800
+ * const u = await createUser(await ctx.body.json())
801
+ * await ctx.queue('emails').add({ to: u.email, body: 'Welcome!' })
802
+ * return { ok: true }
803
+ * })
804
+ */
805
+ queue<TData>(name: string, opts: QueueOptions<TData>, worker: QueueWorker<TData>): this
806
+ queue<TData>(name: string, worker: QueueWorker<TData>): this
807
+ queue<TData>(
808
+ name: string,
809
+ optsOrWorker: QueueOptions<TData> | QueueWorker<TData>,
810
+ maybeWorker?: QueueWorker<TData>,
811
+ ): this {
812
+ const opts: QueueOptions<TData> = typeof optsOrWorker === 'function' ? {} : optsOrWorker
813
+ const worker: QueueWorker<TData> =
814
+ typeof optsOrWorker === 'function' ? optsOrWorker : (maybeWorker as QueueWorker<TData>)
815
+ if (typeof worker !== 'function') {
816
+ throw new TypeError(`ingenium: app.queue("${name}", ...) requires a worker function`)
817
+ }
818
+ this._queues.register<TData>(name, opts, worker)
819
+ this.dirty = true
820
+ return this
821
+ }
822
+
823
+ /**
824
+ * Register a cron job. Spec is a 5-field crontab string. See
825
+ * `src/cron/parser.ts` for the supported grammar.
826
+ *
827
+ * @example
828
+ * app.cron('0 *\/15 * * *', () => refreshCaches()) // every 15 min
829
+ * app.cron('0 0 * * 0', { timezone: 'America/Los_Angeles' }, weeklyReport)
830
+ */
831
+ cron(spec: string, handler: CronHandler): this
832
+ cron(spec: string, opts: CronOptions, handler: CronHandler): this
833
+ cron(
834
+ spec: string,
835
+ optsOrHandler: CronOptions | CronHandler,
836
+ maybeHandler?: CronHandler,
837
+ ): this {
838
+ const opts: CronOptions = typeof optsOrHandler === 'function' ? {} : optsOrHandler
839
+ const handler: CronHandler =
840
+ typeof optsOrHandler === 'function' ? optsOrHandler : (maybeHandler as CronHandler)
841
+ if (typeof handler !== 'function') {
842
+ throw new TypeError('ingenium: app.cron(spec, ..., handler) requires a handler function')
843
+ }
844
+ this._crons.register(spec, opts, handler)
845
+ this.dirty = true
846
+ return this
847
+ }
848
+
849
+ /** @internal Read-only access to the queue registry for ops/test introspection. */
850
+ get queues(): QueueRegistry { return this._queues }
851
+ /** @internal Read-only access to the cron registry for ops/test introspection. */
852
+ get crons(): CronRegistry { return this._crons }
853
+
854
+ // ───── Composition ───────────────────────────────────────────────────────
855
+
856
+ /**
857
+ * Walk the registration journal and rebuild the trie with composed
858
+ * handlers at every leaf. Auto-runs on first request; safe to call
859
+ * explicitly to pre-warm.
860
+ */
861
+ /** Cached flat registrations — used to build the on-miss fallback chain. */
862
+ private _flat: ReturnType<typeof flattenRouter> | null = null
863
+
864
+ compose(): void {
865
+ // Note: this entry is synchronous. Async `onCompose` hooks are awaited
866
+ // by `composeAsync()` (the path used by `handle()` and `listen()`).
867
+ // Calling `compose()` directly skips `onCompose` — pre-warm only.
868
+ const flat = flattenRouter(this.router)
869
+ const trie = new RouterTrie()
870
+ const hasOnRoute = this._hooks.hasOnRoute()
871
+
872
+ for (const route of flat.routes) {
873
+ const node = trie.insert(route.path)
874
+
875
+ // Determine which middleware applies to this route's path.
876
+ const applicable: IngeniumMiddleware[] = [...flat.globalMiddleware]
877
+ for (const scoped of flat.scopedMiddleware) {
878
+ if (pathStartsWith(route.path, scoped.prefix)) {
879
+ applicable.push(scoped.mw)
880
+ }
881
+ }
882
+
883
+ // Splice inline middleware (positional + declarative-translated) AFTER
884
+ // global + scoped middleware AND BEFORE the terminal handler. This
885
+ // matches Express's positional-mw semantics and is the foundation
886
+ // `app.declare()` builds on.
887
+ if (route.inlineMiddleware && route.inlineMiddleware.length > 0) {
888
+ for (const mw of route.inlineMiddleware) applicable.push(mw)
889
+ }
890
+
891
+ const composed = composeWithHandler(applicable, route.handler)
892
+ node.handlers[route.method] = composed
893
+
894
+ if (hasOnRoute) {
895
+ this._hooks.runOnRoute({ method: route.method, path: route.path })
896
+ }
897
+ }
898
+
899
+ this.trie = trie
900
+ this._flat = flat
901
+ this.dirty = false
902
+
903
+ // Start cron timers + queue worker pools at compose time so they don't
904
+ // process work before the app is wired up. Both `startAll` calls are
905
+ // idempotent — safe to run on every recompose triggered by the dirty bit.
906
+ this._crons.startAll()
907
+ this._queues.startAll()
908
+
909
+ // Cache dispatch booleans so handle() can branch on field reads instead
910
+ // of `.hasAny()` calls. Re-runs on every recompose because plugins can
911
+ // be `register()`'d after listen().
912
+ this._recomputeDispatchFlags()
913
+ }
914
+
915
+ /**
916
+ * Async composition entry — runs `onCompose` hooks first, then composes.
917
+ * Used by `handle()` and `listen()` when there are async pre-compose
918
+ * listeners. Sync-only composition still works via `compose()` above.
919
+ */
920
+ private async composeAsync(): Promise<void> {
921
+ if (this._hooks.hasOnCompose()) {
922
+ await this._hooks.runOnCompose()
923
+ }
924
+ this.compose()
925
+ }
926
+
927
+ // ───── Dispatch entry point (used by transports and tests) ───────────────
928
+
929
+ /**
930
+ * Dispatch a single context through the framework. Handles route lookup,
931
+ * 404/405 generation, and the error boundary. The transport is responsible
932
+ * for populating the request side of the context and writing the response
933
+ * side after this resolves.
934
+ */
935
+ async handle(ctx: IngeniumContext): Promise<void> {
936
+ if (this.dirty) await this.composeAsync()
937
+
938
+ // Back-reference so app-aware handlers (e.g. openapiHandler) can reach
939
+ // the app from inside a route handler. ctx.state is reset per request
940
+ // by the pool, so this doesn't leak between requests.
941
+ ctx.state._ingeniumApp = this
942
+
943
+ // Stamp cookie secrets ONLY when configured. Same pattern as `_trustProxy`:
944
+ // apps that don't use signed cookies pay zero (the field load on the ctx
945
+ // returns the default empty-array initialised at class-field stamp time).
946
+ // We don't reset this between requests because it's app-wide config — the
947
+ // reassignment is idempotent against the same frozen array reference.
948
+ if (this._hasCookieSecrets) ctx._cookieSecrets = this._cookieSecrets
949
+
950
+ // ───── Fast path ─────────────────────────────────────────────────────
951
+ // Apps with no plugins, no decorators, no timeout, and no trust-proxy
952
+ // skip the entire branch ladder below. This is the typical Sinatra-shape
953
+ // app — register routes, listen, done. Bench impact: meaningfully better
954
+ // hello-rps because we don't pay for features the app doesn't use.
955
+ if (this._useFastPath) {
956
+ try {
957
+ const match = this.trie.find(ctx.method, ctx.path)
958
+ if ('handler' in match) {
959
+ if (match.params !== EMPTY_PARAMS) ctx.params = match.params as never
960
+ await match.handler(ctx)
961
+ return
962
+ }
963
+ await this.runFallback(ctx, missToError(match))
964
+ } catch (err) {
965
+ await this.handleError(err, ctx)
966
+ }
967
+ return
968
+ }
969
+
970
+ // ───── Full path (plugins / decorators / timeout / trust-proxy on) ──
971
+ // Stamp trust-proxy config so ctx.ip/protocol/hostname resolve correctly.
972
+ if (this._hasTrustProxy) ctx._trustProxy = this._trustProxy
973
+
974
+ // All booleans below are pre-computed at compose() time — no per-request
975
+ // `.hasAny()` array scans. See `_recomputeDispatchFlags`.
976
+ const hooks = this._hooks
977
+ const decorators = this._decorators
978
+
979
+ try {
980
+ if (this._hasOnRequest) {
981
+ await hooks.runOnRequest(ctx)
982
+ }
983
+ if (this._hasDecorators) {
984
+ decorators.applyTo(ctx)
985
+ }
986
+
987
+ const match = this.trie.find(ctx.method, ctx.path)
988
+ if ('handler' in match) {
989
+ // Only assign params when the route actually has them — otherwise
990
+ // ctx.params already points at the frozen empty sentinel from reset.
991
+ if (match.params !== EMPTY_PARAMS) {
992
+ ctx.params = match.params as never
993
+ }
994
+ // Wrap the dispatch in a wall-clock race AND a closure-bound
995
+ // late-write guard on the response writers. The race is HTTP-
996
+ // request-scoped only — WS / SSE upgrades bypass this path because
997
+ // they're dispatched through their own adapters and never resolve
998
+ // back to a normal HTTP response. See `withEpochGuard` for the
999
+ // mechanism that prevents orphaned handlers from corrupting the
1000
+ // next request bound to this same pooled context instance.
1001
+ if (this._hasTimeout) {
1002
+ await withEpochGuard(ctx, this._requestTimeoutMs as number, () => match.handler(ctx))
1003
+ } else {
1004
+ await match.handler(ctx)
1005
+ }
1006
+
1007
+ if (this._hasOnResponse) {
1008
+ await hooks.runOnResponse(ctx)
1009
+ }
1010
+ return
1011
+ }
1012
+ // Miss — but middleware that mounts a path-handler (e.g. `ingenium.static()`)
1013
+ // expects to run on requests that don't match a registered route. Build a
1014
+ // fall-through chain from any global + mount-prefix-matching middleware
1015
+ // and let it have a shot. If it writes the response, we're done; if it
1016
+ // calls next() or doesn't write, we surface the original 404/405.
1017
+ await this.runFallback(ctx, missToError(match))
1018
+ if (this._hasOnResponse) {
1019
+ await hooks.runOnResponse(ctx)
1020
+ }
1021
+ } catch (err) {
1022
+ // Observation hook fires BEFORE the error boundary writes a response.
1023
+ // The boundary still owns the actual response — these hooks cannot
1024
+ // swallow or replace the error.
1025
+ if (this._hasOnError) {
1026
+ await hooks.runOnError(err, ctx)
1027
+ }
1028
+ await this.handleError(err, ctx)
1029
+ }
1030
+ }
1031
+
1032
+ /**
1033
+ * Run global + path-matching scoped middleware as a fallback chain when the
1034
+ * trie has no matching route. The terminal handler re-throws the original
1035
+ * trie miss so the error boundary still produces 404/405 if no middleware
1036
+ * wrote the response. Composed per-request — misses are exceptional.
1037
+ */
1038
+ private async runFallback(ctx: IngeniumContext, miss: IngeniumError): Promise<void> {
1039
+ const flat = this._flat
1040
+ if (!flat) {
1041
+ throw miss
1042
+ }
1043
+ const applicable: IngeniumMiddleware[] = [...flat.globalMiddleware]
1044
+ for (const scoped of flat.scopedMiddleware) {
1045
+ if (pathStartsWith(ctx.path, scoped.prefix)) applicable.push(scoped.mw)
1046
+ }
1047
+ if (applicable.length === 0) {
1048
+ throw miss
1049
+ }
1050
+ // No-op terminal — let middleware finish completely (including post-next
1051
+ // hooks). If nothing wrote a response, surface the trie miss as a 404/405.
1052
+ const chain = composeWithHandler(applicable, () => {})
1053
+ await chain(ctx)
1054
+ if (!ctx._written) throw miss
1055
+ }
1056
+
1057
+ private async handleError(err: unknown, ctx: IngeniumContext): Promise<void> {
1058
+ // Reset response state in case a partial helper had been called.
1059
+ if (this.errorHandler) {
1060
+ try {
1061
+ await this.errorHandler(err, ctx)
1062
+ return
1063
+ } catch (rethrow) {
1064
+ err = rethrow
1065
+ }
1066
+ }
1067
+ writeDefaultError(err, ctx)
1068
+ }
1069
+
1070
+ // ───── In-process test client ──────────────────────────────────────────
1071
+
1072
+ /**
1073
+ * Dispatch a synthetic request through the SAME path as the wire — no
1074
+ * socket, no transport. Acquires a pooled context, populates it exactly as
1075
+ * the Node adapter would from an `IncomingMessage`, runs `handle()` (so
1076
+ * routing, 404/405, middleware, hooks, and the error boundary all behave
1077
+ * identically), then materializes the response into a plain {@link InjectResponse}.
1078
+ *
1079
+ * Crucially the context is extracted FULLY and only THEN released back to
1080
+ * the pool: releasing bumps `_epoch` and reassigns `_headers`/`_body`, so
1081
+ * reading them after release would surface the next request's state (or
1082
+ * empty). The returned object holds copies, so it survives the recycle and
1083
+ * sequential `inject()` calls never bleed header/body state into each other.
1084
+ *
1085
+ * @example
1086
+ * const res = await app.inject({ method: 'POST', url: '/users', body: { name: 'a' } })
1087
+ * expect(res.status).toBe(201)
1088
+ * expect(res.json<{ id: string }>().id).toBeDefined()
1089
+ */
1090
+ async inject(opts: InjectRequest): Promise<InjectResponse> {
1091
+ if (this.dirty) await this.composeAsync()
1092
+
1093
+ const ctx = this.pool.acquire()
1094
+ try {
1095
+ populateInjectContext(ctx, opts)
1096
+ await this.handle(ctx)
1097
+ // Extract EVERYTHING before release — see the doc comment above.
1098
+ return await extractInjectResponse(ctx)
1099
+ } finally {
1100
+ this.pool.release(ctx)
1101
+ }
1102
+ }
1103
+
1104
+ // ───── Transport ─────────────────────────────────────────────────────────
1105
+
1106
+ /** Bind a port and accept requests. Returns a handle for graceful shutdown. */
1107
+ async listen(port: number, host?: string): Promise<ListeningServer> {
1108
+ // Double-listen guard. Binding a second server to the same app means two
1109
+ // ports feeding one context pool — almost always a copy-paste bug. Throw a
1110
+ // clear TypeError instead of silently double-binding. Re-listening after a
1111
+ // clean `close()` is allowed (the flag is cleared there).
1112
+ if (this._listening) {
1113
+ throw new TypeError(
1114
+ 'ingenium: app.listen() called while already listening. Call close() on the ' +
1115
+ 'returned server handle before listening again, or create a separate app.',
1116
+ )
1117
+ }
1118
+ if (this.dirty) await this.composeAsync()
1119
+ this.transport.attach({
1120
+ acquire: () => this.pool.acquire(),
1121
+ release: (ctx) => this.pool.release(ctx),
1122
+ dispatch: (ctx) => this.handle(ctx),
1123
+ maxRequestBytes: this._maxRequestBytes,
1124
+ })
1125
+ const handle = host !== undefined
1126
+ ? await this.transport.listen(port, host)
1127
+ : await this.transport.listen(port)
1128
+
1129
+ // Mark listening only AFTER the bind succeeds — a failed bind (e.g. port
1130
+ // in use) leaves the app re-listenable.
1131
+ this._listening = true
1132
+
1133
+ // Wrap the underlying close so cron timers + queue worker pools are torn
1134
+ // down as part of graceful shutdown. Sockets and queues drain in parallel
1135
+ // (wall-clock bounded by max(socket-drain, queue-drain), not their sum).
1136
+ const drainTimeout = this._queueDrainTimeoutMs
1137
+ const queues = this._queues
1138
+ const crons = this._crons
1139
+ const wrappedClose: ListeningServer['close'] = async (closeOpts) => {
1140
+ // Allow a fresh listen() once shutdown begins. Cleared before the await
1141
+ // so a re-listen racing the drain still binds (the old server is already
1142
+ // refusing new connections via server.close()).
1143
+ this._listening = false
1144
+ // Stop cron tickers FIRST so they don't enqueue new work mid-shutdown.
1145
+ crons.stopAll()
1146
+ const queueDrain = queues.drainAll(drainTimeout)
1147
+ const socketClose = handle.close(closeOpts)
1148
+ const [, drainResult] = await Promise.all([socketClose, queueDrain])
1149
+ if (drainResult.timedOut.length > 0) {
1150
+ try {
1151
+ process.emitWarning(
1152
+ `ingenium: queues did not drain within ${drainTimeout}ms: ${drainResult.timedOut.join(', ')}`,
1153
+ { type: 'IngeniumQueueDrainWarning' },
1154
+ )
1155
+ } catch {
1156
+ /* worker contexts may throw on emitWarning */
1157
+ }
1158
+ }
1159
+ }
1160
+ return { ...handle, close: wrappedClose }
1161
+ }
1162
+ }
1163
+
1164
+ function missToError(miss: MatchMiss): IngeniumError {
1165
+ if (miss.kind === 'not-found') return new IngeniumNotFoundError()
1166
+ return new IngeniumMethodNotAllowedError(miss.allowed)
1167
+ }
1168
+
1169
+ function writeDefaultError(err: unknown, ctx: IngeniumContext): void {
1170
+ if (err instanceof IngeniumHaltError) {
1171
+ // Halt body shape was decided at the call site (`ctx.halt`):
1172
+ // - 'text' → body is a string, write as text/plain verbatim
1173
+ // - 'json' → body is an object, write as application/json verbatim
1174
+ // - 'none' → no body provided, fall through to default JSON shape
1175
+ if (err.bodyShape === 'text') {
1176
+ ctx.text(err.body as string, err.statusCode)
1177
+ return
1178
+ }
1179
+ if (err.bodyShape === 'json') {
1180
+ ctx.json(err.body as Record<string, unknown>, err.statusCode)
1181
+ return
1182
+ }
1183
+ ctx.json({ error: err.message, code: err.code }, err.statusCode)
1184
+ return
1185
+ }
1186
+ if (err instanceof IngeniumError) {
1187
+ if (err instanceof IngeniumMethodNotAllowedError) {
1188
+ ctx.set('allow', err.allowed.join(', '))
1189
+ }
1190
+ const payload: Record<string, unknown> = { error: err.message, code: err.code }
1191
+ if ('fields' in err && err.fields) payload.fields = err.fields
1192
+ ctx.json(payload, err.statusCode)
1193
+ return
1194
+ }
1195
+ // Unknown error → 500
1196
+ const message = (err as Error)?.message ?? 'Internal Server Error'
1197
+ ctx.json({ error: message, code: 'INTERNAL_ERROR' }, 500)
1198
+ }
1199
+
1200
+ /**
1201
+ * Populate a pooled context from {@link InjectRequest}, mirroring the Node
1202
+ * adapter's `populateContext`. Splits path / query, lowercases header names,
1203
+ * normalizes the body into a `Readable` (so `ctx.body.*` consumers behave
1204
+ * exactly as they do on the wire), and tags JSON bodies with a content-type
1205
+ * unless the caller set one.
1206
+ */
1207
+ function populateInjectContext(ctx: IngeniumContext, opts: InjectRequest): void {
1208
+ ctx.method = opts.method ?? 'GET'
1209
+ ctx.url = opts.url
1210
+ const qIdx = opts.url.indexOf('?')
1211
+ if (qIdx >= 0) {
1212
+ ctx.path = opts.url.slice(0, qIdx)
1213
+ ctx.rawQuery = opts.url.slice(qIdx + 1)
1214
+ } else {
1215
+ ctx.path = opts.url
1216
+ ctx.rawQuery = ''
1217
+ }
1218
+
1219
+ // Lowercase header names (node:http convention). Collect into the bag the
1220
+ // handler reads via ctx.headers[...].
1221
+ const headers: Record<string, string | string[]> = {}
1222
+ if (opts.headers) {
1223
+ for (const name of Object.keys(opts.headers)) {
1224
+ headers[name.toLowerCase()] = opts.headers[name] as string | string[]
1225
+ }
1226
+ }
1227
+
1228
+ ctx.remoteAddress = opts.remoteAddress ?? '127.0.0.1'
1229
+ ctx.baseProtocol = 'http'
1230
+
1231
+ // Normalize the body into raw bytes. Objects → JSON (+ default content-type
1232
+ // unless the caller already set one). Strings/Buffers/Uint8Arrays pass
1233
+ // through verbatim. `undefined` → no body.
1234
+ let bodyBuf: Buffer | null = null
1235
+ const body = opts.body
1236
+ if (body !== undefined) {
1237
+ if (typeof body === 'string') {
1238
+ bodyBuf = Buffer.from(body, 'utf8')
1239
+ } else if (Buffer.isBuffer(body)) {
1240
+ bodyBuf = body
1241
+ } else if (body instanceof Uint8Array) {
1242
+ bodyBuf = Buffer.from(body)
1243
+ } else {
1244
+ // Plain object / array → JSON. Auto-tag content-type unless explicit.
1245
+ bodyBuf = Buffer.from(JSON.stringify(body), 'utf8')
1246
+ if (headers['content-type'] === undefined) {
1247
+ headers['content-type'] = 'application/json'
1248
+ }
1249
+ }
1250
+ }
1251
+
1252
+ ctx.headers = headers
1253
+
1254
+ if (bodyBuf !== null) {
1255
+ const ct = headers['content-type']
1256
+ ctx.body._attach(
1257
+ Readable.from(bodyBuf),
1258
+ Array.isArray(ct) ? ct[0] : ct,
1259
+ bodyBuf.length,
1260
+ )
1261
+ } else {
1262
+ ctx.body._attach(null, undefined, undefined)
1263
+ }
1264
+ }
1265
+
1266
+ /**
1267
+ * Materialize a dispatched context's response into a plain {@link InjectResponse}.
1268
+ * Clones the header bag (array values copied) so callers never alias the
1269
+ * pooled context's `_headers`, and drains a stream body to a UTF-8 string.
1270
+ * MUST be called before the context is released back to the pool.
1271
+ */
1272
+ async function extractInjectResponse(ctx: IngeniumContext): Promise<InjectResponse> {
1273
+ const status = ctx._statusCode
1274
+
1275
+ // Deep-ish copy of headers — array values cloned so a later inject() can't
1276
+ // mutate this snapshot (and vice versa).
1277
+ const headers: Record<string, string | string[]> = {}
1278
+ for (const key of Object.keys(ctx._headers)) {
1279
+ const v = ctx._headers[key]
1280
+ if (v === undefined) continue
1281
+ headers[key] = Array.isArray(v) ? v.slice() : v
1282
+ }
1283
+
1284
+ let body = ''
1285
+ const rb = ctx._body
1286
+ switch (rb.kind) {
1287
+ case 'string':
1288
+ body = rb.data
1289
+ break
1290
+ case 'buffer':
1291
+ body = rb.data.toString('utf8')
1292
+ break
1293
+ case 'stream': {
1294
+ const chunks: Buffer[] = []
1295
+ for await (const chunk of rb.data) {
1296
+ chunks.push(typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : (chunk as Buffer))
1297
+ }
1298
+ body = Buffer.concat(chunks).toString('utf8')
1299
+ break
1300
+ }
1301
+ case 'none':
1302
+ body = ''
1303
+ break
1304
+ }
1305
+
1306
+ return {
1307
+ status,
1308
+ headers,
1309
+ body,
1310
+ json<T = unknown>(): T {
1311
+ return JSON.parse(body) as T
1312
+ },
1313
+ }
1314
+ }
1315
+
1316
+ /**
1317
+ * Detect whether a value is a "plain options object" — i.e. the declarative
1318
+ * route-options shape (`{ auth: [...], rateLimit: '...' }`) — vs anything
1319
+ * else that might be in argument-position 0 (a middleware function, a
1320
+ * handler function, a class instance, a Buffer, an Array, etc).
1321
+ *
1322
+ * Rule: must be non-null, `typeof === 'object'`, NOT a function, NOT an
1323
+ * Array, and `Object.getPrototypeOf(v)` is either `Object.prototype` or
1324
+ * `null` (covers `Object.create(null)` bags). Class instances have a
1325
+ * non-Object prototype and are correctly rejected. This is a stricter check
1326
+ * than `typeof === 'object'` alone — we want false positives to be near-
1327
+ * impossible because misclassifying a middleware as options would bury the
1328
+ * mistake under "unknown declarator" errors.
1329
+ */
1330
+ function isPlainOptionsObject(v: unknown): v is RouteOptions {
1331
+ if (v === null || typeof v !== 'object') return false
1332
+ if (typeof v === 'function') return false
1333
+ if (Array.isArray(v)) return false
1334
+ const proto = Object.getPrototypeOf(v) as object | null
1335
+ return proto === null || proto === Object.prototype
1336
+ }
1337
+
1338
+ /**
1339
+ * Translate the peeled-off built-in slice of a route-options object into the
1340
+ * shape `app.describe()` expects. Pass-through for plain OpenAPI keys
1341
+ * (`summary`, `description`, `operationId`, `tags`, `deprecated`, `security`,
1342
+ * `parameters`); normalization for `response` / `requestBody` which the user
1343
+ * may write in compact form (a bare Schema). See `normalizeResponse` /
1344
+ * `normalizeRequestBody` for the rules. Throws at REGISTRATION time on any
1345
+ * inline value that looks like a live validator (Standard Schema or Zod-style
1346
+ * `safeParse`) — we don't try to convert validators to JSON Schema from this
1347
+ * code path; callers can precompute and use `app.describe()` directly.
1348
+ */
1349
+ function buildDescriptorFromBuiltins(
1350
+ method: HttpMethod,
1351
+ path: string,
1352
+ builtins: Record<string, unknown>,
1353
+ ): RouteDescriptor {
1354
+ const desc: RouteDescriptor = {}
1355
+ if ('summary' in builtins) desc.summary = builtins.summary as string
1356
+ if ('description' in builtins) desc.description = builtins.description as string
1357
+ if ('operationId' in builtins) desc.operationId = builtins.operationId as string
1358
+ if ('tags' in builtins) desc.tags = builtins.tags as string[]
1359
+ if ('deprecated' in builtins) desc.deprecated = builtins.deprecated as boolean
1360
+ if ('security' in builtins) desc.security = builtins.security as NonNullable<RouteDescriptor['security']>
1361
+ if ('parameters' in builtins) desc.parameters = builtins.parameters as NonNullable<RouteDescriptor['parameters']>
1362
+ if ('response' in builtins) {
1363
+ desc.responses = normalizeResponse(method, path, builtins.response)
1364
+ }
1365
+ if ('requestBody' in builtins) {
1366
+ desc.requestBody = normalizeRequestBody(method, path, builtins.requestBody)
1367
+ }
1368
+ return desc
1369
+ }
1370
+
1371
+ /**
1372
+ * Reject inline schema values that need conversion we don't perform at this
1373
+ * seam. We accept ONLY raw OpenAPI Schema objects (plain JSON Schema literals,
1374
+ * including the wrappers Zod 3.24+/ArkType/Effect emit when the caller has
1375
+ * already called `.toJsonSchema()`). Anything that still carries a live
1376
+ * `~standard` or `safeParse` is a precondition violation: it's the caller's
1377
+ * job to convert. The OpenAPI generator's runtime conversion path handles
1378
+ * validators passed through the long-form `app.describe()` API; this inline
1379
+ * convenience form intentionally stays conversion-free so the failure mode
1380
+ * is at registration, not at spec-generation time inside a healthcheck.
1381
+ */
1382
+ function validateInlineSchema(
1383
+ method: HttpMethod,
1384
+ path: string,
1385
+ location: 'response' | 'requestBody',
1386
+ value: unknown,
1387
+ ): void {
1388
+ if (value === null || typeof value !== 'object') return
1389
+ if (isStandardSchema(value)) {
1390
+ throw new TypeError(
1391
+ `app.${method.toLowerCase()}('${path}', { ${location}: ... }, handler): inline schema conversion isn't supported yet. ` +
1392
+ `Pass a plain OpenAPI Schema object, or use app.describe() with a precomputed schema.`,
1393
+ )
1394
+ }
1395
+ const maybeZod = value as { safeParse?: unknown }
1396
+ if (typeof maybeZod.safeParse === 'function') {
1397
+ throw new TypeError(
1398
+ `app.${method.toLowerCase()}('${path}', { ${location}: ... }, handler): inline schema conversion isn't supported yet. ` +
1399
+ `Pass a plain OpenAPI Schema object, or use app.describe() with a precomputed schema.`,
1400
+ )
1401
+ }
1402
+ }
1403
+
1404
+ /**
1405
+ * Three-digit status-code key heuristic — `200`, `404`, `4XX` are all
1406
+ * accepted. Used to distinguish "status-keyed response map" from "bare
1407
+ * schema with `200`-shaped property names." Keys longer than 3 chars, with
1408
+ * non-digit/non-X content, or starting with `0` are NOT status codes.
1409
+ */
1410
+ function isStatusKey(k: string): boolean {
1411
+ if (k.length !== 3) return false
1412
+ if (k[0] === '0') return false
1413
+ for (let i = 0; i < 3; i++) {
1414
+ const c = k.charCodeAt(i)
1415
+ // 0-9 or X / x
1416
+ if (!((c >= 48 && c <= 57) || c === 88 || c === 120)) return false
1417
+ }
1418
+ return true
1419
+ }
1420
+
1421
+ /**
1422
+ * Heuristic: does this look like a JSON Schema literal? Detects the common
1423
+ * top-level keywords. Used to choose between `{ response: SchemaLiteral }`
1424
+ * (wrap as 200) and `{ response: { '200': {...}, '404': {...} } }`
1425
+ * (status-keyed map). The two shapes are unambiguous in practice because a
1426
+ * status-keyed map's keys are all 3-char digits and JSON Schema keywords
1427
+ * never are.
1428
+ */
1429
+ function looksLikeSchemaLiteral(v: object): boolean {
1430
+ const obj = v as Record<string, unknown>
1431
+ return (
1432
+ 'type' in obj ||
1433
+ 'properties' in obj ||
1434
+ '$ref' in obj ||
1435
+ 'oneOf' in obj ||
1436
+ 'anyOf' in obj ||
1437
+ 'allOf' in obj ||
1438
+ 'enum' in obj ||
1439
+ 'const' in obj ||
1440
+ 'items' in obj
1441
+ )
1442
+ }
1443
+
1444
+ /**
1445
+ * Normalize the inline `response` option into the descriptor's `responses`
1446
+ * map. Three accepted shapes:
1447
+ * 1. A bare OpenAPI Schema → wrapped as `200 application/json` content.
1448
+ * 2. A status-keyed map (`{ '200': {...}, '404': {...} }`) where each entry
1449
+ * is either a full `Response` object or a bare Schema.
1450
+ * 3. An already-shaped `Response` (has `description` and/or `content`) for
1451
+ * the 200 slot.
1452
+ *
1453
+ * Picks (1) vs (2) by inspecting the object's keys: if any key is a 3-digit
1454
+ * status code (`200`, `4XX`), it's (2); if the object looks like a JSON
1455
+ * Schema literal (`type`/`properties`/`$ref`/...), it's (1); otherwise we
1456
+ * fall back to (3) — wrap the value as the 200 Response.
1457
+ */
1458
+ function normalizeResponse(
1459
+ method: HttpMethod,
1460
+ path: string,
1461
+ value: unknown,
1462
+ ): Record<string, Response> {
1463
+ validateInlineSchema(method, path, 'response', value)
1464
+ if (value === null || typeof value !== 'object') {
1465
+ // A non-object is not a meaningful response shape. Wrap as an opaque
1466
+ // 200 schema so the caller at least sees something in the spec.
1467
+ return {
1468
+ '200': {
1469
+ description: 'OK',
1470
+ content: { 'application/json': { schema: value as Schema } },
1471
+ },
1472
+ }
1473
+ }
1474
+ const obj = value as Record<string, unknown>
1475
+ const keys = Object.keys(obj)
1476
+ const allStatus = keys.length > 0 && keys.every(isStatusKey)
1477
+
1478
+ if (allStatus) {
1479
+ const out: Record<string, Response> = {}
1480
+ for (const k of keys) {
1481
+ out[k] = coerceResponseEntry(method, path, obj[k])
1482
+ }
1483
+ return out
1484
+ }
1485
+
1486
+ // Single 200 entry. If it already looks like a Response (has description
1487
+ // and/or content), pass it through; otherwise wrap as JSON content.
1488
+ if ('content' in obj || ('description' in obj && !looksLikeSchemaLiteral(obj))) {
1489
+ return { '200': obj as unknown as Response }
1490
+ }
1491
+ return {
1492
+ '200': {
1493
+ description: 'OK',
1494
+ content: { 'application/json': { schema: obj as Schema } },
1495
+ },
1496
+ }
1497
+ }
1498
+
1499
+ /**
1500
+ * Coerce a single value from a status-keyed `response` map into a `Response`.
1501
+ * Accepts either a full `Response` (passed through) or a bare schema (wrapped
1502
+ * as `application/json` content with a default description).
1503
+ */
1504
+ function coerceResponseEntry(method: HttpMethod, path: string, value: unknown): Response {
1505
+ validateInlineSchema(method, path, 'response', value)
1506
+ if (value === null || typeof value !== 'object') {
1507
+ return {
1508
+ description: 'Response',
1509
+ content: { 'application/json': { schema: value as Schema } },
1510
+ }
1511
+ }
1512
+ const obj = value as Record<string, unknown>
1513
+ if ('content' in obj || ('description' in obj && !looksLikeSchemaLiteral(obj))) {
1514
+ return obj as unknown as Response
1515
+ }
1516
+ return {
1517
+ description: 'Response',
1518
+ content: { 'application/json': { schema: obj as Schema } },
1519
+ }
1520
+ }
1521
+
1522
+ /**
1523
+ * Normalize the inline `requestBody` option into a full `RequestBody` shape.
1524
+ * If the value already has a `content` field (an OpenAPI RequestBody),
1525
+ * pass it through; otherwise wrap as `application/json` content.
1526
+ */
1527
+ function normalizeRequestBody(
1528
+ method: HttpMethod,
1529
+ path: string,
1530
+ value: unknown,
1531
+ ): RequestBody {
1532
+ validateInlineSchema(method, path, 'requestBody', value)
1533
+ if (value === null || typeof value !== 'object') {
1534
+ return { content: { 'application/json': { schema: value as Schema } } }
1535
+ }
1536
+ const obj = value as Record<string, unknown>
1537
+ if ('content' in obj) return obj as unknown as RequestBody
1538
+ return { content: { 'application/json': { schema: obj as Schema } } }
1539
+ }
1540
+
1541
+ /**
1542
+ * Race a dispatched handler against a wall-clock deadline. Resolves when
1543
+ * the handler does, or rejects with `IngeniumTimeoutError` if the deadline
1544
+ * fires first. The handler promise is NOT cancelled (JS can't) — it's just
1545
+ * orphaned. We bump `ctx._epoch` at timeout so any subsequent writes from
1546
+ * the orphaned handler hit the epoch-guard installed around the dispatch
1547
+ * and get swallowed instead of corrupting the next request.
1548
+ *
1549
+ * The timer is `unref`'d so a fast handler that resolves naturally doesn't
1550
+ * keep the event loop alive waiting for the timeout to fire.
1551
+ */
1552
+ function raceWithTimeout(
1553
+ dispatch: Promise<unknown> | unknown,
1554
+ timeoutMs: number,
1555
+ ctx: IngeniumContext,
1556
+ capturedEpoch: number,
1557
+ ): Promise<void> {
1558
+ return new Promise<void>((resolve, reject) => {
1559
+ let settled = false
1560
+ const timer = setTimeout(() => {
1561
+ if (settled) return
1562
+ settled = true
1563
+ // Bump the epoch so the orphaned handler's late writes are detected
1564
+ // by the guard wrappers as stale and discarded. This is what
1565
+ // prevents cross-request response corruption when the ctx is
1566
+ // recycled and rebound to a new request.
1567
+ if (ctx._epoch === capturedEpoch) ctx._epoch++
1568
+ reject(new IngeniumTimeoutError(timeoutMs))
1569
+ }, timeoutMs)
1570
+ // Crucially, never keep the event loop alive. A handler that resolves
1571
+ // in 5ms with a 30s timeout configured should not block process exit.
1572
+ timer.unref?.()
1573
+ Promise.resolve(dispatch).then(
1574
+ (v) => {
1575
+ if (settled) return
1576
+ settled = true
1577
+ clearTimeout(timer)
1578
+ resolve(v as void)
1579
+ },
1580
+ (err) => {
1581
+ if (settled) return
1582
+ settled = true
1583
+ clearTimeout(timer)
1584
+ reject(err)
1585
+ },
1586
+ )
1587
+ })
1588
+ }
1589
+
1590
+ /**
1591
+ * Per-dispatch async-context store. Carries the epoch captured at dispatch
1592
+ * entry through every `await` in the handler chain. The orphaned handler
1593
+ * of a timed-out dispatch keeps running inside its OWN ALS frame even
1594
+ * after `Promise.race` rejects in `handle()`, so its `als.getStore()`
1595
+ * still returns the original captured epoch. The next request runs in a
1596
+ * different ALS frame (because `als.run(...)` was called fresh), so its
1597
+ * store is its own captured epoch. This is the ONLY reliable way to
1598
+ * distinguish "the orphan calling `ctx.json`" from "the legitimate next
1599
+ * request calling `ctx.json`" — ctx state alone cannot.
1600
+ */
1601
+ const dispatchEpochStore: AsyncLocalStorage<number> = new AsyncLocalStorage()
1602
+
1603
+ /**
1604
+ * Run `fn()` under both the wall-clock race AND a per-dispatch late-write
1605
+ * guard. The guard installs closure-bound wrappers around `ctx`'s response
1606
+ * writers; each wrapper checks `dispatchEpochStore.getStore()` (carried by
1607
+ * `AsyncLocalStorage` through every `await` in the handler chain) against
1608
+ * the epoch captured at dispatch entry. When they diverge — because the
1609
+ * timeout path bumped `ctx._epoch` and we're now executing inside a stale
1610
+ * orphan continuation — the wrapper swallows the write and emits a single
1611
+ * `IngeniumLateWriteWarning` so leaks remain observable in production.
1612
+ *
1613
+ * Behavior:
1614
+ * - Dispatch resolves naturally → restore originals (no orphan possible).
1615
+ * - Dispatch times out → bump `_epoch` (so any check against the captured
1616
+ * value fails), `Promise.race` rejects with `IngeniumTimeoutError`, the
1617
+ * error boundary writes the 503. The orphaned handler keeps running in
1618
+ * its own ALS frame; its eventual `ctx.json(...)` is detected as stale
1619
+ * either by (a) the still-installed wrapper (if no new request has
1620
+ * re-wrapped) — store mismatch → swallow; or (b) the next request's
1621
+ * wrapper, which compares the orphan's ALS-store epoch against its own
1622
+ * captured value → mismatch → swallow.
1623
+ *
1624
+ * The error boundary itself runs OUTSIDE the orphan's ALS frame (it
1625
+ * executes synchronously after the `await` in `handle()`), so its
1626
+ * `als.getStore()` returns `undefined`, which the wrapper treats as "no
1627
+ * guard active for this caller" and lets through.
1628
+ *
1629
+ * The timer is `unref`'d so a fast handler that resolves naturally doesn't
1630
+ * keep the event loop alive waiting for the timeout to fire.
1631
+ */
1632
+ async function withEpochGuard(
1633
+ ctx: IngeniumContext,
1634
+ timeoutMs: number,
1635
+ fn: () => Promise<unknown> | unknown,
1636
+ ): Promise<void> {
1637
+ const capturedEpoch = ctx._epoch
1638
+ ctx._dispatchEpoch = capturedEpoch
1639
+
1640
+ // Snapshot originals — un-wrapped methods we restore on dispatch end.
1641
+ const originals = {
1642
+ json: ctx.json,
1643
+ text: ctx.text,
1644
+ html: ctx.html,
1645
+ send: ctx.send,
1646
+ redirect: ctx.redirect,
1647
+ stream: ctx.stream,
1648
+ }
1649
+
1650
+ // The closure + ALS combined check. `capturedEpoch` is the per-dispatch
1651
+ // identity. We swallow ONLY when this wrapper's own orphan is calling:
1652
+ // the ALS store still says "you're inside dispatch N" (because the
1653
+ // orphan kept running its async chain past the timeout) BUT the live
1654
+ // `_epoch` has moved on (because the timeout path bumped it). For every
1655
+ // other caller — the legitimate handler under THIS dispatch, the error
1656
+ // boundary (no store), the next dispatch's handler (different store) —
1657
+ // we pass through to the underlying writer. With wrappers stacked
1658
+ // across recycled contexts, each layer detects only its own orphan and
1659
+ // forwards everything else; the deepest layer is the original writer.
1660
+ const guarded = (orig: (...args: never[]) => unknown) =>
1661
+ function guardedWriter(this: IngeniumContext, ...args: unknown[]): unknown {
1662
+ const callerEpoch = dispatchEpochStore.getStore()
1663
+ // Only my-own-orphan path swallows: caller is in the same ALS frame
1664
+ // I installed, but the live epoch has been bumped past my captured
1665
+ // value (timeout fired or the ctx was recycled).
1666
+ if (callerEpoch === capturedEpoch && ctx._epoch !== capturedEpoch) {
1667
+ try {
1668
+ process.emitWarning(
1669
+ 'Late response write after timeout — handler may be leaking',
1670
+ { type: 'IngeniumLateWriteWarning' },
1671
+ )
1672
+ } catch {
1673
+ // process.emitWarning can throw in unusual runtimes (workers); swallow.
1674
+ }
1675
+ return undefined
1676
+ }
1677
+ return (orig as (...a: unknown[]) => unknown).apply(ctx, args)
1678
+ }
1679
+
1680
+ ctx.json = guarded(originals.json) as typeof ctx.json
1681
+ ctx.text = guarded(originals.text) as typeof ctx.text
1682
+ ctx.html = guarded(originals.html) as typeof ctx.html
1683
+ ctx.send = guarded(originals.send) as typeof ctx.send
1684
+ ctx.redirect = guarded(originals.redirect) as typeof ctx.redirect
1685
+ ctx.stream = guarded(originals.stream) as typeof ctx.stream
1686
+
1687
+ const restore = (): void => {
1688
+ ctx.json = originals.json
1689
+ ctx.text = originals.text
1690
+ ctx.html = originals.html
1691
+ ctx.send = originals.send
1692
+ ctx.redirect = originals.redirect
1693
+ ctx.stream = originals.stream
1694
+ ctx._dispatchEpoch = 0
1695
+ }
1696
+
1697
+ try {
1698
+ await dispatchEpochStore.run(capturedEpoch, () =>
1699
+ raceWithTimeout(fn(), timeoutMs, ctx, capturedEpoch),
1700
+ )
1701
+ // Success path: no orphan can exist. Restore originals so we don't
1702
+ // accumulate dead wrappers on the pooled context across thousands of
1703
+ // successful requests.
1704
+ restore()
1705
+ } catch (err) {
1706
+ // Failure path. If it's a timeout, the orphaned handler is still alive
1707
+ // and may eventually call ctx.json/text/...; we KEEP the wrapper
1708
+ // installed so its ALS-store check can detect and swallow those late
1709
+ // writes. The error boundary itself runs outside the orphan's ALS
1710
+ // frame, so its writes pass through (no store ⇒ no swallow).
1711
+ //
1712
+ // For non-timeout errors (handler threw synchronously, etc.) there's
1713
+ // no orphan to defend against, so restore for hygiene.
1714
+ if (err instanceof IngeniumTimeoutError) {
1715
+ ctx._dispatchEpoch = 0 // Stop advertising "active dispatch"; wrapper still inspects ALS store.
1716
+ } else {
1717
+ restore()
1718
+ }
1719
+ throw err
1720
+ }
1721
+ }
1722
+
1723
+ function pathStartsWith(path: string, prefix: string): boolean {
1724
+ if (prefix === '') return true
1725
+ if (!path.startsWith(prefix)) return false
1726
+ // Boundary check: '/api' must not match '/apiary'.
1727
+ const after = path.charCodeAt(prefix.length)
1728
+ return Number.isNaN(after) || after === 47 // '/'
1729
+ }
1730
+
1731
+ /**
1732
+ * Factory function. Mirrors the `express()` ergonomics — `ingenium(...)` returns
1733
+ * a new app, and the function carries the body-parser middleware factories
1734
+ * plus a `Router` constructor as static properties.
1735
+ */
1736
+ export interface IngeniumFactory {
1737
+ (options?: IngeniumAppOptions): IngeniumApp
1738
+ Router: () => Router
1739
+ }
1740
+
1741
+ /** Match: `void` for error-handler that delegates. (Used by the type tests.) */
1742
+ export type IngeniumErrorReturn = unknown | Promise<unknown>
1743
+
1744
+ /** Function-with-properties factory created and exported by `index.ts`. */
1745
+ export function makeIngeniumFactory(): IngeniumFactory {
1746
+ const fn = ((options?: IngeniumAppOptions) => new IngeniumApp(options)) as IngeniumFactory
1747
+ fn.Router = () => new Router()
1748
+ return fn
1749
+ }
1750
+
1751
+ /** Match path-with-prefix exported for tests. */
1752
+ export const _internal_pathStartsWith = pathStartsWith