castrum 0.9.1 → 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 (49) hide show
  1. package/README.md +1 -1
  2. package/castrum.linux-x64-gnu.node +0 -0
  3. package/dist/index.d.ts +239 -5
  4. package/dist/index.js +3852 -3375
  5. package/docs/ARCHITECTURE.md +11 -9
  6. package/docs/BENCHMARKS.md +70 -38
  7. package/docs/CASE_STUDY.md +10 -7
  8. package/docs/ENVIRONMENT.md +16 -1
  9. package/docs/FFI_BUN_GUIDE.md +18 -10
  10. package/docs/GETTING_STARTED.md +5 -4
  11. package/docs/HOW_TO_ADD_AN_OP.md +180 -0
  12. package/docs/INGRESS-ROUTER.md +3 -2
  13. package/docs/INGRESS.md +6 -3
  14. package/docs/NATIVE-ROUTE.md +28 -4
  15. package/docs/REPO_MAP.md +60 -30
  16. package/docs/adr/0001-two-wire-formats.md +1 -1
  17. package/docs/ai/LOCAL_DEV.md +81 -0
  18. package/docs/ai/TREE.md +1070 -0
  19. package/package.json +24 -23
  20. package/src/ingress/decode/result-base.ts +10 -2
  21. package/src/ingress/handlers.ts +45 -290
  22. package/src/ingress/headers/security.ts +41 -0
  23. package/src/ingress/index.ts +12 -148
  24. package/src/ingress/native-route.ts +182 -0
  25. package/src/ingress/packing/gather-raw-headers.ts +26 -2
  26. package/src/ingress/packing/route-wire.ts +197 -0
  27. package/src/ingress/packing/select-headers.ts +8 -1
  28. package/src/ingress/path-matcher.ts +115 -0
  29. package/src/ingress/response/baked-response.ts +302 -0
  30. package/src/ingress/router.ts +42 -2
  31. package/src/ingress/routes/common.ts +9 -1
  32. package/src/ingress/routes/json-write.ts +21 -18
  33. package/src/ingress/routes/native.ts +130 -0
  34. package/src/ingress/server-node.ts +1 -1
  35. package/src/ingress/server.ts +38 -99
  36. package/src/ingress/sync.ts +151 -0
  37. package/src/ingress/types.ts +9 -0
  38. package/src/native/ffi/build/codecs.ts +736 -0
  39. package/src/native/ffi/build/compress.ts +248 -0
  40. package/src/native/ffi/build/instances.ts +598 -0
  41. package/src/native/ffi/build/parse.ts +539 -0
  42. package/src/native/ffi/build/util.ts +140 -0
  43. package/src/native/ffi/build.ts +94 -0
  44. package/src/native/ffi/selftest.ts +11 -718
  45. package/src/native/ffi/types.ts +25 -0
  46. package/src/native/ffi.ts +36 -1320
  47. package/src/native/types/instances.ts +5 -0
  48. package/src/rust-ffi/packed.ts +38 -0
  49. package/src/rust-ffi/scalar/factories.ts +4 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "castrum",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
4
4
  "private": false,
5
5
  "description": "Enterprise-grade Bun + Rust runtime: Rust-accelerated FFI primitives, a production-grade HTTP ingress pipeline, validation, hashing, and JSON processing with benchmark-grade performance.",
6
6
  "type": "module",
@@ -81,35 +81,36 @@
81
81
  "check:jsdoc": "bun scripts/check-jsdoc.ts",
82
82
  "check:selection": "bun scripts/select-native.ts --check",
83
83
  "check:clean": "bun scripts/check-clean.ts",
84
+ "gen:ai-map": "bun scripts/gen-ai-map.ts",
84
85
  "audit": "bun audit",
85
86
  "publish:manual": "node scripts/publish-manual.mjs",
86
87
  "publish:manual:dry": "node scripts/publish-manual.mjs --dry-run",
87
88
  "prepublishOnly": "bun run build:js && node scripts/prepublish.mjs",
88
- "bench:http": "BUN_CONFIG_MAX_HTTP_REQUESTS=65536 bun run bench/run-bench.ts",
89
- "bench:http:smoke": "BUN_CONFIG_MAX_HTTP_REQUESTS=65536 bun run bench/run-bench.ts smoke",
90
- "bench:http:stress": "BUN_CONFIG_MAX_HTTP_REQUESTS=65536 bun run bench/run-bench.ts stress",
91
- "bench:http:heavy": "BUN_CONFIG_MAX_HTTP_REQUESTS=65536 bun run bench/run-bench.ts heavy-json",
92
- "bench:http:crud": "BUN_CONFIG_MAX_HTTP_REQUESTS=65536 bun run bench/run-bench.ts crud",
93
- "bench:http:spike": "BUN_CONFIG_MAX_HTTP_REQUESTS=65536 bun run bench/run-bench.ts spike",
94
- "bench:http:soak": "BUN_CONFIG_MAX_HTTP_REQUESTS=65536 bun run bench/run-bench.ts soak",
95
- "bench:http:storm": "BUN_CONFIG_MAX_HTTP_REQUESTS=65536 bun run bench/run-bench.ts storm",
96
- "bench:http:boundary": "BUN_CONFIG_MAX_HTTP_REQUESTS=65536 bun run bench/run-bench.ts boundary",
97
- "bench:http:elysia": "BUN_CONFIG_MAX_HTTP_REQUESTS=65536 bun run bench/run-bench.ts '' elysia",
98
- "bench:http:ingress": "BUN_CONFIG_MAX_HTTP_REQUESTS=65536 bun run bench/run-bench.ts '' ingress",
99
- "bench:http:bun-only": "BUN_CONFIG_MAX_HTTP_REQUESTS=65536 bun run bench/run-bench.ts '' bun",
100
- "bench:http:all-heavy": "BUN_CONFIG_MAX_HTTP_REQUESTS=65536 bun run bench/run-bench.ts 1",
89
+ "bench:http": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts",
90
+ "bench:http:smoke": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts smoke",
91
+ "bench:http:stress": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts stress",
92
+ "bench:http:heavy": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts heavy-json",
93
+ "bench:http:crud": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts crud",
94
+ "bench:http:spike": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts spike",
95
+ "bench:http:soak": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts soak",
96
+ "bench:http:storm": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts storm",
97
+ "bench:http:boundary": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts boundary",
98
+ "bench:http:elysia": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts '' elysia",
99
+ "bench:http:ingress": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts '' ingress",
100
+ "bench:http:bun-only": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts '' bun",
101
+ "bench:http:all-heavy": "BUN_CONFIG_MAX_HTTP_REQUESTS=65535 bun run bench/http/run-bench.ts 1",
101
102
  "bench:http:ac": "bun bench/autocannon-stress.mjs",
102
103
  "bench:http:stress:ac": "SCENARIO=03-stress bun bench/autocannon-stress.mjs",
103
104
  "bench:startup": "bun run bench/startup.ts",
104
- "bench:load": "bun run bench/load-phase.ts",
105
- "bench:ffi": "bun run bench/ffi-all.ts",
106
- "bench:ffi:load": "bun run bench/ffi-load.ts",
107
- "bench:ffi:public": "bun run bench/ffi-public.ts",
108
- "bench:ffi:workers": "bun run bench/ffi-workers.ts",
109
- "bench:margin": "bun run bench/ffi-margin.ts",
110
- "bench:router": "bun run bench/router-cost.ts",
111
- "bench:ingress-cost": "bun run bench/ingress-cost.ts",
112
- "bench:ingress-cost:post": "bun run bench/ingress-cost-post.ts",
105
+ "bench:load": "bun run bench/http/load-phase.ts",
106
+ "bench:ffi": "bun run bench/ffi/ffi-all.ts",
107
+ "bench:ffi:load": "bun run bench/ffi/ffi-load.ts",
108
+ "bench:ffi:public": "bun run bench/ffi/ffi-public.ts",
109
+ "bench:ffi:workers": "bun run bench/ffi/ffi-workers.ts",
110
+ "bench:margin": "bun run bench/ffi/ffi-margin.ts",
111
+ "bench:router": "bun run bench/cost/router-cost.ts",
112
+ "bench:ingress-cost": "bun run bench/cost/ingress-cost.ts",
113
+ "bench:ingress-cost:post": "bun run bench/cost/ingress-cost-post.ts",
113
114
  "verify:native:batch": "bun run scripts/verify-native-batch.ts"
114
115
  },
115
116
  "dependencies": {},
@@ -110,8 +110,16 @@ export abstract class IngressResultBase {
110
110
  */
111
111
  protected setRateWindow(rateLimit: number, rateLimited: boolean, view: DataView): void {
112
112
  if (rateLimit > 0 || rateLimited) {
113
- this.rateResetMs = Number(view.getBigUint64(OUT_RATE_RESET, true))
114
- this.retryAfterMs = Number(view.getBigUint64(OUT_RETRY_AFTER, true))
113
+ // i64 LE read as two u32 halves (lo + hi * 2^32) instead of
114
+ // `getBigUint64` — avoids the per-read BigInt boxing (measured ~10ns
115
+ // per read on the hot path). Bit-identical to `Number(getBigUint64(..))`
116
+ // for the unsigned interpretation and exact for epoch-ms (< 2^53).
117
+ const resetLo = view.getUint32(OUT_RATE_RESET, true)
118
+ const resetHi = view.getUint32(OUT_RATE_RESET + 4, true)
119
+ this.rateResetMs = resetLo + resetHi * 4294967296
120
+ const retryLo = view.getUint32(OUT_RETRY_AFTER, true)
121
+ const retryHi = view.getUint32(OUT_RETRY_AFTER + 4, true)
122
+ this.retryAfterMs = retryLo + retryHi * 4294967296
115
123
  } else {
116
124
  this.rateResetMs = 0
117
125
  this.retryAfterMs = 0
@@ -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
@@ -39,23 +41,13 @@
39
41
  import { getAddon } from '../native'
40
42
  import { getBunFFI } from '../native/ffi'
41
43
  import { BufferPool, type PooledBuffer } from '../shared/buffer-pool'
42
- import { decoder, encoder, viewForArrayBuffer } from '../shared/bytes'
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'
57
- import type { SecurityHeadersOptions } from './headers/hsts'
58
- import { buildSecurityPairs } from './headers/hsts'
50
+ import { buildBakedSecurityEntries } from './headers/security'
59
51
  import {
60
52
  assertIngressOptionValues,
61
53
  assertKnownIngressOptions,
@@ -64,7 +56,7 @@ import {
64
56
  } from './options'
65
57
  import { gatherRawHeadersPacked } from './packing/gather-raw-headers'
66
58
  import { IngressInputPacker } from './packing/input-packer'
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'
@@ -134,32 +125,6 @@ function pathForLog(req: Request): string {
134
125
  }
135
126
  }
136
127
 
137
- /**
138
- * Merge structured `options.security` (SecurityHeadersOptions) with raw
139
- * `runtime.securityHeaders` pairs into the ordered entries the baked templates
140
- * prepend to every response variant. Raw pairs win on name conflicts; names are
141
- * lowercased for the template merge.
142
- *
143
- * The structured defaults (nosniff/DENY/no-referrer) are ONLY applied when the
144
- * user explicitly opted into security on this path (`security !== undefined`) —
145
- * the baked path's long-standing default is no security headers, and silently
146
- * adding them would change every response.
147
- */
148
- function buildBakedSecurityEntries(
149
- security: SecurityHeadersOptions | undefined,
150
- https: boolean | undefined,
151
- raw: ReadonlyArray<[string, string]> | undefined,
152
- ): Array<[string, string]> {
153
- const structured = security === undefined ? [] : buildSecurityPairs(security, https, true)
154
-
155
- if (raw === undefined || raw.length === 0) return structured
156
-
157
- const byName = new Map<string, string>()
158
- for (const [k, v] of structured) byName.set(k, v)
159
- for (const [k, v] of raw) byName.set(k.toLowerCase(), v)
160
- return [...byName.entries()]
161
- }
162
-
163
128
  // ── Types ────────────────────────────────────────────────────────
164
129
  // `BakedContext` and `OptimizedIngressHandler` are defined in ./types.ts
165
130
  // (shared with the route factories) and re-exported here for back-compat.
@@ -333,11 +298,12 @@ export function createIngressHandler(
333
298
  // the napi `Vec<Vec<String>>` header marshaling of the full_sync family.
334
299
  const inputPacker = new IngressInputPacker()
335
300
 
336
- // Per-call state: the output handle backing the current `run()`'s result,
337
- // and whether a zero-copy Response claimed it (so `run()` must not release
338
- // it back to the pool before the body is consumed).
339
- let currentHandle: PooledBuffer | null = null
340
- 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 }
341
307
 
342
308
  // FFI circuit breaker (defense-in-depth): the raw C ABI cannot contain
343
309
  // non-panic UB (a segfault still crashes the process), but a RECURRING
@@ -400,222 +366,18 @@ export function createIngressHandler(
400
366
  rateLimitStr,
401
367
  })
402
368
 
403
- function responseHeaders(
404
- variant: number,
405
- requestIdHeader: string | null,
406
- origin: string | null,
407
- rateRemaining?: number,
408
- rateResetSecs?: number,
409
- retryAfterSecs?: number,
410
- ): [string, string][] {
411
- const template: ReadonlyArray<[string, string]> =
412
- headerTemplates[variant & 31] ?? headerTemplates[0] ?? []
413
-
414
- const needsRequestId = emitRequestIdHeader && requestIdHeader !== null
415
- const needsOrigin =
416
- ((variant & HV_CORS_SIMPLE) !== 0 || (variant & HV_CORS_PREFLIGHT) !== 0) && origin !== null
417
- const needsRate = (variant & HV_RATE_ACTIVE) !== 0
418
- const needsRetry = (variant & HV_RATE_LIMITED) !== 0
419
-
420
- if (!needsRequestId && !needsOrigin && !needsRate && !needsRetry) {
421
- // Frozen-readonly → mutable: callers only ever read the baked template
422
- // (zero-copy steady-state path), so the widening is safe in practice.
423
- return template as unknown as [string, string][]
424
- }
425
-
426
- if (!needsRequestId && !needsRate && !needsRetry && needsOrigin) {
427
- // Steady-state CORS: only the origin varies — cache the augmented array
428
- // per (variant, origin) so the Origin-present case (the common
429
- // browser/bench case) stays allocation-free after the first hit for EACH
430
- // distinct origin (a single-slot memo thrashed when clients alternate
431
- // between allowed origins, allocating a fresh array on every switch).
432
- const key = `${variant & 31}\u0000${origin as string}`
433
- const cached = originHeaderCache.get(key)
434
- if (cached !== undefined) {
435
- return cached
436
- }
437
- const entries = new Array<[string, string]>(template.length + 1)
438
- let i = 0
439
- for (const pair of template) {
440
- entries[i++] = pair
441
- }
442
- entries[i] = ['access-control-allow-origin', origin as string]
443
- originHeaderCache.set(key, entries)
444
- return entries
445
- }
446
-
447
- let extra = 0
448
- if (needsRequestId) extra++
449
- if (needsOrigin) extra++
450
- if (needsRate) extra += 2
451
- if (needsRetry) extra++
452
-
453
- const entries = new Array<[string, string]>(template.length + extra)
454
- let i = 0
455
-
456
- for (const pair of template) {
457
- entries[i++] = pair
458
- }
459
-
460
- if (needsRequestId) {
461
- entries[i++] = ['x-request-id', requestIdHeader as string]
462
- }
463
-
464
- if (needsOrigin) {
465
- entries[i++] = ['access-control-allow-origin', origin as string]
466
- }
467
-
468
- if (needsRate) {
469
- entries[i++] = ['ratelimit-remaining', String(rateRemaining ?? 0)]
470
- entries[i++] = ['ratelimit-reset', String(rateResetSecs ?? 0)]
471
- }
472
-
473
- if (needsRetry) {
474
- entries[i++] = ['retry-after', String(retryAfterSecs ?? 0)]
475
- }
476
-
477
- return entries
478
- }
479
-
480
- function terminalHeaders(
481
- variant: number,
482
- ctx: BakedContext,
483
- result: BakedIngressResult | null,
484
- ): [string, string][] {
485
- const v = variant | HV_JSON
486
- const requestIdHeader = ctx.requestIdHeader
487
- const origin = ctx.origin
488
- const rateResetSecs =
489
- result && result.rateResetMs > 0 ? secondsFromMs(result.rateResetMs) : undefined
490
- const retryAfterSecs =
491
- result && result.retryAfterMs > 0 ? secondsFromMs(result.retryAfterMs) : undefined
492
-
493
- const needsRequestId = emitRequestIdHeader && requestIdHeader !== null
494
- const needsOrigin =
495
- ((v & HV_CORS_SIMPLE) !== 0 || (v & HV_CORS_PREFLIGHT) !== 0) && origin !== null
496
- const needsRate = (v & HV_RATE_ACTIVE) !== 0
497
- const needsRetry = (v & HV_RATE_LIMITED) !== 0
498
-
499
- // Steady state: no request-id/origin/rate extras — serve the pre-baked
500
- // terminal template (JSON + `cache-control: no-store`) directly with zero
501
- // per-response array allocation/copy.
502
- if (!needsRequestId && !needsOrigin && !needsRate && !needsRetry) {
503
- // Frozen-readonly → mutable: the baked terminal template is only read.
504
- return terminalTemplates[v & 31] as unknown as [string, string][]
505
- }
506
-
507
- // Rare path: extras appended after the baked entries, then `cache-control`.
508
- const base = responseHeaders(
509
- v,
510
- requestIdHeader,
511
- origin,
512
- result?.rateRemaining,
513
- rateResetSecs,
514
- retryAfterSecs,
515
- )
516
-
517
- const out = new Array<[string, string]>(base.length + 1)
518
- let i = 0
519
- for (const pair of base) {
520
- out[i++] = pair
521
- }
522
- out[base.length] = ['cache-control', 'no-store']
523
- return out
524
- }
525
-
526
- function terminalResponse(
527
- _req: Request | undefined,
528
- result: BakedIngressResult,
529
- ctx: BakedContext,
530
- ): Response | null {
531
- if (!result.terminal) {
532
- return null
533
- }
534
-
535
- const preflightAllowed = result.isPreflight && result.corsAllowed
536
-
537
- if (preflightAllowed) {
538
- return new Response(null, {
539
- status: 204,
540
- headers: responseHeaders(
541
- result.headerVariant,
542
- ctx.requestIdHeader,
543
- ctx.origin,
544
- result.rateRemaining,
545
- result.rateResetMs > 0 ? secondsFromMs(result.rateResetMs) : undefined,
546
- result.retryAfterMs > 0 ? secondsFromMs(result.retryAfterMs) : undefined,
547
- ),
548
- })
549
- }
550
-
551
- const status = safeTerminalStatus(result)
552
-
553
- const body: Uint8Array =
554
- result.errorCode === ERROR_CODE_RATE_LIMITED
555
- ? rateLimitedBody(result.retryAfterMs)
556
- : (ERROR_CODE_BODIES[result.errorCode] ?? ERROR_BODIES.internal ?? EMPTY_BODY)
557
-
558
- return new Response(body, {
559
- status,
560
- headers: terminalHeaders(result.headerVariant, ctx, result),
561
- })
562
- }
563
-
564
- function errorResponse(
565
- _req: Request | undefined,
566
- result: BakedIngressResult | null,
567
- status: number,
568
- code: string,
569
- message: string,
570
- ctx: BakedContext,
571
- ): Response {
572
- const body =
573
- ERROR_BODIES[code] ?? encoder.encode(JSON.stringify({ ok: false, error: { code, message } }))
574
-
575
- return new Response(body, {
576
- status,
577
- headers: terminalHeaders(result?.headerVariant ?? HV_JSON, ctx, result),
578
- })
579
- }
580
-
581
- function internalErrorResponse(ctx: BakedContext, result?: BakedIngressResult): Response {
582
- return new Response(ERROR_BODIES.internal, {
583
- status: 500,
584
- headers: terminalHeaders(result?.headerVariant ?? HV_JSON, ctx, result ?? null),
585
- })
586
- }
587
-
588
- function withContentType(
589
- headers: ReadonlyArray<[string, string]>,
590
- contentType: string,
591
- ): [string, string][] {
592
- const out = new Array<[string, string]>(headers.length + 1)
593
- let i = 0
594
- for (const pair of headers) {
595
- out[i++] = pair
596
- }
597
- out[headers.length] = ['content-type', contentType]
598
- return out
599
- }
600
-
601
- function zeroCopyResponse(
602
- result: BakedIngressResult,
603
- _ctx: BakedContext,
604
- init: ResponseInit,
605
- ): Response {
606
- if (currentHandle === null) {
607
- // `zeroCopyResponse` must only run inside a live `run()` callback (a
608
- // pooled output buffer must be in flight). Called outside `run()` the
609
- // result has already been invalidated and `result.bodyJson` is empty —
610
- // throw instead of silently serving an empty body.
611
- throw new Error(
612
- 'zeroCopyResponse() can only be called from within a run() callback ' +
613
- '(the pooled output buffer is only live during run()).',
614
- )
615
- }
616
- responseBorrowsBuffer = true
617
- return pooledBodyResponse(currentHandle, result.bodyJson(false), init, zeroCopyTimeoutMs)
618
- }
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
+ })
619
381
 
620
382
  const result = new BakedIngressResult()
621
383
  const ctx: BakedContext = {
@@ -648,8 +410,8 @@ export function createIngressHandler(
648
410
  // full_sync family paid on every request. The response wire format is
649
411
  // unchanged because both entries run the identical `handle_packed` core.
650
412
  let handle: PooledBuffer | null = null
651
- currentHandle = null
652
- responseBorrowsBuffer = false
413
+ responseState.currentHandle = null
414
+ responseState.responseBorrowsBuffer = false
653
415
 
654
416
  try {
655
417
  // Pack the request frame (inside the try: an unexpected packing failure
@@ -669,7 +431,7 @@ export function createIngressHandler(
669
431
  // Acquire inside the try: pool exhaustion (maxInFlight) becomes a 500
670
432
  // via setInternalError — never an uncaught exception out of run().
671
433
  handle = outputPool.acquire(outputBufferSize)
672
- currentHandle = handle
434
+ responseState.currentHandle = handle
673
435
 
674
436
  // Write into the pooled buffer — no per-request allocation. Bun
675
437
  // PRIMARY: the raw-components C-ABI (`castrum_ingress_handle_components`)
@@ -718,13 +480,7 @@ export function createIngressHandler(
718
480
  written = handler.handleRequestPacked(input, body, handle.buffer)
719
481
  }
720
482
  } else if (bunFFI !== null && ingressPtr !== 0 && !ffiDisabled) {
721
- const input = inputPacker.packParts(
722
- methodKind,
723
- packUrl,
724
- packIp,
725
- ridBytes,
726
- packedHeaders,
727
- )
483
+ const input = inputPacker.packParts(methodKind, packUrl, packIp, ridBytes, packedHeaders)
728
484
  try {
729
485
  written = bunFFI.ingressHandlePacked(ingressPtr, input, body, handle.buffer)
730
486
  ffiFailures = 0
@@ -738,13 +494,7 @@ export function createIngressHandler(
738
494
  written = handler.handleRequestPacked(input, body, handle.buffer)
739
495
  }
740
496
  } else {
741
- const input = inputPacker.packParts(
742
- methodKind,
743
- packUrl,
744
- packIp,
745
- ridBytes,
746
- packedHeaders,
747
- )
497
+ const input = inputPacker.packParts(methodKind, packUrl, packIp, ridBytes, packedHeaders)
748
498
  written = handler.handleRequestPacked(input, body, handle.buffer)
749
499
  }
750
500
 
@@ -797,14 +547,14 @@ export function createIngressHandler(
797
547
  // request accounting completes (metrics in_flight must not leak), then
798
548
  // rethrow for the caller. The native-failure path above does NOT rethrow,
799
549
  // so this fires at most once per request.
800
- if (responseBorrowsBuffer && handle !== null) {
550
+ if (responseState.responseBorrowsBuffer && handle !== null) {
801
551
  // `zeroCopyResponse` was called but the Response never escaped (the
802
552
  // callback threw): the stream that would release the pooled buffer was
803
553
  // never delivered. Release it here so the buffer isn't stuck in flight
804
554
  // forever.
805
555
  handle.release()
806
556
  handle = null
807
- responseBorrowsBuffer = false
557
+ responseState.responseBorrowsBuffer = false
808
558
  }
809
559
  const error = err instanceof Error ? err : new Error(String(err))
810
560
  runtime.onError?.(req, requestIdStr, error)
@@ -820,21 +570,26 @@ export function createIngressHandler(
820
570
  // A zero-copy Response owns the handle until its body is consumed; in
821
571
  // every other case (copy mode, terminal/error responses) the buffer is
822
572
  // safe to return to the pool immediately.
823
- if (handle !== null && !responseBorrowsBuffer) {
573
+ if (handle !== null && !responseState.responseBorrowsBuffer) {
824
574
  handle.release()
825
575
  }
826
- currentHandle = null
576
+ responseState.currentHandle = null
827
577
  }
828
578
  }
829
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
+
830
590
  return {
831
591
  run,
832
- responseHeaders,
833
- terminalHeaders,
834
- terminalResponse,
835
- errorResponse,
836
- internalErrorResponse,
837
- withContentType,
838
- zeroCopyResponse,
592
+ ...responseBuilders,
593
+ jsonValid: nativeJsonValid,
839
594
  }
840
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
+ }