castrum 0.7.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +154 -368
  3. package/castrum.linux-x64-gnu.node +0 -0
  4. package/dist/index.d.ts +2763 -0
  5. package/dist/index.js +8707 -0
  6. package/docs/API.md +337 -0
  7. package/docs/ARCHITECTURE.md +507 -0
  8. package/docs/BENCHMARKS.md +343 -0
  9. package/docs/CASE_STUDY.md +240 -0
  10. package/docs/ENVIRONMENT.md +81 -0
  11. package/docs/FFI_BUN_GUIDE.md +327 -0
  12. package/docs/GETTING_STARTED.md +192 -0
  13. package/docs/INGRESS-ROUTER.md +81 -0
  14. package/docs/INGRESS.md +746 -0
  15. package/docs/NATIVE-ROUTE.md +53 -0
  16. package/docs/REPO_MAP.md +279 -0
  17. package/docs/adr/0001-two-wire-formats.md +39 -0
  18. package/docs/adr/0002-zero-dom-fast-schema.md +38 -0
  19. package/docs/adr/0003-bun-builtins-delegation.md +43 -0
  20. package/docs/bun-builtins-decision-matrix.md +115 -0
  21. package/index.ts +44 -8
  22. package/package.json +98 -33
  23. package/src/ingress/body.ts +289 -0
  24. package/src/ingress/constants.ts +220 -48
  25. package/src/ingress/context.ts +136 -0
  26. package/src/ingress/decode/baked-result.ts +121 -0
  27. package/src/ingress/decode/fast-result.ts +160 -0
  28. package/src/ingress/decode/packed-sections.ts +56 -0
  29. package/src/ingress/decode/result-base.ts +172 -0
  30. package/src/ingress/errors.ts +72 -0
  31. package/src/ingress/fast.ts +152 -1021
  32. package/src/ingress/handlers.ts +639 -1001
  33. package/src/ingress/headers/baked-templates.ts +102 -0
  34. package/src/ingress/headers/cors.ts +44 -0
  35. package/src/ingress/headers/fast-templates.ts +238 -0
  36. package/src/ingress/headers/hsts.ts +83 -0
  37. package/src/ingress/health.ts +68 -0
  38. package/src/ingress/index.ts +142 -405
  39. package/src/ingress/metrics.ts +132 -0
  40. package/src/ingress/options.ts +197 -0
  41. package/src/ingress/packing/gather-raw-headers.ts +139 -0
  42. package/src/ingress/packing/header-packing.ts +75 -0
  43. package/src/ingress/packing/input-packer.ts +164 -0
  44. package/src/ingress/packing/scratch.ts +50 -0
  45. package/src/ingress/packing/select-headers.ts +102 -0
  46. package/src/ingress/response/error-bodies.ts +75 -0
  47. package/src/ingress/response/terminal.ts +59 -0
  48. package/src/ingress/router.ts +237 -0
  49. package/src/ingress/routes/common.ts +79 -0
  50. package/src/ingress/routes/delete.ts +14 -0
  51. package/src/ingress/routes/echo.ts +107 -0
  52. package/src/ingress/routes/fallback.ts +23 -0
  53. package/src/ingress/routes/head.ts +27 -0
  54. package/src/ingress/routes/index.ts +11 -0
  55. package/src/ingress/routes/json-write.ts +129 -0
  56. package/src/ingress/routes/options.ts +26 -0
  57. package/src/ingress/routes/read.ts +35 -0
  58. package/src/ingress/routes/responder.ts +159 -0
  59. package/src/ingress/server-node.ts +376 -0
  60. package/src/ingress/server.ts +475 -0
  61. package/src/ingress/shared.ts +132 -0
  62. package/src/ingress/status.ts +97 -0
  63. package/src/ingress/types.ts +191 -0
  64. package/src/integration/batch.ts +58 -0
  65. package/src/integration/index.ts +29 -0
  66. package/src/integration/pipeline.ts +220 -0
  67. package/src/integration/streaming.ts +78 -0
  68. package/src/integration/websocket.ts +93 -0
  69. package/src/loader/batch.ts +137 -0
  70. package/src/loader/cost.ts +105 -0
  71. package/src/loader/index.ts +745 -0
  72. package/src/loader/ops.ts +657 -0
  73. package/src/loader/state.ts +84 -0
  74. package/src/loader/types.ts +145 -0
  75. package/src/native/ffi/constants.ts +58 -0
  76. package/src/native/ffi/selftest.ts +729 -0
  77. package/src/native/ffi/types.ts +474 -0
  78. package/src/native/ffi.ts +1775 -0
  79. package/src/native/index.ts +8 -207
  80. package/src/native/loader.ts +240 -0
  81. package/src/native/types/instances.ts +274 -0
  82. package/src/native/types.ts +401 -0
  83. package/src/runtime/builtins.ts +131 -0
  84. package/src/runtime/codec.ts +147 -0
  85. package/src/runtime/detect.ts +36 -0
  86. package/src/runtime/index.ts +28 -0
  87. package/src/runtime/native.ts +35 -0
  88. package/src/runtime/server.ts +31 -0
  89. package/src/runtime/transport.ts +64 -0
  90. package/src/runtime/types.ts +173 -0
  91. package/src/runtime/uuid.ts +45 -0
  92. package/src/runtime/websocket.ts +30 -0
  93. package/src/rust-ffi/addon.ts +10 -0
  94. package/src/rust-ffi/batch/build.ts +155 -0
  95. package/src/rust-ffi/batch/index.ts +8 -0
  96. package/src/rust-ffi/batch/types.ts +92 -0
  97. package/src/rust-ffi/client.ts +87 -0
  98. package/src/rust-ffi/context.ts +193 -0
  99. package/src/rust-ffi/index.ts +51 -541
  100. package/src/rust-ffi/into.ts +21 -0
  101. package/src/rust-ffi/options.ts +113 -0
  102. package/src/rust-ffi/packed.ts +253 -0
  103. package/src/rust-ffi/proven.ts +28 -0
  104. package/src/rust-ffi/scalar/crypto.ts +346 -0
  105. package/src/rust-ffi/scalar/factories.ts +459 -0
  106. package/src/rust-ffi/scalar/hashing.ts +74 -0
  107. package/src/rust-ffi/scalar/http.ts +337 -0
  108. package/src/rust-ffi/scalar/index.ts +31 -0
  109. package/src/rust-ffi/scalar/interface.ts +325 -0
  110. package/src/rust-ffi/scalar/json-packed.ts +175 -0
  111. package/src/rust-ffi/scalar/json.ts +61 -0
  112. package/src/rust-ffi/scalar/payload.ts +241 -0
  113. package/src/rust-ffi/text.ts +121 -0
  114. package/src/selection.json +194 -0
  115. package/src/selection.ts +73 -0
  116. package/src/shared/adaptive.ts +67 -0
  117. package/src/shared/buffer-pool.ts +264 -0
  118. package/src/shared/bytes.ts +108 -9
  119. package/src/shared/codec.ts +78 -0
  120. package/src/shared/env.ts +20 -0
  121. package/src/shared/log.ts +75 -0
  122. package/src/shared/metrics.ts +247 -0
  123. package/src/shared/packed/index.ts +10 -0
  124. package/src/shared/packed/parsers.ts +31 -0
  125. package/src/shared/packed/schema.ts +23 -0
  126. package/src/shared/packed/wire.ts +504 -0
  127. package/src/shared/proven.ts +289 -0
  128. package/src/shared/request-id.ts +67 -0
  129. package/src/shared/response.ts +104 -0
  130. package/src/shared/runtime.ts +29 -0
  131. package/src/shared/trace.ts +81 -0
  132. package/src/shared/uuid.ts +15 -0
  133. package/Cargo.toml +0 -73
  134. package/bench.ts +0 -3
  135. package/rust/batch.rs +0 -195
  136. package/rust/cookie_parser.rs +0 -70
  137. package/rust/cors.rs +0 -263
  138. package/rust/hashing.rs +0 -164
  139. package/rust/headers.rs +0 -234
  140. package/rust/hmac_sha256.rs +0 -113
  141. package/rust/http_parser.rs +0 -132
  142. package/rust/ingress.rs +0 -616
  143. package/rust/ingress_constants.rs +0 -89
  144. package/rust/ip_trust.rs +0 -185
  145. package/rust/json_ops.rs +0 -66
  146. package/rust/json_patch_ops.rs +0 -25
  147. package/rust/json_schema.rs +0 -151
  148. package/rust/json_ser.rs +0 -429
  149. package/rust/lib.rs +0 -48
  150. package/rust/method.rs +0 -141
  151. package/rust/mime_lookup.rs +0 -76
  152. package/rust/output.rs +0 -238
  153. package/rust/proxy.rs +0 -90
  154. package/rust/query_parser.rs +0 -141
  155. package/rust/random_token.rs +0 -52
  156. package/rust/rate_limit.rs +0 -203
  157. package/rust/terminal.rs +0 -95
  158. package/rust/unit_tests.rs +0 -843
  159. package/rust/url_codec.rs +0 -133
  160. package/rust/util.rs +0 -537
  161. package/rust/validation.rs +0 -107
  162. package/rust/websocket.rs +0 -23
  163. package/src/baseline/index.ts +0 -12
  164. package/src/baseline/tasks/cookie.ts +0 -22
  165. package/src/baseline/tasks/hashing.ts +0 -18
  166. package/src/baseline/tasks/hmac.ts +0 -38
  167. package/src/baseline/tasks/http.ts +0 -62
  168. package/src/baseline/tasks/json-patch.ts +0 -12
  169. package/src/baseline/tasks/json.ts +0 -50
  170. package/src/baseline/tasks/mime.ts +0 -5
  171. package/src/baseline/tasks/query.ts +0 -14
  172. package/src/baseline/tasks/token.ts +0 -15
  173. package/src/baseline/tasks/url.ts +0 -11
  174. package/src/baseline/tasks/validation.ts +0 -19
  175. package/src/baseline/tasks/websocket.ts +0 -14
  176. package/src/bench/assert.ts +0 -70
  177. package/src/bench/checks.ts +0 -227
  178. package/src/bench/checksum.ts +0 -27
  179. package/src/bench/comparisons.ts +0 -70
  180. package/src/bench/concurrent-worker.ts +0 -135
  181. package/src/bench/fixtures.ts +0 -233
  182. package/src/bench/index.ts +0 -2
  183. package/src/bench/measure.ts +0 -177
  184. package/src/bench/now.ts +0 -3
  185. package/src/bench/report.ts +0 -79
  186. package/src/bench/run.ts +0 -57
  187. package/src/bench/tasks/complex.ts +0 -232
  188. package/src/bench/tasks/concurrent.ts +0 -213
  189. package/src/bench/tasks/cookie.ts +0 -44
  190. package/src/bench/tasks/hashing.ts +0 -33
  191. package/src/bench/tasks/hmac.ts +0 -36
  192. package/src/bench/tasks/http.ts +0 -50
  193. package/src/bench/tasks/index.ts +0 -52
  194. package/src/bench/tasks/json-patch.ts +0 -21
  195. package/src/bench/tasks/json.ts +0 -33
  196. package/src/bench/tasks/mime.ts +0 -21
  197. package/src/bench/tasks/query.ts +0 -44
  198. package/src/bench/tasks/stress.ts +0 -142
  199. package/src/bench/tasks/token.ts +0 -20
  200. package/src/bench/tasks/url.ts +0 -34
  201. package/src/bench/tasks/validation.ts +0 -57
  202. package/src/bench/tasks/websocket.ts +0 -21
  203. package/src/bench/types.ts +0 -60
  204. package/src/data/json-rows.ts +0 -33
  205. package/src/ingress/packed-input.ts +0 -59
  206. package/src/shared/json.ts +0 -18
  207. package/src/shared/packed.ts +0 -316
@@ -0,0 +1,132 @@
1
+ // src/ingress/metrics.ts — Ingress observability wiring (zero-dep).
2
+ //
3
+ // `createIngressMetrics()` builds a {@link MetricsRegistry} and returns the
4
+ // `onRequest`/`onResponse`/`onError` hooks to pass into `createIngressHandler`'s
5
+ // runtime, so every request is counted + latency-histogrammed. Expose the
6
+ // numbers at a `/metrics` endpoint with {@link metricsHandler} (Prometheus text
7
+ // format), served by both `createIngressServer` (Bun) and
8
+ // `createIngressServerNode` (Node).
9
+ //
10
+ // const m = createIngressMetrics();
11
+ // const ingress = createIngressHandler(options, { ...m.runtime });
12
+ // const srv = createIngressServer({ routes: {
13
+ // "/health": { read: livenessHandler() },
14
+ // "/metrics": { read: metricsHandler(m) },
15
+ // }});
16
+
17
+ import { createMetrics, DEFAULT_BUCKETS, type MetricsRegistry } from '../shared/metrics'
18
+ import type { BakedIngressResult } from './decode/baked-result'
19
+ import type { BakedIngressRuntime } from './handlers'
20
+
21
+ /** The runtime hooks + registry for ingress metrics. */
22
+ export interface IngressMetrics {
23
+ registry: MetricsRegistry
24
+ /** Pass as the runtime to `createIngressHandler`. */
25
+ runtime: Pick<BakedIngressRuntime, 'onRequest' | 'onResponse' | 'onError'>
26
+ /** Render the Prometheus exposition. */
27
+ render(): string
28
+ }
29
+
30
+ /**
31
+ * Build ingress request/error/latency metrics wired to the pre-baked handler
32
+ * runtime hooks. Metric names follow the Prometheus `castrum_http_*` scheme.
33
+ */
34
+ export function createIngressMetrics(): IngressMetrics {
35
+ const registry = createMetrics()
36
+
37
+ const requestsTotal = registry.counter(
38
+ 'castrum_http_requests_total',
39
+ 'Total ingress requests processed.',
40
+ ['method', 'status'],
41
+ )
42
+ const requestDuration = registry.histogram(
43
+ 'castrum_http_request_duration_seconds',
44
+ 'Ingress request latency in seconds.',
45
+ DEFAULT_BUCKETS,
46
+ ['method', 'status'],
47
+ )
48
+ const errorsTotal = registry.counter(
49
+ 'castrum_http_errors_total',
50
+ 'Total internal/terminal errors.',
51
+ ['code'],
52
+ )
53
+ const rateLimitedTotal = registry.counter(
54
+ 'castrum_http_rate_limited_total',
55
+ 'Total requests rejected by the rate limiter (429).',
56
+ )
57
+ const inFlight = registry.gauge(
58
+ 'castrum_http_in_flight_requests',
59
+ 'Requests currently being processed.',
60
+ )
61
+
62
+ const statusLabel = (status: number): string => String(status)
63
+
64
+ // Per-request in-flight accounting keyed by the request id (also surfaced in
65
+ // logs/metrics). This makes the gauge EXACTLY-ONCE per request, which matters
66
+ // because the baked handler can fire both `onError` and `onResponse` for the
67
+ // same request (native pipeline failure), or only `onError` (a throwing route
68
+ // callback). Without it the gauge either double-decrements or leaks, and the
69
+ // latency histogram reads 0 (result.startTime was never set anywhere).
70
+ // NOTE: error-ended requests (onError fired) are counted via requestsTotal +
71
+ // errorsTotal but not latency-sampled — the duration histogram only observes
72
+ // requests that complete through onResponse.
73
+ const active = new Map<string, number>()
74
+
75
+ return {
76
+ registry,
77
+ runtime: {
78
+ onRequest(_req, requestId, _ip) {
79
+ if (requestId) {
80
+ active.set(requestId, performance.now())
81
+ }
82
+ inFlight.inc()
83
+ },
84
+ onResponse(req, result: BakedIngressResult, status, requestId) {
85
+ const method = req.method ?? 'unknown'
86
+ const statusKey = statusLabel(status)
87
+ requestsTotal.inc({ method, status: statusKey })
88
+ // Only a request that has not already ended (via onError) records its
89
+ // latency and decrements the gauge here.
90
+ const started = requestId ? active.get(requestId) : undefined
91
+ if (started !== undefined) {
92
+ active.delete(requestId)
93
+ inFlight.dec()
94
+ requestDuration.observe(Math.max(performance.now() - started, 0) / 1000, {
95
+ method,
96
+ status: statusKey,
97
+ })
98
+ }
99
+ if (result.rateLimited) rateLimitedTotal.inc()
100
+ },
101
+ onError(_req, requestId, error) {
102
+ // Ends the request's gauge accounting exactly once (a later onResponse
103
+ // for the same request id becomes a no-op for the gauge). When only
104
+ // onError fires (throwing callback), this is what un-leaks inFlight.
105
+ if (requestId && active.delete(requestId)) {
106
+ inFlight.dec()
107
+ }
108
+ errorsTotal.inc({ code: error?.name ?? 'internal' })
109
+ },
110
+ },
111
+ render() {
112
+ return registry.render()
113
+ },
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Route handler exposing the registry in Prometheus text format
119
+ * (`content-type: text/plain; version=0.0.4`). Serve it as a `read` route:
120
+ *
121
+ * createIngressServer({ routes: { "/metrics": { read: metricsHandler(m) } } })
122
+ */
123
+ export function metricsHandler(metrics: IngressMetrics) {
124
+ return (_req: Request): Response =>
125
+ new Response(metrics.render(), {
126
+ status: 200,
127
+ headers: {
128
+ 'content-type': 'text/plain; version=0.0.4; charset=utf-8',
129
+ 'cache-control': 'no-store',
130
+ },
131
+ })
132
+ }
@@ -0,0 +1,197 @@
1
+ // src/ingress/options.ts — IngressFast option types + fail-fast validation.
2
+ //
3
+ // Options are forwarded to the native addon as a plain object; a misspelled
4
+ // key would otherwise be silently ignored. Validation against the known key
5
+ // set makes misconfiguration fail loudly at construction time.
6
+
7
+ /** Options accepted by `createIngressFast` / `createIngress`. */
8
+ export interface IngressFastOptions {
9
+ trustProxy?: boolean
10
+ trustedProxies?: { enabled?: boolean; networks?: string[] }
11
+ parseCookies?: boolean
12
+ parseQuery?: boolean
13
+ requireJsonBody?: boolean
14
+ schema?: Uint8Array
15
+ cors?: CorsOptions
16
+ rateLimit?: { limit?: number; windowMs?: number; maxEntries?: number }
17
+ /**
18
+ * Structured security-header options (CSP/HSTS) — honored by the fast/async
19
+ * paths (`createIngressFast` throws on it; `createIngress` applies it via
20
+ * `buildResponseContext`). The PRE-BAKED path (`createIngressHandler`)
21
+ * instead takes raw `securityHeaders: [name, value][]` pairs. The two paths
22
+ * intentionally use different option shapes (see docs/INGRESS.md).
23
+ */
24
+ security?: SecurityHeadersOptions
25
+ https?: boolean
26
+ maxBodyBytes?: number
27
+ enableSecurityHeaders?: boolean
28
+ enableRequestIds?: boolean
29
+ enableBodySizeGuard?: boolean
30
+ emitMetadataJson?: boolean
31
+ readBody?: boolean
32
+ outputBufferSize?: number
33
+ /**
34
+ * Overall deadline (ms) for reading a request body in the async
35
+ * `createIngress` path. 0 disables. Default: `DEFAULT_BODY_TIMEOUT_MS` (30s).
36
+ * Ignored by the sync `createIngressFast` (it does not read bodies).
37
+ */
38
+ bodyTimeoutMs?: number
39
+ /**
40
+ * Invoked when the native pipeline throws (the request becomes a 500).
41
+ * Native failures are otherwise silent in the fast path. Never throws.
42
+ */
43
+ onError?: (error: Error) => void
44
+ /**
45
+ * When true, run one probe GET at construction so the `run()` closure,
46
+ * packed pipeline and FFI ingress call are JIT-warmed before the first real
47
+ * request (cuts cold-invocation tail latency in serverless-style
48
+ * deployments). Opt-in; default false. Not forwarded to the native addon.
49
+ */
50
+ warmOnCreate?: boolean
51
+ limits?: {
52
+ maxUrlBytes?: number
53
+ maxQueryBytes?: number
54
+ maxCookieBytes?: number
55
+ maxHeadersBytes?: number
56
+ maxHeaders?: number
57
+ maxPairs?: number
58
+ }
59
+ }
60
+
61
+ /** The zero-alloc sync handler returned by `createIngressFast`. */
62
+ export interface IngressFastHandler {
63
+ run<T>(
64
+ req: Request,
65
+ ip: string | undefined,
66
+ body: Uint8Array | null,
67
+ requestId: string,
68
+ fn: (result: FastIngressResult) => T,
69
+ ): T
70
+ }
71
+
72
+ /**
73
+ * Options accepted by `createIngressHandler` (path 2).
74
+ *
75
+ * Same native option surface as the fast path, minus `onError` — path 2
76
+ * reports native failures through `BakedIngressRuntime.onError` instead of the
77
+ * options bag.
78
+ */
79
+ export type IngressHandlerOptions = Omit<IngressFastOptions, 'onError'>
80
+
81
+ import type { CorsOptions } from './headers/cors'
82
+ import type { SecurityHeadersOptions } from './headers/hsts'
83
+ import type { FastIngressResult } from './decode/fast-result'
84
+
85
+ const KNOWN_INGRESS_OPTION_KEYS: ReadonlySet<string> = new Set([
86
+ 'trustProxy',
87
+ 'trustedProxies',
88
+ 'parseCookies',
89
+ 'parseQuery',
90
+ 'requireJsonBody',
91
+ 'schema',
92
+ 'cors',
93
+ 'rateLimit',
94
+ 'security',
95
+ 'https',
96
+ 'maxBodyBytes',
97
+ 'enableSecurityHeaders',
98
+ 'enableRequestIds',
99
+ 'enableBodySizeGuard',
100
+ 'emitMetadataJson',
101
+ 'readBody',
102
+ 'outputBufferSize',
103
+ 'bodyTimeoutMs',
104
+ 'onError',
105
+ 'onRequest',
106
+ 'onResponse',
107
+ 'limits',
108
+ 'warmOnCreate',
109
+ ])
110
+
111
+ /**
112
+ * Throw if `options` contains a key the ingress pipeline does not know.
113
+ *
114
+ * `label` names the calling factory in the error so a typo is easy to map
115
+ * back to the call site (both `createIngressFast` and `createIngressHandler`
116
+ * validate the same option surface).
117
+ */
118
+ export function assertKnownIngressOptions(
119
+ options: IngressFastOptions,
120
+ label = 'createIngressFast',
121
+ ): void {
122
+ for (const key of Object.keys(options)) {
123
+ if (!KNOWN_INGRESS_OPTION_KEYS.has(key)) {
124
+ throw new TypeError(
125
+ `${label}: unknown option '${key}'. ` +
126
+ `Known options: ${[...KNOWN_INGRESS_OPTION_KEYS].sort().join(', ')}`,
127
+ )
128
+ }
129
+ }
130
+ }
131
+
132
+ /** Numeric options that must be finite and non-negative (0 is meaningful, e.g.
133
+ * `rateLimit.limit: 0` disables the limiter). Dotted = nested option. */
134
+ const NON_NEGATIVE_NUMBER_OPTIONS: ReadonlyArray<readonly [path: string, root: string]> = [
135
+ ['maxBodyBytes', 'maxBodyBytes'],
136
+ ['bodyTimeoutMs', 'bodyTimeoutMs'],
137
+ ['outputBufferSize', 'outputBufferSize'],
138
+ ['rateLimit.limit', 'rateLimit'],
139
+ ['rateLimit.windowMs', 'rateLimit'],
140
+ ['rateLimit.maxEntries', 'rateLimit'],
141
+ ['limits.maxUrlBytes', 'limits'],
142
+ ['limits.maxQueryBytes', 'limits'],
143
+ ['limits.maxCookieBytes', 'limits'],
144
+ ['limits.maxHeadersBytes', 'limits'],
145
+ ['limits.maxHeaders', 'limits'],
146
+ ['limits.maxPairs', 'limits'],
147
+ ]
148
+
149
+ function assertNonNegativeNumber(value: unknown, path: string, label: string): void {
150
+ if (typeof value === 'number' && (!Number.isFinite(value) || value < 0)) {
151
+ throw new TypeError(
152
+ `${label}: option '${path}' must be a finite, non-negative number (got ${value}).`,
153
+ )
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Fail-fast on non-finite / negative numeric option values. `assertKnownIngressOptions`
159
+ * only checks the key set; a misconfigured negative value (e.g. `maxBodyBytes: -1`)
160
+ * would otherwise silently corrupt the pipeline (a napi marshal error or an
161
+ * always-rejecting limit).
162
+ */
163
+ export function assertIngressOptionValues(
164
+ options: IngressFastOptions,
165
+ label = 'createIngressFast',
166
+ ): void {
167
+ for (const [path, root] of NON_NEGATIVE_NUMBER_OPTIONS) {
168
+ if (path.includes('.')) {
169
+ const [, inner] = path.split('.')
170
+ const obj = (options as Record<string, unknown>)[root] as Record<string, unknown> | undefined
171
+ assertNonNegativeNumber(obj?.[inner ?? ''], path, label)
172
+ } else {
173
+ assertNonNegativeNumber((options as Record<string, unknown>)[path], path, label)
174
+ }
175
+ }
176
+ }
177
+
178
+ let trustProxyWarned = false
179
+
180
+ /**
181
+ * Warn once (per process) when the legacy `trustProxy: true` boolean is used.
182
+ *
183
+ * `trustProxy: true` makes the pipeline trust EVERY hop of
184
+ * `X-Forwarded-For`/`X-Real-IP`, so a client can forge its IP and bypass
185
+ * IP-based rate limiting. Prefer the `trustedProxies` network-list API and
186
+ * only enable proxy trust behind a trusted edge.
187
+ */
188
+ export function warnTrustProxyDeprecated(): void {
189
+ if (trustProxyWarned) return
190
+ trustProxyWarned = true
191
+ console.warn(
192
+ '[castrum] WARN: `trustProxy: true` is deprecated and trusts EVERY hop — ' +
193
+ '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.',
196
+ )
197
+ }
@@ -0,0 +1,139 @@
1
+ // src/ingress/packing/gather-raw-headers.ts — Raw header gathering (pre-baked
2
+ // path).
3
+ //
4
+ // The pre-baked path passes headers to the native addon as a plain
5
+ // [name, value][] array (the addon packs them internally in Rust). This module
6
+ // extracts exactly the headers selected by a HeaderPlan, with per-header size
7
+ // guards so oversized values are dropped rather than forwarded.
8
+
9
+ import type { HeaderPlan } from '../shared'
10
+ import { METHOD_KIND } from '../shared'
11
+ import { writeHeaderPair } from './header-packing'
12
+ import { forEachSelectedHeader, HDR_ORIGIN } from './select-headers'
13
+ import { getHeaderBuf, MAX_SMALL_HEADER_BYTES } from './scratch'
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
+ /**
24
+ * Gather the request headers selected by `plan` as a packed byte block
25
+ * (`[u16 count] { [u16 name_len][name][u32 val_len][value] }`) written into a
26
+ * reusable thread-local buffer.
27
+ *
28
+ * This is the zero-intermediate-string equivalent of the legacy string-array
29
+ * header gathering (a test-local reference in header-packing.test.ts) for the
30
+ * packed-input pipeline: identical header selection and per-header size
31
+ * guards, but no `[string, string][]` array and no napi string marshaling.
32
+ * The returned view is valid only until the next call on this call site.
33
+ *
34
+ * A plan that selects NO headers (no cookie/cors/proxy/proto) short-circuits
35
+ * to a shared empty block — skipping the scratch-buffer round-robin, the
36
+ * `setUint16` and the per-call `subarray` entirely (the common minimal-route
37
+ * case).
38
+ *
39
+ * A CORS-ONLY plan on a non-preflight request (the dominant minimal-route
40
+ * case, e.g. the benchmark server) produces a block that depends ONLY on the
41
+ * `Origin` header value — which is constant per deployment. Such blocks are
42
+ * cached keyed by origin, so the per-request UTF-8 encode of the origin (the
43
+ * largest single JS packing cost) and the scratch write are skipped entirely;
44
+ * the cache is bounded (FIFO eviction) so multi-origin deployments stay safe.
45
+ *
46
+ * @param originValue - When provided (non-undefined), the already-fetched
47
+ * `Origin` header value to pack, avoiding a second `req.headers.get('origin')`
48
+ * native→JS string conversion per request on the hot path (the caller, e.g.
49
+ * `handlers.ts::run`, already fetched it once for `ctx.origin`). When
50
+ * omitted, the origin is fetched here as before (back-compat). The same
51
+ * `MAX_SMALL_HEADER_BYTES` size guard still applies either way.
52
+ */
53
+ export function gatherRawHeadersPacked(
54
+ req: Request,
55
+ plan: HeaderPlan,
56
+ methodKind: number,
57
+ originValue?: string | null,
58
+ ): Uint8Array {
59
+ if (!plan.cookie && !plan.cors && !plan.proxy && !plan.proto) {
60
+ return EMPTY_HEADERS_BLOCK
61
+ }
62
+
63
+ // CORS-only fast path: the only selected header is the (typically constant)
64
+ // Origin, so the whole packed block is a pure function of the origin string.
65
+ // Skip the per-request encode + scratch write by returning the cached block.
66
+ // Preflight (OPTIONS) is excluded — it also packs ACRM/ACRH and must take
67
+ // the general path. The MAX_SMALL_HEADER_BYTES guard mirrors the general
68
+ // path (an oversized origin is dropped → empty block, not cached).
69
+ if (plan.cors && !plan.cookie && !plan.proxy && !plan.proto && methodKind !== METHOD_KIND.OPTIONS) {
70
+ const origin = originValue !== undefined ? originValue : req.headers.get('origin')
71
+ if (origin === null || origin.length > MAX_SMALL_HEADER_BYTES) {
72
+ return EMPTY_HEADERS_BLOCK
73
+ }
74
+ return cachedOriginBlock(origin)
75
+ }
76
+
77
+ let [buf, view] = getHeaderBuf()
78
+ let pos = 2
79
+ let count = 0
80
+
81
+ const write = (name: Uint8Array, value: string): void => {
82
+ ;[pos, buf, view] = writeHeaderPair(buf, view, pos, name, value)
83
+ count++
84
+ }
85
+
86
+ forEachSelectedHeader(req, plan, methodKind, originValue, write)
87
+
88
+ // No selected header was actually present (e.g. a route with cookie+cors in
89
+ // its plan but a request that sends neither) — return the shared empty block
90
+ // instead of touching the scratch buffer again (setUint16 + subarray) on the
91
+ // hot path.
92
+ if (count === 0) {
93
+ return EMPTY_HEADERS_BLOCK
94
+ }
95
+
96
+ view.setUint16(0, count, true)
97
+ return buf.subarray(0, pos)
98
+ }
99
+
100
+ /** Shared `[0, 0]` (zero headers) block — returned when a plan selects nothing.
101
+ * Callers treat the result as read-only (the packers copy it verbatim). */
102
+ const EMPTY_HEADERS_BLOCK = new Uint8Array(2)
103
+
104
+ /**
105
+ * Cached packed blocks for the CORS-only case, keyed by origin string.
106
+ * Bounded (FIFO eviction) — a multi-origin deployment never grows it
107
+ * unboundedly, and a single-origin deployment hits a stable single entry.
108
+ * Module state is per-thread in Bun workers, so no cross-thread aliasing.
109
+ */
110
+ const ORIGIN_BLOCK_CACHE = new Map<string, Uint8Array>()
111
+ const ORIGIN_CACHE_MAX = 8
112
+
113
+ /**
114
+ * Return (building and caching on first use) the packed header block for a
115
+ * CORS-only plan whose only selected header is `origin`.
116
+ *
117
+ * The block is byte-identical to what the general path would produce
118
+ * (`[u16 1][u16 name_len]['origin'][u32 val_len][value]`), built once with the
119
+ * shared `writeHeaderPair` and copied out of the scratch buffer into a stable
120
+ * allocation — never aliased to the round-robin scratch, so it stays valid
121
+ * across subsequent gathers.
122
+ */
123
+ function cachedOriginBlock(origin: string): Uint8Array {
124
+ const hit = ORIGIN_BLOCK_CACHE.get(origin)
125
+ if (hit !== undefined) return hit
126
+
127
+ let [buf, view] = getHeaderBuf()
128
+ let pos = 2
129
+ ;[pos, buf, view] = writeHeaderPair(buf, view, pos, HDR_ORIGIN, origin)
130
+ view.setUint16(0, 1, true)
131
+ const block = buf.slice(0, pos)
132
+
133
+ if (ORIGIN_BLOCK_CACHE.size >= ORIGIN_CACHE_MAX) {
134
+ const firstKey = ORIGIN_BLOCK_CACHE.keys().next().value
135
+ if (firstKey !== undefined) ORIGIN_BLOCK_CACHE.delete(firstKey)
136
+ }
137
+ ORIGIN_BLOCK_CACHE.set(origin, block)
138
+ return block
139
+ }
@@ -0,0 +1,75 @@
1
+ // src/ingress/packing/header-packing.ts — Binary header packing (fast path).
2
+ //
3
+ // Packs the request headers selected by a HeaderPlan directly into a reusable
4
+ // Uint8Array with no intermediate strings. A bounded thread-local pool of
5
+ // scratch buffers keeps this allocation-free on the hot path while isolating
6
+ // the buffers per call-site sequence.
7
+
8
+ import { encodeUtf8Into } from '../../shared/codec'
9
+ import { type HeaderPlan, METHOD_KIND, METHOD_KIND_UNKNOWN } from '../shared'
10
+ import { getHeaderBuf } from './scratch'
11
+ import { forEachSelectedHeader } from './select-headers'
12
+
13
+ /**
14
+ * Write one header pair into `buf`/`view`, growing them if needed.
15
+ *
16
+ * IMPORTANT: the header block can exceed `HEADER_BUF_SIZE` (e.g. a very large
17
+ * cookie or origin header). When that happens `buf`/`view` are replaced by a
18
+ * larger buffer, so the (possibly grown) buffer + view are returned alongside
19
+ * the new position. Callers MUST thread the returned buffer/view back into
20
+ * subsequent writes — otherwise the grown bytes are written into a discarded
21
+ * array and the packed output is silently corrupted.
22
+ */
23
+ export function writeHeaderPair(
24
+ buf: Uint8Array,
25
+ view: DataView,
26
+ pos: number,
27
+ name: Uint8Array,
28
+ value: string,
29
+ ): [pos: number, buf: Uint8Array, view: DataView] {
30
+ const needed = 2 + name.length + 4 + value.length * 3
31
+ if (pos + needed > buf.length) {
32
+ const next = new Uint8Array(Math.max(buf.length * 2, pos + needed))
33
+ next.set(buf.subarray(0, pos))
34
+ buf = next
35
+ view = new DataView(buf.buffer)
36
+ }
37
+
38
+ view.setUint16(pos, name.length, true)
39
+ buf.set(name, pos + 2)
40
+ pos += 2 + name.length
41
+
42
+ const valueLenPos = pos
43
+ pos += 4
44
+
45
+ // Number-returning codec write directly at `pos` (no `{read, written}`
46
+ // object, no intermediate `subarray` — both were per-header allocations).
47
+ const written = encodeUtf8Into(value, buf, pos)
48
+
49
+ view.setUint32(valueLenPos, written, true)
50
+ pos += written
51
+
52
+ return [pos, buf, view]
53
+ }
54
+
55
+ /** Pack the headers selected by `plan` into a reusable Uint8Array. */
56
+ export function packHeaders(req: Request, plan: HeaderPlan): Uint8Array {
57
+ let [buf, view] = getHeaderBuf()
58
+ let pos = 2
59
+ let count = 0
60
+
61
+ // Selection rules + per-header size guards live in forEachSelectedHeader
62
+ // (single source of truth shared with the pre-baked path): an oversized
63
+ // value is dropped rather than forwarded so the packed block can never
64
+ // exceed the native `max_headers_bytes` (65536) and 500.
65
+ const write = (name: Uint8Array, value: string): void => {
66
+ ;[pos, buf, view] = writeHeaderPair(buf, view, pos, name, value)
67
+ count++
68
+ }
69
+
70
+ const methodKind = METHOD_KIND[req.method] ?? METHOD_KIND_UNKNOWN
71
+ forEachSelectedHeader(req, plan, methodKind, undefined, write)
72
+
73
+ view.setUint16(0, count, true)
74
+ return buf.subarray(0, pos)
75
+ }