castrum 0.9.0 → 0.9.2

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 (120) hide show
  1. package/README.md +153 -630
  2. package/castrum.linux-x64-gnu.node +0 -0
  3. package/dist/index.d.ts +1653 -1817
  4. package/dist/index.js +6172 -4294
  5. package/docs/API.md +337 -0
  6. package/docs/ARCHITECTURE.md +111 -17
  7. package/docs/BENCHMARKS.md +117 -49
  8. package/docs/CASE_STUDY.md +243 -0
  9. package/docs/ENVIRONMENT.md +24 -7
  10. package/docs/FFI_BUN_GUIDE.md +335 -0
  11. package/docs/GETTING_STARTED.md +18 -34
  12. package/docs/HOW_TO_ADD_AN_OP.md +180 -0
  13. package/docs/INGRESS-ROUTER.md +82 -0
  14. package/docs/INGRESS.md +11 -7
  15. package/docs/NATIVE-ROUTE.md +77 -0
  16. package/docs/REPO_MAP.md +103 -53
  17. package/docs/adr/0001-two-wire-formats.md +1 -1
  18. package/docs/adr/0003-bun-builtins-delegation.md +6 -5
  19. package/docs/ai/LOCAL_DEV.md +81 -0
  20. package/docs/ai/TREE.md +1070 -0
  21. package/docs/bun-builtins-decision-matrix.md +25 -18
  22. package/index.ts +9 -16
  23. package/package.json +41 -28
  24. package/src/ingress/body.ts +167 -27
  25. package/src/ingress/decode/result-base.ts +10 -2
  26. package/src/ingress/handlers.ts +116 -285
  27. package/src/ingress/headers/security.ts +41 -0
  28. package/src/ingress/health.ts +1 -0
  29. package/src/ingress/index.ts +45 -169
  30. package/src/ingress/native-route.ts +182 -0
  31. package/src/ingress/packing/gather-raw-headers.ts +110 -22
  32. package/src/ingress/packing/header-packing.ts +5 -5
  33. package/src/ingress/packing/input-packer.ts +17 -3
  34. package/src/ingress/packing/route-wire.ts +197 -0
  35. package/src/ingress/packing/select-headers.ts +20 -7
  36. package/src/ingress/path-matcher.ts +115 -0
  37. package/src/ingress/response/baked-response.ts +302 -0
  38. package/src/ingress/router.ts +277 -0
  39. package/src/ingress/routes/common.ts +50 -1
  40. package/src/ingress/routes/fallback.ts +2 -2
  41. package/src/ingress/routes/head.ts +3 -13
  42. package/src/ingress/routes/index.ts +5 -4
  43. package/src/ingress/routes/json-write.ts +26 -47
  44. package/src/ingress/routes/native.ts +130 -0
  45. package/src/ingress/routes/options.ts +2 -12
  46. package/src/ingress/routes/read.ts +3 -13
  47. package/src/ingress/routes/responder.ts +159 -0
  48. package/src/ingress/server-node.ts +7 -7
  49. package/src/ingress/server.ts +83 -112
  50. package/src/ingress/sync.ts +151 -0
  51. package/src/ingress/types.ts +44 -1
  52. package/src/integration/pipeline.ts +3 -0
  53. package/src/integration/streaming.ts +2 -3
  54. package/src/integration/websocket.ts +4 -3
  55. package/src/loader/batch.ts +4 -0
  56. package/src/loader/cost.ts +1 -0
  57. package/src/loader/index.ts +43 -228
  58. package/src/loader/state.ts +84 -0
  59. package/src/loader/types.ts +145 -0
  60. package/src/native/ffi/build/codecs.ts +736 -0
  61. package/src/native/ffi/build/compress.ts +248 -0
  62. package/src/native/ffi/build/instances.ts +598 -0
  63. package/src/native/ffi/build/parse.ts +539 -0
  64. package/src/native/ffi/build/util.ts +140 -0
  65. package/src/native/ffi/build.ts +94 -0
  66. package/src/native/ffi/constants.ts +58 -0
  67. package/src/native/ffi/selftest.ts +22 -0
  68. package/src/native/ffi/types.ts +499 -0
  69. package/src/native/ffi.ts +307 -1357
  70. package/src/native/loader.ts +52 -6
  71. package/src/native/types/instances.ts +279 -0
  72. package/src/native/types.ts +41 -238
  73. package/src/runtime/builtins.ts +131 -0
  74. package/src/runtime/codec.ts +147 -0
  75. package/src/runtime/detect.ts +36 -0
  76. package/src/runtime/index.ts +28 -0
  77. package/src/runtime/native.ts +35 -0
  78. package/src/runtime/server.ts +31 -0
  79. package/src/runtime/transport.ts +64 -0
  80. package/src/runtime/types.ts +173 -0
  81. package/src/runtime/uuid.ts +45 -0
  82. package/src/runtime/websocket.ts +30 -0
  83. package/src/rust-ffi/addon.ts +1 -1
  84. package/src/rust-ffi/batch/build.ts +155 -0
  85. package/src/rust-ffi/batch/index.ts +8 -0
  86. package/src/rust-ffi/batch/types.ts +92 -0
  87. package/src/rust-ffi/client.ts +5 -6
  88. package/src/rust-ffi/context.ts +39 -10
  89. package/src/rust-ffi/index.ts +28 -17
  90. package/src/rust-ffi/into.ts +21 -0
  91. package/src/rust-ffi/options.ts +1 -1
  92. package/src/rust-ffi/packed.ts +40 -2
  93. package/src/rust-ffi/proven.ts +16 -30
  94. package/src/rust-ffi/scalar/crypto.ts +188 -107
  95. package/src/rust-ffi/scalar/factories.ts +257 -27
  96. package/src/rust-ffi/scalar/hashing.ts +43 -41
  97. package/src/rust-ffi/scalar/http.ts +208 -77
  98. package/src/rust-ffi/scalar/index.ts +3 -3
  99. package/src/rust-ffi/scalar/interface.ts +88 -213
  100. package/src/rust-ffi/scalar/json-packed.ts +175 -0
  101. package/src/rust-ffi/scalar/json.ts +28 -12
  102. package/src/rust-ffi/scalar/payload.ts +159 -42
  103. package/src/rust-ffi/text.ts +31 -5
  104. package/src/selection.ts +27 -31
  105. package/src/shared/adaptive.ts +1 -2
  106. package/src/shared/buffer-pool.ts +72 -17
  107. package/src/shared/bytes.ts +75 -6
  108. package/src/shared/codec.ts +78 -0
  109. package/src/shared/env.ts +4 -4
  110. package/src/shared/log.ts +1 -0
  111. package/src/shared/packed/index.ts +10 -0
  112. package/src/shared/packed/parsers.ts +31 -0
  113. package/src/shared/packed/schema.ts +23 -0
  114. package/src/shared/{packed.ts → packed/wire.ts} +31 -39
  115. package/src/shared/proven.ts +189 -496
  116. package/src/shared/runtime.ts +20 -25
  117. package/src/shared/uuid.ts +12 -28
  118. package/src/data/json-rows.ts +0 -33
  119. package/src/rust-ffi/batch.ts +0 -431
  120. package/src/shared/bench-classify.ts +0 -209
@@ -31,6 +31,8 @@
31
31
  // The pre-baked pipeline is decomposed into task-focused submodules:
32
32
  // - decode/baked-result.ts BakedIngressResult (zero-alloc decode)
33
33
  // - response/error-bodies.ts pre-encoded error bodies
34
+ // - response/baked-response.ts pre-baked response builders (baked wire)
35
+ // - headers/security.ts security header merge
34
36
  // - headers/baked-templates.ts ratelimit-* header templates
35
37
  // - packing/gather-raw-headers.ts raw header gathering
36
38
  // - routes/* route factories
@@ -42,18 +44,10 @@ import { BufferPool, type PooledBuffer } from '../shared/buffer-pool'
42
44
  import { decoder, viewForArrayBuffer } from '../shared/bytes'
43
45
  import { createStructuredLogger } from '../shared/log'
44
46
  import { generateRequestId } from '../shared/request-id'
45
- import { pooledBodyResponse } from '../shared/response'
46
- import {
47
- ERR_CODE_RATE_LIMITED as ERROR_CODE_RATE_LIMITED,
48
- HV_CORS_PREFLIGHT,
49
- HV_CORS_SIMPLE,
50
- HV_JSON,
51
- HV_RATE_ACTIVE,
52
- HV_RATE_LIMITED,
53
- OUT_DATA_START,
54
- } from './constants'
47
+ import { OUT_DATA_START } from './constants'
55
48
  import { BakedIngressResult } from './decode/baked-result'
56
49
  import { buildBakedHeaderTemplates } from './headers/baked-templates'
50
+ import { buildBakedSecurityEntries } from './headers/security'
57
51
  import {
58
52
  assertIngressOptionValues,
59
53
  assertKnownIngressOptions,
@@ -62,9 +56,7 @@ import {
62
56
  } from './options'
63
57
  import { gatherRawHeadersPacked } from './packing/gather-raw-headers'
64
58
  import { IngressInputPacker } from './packing/input-packer'
65
- import { buildSecurityPairs } from './headers/hsts'
66
- import type { SecurityHeadersOptions } from './headers/hsts'
67
- import { ERROR_BODIES, ERROR_CODE_BODIES, rateLimitedBody } from './response/error-bodies'
59
+ import { type BakedResponseState, buildBakedResponseBuilders } from './response/baked-response'
68
60
  import {
69
61
  assertSyncCallback,
70
62
  buildHeaderPlan,
@@ -73,7 +65,6 @@ import {
73
65
  type HeaderPlan,
74
66
  METHOD_KIND,
75
67
  METHOD_KIND_UNKNOWN,
76
- secondsFromMs,
77
68
  } from './shared'
78
69
  import { safeTerminalStatus } from './status'
79
70
  import type { BakedContext, OptimizedIngressHandler } from './types'
@@ -84,10 +75,13 @@ export type { IngressHandlerOptions } from './options'
84
75
  export { ERROR_BODIES } from './response/error-bodies'
85
76
  export type { BakedHandlerOptions } from './routes'
86
77
  export {
78
+ deleteHandler,
87
79
  echoHandler,
88
80
  fallbackHandler,
89
81
  headHandler,
90
82
  jsonWriteHandler,
83
+ nativeResponderRoute,
84
+ optionsHandler,
91
85
  readHandler,
92
86
  } from './routes'
93
87
  export type {
@@ -102,7 +96,6 @@ export type { RouteHandler as IngressNodeRouteHandler } from './server-node'
102
96
  // Node.js HTTP adapter (same pre-baked route handlers, node:http backend).
103
97
  export { createIngressServerNode } from './server-node'
104
98
 
105
- const encoder = new TextEncoder()
106
99
  const EMPTY_BODY = new Uint8Array(0)
107
100
  const EMPTY_IP = '0.0.0.0'
108
101
 
@@ -132,37 +125,12 @@ function pathForLog(req: Request): string {
132
125
  }
133
126
  }
134
127
 
135
- /**
136
- * Merge structured `options.security` (SecurityHeadersOptions) with raw
137
- * `runtime.securityHeaders` pairs into the ordered entries the baked templates
138
- * prepend to every response variant. Raw pairs win on name conflicts; names are
139
- * lowercased for the template merge.
140
- *
141
- * The structured defaults (nosniff/DENY/no-referrer) are ONLY applied when the
142
- * user explicitly opted into security on this path (`security !== undefined`) —
143
- * the baked path's long-standing default is no security headers, and silently
144
- * adding them would change every response.
145
- */
146
- function buildBakedSecurityEntries(
147
- security: SecurityHeadersOptions | undefined,
148
- https: boolean | undefined,
149
- raw: ReadonlyArray<[string, string]> | undefined,
150
- ): Array<[string, string]> {
151
- const structured = security === undefined ? [] : buildSecurityPairs(security, https, true)
152
-
153
- if (raw === undefined || raw.length === 0) return structured
154
-
155
- const byName = new Map<string, string>()
156
- for (const [k, v] of structured) byName.set(k, v)
157
- for (const [k, v] of raw) byName.set(k.toLowerCase(), v)
158
- return [...byName.entries()]
159
- }
160
-
161
128
  // ── Types ────────────────────────────────────────────────────────
162
129
  // `BakedContext` and `OptimizedIngressHandler` are defined in ./types.ts
163
130
  // (shared with the route factories) and re-exported here for back-compat.
164
131
  export type { BakedContext, OptimizedIngressHandler } from './types'
165
132
 
133
+ /** Runtime configuration for the pre-baked handler path (`createIngressHandler`). */
166
134
  export interface BakedIngressRuntime {
167
135
  /** Emit an `x-request-id` header on responses. Default: false. */
168
136
  emitRequestIdHeader?: boolean
@@ -274,6 +242,21 @@ export function createIngressHandler(
274
242
  const limit = rateLimit?.limit
275
243
  const rateEnabled = typeof limit === 'number' && limit !== RATE_LIMIT_U32_MAX && limit > 0
276
244
 
245
+ // Minimal-processing flags: skip per-request JS UTF-8 encoding of the URL/IP
246
+ // when the native pipeline (rust/ingress/pipeline.rs) provably cannot read
247
+ // them — mirror its consumption exactly, and KEEP IN SYNC:
248
+ // - URL is consumed iff parseQuery (extract_query), emitMetadataJson
249
+ // (extract_path into the envelope) or https is unpinned (detect_https
250
+ // scans the scheme prefix). Otherwise an empty URL section is safe.
251
+ // - IP is consumed iff rate limiting is active (resolve_client_ip /
252
+ // rate_key) or a proxy-trust mode is configured (socket_is_trusted
253
+ // parses it). Otherwise the IP is dropped from the frame (logs/hooks
254
+ // still see the real `ip` — this only skips the frame encode).
255
+ const trustEnabled = options.trustProxy === true || options.trustedProxies?.enabled === true
256
+ const urlNeeded =
257
+ options.parseQuery === true || options.emitMetadataJson === true || options.https === undefined
258
+ const ipNeeded = rateEnabled || trustEnabled
259
+
277
260
  // Shared with the fast path — proxy extraction is driven by trust config
278
261
  // (trustProxy / trustedProxies), not by whether rate limiting is enabled.
279
262
  const headerPlan: HeaderPlan = buildHeaderPlan(options)
@@ -315,11 +298,12 @@ export function createIngressHandler(
315
298
  // the napi `Vec<Vec<String>>` header marshaling of the full_sync family.
316
299
  const inputPacker = new IngressInputPacker()
317
300
 
318
- // Per-call state: the output handle backing the current `run()`'s result,
319
- // and whether a zero-copy Response claimed it (so `run()` must not release
320
- // it back to the pool before the body is consumed).
321
- let currentHandle: PooledBuffer | null = null
322
- let responseBorrowsBuffer = false
301
+ // Shared per-request state with the response builders
302
+ // (response/baked-response.ts): `zeroCopyResponse` marks the pooled output
303
+ // buffer as borrowed so `run()` must not release it before the body is
304
+ // consumed. The result decoders share a single mutable object too, so this
305
+ // is the same pattern — one live request per handler at a time.
306
+ const responseState: BakedResponseState = { currentHandle: null, responseBorrowsBuffer: false }
323
307
 
324
308
  // FFI circuit breaker (defense-in-depth): the raw C ABI cannot contain
325
309
  // non-panic UB (a segfault still crashes the process), but a RECURRING
@@ -382,219 +366,18 @@ export function createIngressHandler(
382
366
  rateLimitStr,
383
367
  })
384
368
 
385
- function responseHeaders(
386
- variant: number,
387
- requestIdHeader: string | null,
388
- origin: string | null,
389
- rateRemaining?: number,
390
- rateResetSecs?: number,
391
- retryAfterSecs?: number,
392
- ): [string, string][] {
393
- const template: ReadonlyArray<[string, string]> =
394
- headerTemplates[variant & 31] ?? headerTemplates[0] ?? []
395
-
396
- const needsRequestId = emitRequestIdHeader && requestIdHeader !== null
397
- const needsOrigin =
398
- ((variant & HV_CORS_SIMPLE) !== 0 || (variant & HV_CORS_PREFLIGHT) !== 0) && origin !== null
399
- const needsRate = (variant & HV_RATE_ACTIVE) !== 0
400
- const needsRetry = (variant & HV_RATE_LIMITED) !== 0
401
-
402
- if (!needsRequestId && !needsOrigin && !needsRate && !needsRetry) {
403
- return template as unknown as [string, string][]
404
- }
405
-
406
- if (!needsRequestId && !needsRate && !needsRetry && needsOrigin) {
407
- // Steady-state CORS: only the origin varies — cache the augmented array
408
- // per (variant, origin) so the Origin-present case (the common
409
- // browser/bench case) stays allocation-free after the first hit for EACH
410
- // distinct origin (a single-slot memo thrashed when clients alternate
411
- // between allowed origins, allocating a fresh array on every switch).
412
- const key = `${variant & 31}\u0000${origin as string}`
413
- const cached = originHeaderCache.get(key)
414
- if (cached !== undefined) {
415
- return cached
416
- }
417
- const entries = new Array<[string, string]>(template.length + 1)
418
- let i = 0
419
- for (const pair of template) {
420
- entries[i++] = pair
421
- }
422
- entries[i] = ['access-control-allow-origin', origin as string]
423
- originHeaderCache.set(key, entries)
424
- return entries
425
- }
426
-
427
- let extra = 0
428
- if (needsRequestId) extra++
429
- if (needsOrigin) extra++
430
- if (needsRate) extra += 2
431
- if (needsRetry) extra++
432
-
433
- const entries = new Array<[string, string]>(template.length + extra)
434
- let i = 0
435
-
436
- for (const pair of template) {
437
- entries[i++] = pair
438
- }
439
-
440
- if (needsRequestId) {
441
- entries[i++] = ['x-request-id', requestIdHeader as string]
442
- }
443
-
444
- if (needsOrigin) {
445
- entries[i++] = ['access-control-allow-origin', origin as string]
446
- }
447
-
448
- if (needsRate) {
449
- entries[i++] = ['ratelimit-remaining', String(rateRemaining ?? 0)]
450
- entries[i++] = ['ratelimit-reset', String(rateResetSecs ?? 0)]
451
- }
452
-
453
- if (needsRetry) {
454
- entries[i++] = ['retry-after', String(retryAfterSecs ?? 0)]
455
- }
456
-
457
- return entries
458
- }
459
-
460
- function terminalHeaders(
461
- variant: number,
462
- ctx: BakedContext,
463
- result: BakedIngressResult | null,
464
- ): [string, string][] {
465
- const v = variant | HV_JSON
466
- const requestIdHeader = ctx.requestIdHeader
467
- const origin = ctx.origin
468
- const rateResetSecs =
469
- result && result.rateResetMs > 0 ? secondsFromMs(result.rateResetMs) : undefined
470
- const retryAfterSecs =
471
- result && result.retryAfterMs > 0 ? secondsFromMs(result.retryAfterMs) : undefined
472
-
473
- const needsRequestId = emitRequestIdHeader && requestIdHeader !== null
474
- const needsOrigin =
475
- ((v & HV_CORS_SIMPLE) !== 0 || (v & HV_CORS_PREFLIGHT) !== 0) && origin !== null
476
- const needsRate = (v & HV_RATE_ACTIVE) !== 0
477
- const needsRetry = (v & HV_RATE_LIMITED) !== 0
478
-
479
- // Steady state: no request-id/origin/rate extras — serve the pre-baked
480
- // terminal template (JSON + `cache-control: no-store`) directly with zero
481
- // per-response array allocation/copy.
482
- if (!needsRequestId && !needsOrigin && !needsRate && !needsRetry) {
483
- return terminalTemplates[v & 31] as unknown as [string, string][]
484
- }
485
-
486
- // Rare path: extras appended after the baked entries, then `cache-control`.
487
- const base = responseHeaders(
488
- v,
489
- requestIdHeader,
490
- origin,
491
- result?.rateRemaining,
492
- rateResetSecs,
493
- retryAfterSecs,
494
- )
495
-
496
- const out = new Array<[string, string]>(base.length + 1)
497
- let i = 0
498
- for (const pair of base) {
499
- out[i++] = pair
500
- }
501
- out[base.length] = ['cache-control', 'no-store']
502
- return out
503
- }
504
-
505
- function terminalResponse(
506
- _req: Request | undefined,
507
- result: BakedIngressResult,
508
- ctx: BakedContext,
509
- ): Response | null {
510
- if (!result.terminal) {
511
- return null
512
- }
513
-
514
- const preflightAllowed = result.isPreflight && result.corsAllowed
515
-
516
- if (preflightAllowed) {
517
- return new Response(null, {
518
- status: 204,
519
- headers: responseHeaders(
520
- result.headerVariant,
521
- ctx.requestIdHeader,
522
- ctx.origin,
523
- result.rateRemaining,
524
- result.rateResetMs > 0 ? secondsFromMs(result.rateResetMs) : undefined,
525
- result.retryAfterMs > 0 ? secondsFromMs(result.retryAfterMs) : undefined,
526
- ),
527
- })
528
- }
529
-
530
- const status = safeTerminalStatus(result)
531
-
532
- const body: Uint8Array =
533
- result.errorCode === ERROR_CODE_RATE_LIMITED
534
- ? rateLimitedBody(result.retryAfterMs)
535
- : (ERROR_CODE_BODIES[result.errorCode] ?? ERROR_BODIES.internal ?? EMPTY_BODY)
536
-
537
- return new Response(body, {
538
- status,
539
- headers: terminalHeaders(result.headerVariant, ctx, result),
540
- })
541
- }
542
-
543
- function errorResponse(
544
- _req: Request | undefined,
545
- result: BakedIngressResult | null,
546
- status: number,
547
- code: string,
548
- message: string,
549
- ctx: BakedContext,
550
- ): Response {
551
- const body =
552
- ERROR_BODIES[code] ?? encoder.encode(JSON.stringify({ ok: false, error: { code, message } }))
553
-
554
- return new Response(body, {
555
- status,
556
- headers: terminalHeaders(result?.headerVariant ?? HV_JSON, ctx, result),
557
- })
558
- }
559
-
560
- function internalErrorResponse(ctx: BakedContext, result?: BakedIngressResult): Response {
561
- return new Response(ERROR_BODIES.internal, {
562
- status: 500,
563
- headers: terminalHeaders(result?.headerVariant ?? HV_JSON, ctx, result ?? null),
564
- })
565
- }
566
-
567
- function withContentType(
568
- headers: ReadonlyArray<[string, string]>,
569
- contentType: string,
570
- ): [string, string][] {
571
- const out = new Array<[string, string]>(headers.length + 1)
572
- let i = 0
573
- for (const pair of headers) {
574
- out[i++] = pair
575
- }
576
- out[headers.length] = ['content-type', contentType]
577
- return out
578
- }
579
-
580
- function zeroCopyResponse(
581
- result: BakedIngressResult,
582
- _ctx: BakedContext,
583
- init: ResponseInit,
584
- ): Response {
585
- if (currentHandle === null) {
586
- // `zeroCopyResponse` must only run inside a live `run()` callback (a
587
- // pooled output buffer must be in flight). Called outside `run()` the
588
- // result has already been invalidated and `result.bodyJson` is empty —
589
- // throw instead of silently serving an empty body.
590
- throw new Error(
591
- 'zeroCopyResponse() can only be called from within a run() callback ' +
592
- '(the pooled output buffer is only live during run()).',
593
- )
594
- }
595
- responseBorrowsBuffer = true
596
- return pooledBodyResponse(currentHandle, result.bodyJson(false), init, zeroCopyTimeoutMs)
597
- }
369
+ // Response builders, pre-bound to this handler's header templates, request-id
370
+ // policy, CORS cache and shared per-request state. This is the extracted
371
+ // response/terminal family (see response/baked-response.ts) — identical
372
+ // bodies, now living next to response/error-bodies.ts for navigability.
373
+ const responseBuilders = buildBakedResponseBuilders({
374
+ headerTemplates,
375
+ terminalTemplates,
376
+ emitRequestIdHeader,
377
+ zeroCopyTimeoutMs,
378
+ originHeaderCache,
379
+ state: responseState,
380
+ })
598
381
 
599
382
  const result = new BakedIngressResult()
600
383
  const ctx: BakedContext = {
@@ -627,8 +410,8 @@ export function createIngressHandler(
627
410
  // full_sync family paid on every request. The response wire format is
628
411
  // unchanged because both entries run the identical `handle_packed` core.
629
412
  let handle: PooledBuffer | null = null
630
- currentHandle = null
631
- responseBorrowsBuffer = false
413
+ responseState.currentHandle = null
414
+ responseState.responseBorrowsBuffer = false
632
415
 
633
416
  try {
634
417
  // Pack the request frame (inside the try: an unexpected packing failure
@@ -637,25 +420,67 @@ export function createIngressHandler(
637
420
  // above, so pass it in to avoid a second `req.headers.get('origin')`
638
421
  // native→JS string conversion on the hot path.
639
422
  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)
423
+ // Minimal processing: when the native pipeline cannot read the URL/IP
424
+ // (see `urlNeeded`/`ipNeeded`), pass an empty URL / omit the IP so we
425
+ // skip the per-request JS UTF-8 encode entirely (the frame still carries
426
+ // a valid 0-length section the native bounds-checks).
427
+ const packUrl = urlNeeded ? req.url : ''
428
+ const packIp = ipNeeded ? ip : undefined
644
429
 
645
430
  try {
646
431
  // Acquire inside the try: pool exhaustion (maxInFlight) becomes a 500
647
432
  // via setInternalError — never an uncaught exception out of run().
648
433
  handle = outputPool.acquire(outputBufferSize)
649
- currentHandle = handle
650
-
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.
434
+ responseState.currentHandle = handle
435
+
436
+ // Write into the pooled buffer — no per-request allocation. Bun
437
+ // PRIMARY: the raw-components C-ABI (`castrum_ingress_handle_components`)
438
+ // passes `url`/`ip` as `bun:ffi` `cstring` args — the engine transcodes
439
+ // the JS strings to call-scoped UTF-8 in-engine, so we skip the JS
440
+ // frame assembly + `Buffer.write` encode for the URL/IP (same wire
441
+ // format: both paths run the shared `handle_components` core). Falls
442
+ // back to the packed frame (FFI or napi) on any failure / stale addon.
443
+ // If an ffi call throws (native reported 0 = error, or a panic
444
+ // `panic_guard` contained), re-dispatch through napi once and, after
445
+ // repeated failures, permanently disable ffi for this handler.
657
446
  let written: number
658
- if (bunFFI !== null && ingressPtr !== 0 && !ffiDisabled) {
447
+ if (
448
+ bunFFI !== null &&
449
+ ingressPtr !== 0 &&
450
+ !ffiDisabled &&
451
+ typeof bunFFI.ingressHandleComponents === 'function'
452
+ ) {
453
+ try {
454
+ written = bunFFI.ingressHandleComponents(
455
+ ingressPtr,
456
+ methodKind,
457
+ packUrl,
458
+ packIp ?? '',
459
+ ridBytes,
460
+ packedHeaders,
461
+ body,
462
+ handle.buffer,
463
+ )
464
+ ffiFailures = 0
465
+ } catch {
466
+ ffiFailures++
467
+ if (ffiFailures >= MAX_FFI_FAILURES) {
468
+ ffiDisabled = true
469
+ }
470
+ // napi re-run: crash-safe (napi-rs catch_unwind) and semantically
471
+ // identical — a transient ffi panic still serves the request. The
472
+ // packed frame respects the same url/ip minimal-processing.
473
+ const input = inputPacker.packParts(
474
+ methodKind,
475
+ packUrl,
476
+ packIp,
477
+ ridBytes,
478
+ packedHeaders,
479
+ )
480
+ written = handler.handleRequestPacked(input, body, handle.buffer)
481
+ }
482
+ } else if (bunFFI !== null && ingressPtr !== 0 && !ffiDisabled) {
483
+ const input = inputPacker.packParts(methodKind, packUrl, packIp, ridBytes, packedHeaders)
659
484
  try {
660
485
  written = bunFFI.ingressHandlePacked(ingressPtr, input, body, handle.buffer)
661
486
  ffiFailures = 0
@@ -669,6 +494,7 @@ export function createIngressHandler(
669
494
  written = handler.handleRequestPacked(input, body, handle.buffer)
670
495
  }
671
496
  } else {
497
+ const input = inputPacker.packParts(methodKind, packUrl, packIp, ridBytes, packedHeaders)
672
498
  written = handler.handleRequestPacked(input, body, handle.buffer)
673
499
  }
674
500
 
@@ -721,14 +547,14 @@ export function createIngressHandler(
721
547
  // request accounting completes (metrics in_flight must not leak), then
722
548
  // rethrow for the caller. The native-failure path above does NOT rethrow,
723
549
  // so this fires at most once per request.
724
- if (responseBorrowsBuffer && handle !== null) {
550
+ if (responseState.responseBorrowsBuffer && handle !== null) {
725
551
  // `zeroCopyResponse` was called but the Response never escaped (the
726
552
  // callback threw): the stream that would release the pooled buffer was
727
553
  // never delivered. Release it here so the buffer isn't stuck in flight
728
554
  // forever.
729
555
  handle.release()
730
556
  handle = null
731
- responseBorrowsBuffer = false
557
+ responseState.responseBorrowsBuffer = false
732
558
  }
733
559
  const error = err instanceof Error ? err : new Error(String(err))
734
560
  runtime.onError?.(req, requestIdStr, error)
@@ -744,21 +570,26 @@ export function createIngressHandler(
744
570
  // A zero-copy Response owns the handle until its body is consumed; in
745
571
  // every other case (copy mode, terminal/error responses) the buffer is
746
572
  // safe to return to the pool immediately.
747
- if (handle !== null && !responseBorrowsBuffer) {
573
+ if (handle !== null && !responseState.responseBorrowsBuffer) {
748
574
  handle.release()
749
575
  }
750
- currentHandle = null
576
+ responseState.currentHandle = null
751
577
  }
752
578
  }
753
579
 
580
+ // Native JSON-validity capability for the pure route factories: the zero-DOM
581
+ // `json_valid` fast path (same one the pipeline uses), routed through the
582
+ // primary bun:ffi transport with the napi fallback. Exposed on the handler so
583
+ // `routes/json-write.ts` validates request bodies via DI — it must not import
584
+ // the native layer (purity boundary).
585
+ const nativeJsonValid = (bytes: Uint8Array): boolean =>
586
+ bunFFI !== null && typeof bunFFI.jsonValid === 'function'
587
+ ? bunFFI.jsonValid(bytes)
588
+ : addon.jsonValid(bytes)
589
+
754
590
  return {
755
591
  run,
756
- responseHeaders,
757
- terminalHeaders,
758
- terminalResponse,
759
- errorResponse,
760
- internalErrorResponse,
761
- withContentType,
762
- zeroCopyResponse,
592
+ ...responseBuilders,
593
+ jsonValid: nativeJsonValid,
763
594
  }
764
595
  }
@@ -0,0 +1,41 @@
1
+ // src/ingress/headers/security.ts — baked-path security header merge
2
+ //
3
+ // Merges the structured `options.security` (SecurityHeadersOptions) with raw
4
+ // `runtime.securityHeaders` pairs into the ordered entries the pre-baked
5
+ // handler prepends to every response variant. Raw pairs win on name conflicts;
6
+ // names are lowercased for the template merge.
7
+
8
+ import type { SecurityHeadersOptions } from './hsts'
9
+ import { buildSecurityPairs } from './hsts'
10
+
11
+ /**
12
+ * Merge structured `options.security` with raw `runtime.securityHeaders` pairs
13
+ * into the ordered entries the baked templates prepend to every response
14
+ * variant. Raw pairs win on name conflicts; names are lowercased for the
15
+ * template merge.
16
+ *
17
+ * The structured defaults (nosniff/DENY/no-referrer) are ONLY applied when the
18
+ * user explicitly opted into security on this path (`security !== undefined`) —
19
+ * the baked path's long-standing default is no security headers, and silently
20
+ * adding them would change every response.
21
+ *
22
+ * @param security structured `SecurityHeadersOptions` from `options.security`
23
+ * (the same shape `createIngress` honors)
24
+ * @param https whether HTTPS is pinned (drives HSTS defaults)
25
+ * @param raw raw `runtime.securityHeaders` pairs (names lowercased)
26
+ * @returns ordered `[name, value]` entries for the baked header templates
27
+ */
28
+ export function buildBakedSecurityEntries(
29
+ security: SecurityHeadersOptions | undefined,
30
+ https: boolean | undefined,
31
+ raw: ReadonlyArray<[string, string]> | undefined,
32
+ ): Array<[string, string]> {
33
+ const structured = security === undefined ? [] : buildSecurityPairs(security, https, true)
34
+
35
+ if (raw === undefined || raw.length === 0) return structured
36
+
37
+ const byName = new Map<string, string>()
38
+ for (const [k, v] of structured) byName.set(k, v)
39
+ for (const [k, v] of raw) byName.set(k.toLowerCase(), v)
40
+ return [...byName.entries()]
41
+ }
@@ -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