castrum 0.7.0 → 0.9.0

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 (176) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +322 -59
  3. package/castrum.linux-x64-gnu.node +0 -0
  4. package/dist/index.d.ts +3161 -0
  5. package/dist/index.js +7306 -0
  6. package/docs/ARCHITECTURE.md +415 -0
  7. package/docs/BENCHMARKS.md +307 -0
  8. package/docs/ENVIRONMENT.md +79 -0
  9. package/docs/GETTING_STARTED.md +209 -0
  10. package/docs/INGRESS.md +745 -0
  11. package/docs/REPO_MAP.md +259 -0
  12. package/docs/adr/0001-two-wire-formats.md +39 -0
  13. package/docs/adr/0002-zero-dom-fast-schema.md +38 -0
  14. package/docs/adr/0003-bun-builtins-delegation.md +42 -0
  15. package/docs/bun-builtins-decision-matrix.md +108 -0
  16. package/index.ts +51 -8
  17. package/package.json +73 -20
  18. package/src/data/json-rows.ts +13 -13
  19. package/src/ingress/body.ts +149 -0
  20. package/src/ingress/constants.ts +220 -48
  21. package/src/ingress/context.ts +136 -0
  22. package/src/ingress/decode/baked-result.ts +121 -0
  23. package/src/ingress/decode/fast-result.ts +160 -0
  24. package/src/ingress/decode/packed-sections.ts +56 -0
  25. package/src/ingress/decode/result-base.ts +172 -0
  26. package/src/ingress/errors.ts +72 -0
  27. package/src/ingress/fast.ts +152 -1021
  28. package/src/ingress/handlers.ts +563 -1001
  29. package/src/ingress/headers/baked-templates.ts +102 -0
  30. package/src/ingress/headers/cors.ts +44 -0
  31. package/src/ingress/headers/fast-templates.ts +238 -0
  32. package/src/ingress/headers/hsts.ts +83 -0
  33. package/src/ingress/health.ts +67 -0
  34. package/src/ingress/index.ts +130 -405
  35. package/src/ingress/metrics.ts +132 -0
  36. package/src/ingress/options.ts +197 -0
  37. package/src/ingress/packing/gather-raw-headers.ts +75 -0
  38. package/src/ingress/packing/header-packing.ts +75 -0
  39. package/src/ingress/packing/input-packer.ts +150 -0
  40. package/src/ingress/packing/scratch.ts +50 -0
  41. package/src/ingress/packing/select-headers.ts +96 -0
  42. package/src/ingress/response/error-bodies.ts +75 -0
  43. package/src/ingress/response/terminal.ts +59 -0
  44. package/src/ingress/routes/common.ts +38 -0
  45. package/src/ingress/routes/delete.ts +14 -0
  46. package/src/ingress/routes/echo.ts +107 -0
  47. package/src/ingress/routes/fallback.ts +23 -0
  48. package/src/ingress/routes/head.ts +37 -0
  49. package/src/ingress/routes/index.ts +10 -0
  50. package/src/ingress/routes/json-write.ts +153 -0
  51. package/src/ingress/routes/options.ts +36 -0
  52. package/src/ingress/routes/read.ts +45 -0
  53. package/src/ingress/server-node.ts +376 -0
  54. package/src/ingress/server.ts +443 -0
  55. package/src/ingress/shared.ts +132 -0
  56. package/src/ingress/status.ts +97 -0
  57. package/src/ingress/types.ts +157 -0
  58. package/src/integration/batch.ts +58 -0
  59. package/src/integration/index.ts +29 -0
  60. package/src/integration/pipeline.ts +217 -0
  61. package/src/integration/streaming.ts +79 -0
  62. package/src/integration/websocket.ts +92 -0
  63. package/src/loader/batch.ts +133 -0
  64. package/src/loader/cost.ts +104 -0
  65. package/src/loader/index.ts +930 -0
  66. package/src/loader/ops.ts +657 -0
  67. package/src/native/ffi.ts +1541 -0
  68. package/src/native/index.ts +8 -207
  69. package/src/native/loader.ts +194 -0
  70. package/src/native/types.ts +598 -0
  71. package/src/rust-ffi/addon.ts +10 -0
  72. package/src/rust-ffi/batch.ts +431 -0
  73. package/src/rust-ffi/client.ts +88 -0
  74. package/src/rust-ffi/context.ts +164 -0
  75. package/src/rust-ffi/index.ts +40 -541
  76. package/src/rust-ffi/options.ts +113 -0
  77. package/src/rust-ffi/packed.ts +253 -0
  78. package/src/rust-ffi/proven.ts +42 -0
  79. package/src/rust-ffi/scalar/crypto.ts +265 -0
  80. package/src/rust-ffi/scalar/factories.ts +233 -0
  81. package/src/rust-ffi/scalar/hashing.ts +72 -0
  82. package/src/rust-ffi/scalar/http.ts +206 -0
  83. package/src/rust-ffi/scalar/index.ts +31 -0
  84. package/src/rust-ffi/scalar/interface.ts +450 -0
  85. package/src/rust-ffi/scalar/json.ts +45 -0
  86. package/src/rust-ffi/scalar/payload.ts +124 -0
  87. package/src/rust-ffi/text.ts +95 -0
  88. package/src/selection.json +194 -0
  89. package/src/selection.ts +77 -0
  90. package/src/shared/adaptive.ts +68 -0
  91. package/src/shared/bench-classify.ts +209 -0
  92. package/src/shared/buffer-pool.ts +209 -0
  93. package/src/shared/bytes.ts +35 -5
  94. package/src/shared/env.ts +20 -0
  95. package/src/shared/log.ts +74 -0
  96. package/src/shared/metrics.ts +247 -0
  97. package/src/shared/packed.ts +350 -154
  98. package/src/shared/proven.ts +596 -0
  99. package/src/shared/request-id.ts +67 -0
  100. package/src/shared/response.ts +104 -0
  101. package/src/shared/runtime.ts +34 -0
  102. package/src/shared/trace.ts +81 -0
  103. package/src/shared/uuid.ts +31 -0
  104. package/Cargo.toml +0 -73
  105. package/bench.ts +0 -3
  106. package/rust/batch.rs +0 -195
  107. package/rust/cookie_parser.rs +0 -70
  108. package/rust/cors.rs +0 -263
  109. package/rust/hashing.rs +0 -164
  110. package/rust/headers.rs +0 -234
  111. package/rust/hmac_sha256.rs +0 -113
  112. package/rust/http_parser.rs +0 -132
  113. package/rust/ingress.rs +0 -616
  114. package/rust/ingress_constants.rs +0 -89
  115. package/rust/ip_trust.rs +0 -185
  116. package/rust/json_ops.rs +0 -66
  117. package/rust/json_patch_ops.rs +0 -25
  118. package/rust/json_schema.rs +0 -151
  119. package/rust/json_ser.rs +0 -429
  120. package/rust/lib.rs +0 -48
  121. package/rust/method.rs +0 -141
  122. package/rust/mime_lookup.rs +0 -76
  123. package/rust/output.rs +0 -238
  124. package/rust/proxy.rs +0 -90
  125. package/rust/query_parser.rs +0 -141
  126. package/rust/random_token.rs +0 -52
  127. package/rust/rate_limit.rs +0 -203
  128. package/rust/terminal.rs +0 -95
  129. package/rust/unit_tests.rs +0 -843
  130. package/rust/url_codec.rs +0 -133
  131. package/rust/util.rs +0 -537
  132. package/rust/validation.rs +0 -107
  133. package/rust/websocket.rs +0 -23
  134. package/src/baseline/index.ts +0 -12
  135. package/src/baseline/tasks/cookie.ts +0 -22
  136. package/src/baseline/tasks/hashing.ts +0 -18
  137. package/src/baseline/tasks/hmac.ts +0 -38
  138. package/src/baseline/tasks/http.ts +0 -62
  139. package/src/baseline/tasks/json-patch.ts +0 -12
  140. package/src/baseline/tasks/json.ts +0 -50
  141. package/src/baseline/tasks/mime.ts +0 -5
  142. package/src/baseline/tasks/query.ts +0 -14
  143. package/src/baseline/tasks/token.ts +0 -15
  144. package/src/baseline/tasks/url.ts +0 -11
  145. package/src/baseline/tasks/validation.ts +0 -19
  146. package/src/baseline/tasks/websocket.ts +0 -14
  147. package/src/bench/assert.ts +0 -70
  148. package/src/bench/checks.ts +0 -227
  149. package/src/bench/checksum.ts +0 -27
  150. package/src/bench/comparisons.ts +0 -70
  151. package/src/bench/concurrent-worker.ts +0 -135
  152. package/src/bench/fixtures.ts +0 -233
  153. package/src/bench/index.ts +0 -2
  154. package/src/bench/measure.ts +0 -177
  155. package/src/bench/now.ts +0 -3
  156. package/src/bench/report.ts +0 -79
  157. package/src/bench/run.ts +0 -57
  158. package/src/bench/tasks/complex.ts +0 -232
  159. package/src/bench/tasks/concurrent.ts +0 -213
  160. package/src/bench/tasks/cookie.ts +0 -44
  161. package/src/bench/tasks/hashing.ts +0 -33
  162. package/src/bench/tasks/hmac.ts +0 -36
  163. package/src/bench/tasks/http.ts +0 -50
  164. package/src/bench/tasks/index.ts +0 -52
  165. package/src/bench/tasks/json-patch.ts +0 -21
  166. package/src/bench/tasks/json.ts +0 -33
  167. package/src/bench/tasks/mime.ts +0 -21
  168. package/src/bench/tasks/query.ts +0 -44
  169. package/src/bench/tasks/stress.ts +0 -142
  170. package/src/bench/tasks/token.ts +0 -20
  171. package/src/bench/tasks/url.ts +0 -34
  172. package/src/bench/tasks/validation.ts +0 -57
  173. package/src/bench/tasks/websocket.ts +0 -21
  174. package/src/bench/types.ts +0 -60
  175. package/src/ingress/packed-input.ts +0 -59
  176. package/src/shared/json.ts +0 -18
@@ -0,0 +1,45 @@
1
+ // src/ingress/routes/read.ts — Pre-baked GET read handler.
2
+
3
+ import type { BakedContext, OptimizedIngressHandler } from '../types'
4
+ import type { BakedIngressResult } from '../decode/baked-result'
5
+ import { resolveIp, type BakedHandlerOptions } from './common'
6
+ import { secondsFromMs } from '../shared'
7
+
8
+ /**
9
+ * Pre-baked GET read handler: returns the ingress body JSON on success.
10
+ */
11
+ export function readHandler(
12
+ ingress: OptimizedIngressHandler,
13
+ opts: BakedHandlerOptions = {},
14
+ ): (req: Request, srv?: unknown) => Response {
15
+ const copyBody = opts.copyBody !== false
16
+
17
+ // Result→Response callback, built ONCE per handler (it depends only on the
18
+ // ingress handle + config, never on the per-request `req`), so the hot path
19
+ // allocates no fresh closure per request.
20
+ const respond = (result: BakedIngressResult, ctx: BakedContext): Response => {
21
+ const terminal = ingress.terminalResponse(undefined, result, ctx)
22
+ if (terminal) return terminal
23
+
24
+ if (result.bodyTruncated) {
25
+ return ingress.internalErrorResponse(ctx, result)
26
+ }
27
+
28
+ const init: ResponseInit = {
29
+ status: 200,
30
+ headers: ingress.responseHeaders(
31
+ result.headerVariant,
32
+ ctx.requestIdHeader,
33
+ ctx.origin,
34
+ result.rateRemaining,
35
+ result.rateResetMs > 0 ? secondsFromMs(result.rateResetMs) : undefined,
36
+ ),
37
+ }
38
+
39
+ return copyBody
40
+ ? new Response(result.bodyJson(true), init)
41
+ : ingress.zeroCopyResponse(result, ctx, init)
42
+ }
43
+
44
+ return (req, srv) => ingress.run<Response>(req, resolveIp(req, srv, opts), null, respond)
45
+ }
@@ -0,0 +1,376 @@
1
+ // src/ingress/server-node.ts — Node.js HTTP adapter for the pre-baked ingress
2
+ // route handlers.
3
+ //
4
+ // Bun remains the primary server target (`createIngressServer` in server.ts
5
+ // uses Bun.serve). This adapter lets the SAME pre-baked route handlers run
6
+ // under Node.js via `node:http`. Route handlers are web-standard
7
+ // (`Request` → `Response`), so the adapter only translates between Node's
8
+ // `IncomingMessage`/`ServerResponse` and the web `Request`/`Response` types.
9
+ //
10
+ // Differences vs Bun: no `reusePort`, `maxRequestBodySize` is not enforced at
11
+ // the socket level (the route handlers still enforce `maxBodyBytes` after
12
+ // reading, matching the Bun path's current behavior), and `getIp` receives the
13
+ // Node `IncomingMessage` (which exposes `socket.remoteAddress`, not
14
+ // `srv.requestIP`).
15
+
16
+ import {
17
+ createServer,
18
+ type IncomingMessage,
19
+ type Server as HttpServer,
20
+ type ServerResponse,
21
+ } from 'node:http'
22
+ import { Readable, type Duplex } from 'node:stream'
23
+
24
+ import {
25
+ buildPathMatcher,
26
+ buildRouteHandlers,
27
+ type BakedServer,
28
+ type CreateIngressServerOptions,
29
+ type ServerHandle,
30
+ type RouteHandler,
31
+ } from './server'
32
+ import { fallbackHandler } from './routes'
33
+
34
+ export type { RouteHandler } from './server'
35
+
36
+ /**
37
+ * Default socket-level guard for receiving request headers/body (ms). Node's
38
+ * `requestTimeout` / `headersTimeout` default to 0 (disabled); castrum opts
39
+ * into a 30s cap to reject slowloris/trickling sockets. Distinct from the
40
+ * route-level `bodyTimeoutMs` (`DEFAULT_BODY_TIMEOUT_MS`), which guards the
41
+ * body-read loop after headers arrive.
42
+ */
43
+ const DEFAULT_SOCKET_TIMEOUT_MS = 30_000
44
+ import type { BakedHandlerOptions } from './routes/common'
45
+
46
+ /** A Node.js ingress server: {@link BakedServer} plus an async ready signal. */
47
+ export interface NodeIngressServer extends BakedServer {
48
+ /**
49
+ * Resolves to the actually-bound port once the server is listening
50
+ * (`node:http` binds asynchronously; useful when `port: 0`).
51
+ */
52
+ ready: Promise<number>
53
+ }
54
+
55
+ /** Convert a Node IncomingMessage into a web-standard Request. */
56
+ function nodeRequestToWebRequest(req: IncomingMessage): Request {
57
+ const host = req.headers.host ?? 'localhost'
58
+ const url = new URL(req.url ?? '/', `http://${host}`)
59
+
60
+ const headers = new Headers()
61
+ for (const [key, value] of Object.entries(req.headers)) {
62
+ if (value === undefined) continue
63
+ if (Array.isArray(value)) {
64
+ for (const v of value) headers.append(key, v)
65
+ } else {
66
+ headers.set(key, value)
67
+ }
68
+ }
69
+
70
+ const method = req.method ?? 'GET'
71
+ // The fetch Request constructor forbids a body on GET/HEAD, but allows it on
72
+ // POST/PUT/PATCH/DELETE/OPTIONS etc. Attach the body stream whenever the
73
+ // request actually carries one (content-length > 0 or chunked), so DELETE/
74
+ // OPTIONS-with-a-body are no longer dropped.
75
+ const bodyForbidden = method === 'GET' || method === 'HEAD'
76
+ const hasBody =
77
+ Number(req.headers['content-length'] ?? 0) > 0 || req.headers['transfer-encoding'] !== undefined
78
+
79
+ const init: RequestInit & { duplex?: 'half' } = { method, headers }
80
+
81
+ // Stream the request body through (the route handlers call
82
+ // `req.arrayBuffer()` which drains this stream).
83
+ if (hasBody && !bodyForbidden) {
84
+ init.body = Readable.toWeb(req) as ReadableStream<Uint8Array>
85
+ init.duplex = 'half'
86
+ }
87
+
88
+ return new Request(url.toString(), init)
89
+ }
90
+
91
+ /** Write a web Response back to a Node ServerResponse. */
92
+ async function writeResponse(res: ServerResponse, response: Response): Promise<void> {
93
+ // If the handler answered without consuming the request body (e.g. an early
94
+ // 400/413/415 guard), drain it BEFORE ending the response: Node only reuses a
95
+ // keep-alive socket once the request is fully read, and calling resume()
96
+ // after end() is too late — the unread bytes would bleed into the next
97
+ // request's parse (spurious 400s).
98
+ drainUnreadBody(res)
99
+
100
+ res.statusCode = response.status
101
+
102
+ if (response.headers.has('set-cookie')) {
103
+ res.setHeader('set-cookie', response.headers.getSetCookie())
104
+ }
105
+ for (const [key, value] of response.headers) {
106
+ if (key.toLowerCase() === 'set-cookie') continue
107
+ res.setHeader(key, value)
108
+ }
109
+
110
+ // HEAD requests must not carry a body.
111
+ if (res.req?.method === 'HEAD') {
112
+ res.end()
113
+ return
114
+ }
115
+
116
+ if (response.body !== null) {
117
+ const reader = response.body.getReader()
118
+ try {
119
+ for (;;) {
120
+ const { done, value } = await reader.read()
121
+ if (done) break
122
+ if (!res.write(value)) {
123
+ await new Promise<void>((resolve) => res.once('drain', resolve))
124
+ }
125
+ }
126
+ } finally {
127
+ reader.releaseLock()
128
+ }
129
+ res.end()
130
+ } else {
131
+ res.end(Buffer.from(await response.arrayBuffer()))
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Handle a response whose request body was never consumed (early 4xx guards).
137
+ *
138
+ * Empirically Node CANNOT safely reuse a keep-alive socket here: `resume()`
139
+ * alone still lets the unread bytes bleed into the next request's parse
140
+ * (spurious 400s — verified with a minimal repro). The safe behavior is to
141
+ * drain best-effort AND force `Connection: close` so the socket ends cleanly
142
+ * instead of corrupting the next request.
143
+ */
144
+ function drainUnreadBody(res: ServerResponse): void {
145
+ const req = res.req
146
+ if (req !== undefined && req !== null && !req.complete && hasBody(req)) {
147
+ req.resume()
148
+ // Only force-close when the request actually carried a body that was not
149
+ // consumed. A bodiless GET/HEAD is always safe to keep alive (bun's
150
+ // node:http reports `complete` differently, so don't trust it alone).
151
+ res.shouldKeepAlive = false
152
+ if (!res.headersSent) {
153
+ res.setHeader('connection', 'close')
154
+ }
155
+ }
156
+ }
157
+
158
+ /** Whether an incoming request carried a body (content-length or chunked). */
159
+ function hasBody(req: IncomingMessage): boolean {
160
+ return (
161
+ Number(req.headers['content-length'] ?? 0) > 0 || req.headers['transfer-encoding'] !== undefined
162
+ )
163
+ }
164
+
165
+ /** Build a request listener that dispatches to the shared route map. */
166
+ function makeRequestListener(
167
+ options: CreateIngressServerOptions,
168
+ routes: Record<string, Record<string, unknown>>,
169
+ baseOpts: BakedHandlerOptions,
170
+ ): (req: IncomingMessage, res: ServerResponse) => Promise<void> {
171
+ // Exact paths win; `:param`/`*` patterns are matched most-specific-first.
172
+ const matchPath = buildPathMatcher(routes)
173
+
174
+ return async (req, res) => {
175
+ try {
176
+ // Socket-level body cap: reject oversized requests BEFORE any body is
177
+ // buffered (mirrors Bun's `maxRequestBodySize` on the Node adapter).
178
+ const contentLengthHeader = req.headers['content-length']
179
+ if (
180
+ options.maxRequestBodySize !== undefined &&
181
+ contentLengthHeader !== undefined &&
182
+ Number(contentLengthHeader) > options.maxRequestBodySize
183
+ ) {
184
+ // Reject before buffering; Node cannot safely reuse a keep-alive socket
185
+ // with an unread body, so force the connection to close (no corruption
186
+ // of the next request).
187
+ req.resume()
188
+ res.shouldKeepAlive = false
189
+ res.setHeader('connection', 'close')
190
+ const body = JSON.stringify({
191
+ error: { code: 'body_too_large', message: 'Request body is too large' },
192
+ })
193
+ res.statusCode = 413
194
+ res.setHeader('content-type', 'application/json')
195
+ res.setHeader('content-length', String(Buffer.byteLength(body)))
196
+ res.end(body)
197
+ return
198
+ }
199
+
200
+ const webReq = nodeRequestToWebRequest(req)
201
+ const pathname = new URL(webReq.url).pathname
202
+ const method = webReq.method ?? 'GET'
203
+
204
+ const matched = matchPath(pathname)
205
+ const handler = matched?.methods?.[method] as RouteHandler | undefined
206
+
207
+ let response: Response
208
+ if (handler !== undefined && matched !== undefined) {
209
+ // The ingress pipeline does not echo path params, so matching only
210
+ // selects the handler; the extracted params are passed as a 3rd arg
211
+ // (ignored by current handlers) for future/raw-handler use.
212
+ response = await handler(webReq, req, matched.params)
213
+ } else if (options.fallback !== undefined) {
214
+ const fallback = fallbackHandler(options.fallback, baseOpts)
215
+ response = await fallback(webReq, req)
216
+ } else {
217
+ response = new Response('Not Found', { status: 404 })
218
+ }
219
+
220
+ await writeResponse(res, response)
221
+ } catch (err) {
222
+ // Never leave a client hanging on a handler failure.
223
+ if (!res.headersSent) {
224
+ res.statusCode = 500
225
+ res.setHeader('content-type', 'application/json')
226
+ res.end(
227
+ JSON.stringify({
228
+ error: { code: 'internal_error', message: 'Internal Server Error' },
229
+ }),
230
+ )
231
+ } else {
232
+ res.destroy()
233
+ }
234
+ // NOTE: surfaced via the onError hook (observability phase).
235
+ if (options.onError) {
236
+ try {
237
+ options.onError({
238
+ error: err instanceof Error ? err : new Error(String(err)),
239
+ })
240
+ } catch {
241
+ // hook must never crash the server
242
+ }
243
+ }
244
+ }
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Handle a WebSocket upgrade on the Node adapter. Node's `Response` cannot
250
+ * carry status 101 (undici only allows 200-599), so `createWebSocketUpgrade`
251
+ * is Bun-only. Here the caller supplies the RFC 6455 handshake VALUES via
252
+ * `options.upgrade` (accept key + optional negotiated subprotocol); we write
253
+ * the 101 head on the hijacked socket and hand it to `options.onUpgrade`
254
+ * (which owns the frame codec — e.g. the `ws` library). Previously a 101 was
255
+ * written as a normal response and the connection never became a socket.
256
+ */
257
+ function makeUpgradeListener(
258
+ options: CreateIngressServerOptions,
259
+ ): (req: IncomingMessage, socket: Duplex, head: Buffer) => Promise<void> {
260
+ return async (req, socket, head) => {
261
+ try {
262
+ const webReq = nodeRequestToWebRequest(req)
263
+ const handshake = options.upgrade?.(webReq)
264
+ if (!handshake?.accept) {
265
+ // No valid handshake — decline the upgrade (never reuse the socket).
266
+ socket.destroy()
267
+ return
268
+ }
269
+
270
+ // Complete the RFC 6455 handshake on the hijacked socket.
271
+ let raw =
272
+ 'HTTP/1.1 101 Switching Protocols\r\n' +
273
+ 'Upgrade: websocket\r\n' +
274
+ 'Connection: Upgrade\r\n' +
275
+ `Sec-WebSocket-Accept: ${handshake.accept}\r\n`
276
+ if (handshake.protocol) {
277
+ raw += `Sec-WebSocket-Protocol: ${handshake.protocol}\r\n`
278
+ }
279
+ raw += '\r\n'
280
+ socket.write(raw)
281
+
282
+ // Hand the upgraded socket to the caller (frame codec / message loop).
283
+ options.onUpgrade?.(socket, req, head)
284
+ } catch {
285
+ // Never leave a half-open socket on a handshake failure.
286
+ socket.destroy()
287
+ }
288
+ }
289
+ }
290
+
291
+ /**
292
+ * Start a Node.js `node:http` server serving the same pre-baked ingress route
293
+ * handlers as {@link createIngressServer}. Returns a {@link NodeIngressServer};
294
+ * `stop()` drains in-flight connections (graceful) before closing, and
295
+ * `ready` resolves to the bound port once listening.
296
+ *
297
+ * Requires Node.js >= 20.3 (N-API floor). Bun users should prefer
298
+ * {@link createIngressServer} (Bun.serve).
299
+ */
300
+ export function createIngressServerNode(options: CreateIngressServerOptions): NodeIngressServer {
301
+ const { routes, baseOpts } = buildRouteHandlers(options)
302
+
303
+ const server: HttpServer = createServer(makeRequestListener(options, routes, baseOpts))
304
+
305
+ // Map Bun-style options onto Node equivalents + harden slowloris/DoS.
306
+ server.keepAliveTimeout = (options.idleTimeout ?? 30) * 1000
307
+ server.requestTimeout = options.requestTimeoutMs ?? DEFAULT_SOCKET_TIMEOUT_MS
308
+ server.headersTimeout = options.headersTimeoutMs ?? DEFAULT_SOCKET_TIMEOUT_MS
309
+ // Apply unconditionally: Node's own default is 0 (unlimited), so the
310
+ // documented `maxRequestsPerSocket ?? 1000` must always land here — a
311
+ // conditional write could silently leave the unlimited default in place.
312
+ server.maxRequestsPerSocket = options.maxRequestsPerSocket ?? 1000
313
+
314
+ // Malformed requests → castrum's JSON error shape (not Node's raw 400/431).
315
+ server.on('clientError', (err, socket) => {
316
+ if (!socket.writable) {
317
+ socket.destroy()
318
+ return
319
+ }
320
+ // Header-too-large parse failures are 431, everything else 400.
321
+ const isHeaderOverflow =
322
+ (err as NodeJS.ErrnoException | undefined)?.code === 'HPE_HEADER_OVERFLOW'
323
+ const status = isHeaderOverflow ? 431 : 400
324
+ const reason = isHeaderOverflow ? 'Request Header Fields Too Large' : 'Bad Request'
325
+ const code = isHeaderOverflow ? 'headers_too_large' : 'bad_request'
326
+ const body = JSON.stringify({
327
+ error: { code, message: reason },
328
+ })
329
+ socket.end(
330
+ `HTTP/1.1 ${status} ${reason}\r\n` +
331
+ 'Content-Type: application/json\r\n' +
332
+ `Content-Length: ${Buffer.byteLength(body)}\r\n` +
333
+ 'Connection: close\r\n\r\n' +
334
+ body,
335
+ )
336
+ })
337
+
338
+ // WebSocket upgrades: only register a listener when the caller opted in
339
+ // (otherwise Node's default — destroy the socket — applies unchanged).
340
+ if (options.upgrade || options.onUpgrade) {
341
+ server.on('upgrade', makeUpgradeListener(options))
342
+ }
343
+
344
+ const hostname = options.hostname ?? '0.0.0.0'
345
+
346
+ const ready = new Promise<number>((resolve, reject) => {
347
+ server.once('listening', () => {
348
+ const addr = server.address()
349
+ resolve(typeof addr === 'object' && addr !== null ? addr.port : options.port)
350
+ })
351
+ server.once('error', reject)
352
+ })
353
+
354
+ server.listen(options.port, hostname)
355
+
356
+ const handle: ServerHandle = {
357
+ get port(): number {
358
+ const addr = server.address()
359
+ return typeof addr === 'object' && addr !== null ? addr.port : options.port
360
+ },
361
+ stop: (force?: boolean) => {
362
+ if (force) {
363
+ server.closeAllConnections?.()
364
+ }
365
+ // Graceful: stop accepting and drain in-flight connections.
366
+ server.close()
367
+ },
368
+ }
369
+
370
+ return {
371
+ server: handle,
372
+ stop: () => handle.stop(),
373
+ port: options.port,
374
+ ready,
375
+ }
376
+ }