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
@@ -1,7 +1,7 @@
1
1
  // src/ingress/handlers.ts — Pre-baked ingress handler functions
2
2
  //
3
3
  // A ready-to-use convenience layer for consuming the optimized ingress pipeline
4
- // (native Ingress.handleRequestFullSync) with zero boilerplate. Any system can
4
+ // (native Ingress.handleRequestPacked) with zero boilerplate. Any system can
5
5
  // wire up ingress in a few lines:
6
6
  //
7
7
  // import {
@@ -27,443 +27,378 @@
27
27
  // This module deliberately keeps the benchmark's wire format ({"ok":false,
28
28
  // "error":{...}} bodies, `ratelimit-*` headers). It does NOT reuse fast.ts's
29
29
  // response builders because those emit a different payload shape.
30
-
31
- import addon from "../native";
30
+ //
31
+ // The pre-baked pipeline is decomposed into task-focused submodules:
32
+ // - decode/baked-result.ts BakedIngressResult (zero-alloc decode)
33
+ // - response/error-bodies.ts pre-encoded error bodies
34
+ // - headers/baked-templates.ts ratelimit-* header templates
35
+ // - packing/gather-raw-headers.ts raw header gathering
36
+ // - routes/* route factories
37
+ // - server.ts Bun.serve builder
38
+
39
+ import { getAddon } from '../native'
40
+ import { getBunFFI } from '../native/ffi'
41
+ import { BufferPool, type PooledBuffer } from '../shared/buffer-pool'
42
+ import { decoder, encoder, viewForArrayBuffer } from '../shared/bytes'
43
+ import { createStructuredLogger } from '../shared/log'
44
+ import { generateRequestId } from '../shared/request-id'
45
+ import { pooledBodyResponse } from '../shared/response'
32
46
  import {
33
- OUT_VERDICT,
34
- OUT_FLAGS,
35
- OUT_RATE_LIMIT,
36
- OUT_RATE_REMAINING,
37
- OUT_RATE_RESET,
38
- OUT_RETRY_AFTER,
39
- OUT_COOKIES_JSON_LEN,
40
- OUT_QUERY_JSON_LEN,
41
- OUT_HEADER_VARIANT,
42
- OUT_BODY_JSON_LEN,
43
- OUT_DATA_START,
44
- FLAG_BODY_VALID_JSON,
45
- FLAG_SCHEMA_VALID,
46
- FLAG_CORS_ALLOWED,
47
- FLAG_IS_PREFLIGHT,
48
- FLAG_RATE_LIMITED,
49
- FLAG_TRUSTED_PROXY,
50
- FLAG_BODY_TRUNCATED,
51
- HV_JSON,
52
- HV_CORS_SIMPLE,
47
+ ERR_CODE_RATE_LIMITED as ERROR_CODE_RATE_LIMITED,
53
48
  HV_CORS_PREFLIGHT,
49
+ HV_CORS_SIMPLE,
50
+ HV_JSON,
54
51
  HV_RATE_ACTIVE,
55
52
  HV_RATE_LIMITED,
56
- ERR_CODE_CORS_PREFLIGHT as ERROR_CODE_CORS_PREFLIGHT,
57
- ERR_CODE_RATE_LIMITED as ERROR_CODE_RATE_LIMITED,
58
- ERR_CODE_BODY_TOO_LARGE as ERROR_CODE_BODY_TOO_LARGE,
59
- ERR_CODE_INVALID_JSON as ERROR_CODE_INVALID_JSON,
60
- ERR_CODE_SCHEMA_VALIDATION as ERROR_CODE_SCHEMA_VALIDATION,
61
- ERR_CODE_BAD_REQUEST as ERROR_CODE_BAD_REQUEST,
62
- ERR_CODE_REQUEST_TOO_LARGE as ERROR_CODE_REQUEST_TOO_LARGE,
63
- ERR_CODE_INTERNAL as ERROR_CODE_INTERNAL,
64
- } from "./constants";
53
+ OUT_DATA_START,
54
+ } from './constants'
55
+ import { BakedIngressResult } from './decode/baked-result'
56
+ import { buildBakedHeaderTemplates } from './headers/baked-templates'
57
+ import type { SecurityHeadersOptions } from './headers/hsts'
58
+ import { buildSecurityPairs } from './headers/hsts'
65
59
  import {
66
- METHOD_KIND,
67
- generateRequestId,
68
- safeTerminalStatus,
69
- DEFAULT_MAX_BODY_BYTES,
60
+ assertIngressOptionValues,
61
+ assertKnownIngressOptions,
62
+ type IngressHandlerOptions,
63
+ warnTrustProxyDeprecated,
64
+ } from './options'
65
+ import { gatherRawHeadersPacked } from './packing/gather-raw-headers'
66
+ import { IngressInputPacker } from './packing/input-packer'
67
+ import { ERROR_BODIES, ERROR_CODE_BODIES, rateLimitedBody } from './response/error-bodies'
68
+ import {
69
+ assertSyncCallback,
70
+ buildHeaderPlan,
71
+ clampIngressBufferSize,
72
+ DEFAULT_BAKED_OUTPUT_BUFFER_SIZE,
70
73
  type HeaderPlan,
71
- } from "./fast";
72
-
73
- const encoder = new TextEncoder();
74
- const decoder = new TextDecoder();
75
- const EMPTY_BODY = new Uint8Array(0);
76
- const EMPTY_IP = "0.0.0.0";
74
+ METHOD_KIND,
75
+ METHOD_KIND_UNKNOWN,
76
+ secondsFromMs,
77
+ } from './shared'
78
+ import { safeTerminalStatus } from './status'
79
+ import type { BakedContext, OptimizedIngressHandler } from './types'
80
+
81
+ // ── Re-exports (back-compat: preserve handlers.ts's original public surface) ──
82
+ export { BakedIngressResult } from './decode/baked-result'
83
+ export type { IngressHandlerOptions } from './options'
84
+ export { ERROR_BODIES } from './response/error-bodies'
85
+ export type { BakedHandlerOptions } from './routes'
86
+ export {
87
+ deleteHandler,
88
+ echoHandler,
89
+ fallbackHandler,
90
+ headHandler,
91
+ jsonWriteHandler,
92
+ nativeResponderRoute,
93
+ optionsHandler,
94
+ readHandler,
95
+ } from './routes'
96
+ export type {
97
+ BakedRoute,
98
+ BakedServer,
99
+ CreateIngressServerOptions,
100
+ GracefulShutdownOptions,
101
+ ServerHandle,
102
+ } from './server'
103
+ export { createIngressServer, gracefulShutdown } from './server'
104
+ export type { RouteHandler as IngressNodeRouteHandler } from './server-node'
105
+ // Node.js HTTP adapter (same pre-baked route handlers, node:http backend).
106
+ export { createIngressServerNode } from './server-node'
107
+
108
+ const EMPTY_BODY = new Uint8Array(0)
109
+ const EMPTY_IP = '0.0.0.0'
77
110
 
78
111
  /** Sentinel value used by Rust to mean "rate limiting disabled". */
79
- export const RATE_LIMIT_U32_MAX = 4_294_967_295;
80
-
81
- const DEFAULT_OUTPUT_BUF_SIZE = 131_072;
82
-
83
- const MAX_COOKIE_HEADER_BYTES = 8192;
84
- const MAX_SMALL_HEADER_BYTES = 2048;
85
- const MAX_XFF_HEADER_BYTES = 8192;
112
+ export const RATE_LIMIT_U32_MAX = 4_294_967_295
86
113
 
87
- // ── Static error bodies ──
88
- function staticErrorBody(code: string, message: string): Uint8Array {
89
- return encoder.encode(
90
- `{"ok":false,"error":{"code":"${code}","message":"${message}"}}`,
91
- );
92
- }
93
-
94
- export const ERROR_BODIES: Record<string, Uint8Array> = {
95
- not_found: staticErrorBody("not_found", "Not found"),
96
- unsupported_media_type: staticErrorBody(
97
- "unsupported_media_type",
98
- "Content-Type must be application/json",
99
- ),
100
- body_too_large: staticErrorBody("body_too_large", "Request body is too large"),
101
- invalid_json: staticErrorBody("invalid_json", "Invalid JSON body"),
102
- schema_validation_failed: staticErrorBody(
103
- "schema_validation_failed",
104
- "Request body failed schema validation",
105
- ),
106
- cors_preflight_not_allowed: staticErrorBody(
107
- "cors_preflight_not_allowed",
108
- "CORS preflight not allowed",
109
- ),
110
- bad_request: staticErrorBody("bad_request", "Bad request"),
111
- request_too_large: staticErrorBody("request_too_large", "Request too large"),
112
- rate_limited: staticErrorBody("rate_limited", "Too Many Requests"),
113
- internal: staticErrorBody("internal_error", "Internal server error"),
114
- };
115
-
116
- const ERROR_CODE_BODIES: (Uint8Array | undefined)[] = [];
117
- ERROR_CODE_BODIES[ERROR_CODE_CORS_PREFLIGHT] =
118
- ERROR_BODIES.cors_preflight_not_allowed;
119
- ERROR_CODE_BODIES[ERROR_CODE_RATE_LIMITED] = ERROR_BODIES.rate_limited;
120
- ERROR_CODE_BODIES[ERROR_CODE_BODY_TOO_LARGE] = ERROR_BODIES.body_too_large;
121
- ERROR_CODE_BODIES[ERROR_CODE_INVALID_JSON] = ERROR_BODIES.invalid_json;
122
- ERROR_CODE_BODIES[ERROR_CODE_SCHEMA_VALIDATION] =
123
- ERROR_BODIES.schema_validation_failed;
124
- ERROR_CODE_BODIES[ERROR_CODE_BAD_REQUEST] = ERROR_BODIES.bad_request;
125
- ERROR_CODE_BODIES[ERROR_CODE_REQUEST_TOO_LARGE] =
126
- ERROR_BODIES.request_too_large;
127
- ERROR_CODE_BODIES[ERROR_CODE_INTERNAL] = ERROR_BODIES.internal;
128
-
129
- const RATE_LIMIT_BODY_PREFIX = encoder.encode(
130
- '{"ok":false,"error":{"code":"rate_limited","message":"Too Many Requests","retry_after_ms":',
131
- );
132
- const RATE_LIMIT_BODY_SUFFIX = encoder.encode("}}");
133
-
134
- function rateLimitedBody(retryAfterMs: number): Uint8Array {
135
- const digits = encoder.encode(String(Math.max(0, Math.floor(retryAfterMs))));
136
- const out = new Uint8Array(
137
- RATE_LIMIT_BODY_PREFIX.byteLength +
138
- digits.byteLength +
139
- RATE_LIMIT_BODY_SUFFIX.byteLength,
140
- );
141
- out.set(RATE_LIMIT_BODY_PREFIX, 0);
142
- out.set(digits, RATE_LIMIT_BODY_PREFIX.byteLength);
143
- out.set(
144
- RATE_LIMIT_BODY_SUFFIX,
145
- RATE_LIMIT_BODY_PREFIX.byteLength + digits.byteLength,
146
- );
147
- return out;
148
- }
149
-
150
- // ── Zero-alloc result wrapper ──
151
- export class BakedIngressResult {
152
- status = 0;
153
- verdict = 0;
154
- errorCode = 0;
155
- headerVariant = 0;
156
- rateRemaining = 0;
157
- rateResetMs = 0;
158
- retryAfterMs = 0;
159
- terminal = true;
160
- ok = false;
161
- isPreflight = false;
162
- corsAllowed = false;
163
- rateLimited = false;
164
- trustedProxy = false;
165
- bodyValidJson = false;
166
- schemaValid = false;
167
- bodyTruncated = false;
168
- body: Uint8Array = EMPTY_BODY;
169
-
170
- private _buf: Uint8Array = EMPTY_BODY;
171
- private _bodyJsonStart = OUT_DATA_START;
172
- private _bodyJsonLen = 0;
173
-
174
- refresh(buf: Uint8Array, body: Uint8Array, view: DataView): void {
175
- this._buf = buf;
176
- this.body = body;
177
-
178
- const h0 = view.getUint32(OUT_VERDICT, true);
179
- const h1 = view.getUint32(OUT_FLAGS, true);
180
- const h2 = view.getUint32(OUT_RATE_LIMIT, true);
181
- const h3 = view.getUint32(OUT_RATE_REMAINING, true);
182
-
183
- const cookiesLenRaw = view.getUint32(OUT_COOKIES_JSON_LEN, true);
184
- const queryLenRaw = view.getUint32(OUT_QUERY_JSON_LEN, true);
185
- const headerVariant = view.getUint8(OUT_HEADER_VARIANT);
186
- const bodyJsonLenRaw = view.getUint32(OUT_BODY_JSON_LEN, true);
187
-
188
- const safeCookiesLen =
189
- OUT_DATA_START + cookiesLenRaw <= buf.byteLength ? cookiesLenRaw : 0;
190
- const queryStart = OUT_DATA_START + safeCookiesLen;
191
- const safeQueryLen =
192
- queryStart + queryLenRaw <= buf.byteLength ? queryLenRaw : 0;
193
- const bodyJsonStart = queryStart + safeQueryLen;
194
- const safeBodyJsonLen =
195
- bodyJsonStart + bodyJsonLenRaw <= buf.byteLength ? bodyJsonLenRaw : 0;
196
-
197
- if (h0 === 0 && h1 === 0) {
198
- this.verdict = 1;
199
- this.errorCode = ERROR_CODE_INTERNAL;
200
- this.status = 500;
201
- } else {
202
- this.verdict = h0 & 0xff;
203
- this.errorCode = (h0 >>> 8) & 0xff;
204
-
205
- const rawStatus = (h0 >>> 16) & 0xffff;
206
- const validStatus =
207
- rawStatus === 101 || (rawStatus >= 200 && rawStatus <= 599);
208
- this.status = validStatus ? rawStatus : 500;
209
- }
210
-
211
- const flags = h1;
212
-
213
- this.rateRemaining = h3;
214
-
215
- if (h2 > 0 || (flags & FLAG_RATE_LIMITED) !== 0) {
216
- this.rateResetMs = Number(view.getBigUint64(OUT_RATE_RESET, true));
217
- this.retryAfterMs = Number(view.getBigUint64(OUT_RETRY_AFTER, true));
218
- } else {
219
- this.rateResetMs = 0;
220
- this.retryAfterMs = 0;
221
- }
222
-
223
- this.headerVariant = headerVariant;
224
- this._bodyJsonStart = bodyJsonStart;
225
- this._bodyJsonLen = safeBodyJsonLen;
226
-
227
- this.terminal = this.verdict !== 0 || this.status >= 400;
228
- this.ok = this.verdict === 0 && this.status >= 200 && this.status < 400;
229
-
230
- this.isPreflight = (flags & FLAG_IS_PREFLIGHT) !== 0;
231
- this.corsAllowed = (flags & FLAG_CORS_ALLOWED) !== 0;
232
- this.rateLimited = (flags & FLAG_RATE_LIMITED) !== 0;
233
- this.trustedProxy = (flags & FLAG_TRUSTED_PROXY) !== 0;
234
- this.bodyValidJson = (flags & FLAG_BODY_VALID_JSON) !== 0;
235
- this.schemaValid = (flags & FLAG_SCHEMA_VALID) !== 0;
236
-
237
- this.bodyTruncated =
238
- (flags & FLAG_BODY_TRUNCATED) !== 0 ||
239
- safeCookiesLen !== cookiesLenRaw ||
240
- safeQueryLen !== queryLenRaw ||
241
- safeBodyJsonLen !== bodyJsonLenRaw;
242
- }
243
-
244
- invalidate(): void {
245
- this.status = 0;
246
- this.verdict = 0;
247
- this.errorCode = 0;
248
- this.headerVariant = 0;
249
- this.rateRemaining = 0;
250
- this.rateResetMs = 0;
251
- this.retryAfterMs = 0;
252
- this.terminal = true;
253
- this.ok = false;
254
- this.isPreflight = false;
255
- this.corsAllowed = false;
256
- this.rateLimited = false;
257
- this.trustedProxy = false;
258
- this.bodyValidJson = false;
259
- this.schemaValid = false;
260
- this.bodyTruncated = false;
261
- this.body = EMPTY_BODY;
262
- this._buf = EMPTY_BODY;
263
- this._bodyJsonStart = OUT_DATA_START;
264
- this._bodyJsonLen = 0;
265
- }
114
+ /**
115
+ * Consecutive `bun:ffi` ingress failures before this handler permanently
116
+ * falls back to the napi addon (a panic that `panic_guard` contained, or a
117
+ * bun:ffi regression that throws). Low enough to recover fast from a flaky
118
+ * transport, high enough that a single transient failure never flips it.
119
+ */
120
+ const MAX_FFI_FAILURES = 3
266
121
 
267
- setInternalError(): void {
268
- this.invalidate();
269
- this.status = 500;
270
- this.verdict = 1;
271
- this.errorCode = ERROR_CODE_INTERNAL;
272
- this.headerVariant = HV_JSON;
273
- this.terminal = true;
274
- this.ok = false;
122
+ /**
123
+ * Best-effort URL pathname extraction for log/error lines.
124
+ *
125
+ * This runs inside catch/finally paths, so a malformed `req.url` must never
126
+ * throw out of the handler (an uncaught error here would bypass `onError`).
127
+ * Falls back to the raw URL string when parsing fails.
128
+ */
129
+ function pathForLog(req: Request): string {
130
+ try {
131
+ return new URL(req.url).pathname
132
+ } catch {
133
+ return req.url
275
134
  }
135
+ }
276
136
 
277
- bodyJson(copy: boolean): Uint8Array {
278
- if (this._bodyJsonLen === 0) {
279
- return EMPTY_BODY;
280
- }
281
-
282
- const end = this._bodyJsonStart + this._bodyJsonLen;
283
- if (end > this._buf.byteLength) {
284
- return EMPTY_BODY;
285
- }
286
-
287
- const slice = this._buf.subarray(this._bodyJsonStart, end);
288
- return (copy ? slice.slice() : slice) as Uint8Array;
289
- }
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()]
290
161
  }
291
162
 
292
163
  // ── Types ────────────────────────────────────────────────────────
293
- export interface BakedContext {
294
- requestIdHeader: string | null;
295
- origin: string | null;
296
- }
164
+ // `BakedContext` and `OptimizedIngressHandler` are defined in ./types.ts
165
+ // (shared with the route factories) and re-exported here for back-compat.
166
+ export type { BakedContext, OptimizedIngressHandler } from './types'
297
167
 
168
+ /** Runtime configuration for the pre-baked handler path (`createIngressHandler`). */
298
169
  export interface BakedIngressRuntime {
299
170
  /** Emit an `x-request-id` header on responses. Default: false. */
300
- emitRequestIdHeader?: boolean;
171
+ emitRequestIdHeader?: boolean
301
172
  /** Enable security headers on responses. Default: true. */
302
- enableSecurityHeaders?: boolean;
303
- /** Ordered `[name, value]` security headers (names are lowercased). */
304
- securityHeaders?: ReadonlyArray<[string, string]>;
173
+ enableSecurityHeaders?: boolean
174
+ /**
175
+ * Ordered `[name, value]` security headers (names are lowercased).
176
+ * NOTE: this PRE-BAKED path takes RAW `[name, value]` pairs here; the
177
+ * fast/async paths (`createIngressFast`/`createIngress`) instead take a
178
+ * structured `security: SecurityHeadersOptions` object (CSP/HSTS builders).
179
+ * The two paths intentionally use different option shapes (see
180
+ * docs/INGRESS.md).
181
+ */
182
+ securityHeaders?: ReadonlyArray<[string, string]>
305
183
  /** Native output buffer size in bytes. Default: 131072. */
306
- outputBufferSize?: number;
184
+ outputBufferSize?: number
185
+ /** Invoked before a request is processed (for tracing/context hooks). */
186
+ onRequest?: (req: Request, requestId: string, ip: string | undefined) => void
307
187
  /** Invoked after a Response is produced (for metrics/logging hooks). */
308
- onResponse?: (
309
- req: Request,
310
- result: BakedIngressResult,
311
- status: number,
312
- requestId: string,
313
- ) => void;
188
+ onResponse?: (req: Request, result: BakedIngressResult, status: number, requestId: string) => void
189
+ /**
190
+ * Invoked when the native pipeline fails (the request becomes a 500).
191
+ * Native failures are otherwise silent — wire this to your error tracker.
192
+ */
193
+ onError?: (req: Request, requestId: string, error: Error) => void
194
+ /**
195
+ * Emit one structured JSON line per request/error via the built-in logger
196
+ * (gated by `CASTRUM_LOG_LEVEL`). Default: false.
197
+ */
198
+ structuredLog?: boolean
199
+ /**
200
+ * Custom structured logger (e.g. with a custom stream or extra fields).
201
+ * When provided, request/error lines go here instead of the built-in stderr
202
+ * logger and `structuredLog` is implicitly enabled.
203
+ */
204
+ logger?: ReturnType<typeof createStructuredLogger>
205
+ /**
206
+ * Hard cap on concurrently borrowed output buffers (zero-copy responses not
207
+ * yet consumed). Bounds memory when zero-copy responses are consumed
208
+ * slowly; when exceeded, requests fail with a 500 (pool exhaustion) instead
209
+ * of allocating unbounded temporaries. 0 (default) is unlimited — see
210
+ * `BufferPool.maxInFlight`.
211
+ */
212
+ maxInFlight?: number
213
+ /**
214
+ * Abandonment guard (ms) for zero-copy responses: if a response body is
215
+ * neither pulled nor cancelled within this window, its pooled buffer is
216
+ * released and the stream closed. Bounds memory under abandoned (unread)
217
+ * zero-copy responses. 0 (default) disables it — see `pooledBodyResponse`.
218
+ */
219
+ zeroCopyTimeoutMs?: number
314
220
  }
315
221
 
222
+ // ── Optimized ingress factory ─────────────────────────────────────
223
+
316
224
  /**
317
- * An optimized ingress handler. `run()` is the zero-alloc pipeline entry
318
- * point; the response-builder methods are pre-baked and bound to this
319
- * handler's configuration, so consumers can build custom routes without
320
- * touching header templates or error bodies.
225
+ * Create a pre-baked ingress handler (path 2).
226
+ *
227
+ * The request frame (url/ip/rid/headers) is packed in JS via `IngressInputPacker`
228
+ * + `gatherRawHeadersPacked` and driven through the same native core as the
229
+ * fast path (`Ingress.handleRequestPacked`), writing into a pooled output
230
+ * buffer (no per-request allocation). Responses use the benchmark wire format
231
+ * (`{"ok":false,"error":{...}}` + `ratelimit-*` headers) — see AGENTS.md for
232
+ * why this differs from `createIngressFast`.
233
+ *
234
+ * Returns an `OptimizedIngressHandler` whose response-builder methods are
235
+ * bound to this handler's configuration. Pair it with the route factories
236
+ * (`readHandler`, `jsonWriteHandler`, ...) or `createIngressServer`.
321
237
  */
322
- export interface OptimizedIngressHandler {
323
- run<T>(
324
- req: Request,
325
- ip: string | undefined,
326
- body: Uint8Array | null,
327
- fn: (result: BakedIngressResult, ctx: BakedContext) => T,
328
- ): T;
329
-
330
- responseHeaders(
331
- variant: number,
332
- requestIdHeader: string | null,
333
- origin: string | null,
334
- rateRemaining?: number,
335
- rateResetSecs?: number,
336
- retryAfterSecs?: number,
337
- ): [string, string][];
338
-
339
- terminalHeaders(
340
- variant: number,
341
- ctx: BakedContext,
342
- result: BakedIngressResult | null,
343
- ): [string, string][];
344
-
345
- terminalResponse(
346
- req: Request,
347
- result: BakedIngressResult,
348
- ctx: BakedContext,
349
- ): Response | null;
350
-
351
- errorResponse(
352
- req: Request,
353
- result: BakedIngressResult | null,
354
- status: number,
355
- code: string,
356
- message: string,
357
- ctx: BakedContext,
358
- ): Response;
359
-
360
- internalErrorResponse(ctx: BakedContext, result?: BakedIngressResult): Response;
361
-
362
- withContentType(
363
- headers: ReadonlyArray<[string, string]>,
364
- contentType: string,
365
- ): [string, string][];
366
- }
367
-
368
- // ── Optimized ingress factory ─────────────────────────────────────
369
238
  export function createIngressHandler(
370
- options: Record<string, unknown>,
239
+ options: IngressHandlerOptions = {},
371
240
  runtime: BakedIngressRuntime = {},
372
241
  ): OptimizedIngressHandler {
373
- const NativeIngress = (addon as any).Ingress;
374
- if (typeof NativeIngress !== "function") {
375
- throw new Error("Native Ingress class missing. Rebuild the Rust addon.");
242
+ // Fail fast on a misspelled option key — same guard as the fast path.
243
+ assertKnownIngressOptions(options, 'createIngressHandler')
244
+ assertIngressOptionValues(options, 'createIngressHandler')
245
+ if (options.trustProxy === true) {
246
+ warnTrustProxyDeprecated()
376
247
  }
377
-
378
- const handler = new NativeIngress(options);
379
- if (typeof handler.handleRequestFullSync !== "function") {
380
- throw new Error(
381
- "Native Ingress.handleRequestFullSync missing. Rebuild the Rust addon.",
382
- );
248
+ // Lazy: the native addon is only needed once a handler is created.
249
+ const addon = getAddon()
250
+ const NativeIngress = addon.Ingress
251
+ if (typeof NativeIngress !== 'function') {
252
+ throw new Error('Native Ingress class missing. Rebuild the Rust addon.')
383
253
  }
384
254
 
385
- const trust = options.trustProxy === true;
386
- const rateLimit = options.rateLimit as { limit?: number } | undefined;
387
- const limit = rateLimit?.limit;
388
- const rateEnabled =
389
- typeof limit === "number" && limit !== RATE_LIMIT_U32_MAX && limit > 0;
390
-
391
- const headerPlan: HeaderPlan = {
392
- cookie: options.parseCookies === true,
393
- cors: options.cors != null,
394
- proxy: trust && rateEnabled,
395
- proto: trust && options.https === undefined,
396
- };
255
+ const handler = new NativeIngress(options)
256
+ if (typeof handler.handleRequestPacked !== 'function') {
257
+ throw new Error('Native Ingress.handleRequestPacked missing. Rebuild the Rust addon.')
258
+ }
397
259
 
398
- const emitRequestIdHeader = runtime.emitRequestIdHeader === true;
399
- const outputBufferSize = runtime.outputBufferSize ?? DEFAULT_OUTPUT_BUF_SIZE;
260
+ // Bun fast path: drive the SAME native pipeline through `bun:ffi` (opaque
261
+ // inner handle from `ingressInnerPtr`), cutting the per-request N-API
262
+ // crossing. The handle is valid only while `handler` is alive — this closure
263
+ // holds `handler` for the handler's lifetime, so it can never dangle. Falls
264
+ // back to napi when ffi is unavailable, the addon lacks the pointer method,
265
+ // or the handle is 0 (i.e. the state was dropped).
266
+ const bunFFI = getBunFFI()
267
+ // The opaque handle is a u64 under the 2^53 number range, so convert it to a
268
+ // plain JS number ONCE here — bun:ffi converts a BigInt argument to a number
269
+ // on every call anyway (and docs note BigInt is slower), so passing a number
270
+ // removes that per-request conversion on the hottest path.
271
+ const ingressPtr =
272
+ bunFFI !== null && typeof handler.ingressInnerPtr === 'function'
273
+ ? Number(handler.ingressInnerPtr())
274
+ : 0
275
+
276
+ const rateLimit = options.rateLimit as { limit?: number } | undefined
277
+ const limit = rateLimit?.limit
278
+ const rateEnabled = typeof limit === 'number' && limit !== RATE_LIMIT_U32_MAX && limit > 0
279
+
280
+ // Minimal-processing flags: skip per-request JS UTF-8 encoding of the URL/IP
281
+ // when the native pipeline (rust/ingress/pipeline.rs) provably cannot read
282
+ // them — mirror its consumption exactly, and KEEP IN SYNC:
283
+ // - URL is consumed iff parseQuery (extract_query), emitMetadataJson
284
+ // (extract_path into the envelope) or https is unpinned (detect_https
285
+ // scans the scheme prefix). Otherwise an empty URL section is safe.
286
+ // - IP is consumed iff rate limiting is active (resolve_client_ip /
287
+ // rate_key) or a proxy-trust mode is configured (socket_is_trusted
288
+ // parses it). Otherwise the IP is dropped from the frame (logs/hooks
289
+ // still see the real `ip` — this only skips the frame encode).
290
+ const trustEnabled = options.trustProxy === true || options.trustedProxies?.enabled === true
291
+ const urlNeeded =
292
+ options.parseQuery === true || options.emitMetadataJson === true || options.https === undefined
293
+ const ipNeeded = rateEnabled || trustEnabled
294
+
295
+ // Shared with the fast path — proxy extraction is driven by trust config
296
+ // (trustProxy / trustedProxies), not by whether rate limiting is enabled.
297
+ const headerPlan: HeaderPlan = buildHeaderPlan(options)
298
+
299
+ const emitRequestIdHeader = runtime.emitRequestIdHeader === true
300
+ const zeroCopyTimeoutMs = runtime.zeroCopyTimeoutMs ?? 0
301
+ const outputBufferSize = clampIngressBufferSize(
302
+ runtime.outputBufferSize ?? DEFAULT_BAKED_OUTPUT_BUFFER_SIZE,
303
+ OUT_DATA_START,
304
+ )
305
+
306
+ // Built-in structured logger (opt-in; gated by CASTRUM_LOG_LEVEL). A custom
307
+ // `runtime.logger` overrides it and implies structuredLog:true.
308
+ const logger =
309
+ runtime.logger ?? (runtime.structuredLog === true ? createStructuredLogger() : null)
310
+
311
+ // Only materialize the request-id string when a consumer needs it, avoiding
312
+ // one string allocation + UTF-8 decode per request in deployments with no
313
+ // hooks/logging. The bench wires `onResponse`, so the decode stays hot there;
314
+ // lean servers with no hooks skip it entirely.
315
+ const needRequestIdString = !!(
316
+ emitRequestIdHeader ||
317
+ runtime.onRequest ||
318
+ runtime.onError ||
319
+ runtime.onResponse ||
320
+ logger
321
+ )
322
+
323
+ // Reusable output-buffer pool: eliminates the per-request output-buffer
324
+ // allocation by reusing buffers across requests.
325
+ // `maxInFlight` (when set) bounds zero-copy borrowing under slow consumers.
326
+ const outputPool = new BufferPool({
327
+ initialSize: outputBufferSize,
328
+ maxInFlight: runtime.maxInFlight,
329
+ })
330
+
331
+ // Reusable packed-input builder (same zero-alloc discipline as the fast
332
+ // path): the per-request frame is packed into this buffer instead of paying
333
+ // the napi `Vec<Vec<String>>` header marshaling of the full_sync family.
334
+ const inputPacker = new IngressInputPacker()
335
+
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
341
+
342
+ // FFI circuit breaker (defense-in-depth): the raw C ABI cannot contain
343
+ // non-panic UB (a segfault still crashes the process), but a RECURRING
344
+ // observable failure — a panic that `panic_guard` turned into 0 → throw, or
345
+ // a bun:ffi regression that throws — is caught here and permanently
346
+ // re-routed to the napi fallback (crash-safe via napi-rs catch_unwind), so a
347
+ // flaky FFI path degrades to a correct response instead of a failure storm.
348
+ let ffiFailures = 0
349
+ let ffiDisabled = false
350
+
351
+ // Cache Origin-augmented header arrays keyed by `variant\u0000origin` (the
352
+ // steady-state CORS case: the pre-baked template + one
353
+ // `access-control-allow-origin` row). Callers treat the returned array as
354
+ // read-only (the `Headers`/`Response` constructors copy entries), so every
355
+ // distinct (variant, origin) is reused after its first hit — removing the
356
+ // per-request array alloc + copy when an Origin header is present. Only
357
+ // ALLOWED origins reach the CORS-allowed path (the native pipeline gates the
358
+ // variant on CORS approval), so the map stays bounded by the configured
359
+ // allow-origin list (+ a couple of preflight variants).
360
+ const originHeaderCache = new Map<string, [string, string][]>()
400
361
 
401
362
  // ── Variant-indexed header templates (precomputed once) ──
402
363
  const cors = options.cors as
403
364
  | {
404
- allowOrigin?: string[];
405
- allowMethods?: string[];
406
- allowHeaders?: string[];
407
- exposeHeaders?: string[];
408
- allowCredentials?: boolean;
409
- maxAge?: number;
365
+ allowOrigin?: string[]
366
+ allowMethods?: string[]
367
+ allowHeaders?: string[]
368
+ exposeHeaders?: string[]
369
+ allowCredentials?: boolean
370
+ maxAge?: number
410
371
  }
411
- | undefined;
412
-
372
+ | undefined
373
+
374
+ // Security headers: honor the STRUCTURED `options.security`
375
+ // (SecurityHeadersOptions — the same shape `createIngress` honors) merged
376
+ // with raw `runtime.securityHeaders` pairs (raw wins on name conflicts).
377
+ // This closes the historic gotcha where `options.security` was silently
378
+ // ignored on the pre-baked path. When neither is provided, no security
379
+ // headers are emitted (the baked path's long-standing default).
413
380
  const securityEntries: ReadonlyArray<[string, string]> =
414
381
  runtime.enableSecurityHeaders === false
415
382
  ? Object.freeze([] as [string, string][])
416
383
  : Object.freeze(
417
- (runtime.securityHeaders ?? []).map(
418
- ([k, v]) => [k.toLowerCase(), v] as [string, string],
419
- ),
420
- );
421
-
422
- const corsAllowMethods = cors?.allowMethods?.join(", ") ?? "";
423
- const corsAllowHeaders = cors?.allowHeaders?.join(", ") ?? "";
424
- const corsExposeHeaders = cors?.exposeHeaders?.join(", ") ?? "";
425
- const corsMaxAge = cors?.maxAge != null ? String(cors.maxAge) : "";
426
- const rateLimitStr = rateEnabled ? String(limit) : "";
427
-
428
- const headerTemplates: ReadonlyArray<ReadonlyArray<[string, string]>> =
429
- Object.freeze(
430
- Array.from({ length: 32 }, (_, variant) => {
431
- const entries: [string, string][] = [...securityEntries];
432
-
433
- if ((variant & HV_JSON) !== 0) {
434
- entries.push(["content-type", "application/json"]);
435
- }
436
-
437
- if ((variant & HV_CORS_SIMPLE) !== 0) {
438
- entries.push(["vary", "Origin"]);
439
- if (cors?.allowCredentials) {
440
- entries.push(["access-control-allow-credentials", "true"]);
441
- }
442
- if (corsExposeHeaders.length > 0) {
443
- entries.push(["access-control-expose-headers", corsExposeHeaders]);
444
- }
445
- }
446
-
447
- if ((variant & HV_CORS_PREFLIGHT) !== 0) {
448
- entries.push([
449
- "vary",
450
- "Origin, Access-Control-Request-Method, Access-Control-Request-Headers",
451
- ]);
452
- if (cors?.allowCredentials) {
453
- entries.push(["access-control-allow-credentials", "true"]);
454
- }
455
- entries.push(["access-control-allow-methods", corsAllowMethods]);
456
- entries.push(["access-control-allow-headers", corsAllowHeaders]);
457
- entries.push(["access-control-max-age", corsMaxAge]);
458
- }
459
-
460
- if ((variant & HV_RATE_ACTIVE) !== 0) {
461
- entries.push(["ratelimit-limit", rateLimitStr]);
462
- }
463
-
464
- return Object.freeze(entries);
465
- }),
466
- );
384
+ buildBakedSecurityEntries(options.security, options.https, runtime.securityHeaders),
385
+ )
386
+
387
+ const corsAllowMethods = cors?.allowMethods?.join(', ') ?? ''
388
+ const corsAllowHeaders = cors?.allowHeaders?.join(', ') ?? ''
389
+ const corsExposeHeaders = cors?.exposeHeaders?.join(', ') ?? ''
390
+ const corsMaxAge = cors?.maxAge != null ? String(cors.maxAge) : ''
391
+ const rateLimitStr = rateEnabled ? String(limit) : ''
392
+
393
+ const { regular: headerTemplates, terminal: terminalTemplates } = buildBakedHeaderTemplates({
394
+ securityEntries,
395
+ cors,
396
+ corsAllowMethods,
397
+ corsAllowHeaders,
398
+ corsExposeHeaders,
399
+ corsMaxAge,
400
+ rateLimitStr,
401
+ })
467
402
 
468
403
  function responseHeaders(
469
404
  variant: number,
@@ -474,51 +409,72 @@ export function createIngressHandler(
474
409
  retryAfterSecs?: number,
475
410
  ): [string, string][] {
476
411
  const template: ReadonlyArray<[string, string]> =
477
- headerTemplates[variant & 31] ?? headerTemplates[0] ?? [];
412
+ headerTemplates[variant & 31] ?? headerTemplates[0] ?? []
478
413
 
479
- const needsRequestId = emitRequestIdHeader && requestIdHeader !== null;
414
+ const needsRequestId = emitRequestIdHeader && requestIdHeader !== null
480
415
  const needsOrigin =
481
- ((variant & HV_CORS_SIMPLE) !== 0 ||
482
- (variant & HV_CORS_PREFLIGHT) !== 0) &&
483
- origin !== null;
484
- const needsRate = (variant & HV_RATE_ACTIVE) !== 0;
485
- const needsRetry = (variant & HV_RATE_LIMITED) !== 0;
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
486
419
 
487
420
  if (!needsRequestId && !needsOrigin && !needsRate && !needsRetry) {
488
- return template as unknown as [string, string][];
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
489
445
  }
490
446
 
491
- let extra = 0;
492
- if (needsRequestId) extra++;
493
- if (needsOrigin) extra++;
494
- if (needsRate) extra += 2;
495
- if (needsRetry) extra++;
447
+ let extra = 0
448
+ if (needsRequestId) extra++
449
+ if (needsOrigin) extra++
450
+ if (needsRate) extra += 2
451
+ if (needsRetry) extra++
496
452
 
497
- const entries = new Array<[string, string]>(template.length + extra);
498
- let i = 0;
453
+ const entries = new Array<[string, string]>(template.length + extra)
454
+ let i = 0
499
455
 
500
- for (; i < template.length; i++) {
501
- entries[i] = template[i]!;
456
+ for (const pair of template) {
457
+ entries[i++] = pair
502
458
  }
503
459
 
504
460
  if (needsRequestId) {
505
- entries[i++] = ["x-request-id", requestIdHeader as string];
461
+ entries[i++] = ['x-request-id', requestIdHeader as string]
506
462
  }
507
463
 
508
464
  if (needsOrigin) {
509
- entries[i++] = ["access-control-allow-origin", origin as string];
465
+ entries[i++] = ['access-control-allow-origin', origin as string]
510
466
  }
511
467
 
512
468
  if (needsRate) {
513
- entries[i++] = ["ratelimit-remaining", String(rateRemaining ?? 0)];
514
- entries[i++] = ["ratelimit-reset", String(rateResetSecs ?? 0)];
469
+ entries[i++] = ['ratelimit-remaining', String(rateRemaining ?? 0)]
470
+ entries[i++] = ['ratelimit-reset', String(rateResetSecs ?? 0)]
515
471
  }
516
472
 
517
473
  if (needsRetry) {
518
- entries[i++] = ["retry-after", String(retryAfterSecs ?? 0)];
474
+ entries[i++] = ['retry-after', String(retryAfterSecs ?? 0)]
519
475
  }
520
476
 
521
- return entries;
477
+ return entries
522
478
  }
523
479
 
524
480
  function terminalHeaders(
@@ -526,37 +482,57 @@ export function createIngressHandler(
526
482
  ctx: BakedContext,
527
483
  result: BakedIngressResult | null,
528
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`.
529
508
  const base = responseHeaders(
530
- variant | HV_JSON,
531
- ctx.requestIdHeader,
532
- ctx.origin,
509
+ v,
510
+ requestIdHeader,
511
+ origin,
533
512
  result?.rateRemaining,
534
- result && result.rateResetMs > 0
535
- ? Math.ceil(result.rateResetMs / 1000)
536
- : undefined,
537
- result && result.retryAfterMs > 0
538
- ? Math.ceil(result.retryAfterMs / 1000)
539
- : undefined,
540
- );
541
-
542
- const out = new Array<[string, string]>(base.length + 1);
543
- for (let i = 0; i < base.length; i++) {
544
- out[i] = base[i]!;
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
545
521
  }
546
- out[base.length] = ["cache-control", "no-store"];
547
- return out;
522
+ out[base.length] = ['cache-control', 'no-store']
523
+ return out
548
524
  }
549
525
 
550
526
  function terminalResponse(
551
- _req: Request,
527
+ _req: Request | undefined,
552
528
  result: BakedIngressResult,
553
529
  ctx: BakedContext,
554
530
  ): Response | null {
555
531
  if (!result.terminal) {
556
- return null;
532
+ return null
557
533
  }
558
534
 
559
- const preflightAllowed = result.isPreflight && result.corsAllowed;
535
+ const preflightAllowed = result.isPreflight && result.corsAllowed
560
536
 
561
537
  if (preflightAllowed) {
562
538
  return new Response(null, {
@@ -566,31 +542,27 @@ export function createIngressHandler(
566
542
  ctx.requestIdHeader,
567
543
  ctx.origin,
568
544
  result.rateRemaining,
569
- result.rateResetMs > 0
570
- ? Math.ceil(result.rateResetMs / 1000)
571
- : undefined,
572
- result.retryAfterMs > 0
573
- ? Math.ceil(result.retryAfterMs / 1000)
574
- : undefined,
545
+ result.rateResetMs > 0 ? secondsFromMs(result.rateResetMs) : undefined,
546
+ result.retryAfterMs > 0 ? secondsFromMs(result.retryAfterMs) : undefined,
575
547
  ),
576
- });
548
+ })
577
549
  }
578
550
 
579
- const status = safeTerminalStatus(result);
551
+ const status = safeTerminalStatus(result)
580
552
 
581
553
  const body: Uint8Array =
582
554
  result.errorCode === ERROR_CODE_RATE_LIMITED
583
555
  ? rateLimitedBody(result.retryAfterMs)
584
- : (ERROR_CODE_BODIES[result.errorCode] ?? ERROR_BODIES.internal!);
556
+ : (ERROR_CODE_BODIES[result.errorCode] ?? ERROR_BODIES.internal ?? EMPTY_BODY)
585
557
 
586
558
  return new Response(body, {
587
559
  status,
588
560
  headers: terminalHeaders(result.headerVariant, ctx, result),
589
- });
561
+ })
590
562
  }
591
563
 
592
564
  function errorResponse(
593
- _req: Request,
565
+ _req: Request | undefined,
594
566
  result: BakedIngressResult | null,
595
567
  status: number,
596
568
  code: string,
@@ -598,46 +570,58 @@ export function createIngressHandler(
598
570
  ctx: BakedContext,
599
571
  ): Response {
600
572
  const body =
601
- ERROR_BODIES[code] ??
602
- encoder.encode(JSON.stringify({ ok: false, error: { code, message } }));
573
+ ERROR_BODIES[code] ?? encoder.encode(JSON.stringify({ ok: false, error: { code, message } }))
603
574
 
604
575
  return new Response(body, {
605
576
  status,
606
577
  headers: terminalHeaders(result?.headerVariant ?? HV_JSON, ctx, result),
607
- });
578
+ })
608
579
  }
609
580
 
610
- function internalErrorResponse(
611
- ctx: BakedContext,
612
- result?: BakedIngressResult,
613
- ): Response {
581
+ function internalErrorResponse(ctx: BakedContext, result?: BakedIngressResult): Response {
614
582
  return new Response(ERROR_BODIES.internal, {
615
583
  status: 500,
616
- headers: terminalHeaders(
617
- result?.headerVariant ?? HV_JSON,
618
- ctx,
619
- result ?? null,
620
- ),
621
- });
584
+ headers: terminalHeaders(result?.headerVariant ?? HV_JSON, ctx, result ?? null),
585
+ })
622
586
  }
623
587
 
624
588
  function withContentType(
625
589
  headers: ReadonlyArray<[string, string]>,
626
590
  contentType: string,
627
591
  ): [string, string][] {
628
- const out = new Array<[string, string]>(headers.length + 1);
629
- for (let i = 0; i < headers.length; i++) {
630
- out[i] = headers[i]!;
592
+ const out = new Array<[string, string]>(headers.length + 1)
593
+ let i = 0
594
+ for (const pair of headers) {
595
+ out[i++] = pair
631
596
  }
632
- out[headers.length] = ["content-type", contentType];
633
- return out;
597
+ out[headers.length] = ['content-type', contentType]
598
+ return out
634
599
  }
635
600
 
636
- const result = new BakedIngressResult();
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
+ }
619
+
620
+ const result = new BakedIngressResult()
637
621
  const ctx: BakedContext = {
638
622
  requestIdHeader: null,
639
623
  origin: null,
640
- };
624
+ }
641
625
 
642
626
  function run<T>(
643
627
  req: Request,
@@ -645,98 +629,201 @@ export function createIngressHandler(
645
629
  body: Uint8Array | null,
646
630
  fn: (result: BakedIngressResult, ctx: BakedContext) => T,
647
631
  ): T {
648
- const methodKind = METHOD_KIND[req.method] ?? 7;
649
- const ridBytes = generateRequestId();
650
- const requestIdStr = decoder.decode(ridBytes);
651
-
652
- ctx.requestIdHeader = emitRequestIdHeader ? requestIdStr : null;
653
- ctx.origin = headerPlan.cors ? req.headers.get("origin") : null;
654
-
655
- // Gather raw headers as [name, value][] — no binary packing in JS.
656
- // handleRequestFullSync packs them internally in Rust synchronously,
657
- // eliminating both JS-side encoding and async overhead.
658
- const headers: [string, string][] = [];
659
- const h = req.headers;
660
-
661
- if (headerPlan.cookie) {
662
- const v = h.get("cookie");
663
- if (v !== null && v.length <= MAX_COOKIE_HEADER_BYTES) {
664
- headers.push(["cookie", v]);
665
- }
666
- }
632
+ const startedAt = logger ? performance.now() : 0
633
+ const methodKind = METHOD_KIND[req.method] ?? METHOD_KIND_UNKNOWN
634
+ const ridBytes = generateRequestId()
635
+ const requestIdStr = needRequestIdString ? decoder.decode(ridBytes) : ''
667
636
 
668
- if (headerPlan.cors) {
669
- const originV = h.get("origin");
670
- if (originV !== null && originV.length <= MAX_SMALL_HEADER_BYTES) {
671
- headers.push(["origin", originV]);
672
- }
637
+ ctx.requestIdHeader = emitRequestIdHeader ? requestIdStr : null
638
+ ctx.origin = headerPlan.cors ? req.headers.get('origin') : null
673
639
 
674
- if (methodKind === 6) {
675
- const acrm = h.get("access-control-request-method");
676
- if (acrm !== null && acrm.length <= MAX_SMALL_HEADER_BYTES) {
677
- headers.push(["access-control-request-method", acrm]);
678
- }
640
+ runtime.onRequest?.(req, requestIdStr, ip)
679
641
 
680
- const acrh = h.get("access-control-request-headers");
681
- if (acrh !== null && acrh.length <= MAX_SMALL_HEADER_BYTES) {
682
- headers.push(["access-control-request-headers", acrh]);
683
- }
684
- }
685
- }
642
+ const ipStr = ip ?? EMPTY_IP
686
643
 
687
- if (headerPlan.proxy) {
688
- const xff = h.get("x-forwarded-for");
689
- if (xff !== null && xff.length <= MAX_XFF_HEADER_BYTES) {
690
- headers.push(["x-forwarded-for", xff]);
691
- }
644
+ // Pack the request frame in JS (reusing the fast path's zero-alloc packer
645
+ // discipline) and drive the same native core as `handleRequestPacked`.
646
+ // This removes the per-request `[name, value][]` header marshaling + napi
647
+ // `Vec<Vec<String>>` and the request-id string re-encode that the
648
+ // full_sync family paid on every request. The response wire format is
649
+ // unchanged because both entries run the identical `handle_packed` core.
650
+ let handle: PooledBuffer | null = null
651
+ currentHandle = null
652
+ responseBorrowsBuffer = false
692
653
 
693
- const xri = h.get("x-real-ip");
694
- if (xri !== null && xri.length <= MAX_SMALL_HEADER_BYTES) {
695
- headers.push(["x-real-ip", xri]);
696
- }
697
- }
654
+ try {
655
+ // Pack the request frame (inside the try: an unexpected packing failure
656
+ // is reported by the escaping-error catch below — it must never leak the
657
+ // request without accounting). `ctx.origin` was already fetched once
658
+ // above, so pass it in to avoid a second `req.headers.get('origin')`
659
+ // native→JS string conversion on the hot path.
660
+ const packedHeaders = gatherRawHeadersPacked(req, headerPlan, methodKind, ctx.origin)
661
+ // Minimal processing: when the native pipeline cannot read the URL/IP
662
+ // (see `urlNeeded`/`ipNeeded`), pass an empty URL / omit the IP so we
663
+ // skip the per-request JS UTF-8 encode entirely (the frame still carries
664
+ // a valid 0-length section the native bounds-checks).
665
+ const packUrl = urlNeeded ? req.url : ''
666
+ const packIp = ipNeeded ? ip : undefined
698
667
 
699
- if (headerPlan.proto) {
700
- const xfp = h.get("x-forwarded-proto");
701
- if (xfp !== null && xfp.length <= MAX_SMALL_HEADER_BYTES) {
702
- headers.push(["x-forwarded-proto", xfp]);
703
- }
704
- }
668
+ try {
669
+ // Acquire inside the try: pool exhaustion (maxInFlight) becomes a 500
670
+ // via setInternalError — never an uncaught exception out of run().
671
+ handle = outputPool.acquire(outputBufferSize)
672
+ currentHandle = handle
673
+
674
+ // Write into the pooled buffer — no per-request allocation. Bun
675
+ // PRIMARY: the raw-components C-ABI (`castrum_ingress_handle_components`)
676
+ // passes `url`/`ip` as `bun:ffi` `cstring` args — the engine transcodes
677
+ // the JS strings to call-scoped UTF-8 in-engine, so we skip the JS
678
+ // frame assembly + `Buffer.write` encode for the URL/IP (same wire
679
+ // format: both paths run the shared `handle_components` core). Falls
680
+ // back to the packed frame (FFI or napi) on any failure / stale addon.
681
+ // If an ffi call throws (native reported 0 = error, or a panic
682
+ // `panic_guard` contained), re-dispatch through napi once and, after
683
+ // repeated failures, permanently disable ffi for this handler.
684
+ let written: number
685
+ if (
686
+ bunFFI !== null &&
687
+ ingressPtr !== 0 &&
688
+ !ffiDisabled &&
689
+ typeof bunFFI.ingressHandleComponents === 'function'
690
+ ) {
691
+ try {
692
+ written = bunFFI.ingressHandleComponents(
693
+ ingressPtr,
694
+ methodKind,
695
+ packUrl,
696
+ packIp ?? '',
697
+ ridBytes,
698
+ packedHeaders,
699
+ body,
700
+ handle.buffer,
701
+ )
702
+ ffiFailures = 0
703
+ } catch {
704
+ ffiFailures++
705
+ if (ffiFailures >= MAX_FFI_FAILURES) {
706
+ ffiDisabled = true
707
+ }
708
+ // napi re-run: crash-safe (napi-rs catch_unwind) and semantically
709
+ // identical — a transient ffi panic still serves the request. The
710
+ // packed frame respects the same url/ip minimal-processing.
711
+ const input = inputPacker.packParts(
712
+ methodKind,
713
+ packUrl,
714
+ packIp,
715
+ ridBytes,
716
+ packedHeaders,
717
+ )
718
+ written = handler.handleRequestPacked(input, body, handle.buffer)
719
+ }
720
+ } else if (bunFFI !== null && ingressPtr !== 0 && !ffiDisabled) {
721
+ const input = inputPacker.packParts(
722
+ methodKind,
723
+ packUrl,
724
+ packIp,
725
+ ridBytes,
726
+ packedHeaders,
727
+ )
728
+ try {
729
+ written = bunFFI.ingressHandlePacked(ingressPtr, input, body, handle.buffer)
730
+ ffiFailures = 0
731
+ } catch {
732
+ ffiFailures++
733
+ if (ffiFailures >= MAX_FFI_FAILURES) {
734
+ ffiDisabled = true
735
+ }
736
+ // napi re-run: crash-safe (napi-rs catch_unwind) and semantically
737
+ // identical — a transient ffi panic still serves the request.
738
+ written = handler.handleRequestPacked(input, body, handle.buffer)
739
+ }
740
+ } else {
741
+ const input = inputPacker.packParts(
742
+ methodKind,
743
+ packUrl,
744
+ packIp,
745
+ ridBytes,
746
+ packedHeaders,
747
+ )
748
+ written = handler.handleRequestPacked(input, body, handle.buffer)
749
+ }
705
750
 
706
- const ipStr = ip ?? EMPTY_IP;
751
+ const used = handle.buffer.subarray(0, written)
752
+ // Cached per-ArrayBuffer DataView: no per-request view allocation.
753
+ result.refresh(used, body ?? EMPTY_BODY, viewForArrayBuffer(used.buffer, used.byteOffset))
754
+ } catch (err) {
755
+ result.setInternalError()
756
+ const error = err instanceof Error ? err : new Error(String(err))
757
+ runtime.onError?.(req, requestIdStr, error)
758
+ logger?.error({
759
+ requestId: requestIdStr,
760
+ method: req.method,
761
+ path: pathForLog(req),
762
+ code: 'internal',
763
+ message: error.message,
764
+ })
765
+ }
707
766
 
708
- try {
709
- const outputBuf = handler.handleRequestFullSync(
710
- methodKind,
711
- req.url,
712
- ipStr,
713
- requestIdStr,
714
- headers,
715
- body,
716
- outputBufferSize,
717
- );
718
-
719
- const outputView = new DataView(
720
- outputBuf.buffer,
721
- outputBuf.byteOffset,
722
- outputBuf.byteLength,
723
- );
724
-
725
- result.refresh(outputBuf, body ?? EMPTY_BODY, outputView);
767
+ try {
768
+ const out = fn(result, ctx)
769
+ // The ingress result is invalidated when run() returns, so an async
770
+ // callback would observe a stale/zeroed result — reject it up front
771
+ // (parity with the fast path / createIngressSync).
772
+ assertSyncCallback(out, 'createIngressHandler().run()')
773
+ // Report observability for every outcome. Non-Response callbacks
774
+ // (e.g. echoHandler's object return) fall back to the decoded terminal
775
+ // status so they are not silently invisible to onResponse/logging.
776
+ const status = out instanceof Response ? out.status : safeTerminalStatus(result)
777
+ runtime.onResponse?.(req, result, status, requestIdStr)
778
+ if (logger) {
779
+ logger.request({
780
+ requestId: requestIdStr,
781
+ method: req.method,
782
+ path: pathForLog(req),
783
+ status,
784
+ durationMs: Math.round((performance.now() - startedAt) * 1000) / 1000,
785
+ ip: ipStr,
786
+ })
787
+ }
788
+ return out
789
+ } finally {
790
+ result.invalidate()
791
+ ctx.requestIdHeader = null
792
+ ctx.origin = null
793
+ }
726
794
  } catch (err) {
727
- result.setInternalError();
728
- }
729
-
730
- try {
731
- const out = fn(result, ctx);
732
- if (out instanceof Response) {
733
- runtime.onResponse?.(req, result, out.status, requestIdStr);
795
+ // An unexpected error escaped run() after onRequest (a packing failure
796
+ // before the native call, or a throwing route callback). Report it so
797
+ // request accounting completes (metrics in_flight must not leak), then
798
+ // rethrow for the caller. The native-failure path above does NOT rethrow,
799
+ // so this fires at most once per request.
800
+ if (responseBorrowsBuffer && handle !== null) {
801
+ // `zeroCopyResponse` was called but the Response never escaped (the
802
+ // callback threw): the stream that would release the pooled buffer was
803
+ // never delivered. Release it here so the buffer isn't stuck in flight
804
+ // forever.
805
+ handle.release()
806
+ handle = null
807
+ responseBorrowsBuffer = false
734
808
  }
735
- return out;
809
+ const error = err instanceof Error ? err : new Error(String(err))
810
+ runtime.onError?.(req, requestIdStr, error)
811
+ logger?.error({
812
+ requestId: requestIdStr,
813
+ method: req.method,
814
+ path: pathForLog(req),
815
+ code: 'internal',
816
+ message: error.message,
817
+ })
818
+ throw error
736
819
  } finally {
737
- result.invalidate();
738
- ctx.requestIdHeader = null;
739
- ctx.origin = null;
820
+ // A zero-copy Response owns the handle until its body is consumed; in
821
+ // every other case (copy mode, terminal/error responses) the buffer is
822
+ // safe to return to the pool immediately.
823
+ if (handle !== null && !responseBorrowsBuffer) {
824
+ handle.release()
825
+ }
826
+ currentHandle = null
740
827
  }
741
828
  }
742
829
 
@@ -748,455 +835,6 @@ export function createIngressHandler(
748
835
  errorResponse,
749
836
  internalErrorResponse,
750
837
  withContentType,
751
- };
752
- }
753
-
754
- // ── Route-handler factories ───────────────────────────────────────
755
- export interface BakedHandlerOptions {
756
- /** Resolve the client IP from the server object (e.g. `srv.requestIP`). */
757
- getIp?: (req: Request, srv: unknown) => string | undefined;
758
- /** Copy body slices instead of sharing the native buffer. Default: true (safe). */
759
- copyBody?: boolean;
760
- /** Maximum request body bytes for write/echo handlers. Default: 1 MiB. */
761
- maxBodyBytes?: number;
762
- /** Fallback handler used for write error paths. Defaults to the write ingress. */
763
- fallback?: OptimizedIngressHandler;
764
- }
765
-
766
- function resolveIp(
767
- req: Request,
768
- srv: unknown,
769
- opts: BakedHandlerOptions,
770
- ): string | undefined {
771
- return opts.getIp ? opts.getIp(req, srv) : undefined;
772
- }
773
-
774
- /** Pre-baked GET read handler: returns the ingress body JSON on success. */
775
- export function readHandler(
776
- ingress: OptimizedIngressHandler,
777
- opts: BakedHandlerOptions = {},
778
- ): (req: Request, srv?: unknown) => Response {
779
- const copyBody = opts.copyBody !== false;
780
-
781
- return (req, srv) =>
782
- ingress.run<Response>(req, resolveIp(req, srv, opts), null, (result, ctx) => {
783
- const terminal = ingress.terminalResponse(req, result, ctx);
784
- if (terminal) return terminal;
785
-
786
- if (result.bodyTruncated) {
787
- return ingress.internalErrorResponse(ctx, result);
788
- }
789
-
790
- return new Response(result.bodyJson(copyBody), {
791
- status: 200,
792
- headers: ingress.responseHeaders(
793
- result.headerVariant,
794
- ctx.requestIdHeader,
795
- ctx.origin,
796
- result.rateRemaining,
797
- result.rateResetMs > 0
798
- ? Math.ceil(result.rateResetMs / 1000)
799
- : undefined,
800
- ),
801
- });
802
- });
803
- }
804
-
805
- /** Pre-baked HEAD read handler: headers only, no body. */
806
- export function headHandler(
807
- ingress: OptimizedIngressHandler,
808
- opts: BakedHandlerOptions = {},
809
- ): (req: Request, srv?: unknown) => Response {
810
- return (req, srv) =>
811
- ingress.run<Response>(req, resolveIp(req, srv, opts), null, (result, ctx) => {
812
- const terminal = ingress.terminalResponse(req, result, ctx);
813
- if (terminal) return terminal;
814
-
815
- if (result.bodyTruncated) {
816
- return ingress.internalErrorResponse(ctx, result);
817
- }
818
-
819
- return new Response(null, {
820
- status: 200,
821
- headers: ingress.responseHeaders(
822
- result.headerVariant,
823
- ctx.requestIdHeader,
824
- ctx.origin,
825
- result.rateRemaining,
826
- result.rateResetMs > 0
827
- ? Math.ceil(result.rateResetMs / 1000)
828
- : undefined,
829
- ),
830
- });
831
- });
832
- }
833
-
834
- /**
835
- * Pre-baked JSON-write handler (POST/PUT/PATCH): enforces Content-Type,
836
- * content-length/body-size limits, JSON validity and (optionally) schema
837
- * validation, then returns the ingress body JSON on success.
838
- */
839
- export function jsonWriteHandler(
840
- ingress: OptimizedIngressHandler,
841
- opts: BakedHandlerOptions = {},
842
- ): (req: Request, srv?: unknown) => Promise<Response> {
843
- const maxBodyBytes = opts.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
844
- const fallback = opts.fallback ?? ingress;
845
- const copyBody = opts.copyBody !== false;
846
-
847
- return async (req, srv) => {
848
- const ip = resolveIp(req, srv, opts);
849
-
850
- const contentType = req.headers.get("content-type") ?? "";
851
- if (!contentType.includes("application/json")) {
852
- return fallback.run(req, ip, null, (result, ctx) => {
853
- const terminal = fallback.terminalResponse(req, result, ctx);
854
- if (terminal) return terminal;
855
-
856
- return fallback.errorResponse(
857
- req,
858
- result,
859
- 415,
860
- "unsupported_media_type",
861
- "Content-Type must be application/json",
862
- ctx,
863
- );
864
- });
865
- }
866
-
867
- const contentLengthHeader = req.headers.get("content-length");
868
- const contentLength =
869
- contentLengthHeader === null ? NaN : Number(contentLengthHeader);
870
-
871
- if (Number.isFinite(contentLength) && contentLength > maxBodyBytes) {
872
- return fallback.run(req, ip, null, (result, ctx) => {
873
- const terminal = fallback.terminalResponse(req, result, ctx);
874
- if (terminal) return terminal;
875
-
876
- return fallback.errorResponse(
877
- req,
878
- result,
879
- 413,
880
- "body_too_large",
881
- "Request body is too large",
882
- ctx,
883
- );
884
- });
885
- }
886
-
887
- let bodyBytes: Uint8Array;
888
- try {
889
- bodyBytes = new Uint8Array(await req.arrayBuffer());
890
- } catch {
891
- return fallback.run(req, ip, null, (result, ctx) => {
892
- const terminal = fallback.terminalResponse(req, result, ctx);
893
- if (terminal) return terminal;
894
-
895
- return fallback.errorResponse(
896
- req,
897
- result,
898
- 400,
899
- "bad_request",
900
- "Unable to read request body",
901
- ctx,
902
- );
903
- });
904
- }
905
-
906
- if (bodyBytes.byteLength > maxBodyBytes) {
907
- return fallback.run(req, ip, null, (result, ctx) => {
908
- const terminal = fallback.terminalResponse(req, result, ctx);
909
- if (terminal) return terminal;
910
-
911
- return fallback.errorResponse(
912
- req,
913
- result,
914
- 413,
915
- "body_too_large",
916
- "Request body is too large",
917
- ctx,
918
- );
919
- });
920
- }
921
-
922
- return ingress.run(req, ip, bodyBytes, (result, ctx) => {
923
- const terminal = ingress.terminalResponse(req, result, ctx);
924
- if (terminal) return terminal;
925
-
926
- if (result.bodyTruncated) {
927
- return ingress.internalErrorResponse(ctx, result);
928
- }
929
-
930
- if (!result.bodyValidJson) {
931
- return ingress.errorResponse(
932
- req,
933
- result,
934
- 400,
935
- "invalid_json",
936
- "Invalid JSON body",
937
- ctx,
938
- );
939
- }
940
-
941
- if (!result.schemaValid) {
942
- return ingress.errorResponse(
943
- req,
944
- result,
945
- 422,
946
- "schema_validation_failed",
947
- "Request body failed schema validation",
948
- ctx,
949
- );
950
- }
951
-
952
- return new Response(result.bodyJson(copyBody), {
953
- status: 200,
954
- headers: ingress.responseHeaders(
955
- result.headerVariant,
956
- ctx.requestIdHeader,
957
- ctx.origin,
958
- result.rateRemaining,
959
- result.rateResetMs > 0
960
- ? Math.ceil(result.rateResetMs / 1000)
961
- : undefined,
962
- ),
963
- });
964
- });
965
- };
966
- }
967
-
968
- /**
969
- * Pre-baked echo handler: streams the request body back with the client's
970
- * Content-Type, bounded by `maxBodyBytes`.
971
- */
972
- export function echoHandler(
973
- ingress: OptimizedIngressHandler,
974
- opts: BakedHandlerOptions = {},
975
- ): (req: Request, srv?: unknown) => Promise<Response> {
976
- const maxBodyBytes = opts.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
977
-
978
- return async (req, srv) => {
979
- const ip = resolveIp(req, srv, opts);
980
-
981
- const prep = ingress.run<{
982
- terminal?: Response;
983
- headers?: ReadonlyArray<[string, string]>;
984
- }>(req, ip, null, (result, ctx) => {
985
- const terminal = ingress.terminalResponse(req, result, ctx);
986
- if (terminal) return { terminal };
987
-
988
- if (result.bodyTruncated) {
989
- return { terminal: ingress.internalErrorResponse(ctx, result) };
990
- }
991
-
992
- const hv = result.headerVariant & ~HV_JSON;
993
-
994
- return {
995
- headers: ingress.responseHeaders(
996
- hv,
997
- ctx.requestIdHeader,
998
- ctx.origin,
999
- result.rateRemaining,
1000
- result.rateResetMs > 0
1001
- ? Math.ceil(result.rateResetMs / 1000)
1002
- : undefined,
1003
- ),
1004
- };
1005
- });
1006
-
1007
- if (prep.terminal) return prep.terminal;
1008
-
1009
- const baseHeaders: ReadonlyArray<[string, string]> = prep.headers ?? [];
1010
-
1011
- const requestedContentType =
1012
- req.headers.get("content-type") ?? "application/octet-stream";
1013
-
1014
- const contentLengthHeader = req.headers.get("content-length");
1015
- const contentLength =
1016
- contentLengthHeader === null ? NaN : Number(contentLengthHeader);
1017
-
1018
- if (Number.isFinite(contentLength)) {
1019
- if (contentLength > maxBodyBytes) {
1020
- return new Response(ERROR_BODIES.body_too_large, {
1021
- status: 413,
1022
- headers: ingress.withContentType(baseHeaders, "application/json"),
1023
- });
1024
- }
1025
-
1026
- if (contentLength <= 0 || req.body === null) {
1027
- return new Response(null, {
1028
- status: 200,
1029
- headers: ingress.withContentType(baseHeaders, requestedContentType),
1030
- });
1031
- }
1032
-
1033
- return new Response(req.body, {
1034
- status: 200,
1035
- headers: ingress.withContentType(baseHeaders, requestedContentType),
1036
- });
1037
- }
1038
-
1039
- try {
1040
- const bodyBytes = new Uint8Array(await req.arrayBuffer());
1041
-
1042
- if (bodyBytes.byteLength > maxBodyBytes) {
1043
- return new Response(ERROR_BODIES.body_too_large, {
1044
- status: 413,
1045
- headers: ingress.withContentType(baseHeaders, "application/json"),
1046
- });
1047
- }
1048
-
1049
- return new Response(bodyBytes.byteLength > 0 ? bodyBytes : null, {
1050
- status: 200,
1051
- headers: ingress.withContentType(baseHeaders, requestedContentType),
1052
- });
1053
- } catch {
1054
- return new Response(ERROR_BODIES.bad_request, {
1055
- status: 400,
1056
- headers: ingress.withContentType(baseHeaders, "application/json"),
1057
- });
1058
- }
1059
- };
1060
- }
1061
-
1062
- /** Pre-baked fallback handler: 404 for unmatched routes / OPTIONS. */
1063
- export function fallbackHandler(
1064
- ingress: OptimizedIngressHandler,
1065
- opts: BakedHandlerOptions = {},
1066
- ): (req: Request, srv?: unknown) => Response {
1067
- return (req, srv) =>
1068
- ingress.run<Response>(req, resolveIp(req, srv, opts), null, (result, ctx) => {
1069
- const terminal = ingress.terminalResponse(req, result, ctx);
1070
- if (terminal) return terminal;
1071
-
1072
- return ingress.errorResponse(
1073
- req,
1074
- result,
1075
- 404,
1076
- "not_found",
1077
- "Not found",
1078
- ctx,
1079
- );
1080
- });
1081
- }
1082
-
1083
- // ── Bun.serve builder ─────────────────────────────────────────────
1084
- export interface BakedRoute {
1085
- /** Wires GET + HEAD read handlers. */
1086
- read?: OptimizedIngressHandler;
1087
- /** Wires POST/PUT/PATCH JSON-write handlers (and OPTIONS fallback). */
1088
- write?: OptimizedIngressHandler;
1089
- /** Wires a POST echo handler. */
1090
- echo?: OptimizedIngressHandler;
1091
- /** Wires a GET read handler (cookies-style route). */
1092
- cookies?: OptimizedIngressHandler;
1093
- /** Overrides `maxBodyBytes` for this route's write/echo handlers. */
1094
- maxBodyBytes?: number;
1095
- }
1096
-
1097
- export interface CreateIngressServerOptions {
1098
- port: number;
1099
- hostname?: string;
1100
- idleTimeout?: number;
1101
- maxRequestBodySize?: number;
1102
- reusePort?: boolean;
1103
- copyBody?: boolean;
1104
- getIp?: (req: Request, srv: unknown) => string | undefined;
1105
- routes: Record<string, BakedRoute>;
1106
- fallback?: OptimizedIngressHandler;
1107
- }
1108
-
1109
- export interface BakedServer {
1110
- server: ReturnType<typeof Bun.serve>;
1111
- stop(): void;
1112
- port: number;
1113
- }
1114
-
1115
- /** Build a Bun.serve config from pre-baked route handlers. */
1116
- export function createIngressServer(
1117
- options: CreateIngressServerOptions,
1118
- ): BakedServer {
1119
- const baseOpts: BakedHandlerOptions = {
1120
- getIp: options.getIp,
1121
- copyBody: options.copyBody,
1122
- };
1123
-
1124
- const serverRoutes: Record<string, Record<string, unknown>> = {};
1125
-
1126
- for (const [path, spec] of Object.entries(options.routes)) {
1127
- const routeOpts: BakedHandlerOptions = {
1128
- ...baseOpts,
1129
- maxBodyBytes: spec.maxBodyBytes,
1130
- };
1131
- const methods: Record<string, unknown> = {};
1132
-
1133
- if (spec.read) {
1134
- methods.GET = readHandler(spec.read, routeOpts);
1135
- methods.HEAD = headHandler(spec.read, routeOpts);
1136
- }
1137
-
1138
- if (spec.write) {
1139
- const writeOpts: BakedHandlerOptions = {
1140
- ...routeOpts,
1141
- fallback: options.fallback ?? spec.write,
1142
- };
1143
- methods.POST = jsonWriteHandler(spec.write, writeOpts);
1144
- methods.PUT = jsonWriteHandler(spec.write, writeOpts);
1145
- methods.PATCH = jsonWriteHandler(spec.write, writeOpts);
1146
- if (options.fallback) {
1147
- methods.OPTIONS = fallbackHandler(options.fallback, routeOpts);
1148
- }
1149
- }
1150
-
1151
- if (spec.echo) {
1152
- methods.POST = echoHandler(spec.echo, routeOpts);
1153
- }
1154
-
1155
- if (spec.cookies) {
1156
- methods.GET = readHandler(spec.cookies, routeOpts);
1157
- }
1158
-
1159
- serverRoutes[path] = methods;
1160
- }
1161
-
1162
- const serverOptions: Record<string, unknown> = {
1163
- hostname: options.hostname ?? "0.0.0.0",
1164
- port: options.port,
1165
- idleTimeout: options.idleTimeout ?? 30,
1166
- routes: serverRoutes,
1167
- };
1168
-
1169
- if (options.maxRequestBodySize !== undefined) {
1170
- serverOptions.maxRequestBodySize = options.maxRequestBodySize;
1171
- }
1172
- if (options.reusePort) {
1173
- serverOptions.reusePort = true;
838
+ zeroCopyResponse,
1174
839
  }
1175
- if (options.fallback) {
1176
- serverOptions.fetch = fallbackHandler(options.fallback, baseOpts);
1177
- }
1178
-
1179
- let server: ReturnType<typeof Bun.serve>;
1180
- try {
1181
- server = Bun.serve(serverOptions as any);
1182
- } catch (err) {
1183
- if (options.reusePort) {
1184
- delete serverOptions.reusePort;
1185
- server = Bun.serve(serverOptions as any);
1186
- } else {
1187
- throw err;
1188
- }
1189
- }
1190
-
1191
- return {
1192
- server,
1193
- stop: () => {
1194
- try {
1195
- server.stop(true);
1196
- } catch {
1197
- // already stopped
1198
- }
1199
- },
1200
- port: options.port,
1201
- };
1202
840
  }