ingenium 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +943 -0
- package/dist/index.cjs +7078 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +4262 -0
- package/dist/index.d.ts +4262 -0
- package/dist/index.js +6963 -0
- package/dist/index.js.map +1 -0
- package/package.json +47 -0
- package/src/api-key/middleware.ts +157 -0
- package/src/api-key/types.ts +37 -0
- package/src/app/scope.ts +392 -0
- package/src/app.ts +1752 -0
- package/src/body/limit.ts +21 -0
- package/src/body/middleware.ts +30 -0
- package/src/body/multipart-types.ts +40 -0
- package/src/body/multipart.ts +254 -0
- package/src/context/body.ts +324 -0
- package/src/context/context.ts +650 -0
- package/src/context/cookies.ts +282 -0
- package/src/context/pool.ts +32 -0
- package/src/cors/middleware.ts +182 -0
- package/src/cors/types.ts +79 -0
- package/src/cron/parser.ts +311 -0
- package/src/cron/registry.ts +49 -0
- package/src/cron/scheduler.ts +153 -0
- package/src/csrf/middleware.ts +224 -0
- package/src/csrf/types.ts +65 -0
- package/src/errors.ts +148 -0
- package/src/idempotency/middleware.ts +197 -0
- package/src/idempotency/store.ts +70 -0
- package/src/idempotency/types.ts +87 -0
- package/src/index.ts +328 -0
- package/src/jobs/queue.ts +306 -0
- package/src/jobs/registry.ts +82 -0
- package/src/jobs/store-memory.ts +113 -0
- package/src/jobs/types.ts +135 -0
- package/src/jwt/jwks.ts +143 -0
- package/src/jwt/middleware.ts +313 -0
- package/src/jwt/types.ts +137 -0
- package/src/jwt/verify.ts +370 -0
- package/src/middleware/compose.ts +94 -0
- package/src/middleware/types.ts +37 -0
- package/src/negotiation/accept.ts +159 -0
- package/src/negotiation/etag.ts +30 -0
- package/src/negotiation/format.ts +88 -0
- package/src/negotiation/fresh.ts +89 -0
- package/src/negotiation/json-etag.ts +122 -0
- package/src/negotiation/negotiate.ts +97 -0
- package/src/openapi/describe.ts +79 -0
- package/src/openapi/extract-params.ts +62 -0
- package/src/openapi/generate.ts +251 -0
- package/src/openapi/handler.ts +73 -0
- package/src/openapi/types.ts +145 -0
- package/src/plugin/decorators.ts +100 -0
- package/src/plugin/hooks.ts +114 -0
- package/src/plugin/types.ts +189 -0
- package/src/problem/middleware.ts +55 -0
- package/src/problem/serialize.ts +121 -0
- package/src/problem/types.ts +68 -0
- package/src/proxy/trust.ts +247 -0
- package/src/rate-limit/middleware.ts +72 -0
- package/src/rate-limit/store.ts +129 -0
- package/src/rate-limit/types.ts +60 -0
- package/src/response/reflect.ts +93 -0
- package/src/router/router.ts +284 -0
- package/src/router/trie.ts +309 -0
- package/src/router/types.ts +54 -0
- package/src/schema/standard.ts +67 -0
- package/src/session/middleware.ts +379 -0
- package/src/session/store-memory.ts +79 -0
- package/src/session/types.ts +95 -0
- package/src/sinatra/filters.ts +129 -0
- package/src/sinatra/top-level.ts +151 -0
- package/src/sse/keep-alive.ts +52 -0
- package/src/sse/sse.ts +115 -0
- package/src/static/middleware.ts +254 -0
- package/src/static/types.ts +31 -0
- package/src/transport/http2-helpers.ts +242 -0
- package/src/transport/http2.ts +316 -0
- package/src/transport/node.ts +261 -0
- package/src/transport/shutdown.ts +86 -0
- package/src/transport/types.ts +72 -0
- package/src/util/safe-json.ts +66 -0
- package/src/ws/index.ts +164 -0
- package/src/ws/middleware.ts +178 -0
- package/src/ws/types.ts +52 -0
- package/src/ws/ws-node-adapter.ts +162 -0
package/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
|