castrum 0.7.0 → 0.9.0

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 (176) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +322 -59
  3. package/castrum.linux-x64-gnu.node +0 -0
  4. package/dist/index.d.ts +3161 -0
  5. package/dist/index.js +7306 -0
  6. package/docs/ARCHITECTURE.md +415 -0
  7. package/docs/BENCHMARKS.md +307 -0
  8. package/docs/ENVIRONMENT.md +79 -0
  9. package/docs/GETTING_STARTED.md +209 -0
  10. package/docs/INGRESS.md +745 -0
  11. package/docs/REPO_MAP.md +259 -0
  12. package/docs/adr/0001-two-wire-formats.md +39 -0
  13. package/docs/adr/0002-zero-dom-fast-schema.md +38 -0
  14. package/docs/adr/0003-bun-builtins-delegation.md +42 -0
  15. package/docs/bun-builtins-decision-matrix.md +108 -0
  16. package/index.ts +51 -8
  17. package/package.json +73 -20
  18. package/src/data/json-rows.ts +13 -13
  19. package/src/ingress/body.ts +149 -0
  20. package/src/ingress/constants.ts +220 -48
  21. package/src/ingress/context.ts +136 -0
  22. package/src/ingress/decode/baked-result.ts +121 -0
  23. package/src/ingress/decode/fast-result.ts +160 -0
  24. package/src/ingress/decode/packed-sections.ts +56 -0
  25. package/src/ingress/decode/result-base.ts +172 -0
  26. package/src/ingress/errors.ts +72 -0
  27. package/src/ingress/fast.ts +152 -1021
  28. package/src/ingress/handlers.ts +563 -1001
  29. package/src/ingress/headers/baked-templates.ts +102 -0
  30. package/src/ingress/headers/cors.ts +44 -0
  31. package/src/ingress/headers/fast-templates.ts +238 -0
  32. package/src/ingress/headers/hsts.ts +83 -0
  33. package/src/ingress/health.ts +67 -0
  34. package/src/ingress/index.ts +130 -405
  35. package/src/ingress/metrics.ts +132 -0
  36. package/src/ingress/options.ts +197 -0
  37. package/src/ingress/packing/gather-raw-headers.ts +75 -0
  38. package/src/ingress/packing/header-packing.ts +75 -0
  39. package/src/ingress/packing/input-packer.ts +150 -0
  40. package/src/ingress/packing/scratch.ts +50 -0
  41. package/src/ingress/packing/select-headers.ts +96 -0
  42. package/src/ingress/response/error-bodies.ts +75 -0
  43. package/src/ingress/response/terminal.ts +59 -0
  44. package/src/ingress/routes/common.ts +38 -0
  45. package/src/ingress/routes/delete.ts +14 -0
  46. package/src/ingress/routes/echo.ts +107 -0
  47. package/src/ingress/routes/fallback.ts +23 -0
  48. package/src/ingress/routes/head.ts +37 -0
  49. package/src/ingress/routes/index.ts +10 -0
  50. package/src/ingress/routes/json-write.ts +153 -0
  51. package/src/ingress/routes/options.ts +36 -0
  52. package/src/ingress/routes/read.ts +45 -0
  53. package/src/ingress/server-node.ts +376 -0
  54. package/src/ingress/server.ts +443 -0
  55. package/src/ingress/shared.ts +132 -0
  56. package/src/ingress/status.ts +97 -0
  57. package/src/ingress/types.ts +157 -0
  58. package/src/integration/batch.ts +58 -0
  59. package/src/integration/index.ts +29 -0
  60. package/src/integration/pipeline.ts +217 -0
  61. package/src/integration/streaming.ts +79 -0
  62. package/src/integration/websocket.ts +92 -0
  63. package/src/loader/batch.ts +133 -0
  64. package/src/loader/cost.ts +104 -0
  65. package/src/loader/index.ts +930 -0
  66. package/src/loader/ops.ts +657 -0
  67. package/src/native/ffi.ts +1541 -0
  68. package/src/native/index.ts +8 -207
  69. package/src/native/loader.ts +194 -0
  70. package/src/native/types.ts +598 -0
  71. package/src/rust-ffi/addon.ts +10 -0
  72. package/src/rust-ffi/batch.ts +431 -0
  73. package/src/rust-ffi/client.ts +88 -0
  74. package/src/rust-ffi/context.ts +164 -0
  75. package/src/rust-ffi/index.ts +40 -541
  76. package/src/rust-ffi/options.ts +113 -0
  77. package/src/rust-ffi/packed.ts +253 -0
  78. package/src/rust-ffi/proven.ts +42 -0
  79. package/src/rust-ffi/scalar/crypto.ts +265 -0
  80. package/src/rust-ffi/scalar/factories.ts +233 -0
  81. package/src/rust-ffi/scalar/hashing.ts +72 -0
  82. package/src/rust-ffi/scalar/http.ts +206 -0
  83. package/src/rust-ffi/scalar/index.ts +31 -0
  84. package/src/rust-ffi/scalar/interface.ts +450 -0
  85. package/src/rust-ffi/scalar/json.ts +45 -0
  86. package/src/rust-ffi/scalar/payload.ts +124 -0
  87. package/src/rust-ffi/text.ts +95 -0
  88. package/src/selection.json +194 -0
  89. package/src/selection.ts +77 -0
  90. package/src/shared/adaptive.ts +68 -0
  91. package/src/shared/bench-classify.ts +209 -0
  92. package/src/shared/buffer-pool.ts +209 -0
  93. package/src/shared/bytes.ts +35 -5
  94. package/src/shared/env.ts +20 -0
  95. package/src/shared/log.ts +74 -0
  96. package/src/shared/metrics.ts +247 -0
  97. package/src/shared/packed.ts +350 -154
  98. package/src/shared/proven.ts +596 -0
  99. package/src/shared/request-id.ts +67 -0
  100. package/src/shared/response.ts +104 -0
  101. package/src/shared/runtime.ts +34 -0
  102. package/src/shared/trace.ts +81 -0
  103. package/src/shared/uuid.ts +31 -0
  104. package/Cargo.toml +0 -73
  105. package/bench.ts +0 -3
  106. package/rust/batch.rs +0 -195
  107. package/rust/cookie_parser.rs +0 -70
  108. package/rust/cors.rs +0 -263
  109. package/rust/hashing.rs +0 -164
  110. package/rust/headers.rs +0 -234
  111. package/rust/hmac_sha256.rs +0 -113
  112. package/rust/http_parser.rs +0 -132
  113. package/rust/ingress.rs +0 -616
  114. package/rust/ingress_constants.rs +0 -89
  115. package/rust/ip_trust.rs +0 -185
  116. package/rust/json_ops.rs +0 -66
  117. package/rust/json_patch_ops.rs +0 -25
  118. package/rust/json_schema.rs +0 -151
  119. package/rust/json_ser.rs +0 -429
  120. package/rust/lib.rs +0 -48
  121. package/rust/method.rs +0 -141
  122. package/rust/mime_lookup.rs +0 -76
  123. package/rust/output.rs +0 -238
  124. package/rust/proxy.rs +0 -90
  125. package/rust/query_parser.rs +0 -141
  126. package/rust/random_token.rs +0 -52
  127. package/rust/rate_limit.rs +0 -203
  128. package/rust/terminal.rs +0 -95
  129. package/rust/unit_tests.rs +0 -843
  130. package/rust/url_codec.rs +0 -133
  131. package/rust/util.rs +0 -537
  132. package/rust/validation.rs +0 -107
  133. package/rust/websocket.rs +0 -23
  134. package/src/baseline/index.ts +0 -12
  135. package/src/baseline/tasks/cookie.ts +0 -22
  136. package/src/baseline/tasks/hashing.ts +0 -18
  137. package/src/baseline/tasks/hmac.ts +0 -38
  138. package/src/baseline/tasks/http.ts +0 -62
  139. package/src/baseline/tasks/json-patch.ts +0 -12
  140. package/src/baseline/tasks/json.ts +0 -50
  141. package/src/baseline/tasks/mime.ts +0 -5
  142. package/src/baseline/tasks/query.ts +0 -14
  143. package/src/baseline/tasks/token.ts +0 -15
  144. package/src/baseline/tasks/url.ts +0 -11
  145. package/src/baseline/tasks/validation.ts +0 -19
  146. package/src/baseline/tasks/websocket.ts +0 -14
  147. package/src/bench/assert.ts +0 -70
  148. package/src/bench/checks.ts +0 -227
  149. package/src/bench/checksum.ts +0 -27
  150. package/src/bench/comparisons.ts +0 -70
  151. package/src/bench/concurrent-worker.ts +0 -135
  152. package/src/bench/fixtures.ts +0 -233
  153. package/src/bench/index.ts +0 -2
  154. package/src/bench/measure.ts +0 -177
  155. package/src/bench/now.ts +0 -3
  156. package/src/bench/report.ts +0 -79
  157. package/src/bench/run.ts +0 -57
  158. package/src/bench/tasks/complex.ts +0 -232
  159. package/src/bench/tasks/concurrent.ts +0 -213
  160. package/src/bench/tasks/cookie.ts +0 -44
  161. package/src/bench/tasks/hashing.ts +0 -33
  162. package/src/bench/tasks/hmac.ts +0 -36
  163. package/src/bench/tasks/http.ts +0 -50
  164. package/src/bench/tasks/index.ts +0 -52
  165. package/src/bench/tasks/json-patch.ts +0 -21
  166. package/src/bench/tasks/json.ts +0 -33
  167. package/src/bench/tasks/mime.ts +0 -21
  168. package/src/bench/tasks/query.ts +0 -44
  169. package/src/bench/tasks/stress.ts +0 -142
  170. package/src/bench/tasks/token.ts +0 -20
  171. package/src/bench/tasks/url.ts +0 -34
  172. package/src/bench/tasks/validation.ts +0 -57
  173. package/src/bench/tasks/websocket.ts +0 -21
  174. package/src/bench/types.ts +0 -60
  175. package/src/ingress/packed-input.ts +0 -59
  176. package/src/shared/json.ts +0 -18
@@ -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,75 @@
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 { writeHeaderPair } from './header-packing'
11
+ import { forEachSelectedHeader } from './select-headers'
12
+ import { getHeaderBuf } from './scratch'
13
+ import { decoder } from '../../shared/bytes'
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 `[name, value][]` array.
25
+ * `methodKind` is the native method-kind enum (see `METHOD_KIND`).
26
+ */
27
+ export function gatherRawHeaders(
28
+ req: Request,
29
+ plan: HeaderPlan,
30
+ methodKind: number,
31
+ ): [string, string][] {
32
+ const headers: [string, string][] = []
33
+ forEachSelectedHeader(req, plan, methodKind, undefined, (name, value) => {
34
+ headers.push([decoder.decode(name), value])
35
+ })
36
+ return headers
37
+ }
38
+
39
+ /**
40
+ * Gather the request headers selected by `plan` as a packed byte block
41
+ * (`[u16 count] { [u16 name_len][name][u32 val_len][value] }`) written into a
42
+ * reusable thread-local buffer.
43
+ *
44
+ * This is the zero-intermediate-string equivalent of [`gatherRawHeaders`] for
45
+ * the packed-input pipeline: identical header selection and per-header size
46
+ * guards, but no `[string, string][]` array and no napi string marshaling.
47
+ * The returned view is valid only until the next call on this call site.
48
+ *
49
+ * @param originValue - When provided (non-undefined), the already-fetched
50
+ * `Origin` header value to pack, avoiding a second `req.headers.get('origin')`
51
+ * native→JS string conversion per request on the hot path (the caller, e.g.
52
+ * `handlers.ts::run`, already fetched it once for `ctx.origin`). When
53
+ * omitted, the origin is fetched here as before (back-compat). The same
54
+ * `MAX_SMALL_HEADER_BYTES` size guard still applies either way.
55
+ */
56
+ export function gatherRawHeadersPacked(
57
+ req: Request,
58
+ plan: HeaderPlan,
59
+ methodKind: number,
60
+ originValue?: string | null,
61
+ ): Uint8Array {
62
+ let [buf, view] = getHeaderBuf()
63
+ let pos = 2
64
+ let count = 0
65
+
66
+ const write = (name: Uint8Array, value: string): void => {
67
+ ;[pos, buf, view] = writeHeaderPair(buf, view, pos, name, value)
68
+ count++
69
+ }
70
+
71
+ forEachSelectedHeader(req, plan, methodKind, originValue, write)
72
+
73
+ view.setUint16(0, count, true)
74
+ return buf.subarray(0, pos)
75
+ }
@@ -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 { METHOD_KIND, METHOD_KIND_UNKNOWN, type HeaderPlan } from '../shared'
9
+ import { getHeaderBuf } from './scratch'
10
+ import { forEachSelectedHeader } from './select-headers'
11
+
12
+ const encoder = new TextEncoder()
13
+
14
+ /**
15
+ * Write one header pair into `buf`/`view`, growing them if needed.
16
+ *
17
+ * IMPORTANT: the header block can exceed `HEADER_BUF_SIZE` (e.g. a very large
18
+ * cookie or origin header). When that happens `buf`/`view` are replaced by a
19
+ * larger buffer, so the (possibly grown) buffer + view are returned alongside
20
+ * the new position. Callers MUST thread the returned buffer/view back into
21
+ * subsequent writes — otherwise the grown bytes are written into a discarded
22
+ * array and the packed output is silently corrupted.
23
+ */
24
+ export function writeHeaderPair(
25
+ buf: Uint8Array,
26
+ view: DataView,
27
+ pos: number,
28
+ name: Uint8Array,
29
+ value: string,
30
+ ): [pos: number, buf: Uint8Array, view: DataView] {
31
+ const needed = 2 + name.length + 4 + value.length * 3
32
+ if (pos + needed > buf.length) {
33
+ const next = new Uint8Array(Math.max(buf.length * 2, pos + needed))
34
+ next.set(buf.subarray(0, pos))
35
+ buf = next
36
+ view = new DataView(buf.buffer)
37
+ }
38
+
39
+ view.setUint16(pos, name.length, true)
40
+ buf.set(name, pos + 2)
41
+ pos += 2 + name.length
42
+
43
+ const valueLenPos = pos
44
+ pos += 4
45
+
46
+ const dest = buf.subarray(pos)
47
+ const { written } = encoder.encodeInto(value, dest)
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
+ }
@@ -0,0 +1,150 @@
1
+ // src/ingress/packing/input-packer.ts — Binary-packed ingress input builder.
2
+ //
3
+ // Packs the per-request inputs (method kind + url + ip + request id + packed
4
+ // headers) into a single growable Uint8Array in the layout the native
5
+ // `Ingress.handleRequestPacked` expects.
6
+
7
+ const encoder = new TextEncoder()
8
+ const EMPTY_BYTES = new Uint8Array(0)
9
+ const EMPTY_IP_BYTES = encoder.encode('0.0.0.0')
10
+
11
+ /** Growable packer for the fast-path packed input buffer. */
12
+ export class IngressInputPacker {
13
+ private buf: Uint8Array
14
+ private view: DataView
15
+ private pos = 0
16
+
17
+ constructor(initialSize = 65536) {
18
+ this.buf = new Uint8Array(Math.max(1024, initialSize))
19
+ this.view = new DataView(this.buf.buffer)
20
+ }
21
+
22
+ private ensure(additional: number): void {
23
+ const needed = this.pos + additional
24
+ if (needed <= this.buf.byteLength) return
25
+
26
+ let nextSize = this.buf.byteLength * 2
27
+ while (nextSize < needed) {
28
+ nextSize *= 2
29
+ }
30
+
31
+ const next = new Uint8Array(nextSize)
32
+ next.set(this.buf.subarray(0, this.pos))
33
+
34
+ this.buf = next
35
+ this.view = new DataView(next.buffer)
36
+ }
37
+
38
+ private writeU8(value: number): void {
39
+ this.ensure(1)
40
+ this.buf[this.pos++] = value & 0xff
41
+ }
42
+
43
+ private writeLenPrefixed(bytes: Uint8Array): void {
44
+ this.ensure(4 + bytes.byteLength)
45
+
46
+ this.view.setUint32(this.pos, bytes.byteLength, true)
47
+ this.pos += 4
48
+
49
+ this.buf.set(bytes, this.pos)
50
+ this.pos += bytes.byteLength
51
+ }
52
+
53
+ /** Pack the request inputs; the returned view is valid until the next pack. */
54
+ pack(
55
+ methodKind: number,
56
+ urlBytes: Uint8Array,
57
+ ipBytes: Uint8Array,
58
+ requestIdBytes: Uint8Array,
59
+ headers: Uint8Array,
60
+ ): Uint8Array {
61
+ this.pos = 0
62
+
63
+ this.writeU8(methodKind)
64
+ this.writeLenPrefixed(urlBytes)
65
+ this.writeLenPrefixed(ipBytes)
66
+ this.writeLenPrefixed(requestIdBytes)
67
+ this.writeLenPrefixed(headers)
68
+
69
+ return this.buf.subarray(0, this.pos)
70
+ }
71
+
72
+ /** Encode `value` directly into the buffer, length-prefixed (zero-copy). */
73
+ private writeStringLenPrefixed(value: string): void {
74
+ // 3 bytes per UTF-16 code unit is a safe upper bound (BMP non-ASCII);
75
+ // surrogate pairs encode to fewer bytes per code unit.
76
+ this.ensure(4 + value.length * 3)
77
+ const valueLenPos = this.pos
78
+ this.pos += 4
79
+ const dest = this.buf.subarray(this.pos)
80
+ const { written } = encoder.encodeInto(value, dest)
81
+ this.view.setUint32(valueLenPos, written, true)
82
+ this.pos += written
83
+ }
84
+
85
+ /**
86
+ * Pack the request inputs from raw strings, encoding url/ip/requestId
87
+ * directly into the internal buffer via `encodeInto` — three fewer
88
+ * intermediate `Uint8Array` allocations + copies per request vs [`pack`].
89
+ * The returned view is valid until the next pack.
90
+ */
91
+ packFromStrings(
92
+ methodKind: number,
93
+ url: string,
94
+ ip: string | undefined,
95
+ requestId: string | undefined,
96
+ headers: Uint8Array,
97
+ ): Uint8Array {
98
+ this.pos = 0
99
+
100
+ this.writeU8(methodKind)
101
+ this.writeStringLenPrefixed(url)
102
+ if (ip && ip.length > 0) {
103
+ this.writeStringLenPrefixed(ip)
104
+ } else {
105
+ this.writeLenPrefixed(EMPTY_IP_BYTES)
106
+ }
107
+ if (requestId) {
108
+ this.writeStringLenPrefixed(requestId)
109
+ } else {
110
+ this.writeLenPrefixed(EMPTY_BYTES)
111
+ }
112
+ this.writeLenPrefixed(headers)
113
+
114
+ return this.buf.subarray(0, this.pos)
115
+ }
116
+
117
+ /**
118
+ * Pack the request inputs with a mixed strategy: `url`/`ip` are encoded
119
+ * directly from strings via `encodeInto` (no intermediate `Uint8Array`),
120
+ * while `requestId`/`headers` are already-encoded byte slices copied in
121
+ * verbatim.
122
+ *
123
+ * This removes the two `encoder.encode` allocations + copies per request
124
+ * that [`pack`] pays for URL/IP, while keeping the pre-encoded request-id
125
+ * bytes untouched (no decode-to-string → re-encode round trip, which
126
+ * `packFromStrings` would incur when a request id was already generated as
127
+ * bytes). The returned view is valid until the next pack.
128
+ */
129
+ packParts(
130
+ methodKind: number,
131
+ url: string,
132
+ ip: string | undefined,
133
+ requestId: Uint8Array,
134
+ headers: Uint8Array,
135
+ ): Uint8Array {
136
+ this.pos = 0
137
+
138
+ this.writeU8(methodKind)
139
+ this.writeStringLenPrefixed(url)
140
+ if (ip && ip.length > 0) {
141
+ this.writeStringLenPrefixed(ip)
142
+ } else {
143
+ this.writeLenPrefixed(EMPTY_IP_BYTES)
144
+ }
145
+ this.writeLenPrefixed(requestId)
146
+ this.writeLenPrefixed(headers)
147
+
148
+ return this.buf.subarray(0, this.pos)
149
+ }
150
+ }
@@ -0,0 +1,50 @@
1
+ // src/ingress/packing/scratch.ts — Shared thread-local scratch header buffers.
2
+ //
3
+ // Both the fast path (`packHeaders` in header-packing.ts) and the pre-baked
4
+ // path (`gatherRawHeadersPacked` in gather-raw-headers.ts) pack request
5
+ // headers into a reusable Uint8Array. This thread-local pool isolates the
6
+ // buffer per call-site sequence and bounds the number of cached buffers,
7
+ // keeping the hot path allocation-free.
8
+
9
+ /** Scratch capacity for the reusable packed-header buffer. */
10
+ export const HEADER_BUF_SIZE = 8192
11
+
12
+ // ── Per-header size guards (SHARED by BOTH packing paths) ─────────
13
+ // A header value larger than the bound below is dropped BEFORE packing rather
14
+ // than forwarded. Without guards the fast path (`packHeaders`) forwarded any
15
+ // size, so an oversized cookie/xff/origin could push the packed block past the
16
+ // native `max_headers_bytes` (65536) and fail with a 500, while the pre-baked
17
+ // path silently dropped the same header. Both paths must share ONE policy;
18
+ // keep the Rust `max_headers_bytes` bound in mind when changing these.
19
+ /** Upper bound for the `cookie` header value. */
20
+ export const MAX_COOKIE_HEADER_BYTES = 8192
21
+ /** Upper bound for small single-value headers (origin, ACRM, ACRH, ...). */
22
+ export const MAX_SMALL_HEADER_BYTES = 2048
23
+ /** Upper bound for the `x-forwarded-for` header value. */
24
+ export const MAX_XFF_HEADER_BYTES = 8192
25
+
26
+ // Thread-local header buffer for per-call isolation (round-robin over a
27
+ // bounded pool; each buffer is only borrowed for the duration of one
28
+ // pack/gather call).
29
+ const [getHeaderBuf] = (() => {
30
+ const tls: [Uint8Array, DataView][] = []
31
+ const MAX_CACHED = 256
32
+ let tlsIdx = 0
33
+
34
+ function acquire(): [Uint8Array, DataView] {
35
+ const cached = tls[tlsIdx]
36
+ tlsIdx = (tlsIdx + 1) % MAX_CACHED
37
+ if (cached) return cached
38
+
39
+ const buf = new Uint8Array(HEADER_BUF_SIZE)
40
+ const view = new DataView(buf.buffer)
41
+ const pair: [Uint8Array, DataView] = [buf, view]
42
+ tls.push(pair)
43
+ return pair
44
+ }
45
+
46
+ return [acquire]
47
+ })()
48
+
49
+ /** Acquire the next scratch header buffer + view from the thread-local pool. */
50
+ export { getHeaderBuf }