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,242 @@
1
+ import type { ServerHttp2Stream, IncomingHttpHeaders as Http2IncomingHeaders } from 'node:http2'
2
+ import { constants as h2 } from 'node:http2'
3
+ import type { IncomingHttpHeaders } from 'node:http'
4
+ import type { IngeniumContext } from '../context/context.ts'
5
+ import type { HttpMethod } from '../router/types.ts'
6
+ import { createByteLimit } from '../body/limit.ts'
7
+
8
+ /**
9
+ * HTTP/2 pseudo-headers (RFC 7540 §8.1.2.1). These appear as keys on the
10
+ * `headers` object when reading an inbound stream and must NOT be passed to
11
+ * any setHeader-style API on outbound responses (Node throws). Strip them
12
+ * from `ctx.headers` so user middleware sees a Node-http-compatible shape.
13
+ */
14
+ const PSEUDO_HEADERS = new Set<string>([':method', ':path', ':scheme', ':authority', ':status'])
15
+
16
+ /**
17
+ * Some HTTP/1 hop-by-hop headers are forbidden in HTTP/2 (RFC 7540 §8.1.2.2).
18
+ * Strip these from outbound responses if a handler set them — `Transfer-Encoding`
19
+ * is the most common offender (Express habit) and `connection` is implicit.
20
+ */
21
+ const FORBIDDEN_RESPONSE_HEADERS = new Set<string>([
22
+ 'transfer-encoding',
23
+ 'connection',
24
+ 'keep-alive',
25
+ 'proxy-connection',
26
+ 'upgrade',
27
+ ])
28
+
29
+ /**
30
+ * Populate a pooled `IngeniumContext` from an inbound HTTP/2 stream + headers map.
31
+ * Mirrors `node.ts`'s `populateContext` but unpacks pseudo-headers and
32
+ * uppercases the method (HTTP/2 sends it lowercase per node:http2 convention).
33
+ */
34
+ export function populateFromH2(
35
+ ctx: IngeniumContext,
36
+ stream: ServerHttp2Stream,
37
+ headers: Http2IncomingHeaders,
38
+ maxRequestBytes: number,
39
+ ): void {
40
+ const rawMethod = headers[h2.HTTP2_HEADER_METHOD]
41
+ ctx.method = (typeof rawMethod === 'string' ? rawMethod.toUpperCase() : 'GET') as HttpMethod
42
+
43
+ const rawPath = headers[h2.HTTP2_HEADER_PATH]
44
+ const url = typeof rawPath === 'string' ? rawPath : '/'
45
+ ctx.url = url
46
+
47
+ // Split path / query without allocating a URL object — same trick as NodeAdapter.
48
+ const qIdx = url.indexOf('?')
49
+ if (qIdx >= 0) {
50
+ ctx.path = url.slice(0, qIdx)
51
+ ctx.rawQuery = url.slice(qIdx + 1)
52
+ } else {
53
+ ctx.path = url
54
+ ctx.rawQuery = ''
55
+ }
56
+
57
+ // Filter pseudo-headers out of the user-visible `ctx.headers` so middleware
58
+ // sees an `IncomingHttpHeaders`-compatible bag.
59
+ const userHeaders: Record<string, string | string[] | undefined> = Object.create(null)
60
+ for (const name in headers) {
61
+ if (PSEUDO_HEADERS.has(name)) continue
62
+ userHeaders[name] = headers[name]
63
+ }
64
+ ctx.headers = userHeaders as IncomingHttpHeaders
65
+
66
+ const cl = userHeaders['content-length']
67
+ const contentLength = typeof cl === 'string' ? Number(cl) : undefined
68
+ const ct = typeof userHeaders['content-type'] === 'string' ? (userHeaders['content-type'] as string) : undefined
69
+
70
+ // The `ServerHttp2Stream` IS a Duplex with a Readable side — wrap it in the
71
+ // byte-limit Transform so the cap applies to EVERY consumer, including
72
+ // `ctx.body.stream()`. Three provably-safe skip conditions (mirror NodeAdapter):
73
+ // 1. Body-less method or Content-Length: 0
74
+ // 2. Cap disabled (Infinity)
75
+ // 3. Content-Length declared and ≤ cap (pre-check enforced; protocol
76
+ // bounds the actual byte count to the declared length)
77
+ const noBody =
78
+ contentLength === 0 ||
79
+ ctx.method === 'GET' ||
80
+ ctx.method === 'HEAD' ||
81
+ ctx.method === 'OPTIONS'
82
+ const knownSafe =
83
+ contentLength !== undefined &&
84
+ Number.isFinite(contentLength) &&
85
+ contentLength <= maxRequestBytes
86
+ if (noBody || !Number.isFinite(maxRequestBytes) || knownSafe) {
87
+ ctx.body._attach(stream, ct, Number.isFinite(contentLength) ? contentLength : undefined)
88
+ return
89
+ }
90
+
91
+ // Cap unknown-length (chunked) bodies with a byte-limit Transform that
92
+ // becomes `ctx.body`'s source. Two failure modes have to be defended here,
93
+ // both rooted in `Stream.prototype.pipe` NOT forwarding `'error'` events:
94
+ //
95
+ // 1. Unhandled-error crash. When the cap trips, the Transform emits
96
+ // `'error'`. The `body.stream()` consumer attaches its own listener, but
97
+ // the chunked path in `IngeniumBody.buffer` re-pipes THIS Transform into
98
+ // a second limiter and only listens on the DOWNSTREAM pipe — so this
99
+ // Transform's `'error'` has no listener and becomes a process-killing
100
+ // unhandled error (h2c has no socket-level teardown to swallow it).
101
+ //
102
+ // 2. Hung request. Because `pipe()` drops errors, that re-piped downstream
103
+ // limiter never sees the overrun: it stops receiving data but never
104
+ // `end`s or `error`s, so `body.buffer()`'s promise never settles and the
105
+ // request hangs until the test/clien­t timeout.
106
+ //
107
+ // Fix both by (a) attaching a guard `'error'` listener so the event is always
108
+ // handled, and (b) forwarding the cap error to every stream this Transform
109
+ // was piped into, so re-piping consumers reject promptly. We deliberately do
110
+ // NOT touch the underlying h2 `stream` here: the 413 is produced by the body
111
+ // consumer's `IngeniumPayloadTooLargeError`, which the error boundary
112
+ // serializes and `writeH2Response` must flush on the still-open stream.
113
+ const limited = createByteLimit(maxRequestBytes)
114
+ const downstream = new Set<{ destroy(err?: Error): void; destroyed: boolean }>()
115
+ const origPipe = limited.pipe.bind(limited) as typeof limited.pipe
116
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
117
+ limited.pipe = function pipe(dest: any, ...rest: any[]) {
118
+ downstream.add(dest)
119
+ return origPipe(dest, ...rest)
120
+ } as typeof limited.pipe
121
+ limited.on('error', (err: Error) => {
122
+ for (const dest of downstream) {
123
+ if (!dest.destroyed) dest.destroy(err)
124
+ }
125
+ // Drain whatever inbound bytes the client is still sending. We stopped
126
+ // reading at the cap, so the raw h2 stream's readable side is left with
127
+ // unconsumed (and incoming) DATA frames. The response (413) flushes on the
128
+ // WRITABLE side, but a half-closed stream whose readable side never ends
129
+ // keeps the h2 SESSION's stream count > 0 — so a later graceful
130
+ // `client.close()` / `server.close()` hangs waiting for it. Unpipe the dead
131
+ // Transform and resume the raw stream to discard the rest, letting it reach
132
+ // `end` and the session close cleanly. `stream.on('error')` below absorbs
133
+ // any RST that arrives while draining.
134
+ stream.unpipe(limited)
135
+ stream.on('error', () => {})
136
+ stream.resume()
137
+ })
138
+ stream.pipe(limited)
139
+ ctx.body._attach(limited, ct, Number.isFinite(contentLength) ? contentLength : undefined)
140
+ }
141
+
142
+ /**
143
+ * Returns `true` (and writes a 413 response) if the inbound h2 stream's
144
+ * Content-Length exceeds the cap. Mirrors the Node adapter pre-check —
145
+ * called BEFORE `populateFromH2` so we don't even acquire a context for
146
+ * a request we're going to reject. Missing / invalid Content-Length →
147
+ * `false` (chunked-style framing, where the byte-limit catches the overrun).
148
+ */
149
+ export function rejectH2IfContentLengthTooBig(
150
+ stream: ServerHttp2Stream,
151
+ headers: Http2IncomingHeaders,
152
+ maxRequestBytes: number,
153
+ ): boolean {
154
+ if (!Number.isFinite(maxRequestBytes)) return false
155
+ const raw = headers['content-length']
156
+ const cl = typeof raw === 'string' ? raw : Array.isArray(raw) ? raw[0] : undefined
157
+ if (typeof cl !== 'string' || cl.length === 0) return false
158
+ const n = Number(cl)
159
+ if (!Number.isFinite(n)) return false
160
+ if (n <= maxRequestBytes) return false
161
+
162
+ if (stream.destroyed || stream.closed) return true
163
+
164
+ // A client that declared an oversized Content-Length is, by definition,
165
+ // about to send (or has half-sent) body frames we will never read. When we
166
+ // respond + end early, the peer's continued DATA — or its own Content-Length
167
+ // bookkeeping if it later sends fewer bytes than declared — makes node:http2
168
+ // emit `ERR_HTTP2_STREAM_ERROR` on this stream. With no listener that is an
169
+ // unhandled-error crash. Absorb it: the 413 has already been delivered (or
170
+ // the stream is being torn down anyway), so there is nothing left to do.
171
+ stream.on('error', () => {
172
+ /* absorb late RST/protocol error from the rejected, never-read body */
173
+ })
174
+
175
+ try {
176
+ stream.respond({
177
+ [h2.HTTP2_HEADER_STATUS]: 413,
178
+ 'content-type': 'application/json; charset=utf-8',
179
+ })
180
+ stream.end(
181
+ JSON.stringify({
182
+ error: `Request body exceeded ${maxRequestBytes} bytes`,
183
+ code: 'PAYLOAD_TOO_LARGE',
184
+ }),
185
+ )
186
+ } catch {
187
+ try {
188
+ stream.close(h2.NGHTTP2_INTERNAL_ERROR)
189
+ } catch {
190
+ stream.destroy()
191
+ }
192
+ }
193
+ return true
194
+ }
195
+
196
+ /**
197
+ * Write the `IngeniumContext` response state to an HTTP/2 stream. Handles all four
198
+ * `_body.kind` variants. HTTP/2 has no `Transfer-Encoding: chunked` (framing
199
+ * is implicit) and no hop-by-hop headers, so we strip those before responding.
200
+ */
201
+ export function writeH2Response(ctx: IngeniumContext, stream: ServerHttp2Stream): void {
202
+ if (stream.destroyed || stream.closed) return
203
+
204
+ const responseHeaders: Record<string, string | string[] | number> = Object.create(null)
205
+ responseHeaders[h2.HTTP2_HEADER_STATUS] = ctx._statusCode
206
+
207
+ for (const name in ctx._headers) {
208
+ const lc = name.toLowerCase()
209
+ if (FORBIDDEN_RESPONSE_HEADERS.has(lc)) continue
210
+ if (PSEUDO_HEADERS.has(lc)) continue // defensive — shouldn't ever happen
211
+ const value = ctx._headers[name]
212
+ if (value !== undefined) responseHeaders[lc] = value
213
+ }
214
+
215
+ const body = ctx._body
216
+ switch (body.kind) {
217
+ case 'none':
218
+ stream.respond(responseHeaders, { endStream: true })
219
+ return
220
+ case 'string': {
221
+ if (responseHeaders['content-length'] === undefined) {
222
+ responseHeaders['content-length'] = Buffer.byteLength(body.data)
223
+ }
224
+ stream.respond(responseHeaders)
225
+ stream.end(body.data)
226
+ return
227
+ }
228
+ case 'buffer': {
229
+ if (responseHeaders['content-length'] === undefined) {
230
+ responseHeaders['content-length'] = body.data.length
231
+ }
232
+ stream.respond(responseHeaders)
233
+ stream.end(body.data)
234
+ return
235
+ }
236
+ case 'stream': {
237
+ stream.respond(responseHeaders)
238
+ body.data.pipe(stream)
239
+ return
240
+ }
241
+ }
242
+ }
@@ -0,0 +1,316 @@
1
+ import {
2
+ createServer as createH2cServer,
3
+ createSecureServer as createH2Server,
4
+ constants as h2,
5
+ type Http2SecureServer,
6
+ type Http2Server,
7
+ type ServerHttp2Stream,
8
+ type IncomingHttpHeaders as Http2IncomingHeaders,
9
+ type Http2ServerRequest,
10
+ type Http2ServerResponse,
11
+ } from 'node:http2'
12
+ import type { Socket } from 'node:net'
13
+ import type { TLSSocket } from 'node:tls'
14
+ import type { IncomingHttpHeaders } from 'node:http'
15
+ import type { HttpMethod } from '../router/types.ts'
16
+ import type {
17
+ CloseOptions,
18
+ ListeningServer,
19
+ Transport,
20
+ TransportHooks,
21
+ } from './types.ts'
22
+ import { populateFromH2, rejectH2IfContentLengthTooBig, writeH2Response } from './http2-helpers.ts'
23
+ import { createByteLimit } from '../body/limit.ts'
24
+
25
+ /** TLS options accepted by the h2 (secure) adapter. */
26
+ export interface Http2AdapterOptions {
27
+ /** TLS certificate (PEM). */
28
+ cert: Buffer | string
29
+ /** TLS private key (PEM). */
30
+ key: Buffer | string
31
+ /**
32
+ * If true, the secure server also accepts HTTP/1.1 connections via ALPN
33
+ * fallback. Inbound HTTP/1 requests are dispatched through the same path
34
+ * used by `NodeAdapter`. Default: false (HTTP/2 only).
35
+ */
36
+ allowHttp1?: boolean
37
+ }
38
+
39
+ /**
40
+ * HTTP/2-over-TLS (`h2`) transport. Uses Node's built-in `http2.createSecureServer`.
41
+ * Browsers REQUIRE TLS for HTTP/2 — there is no cleartext HTTP/2 negotiation
42
+ * over the open web. For local testing without certs, use {@link Http2cAdapter}.
43
+ *
44
+ * Per-request: on `'stream'`, populates a pooled `IngeniumContext` from pseudo-headers,
45
+ * awaits dispatch, then writes the response via `stream.respond()` + `stream.end()`
46
+ * (or pipes for `Readable` bodies).
47
+ */
48
+ export class Http2Adapter implements Transport {
49
+ private hooks: TransportHooks | null = null
50
+
51
+ constructor(private readonly options: Http2AdapterOptions) {}
52
+
53
+ attach(hooks: TransportHooks): void {
54
+ this.hooks = hooks
55
+ }
56
+
57
+ async listen(port: number, host = '127.0.0.1'): Promise<ListeningServer> {
58
+ if (!this.hooks) throw new Error('Http2Adapter.listen() called before attach()')
59
+ const hooks = this.hooks
60
+
61
+ const server: Http2SecureServer = createH2Server({
62
+ cert: this.options.cert,
63
+ key: this.options.key,
64
+ allowHTTP1: this.options.allowHttp1 === true,
65
+ })
66
+
67
+ server.on('stream', (stream, headers) => {
68
+ handleStream(stream, headers, hooks).catch((err) => emergencyAbort(stream, err))
69
+ })
70
+
71
+ if (this.options.allowHttp1 === true) {
72
+ // ALPN fallback: HTTP/1.1 clients land here, NOT on `'stream'`.
73
+ server.on('request', (req, res) => {
74
+ handleHttp1Fallback(req, res, hooks).catch((err) => {
75
+ if (!res.headersSent) {
76
+ res.statusCode = 500
77
+ res.setHeader('content-type', 'application/json; charset=utf-8')
78
+ res.end(JSON.stringify({ error: 'Internal Server Error', code: 'INTERNAL_ERROR' }))
79
+ } else {
80
+ res.end()
81
+ }
82
+ process.emitWarning(`ingenium(h2/http1): dispatch leaked: ${(err as Error).message ?? String(err)}`)
83
+ })
84
+ })
85
+ }
86
+
87
+ return startServer(server, port, host)
88
+ }
89
+ }
90
+
91
+ /**
92
+ * HTTP/2 cleartext (`h2c`) transport. Uses Node's `http2.createServer` — no TLS,
93
+ * so this is intended for local development, internal service-to-service calls
94
+ * behind an L7 proxy that handles TLS termination, or test suites. Browsers do
95
+ * not speak h2c; use {@link Http2Adapter} for browser traffic.
96
+ *
97
+ * Constructor takes no required arguments.
98
+ */
99
+ export class Http2cAdapter implements Transport {
100
+ private hooks: TransportHooks | null = null
101
+
102
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
103
+ constructor(_options: {} = {}) {
104
+ // Reserved for future tuning knobs (settings frame, max concurrent streams, …).
105
+ }
106
+
107
+ attach(hooks: TransportHooks): void {
108
+ this.hooks = hooks
109
+ }
110
+
111
+ async listen(port: number, host = '127.0.0.1'): Promise<ListeningServer> {
112
+ if (!this.hooks) throw new Error('Http2cAdapter.listen() called before attach()')
113
+ const hooks = this.hooks
114
+
115
+ const server: Http2Server = createH2cServer()
116
+
117
+ server.on('stream', (stream, headers) => {
118
+ handleStream(stream, headers, hooks).catch((err) => emergencyAbort(stream, err))
119
+ })
120
+
121
+ return startServer(server, port, host)
122
+ }
123
+ }
124
+
125
+ // ───── shared internals ─────────────────────────────────────────────────────
126
+
127
+ async function handleStream(
128
+ stream: ServerHttp2Stream,
129
+ headers: Http2IncomingHeaders,
130
+ hooks: TransportHooks,
131
+ ): Promise<void> {
132
+ // Normalize the optional hook field — older fixtures may not set it.
133
+ const maxBytes = hooks.maxRequestBytes ?? Number.POSITIVE_INFINITY
134
+ // Reject oversized Content-Length BEFORE we acquire a context — the request
135
+ // is dead on arrival. Chunked / unknown-length bodies fall through to the
136
+ // byte-limit Transform installed by `populateFromH2`.
137
+ if (rejectH2IfContentLengthTooBig(stream, headers, maxBytes)) return
138
+
139
+ const ctx = hooks.acquire()
140
+ try {
141
+ populateFromH2(ctx, stream, headers, maxBytes)
142
+ await hooks.dispatch(ctx)
143
+ writeH2Response(ctx, stream)
144
+ } finally {
145
+ hooks.release(ctx)
146
+ }
147
+ }
148
+
149
+ /**
150
+ * HTTP/1.1 fallback path used when `allowHttp1` is set on `Http2Adapter`.
151
+ * Mirrors `NodeAdapter.populateContext` + `writeResponse`. We can't reuse
152
+ * `node.ts` directly because the framework should not import from a sibling
153
+ * adapter, and `Http2ServerRequest`/`Response` are subclasses of the http
154
+ * primitives but with the same surface — so we duplicate the small populate +
155
+ * write loop here.
156
+ */
157
+ async function handleHttp1Fallback(
158
+ req: Http2ServerRequest,
159
+ res: Http2ServerResponse,
160
+ hooks: TransportHooks,
161
+ ): Promise<void> {
162
+ // Same Content-Length pre-check as the pure-h1 NodeAdapter path.
163
+ const maxBytes = hooks.maxRequestBytes ?? Number.POSITIVE_INFINITY
164
+ if (Number.isFinite(maxBytes)) {
165
+ const raw = req.headers['content-length']
166
+ if (typeof raw === 'string' && raw.length > 0) {
167
+ const n = Number(raw)
168
+ if (Number.isFinite(n) && n > maxBytes) {
169
+ res.statusCode = 413
170
+ res.setHeader('content-type', 'application/json; charset=utf-8')
171
+ res.setHeader('connection', 'close')
172
+ res.end(
173
+ JSON.stringify({
174
+ error: `Request body exceeded ${maxBytes} bytes`,
175
+ code: 'PAYLOAD_TOO_LARGE',
176
+ }),
177
+ )
178
+ return
179
+ }
180
+ }
181
+ }
182
+
183
+ const ctx = hooks.acquire()
184
+ try {
185
+ ctx.method = (req.method ?? 'GET') as HttpMethod
186
+ const url = req.url ?? '/'
187
+ ctx.url = url
188
+ const qIdx = url.indexOf('?')
189
+ if (qIdx >= 0) {
190
+ ctx.path = url.slice(0, qIdx)
191
+ ctx.rawQuery = url.slice(qIdx + 1)
192
+ } else {
193
+ ctx.path = url
194
+ ctx.rawQuery = ''
195
+ }
196
+ ctx.headers = req.headers as unknown as IncomingHttpHeaders
197
+
198
+ const cl = req.headers['content-length']
199
+ const contentLength = typeof cl === 'string' ? Number(cl) : undefined
200
+ const ct = typeof req.headers['content-type'] === 'string' ? (req.headers['content-type'] as string) : undefined
201
+ const source = Number.isFinite(maxBytes) ? req.pipe(createByteLimit(maxBytes)) : req
202
+ ctx.body._attach(source, ct, Number.isFinite(contentLength) ? contentLength : undefined)
203
+
204
+ await hooks.dispatch(ctx)
205
+
206
+ res.statusCode = ctx._statusCode
207
+ for (const name in ctx._headers) {
208
+ const value = ctx._headers[name]
209
+ if (value !== undefined) res.setHeader(name, value)
210
+ }
211
+ const body = ctx._body
212
+ switch (body.kind) {
213
+ case 'none':
214
+ res.end()
215
+ break
216
+ case 'string':
217
+ if (!res.hasHeader('content-length')) {
218
+ res.setHeader('content-length', Buffer.byteLength(body.data))
219
+ }
220
+ res.end(body.data)
221
+ break
222
+ case 'buffer':
223
+ if (!res.hasHeader('content-length')) {
224
+ res.setHeader('content-length', body.data.length)
225
+ }
226
+ res.end(body.data)
227
+ break
228
+ case 'stream':
229
+ body.data.pipe(res)
230
+ break
231
+ }
232
+ } finally {
233
+ hooks.release(ctx)
234
+ }
235
+ }
236
+
237
+ function emergencyAbort(stream: ServerHttp2Stream, err: unknown): void {
238
+ // Last-resort safety net — the dispatch loop should have caught everything.
239
+ if (!stream.headersSent && !stream.destroyed) {
240
+ try {
241
+ stream.respond(
242
+ { [h2.HTTP2_HEADER_STATUS]: 500, 'content-type': 'application/json; charset=utf-8' },
243
+ )
244
+ stream.end(JSON.stringify({ error: 'Internal Server Error', code: 'INTERNAL_ERROR' }))
245
+ } catch {
246
+ // fall through to destroy
247
+ }
248
+ }
249
+ if (!stream.destroyed) {
250
+ try {
251
+ stream.close(h2.NGHTTP2_INTERNAL_ERROR)
252
+ } catch {
253
+ stream.destroy()
254
+ }
255
+ }
256
+ process.emitWarning(`ingenium(h2): dispatch leaked: ${(err as Error).message ?? String(err)}`)
257
+ }
258
+
259
+ /**
260
+ * Bind the underlying server and return a {@link ListeningServer} handle.
261
+ * Same socket-tracking pattern as `NodeAdapter` so `close({ gracefulTimeoutMs })`
262
+ * can force-kill idle connections.
263
+ */
264
+ function startServer(
265
+ server: Http2Server | Http2SecureServer,
266
+ port: number,
267
+ host: string,
268
+ ): Promise<ListeningServer> {
269
+ const sockets = new Set<Socket | TLSSocket>()
270
+ server.on('connection', (socket: Socket) => {
271
+ sockets.add(socket)
272
+ socket.on('close', () => sockets.delete(socket))
273
+ })
274
+ server.on('secureConnection', (socket: TLSSocket) => {
275
+ sockets.add(socket)
276
+ socket.on('close', () => sockets.delete(socket))
277
+ })
278
+
279
+ return new Promise<ListeningServer>((resolve, reject) => {
280
+ server.once('error', reject)
281
+ server.listen(port, host, () => {
282
+ const addr = server.address()
283
+ if (!addr || typeof addr === 'string') {
284
+ reject(new Error('Failed to determine bound address'))
285
+ return
286
+ }
287
+ resolve({
288
+ port: addr.port,
289
+ host: addr.address,
290
+ close: (opts?: CloseOptions): Promise<void> =>
291
+ new Promise<void>((res, rej) => {
292
+ let settled = false
293
+ let timer: NodeJS.Timeout | null = null
294
+
295
+ server.close((err) => {
296
+ if (timer) clearTimeout(timer)
297
+ if (settled) return
298
+ settled = true
299
+ err ? rej(err) : res()
300
+ })
301
+
302
+ const timeoutMs = opts?.gracefulTimeoutMs
303
+ if (typeof timeoutMs === 'number' && Number.isFinite(timeoutMs)) {
304
+ timer = setTimeout(() => {
305
+ for (const socket of sockets) socket.destroy()
306
+ }, Math.max(0, timeoutMs))
307
+ if (typeof timer.unref === 'function') timer.unref()
308
+ }
309
+ }),
310
+ })
311
+ })
312
+ })
313
+ }
314
+
315
+ // Re-export types for downstream consumers who need to type adapter options.
316
+ export type { Http2AdapterOptions as Http2Options }