ingenium 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +943 -0
  3. package/dist/index.cjs +7078 -0
  4. package/dist/index.cjs.map +1 -0
  5. package/dist/index.d.cts +4262 -0
  6. package/dist/index.d.ts +4262 -0
  7. package/dist/index.js +6963 -0
  8. package/dist/index.js.map +1 -0
  9. package/package.json +47 -0
  10. package/src/api-key/middleware.ts +157 -0
  11. package/src/api-key/types.ts +37 -0
  12. package/src/app/scope.ts +392 -0
  13. package/src/app.ts +1752 -0
  14. package/src/body/limit.ts +21 -0
  15. package/src/body/middleware.ts +30 -0
  16. package/src/body/multipart-types.ts +40 -0
  17. package/src/body/multipart.ts +254 -0
  18. package/src/context/body.ts +324 -0
  19. package/src/context/context.ts +650 -0
  20. package/src/context/cookies.ts +282 -0
  21. package/src/context/pool.ts +32 -0
  22. package/src/cors/middleware.ts +182 -0
  23. package/src/cors/types.ts +79 -0
  24. package/src/cron/parser.ts +311 -0
  25. package/src/cron/registry.ts +49 -0
  26. package/src/cron/scheduler.ts +153 -0
  27. package/src/csrf/middleware.ts +224 -0
  28. package/src/csrf/types.ts +65 -0
  29. package/src/errors.ts +148 -0
  30. package/src/idempotency/middleware.ts +197 -0
  31. package/src/idempotency/store.ts +70 -0
  32. package/src/idempotency/types.ts +87 -0
  33. package/src/index.ts +328 -0
  34. package/src/jobs/queue.ts +306 -0
  35. package/src/jobs/registry.ts +82 -0
  36. package/src/jobs/store-memory.ts +113 -0
  37. package/src/jobs/types.ts +135 -0
  38. package/src/jwt/jwks.ts +143 -0
  39. package/src/jwt/middleware.ts +313 -0
  40. package/src/jwt/types.ts +137 -0
  41. package/src/jwt/verify.ts +370 -0
  42. package/src/middleware/compose.ts +94 -0
  43. package/src/middleware/types.ts +37 -0
  44. package/src/negotiation/accept.ts +159 -0
  45. package/src/negotiation/etag.ts +30 -0
  46. package/src/negotiation/format.ts +88 -0
  47. package/src/negotiation/fresh.ts +89 -0
  48. package/src/negotiation/json-etag.ts +122 -0
  49. package/src/negotiation/negotiate.ts +97 -0
  50. package/src/openapi/describe.ts +79 -0
  51. package/src/openapi/extract-params.ts +62 -0
  52. package/src/openapi/generate.ts +251 -0
  53. package/src/openapi/handler.ts +73 -0
  54. package/src/openapi/types.ts +145 -0
  55. package/src/plugin/decorators.ts +100 -0
  56. package/src/plugin/hooks.ts +114 -0
  57. package/src/plugin/types.ts +189 -0
  58. package/src/problem/middleware.ts +55 -0
  59. package/src/problem/serialize.ts +121 -0
  60. package/src/problem/types.ts +68 -0
  61. package/src/proxy/trust.ts +247 -0
  62. package/src/rate-limit/middleware.ts +72 -0
  63. package/src/rate-limit/store.ts +129 -0
  64. package/src/rate-limit/types.ts +60 -0
  65. package/src/response/reflect.ts +93 -0
  66. package/src/router/router.ts +284 -0
  67. package/src/router/trie.ts +309 -0
  68. package/src/router/types.ts +54 -0
  69. package/src/schema/standard.ts +67 -0
  70. package/src/session/middleware.ts +379 -0
  71. package/src/session/store-memory.ts +79 -0
  72. package/src/session/types.ts +95 -0
  73. package/src/sinatra/filters.ts +129 -0
  74. package/src/sinatra/top-level.ts +151 -0
  75. package/src/sse/keep-alive.ts +52 -0
  76. package/src/sse/sse.ts +115 -0
  77. package/src/static/middleware.ts +254 -0
  78. package/src/static/types.ts +31 -0
  79. package/src/transport/http2-helpers.ts +242 -0
  80. package/src/transport/http2.ts +316 -0
  81. package/src/transport/node.ts +261 -0
  82. package/src/transport/shutdown.ts +86 -0
  83. package/src/transport/types.ts +72 -0
  84. package/src/util/safe-json.ts +66 -0
  85. package/src/ws/index.ts +164 -0
  86. package/src/ws/middleware.ts +178 -0
  87. package/src/ws/types.ts +52 -0
  88. package/src/ws/ws-node-adapter.ts +162 -0
@@ -0,0 +1,650 @@
1
+ import type { IncomingHttpHeaders } from 'node:http'
2
+ import type { Readable } from 'node:stream'
3
+ import { Buffer } from 'node:buffer'
4
+ import { IngeniumBody, type ParseSchema, type SafeParseSchema } from './body.ts'
5
+ import { makeIngeniumCookies, type IngeniumCookies } from './cookies.ts'
6
+ import { isStandardSchema, type StandardIssue, type StandardSchemaV1 } from '../schema/standard.ts'
7
+ import type { HttpMethod } from '../router/types.ts'
8
+ import { resolveForwarded, type ForwardedInfo, type TrustProxy } from '../proxy/trust.ts'
9
+ import {
10
+ accepts as acceptsFn,
11
+ acceptsCharsets as acceptsCharsetsFn,
12
+ acceptsLanguages as acceptsLanguagesFn,
13
+ acceptsEncodings as acceptsEncodingsFn,
14
+ } from '../negotiation/negotiate.ts'
15
+ import { formatResponse, type FormatHandlers } from '../negotiation/format.ts'
16
+ import { isFresh } from '../negotiation/fresh.ts'
17
+ import { respondJsonWithEtag, type JsonEtagOptions } from '../negotiation/json-etag.ts'
18
+ import {
19
+ IngeniumHaltError,
20
+ IngeniumHeaderInjectionError,
21
+ IngeniumUnserializableError,
22
+ IngeniumValidationError,
23
+ } from '../errors.ts'
24
+
25
+ /**
26
+ * Dev-mode gate. Captured ONCE at module load; in production V8 dead-code-
27
+ * eliminates the branch bodies behind `if (IS_DEV)`. Every dev diagnostic
28
+ * MUST check this first so it pays nothing on the hot path.
29
+ */
30
+ const IS_DEV = process.env.NODE_ENV !== 'production'
31
+
32
+ /**
33
+ * @internal Test-only flag for the trust-proxy / XFF mismatch warning. Once
34
+ * per process — read-once UX. Exposed via `_resetFootgunWarnings()` for tests.
35
+ */
36
+ let _trustProxyWarned = false
37
+
38
+ /**
39
+ * @internal Test-only reset hook. Clears all module-scoped once-flags used by
40
+ * the dev footgun warnings. Not part of the public API.
41
+ */
42
+ export function _resetFootgunWarnings(): void {
43
+ _trustProxyWarned = false
44
+ }
45
+
46
+ /** CR/LF detector for header-injection guard. Tested against names + values. */
47
+ const CRLF_RE = /[\r\n]/
48
+
49
+ /**
50
+ * Reject header NAMES containing CR or LF. Empty/undefined names are
51
+ * allowed through — the underlying header bag's own type system rejects
52
+ * those naturally.
53
+ */
54
+ function assertHeaderNameSafe(name: string): void {
55
+ if (CRLF_RE.test(name)) {
56
+ throw new IngeniumHeaderInjectionError(
57
+ `Header name contains CR/LF (possible header injection): ${JSON.stringify(name)}`,
58
+ )
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Reject header VALUES containing CR or LF. Accepts a single string or an
64
+ * array — the array form checks each element. `undefined` is allowed (some
65
+ * call sites pass through optionals); empty string is allowed (legitimate).
66
+ */
67
+ function assertHeaderValueSafe(name: string, value: string | string[]): void {
68
+ if (Array.isArray(value)) {
69
+ for (let i = 0; i < value.length; i++) {
70
+ const v = value[i]
71
+ if (typeof v === 'string' && CRLF_RE.test(v)) {
72
+ throw new IngeniumHeaderInjectionError(
73
+ `Header value contains CR/LF (possible header injection): ${name}[${i}]`,
74
+ )
75
+ }
76
+ }
77
+ return
78
+ }
79
+ if (typeof value === 'string' && CRLF_RE.test(value)) {
80
+ throw new IngeniumHeaderInjectionError(
81
+ `Header value contains CR/LF (possible header injection): ${name}`,
82
+ )
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Strict `JSON.stringify` wrapper used by the response helpers. Surfaces
88
+ * `BigInt` / circular / other serialization failures as a
89
+ * `IngeniumUnserializableError` so the framework error boundary can render
90
+ * a clean 500 instead of a deep `TypeError` from V8.
91
+ */
92
+ function strictStringify(body: unknown): string {
93
+ try {
94
+ return JSON.stringify(body) as string
95
+ } catch (err) {
96
+ const msg = err instanceof Error ? err.message : String(err)
97
+ let reason: string
98
+ if (/circular/i.test(msg)) {
99
+ reason = `circular structure (${msg})`
100
+ } else if (/BigInt/i.test(msg)) {
101
+ reason = `BigInt value (${msg})`
102
+ } else {
103
+ reason = msg
104
+ }
105
+ try {
106
+ process.emitWarning(
107
+ `IngeniumUnserializableError: ${reason}`,
108
+ { type: 'IngeniumUnserializableError' },
109
+ )
110
+ } catch {
111
+ // process.emitWarning can throw in unusual runtimes (workers); swallow.
112
+ }
113
+ throw new IngeniumUnserializableError(
114
+ `Response body cannot be serialized: ${reason}`,
115
+ err,
116
+ )
117
+ }
118
+ }
119
+
120
+ /** Sentinel for routes with no params — frozen, so `ctx.params.foo` is safe. */
121
+ const EMPTY_PARAMS = Object.freeze(Object.create(null) as Record<string, string>)
122
+
123
+ /**
124
+ * `URLSearchParams` augmented with a `parse(schema)` method that runs the
125
+ * query through the same schema-detection pipeline as `ctx.body.json(schema)`.
126
+ *
127
+ * The shape passed to the schema is a **shallow array-aware** object:
128
+ *
129
+ * `?id=42&tag=a&tag=b&active=true`
130
+ * → `{ id: '42', tag: ['a','b'], active: 'true' }`
131
+ *
132
+ * Single-occurrence keys → `string`. Repeated keys → `string[]`. Everything is
133
+ * a string on the wire, so the user's schema is responsible for coercing
134
+ * numbers/booleans (Zod: use `z.coerce.number()`; ArkType: use `'string.numeric.parse'`).
135
+ *
136
+ * Rationale for picking THIS coercion over alternatives:
137
+ * - "raw strings only" loses repeated-key fidelity (qs/Express-style arrays)
138
+ * - "pre-coerced booleans/numbers" surprises users when "12foo" silently
139
+ * becomes a string or "true" becomes a boolean against their schema
140
+ * - Shallow-array matches `Object.fromEntries` semantics PLUS the most
141
+ * common ergonomic ask (tag=a&tag=b → tag: string[])
142
+ */
143
+ export interface IngeniumQuery extends URLSearchParams {
144
+ parse<T = unknown>(
145
+ schema: StandardSchemaV1<unknown, T> | SafeParseSchema<T> | ParseSchema<T>,
146
+ ): T
147
+ }
148
+
149
+ /** Normalize a Standard Schema issue path into a dot-joined field key. */
150
+ function queryPathToField(path: StandardIssue['path']): string {
151
+ if (!path || path.length === 0) return '_'
152
+ const parts: string[] = []
153
+ for (const seg of path) {
154
+ if (seg !== null && typeof seg === 'object' && 'key' in seg) {
155
+ parts.push(String(seg.key))
156
+ } else {
157
+ parts.push(String(seg))
158
+ }
159
+ }
160
+ return parts.join('.') || '_'
161
+ }
162
+
163
+ /**
164
+ * Build the `{ key: string | string[] }` input that gets fed to the schema.
165
+ * Walks the URLSearchParams once; collisions promote a scalar to an array.
166
+ *
167
+ * Allocated lazily on `parse()` only — never paid by handlers that just read
168
+ * `ctx.query.get(...)`. Iteration of URLSearchParams is iteration-order stable
169
+ * and yields decoded values, so no manual percent-decoding here.
170
+ */
171
+ function toShallowArrayObject(usp: URLSearchParams): Record<string, string | string[]> {
172
+ const out: Record<string, string | string[]> = Object.create(null)
173
+ for (const [k, v] of usp) {
174
+ const existing = out[k]
175
+ if (existing === undefined) {
176
+ out[k] = v
177
+ } else if (Array.isArray(existing)) {
178
+ existing.push(v)
179
+ } else {
180
+ out[k] = [existing, v]
181
+ }
182
+ }
183
+ return out
184
+ }
185
+
186
+ function makeIngeniumQuery(raw: string): IngeniumQuery {
187
+ const usp = new URLSearchParams(raw) as IngeniumQuery
188
+ Object.defineProperty(usp, 'parse', {
189
+ configurable: true,
190
+ enumerable: false,
191
+ writable: false,
192
+ value: function parse<T>(
193
+ this: URLSearchParams,
194
+ schema: StandardSchemaV1<unknown, T> | SafeParseSchema<T> | ParseSchema<T>,
195
+ ): T {
196
+ const input = toShallowArrayObject(this)
197
+ // 1. Standard Schema v1 takes precedence.
198
+ if (isStandardSchema(schema)) {
199
+ const maybe = schema['~standard'].validate(input)
200
+ // Query parsing is synchronous — async validators are still accepted
201
+ // but throw a clearer error than awaiting at the wire would.
202
+ if (maybe instanceof Promise) {
203
+ throw new IngeniumValidationError({
204
+ _: 'async Standard Schema validators are not supported on ctx.query.parse (use ctx.body.json for async)',
205
+ })
206
+ }
207
+ if (maybe.issues) {
208
+ const fields: Record<string, string> = {}
209
+ for (const issue of maybe.issues) {
210
+ fields[queryPathToField(issue.path)] = issue.message
211
+ }
212
+ throw new IngeniumValidationError(fields)
213
+ }
214
+ return maybe.value as T
215
+ }
216
+ // 2. Zod-like safeParse.
217
+ if (
218
+ 'safeParse' in schema &&
219
+ typeof (schema as SafeParseSchema<T>).safeParse === 'function'
220
+ ) {
221
+ const result = (schema as SafeParseSchema<T>).safeParse(input)
222
+ if (!result.success) {
223
+ const fields: Record<string, string> = {}
224
+ for (const issue of result.error.issues) {
225
+ fields[issue.path.join('.') || '_'] = issue.message
226
+ }
227
+ throw new IngeniumValidationError(fields)
228
+ }
229
+ return result.data
230
+ }
231
+ // 3. Plain parse.
232
+ try {
233
+ return (schema as ParseSchema<T>).parse(input)
234
+ } catch (err) {
235
+ throw new IngeniumValidationError({ _: (err as Error).message ?? 'validation failed' })
236
+ }
237
+ },
238
+ })
239
+ return usp
240
+ }
241
+
242
+ /** Internal response body shape — adapter writes one of these to the wire. */
243
+ export type ResponseBody =
244
+ | { kind: 'none' }
245
+ | { kind: 'buffer'; data: Buffer }
246
+ | { kind: 'string'; data: string }
247
+ | { kind: 'stream'; data: Readable }
248
+
249
+ /**
250
+ * Per-request context. Pool-bound: one instance per pool slot, reused
251
+ * across thousands of requests. All mutable fields are reset between uses.
252
+ *
253
+ * The `Params` generic is a phantom — it narrows `ctx.params` for typed
254
+ * route handlers but is `Record<string, string>` at runtime.
255
+ */
256
+ export class IngeniumContext<Params = Record<string, string>> {
257
+ // ───── Request ─────────────────────────────────────────────────────────
258
+ /** HTTP method, uppercase. */
259
+ method: HttpMethod = 'GET'
260
+ /** Full request URL including query string (e.g. `/users/42?expand=posts`). */
261
+ url = '/'
262
+ /** Path portion of the URL (no query string). Set by the adapter. */
263
+ path = '/'
264
+ /** Raw query string (no leading `?`). Use `query` for parsed access. */
265
+ rawQuery = ''
266
+ /** Route params, written at trie-match time. */
267
+ params: Params = EMPTY_PARAMS as unknown as Params
268
+ /** Lowercased request headers (Node convention). */
269
+ headers: IncomingHttpHeaders = {}
270
+ /** Lazy body accessor. */
271
+ readonly body: IngeniumBody = new IngeniumBody()
272
+ /** Free-form per-request state for plugins/middleware (e.g. `ctx.user = ...`). */
273
+ state: Record<string, unknown> = Object.create(null) as Record<string, unknown>
274
+
275
+ /**
276
+ * Per-request handle to enqueue background jobs onto a registered queue.
277
+ * Wired by `IngeniumApp` as a lazy decorator (declared with `!` because the
278
+ * runtime value is installed by the decorator registry, not the class
279
+ * initializer). Throws if the named queue isn't registered.
280
+ *
281
+ * @example
282
+ * await ctx.queue<{ to: string }>('emails').add({ to: 'a@b.com' })
283
+ */
284
+ queue!: <TData = unknown>(name: string) => import('../jobs/types.ts').JobHandle<TData>
285
+
286
+ /** Lazy-parsed query. First access caches the URLSearchParams. */
287
+ private _query: IngeniumQuery | null = null
288
+ get query(): IngeniumQuery {
289
+ if (!this._query) this._query = makeIngeniumQuery(this.rawQuery)
290
+ return this._query
291
+ }
292
+
293
+ /**
294
+ * @internal Lazy cookie holder. `null` until first read of `ctx.cookies`.
295
+ * Reset to `null` in `reset()` so a context returned to the pool drops the
296
+ * parsed-cookie cache (and any closed-over write state — though writes go
297
+ * straight to `_headers`, which is itself reset by reassignment).
298
+ */
299
+ _cookies: IngeniumCookies | null = null
300
+ /**
301
+ * First-class cookie API. Lazy: the holder is allocated on first access so
302
+ * apps that never touch cookies pay zero per-request overhead. See
303
+ * `cookies.ts` for the read/write contract and signing rules.
304
+ *
305
+ * @example
306
+ * const sid = ctx.cookies.get('sid', { signed: true })
307
+ * ctx.cookies.set('theme', 'dark', { httpOnly: true, sameSite: 'lax' })
308
+ * ctx.cookies.clear('legacy')
309
+ */
310
+ get cookies(): IngeniumCookies {
311
+ if (!this._cookies) this._cookies = makeIngeniumCookies(this)
312
+ return this._cookies
313
+ }
314
+
315
+ /**
316
+ * @internal App-wide cookie-signing secrets. Stamped by `IngeniumApp.handle`
317
+ * on dispatch entry when configured (mirrors `_trustProxy`). First secret
318
+ * signs new cookies; all entries verify reads (supports rotation). Empty
319
+ * means signed cookies will throw `IngeniumError(500, 'COOKIE_SECRET_MISSING')`.
320
+ *
321
+ * NOT cleared in `reset()` — this is app-wide config, not per-request state,
322
+ * and the cost of re-stamping every request is wasteful when the value is
323
+ * stable across the app's lifetime. The first call to `handle()` after a
324
+ * compose sets it; subsequent requests reuse the same array reference.
325
+ */
326
+ _cookieSecrets: readonly string[] = []
327
+
328
+ // ───── Network info (trust-proxy aware) ────────────────────────────────
329
+ /** Immediate socket peer address — populated by the adapter. */
330
+ remoteAddress = '127.0.0.1'
331
+ /** Underlying transport protocol — populated by the adapter (http for node:http, https for TLS). */
332
+ baseProtocol: 'http' | 'https' = 'http'
333
+ /** @internal `trustProxy` config carried in from the app. */ _trustProxy: TrustProxy = false
334
+ /** @internal Cached forwarded resolution; computed lazily from headers. */
335
+ private _forwarded: ForwardedInfo | null = null
336
+
337
+ private resolveForwarded(): ForwardedInfo {
338
+ if (!this._forwarded) {
339
+ this._forwarded = resolveForwarded(
340
+ this._trustProxy,
341
+ this.remoteAddress,
342
+ this.headers as Record<string, string | string[] | undefined>,
343
+ this.baseProtocol,
344
+ )
345
+ // Dev-only — warn once per process when the user reads forwarded info
346
+ // with trustProxy disabled BUT the request carries X-Forwarded-For.
347
+ // Almost always means the user is behind a proxy and forgot to enable
348
+ // trustProxy, so `ctx.ip` is silently returning the proxy's address.
349
+ if (IS_DEV && !_trustProxyWarned && this._trustProxy === false) {
350
+ if (this.headers['x-forwarded-for'] !== undefined) {
351
+ _trustProxyWarned = true
352
+ try {
353
+ process.emitWarning(
354
+ "Read ctx.ip with trustProxy disabled, but request has X-Forwarded-For. Set 'trustProxy' on the app if behind a reverse proxy.",
355
+ { type: 'IngeniumTrustProxyWarning' },
356
+ )
357
+ } catch {
358
+ // process.emitWarning can throw in unusual runtimes (workers); swallow.
359
+ }
360
+ }
361
+ }
362
+ }
363
+ return this._forwarded
364
+ }
365
+
366
+ /**
367
+ * Best-effort client IP. With `trustProxy: false` this is the immediate
368
+ * socket peer; with trust-proxy enabled the X-Forwarded-For chain is
369
+ * walked according to the configured trust policy.
370
+ */
371
+ get ip(): string { return this.resolveForwarded().ip }
372
+ /** Full forwarded chain (left-to-right, immediate peer last). */
373
+ get ips(): readonly string[] { return this.resolveForwarded().ips }
374
+ /** Best-effort protocol — honors `X-Forwarded-Proto` when trust-proxy is enabled. */
375
+ get protocol(): 'http' | 'https' { return this.resolveForwarded().protocol }
376
+ /** Convenience: `protocol === 'https'`. */
377
+ get secure(): boolean { return this.protocol === 'https' }
378
+ /** Best-effort hostname (no port) — honors `X-Forwarded-Host` when trust-proxy is enabled. */
379
+ get hostname(): string { return this.resolveForwarded().hostname }
380
+
381
+ // ───── Response ────────────────────────────────────────────────────────
382
+ /** @internal */ _statusCode = 200
383
+ /** @internal */ _headers: Record<string, string | string[]> = Object.create(null) as Record<string, string | string[]>
384
+ /** @internal */ _body: ResponseBody = { kind: 'none' }
385
+ /** @internal Whether a response helper has been called. */
386
+ _written = false
387
+
388
+ /**
389
+ * @internal Per-request generation counter. Incremented every time the
390
+ * pool resets this context (and also bumped by `IngeniumApp.handle` when a
391
+ * request times out, so writes from the orphaned handler can be detected
392
+ * as stale). Compared against `_dispatchEpoch` by every response writer.
393
+ */
394
+ _epoch = 0
395
+
396
+ /**
397
+ * @internal Last `_epoch` value captured by `IngeniumApp.withEpochGuard`.
398
+ * Set on dispatch entry; the per-dispatch wrappers installed around the
399
+ * response writers close over this value to detect late writes from an
400
+ * orphaned (timed-out) handler. The wrappers compare `_epoch` against
401
+ * the captured value at call time — mismatch ⇒ orphan ⇒ swallow.
402
+ *
403
+ * `0` means no guard is active (no `requestTimeoutMs` configured, or
404
+ * the dispatch already resolved naturally).
405
+ */
406
+ _dispatchEpoch = 0
407
+
408
+ // ───── Response helpers ────────────────────────────────────────────────
409
+
410
+ /**
411
+ * @internal Dev-only — emit `IngeniumDoubleWriteWarning` when a writer is
412
+ * called after `_written` is already true. No-op in production: V8
413
+ * eliminates the branch body behind the `IS_DEV` gate.
414
+ */
415
+ private _warnDoubleWrite(method: string): void {
416
+ if (!IS_DEV) return
417
+ if (!this._written) return
418
+ try {
419
+ process.emitWarning(
420
+ `ctx.${method}() called after response was already written. Second call overrides the first; use 'return' to short-circuit.`,
421
+ { type: 'IngeniumDoubleWriteWarning' },
422
+ )
423
+ } catch {
424
+ // process.emitWarning can throw in unusual runtimes (workers); swallow.
425
+ }
426
+ }
427
+
428
+ /** Set the HTTP status code. Returns `this` for chaining. */
429
+ status(code: number): this {
430
+ this._statusCode = code
431
+ return this
432
+ }
433
+
434
+ /**
435
+ * Set a response header (case-insensitive). Returns `this` for chaining.
436
+ *
437
+ * Throws `IngeniumHeaderInjectionError` if `name` or `value` contains CR
438
+ * or LF — these would otherwise enable header-injection / response-
439
+ * splitting attacks if a caller forwards untrusted user input directly.
440
+ */
441
+ set(name: string, value: string | string[]): this {
442
+ assertHeaderNameSafe(name)
443
+ assertHeaderValueSafe(name, value)
444
+ this._headers[name.toLowerCase()] = value
445
+ return this
446
+ }
447
+ /** Alias for `set` — matches Express's `res.setHeader`. */
448
+ setHeader(name: string, value: string | string[]): this {
449
+ return this.set(name, value)
450
+ }
451
+
452
+ /** Get a previously-set response header (lowercase lookup). */
453
+ getHeader(name: string): string | string[] | undefined {
454
+ return this._headers[name.toLowerCase()]
455
+ }
456
+
457
+ /**
458
+ * Send a JSON response.
459
+ *
460
+ * Throws `IngeniumUnserializableError` if `body` cannot be encoded
461
+ * (circular structure, `BigInt`, etc.) — surfaces a clean 500 from the
462
+ * framework error boundary instead of a deep `TypeError`.
463
+ */
464
+ json(body: unknown, status?: number): void {
465
+ this._warnDoubleWrite('json')
466
+ const data = strictStringify(body)
467
+ if (status !== undefined) this._statusCode = status
468
+ if (!this._headers['content-type']) this._headers['content-type'] = 'application/json; charset=utf-8'
469
+ this._body = { kind: 'string', data }
470
+ this._written = true
471
+ }
472
+
473
+ /** Send a `text/plain` response. */
474
+ text(body: string, status?: number): void {
475
+ this._warnDoubleWrite('text')
476
+ if (status !== undefined) this._statusCode = status
477
+ if (!this._headers['content-type']) this._headers['content-type'] = 'text/plain; charset=utf-8'
478
+ this._body = { kind: 'string', data: body }
479
+ this._written = true
480
+ }
481
+
482
+ /** Send a `text/html` response. */
483
+ html(body: string, status?: number): void {
484
+ this._warnDoubleWrite('html')
485
+ if (status !== undefined) this._statusCode = status
486
+ if (!this._headers['content-type']) this._headers['content-type'] = 'text/html; charset=utf-8'
487
+ this._body = { kind: 'string', data: body }
488
+ this._written = true
489
+ }
490
+
491
+ /** Send a redirect (default 302). */
492
+ redirect(location: string, status = 302): void {
493
+ this._warnDoubleWrite('redirect')
494
+ this._statusCode = status
495
+ this._headers.location = location
496
+ this._body = { kind: 'none' }
497
+ this._written = true
498
+ }
499
+
500
+ /** Stream a `Readable` to the client. Sets content-type if not already set. */
501
+ stream(readable: Readable, contentType?: string): void {
502
+ this._warnDoubleWrite('stream')
503
+ if (contentType && !this._headers['content-type']) this._headers['content-type'] = contentType
504
+ this._body = { kind: 'stream', data: readable }
505
+ this._written = true
506
+ }
507
+
508
+ /**
509
+ * Sinatra-style short-circuit. Throws `IngeniumHaltError(status, body?)`
510
+ * — the framework error boundary catches it and serializes per `bodyShape`:
511
+ *
512
+ * - `ctx.halt(401)` → 401 with default JSON `{ error, code: 'HALT' }`.
513
+ * - `ctx.halt(404, 'Not Found')` → 404 `text/plain` body verbatim.
514
+ * - `ctx.halt(422, { fields })` → 422 `application/json` body verbatim.
515
+ *
516
+ * The TypeScript `never` return type lets `if (!found) ctx.halt(404)`
517
+ * narrow the rest of the function — code after the call is unreachable.
518
+ *
519
+ * To bypass the error boundary entirely (write the response without
520
+ * throwing) call `ctx.json(body, status)` and `return` from the handler.
521
+ *
522
+ * @example
523
+ * if (!authorized(ctx)) ctx.halt(401, 'Unauthorized')
524
+ * if (!user) ctx.halt(404, { error: 'Not Found', id })
525
+ */
526
+ halt(status: number, body?: string | Record<string, unknown>): never {
527
+ throw new IngeniumHaltError(status, body)
528
+ }
529
+
530
+ /** Send a `Buffer` body verbatim. */
531
+ send(body: Buffer | string, status?: number): void {
532
+ this._warnDoubleWrite('send')
533
+ if (status !== undefined) this._statusCode = status
534
+ if (typeof body === 'string') {
535
+ if (!this._headers['content-type']) this._headers['content-type'] = 'text/plain; charset=utf-8'
536
+ this._body = { kind: 'string', data: body }
537
+ } else {
538
+ if (!this._headers['content-type']) this._headers['content-type'] = 'application/octet-stream'
539
+ this._body = { kind: 'buffer', data: body }
540
+ }
541
+ this._written = true
542
+ }
543
+
544
+ // ───── Content negotiation (request side) ──────────────────────────────
545
+
546
+ /**
547
+ * Return the best mime type the client accepts from the offered list, or
548
+ * `false` if none are acceptable. With no arguments, returns the parsed
549
+ * preference-ordered list of accepted types from `Accept`.
550
+ *
551
+ * Each `type` may be a shorthand (`'json'`, `'html'`, `'csv'`, …) or a full
552
+ * mime (`'application/json'`). Quality factors are honored.
553
+ *
554
+ * @example
555
+ * if (ctx.accepts('json')) ctx.json({ ok: true })
556
+ * else ctx.status(406).text('Not Acceptable')
557
+ */
558
+ accepts(): string[]
559
+ accepts(...types: string[]): string | false
560
+ accepts(...types: string[]): string | false | string[] {
561
+ return types.length === 0 ? acceptsFn(this) : acceptsFn(this, ...types)
562
+ }
563
+
564
+ /** Best matching charset from the offered list against `Accept-Charset`. */
565
+ acceptsCharsets(): string[]
566
+ acceptsCharsets(...charsets: string[]): string | false
567
+ acceptsCharsets(...charsets: string[]): string | false | string[] {
568
+ return charsets.length === 0 ? acceptsCharsetsFn(this) : acceptsCharsetsFn(this, ...charsets)
569
+ }
570
+
571
+ /** Best matching language against `Accept-Language` (exact-tag match only). */
572
+ acceptsLanguages(): string[]
573
+ acceptsLanguages(...langs: string[]): string | false
574
+ acceptsLanguages(...langs: string[]): string | false | string[] {
575
+ return langs.length === 0 ? acceptsLanguagesFn(this) : acceptsLanguagesFn(this, ...langs)
576
+ }
577
+
578
+ /** Best matching encoding against `Accept-Encoding` (first offered when header absent). */
579
+ acceptsEncodings(): string[]
580
+ acceptsEncodings(...encodings: string[]): string | false
581
+ acceptsEncodings(...encodings: string[]): string | false | string[] {
582
+ return encodings.length === 0 ? acceptsEncodingsFn(this) : acceptsEncodingsFn(this, ...encodings)
583
+ }
584
+
585
+ // ───── Content negotiation (response side) ─────────────────────────────
586
+
587
+ /**
588
+ * Run the handler whose key best matches the request `Accept` header. The
589
+ * matched key is set as `Content-Type`. If no key matches and no `default`
590
+ * handler is provided, throws `IngeniumError(406, 'NOT_ACCEPTABLE')`.
591
+ */
592
+ format(handlers: FormatHandlers): Promise<void> {
593
+ return formatResponse(this, handlers)
594
+ }
595
+
596
+ /**
597
+ * `true` when the client's `If-None-Match` matches the response `ETag`,
598
+ * or `If-Modified-Since` is at-or-after the response `Last-Modified`.
599
+ * Reads from `_headers` so handlers can set ETag / Last-Modified before checking.
600
+ */
601
+ get fresh(): boolean {
602
+ return isFresh(
603
+ this.headers as Record<string, string | string[] | undefined>,
604
+ this._headers as Record<string, string | string[] | undefined>,
605
+ )
606
+ }
607
+
608
+ /** `!fresh`. */
609
+ get stale(): boolean {
610
+ return !this.fresh
611
+ }
612
+
613
+ /**
614
+ * Send a JSON body with an auto-computed weak ETag. If the request's
615
+ * `If-None-Match` matches the computed tag, short-circuits to 304.
616
+ */
617
+ jsonWithEtag(body: unknown, opts?: JsonEtagOptions): void {
618
+ respondJsonWithEtag(this, body, opts)
619
+ }
620
+
621
+ // ───── Pool lifecycle ──────────────────────────────────────────────────
622
+
623
+ /**
624
+ * Reset all per-request state. Called by the pool before returning the
625
+ * context to the free list. Reassignments preserve the V8 hidden class
626
+ * so subsequent allocations stay monomorphic.
627
+ */
628
+ reset(): void {
629
+ this.method = 'GET'
630
+ this.url = '/'
631
+ this.path = '/'
632
+ this.rawQuery = ''
633
+ this.params = EMPTY_PARAMS as unknown as Params
634
+ this.headers = {}
635
+ this._query = null
636
+ this._cookies = null
637
+ this.state = Object.create(null) as Record<string, unknown>
638
+ this.remoteAddress = '127.0.0.1'
639
+ this.baseProtocol = 'http'
640
+ this._trustProxy = false
641
+ this._forwarded = null
642
+ this._statusCode = 200
643
+ this._headers = Object.create(null) as Record<string, string | string[]>
644
+ this._body = { kind: 'none' }
645
+ this._written = false
646
+ this._dispatchEpoch = 0
647
+ this._epoch++
648
+ this.body._reset()
649
+ }
650
+ }