castrum 0.9.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 (102) hide show
  1. package/README.md +153 -630
  2. package/castrum.linux-x64-gnu.node +0 -0
  3. package/dist/index.d.ts +1374 -1772
  4. package/dist/index.js +5570 -4169
  5. package/docs/API.md +337 -0
  6. package/docs/ARCHITECTURE.md +106 -14
  7. package/docs/BENCHMARKS.md +83 -47
  8. package/docs/CASE_STUDY.md +240 -0
  9. package/docs/ENVIRONMENT.md +8 -6
  10. package/docs/FFI_BUN_GUIDE.md +327 -0
  11. package/docs/GETTING_STARTED.md +15 -32
  12. package/docs/INGRESS-ROUTER.md +81 -0
  13. package/docs/INGRESS.md +5 -4
  14. package/docs/NATIVE-ROUTE.md +53 -0
  15. package/docs/REPO_MAP.md +46 -26
  16. package/docs/adr/0003-bun-builtins-delegation.md +6 -5
  17. package/docs/bun-builtins-decision-matrix.md +25 -18
  18. package/index.ts +9 -16
  19. package/package.json +36 -24
  20. package/src/ingress/body.ts +167 -27
  21. package/src/ingress/handlers.ts +91 -15
  22. package/src/ingress/health.ts +1 -0
  23. package/src/ingress/index.ts +39 -27
  24. package/src/ingress/packing/gather-raw-headers.ts +85 -21
  25. package/src/ingress/packing/header-packing.ts +5 -5
  26. package/src/ingress/packing/input-packer.ts +17 -3
  27. package/src/ingress/packing/select-headers.ts +12 -6
  28. package/src/ingress/router.ts +237 -0
  29. package/src/ingress/routes/common.ts +42 -1
  30. package/src/ingress/routes/fallback.ts +2 -2
  31. package/src/ingress/routes/head.ts +3 -13
  32. package/src/ingress/routes/index.ts +5 -4
  33. package/src/ingress/routes/json-write.ts +7 -31
  34. package/src/ingress/routes/options.ts +2 -12
  35. package/src/ingress/routes/read.ts +3 -13
  36. package/src/ingress/routes/responder.ts +159 -0
  37. package/src/ingress/server-node.ts +6 -6
  38. package/src/ingress/server.ts +45 -13
  39. package/src/ingress/types.ts +35 -1
  40. package/src/integration/pipeline.ts +3 -0
  41. package/src/integration/streaming.ts +2 -3
  42. package/src/integration/websocket.ts +4 -3
  43. package/src/loader/batch.ts +4 -0
  44. package/src/loader/cost.ts +1 -0
  45. package/src/loader/index.ts +43 -228
  46. package/src/loader/state.ts +84 -0
  47. package/src/loader/types.ts +145 -0
  48. package/src/native/ffi/constants.ts +58 -0
  49. package/src/native/ffi/selftest.ts +729 -0
  50. package/src/native/ffi/types.ts +474 -0
  51. package/src/native/ffi.ts +1063 -829
  52. package/src/native/loader.ts +52 -6
  53. package/src/native/types/instances.ts +274 -0
  54. package/src/native/types.ts +41 -238
  55. package/src/runtime/builtins.ts +131 -0
  56. package/src/runtime/codec.ts +147 -0
  57. package/src/runtime/detect.ts +36 -0
  58. package/src/runtime/index.ts +28 -0
  59. package/src/runtime/native.ts +35 -0
  60. package/src/runtime/server.ts +31 -0
  61. package/src/runtime/transport.ts +64 -0
  62. package/src/runtime/types.ts +173 -0
  63. package/src/runtime/uuid.ts +45 -0
  64. package/src/runtime/websocket.ts +30 -0
  65. package/src/rust-ffi/addon.ts +1 -1
  66. package/src/rust-ffi/batch/build.ts +155 -0
  67. package/src/rust-ffi/batch/index.ts +8 -0
  68. package/src/rust-ffi/batch/types.ts +92 -0
  69. package/src/rust-ffi/client.ts +5 -6
  70. package/src/rust-ffi/context.ts +39 -10
  71. package/src/rust-ffi/index.ts +28 -17
  72. package/src/rust-ffi/into.ts +21 -0
  73. package/src/rust-ffi/options.ts +1 -1
  74. package/src/rust-ffi/packed.ts +2 -2
  75. package/src/rust-ffi/proven.ts +16 -30
  76. package/src/rust-ffi/scalar/crypto.ts +188 -107
  77. package/src/rust-ffi/scalar/factories.ts +253 -27
  78. package/src/rust-ffi/scalar/hashing.ts +43 -41
  79. package/src/rust-ffi/scalar/http.ts +208 -77
  80. package/src/rust-ffi/scalar/index.ts +3 -3
  81. package/src/rust-ffi/scalar/interface.ts +88 -213
  82. package/src/rust-ffi/scalar/json-packed.ts +175 -0
  83. package/src/rust-ffi/scalar/json.ts +28 -12
  84. package/src/rust-ffi/scalar/payload.ts +159 -42
  85. package/src/rust-ffi/text.ts +31 -5
  86. package/src/selection.ts +27 -31
  87. package/src/shared/adaptive.ts +1 -2
  88. package/src/shared/buffer-pool.ts +72 -17
  89. package/src/shared/bytes.ts +75 -6
  90. package/src/shared/codec.ts +78 -0
  91. package/src/shared/env.ts +4 -4
  92. package/src/shared/log.ts +1 -0
  93. package/src/shared/packed/index.ts +10 -0
  94. package/src/shared/packed/parsers.ts +31 -0
  95. package/src/shared/packed/schema.ts +23 -0
  96. package/src/shared/{packed.ts → packed/wire.ts} +31 -39
  97. package/src/shared/proven.ts +189 -496
  98. package/src/shared/runtime.ts +20 -25
  99. package/src/shared/uuid.ts +12 -28
  100. package/src/data/json-rows.ts +0 -33
  101. package/src/rust-ffi/batch.ts +0 -431
  102. package/src/shared/bench-classify.ts +0 -209
@@ -1,8 +1,50 @@
1
1
  // src/ingress/body.ts — Streaming request-body reading for the async ingress
2
2
  // API.
3
3
 
4
+ import { isBun } from '../shared/runtime'
5
+
4
6
  const EMPTY_BODY = new Uint8Array(0)
5
7
 
8
+ /**
9
+ * Read the request body as bytes. Bun's `req.bytes()` returns a `Uint8Array`
10
+ * directly (no `ArrayBuffer` → view wrapper and no copy) — feature-detected so
11
+ * Node (which lacks `Response.bytes()` on some versions) falls back to
12
+ * `arrayBuffer()` + a zero-copy view. This is the hot-path body read for every
13
+ * write route.
14
+ */
15
+ async function readBodyBytes(req: Request): Promise<Uint8Array> {
16
+ const bytes = (req as { bytes?: () => Promise<Uint8Array> }).bytes
17
+ if (typeof bytes === 'function') {
18
+ return bytes.call(req)
19
+ }
20
+ return new Uint8Array(await req.arrayBuffer())
21
+ }
22
+
23
+ /**
24
+ * Bun-only synchronous peek of an already-buffered body.
25
+ *
26
+ * Under Bun a body fully received during HTTP parsing resolves `req.bytes()`
27
+ * IMMEDIATELY — `Bun.peek` hands back the `Uint8Array` without awaiting, so a
28
+ * declared-Content-Length write route can read the body with no deadline race,
29
+ * no watchdog entry and no microtask turn (the common small-body case).
30
+ *
31
+ * Returns:
32
+ * - `{ bytes }` — already buffered; the caller reads it synchronously and
33
+ * must NOT pay the deadline race (the body is already here).
34
+ * - `Promise<Uint8Array>` — the read is still pending (slow/trickling body);
35
+ * the caller MUST still enforce its deadline against this promise.
36
+ * - `false` — no `req.bytes()` on this runtime (Node); the caller falls back
37
+ * to the async `readBodyBytes` + race as before.
38
+ */
39
+ function peekBufferedBody(req: Request): { bytes: Uint8Array } | Promise<Uint8Array> | false {
40
+ const bytes = (req as { bytes?: () => Promise<Uint8Array> }).bytes
41
+ if (typeof bytes !== 'function') return false
42
+ const pending = bytes.call(req)
43
+ if (!isBun()) return pending
44
+ const peeked = Bun.peek(pending)
45
+ return peeked instanceof Promise ? peeked : { bytes: peeked }
46
+ }
47
+
6
48
  /** Read the request body once, enforcing the size guard when enabled. */
7
49
  export function readRequestBodyOnce(
8
50
  req: Request,
@@ -23,6 +65,77 @@ function bodyTooLargeError(): Error & { code?: string } {
23
65
  return err
24
66
  }
25
67
 
68
+ // ── Shared body-read deadline watchdog ──────────────────────────────────────
69
+ // One interval timer sweeps ALL in-flight body reads that carry a deadline,
70
+ // replacing a per-request `setTimeout` + `clearTimeout` + `timer.unref` on
71
+ // every write-route body read (that per-request timer alloc is measurable in
72
+ // the POST micro-bench — ~800ns of the ~2.3µs body read). Abort semantics are
73
+ // unchanged: a read that outlives its deadline rejects with REQUEST_TIMEOUT.
74
+ // Entries are removed on completion or when the deadline fires; the interval
75
+ // only runs while at least one read is pending.
76
+ const bodyWatchdog = (() => {
77
+ const inflight = new Set<WatchdogEntry>()
78
+ let timer: ReturnType<typeof setInterval> | null = null
79
+ const SWEEP_MS = 250
80
+
81
+ function sweep() {
82
+ const now = Date.now()
83
+ for (const entry of inflight) {
84
+ if (entry.settled) {
85
+ inflight.delete(entry)
86
+ continue
87
+ }
88
+ if (now >= entry.deadlineWall) {
89
+ entry.settled = true
90
+ inflight.delete(entry)
91
+ entry.reject(timeoutError())
92
+ }
93
+ }
94
+ if (inflight.size === 0 && timer !== null) {
95
+ clearInterval(timer)
96
+ timer = null
97
+ }
98
+ }
99
+
100
+ function track(entry: WatchdogEntry) {
101
+ inflight.add(entry)
102
+ if (timer === null) {
103
+ timer = setInterval(sweep, SWEEP_MS)
104
+ }
105
+ }
106
+
107
+ function untrack(entry: WatchdogEntry) {
108
+ entry.settled = true
109
+ inflight.delete(entry)
110
+ if (inflight.size === 0 && timer !== null) {
111
+ clearInterval(timer)
112
+ timer = null
113
+ }
114
+ }
115
+
116
+ return { track, untrack }
117
+ })()
118
+
119
+ interface WatchdogEntry {
120
+ deadlineWall: number
121
+ settled: boolean
122
+ reject: (err: Error) => void
123
+ }
124
+
125
+ /** Create a deadline tracked by the shared watchdog (no per-read setTimeout). */
126
+ function createDeadline(deadlineMs: number): { promise: Promise<never>; cancel: () => void } {
127
+ const entry: WatchdogEntry = {
128
+ deadlineWall: Date.now() + deadlineMs,
129
+ settled: false,
130
+ reject: () => {},
131
+ }
132
+ const promise = new Promise<never>((_resolve, reject) => {
133
+ entry.reject = reject
134
+ })
135
+ bodyWatchdog.track(entry)
136
+ return { promise, cancel: () => bodyWatchdog.untrack(entry) }
137
+ }
138
+
26
139
  /**
27
140
  * Stream-read the request body, enforcing the size guard when enabled and an
28
141
  * optional overall deadline.
@@ -39,30 +152,16 @@ export async function readBodyWithLimit(
39
152
  guard: boolean,
40
153
  timeoutMs?: number,
41
154
  ): Promise<Uint8Array> {
42
- const body = req.body
43
-
44
- if (!body) {
45
- return EMPTY_BODY
46
- }
47
-
48
155
  const deadline = timeoutMs && timeoutMs > 0 ? timeoutMs : 0
49
- let timer: ReturnType<typeof setTimeout> | undefined
50
- const timeout = new Promise<never>((_resolve, reject) => {
51
- if (deadline <= 0) return
52
- timer = setTimeout(() => reject(timeoutError()), deadline)
53
- // Don't let a pending body timeout keep the event loop alive once the
54
- // request has otherwise completed (Node; a no-op under Bun).
55
- timer.unref?.()
56
- })
57
156
 
58
157
  // Fast path: a declared Content-Length lets us PROVE the body fits the
59
- // guard before reading, so one native `arrayBuffer()` read replaces the
60
- // reader + per-chunk `Promise.race` + `concatUint8Arrays` churn (the bench
61
- // POSTs are single-shot bodies — this removes ~4-6 allocations per write
62
- // request). The deadline (when set) is still enforced with a single race,
63
- // not one per chunk. `req.arrayBuffer()` is bounded by the server's
64
- // `maxRequestBodySize`, and the post-read length re-check catches a client
65
- // that lies about Content-Length.
158
+ // guard before reading, so one native `bytes()`/`arrayBuffer()` read
159
+ // replaces the reader + per-chunk `Promise.race` + `concatUint8Arrays`
160
+ // churn (the bench POSTs are single-shot bodies — this removes ~4-6
161
+ // allocations per write request). The deadline (when set) is still enforced
162
+ // with a single race, not one per chunk. The read is bounded by the
163
+ // server's `maxRequestBodySize`, and the post-read length re-check catches
164
+ // a client that lies about Content-Length.
66
165
  const declared = req.headers.get('content-length')
67
166
  if (declared !== null) {
68
167
  const declaredLen = Number(declared)
@@ -70,20 +169,60 @@ export async function readBodyWithLimit(
70
169
  if (guard && declaredLen > maxBytes) {
71
170
  throw bodyTooLargeError()
72
171
  }
172
+ // `req.body` (the stream getter) is deliberately NOT touched on this
173
+ // path — `req.bytes()` (Bun) / `arrayBuffer()` (Node) read the buffered
174
+ // body directly, avoiding a lazily-constructed stream per write request.
175
+ if (deadline <= 0) {
176
+ const bytes = await readBodyBytes(req)
177
+ if (guard && bytes.byteLength > maxBytes) {
178
+ throw bodyTooLargeError()
179
+ }
180
+ return bytes
181
+ }
182
+ // Deadline path. Under Bun, a small declared-length body is usually
183
+ // already buffered — `Bun.peek` returns the bytes synchronously, so we
184
+ // skip the deadline race + watchdog + microtask entirely for the common
185
+ // case. Only a genuinely pending read (slow/trickling body) pays the
186
+ // race; non-Bun runtimes keep the previous behavior.
187
+ const buffered = peekBufferedBody(req)
188
+ if (buffered === false) {
189
+ const dl = createDeadline(deadline)
190
+ try {
191
+ const bytes = await Promise.race([readBodyBytes(req), dl.promise])
192
+ if (guard && bytes.byteLength > maxBytes) {
193
+ throw bodyTooLargeError()
194
+ }
195
+ return bytes
196
+ } finally {
197
+ dl.cancel()
198
+ }
199
+ }
200
+ if ('bytes' in buffered) {
201
+ if (guard && buffered.bytes.byteLength > maxBytes) {
202
+ throw bodyTooLargeError()
203
+ }
204
+ return buffered.bytes
205
+ }
206
+ const dl = createDeadline(deadline)
73
207
  try {
74
- const buf =
75
- deadline > 0 ? await Promise.race([req.arrayBuffer(), timeout]) : await req.arrayBuffer()
76
- const bytes = new Uint8Array(buf)
208
+ const bytes = await Promise.race([buffered, dl.promise])
77
209
  if (guard && bytes.byteLength > maxBytes) {
78
210
  throw bodyTooLargeError()
79
211
  }
80
212
  return bytes
81
213
  } finally {
82
- clearTimeout(timer)
214
+ dl.cancel()
83
215
  }
84
216
  }
85
217
  }
86
218
 
219
+ // No (usable) Content-Length: fall back to the streaming reader. `req.body`
220
+ // is only touched here — the declared-length fast path never needs it.
221
+ const body = req.body
222
+ if (!body) {
223
+ return EMPTY_BODY
224
+ }
225
+
87
226
  const reader = body.getReader()
88
227
  const chunks: Uint8Array[] = []
89
228
  let total = 0
@@ -91,11 +230,12 @@ export async function readBodyWithLimit(
91
230
  // already closed, so the `cancel()` in `finally` is a wasted async round-trip
92
231
  // per request. It is still required when the loop exits early (timeout/error).
93
232
  let completed = false
233
+ const dl = deadline > 0 ? createDeadline(deadline) : null
94
234
 
95
235
  try {
96
236
  for (;;) {
97
237
  const { done, value } =
98
- deadline > 0 ? await Promise.race([reader.read(), timeout]) : await reader.read()
238
+ dl !== null ? await Promise.race([reader.read(), dl.promise]) : await reader.read()
99
239
 
100
240
  if (done) {
101
241
  completed = true
@@ -112,7 +252,7 @@ export async function readBodyWithLimit(
112
252
  chunks.push(value)
113
253
  }
114
254
  } finally {
115
- clearTimeout(timer)
255
+ dl?.cancel()
116
256
  // Release the underlying stream. Cancelling while a read() is still
117
257
  // pending (the timeout case) can resolve that pending read() as `done`
118
258
  // and RACE the timeout rejection — so reject first, cancel only AFTER
@@ -39,7 +39,7 @@
39
39
  import { getAddon } from '../native'
40
40
  import { getBunFFI } from '../native/ffi'
41
41
  import { BufferPool, type PooledBuffer } from '../shared/buffer-pool'
42
- import { decoder, viewForArrayBuffer } from '../shared/bytes'
42
+ import { decoder, encoder, viewForArrayBuffer } from '../shared/bytes'
43
43
  import { createStructuredLogger } from '../shared/log'
44
44
  import { generateRequestId } from '../shared/request-id'
45
45
  import { pooledBodyResponse } from '../shared/response'
@@ -54,6 +54,8 @@ import {
54
54
  } from './constants'
55
55
  import { BakedIngressResult } from './decode/baked-result'
56
56
  import { buildBakedHeaderTemplates } from './headers/baked-templates'
57
+ import type { SecurityHeadersOptions } from './headers/hsts'
58
+ import { buildSecurityPairs } from './headers/hsts'
57
59
  import {
58
60
  assertIngressOptionValues,
59
61
  assertKnownIngressOptions,
@@ -62,8 +64,6 @@ import {
62
64
  } from './options'
63
65
  import { gatherRawHeadersPacked } from './packing/gather-raw-headers'
64
66
  import { IngressInputPacker } from './packing/input-packer'
65
- import { buildSecurityPairs } from './headers/hsts'
66
- import type { SecurityHeadersOptions } from './headers/hsts'
67
67
  import { ERROR_BODIES, ERROR_CODE_BODIES, rateLimitedBody } from './response/error-bodies'
68
68
  import {
69
69
  assertSyncCallback,
@@ -84,10 +84,13 @@ export type { IngressHandlerOptions } from './options'
84
84
  export { ERROR_BODIES } from './response/error-bodies'
85
85
  export type { BakedHandlerOptions } from './routes'
86
86
  export {
87
+ deleteHandler,
87
88
  echoHandler,
88
89
  fallbackHandler,
89
90
  headHandler,
90
91
  jsonWriteHandler,
92
+ nativeResponderRoute,
93
+ optionsHandler,
91
94
  readHandler,
92
95
  } from './routes'
93
96
  export type {
@@ -102,7 +105,6 @@ export type { RouteHandler as IngressNodeRouteHandler } from './server-node'
102
105
  // Node.js HTTP adapter (same pre-baked route handlers, node:http backend).
103
106
  export { createIngressServerNode } from './server-node'
104
107
 
105
- const encoder = new TextEncoder()
106
108
  const EMPTY_BODY = new Uint8Array(0)
107
109
  const EMPTY_IP = '0.0.0.0'
108
110
 
@@ -163,6 +165,7 @@ function buildBakedSecurityEntries(
163
165
  // (shared with the route factories) and re-exported here for back-compat.
164
166
  export type { BakedContext, OptimizedIngressHandler } from './types'
165
167
 
168
+ /** Runtime configuration for the pre-baked handler path (`createIngressHandler`). */
166
169
  export interface BakedIngressRuntime {
167
170
  /** Emit an `x-request-id` header on responses. Default: false. */
168
171
  emitRequestIdHeader?: boolean
@@ -274,6 +277,21 @@ export function createIngressHandler(
274
277
  const limit = rateLimit?.limit
275
278
  const rateEnabled = typeof limit === 'number' && limit !== RATE_LIMIT_U32_MAX && limit > 0
276
279
 
280
+ // Minimal-processing flags: skip per-request JS UTF-8 encoding of the URL/IP
281
+ // when the native pipeline (rust/ingress/pipeline.rs) provably cannot read
282
+ // them — mirror its consumption exactly, and KEEP IN SYNC:
283
+ // - URL is consumed iff parseQuery (extract_query), emitMetadataJson
284
+ // (extract_path into the envelope) or https is unpinned (detect_https
285
+ // scans the scheme prefix). Otherwise an empty URL section is safe.
286
+ // - IP is consumed iff rate limiting is active (resolve_client_ip /
287
+ // rate_key) or a proxy-trust mode is configured (socket_is_trusted
288
+ // parses it). Otherwise the IP is dropped from the frame (logs/hooks
289
+ // still see the real `ip` — this only skips the frame encode).
290
+ const trustEnabled = options.trustProxy === true || options.trustedProxies?.enabled === true
291
+ const urlNeeded =
292
+ options.parseQuery === true || options.emitMetadataJson === true || options.https === undefined
293
+ const ipNeeded = rateEnabled || trustEnabled
294
+
277
295
  // Shared with the fast path — proxy extraction is driven by trust config
278
296
  // (trustProxy / trustedProxies), not by whether rate limiting is enabled.
279
297
  const headerPlan: HeaderPlan = buildHeaderPlan(options)
@@ -400,6 +418,8 @@ export function createIngressHandler(
400
418
  const needsRetry = (variant & HV_RATE_LIMITED) !== 0
401
419
 
402
420
  if (!needsRequestId && !needsOrigin && !needsRate && !needsRetry) {
421
+ // Frozen-readonly → mutable: callers only ever read the baked template
422
+ // (zero-copy steady-state path), so the widening is safe in practice.
403
423
  return template as unknown as [string, string][]
404
424
  }
405
425
 
@@ -480,6 +500,7 @@ export function createIngressHandler(
480
500
  // terminal template (JSON + `cache-control: no-store`) directly with zero
481
501
  // per-response array allocation/copy.
482
502
  if (!needsRequestId && !needsOrigin && !needsRate && !needsRetry) {
503
+ // Frozen-readonly → mutable: the baked terminal template is only read.
483
504
  return terminalTemplates[v & 31] as unknown as [string, string][]
484
505
  }
485
506
 
@@ -637,10 +658,12 @@ export function createIngressHandler(
637
658
  // above, so pass it in to avoid a second `req.headers.get('origin')`
638
659
  // native→JS string conversion on the hot path.
639
660
  const packedHeaders = gatherRawHeadersPacked(req, headerPlan, methodKind, ctx.origin)
640
- // url/ip are encoded directly into the packer buffer (no intermediate
641
- // `encoder.encode` Uint8Array + copy); requestId/headers are byte slices
642
- // copied verbatim (no decode→re-encode of the pre-encoded request id).
643
- const input = inputPacker.packParts(methodKind, req.url, ip, ridBytes, packedHeaders)
661
+ // Minimal processing: when the native pipeline cannot read the URL/IP
662
+ // (see `urlNeeded`/`ipNeeded`), pass an empty URL / omit the IP so we
663
+ // skip the per-request JS UTF-8 encode entirely (the frame still carries
664
+ // a valid 0-length section the native bounds-checks).
665
+ const packUrl = urlNeeded ? req.url : ''
666
+ const packIp = ipNeeded ? ip : undefined
644
667
 
645
668
  try {
646
669
  // Acquire inside the try: pool exhaustion (maxInFlight) becomes a 500
@@ -648,14 +671,60 @@ export function createIngressHandler(
648
671
  handle = outputPool.acquire(outputBufferSize)
649
672
  currentHandle = handle
650
673
 
651
- // Write into the pooled buffer — no per-request allocation. Under Bun
652
- // with a live ffi handle this runs the pipeline through `bun:ffi`
653
- // (identical packed wire format); otherwise the napi call. If the ffi
654
- // call throws (native reported 0 = error, or a panic `panic_guard`
655
- // contained), re-dispatch through napi once and, after repeated
656
- // failures, permanently disable ffi for this handler.
674
+ // Write into the pooled buffer — no per-request allocation. Bun
675
+ // PRIMARY: the raw-components C-ABI (`castrum_ingress_handle_components`)
676
+ // passes `url`/`ip` as `bun:ffi` `cstring` args — the engine transcodes
677
+ // the JS strings to call-scoped UTF-8 in-engine, so we skip the JS
678
+ // frame assembly + `Buffer.write` encode for the URL/IP (same wire
679
+ // format: both paths run the shared `handle_components` core). Falls
680
+ // back to the packed frame (FFI or napi) on any failure / stale addon.
681
+ // If an ffi call throws (native reported 0 = error, or a panic
682
+ // `panic_guard` contained), re-dispatch through napi once and, after
683
+ // repeated failures, permanently disable ffi for this handler.
657
684
  let written: number
658
- if (bunFFI !== null && ingressPtr !== 0 && !ffiDisabled) {
685
+ if (
686
+ bunFFI !== null &&
687
+ ingressPtr !== 0 &&
688
+ !ffiDisabled &&
689
+ typeof bunFFI.ingressHandleComponents === 'function'
690
+ ) {
691
+ try {
692
+ written = bunFFI.ingressHandleComponents(
693
+ ingressPtr,
694
+ methodKind,
695
+ packUrl,
696
+ packIp ?? '',
697
+ ridBytes,
698
+ packedHeaders,
699
+ body,
700
+ handle.buffer,
701
+ )
702
+ ffiFailures = 0
703
+ } catch {
704
+ ffiFailures++
705
+ if (ffiFailures >= MAX_FFI_FAILURES) {
706
+ ffiDisabled = true
707
+ }
708
+ // napi re-run: crash-safe (napi-rs catch_unwind) and semantically
709
+ // identical — a transient ffi panic still serves the request. The
710
+ // packed frame respects the same url/ip minimal-processing.
711
+ const input = inputPacker.packParts(
712
+ methodKind,
713
+ packUrl,
714
+ packIp,
715
+ ridBytes,
716
+ packedHeaders,
717
+ )
718
+ written = handler.handleRequestPacked(input, body, handle.buffer)
719
+ }
720
+ } else if (bunFFI !== null && ingressPtr !== 0 && !ffiDisabled) {
721
+ const input = inputPacker.packParts(
722
+ methodKind,
723
+ packUrl,
724
+ packIp,
725
+ ridBytes,
726
+ packedHeaders,
727
+ )
659
728
  try {
660
729
  written = bunFFI.ingressHandlePacked(ingressPtr, input, body, handle.buffer)
661
730
  ffiFailures = 0
@@ -669,6 +738,13 @@ export function createIngressHandler(
669
738
  written = handler.handleRequestPacked(input, body, handle.buffer)
670
739
  }
671
740
  } else {
741
+ const input = inputPacker.packParts(
742
+ methodKind,
743
+ packUrl,
744
+ packIp,
745
+ ridBytes,
746
+ packedHeaders,
747
+ )
672
748
  written = handler.handleRequestPacked(input, body, handle.buffer)
673
749
  }
674
750
 
@@ -15,6 +15,7 @@
15
15
  // The readiness probe runs the optional `check` (deps up? -> 200/503).
16
16
  // The health probe is an alias of liveness (process up) plus optional check.
17
17
 
18
+ /** Outcome of a readiness check: HTTP status + plain-text body. */
18
19
  export interface ReadinessResult {
19
20
  status: number
20
21
  body: string
@@ -6,47 +6,59 @@
6
6
 
7
7
  import { decoder } from '../shared/bytes'
8
8
  import { generateRequestId } from '../shared/request-id'
9
- import { assertSyncCallback, DEFAULT_MAX_BODY_BYTES, DEFAULT_BODY_TIMEOUT_MS } from './shared'
9
+ import { readRequestBodyOnce } from './body'
10
+ import { ERR_CODE_BODY_TOO_LARGE, ERR_CODE_REQUEST_TIMEOUT } from './constants'
11
+ import { internalContext, snapshotResult, syntheticContext } from './context'
10
12
  import { createIngressFast } from './fast'
11
13
  import { buildResponseContext } from './headers/fast-templates'
12
14
  import { buildTerminalResponse } from './response/terminal'
13
- import { snapshotResult, syntheticContext, internalContext } from './context'
14
- import { readRequestBodyOnce } from './body'
15
- import { ERR_CODE_BODY_TOO_LARGE, ERR_CODE_REQUEST_TIMEOUT } from './constants'
16
- import type { IngressOptions, IngressContext, SyncIngressHandler, IngressHandler } from './types'
17
-
15
+ import { assertSyncCallback, DEFAULT_BODY_TIMEOUT_MS, DEFAULT_MAX_BODY_BYTES } from './shared'
16
+ import type { IngressContext, IngressHandler, IngressOptions, SyncIngressHandler } from './types'
17
+
18
+ // Runtime-driven server factory: picks Bun.serve on Bun and the node:http
19
+ // adapter on Node via the runtime adapter (src/runtime/server.ts). This
20
+ // explicit re-export SHADOWS the Bun-only `createIngressServer` from
21
+ // './server' (re-exported via './handlers' above) so the public API serves
22
+ // one backend per runtime. `createIngressServerNode` remains exported for
23
+ // consumers who want the Node backend pinned.
24
+ export { createIngressServer } from '../runtime/index'
25
+ export { generateRequestId } from '../shared/request-id'
26
+ export type { TraceContext } from '../shared/trace'
27
+ // W3C trace context helpers (parse `traceparent` for log/hook correlation).
28
+ export {
29
+ createSpanId,
30
+ createTraceId,
31
+ parseTraceParent,
32
+ serializeTraceParent,
33
+ } from '../shared/trace'
18
34
  // ── Re-export the full public ingress API ─────────────────────────
19
35
  export * from './constants'
20
- export * from './status'
21
36
  export * from './errors'
22
- export * from './handlers'
23
37
  export { createIngressFast, FastIngressResult } from './fast'
24
- export { buildTerminalResponse } from './response/terminal'
38
+ export * from './handlers'
39
+ export type { CorsOptions, CorsStaticStrings } from './headers/cors'
40
+ export type { HeaderTemplate, ResponseBuildContext } from './headers/fast-templates'
25
41
  export { buildResponseContext, headersForResult } from './headers/fast-templates'
26
- export { generateRequestId } from '../shared/request-id'
27
- export { METHOD_KIND, DEFAULT_MAX_BODY_BYTES, DEFAULT_BODY_TIMEOUT_MS } from './shared'
28
- export type { HeaderPlan } from './shared'
29
- export type { ResponseBuildContext, HeaderTemplate } from './headers/fast-templates'
30
- export type { CorsStaticStrings, CorsOptions } from './headers/cors'
31
42
  export type { SecurityHeadersOptions } from './headers/hsts'
32
- export type { IngressFastHandler, IngressFastOptions } from './options'
43
+ export { healthHandler, livenessHandler, readinessHandler } from './health'
44
+ export type { IngressMetrics } from './metrics'
33
45
  // ── Observability (zero-dep): metrics, health probes ─────────────
34
46
  export { createIngressMetrics, metricsHandler } from './metrics'
35
- export type { IngressMetrics } from './metrics'
36
- export { livenessHandler, readinessHandler, healthHandler } from './health'
37
- // W3C trace context helpers (parse `traceparent` for log/hook correlation).
38
- export {
39
- parseTraceParent,
40
- createTraceId,
41
- createSpanId,
42
- serializeTraceParent,
43
- } from '../shared/trace'
44
- export type { TraceContext } from '../shared/trace'
47
+ export type { IngressFastHandler, IngressFastOptions } from './options'
48
+ export { buildTerminalResponse } from './response/terminal'
49
+ export type { CreateIngressRouterOptions, IngressRouter, RouterRouteSpec } from './router'
50
+ export { createIngressRouter } from './router'
51
+ export type { HeaderPlan } from './shared'
52
+ export { DEFAULT_BODY_TIMEOUT_MS, DEFAULT_MAX_BODY_BYTES, METHOD_KIND } from './shared'
53
+ export * from './status'
45
54
  export type {
46
- IngressOptions,
47
55
  IngressContext,
48
- SyncIngressHandler,
49
56
  IngressHandler,
57
+ IngressOptions,
58
+ NativeRequestContext,
59
+ NativeResponder,
60
+ SyncIngressHandler,
61
+ TerminalStyle,
50
62
  } from './types'
51
63
 
52
64
  // ── Sync factory ─────────────────────────────────────────────────