castrum 0.9.7 → 0.9.9

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 (108) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/castrum.darwin-arm64.node +0 -0
  4. package/castrum.darwin-x64.node +0 -0
  5. package/castrum.linux-arm64-gnu.node +0 -0
  6. package/castrum.linux-arm64-musl.node +0 -0
  7. package/castrum.linux-x64-gnu.node +0 -0
  8. package/castrum.linux-x64-musl.node +0 -0
  9. package/castrum.linux-x64-v3-gnu.node +0 -0
  10. package/castrum.win32-x64-msvc.node +0 -0
  11. package/dist/index.d.ts +664 -8
  12. package/dist/index.js +2014 -1327
  13. package/docs/BENCHMARKS.md +13 -0
  14. package/docs/CASE_STUDY.md +1 -1
  15. package/docs/ENVIRONMENT.md +8 -1
  16. package/docs/FFI_BUN_GUIDE.md +1 -1
  17. package/docs/INGRESS.md +4 -4
  18. package/docs/REPO_MAP.md +7 -5
  19. package/docs/RND-CONCURRENCY.md +20 -4
  20. package/docs/superpowers/plans/2026-09-18-castrum-hardening-cleanup.md +617 -0
  21. package/docs/superpowers/plans/2026-09-18-elysia-learnings-adoption.md +634 -0
  22. package/index.ts +6 -0
  23. package/package.json +4 -2
  24. package/rust/crypto/aead.rs +44 -0
  25. package/rust/crypto/argon2.rs +32 -2
  26. package/rust/crypto/jwt/tests.rs +19 -0
  27. package/rust/crypto/jwt/token.rs +43 -28
  28. package/rust/crypto/session.rs +6 -0
  29. package/rust/ffi/crypto.rs +63 -32
  30. package/rust/ffi/hashing.rs +48 -33
  31. package/rust/ffi/http.rs +62 -36
  32. package/rust/ffi/metrics.rs +6 -2
  33. package/rust/ffi/payload.rs +47 -27
  34. package/rust/ffi/task.rs +55 -35
  35. package/rust/ffi/tests.rs +24 -0
  36. package/rust/ffi/util.rs +40 -0
  37. package/rust/http/headers.rs +13 -0
  38. package/rust/http/multipart.rs +139 -0
  39. package/rust/ingress/api.rs +21 -5
  40. package/rust/ingress/mod.rs +7 -0
  41. package/rust/ingress/native_route.rs +25 -5
  42. package/rust/ingress/schema_cache.rs +266 -0
  43. package/rust/task/completion.rs +7 -0
  44. package/rust/task/mod.rs +2 -1
  45. package/rust/task/runtime.rs +55 -7
  46. package/rust/task/tests.rs +128 -1
  47. package/src/ingress/abort.ts +48 -0
  48. package/src/ingress/body.ts +104 -8
  49. package/src/ingress/decode/baked-result.ts +13 -5
  50. package/src/ingress/decode/fast-result.ts +1 -1
  51. package/src/ingress/decode/result-base.ts +16 -2
  52. package/src/ingress/handlers.ts +104 -31
  53. package/src/ingress/headers/memoized-headers.ts +40 -0
  54. package/src/ingress/health.ts +22 -0
  55. package/src/ingress/index.ts +3 -1
  56. package/src/ingress/native-route.ts +25 -6
  57. package/src/ingress/options.ts +26 -2
  58. package/src/ingress/packing/gather-raw-headers.ts +24 -8
  59. package/src/ingress/response/baked-response.ts +118 -28
  60. package/src/ingress/router.ts +50 -3
  61. package/src/ingress/routes/common.ts +35 -11
  62. package/src/ingress/routes/echo.ts +60 -7
  63. package/src/ingress/routes/json-write.ts +32 -8
  64. package/src/ingress/routes/native.ts +24 -5
  65. package/src/ingress/routes/options.ts +6 -2
  66. package/src/ingress/routes/responder.ts +29 -9
  67. package/src/ingress/routes/safe-record.ts +57 -0
  68. package/src/ingress/server-error.ts +111 -0
  69. package/src/ingress/server-node.ts +135 -37
  70. package/src/ingress/server.ts +143 -36
  71. package/src/ingress/shared.ts +1 -1
  72. package/src/ingress/types.ts +15 -0
  73. package/src/integration/pipeline.ts +6 -0
  74. package/src/loader/ops.ts +1 -1
  75. package/src/native/ffi/build/codecs-selftest.ts +305 -0
  76. package/src/native/ffi/build/codecs.ts +0 -296
  77. package/src/native/ffi/build/instances-selftest.ts +264 -0
  78. package/src/native/ffi/build/instances.ts +10 -259
  79. package/src/native/ffi/build/parse-selftest.ts +238 -0
  80. package/src/native/ffi/build/parse.ts +11 -234
  81. package/src/native/ffi/build/task.ts +2 -1
  82. package/src/native/ffi/build.ts +5 -1
  83. package/src/native/ffi/selftest.ts +3 -3
  84. package/src/native/ffi/symbols.ts +460 -0
  85. package/src/native/ffi/types.ts +6 -1
  86. package/src/native/ffi.ts +2 -434
  87. package/src/native/ingress-binding.ts +39 -0
  88. package/src/native/types.ts +6 -0
  89. package/src/runtime/codec.ts +24 -0
  90. package/src/runtime/detect.ts +14 -0
  91. package/src/runtime/types.ts +11 -0
  92. package/src/rust-ffi/batch/build.ts +18 -8
  93. package/src/rust-ffi/batch/types.ts +15 -0
  94. package/src/rust-ffi/context.ts +46 -3
  95. package/src/rust-ffi/options.ts +19 -0
  96. package/src/rust-ffi/scalar/crypto.ts +8 -0
  97. package/src/rust-ffi/scalar/factories.ts +53 -20
  98. package/src/rust-ffi/scalar/http.ts +60 -10
  99. package/src/rust-ffi/scalar/interface.ts +18 -1
  100. package/src/rust-ffi/scalar/payload.ts +27 -1
  101. package/src/rust-ffi/text.ts +5 -4
  102. package/src/shared/buffer-pool.ts +54 -9
  103. package/src/shared/codec.ts +26 -0
  104. package/src/shared/memory.ts +68 -0
  105. package/src/shared/metrics.ts +86 -3
  106. package/src/shared/packed/parsers.ts +64 -7
  107. package/src/shared/runtime.ts +7 -0
  108. package/src/task/runtime.ts +81 -37
@@ -0,0 +1,48 @@
1
+ // src/ingress/abort.ts — PURE request-cancellation helpers.
2
+ //
3
+ // The async ingress reads `Request.signal` (fired when the client disconnects)
4
+ // at its genuine async boundaries. A body read or a route wrapper that observes
5
+ // an abort returns the cancelled-client response instead of running more work.
6
+ // PURE: no addon / pool imports — safe for the route factories.
7
+
8
+ /**
9
+ * The `err.code` value a body read carries when it is rejected because the
10
+ * client disconnected (`Request.signal` aborted).
11
+ */
12
+ export const ABORT_CODE = 'REQUEST_ABORTED'
13
+
14
+ /**
15
+ * Whether a thrown value is the cancellation error produced when `req.signal`
16
+ * aborts during a body read (see `readBodyWithLimit` in `./body`).
17
+ *
18
+ * @param err - The caught value.
19
+ * @returns `true` when `err.code === 'REQUEST_ABORTED'`.
20
+ * @example
21
+ * ```ts
22
+ * try {
23
+ * await readBodyWithLimit(req, 1 << 20, true)
24
+ * } catch (err) {
25
+ * if (isAbortError(err)) return abortResponse()
26
+ * throw err
27
+ * }
28
+ * ```
29
+ */
30
+ export function isAbortError(err: unknown): boolean {
31
+ return (err as { code?: unknown } | null | undefined)?.code === ABORT_CODE
32
+ }
33
+
34
+ /**
35
+ * Build the cancelled-client response: status **499** ("Client Closed
36
+ * Request", a de-facto nginx status) with no body. This is the only new
37
+ * outcome added by cancellation — it never replaces an existing success or
38
+ * error status.
39
+ *
40
+ * @returns A bodyless `Response` with status 499.
41
+ * @example
42
+ * ```ts
43
+ * if (req.signal?.aborted) return abortResponse()
44
+ * ```
45
+ */
46
+ export function abortResponse(): Response {
47
+ return new Response(null, { status: 499 })
48
+ }
@@ -2,6 +2,7 @@
2
2
  // API.
3
3
 
4
4
  import { isBun } from '../shared/runtime'
5
+ import { ABORT_CODE } from './abort'
5
6
 
6
7
  const EMPTY_BODY = new Uint8Array(0)
7
8
 
@@ -65,6 +66,59 @@ function bodyTooLargeError(): Error & { code?: string } {
65
66
  return err
66
67
  }
67
68
 
69
+ function abortError(): Error & { code?: string } {
70
+ const err = new Error(ABORT_CODE) as Error & { code?: string }
71
+ err.code = ABORT_CODE
72
+ return err
73
+ }
74
+
75
+ /** Reject when `signal` is already aborted (the entry guard for every path). */
76
+ function throwIfAborted(signal: AbortSignal | undefined): void {
77
+ if (signal?.aborted) throw abortError()
78
+ }
79
+
80
+ /** A reusable rejection promise + listener-cleanup pair for a read race. */
81
+ interface ReadGuard {
82
+ promise: Promise<never>
83
+ cancel: () => void
84
+ }
85
+
86
+ /**
87
+ * Track `req.signal` as a rejection racer: settles with a `REQUEST_ABORTED`
88
+ * error when the client disconnects. Returns `null` when the request carries no
89
+ * signal, so runtimes without `Request.signal` are unaffected. The
90
+ * `{ once: true }` listener self-removes when it fires; `cancel()` detaches it
91
+ * on the read-completed-first path so no listener outlives the read.
92
+ */
93
+ function createAbortGuard(signal: AbortSignal | undefined): ReadGuard | null {
94
+ if (!signal) return null
95
+ if (signal.aborted) {
96
+ return { promise: Promise.reject(abortError()), cancel: () => {} }
97
+ }
98
+ let listener: (() => void) | null = null
99
+ const promise = new Promise<never>((_resolve, reject) => {
100
+ listener = () => reject(abortError())
101
+ signal.addEventListener('abort', listener, { once: true })
102
+ })
103
+ return {
104
+ promise,
105
+ cancel: () => {
106
+ if (listener !== null) {
107
+ signal.removeEventListener('abort', listener)
108
+ listener = null
109
+ }
110
+ },
111
+ }
112
+ }
113
+
114
+ /** Race a read against guard rejections (a plain await when there are none). */
115
+ function raceRead<T>(read: Promise<T>, guards: ReadonlyArray<ReadGuard>): Promise<T> {
116
+ if (guards.length === 0) return read
117
+ const racers: Promise<unknown>[] = [read]
118
+ for (const guard of guards) racers.push(guard.promise)
119
+ return Promise.race(racers) as Promise<T>
120
+ }
121
+
68
122
  // ── Shared body-read deadline watchdog ──────────────────────────────────────
69
123
  // One interval timer sweeps ALL in-flight body reads that carry a deadline,
70
124
  // replacing a per-request `setTimeout` + `clearTimeout` + `timer.unref` on
@@ -145,6 +199,9 @@ function createDeadline(deadlineMs: number): { promise: Promise<never>; cancel:
145
199
  * (thrown as soon as the limit is crossed — the body is never fully
146
200
  * buffered first, which is the slowloris/large-body protection).
147
201
  * - `REQUEST_TIMEOUT` when `timeoutMs` elapses before the body completes.
202
+ * - `REQUEST_ABORTED` when `req.signal` aborts (client disconnect) before the
203
+ * read settles — including an already-aborted signal at entry. The race's
204
+ * abort listener is removed once the read settles.
148
205
  */
149
206
  export async function readBodyWithLimit(
150
207
  req: Request,
@@ -152,6 +209,10 @@ export async function readBodyWithLimit(
152
209
  guard: boolean,
153
210
  timeoutMs?: number,
154
211
  ): Promise<Uint8Array> {
212
+ // Client disconnect before any read work: reject immediately (also covers an
213
+ // already-aborted signal on every downstream path).
214
+ throwIfAborted(req.signal)
215
+
155
216
  const deadline = timeoutMs && timeoutMs > 0 ? timeoutMs : 0
156
217
 
157
218
  // Fast path: a declared Content-Length lets us PROVE the body fits the
@@ -173,11 +234,34 @@ export async function readBodyWithLimit(
173
234
  // path — `req.bytes()` (Bun) / `arrayBuffer()` (Node) read the buffered
174
235
  // body directly, avoiding a lazily-constructed stream per write request.
175
236
  if (deadline <= 0) {
176
- const bytes = await readBodyBytes(req)
177
- if (guard && bytes.byteLength > maxBytes) {
178
- throw bodyTooLargeError()
237
+ // No deadline: use the same buffered fast path as the deadline branch.
238
+ // Under Bun a small declared-length body is usually already buffered —
239
+ // `Bun.peek` returns the bytes synchronously, so the common case skips
240
+ // the abort race + microtask entirely. Only a genuinely pending read
241
+ // (slow/trickling body) or a runtime without `req.bytes()` (Node) pays
242
+ // the abort race.
243
+ const buffered = peekBufferedBody(req)
244
+ if (buffered !== false && 'bytes' in buffered) {
245
+ throwIfAborted(req.signal)
246
+ if (guard && buffered.bytes.byteLength > maxBytes) {
247
+ throw bodyTooLargeError()
248
+ }
249
+ return buffered.bytes
250
+ }
251
+ const abort = createAbortGuard(req.signal)
252
+ try {
253
+ const bytes = await raceRead(
254
+ buffered === false ? readBodyBytes(req) : buffered,
255
+ abort !== null ? [abort] : [],
256
+ )
257
+ throwIfAborted(req.signal)
258
+ if (guard && bytes.byteLength > maxBytes) {
259
+ throw bodyTooLargeError()
260
+ }
261
+ return bytes
262
+ } finally {
263
+ abort?.cancel()
179
264
  }
180
- return bytes
181
265
  }
182
266
  // Deadline path. Under Bun, a small declared-length body is usually
183
267
  // already buffered — `Bun.peek` returns the bytes synchronously, so we
@@ -187,31 +271,38 @@ export async function readBodyWithLimit(
187
271
  const buffered = peekBufferedBody(req)
188
272
  if (buffered === false) {
189
273
  const dl = createDeadline(deadline)
274
+ const abort = createAbortGuard(req.signal)
190
275
  try {
191
- const bytes = await Promise.race([readBodyBytes(req), dl.promise])
276
+ const bytes = await raceRead(readBodyBytes(req), abort !== null ? [dl, abort] : [dl])
277
+ throwIfAborted(req.signal)
192
278
  if (guard && bytes.byteLength > maxBytes) {
193
279
  throw bodyTooLargeError()
194
280
  }
195
281
  return bytes
196
282
  } finally {
197
283
  dl.cancel()
284
+ abort?.cancel()
198
285
  }
199
286
  }
200
287
  if ('bytes' in buffered) {
288
+ throwIfAborted(req.signal)
201
289
  if (guard && buffered.bytes.byteLength > maxBytes) {
202
290
  throw bodyTooLargeError()
203
291
  }
204
292
  return buffered.bytes
205
293
  }
206
294
  const dl = createDeadline(deadline)
295
+ const abort = createAbortGuard(req.signal)
207
296
  try {
208
- const bytes = await Promise.race([buffered, dl.promise])
297
+ const bytes = await raceRead(buffered, abort !== null ? [dl, abort] : [dl])
298
+ throwIfAborted(req.signal)
209
299
  if (guard && bytes.byteLength > maxBytes) {
210
300
  throw bodyTooLargeError()
211
301
  }
212
302
  return bytes
213
303
  } finally {
214
304
  dl.cancel()
305
+ abort?.cancel()
215
306
  }
216
307
  }
217
308
  }
@@ -220,6 +311,7 @@ export async function readBodyWithLimit(
220
311
  // is only touched here — the declared-length fast path never needs it.
221
312
  const body = req.body
222
313
  if (!body) {
314
+ throwIfAborted(req.signal)
223
315
  return EMPTY_BODY
224
316
  }
225
317
 
@@ -231,11 +323,14 @@ export async function readBodyWithLimit(
231
323
  // per request. It is still required when the loop exits early (timeout/error).
232
324
  let completed = false
233
325
  const dl = deadline > 0 ? createDeadline(deadline) : null
326
+ const abort = createAbortGuard(req.signal)
327
+ const guards: ReadGuard[] = []
328
+ if (dl !== null) guards.push(dl)
329
+ if (abort !== null) guards.push(abort)
234
330
 
235
331
  try {
236
332
  for (;;) {
237
- const { done, value } =
238
- dl !== null ? await Promise.race([reader.read(), dl.promise]) : await reader.read()
333
+ const { done, value } = await raceRead(reader.read(), guards)
239
334
 
240
335
  if (done) {
241
336
  completed = true
@@ -253,6 +348,7 @@ export async function readBodyWithLimit(
253
348
  }
254
349
  } finally {
255
350
  dl?.cancel()
351
+ abort?.cancel()
256
352
  // Release the underlying stream. Cancelling while a read() is still
257
353
  // pending (the timeout case) can resolve that pending read() as `done`
258
354
  // and RACE the timeout rejection — so reject first, cancel only AFTER
@@ -36,12 +36,12 @@ import { IngressResultBase } from './result-base'
36
36
 
37
37
  /** Zero-alloc, reusable result decoder for the pre-baked handler path. */
38
38
  export class BakedIngressResult extends IngressResultBase {
39
- refresh(buf: Uint8Array, body: Uint8Array, view: DataView): void {
39
+ refresh(buf: Uint8Array, body: Uint8Array, view: DataView, len: number = buf.byteLength): void {
40
40
  // Defensive: the native core always writes the full fixed header
41
41
  // (>= OUT_DATA_START bytes) before returning `written`. The cached
42
42
  // whole-buffer DataView (see viewForArrayBuffer) would otherwise decode
43
43
  // stale bytes if this contract is ever violated — treat as internal error.
44
- if (buf.byteLength < OUT_DATA_START) {
44
+ if (len < OUT_DATA_START) {
45
45
  this.setInternalError()
46
46
  return
47
47
  }
@@ -59,7 +59,7 @@ export class BakedIngressResult extends IngressResultBase {
59
59
 
60
60
  // Bounds-checked section offsets shared with the fast decoder: a
61
61
  // malformed/truncated buffer can never produce slices past its end.
62
- const layout = sectionLayout(buf.byteLength, cookiesLenRaw, queryLenRaw, bodyJsonLenRaw)
62
+ const layout = sectionLayout(len, cookiesLenRaw, queryLenRaw, bodyJsonLenRaw)
63
63
 
64
64
  if (h0 === 0 && h1 === 0) {
65
65
  this.verdict = 1
@@ -85,7 +85,7 @@ export class BakedIngressResult extends IngressResultBase {
85
85
 
86
86
  // The baked path trusts the already-clamped section lengths, so pass the
87
87
  // safe body len here (the fast path re-checks bounds on read instead).
88
- this.setSections(buf, layout, layout.safeBodyJsonLen)
88
+ this.setSections(buf, layout, layout.safeBodyJsonLen, len)
89
89
 
90
90
  this.updateOkTerminal()
91
91
 
@@ -115,7 +115,15 @@ export class BakedIngressResult extends IngressResultBase {
115
115
  }
116
116
 
117
117
  bodyJson(copy: boolean): Uint8Array {
118
+ if (copy) {
119
+ // Copy directly out of the buffer — `buffer.slice(begin, end)` avoids the
120
+ // intermediate `subarray()` view that `bodyJsonSlice().slice()` created.
121
+ if (this._bodyJsonLen === 0) return this._buf.slice(0, 0)
122
+ const end = this._bodyJsonStart + this._bodyJsonLen
123
+ if (end > this._len) return this._buf.slice(0, 0)
124
+ return this._buf.slice(this._bodyJsonStart, end)
125
+ }
118
126
  const slice = this.bodyJsonSlice()
119
- return (copy ? slice.slice() : slice) as Uint8Array
127
+ return slice as Uint8Array
120
128
  }
121
129
  }
@@ -127,7 +127,7 @@ export class FastIngressResult extends IngressResultBase {
127
127
  // Store slices for lazy decode instead of eagerly decoding strings. The
128
128
  // fast path trusts the RAW declared body length and re-checks bounds on
129
129
  // read (bodyJson()), so pass the raw declared length here.
130
- this.setSections(buf, layout, bodyJsonLen)
130
+ this.setSections(buf, layout, bodyJsonLen, buf.byteLength)
131
131
 
132
132
  // Parity with the baked decoder: any declared section (cookies, query,
133
133
  // OR body) that overran the buffer counts as truncated.
@@ -62,6 +62,13 @@ export abstract class IngressResultBase {
62
62
  body: Uint8Array = EMPTY_BODY
63
63
 
64
64
  protected _buf: Uint8Array = EMPTY_BODY
65
+ /**
66
+ * Valid byte length of `_buf` (the native `written` count). `_buf` may be the
67
+ * WHOLE pooled output buffer (`outputBufferSize` bytes), so bounds checks must
68
+ * use this, not `_buf.byteLength` — that lets `run()` skip a per-request
69
+ * `buffer.subarray(0, written)` view.
70
+ */
71
+ protected _len = 0
65
72
  protected _bodyJsonStart = OUT_DATA_START
66
73
  protected _bodyJsonLen = 0
67
74
  private _cookiesBuf: Uint8Array | null = null
@@ -89,6 +96,7 @@ export abstract class IngressResultBase {
89
96
  this.bodyTruncated = false
90
97
  this.body = EMPTY_BODY
91
98
  this._buf = EMPTY_BODY
99
+ this._len = 0
92
100
  this._bodyJsonStart = OUT_DATA_START
93
101
  this._bodyJsonLen = 0
94
102
  this._cookiesBuf = null
@@ -134,8 +142,14 @@ export abstract class IngressResultBase {
134
142
  * baked path passes the already-clamped `layout.safeBodyJsonLen`. Sections
135
143
  * are stored as slices and decoded lazily by `cookiesJson()`/`queryJson()`.
136
144
  */
137
- protected setSections(buf: Uint8Array, layout: PackedSectionLayout, bodyJsonLen: number): void {
145
+ protected setSections(
146
+ buf: Uint8Array,
147
+ layout: PackedSectionLayout,
148
+ bodyJsonLen: number,
149
+ len: number = buf.byteLength,
150
+ ): void {
138
151
  this._buf = buf
152
+ this._len = len
139
153
  this._bodyJsonStart = layout.bodyJsonStart
140
154
  this._bodyJsonLen = bodyJsonLen
141
155
  this._cookiesBuf =
@@ -174,7 +188,7 @@ export abstract class IngressResultBase {
174
188
  protected bodyJsonSlice(): Uint8Array {
175
189
  if (this._bodyJsonLen === 0) return EMPTY_BODY
176
190
  const end = this._bodyJsonStart + this._bodyJsonLen
177
- if (end > this._buf.byteLength) return EMPTY_BODY
191
+ if (end > this._len) return EMPTY_BODY
178
192
  return this._buf.subarray(this._bodyJsonStart, end)
179
193
  }
180
194
  }
@@ -44,7 +44,7 @@ import { BufferPool, type PooledBuffer } from '../shared/buffer-pool'
44
44
  import { decoder, viewForArrayBuffer } from '../shared/bytes'
45
45
  import { createStructuredLogger } from '../shared/log'
46
46
  import { generateRequestId } from '../shared/request-id'
47
- import { OUT_DATA_START } from './constants'
47
+ import { OUT_DATA_START, HV_CORS_SIMPLE, HV_JSON, HV_RATE_ACTIVE } from './constants'
48
48
  import { BakedIngressResult } from './decode/baked-result'
49
49
  import { buildBakedHeaderTemplates } from './headers/baked-templates'
50
50
  import { buildBakedSecurityEntries } from './headers/security'
@@ -54,9 +54,13 @@ import {
54
54
  type IngressHandlerOptions,
55
55
  warnTrustProxyDeprecated,
56
56
  } from './options'
57
- import { gatherRawHeadersPacked } from './packing/gather-raw-headers'
57
+ import { gatherRawHeadersPacked, prewarmOriginBlocks } from './packing/gather-raw-headers'
58
58
  import { IngressInputPacker } from './packing/input-packer'
59
- import { type BakedResponseState, buildBakedResponseBuilders } from './response/baked-response'
59
+ import {
60
+ type BakedResponseState,
61
+ buildBakedResponseBuilders,
62
+ prewarmOriginHeaders,
63
+ } from './response/baked-response'
60
64
  import {
61
65
  assertSyncCallback,
62
66
  buildHeaderPlan,
@@ -90,6 +94,7 @@ export type {
90
94
  CreateIngressServerOptions,
91
95
  GracefulShutdownOptions,
92
96
  ServerHandle,
97
+ StaticRoute,
93
98
  } from './server'
94
99
  export { createIngressServer, gracefulShutdown } from './server'
95
100
  export type { RouteHandler as IngressNodeRouteHandler } from './server-node'
@@ -110,6 +115,22 @@ export const RATE_LIMIT_U32_MAX = 4_294_967_295
110
115
  */
111
116
  const MAX_FFI_FAILURES = 3
112
117
 
118
+ /**
119
+ * Number of output buffers pre-allocated into the handler's pool at
120
+ * construction (`prefill`), so the first burst wave never pays per-request
121
+ * `new Uint8Array` + GC. Memory trade: `outputBufferSize` × this many bytes
122
+ * retained per handler (bounded by the pool's `maxBuffers` cap).
123
+ */
124
+ const PREWARM_POOL_BUFFERS = 16
125
+
126
+ /**
127
+ * Byte budget for the pool prefill. `createIngressRouter` builds one handler
128
+ * (and therefore one pool) per route, so an unbounded `prefill × outputBufferSize`
129
+ * could reach ~1 GiB per route at the 64 MiB output clamp. This caps the
130
+ * per-handler up-front allocation at 4 MiB regardless of buffer size.
131
+ */
132
+ const PREWARM_POOL_BYTES = 4 * 1024 * 1024
133
+
113
134
  /**
114
135
  * Best-effort URL pathname extraction for log/error lines.
115
136
  *
@@ -285,12 +306,24 @@ export function createIngressHandler(
285
306
  logger
286
307
  )
287
308
 
309
+ // Hoisted observer-presence flags: the hot path skips the response-status
310
+ // computation (an `instanceof` + `safeTerminalStatus`) and the per-request
311
+ // hook call entirely when nothing observes the outcome.
312
+ const hasOnRequest = runtime.onRequest !== undefined
313
+ const hasOnResponse = runtime.onResponse !== undefined
314
+
288
315
  // Reusable output-buffer pool: eliminates the per-request output-buffer
289
316
  // allocation by reusing buffers across requests.
290
317
  // `maxInFlight` (when set) bounds zero-copy borrowing under slow consumers.
318
+ // `prefill` spends up-front memory (`outputBufferSize` × 16 per handler) so
319
+ // a burst ramp never allocates during its first wave — the packaged
320
+ // load-test flow, where the pool would otherwise go 1 buffered → allocate
321
+ // under a 2000-connection flood.
291
322
  const outputPool = new BufferPool({
292
323
  initialSize: outputBufferSize,
293
324
  maxInFlight: runtime.maxInFlight,
325
+ prefill: PREWARM_POOL_BUFFERS,
326
+ prefillBytes: PREWARM_POOL_BYTES,
294
327
  })
295
328
 
296
329
  // Reusable packed-input builder (same zero-alloc discipline as the fast
@@ -313,16 +346,24 @@ export function createIngressHandler(
313
346
  // flaky FFI path degrades to a correct response instead of a failure storm.
314
347
  let ffiFailures = 0
315
348
  let ffiDisabled = false
349
+ // Cold path only (called from a `catch`): counts a recurrence and trips the
350
+ // circuit breaker. Defined once at construction, so it adds nothing per call.
351
+ const noteFfiFailure = (): void => {
352
+ ffiFailures++
353
+ if (ffiFailures >= MAX_FFI_FAILURES) {
354
+ ffiDisabled = true
355
+ }
356
+ }
316
357
 
317
358
  // Cache Origin-augmented header arrays keyed by `variant\u0000origin` (the
318
359
  // steady-state CORS case: the pre-baked template + one
319
360
  // `access-control-allow-origin` row). Callers treat the returned array as
320
361
  // read-only (the `Headers`/`Response` constructors copy entries), so every
321
362
  // distinct (variant, origin) is reused after its first hit — removing the
322
- // per-request array alloc + copy when an Origin header is present. Only
323
- // ALLOWED origins reach the CORS-allowed path (the native pipeline gates the
324
- // variant on CORS approval), so the map stays bounded by the configured
325
- // allow-origin list (+ a couple of preflight variants).
363
+ // per-request array alloc + copy when an Origin header is present. The map is
364
+ // bounded by `ORIGIN_HEADER_CACHE_MAX` in baked-response.ts: with wildcard
365
+ // CORS the Origin is attacker-controlled, so an unbounded map would be a
366
+ // remote memory-exhaustion vector.
326
367
  const originHeaderCache = new Map<string, [string, string][]>()
327
368
 
328
369
  // ── Variant-indexed header templates (precomputed once) ──
@@ -379,6 +420,34 @@ export function createIngressHandler(
379
420
  state: responseState,
380
421
  })
381
422
 
423
+ // Pre-warm the CORS caches for the configured exact allow-origins
424
+ // (load-time-for-RPS trade): the FIRST request from each known origin then
425
+ // hits the baked origin-packed header block AND the cached
426
+ // origin-augmented header array + memoized `Headers` instead of building
427
+ // them. Wildcard/pattern entries (e.g. `https://*.example.com`) are skipped
428
+ // — the concrete resolved origin differs from the pattern string, so
429
+ // caching the pattern would only waste a slot.
430
+ if (headerPlan.cors && typeof cors?.allowOrigin === 'object') {
431
+ const exactOrigins: string[] = []
432
+ for (const origin of cors.allowOrigin) {
433
+ if (
434
+ typeof origin === 'string' &&
435
+ origin.length > 0 &&
436
+ origin.length <= 512 &&
437
+ !origin.includes('*')
438
+ ) {
439
+ exactOrigins.push(origin)
440
+ }
441
+ }
442
+ prewarmOriginBlocks(exactOrigins)
443
+ // Warm the SUCCESS variant the native pipeline actually returns for an
444
+ // allowed CORS JSON response (HV_JSON | HV_CORS_SIMPLE, plus HV_RATE_ACTIVE
445
+ // when rate limiting is on) — not HV_CORS_SIMPLE alone, which is a
446
+ // different cache key and left the warmed entry unreachable.
447
+ const successVariant = HV_JSON | HV_CORS_SIMPLE | (rateEnabled ? HV_RATE_ACTIVE : 0)
448
+ prewarmOriginHeaders(responseBuilders.responseHeaders, exactOrigins, successVariant)
449
+ }
450
+
382
451
  const result = new BakedIngressResult()
383
452
  const ctx: BakedContext = {
384
453
  requestIdHeader: null,
@@ -399,7 +468,7 @@ export function createIngressHandler(
399
468
  ctx.requestIdHeader = emitRequestIdHeader ? requestIdStr : null
400
469
  ctx.origin = headerPlan.cors ? req.headers.get('origin') : null
401
470
 
402
- runtime.onRequest?.(req, requestIdStr, ip)
471
+ if (hasOnRequest) runtime.onRequest?.(req, requestIdStr, ip)
403
472
 
404
473
  const ipStr = ip ?? EMPTY_IP
405
474
 
@@ -463,10 +532,7 @@ export function createIngressHandler(
463
532
  )
464
533
  ffiFailures = 0
465
534
  } catch {
466
- ffiFailures++
467
- if (ffiFailures >= MAX_FFI_FAILURES) {
468
- ffiDisabled = true
469
- }
535
+ noteFfiFailure()
470
536
  // napi re-run: crash-safe (napi-rs catch_unwind) and semantically
471
537
  // identical — a transient ffi panic still serves the request. The
472
538
  // packed frame respects the same url/ip minimal-processing.
@@ -485,10 +551,7 @@ export function createIngressHandler(
485
551
  written = bunFFI.ingressHandlePacked(ingressPtr, input, body, handle.buffer)
486
552
  ffiFailures = 0
487
553
  } catch {
488
- ffiFailures++
489
- if (ffiFailures >= MAX_FFI_FAILURES) {
490
- ffiDisabled = true
491
- }
554
+ noteFfiFailure()
492
555
  // napi re-run: crash-safe (napi-rs catch_unwind) and semantically
493
556
  // identical — a transient ffi panic still serves the request.
494
557
  written = handler.handleRequestPacked(input, body, handle.buffer)
@@ -498,9 +561,16 @@ export function createIngressHandler(
498
561
  written = handler.handleRequestPacked(input, body, handle.buffer)
499
562
  }
500
563
 
501
- const used = handle.buffer.subarray(0, written)
502
- // Cached per-ArrayBuffer DataView: no per-request view allocation.
503
- result.refresh(used, body ?? EMPTY_BODY, viewForArrayBuffer(used.buffer, used.byteOffset))
564
+ // Pass the WHOLE pooled buffer + the written length instead of a
565
+ // per-request `buffer.subarray(0, written)` view (a ~35ns allocation in
566
+ // Bun); the decoder bounds-checks against the length. Same DataView
567
+ // (cached per-ArrayBuffer, offset unchanged).
568
+ result.refresh(
569
+ handle.buffer,
570
+ body ?? EMPTY_BODY,
571
+ viewForArrayBuffer(handle.buffer.buffer, handle.buffer.byteOffset),
572
+ written,
573
+ )
504
574
  } catch (err) {
505
575
  result.setInternalError()
506
576
  const error = err instanceof Error ? err : new Error(String(err))
@@ -522,18 +592,21 @@ export function createIngressHandler(
522
592
  assertSyncCallback(out, 'createIngressHandler().run()')
523
593
  // Report observability for every outcome. Non-Response callbacks
524
594
  // (e.g. echoHandler's object return) fall back to the decoded terminal
525
- // status so they are not silently invisible to onResponse/logging.
526
- const status = out instanceof Response ? out.status : safeTerminalStatus(result)
527
- runtime.onResponse?.(req, result, status, requestIdStr)
528
- if (logger) {
529
- logger.request({
530
- requestId: requestIdStr,
531
- method: req.method,
532
- path: pathForLog(req),
533
- status,
534
- durationMs: Math.round((performance.now() - startedAt) * 1000) / 1000,
535
- ip: ipStr,
536
- })
595
+ // status so they are not silently invisible to onResponse/logging. The
596
+ // status is computed ONLY when something observes it.
597
+ if (hasOnResponse || logger !== null) {
598
+ const status = out instanceof Response ? out.status : safeTerminalStatus(result)
599
+ if (hasOnResponse) runtime.onResponse?.(req, result, status, requestIdStr)
600
+ if (logger !== null) {
601
+ logger.request({
602
+ requestId: requestIdStr,
603
+ method: req.method,
604
+ path: pathForLog(req),
605
+ status,
606
+ durationMs: Math.round((performance.now() - startedAt) * 1000) / 1000,
607
+ ip: ipStr,
608
+ })
609
+ }
537
610
  }
538
611
  return out
539
612
  } finally {
@@ -0,0 +1,40 @@
1
+ // src/ingress/headers/memoized-headers.ts — memoized `Headers` for baked header arrays.
2
+ //
3
+ // `new Response(body, { headers })` copies its headers either way, but Bun
4
+ // re-parses an array-of-pairs on every construction whereas a persistent
5
+ // `Headers` instance is copied directly — measured ~1.4 µs cheaper per response
6
+ // with the full security + CORS set (398 ns vs 1832 ns), the single largest
7
+ // per-request JS cost on the success path.
8
+ //
9
+ // The arrays the ingress builds are themselves cached (the pre-baked templates
10
+ // and the per-origin cache return the SAME array instance across requests), so
11
+ // this memo hits on the steady state. Fresh per-request arrays (rate-limit /
12
+ // request-id extras) are simply never retained — a `WeakMap` lets them be
13
+ // collected, so the cache cannot grow without bound.
14
+ //
15
+ // The returned `Headers` is treated as IMMUTABLE: it is only ever handed to a
16
+ // `Response` constructor, which copies it. Verified: mutating the source array
17
+ // after construction, or one served response's headers, never leaks into
18
+ // another response.
19
+
20
+ /** Memoized `Headers` keyed by the identity of the baked header array. */
21
+ const CACHE = new WeakMap<readonly [string, string][], Headers>()
22
+
23
+ /**
24
+ * Return a memoized `Headers` for a baked header array.
25
+ *
26
+ * @param entries Header pairs from a handler header builder (baked template,
27
+ * per-origin cache, or a fresh extras array).
28
+ * @returns A `Headers` instance safe to pass to a `Response` constructor.
29
+ * @example
30
+ * ```ts
31
+ * new Response(body, { status: 200, headers: memoizedHeaders(pairs) })
32
+ * ```
33
+ */
34
+ export function memoizedHeaders(entries: readonly [string, string][]): Headers {
35
+ const existing = CACHE.get(entries)
36
+ if (existing !== undefined) return existing
37
+ const built = new Headers(entries as [string, string][])
38
+ CACHE.set(entries, built)
39
+ return built
40
+ }
@@ -11,6 +11,11 @@
11
11
  // "/livez": { read: healthHandler() },
12
12
  // }});
13
13
  //
14
+ // For probes that are truly constant, prefer static promotion — it skips the
15
+ // JS ingress pipeline entirely:
16
+ //
17
+ // createIngressServer({ routes: { "/livez": { static: staticLiveness() } } });
18
+ //
14
19
  // The liveness probe NEVER touches dependencies (process alive? -> 200).
15
20
  // The readiness probe runs the optional `check` (deps up? -> 200/503).
16
21
  // The health probe is an alias of liveness (process up) plus optional check.
@@ -36,6 +41,23 @@ export function livenessHandler() {
36
41
  return (_req: Request): Response => jsonResponse(200, { status: 'ok' })
37
42
  }
38
43
 
44
+ /**
45
+ * Liveness probe as a PREBUILT `Response`, for static route promotion.
46
+ *
47
+ * Wire it as `{ static: staticLiveness() }` so `Bun.serve` snapshots the
48
+ * constant body into its native route table and serves `/livez` without
49
+ * entering the JavaScript ingress pipeline at all (see `BakedRoute.static` in
50
+ * src/ingress/server.ts). On the node:http adapter the bare `Response` is
51
+ * re-materialized per request from cached body bytes, so the one-shot body is
52
+ * never replayed. Unlike {@link readinessHandler}, it never runs a dependency
53
+ * check.
54
+ *
55
+ * @returns A fresh 200 `{"status":"ok"}` `Response`.
56
+ */
57
+ export function staticLiveness(): Response {
58
+ return jsonResponse(200, { status: 'ok' })
59
+ }
60
+
39
61
  /**
40
62
  * Readiness probe: runs the optional async `check` (dependency readiness).
41
63
  * Returns `200 {"status":"ready"}` when the check passes (or no check is