castrum 0.9.7 → 0.9.8

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 (84) 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 +501 -5
  12. package/dist/index.js +1648 -1325
  13. package/docs/BENCHMARKS.md +13 -0
  14. package/docs/CASE_STUDY.md +1 -1
  15. package/docs/ENVIRONMENT.md +1 -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/superpowers/plans/2026-09-18-castrum-hardening-cleanup.md +617 -0
  20. package/index.ts +1 -0
  21. package/package.json +3 -2
  22. package/rust/crypto/aead.rs +44 -0
  23. package/rust/crypto/argon2.rs +32 -2
  24. package/rust/crypto/jwt/tests.rs +19 -0
  25. package/rust/crypto/jwt/token.rs +43 -28
  26. package/rust/crypto/session.rs +6 -0
  27. package/rust/ffi/crypto.rs +63 -32
  28. package/rust/ffi/hashing.rs +48 -33
  29. package/rust/ffi/http.rs +62 -36
  30. package/rust/ffi/metrics.rs +6 -2
  31. package/rust/ffi/payload.rs +47 -27
  32. package/rust/ffi/task.rs +51 -32
  33. package/rust/ffi/tests.rs +24 -0
  34. package/rust/ffi/util.rs +40 -0
  35. package/rust/http/headers.rs +13 -0
  36. package/rust/http/multipart.rs +139 -0
  37. package/rust/ingress/native_route.rs +21 -3
  38. package/rust/task/completion.rs +7 -0
  39. package/rust/task/mod.rs +1 -1
  40. package/rust/task/tests.rs +30 -0
  41. package/src/ingress/decode/baked-result.ts +13 -5
  42. package/src/ingress/decode/fast-result.ts +1 -1
  43. package/src/ingress/decode/result-base.ts +16 -2
  44. package/src/ingress/handlers.ts +103 -31
  45. package/src/ingress/headers/memoized-headers.ts +40 -0
  46. package/src/ingress/native-route.ts +25 -6
  47. package/src/ingress/options.ts +26 -2
  48. package/src/ingress/packing/gather-raw-headers.ts +24 -8
  49. package/src/ingress/response/baked-response.ts +118 -28
  50. package/src/ingress/router.ts +14 -2
  51. package/src/ingress/routes/common.ts +26 -10
  52. package/src/ingress/routes/echo.ts +50 -7
  53. package/src/ingress/routes/json-write.ts +21 -8
  54. package/src/ingress/routes/responder.ts +4 -4
  55. package/src/ingress/server-node.ts +33 -14
  56. package/src/ingress/server.ts +3 -4
  57. package/src/ingress/shared.ts +1 -1
  58. package/src/ingress/types.ts +15 -0
  59. package/src/loader/ops.ts +1 -1
  60. package/src/native/ffi/build/codecs-selftest.ts +305 -0
  61. package/src/native/ffi/build/codecs.ts +0 -296
  62. package/src/native/ffi/build/instances-selftest.ts +264 -0
  63. package/src/native/ffi/build/instances.ts +10 -259
  64. package/src/native/ffi/build/parse-selftest.ts +238 -0
  65. package/src/native/ffi/build/parse.ts +11 -234
  66. package/src/native/ffi/build.ts +5 -1
  67. package/src/native/ffi/selftest.ts +3 -3
  68. package/src/native/ffi/symbols.ts +460 -0
  69. package/src/native/ffi.ts +2 -434
  70. package/src/native/ingress-binding.ts +39 -0
  71. package/src/runtime/codec.ts +24 -0
  72. package/src/runtime/types.ts +11 -0
  73. package/src/rust-ffi/batch/build.ts +18 -8
  74. package/src/rust-ffi/batch/types.ts +15 -0
  75. package/src/rust-ffi/options.ts +19 -0
  76. package/src/rust-ffi/scalar/crypto.ts +8 -0
  77. package/src/rust-ffi/scalar/factories.ts +47 -20
  78. package/src/rust-ffi/scalar/http.ts +60 -10
  79. package/src/rust-ffi/scalar/interface.ts +4 -1
  80. package/src/rust-ffi/scalar/payload.ts +27 -1
  81. package/src/shared/buffer-pool.ts +44 -9
  82. package/src/shared/codec.ts +26 -0
  83. package/src/shared/packed/parsers.ts +64 -7
  84. package/src/task/runtime.ts +29 -28
@@ -224,9 +224,11 @@ impl NativeRoute {
224
224
  if self.require_json_body || self.validate_body {
225
225
  if !has_body || body.len() > self.max_body_bytes {
226
226
  body_valid_json = false;
227
- if self.require_json_body {
228
- error_code = ERR_BODY_NOT_JSON;
229
- }
227
+ // Inside `require_json_body || validate_body`: an absent or
228
+ // oversized body can never satisfy either constraint, so fail
229
+ // closed. (Pre-fix a validateBody-only route returned OK with
230
+ // error_code 0 and skipped schema validation entirely.)
231
+ error_code = ERR_BODY_NOT_JSON;
230
232
  } else {
231
233
  body_valid_json = crate::json::json_ops::json_valid_bytes(body);
232
234
  if self.require_json_body && !body_valid_json {
@@ -732,6 +734,22 @@ mod tests {
732
734
  assert_eq!(flags & ROUTE_RESULT_FLAG_OK, 0);
733
735
  }
734
736
 
737
+ #[test]
738
+ fn validate_body_without_require_json_fails_closed_on_absent_body() {
739
+ let schema = br#"{"type":"object","required":["x"],"properties":{"x":{"type":"number"}}}"#;
740
+ // Deliberately NO requireJsonBody stage: a route that only declares
741
+ // validateBody must still reject a missing body. Pre-fix this returned
742
+ // OK with error_code 0 and skipped schema validation entirely.
743
+ let d = descriptor(&[STAGE_VALIDATE_BODY], &[(PART_BODY, schema)]);
744
+ let r = NativeRoute::compile(&d).unwrap();
745
+ let mut out = vec![0u8; 64];
746
+ r.run(&frame(b"", b"", None), &mut out).unwrap();
747
+ let flags = u32::from_le_bytes(out[0..4].try_into().unwrap());
748
+ let code = u32::from_le_bytes(out[4..8].try_into().unwrap());
749
+ assert_eq!(code, 400);
750
+ assert_eq!(flags & ROUTE_RESULT_FLAG_OK, 0);
751
+ }
752
+
735
753
  #[test]
736
754
  fn body_only_route_is_bare_header() {
737
755
  let d = descriptor(&[STAGE_REQUIRE_JSON_BODY], &[]);
@@ -54,6 +54,13 @@ pub fn set_doorbell(cb: usize) {
54
54
  DOORBELL.store(cb, Ordering::Release);
55
55
  }
56
56
 
57
+ /// Disable the doorbell trampoline (`0`). Called on shutdown BEFORE the JS
58
+ /// owner closes the `JSCallback`, so a completion that lands late can never
59
+ /// call a freed trampoline (poll-only mode drains it instead).
60
+ pub fn clear_doorbell() {
61
+ DOORBELL.store(0, Ordering::Release);
62
+ }
63
+
57
64
  /// Push a completion and ring the doorbell at most once per armed window.
58
65
  ///
59
66
  /// The ring lock is released BEFORE the trampoline is called: the JS callback
package/rust/task/mod.rs CHANGED
@@ -22,7 +22,7 @@ pub mod runtime;
22
22
  #[cfg(test)]
23
23
  mod tests;
24
24
 
25
- pub use completion::{drain, pending, set_doorbell};
25
+ pub use completion::{clear_doorbell, drain, pending, set_doorbell};
26
26
  pub use ops::{mark_cancelled, STATUS_CANCELLED, STATUS_ERROR, STATUS_OK};
27
27
  pub use runtime::{init, shutdown, threads};
28
28
 
@@ -223,6 +223,36 @@ fn doorbell_coalesces_a_batch() {
223
223
  completion::clear();
224
224
  }
225
225
 
226
+ #[test]
227
+ fn clear_doorbell_suppresses_the_ring() {
228
+ let _g = LOCK.lock();
229
+ completion::clear();
230
+ completion::set_doorbell(bell as *const () as usize);
231
+ BELLS.store(0, Ordering::SeqCst);
232
+ completion::push(Completion {
233
+ id: 1,
234
+ status: STATUS_OK,
235
+ body: vec![0],
236
+ });
237
+ assert_eq!(BELLS.load(Ordering::SeqCst), 1);
238
+ // Disarm by draining, then clear the trampoline. A later completion must
239
+ // NOT call the (now closed) trampoline address.
240
+ let mut buf = vec![0u8; 4096];
241
+ completion::drain(&mut buf);
242
+ completion::clear_doorbell();
243
+ completion::push(Completion {
244
+ id: 2,
245
+ status: STATUS_OK,
246
+ body: vec![0],
247
+ });
248
+ assert_eq!(
249
+ BELLS.load(Ordering::SeqCst),
250
+ 1,
251
+ "a cleared doorbell must never be called"
252
+ );
253
+ completion::clear();
254
+ }
255
+
226
256
  #[test]
227
257
  fn pool_starts_with_a_nonzero_thread_count() {
228
258
  let _g = LOCK.lock();
@@ -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,
@@ -110,6 +114,22 @@ export const RATE_LIMIT_U32_MAX = 4_294_967_295
110
114
  */
111
115
  const MAX_FFI_FAILURES = 3
112
116
 
117
+ /**
118
+ * Number of output buffers pre-allocated into the handler's pool at
119
+ * construction (`prefill`), so the first burst wave never pays per-request
120
+ * `new Uint8Array` + GC. Memory trade: `outputBufferSize` × this many bytes
121
+ * retained per handler (bounded by the pool's `maxBuffers` cap).
122
+ */
123
+ const PREWARM_POOL_BUFFERS = 16
124
+
125
+ /**
126
+ * Byte budget for the pool prefill. `createIngressRouter` builds one handler
127
+ * (and therefore one pool) per route, so an unbounded `prefill × outputBufferSize`
128
+ * could reach ~1 GiB per route at the 64 MiB output clamp. This caps the
129
+ * per-handler up-front allocation at 4 MiB regardless of buffer size.
130
+ */
131
+ const PREWARM_POOL_BYTES = 4 * 1024 * 1024
132
+
113
133
  /**
114
134
  * Best-effort URL pathname extraction for log/error lines.
115
135
  *
@@ -285,12 +305,24 @@ export function createIngressHandler(
285
305
  logger
286
306
  )
287
307
 
308
+ // Hoisted observer-presence flags: the hot path skips the response-status
309
+ // computation (an `instanceof` + `safeTerminalStatus`) and the per-request
310
+ // hook call entirely when nothing observes the outcome.
311
+ const hasOnRequest = runtime.onRequest !== undefined
312
+ const hasOnResponse = runtime.onResponse !== undefined
313
+
288
314
  // Reusable output-buffer pool: eliminates the per-request output-buffer
289
315
  // allocation by reusing buffers across requests.
290
316
  // `maxInFlight` (when set) bounds zero-copy borrowing under slow consumers.
317
+ // `prefill` spends up-front memory (`outputBufferSize` × 16 per handler) so
318
+ // a burst ramp never allocates during its first wave — the packaged
319
+ // load-test flow, where the pool would otherwise go 1 buffered → allocate
320
+ // under a 2000-connection flood.
291
321
  const outputPool = new BufferPool({
292
322
  initialSize: outputBufferSize,
293
323
  maxInFlight: runtime.maxInFlight,
324
+ prefill: PREWARM_POOL_BUFFERS,
325
+ prefillBytes: PREWARM_POOL_BYTES,
294
326
  })
295
327
 
296
328
  // Reusable packed-input builder (same zero-alloc discipline as the fast
@@ -313,16 +345,24 @@ export function createIngressHandler(
313
345
  // flaky FFI path degrades to a correct response instead of a failure storm.
314
346
  let ffiFailures = 0
315
347
  let ffiDisabled = false
348
+ // Cold path only (called from a `catch`): counts a recurrence and trips the
349
+ // circuit breaker. Defined once at construction, so it adds nothing per call.
350
+ const noteFfiFailure = (): void => {
351
+ ffiFailures++
352
+ if (ffiFailures >= MAX_FFI_FAILURES) {
353
+ ffiDisabled = true
354
+ }
355
+ }
316
356
 
317
357
  // Cache Origin-augmented header arrays keyed by `variant\u0000origin` (the
318
358
  // steady-state CORS case: the pre-baked template + one
319
359
  // `access-control-allow-origin` row). Callers treat the returned array as
320
360
  // read-only (the `Headers`/`Response` constructors copy entries), so every
321
361
  // 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).
362
+ // per-request array alloc + copy when an Origin header is present. The map is
363
+ // bounded by `ORIGIN_HEADER_CACHE_MAX` in baked-response.ts: with wildcard
364
+ // CORS the Origin is attacker-controlled, so an unbounded map would be a
365
+ // remote memory-exhaustion vector.
326
366
  const originHeaderCache = new Map<string, [string, string][]>()
327
367
 
328
368
  // ── Variant-indexed header templates (precomputed once) ──
@@ -379,6 +419,34 @@ export function createIngressHandler(
379
419
  state: responseState,
380
420
  })
381
421
 
422
+ // Pre-warm the CORS caches for the configured exact allow-origins
423
+ // (load-time-for-RPS trade): the FIRST request from each known origin then
424
+ // hits the baked origin-packed header block AND the cached
425
+ // origin-augmented header array + memoized `Headers` instead of building
426
+ // them. Wildcard/pattern entries (e.g. `https://*.example.com`) are skipped
427
+ // — the concrete resolved origin differs from the pattern string, so
428
+ // caching the pattern would only waste a slot.
429
+ if (headerPlan.cors && typeof cors?.allowOrigin === 'object') {
430
+ const exactOrigins: string[] = []
431
+ for (const origin of cors.allowOrigin) {
432
+ if (
433
+ typeof origin === 'string' &&
434
+ origin.length > 0 &&
435
+ origin.length <= 512 &&
436
+ !origin.includes('*')
437
+ ) {
438
+ exactOrigins.push(origin)
439
+ }
440
+ }
441
+ prewarmOriginBlocks(exactOrigins)
442
+ // Warm the SUCCESS variant the native pipeline actually returns for an
443
+ // allowed CORS JSON response (HV_JSON | HV_CORS_SIMPLE, plus HV_RATE_ACTIVE
444
+ // when rate limiting is on) — not HV_CORS_SIMPLE alone, which is a
445
+ // different cache key and left the warmed entry unreachable.
446
+ const successVariant = HV_JSON | HV_CORS_SIMPLE | (rateEnabled ? HV_RATE_ACTIVE : 0)
447
+ prewarmOriginHeaders(responseBuilders.responseHeaders, exactOrigins, successVariant)
448
+ }
449
+
382
450
  const result = new BakedIngressResult()
383
451
  const ctx: BakedContext = {
384
452
  requestIdHeader: null,
@@ -399,7 +467,7 @@ export function createIngressHandler(
399
467
  ctx.requestIdHeader = emitRequestIdHeader ? requestIdStr : null
400
468
  ctx.origin = headerPlan.cors ? req.headers.get('origin') : null
401
469
 
402
- runtime.onRequest?.(req, requestIdStr, ip)
470
+ if (hasOnRequest) runtime.onRequest?.(req, requestIdStr, ip)
403
471
 
404
472
  const ipStr = ip ?? EMPTY_IP
405
473
 
@@ -463,10 +531,7 @@ export function createIngressHandler(
463
531
  )
464
532
  ffiFailures = 0
465
533
  } catch {
466
- ffiFailures++
467
- if (ffiFailures >= MAX_FFI_FAILURES) {
468
- ffiDisabled = true
469
- }
534
+ noteFfiFailure()
470
535
  // napi re-run: crash-safe (napi-rs catch_unwind) and semantically
471
536
  // identical — a transient ffi panic still serves the request. The
472
537
  // packed frame respects the same url/ip minimal-processing.
@@ -485,10 +550,7 @@ export function createIngressHandler(
485
550
  written = bunFFI.ingressHandlePacked(ingressPtr, input, body, handle.buffer)
486
551
  ffiFailures = 0
487
552
  } catch {
488
- ffiFailures++
489
- if (ffiFailures >= MAX_FFI_FAILURES) {
490
- ffiDisabled = true
491
- }
553
+ noteFfiFailure()
492
554
  // napi re-run: crash-safe (napi-rs catch_unwind) and semantically
493
555
  // identical — a transient ffi panic still serves the request.
494
556
  written = handler.handleRequestPacked(input, body, handle.buffer)
@@ -498,9 +560,16 @@ export function createIngressHandler(
498
560
  written = handler.handleRequestPacked(input, body, handle.buffer)
499
561
  }
500
562
 
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))
563
+ // Pass the WHOLE pooled buffer + the written length instead of a
564
+ // per-request `buffer.subarray(0, written)` view (a ~35ns allocation in
565
+ // Bun); the decoder bounds-checks against the length. Same DataView
566
+ // (cached per-ArrayBuffer, offset unchanged).
567
+ result.refresh(
568
+ handle.buffer,
569
+ body ?? EMPTY_BODY,
570
+ viewForArrayBuffer(handle.buffer.buffer, handle.buffer.byteOffset),
571
+ written,
572
+ )
504
573
  } catch (err) {
505
574
  result.setInternalError()
506
575
  const error = err instanceof Error ? err : new Error(String(err))
@@ -522,18 +591,21 @@ export function createIngressHandler(
522
591
  assertSyncCallback(out, 'createIngressHandler().run()')
523
592
  // Report observability for every outcome. Non-Response callbacks
524
593
  // (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
- })
594
+ // status so they are not silently invisible to onResponse/logging. The
595
+ // status is computed ONLY when something observes it.
596
+ if (hasOnResponse || logger !== null) {
597
+ const status = out instanceof Response ? out.status : safeTerminalStatus(result)
598
+ if (hasOnResponse) runtime.onResponse?.(req, result, status, requestIdStr)
599
+ if (logger !== null) {
600
+ logger.request({
601
+ requestId: requestIdStr,
602
+ method: req.method,
603
+ path: pathForLog(req),
604
+ status,
605
+ durationMs: Math.round((performance.now() - startedAt) * 1000) / 1000,
606
+ ip: ipStr,
607
+ })
608
+ }
537
609
  }
538
610
  return out
539
611
  } 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
+ }
@@ -66,7 +66,11 @@ export interface NativeRoute {
66
66
  runFrame(frame: Uint8Array): RouteWireResult
67
67
  /** Convenience: pack a `(query, cookie, body)` frame then {@link runFrame}. */
68
68
  run(query: string, cookie: string, body: Uint8Array | null): RouteWireResult
69
- /** Free the native handle (idempotent; safe to call once at shutdown). */
69
+ /**
70
+ * Free the native handle. Idempotent: a second call is a no-op. After it,
71
+ * {@link runFrame}/{@link run} throw rather than pass a freed handle into the
72
+ * native stack.
73
+ */
70
74
  destroy(): void
71
75
  }
72
76
 
@@ -109,16 +113,21 @@ export function createNativeRoute(plan: NativeRoutePlan = {}): NativeRoute {
109
113
  const parseQuery = plan.parseQuery === true
110
114
  const parseCookies = plan.parseCookies === true
111
115
 
112
- // Transport: bun:ffi PRIMARY on Bun; napi `Route` on Node / fallback. The
116
+ // Transport: bun:ffi PRIMARY on Bun; napi `Route` on Node / fallback. An FFI
117
+ // compile failure (invalid/unsupported descriptor) ALSO falls back to napi so
118
+ // the route stays usable instead of throwing "no active transport". The
113
119
  // compiled route owns exactly one handle; `destroy` frees it.
114
120
  const bunFFI: BunFFI | null = getBunFFI()
115
- const ffiHandle = bunFFI !== null ? compileFfi(bunFFI, descriptor) : 0
116
- const napiRoute = bunFFI === null ? compileNapi(descriptor) : null
121
+ let ffiHandle = bunFFI !== null ? compileFfi(bunFFI, descriptor) : 0
122
+ let napiRoute = bunFFI === null || ffiHandle === 0 ? compileNapi(descriptor) : null
117
123
 
118
124
  // Reusable output buffer for the needed-size convention (grow once, retry).
119
125
  let out = new Uint8Array(256)
120
126
 
121
127
  const runFrame = (frame: Uint8Array): RouteWireResult => {
128
+ if (ffiHandle === 0 && napiRoute === null) {
129
+ throw new Error('native route: already destroyed')
130
+ }
122
131
  let written: number
123
132
  if (ffiHandle !== 0 && bunFFI !== null) {
124
133
  written = bunFFI.routeRun(ffiHandle, frame, out)
@@ -153,7 +162,10 @@ export function createNativeRoute(plan: NativeRoutePlan = {}): NativeRoute {
153
162
  destroy: () => {
154
163
  if (ffiHandle !== 0 && bunFFI !== null) {
155
164
  bunFFI.routeDestroy(ffiHandle)
165
+ ffiHandle = 0
156
166
  }
167
+ napiRoute?.destroy?.()
168
+ napiRoute = null
157
169
  },
158
170
  }
159
171
  }
@@ -170,10 +182,17 @@ function compileFfi(bunFFI: BunFFI, descriptor: Uint8Array): number {
170
182
  /** Compile through the napi `Route` class (Node / fallback transport). */
171
183
  function compileNapi(
172
184
  descriptor: Uint8Array,
173
- ): { run: (frame: Uint8Array, out: Uint8Array) => number } | null {
185
+ ): { run: (frame: Uint8Array, out: Uint8Array) => number; destroy?: () => void } | null {
174
186
  const addon = getAddon()
175
187
  const Route = (
176
- addon as { Route?: new (d: Uint8Array) => { run: (f: Uint8Array, o: Uint8Array) => number } }
188
+ addon as {
189
+ Route?: new (
190
+ d: Uint8Array,
191
+ ) => {
192
+ run: (f: Uint8Array, o: Uint8Array) => number
193
+ destroy?: () => void
194
+ }
195
+ }
177
196
  ).Route
178
197
  if (typeof Route !== 'function') {
179
198
  return null // addon without the route stack (pre-rebuild) — caller falls back
@@ -184,6 +184,10 @@ let trustProxyWarned = false
184
184
  * `X-Forwarded-For`/`X-Real-IP`, so a client can forge its IP and bypass
185
185
  * IP-based rate limiting. Prefer the `trustedProxies` network-list API and
186
186
  * only enable proxy trust behind a trusted edge.
187
+ *
188
+ * Precedence: when `trustedProxies` is also set it wins, so
189
+ * `{ trustProxy: true, trustedProxies: { enabled: false } }` trusts NOTHING —
190
+ * the safer mode, not the "trust everything" this warning describes.
187
191
  */
188
192
  export function warnTrustProxyDeprecated(): void {
189
193
  if (trustProxyWarned) return
@@ -191,7 +195,27 @@ export function warnTrustProxyDeprecated(): void {
191
195
  console.warn(
192
196
  '[castrum] WARN: `trustProxy: true` is deprecated and trusts EVERY hop — ' +
193
197
  'clients can spoof X-Forwarded-For / X-Real-IP to bypass IP-based rate ' +
194
- 'limiting. Use `trustedProxies: { enabled: true, networks: [...] }` and ' +
195
- 'only enable proxy trust behind a trusted edge.',
198
+ 'limiting. Use `trustedProxies: { enabled: true, networks: [...] }` ' +
199
+ '(which takes precedence) and only enable proxy trust behind a trusted edge.',
200
+ )
201
+ }
202
+
203
+ let rateLimitNoIpWarned = false
204
+
205
+ /**
206
+ * Warn once (per process) when rate limiting is enabled without a `getIp`
207
+ * resolver. The native pipeline then resolves an empty client IP, so EVERY
208
+ * client shares one bucket — one attacker can exhaust it and 429 everyone.
209
+ *
210
+ * A single global bucket is a legitimate choice, so this warns rather than
211
+ * fails; provide `getIp` for per-client isolation.
212
+ */
213
+ export function warnRateLimitWithoutGetIp(): void {
214
+ if (rateLimitNoIpWarned) return
215
+ rateLimitNoIpWarned = true
216
+ console.warn(
217
+ '[castrum] WARN: rate limiting is enabled without `getIp` — every client ' +
218
+ 'shares ONE bucket, so one client can exhaust it for everyone. Provide ' +
219
+ '`getIp` for per-client limits (or use a global limit deliberately).',
196
220
  )
197
221
  }
@@ -12,14 +12,6 @@ import { writeHeaderPair } from './header-packing'
12
12
  import { getHeaderBuf, MAX_SMALL_HEADER_BYTES } from './scratch'
13
13
  import { forEachSelectedHeader, HDR_ORIGIN } from './select-headers'
14
14
 
15
- // Shared per-header size guards (single source of truth in scratch.ts).
16
- // Re-exported for back-compat with imports that referenced them here.
17
- export {
18
- MAX_COOKIE_HEADER_BYTES,
19
- MAX_SMALL_HEADER_BYTES,
20
- MAX_XFF_HEADER_BYTES,
21
- } from './scratch'
22
-
23
15
  /**
24
16
  * Gather the request headers selected by `plan` as a packed byte block
25
17
  * (`[u16 count] { [u16 name_len][name][u32 val_len][value] }`) written into a
@@ -161,3 +153,27 @@ function cachedOriginBlock(origin: string): Uint8Array {
161
153
  ORIGIN_BLOCK_CACHE.set(origin, block)
162
154
  return block
163
155
  }
156
+
157
+ /**
158
+ * Pre-warm the origin-packed-block cache for the configured exact
159
+ * `allow-origin` values (load-time-for-RPS trade): the FIRST request from each
160
+ * known origin then skips the shared encode + pack + scratch write entirely,
161
+ * like every later request does. Oversized values are skipped (they map to the
162
+ * EMPTY block anyway) and wildcard/pattern values are skipped (they never
163
+ * resolve to their own literal origin), while the module-level cache stays
164
+ * bounded by `ORIGIN_CACHE_MAX`. No-op for any plan that never consults it.
165
+ */
166
+ export function prewarmOriginBlocks(origins: readonly string[]): void {
167
+ for (const origin of origins) {
168
+ if (
169
+ typeof origin === 'string' &&
170
+ origin.length > 0 &&
171
+ origin.length <= MAX_SMALL_HEADER_BYTES &&
172
+ // A wildcard/pattern never resolves to its own literal origin string, so
173
+ // caching it would only burn a slot (the handler filters these too).
174
+ !origin.includes('*')
175
+ ) {
176
+ cachedOriginBlock(origin)
177
+ }
178
+ }
179
+ }