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
|
@@ -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/client 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 }
|