castrum 0.7.0 → 0.9.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 (207) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +154 -368
  3. package/castrum.linux-x64-gnu.node +0 -0
  4. package/dist/index.d.ts +2763 -0
  5. package/dist/index.js +8707 -0
  6. package/docs/API.md +337 -0
  7. package/docs/ARCHITECTURE.md +507 -0
  8. package/docs/BENCHMARKS.md +343 -0
  9. package/docs/CASE_STUDY.md +240 -0
  10. package/docs/ENVIRONMENT.md +81 -0
  11. package/docs/FFI_BUN_GUIDE.md +327 -0
  12. package/docs/GETTING_STARTED.md +192 -0
  13. package/docs/INGRESS-ROUTER.md +81 -0
  14. package/docs/INGRESS.md +746 -0
  15. package/docs/NATIVE-ROUTE.md +53 -0
  16. package/docs/REPO_MAP.md +279 -0
  17. package/docs/adr/0001-two-wire-formats.md +39 -0
  18. package/docs/adr/0002-zero-dom-fast-schema.md +38 -0
  19. package/docs/adr/0003-bun-builtins-delegation.md +43 -0
  20. package/docs/bun-builtins-decision-matrix.md +115 -0
  21. package/index.ts +44 -8
  22. package/package.json +98 -33
  23. package/src/ingress/body.ts +289 -0
  24. package/src/ingress/constants.ts +220 -48
  25. package/src/ingress/context.ts +136 -0
  26. package/src/ingress/decode/baked-result.ts +121 -0
  27. package/src/ingress/decode/fast-result.ts +160 -0
  28. package/src/ingress/decode/packed-sections.ts +56 -0
  29. package/src/ingress/decode/result-base.ts +172 -0
  30. package/src/ingress/errors.ts +72 -0
  31. package/src/ingress/fast.ts +152 -1021
  32. package/src/ingress/handlers.ts +639 -1001
  33. package/src/ingress/headers/baked-templates.ts +102 -0
  34. package/src/ingress/headers/cors.ts +44 -0
  35. package/src/ingress/headers/fast-templates.ts +238 -0
  36. package/src/ingress/headers/hsts.ts +83 -0
  37. package/src/ingress/health.ts +68 -0
  38. package/src/ingress/index.ts +142 -405
  39. package/src/ingress/metrics.ts +132 -0
  40. package/src/ingress/options.ts +197 -0
  41. package/src/ingress/packing/gather-raw-headers.ts +139 -0
  42. package/src/ingress/packing/header-packing.ts +75 -0
  43. package/src/ingress/packing/input-packer.ts +164 -0
  44. package/src/ingress/packing/scratch.ts +50 -0
  45. package/src/ingress/packing/select-headers.ts +102 -0
  46. package/src/ingress/response/error-bodies.ts +75 -0
  47. package/src/ingress/response/terminal.ts +59 -0
  48. package/src/ingress/router.ts +237 -0
  49. package/src/ingress/routes/common.ts +79 -0
  50. package/src/ingress/routes/delete.ts +14 -0
  51. package/src/ingress/routes/echo.ts +107 -0
  52. package/src/ingress/routes/fallback.ts +23 -0
  53. package/src/ingress/routes/head.ts +27 -0
  54. package/src/ingress/routes/index.ts +11 -0
  55. package/src/ingress/routes/json-write.ts +129 -0
  56. package/src/ingress/routes/options.ts +26 -0
  57. package/src/ingress/routes/read.ts +35 -0
  58. package/src/ingress/routes/responder.ts +159 -0
  59. package/src/ingress/server-node.ts +376 -0
  60. package/src/ingress/server.ts +475 -0
  61. package/src/ingress/shared.ts +132 -0
  62. package/src/ingress/status.ts +97 -0
  63. package/src/ingress/types.ts +191 -0
  64. package/src/integration/batch.ts +58 -0
  65. package/src/integration/index.ts +29 -0
  66. package/src/integration/pipeline.ts +220 -0
  67. package/src/integration/streaming.ts +78 -0
  68. package/src/integration/websocket.ts +93 -0
  69. package/src/loader/batch.ts +137 -0
  70. package/src/loader/cost.ts +105 -0
  71. package/src/loader/index.ts +745 -0
  72. package/src/loader/ops.ts +657 -0
  73. package/src/loader/state.ts +84 -0
  74. package/src/loader/types.ts +145 -0
  75. package/src/native/ffi/constants.ts +58 -0
  76. package/src/native/ffi/selftest.ts +729 -0
  77. package/src/native/ffi/types.ts +474 -0
  78. package/src/native/ffi.ts +1775 -0
  79. package/src/native/index.ts +8 -207
  80. package/src/native/loader.ts +240 -0
  81. package/src/native/types/instances.ts +274 -0
  82. package/src/native/types.ts +401 -0
  83. package/src/runtime/builtins.ts +131 -0
  84. package/src/runtime/codec.ts +147 -0
  85. package/src/runtime/detect.ts +36 -0
  86. package/src/runtime/index.ts +28 -0
  87. package/src/runtime/native.ts +35 -0
  88. package/src/runtime/server.ts +31 -0
  89. package/src/runtime/transport.ts +64 -0
  90. package/src/runtime/types.ts +173 -0
  91. package/src/runtime/uuid.ts +45 -0
  92. package/src/runtime/websocket.ts +30 -0
  93. package/src/rust-ffi/addon.ts +10 -0
  94. package/src/rust-ffi/batch/build.ts +155 -0
  95. package/src/rust-ffi/batch/index.ts +8 -0
  96. package/src/rust-ffi/batch/types.ts +92 -0
  97. package/src/rust-ffi/client.ts +87 -0
  98. package/src/rust-ffi/context.ts +193 -0
  99. package/src/rust-ffi/index.ts +51 -541
  100. package/src/rust-ffi/into.ts +21 -0
  101. package/src/rust-ffi/options.ts +113 -0
  102. package/src/rust-ffi/packed.ts +253 -0
  103. package/src/rust-ffi/proven.ts +28 -0
  104. package/src/rust-ffi/scalar/crypto.ts +346 -0
  105. package/src/rust-ffi/scalar/factories.ts +459 -0
  106. package/src/rust-ffi/scalar/hashing.ts +74 -0
  107. package/src/rust-ffi/scalar/http.ts +337 -0
  108. package/src/rust-ffi/scalar/index.ts +31 -0
  109. package/src/rust-ffi/scalar/interface.ts +325 -0
  110. package/src/rust-ffi/scalar/json-packed.ts +175 -0
  111. package/src/rust-ffi/scalar/json.ts +61 -0
  112. package/src/rust-ffi/scalar/payload.ts +241 -0
  113. package/src/rust-ffi/text.ts +121 -0
  114. package/src/selection.json +194 -0
  115. package/src/selection.ts +73 -0
  116. package/src/shared/adaptive.ts +67 -0
  117. package/src/shared/buffer-pool.ts +264 -0
  118. package/src/shared/bytes.ts +108 -9
  119. package/src/shared/codec.ts +78 -0
  120. package/src/shared/env.ts +20 -0
  121. package/src/shared/log.ts +75 -0
  122. package/src/shared/metrics.ts +247 -0
  123. package/src/shared/packed/index.ts +10 -0
  124. package/src/shared/packed/parsers.ts +31 -0
  125. package/src/shared/packed/schema.ts +23 -0
  126. package/src/shared/packed/wire.ts +504 -0
  127. package/src/shared/proven.ts +289 -0
  128. package/src/shared/request-id.ts +67 -0
  129. package/src/shared/response.ts +104 -0
  130. package/src/shared/runtime.ts +29 -0
  131. package/src/shared/trace.ts +81 -0
  132. package/src/shared/uuid.ts +15 -0
  133. package/Cargo.toml +0 -73
  134. package/bench.ts +0 -3
  135. package/rust/batch.rs +0 -195
  136. package/rust/cookie_parser.rs +0 -70
  137. package/rust/cors.rs +0 -263
  138. package/rust/hashing.rs +0 -164
  139. package/rust/headers.rs +0 -234
  140. package/rust/hmac_sha256.rs +0 -113
  141. package/rust/http_parser.rs +0 -132
  142. package/rust/ingress.rs +0 -616
  143. package/rust/ingress_constants.rs +0 -89
  144. package/rust/ip_trust.rs +0 -185
  145. package/rust/json_ops.rs +0 -66
  146. package/rust/json_patch_ops.rs +0 -25
  147. package/rust/json_schema.rs +0 -151
  148. package/rust/json_ser.rs +0 -429
  149. package/rust/lib.rs +0 -48
  150. package/rust/method.rs +0 -141
  151. package/rust/mime_lookup.rs +0 -76
  152. package/rust/output.rs +0 -238
  153. package/rust/proxy.rs +0 -90
  154. package/rust/query_parser.rs +0 -141
  155. package/rust/random_token.rs +0 -52
  156. package/rust/rate_limit.rs +0 -203
  157. package/rust/terminal.rs +0 -95
  158. package/rust/unit_tests.rs +0 -843
  159. package/rust/url_codec.rs +0 -133
  160. package/rust/util.rs +0 -537
  161. package/rust/validation.rs +0 -107
  162. package/rust/websocket.rs +0 -23
  163. package/src/baseline/index.ts +0 -12
  164. package/src/baseline/tasks/cookie.ts +0 -22
  165. package/src/baseline/tasks/hashing.ts +0 -18
  166. package/src/baseline/tasks/hmac.ts +0 -38
  167. package/src/baseline/tasks/http.ts +0 -62
  168. package/src/baseline/tasks/json-patch.ts +0 -12
  169. package/src/baseline/tasks/json.ts +0 -50
  170. package/src/baseline/tasks/mime.ts +0 -5
  171. package/src/baseline/tasks/query.ts +0 -14
  172. package/src/baseline/tasks/token.ts +0 -15
  173. package/src/baseline/tasks/url.ts +0 -11
  174. package/src/baseline/tasks/validation.ts +0 -19
  175. package/src/baseline/tasks/websocket.ts +0 -14
  176. package/src/bench/assert.ts +0 -70
  177. package/src/bench/checks.ts +0 -227
  178. package/src/bench/checksum.ts +0 -27
  179. package/src/bench/comparisons.ts +0 -70
  180. package/src/bench/concurrent-worker.ts +0 -135
  181. package/src/bench/fixtures.ts +0 -233
  182. package/src/bench/index.ts +0 -2
  183. package/src/bench/measure.ts +0 -177
  184. package/src/bench/now.ts +0 -3
  185. package/src/bench/report.ts +0 -79
  186. package/src/bench/run.ts +0 -57
  187. package/src/bench/tasks/complex.ts +0 -232
  188. package/src/bench/tasks/concurrent.ts +0 -213
  189. package/src/bench/tasks/cookie.ts +0 -44
  190. package/src/bench/tasks/hashing.ts +0 -33
  191. package/src/bench/tasks/hmac.ts +0 -36
  192. package/src/bench/tasks/http.ts +0 -50
  193. package/src/bench/tasks/index.ts +0 -52
  194. package/src/bench/tasks/json-patch.ts +0 -21
  195. package/src/bench/tasks/json.ts +0 -33
  196. package/src/bench/tasks/mime.ts +0 -21
  197. package/src/bench/tasks/query.ts +0 -44
  198. package/src/bench/tasks/stress.ts +0 -142
  199. package/src/bench/tasks/token.ts +0 -20
  200. package/src/bench/tasks/url.ts +0 -34
  201. package/src/bench/tasks/validation.ts +0 -57
  202. package/src/bench/tasks/websocket.ts +0 -21
  203. package/src/bench/types.ts +0 -60
  204. package/src/data/json-rows.ts +0 -33
  205. package/src/ingress/packed-input.ts +0 -59
  206. package/src/shared/json.ts +0 -18
  207. package/src/shared/packed.ts +0 -316
@@ -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 Server as HttpServer,
19
+ type IncomingMessage,
20
+ type ServerResponse,
21
+ } from 'node:http'
22
+ import { type Duplex, Readable } from 'node:stream'
23
+ import { fallbackHandler } from './routes'
24
+ import {
25
+ type BakedServer,
26
+ buildPathMatcher,
27
+ buildRouteHandlers,
28
+ type CreateIngressServerOptions,
29
+ type RouteHandler,
30
+ type ServerHandle,
31
+ } from './server'
32
+
33
+ export type { RouteHandler } from './server'
34
+
35
+ /**
36
+ * Default socket-level guard for receiving request headers/body (ms). Node's
37
+ * `requestTimeout` / `headersTimeout` default to 0 (disabled); castrum opts
38
+ * into a 30s cap to reject slowloris/trickling sockets. Distinct from the
39
+ * route-level `bodyTimeoutMs` (`DEFAULT_BODY_TIMEOUT_MS`), which guards the
40
+ * body-read loop after headers arrive.
41
+ */
42
+ const DEFAULT_SOCKET_TIMEOUT_MS = 30_000
43
+
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
+ }