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
@@ -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,360 @@
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, 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'
65
57
  import {
66
- METHOD_KIND,
67
- generateRequestId,
68
- safeTerminalStatus,
69
- DEFAULT_MAX_BODY_BYTES,
58
+ assertIngressOptionValues,
59
+ assertKnownIngressOptions,
60
+ type IngressHandlerOptions,
61
+ warnTrustProxyDeprecated,
62
+ } from './options'
63
+ import { gatherRawHeadersPacked } from './packing/gather-raw-headers'
64
+ import { IngressInputPacker } from './packing/input-packer'
65
+ import { buildSecurityPairs } from './headers/hsts'
66
+ import type { SecurityHeadersOptions } from './headers/hsts'
67
+ import { ERROR_BODIES, ERROR_CODE_BODIES, rateLimitedBody } from './response/error-bodies'
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
+ echoHandler,
88
+ fallbackHandler,
89
+ headHandler,
90
+ jsonWriteHandler,
91
+ readHandler,
92
+ } from './routes'
93
+ export type {
94
+ BakedRoute,
95
+ BakedServer,
96
+ CreateIngressServerOptions,
97
+ GracefulShutdownOptions,
98
+ ServerHandle,
99
+ } from './server'
100
+ export { createIngressServer, gracefulShutdown } from './server'
101
+ export type { RouteHandler as IngressNodeRouteHandler } from './server-node'
102
+ // Node.js HTTP adapter (same pre-baked route handlers, node:http backend).
103
+ export { createIngressServerNode } from './server-node'
104
+
105
+ const encoder = new TextEncoder()
106
+ const EMPTY_BODY = new Uint8Array(0)
107
+ const EMPTY_IP = '0.0.0.0'
77
108
 
78
109
  /** 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;
110
+ export const RATE_LIMIT_U32_MAX = 4_294_967_295
86
111
 
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
- }
112
+ /**
113
+ * Consecutive `bun:ffi` ingress failures before this handler permanently
114
+ * falls back to the napi addon (a panic that `panic_guard` contained, or a
115
+ * bun:ffi regression that throws). Low enough to recover fast from a flaky
116
+ * transport, high enough that a single transient failure never flips it.
117
+ */
118
+ const MAX_FFI_FAILURES = 3
266
119
 
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;
120
+ /**
121
+ * Best-effort URL pathname extraction for log/error lines.
122
+ *
123
+ * This runs inside catch/finally paths, so a malformed `req.url` must never
124
+ * throw out of the handler (an uncaught error here would bypass `onError`).
125
+ * Falls back to the raw URL string when parsing fails.
126
+ */
127
+ function pathForLog(req: Request): string {
128
+ try {
129
+ return new URL(req.url).pathname
130
+ } catch {
131
+ return req.url
275
132
  }
133
+ }
276
134
 
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
- }
135
+ /**
136
+ * Merge structured `options.security` (SecurityHeadersOptions) with raw
137
+ * `runtime.securityHeaders` pairs into the ordered entries the baked templates
138
+ * prepend to every response variant. Raw pairs win on name conflicts; names are
139
+ * lowercased for the template merge.
140
+ *
141
+ * The structured defaults (nosniff/DENY/no-referrer) are ONLY applied when the
142
+ * user explicitly opted into security on this path (`security !== undefined`) —
143
+ * the baked path's long-standing default is no security headers, and silently
144
+ * adding them would change every response.
145
+ */
146
+ function buildBakedSecurityEntries(
147
+ security: SecurityHeadersOptions | undefined,
148
+ https: boolean | undefined,
149
+ raw: ReadonlyArray<[string, string]> | undefined,
150
+ ): Array<[string, string]> {
151
+ const structured = security === undefined ? [] : buildSecurityPairs(security, https, true)
152
+
153
+ if (raw === undefined || raw.length === 0) return structured
154
+
155
+ const byName = new Map<string, string>()
156
+ for (const [k, v] of structured) byName.set(k, v)
157
+ for (const [k, v] of raw) byName.set(k.toLowerCase(), v)
158
+ return [...byName.entries()]
290
159
  }
291
160
 
292
161
  // ── Types ────────────────────────────────────────────────────────
293
- export interface BakedContext {
294
- requestIdHeader: string | null;
295
- origin: string | null;
296
- }
162
+ // `BakedContext` and `OptimizedIngressHandler` are defined in ./types.ts
163
+ // (shared with the route factories) and re-exported here for back-compat.
164
+ export type { BakedContext, OptimizedIngressHandler } from './types'
297
165
 
298
166
  export interface BakedIngressRuntime {
299
167
  /** Emit an `x-request-id` header on responses. Default: false. */
300
- emitRequestIdHeader?: boolean;
168
+ emitRequestIdHeader?: boolean
301
169
  /** Enable security headers on responses. Default: true. */
302
- enableSecurityHeaders?: boolean;
303
- /** Ordered `[name, value]` security headers (names are lowercased). */
304
- securityHeaders?: ReadonlyArray<[string, string]>;
170
+ enableSecurityHeaders?: boolean
171
+ /**
172
+ * Ordered `[name, value]` security headers (names are lowercased).
173
+ * NOTE: this PRE-BAKED path takes RAW `[name, value]` pairs here; the
174
+ * fast/async paths (`createIngressFast`/`createIngress`) instead take a
175
+ * structured `security: SecurityHeadersOptions` object (CSP/HSTS builders).
176
+ * The two paths intentionally use different option shapes (see
177
+ * docs/INGRESS.md).
178
+ */
179
+ securityHeaders?: ReadonlyArray<[string, string]>
305
180
  /** Native output buffer size in bytes. Default: 131072. */
306
- outputBufferSize?: number;
181
+ outputBufferSize?: number
182
+ /** Invoked before a request is processed (for tracing/context hooks). */
183
+ onRequest?: (req: Request, requestId: string, ip: string | undefined) => void
307
184
  /** 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;
185
+ onResponse?: (req: Request, result: BakedIngressResult, status: number, requestId: string) => void
186
+ /**
187
+ * Invoked when the native pipeline fails (the request becomes a 500).
188
+ * Native failures are otherwise silent — wire this to your error tracker.
189
+ */
190
+ onError?: (req: Request, requestId: string, error: Error) => void
191
+ /**
192
+ * Emit one structured JSON line per request/error via the built-in logger
193
+ * (gated by `CASTRUM_LOG_LEVEL`). Default: false.
194
+ */
195
+ structuredLog?: boolean
196
+ /**
197
+ * Custom structured logger (e.g. with a custom stream or extra fields).
198
+ * When provided, request/error lines go here instead of the built-in stderr
199
+ * logger and `structuredLog` is implicitly enabled.
200
+ */
201
+ logger?: ReturnType<typeof createStructuredLogger>
202
+ /**
203
+ * Hard cap on concurrently borrowed output buffers (zero-copy responses not
204
+ * yet consumed). Bounds memory when zero-copy responses are consumed
205
+ * slowly; when exceeded, requests fail with a 500 (pool exhaustion) instead
206
+ * of allocating unbounded temporaries. 0 (default) is unlimited — see
207
+ * `BufferPool.maxInFlight`.
208
+ */
209
+ maxInFlight?: number
210
+ /**
211
+ * Abandonment guard (ms) for zero-copy responses: if a response body is
212
+ * neither pulled nor cancelled within this window, its pooled buffer is
213
+ * released and the stream closed. Bounds memory under abandoned (unread)
214
+ * zero-copy responses. 0 (default) disables it — see `pooledBodyResponse`.
215
+ */
216
+ zeroCopyTimeoutMs?: number
314
217
  }
315
218
 
219
+ // ── Optimized ingress factory ─────────────────────────────────────
220
+
316
221
  /**
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.
222
+ * Create a pre-baked ingress handler (path 2).
223
+ *
224
+ * The request frame (url/ip/rid/headers) is packed in JS via `IngressInputPacker`
225
+ * + `gatherRawHeadersPacked` and driven through the same native core as the
226
+ * fast path (`Ingress.handleRequestPacked`), writing into a pooled output
227
+ * buffer (no per-request allocation). Responses use the benchmark wire format
228
+ * (`{"ok":false,"error":{...}}` + `ratelimit-*` headers) — see AGENTS.md for
229
+ * why this differs from `createIngressFast`.
230
+ *
231
+ * Returns an `OptimizedIngressHandler` whose response-builder methods are
232
+ * bound to this handler's configuration. Pair it with the route factories
233
+ * (`readHandler`, `jsonWriteHandler`, ...) or `createIngressServer`.
321
234
  */
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
235
  export function createIngressHandler(
370
- options: Record<string, unknown>,
236
+ options: IngressHandlerOptions = {},
371
237
  runtime: BakedIngressRuntime = {},
372
238
  ): 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.");
239
+ // Fail fast on a misspelled option key — same guard as the fast path.
240
+ assertKnownIngressOptions(options, 'createIngressHandler')
241
+ assertIngressOptionValues(options, 'createIngressHandler')
242
+ if (options.trustProxy === true) {
243
+ warnTrustProxyDeprecated()
376
244
  }
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
- );
245
+ // Lazy: the native addon is only needed once a handler is created.
246
+ const addon = getAddon()
247
+ const NativeIngress = addon.Ingress
248
+ if (typeof NativeIngress !== 'function') {
249
+ throw new Error('Native Ingress class missing. Rebuild the Rust addon.')
383
250
  }
384
251
 
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
- };
252
+ const handler = new NativeIngress(options)
253
+ if (typeof handler.handleRequestPacked !== 'function') {
254
+ throw new Error('Native Ingress.handleRequestPacked missing. Rebuild the Rust addon.')
255
+ }
397
256
 
398
- const emitRequestIdHeader = runtime.emitRequestIdHeader === true;
399
- const outputBufferSize = runtime.outputBufferSize ?? DEFAULT_OUTPUT_BUF_SIZE;
257
+ // Bun fast path: drive the SAME native pipeline through `bun:ffi` (opaque
258
+ // inner handle from `ingressInnerPtr`), cutting the per-request N-API
259
+ // crossing. The handle is valid only while `handler` is alive — this closure
260
+ // holds `handler` for the handler's lifetime, so it can never dangle. Falls
261
+ // back to napi when ffi is unavailable, the addon lacks the pointer method,
262
+ // or the handle is 0 (i.e. the state was dropped).
263
+ const bunFFI = getBunFFI()
264
+ // The opaque handle is a u64 under the 2^53 number range, so convert it to a
265
+ // plain JS number ONCE here — bun:ffi converts a BigInt argument to a number
266
+ // on every call anyway (and docs note BigInt is slower), so passing a number
267
+ // removes that per-request conversion on the hottest path.
268
+ const ingressPtr =
269
+ bunFFI !== null && typeof handler.ingressInnerPtr === 'function'
270
+ ? Number(handler.ingressInnerPtr())
271
+ : 0
272
+
273
+ const rateLimit = options.rateLimit as { limit?: number } | undefined
274
+ const limit = rateLimit?.limit
275
+ const rateEnabled = typeof limit === 'number' && limit !== RATE_LIMIT_U32_MAX && limit > 0
276
+
277
+ // Shared with the fast path — proxy extraction is driven by trust config
278
+ // (trustProxy / trustedProxies), not by whether rate limiting is enabled.
279
+ const headerPlan: HeaderPlan = buildHeaderPlan(options)
280
+
281
+ const emitRequestIdHeader = runtime.emitRequestIdHeader === true
282
+ const zeroCopyTimeoutMs = runtime.zeroCopyTimeoutMs ?? 0
283
+ const outputBufferSize = clampIngressBufferSize(
284
+ runtime.outputBufferSize ?? DEFAULT_BAKED_OUTPUT_BUFFER_SIZE,
285
+ OUT_DATA_START,
286
+ )
287
+
288
+ // Built-in structured logger (opt-in; gated by CASTRUM_LOG_LEVEL). A custom
289
+ // `runtime.logger` overrides it and implies structuredLog:true.
290
+ const logger =
291
+ runtime.logger ?? (runtime.structuredLog === true ? createStructuredLogger() : null)
292
+
293
+ // Only materialize the request-id string when a consumer needs it, avoiding
294
+ // one string allocation + UTF-8 decode per request in deployments with no
295
+ // hooks/logging. The bench wires `onResponse`, so the decode stays hot there;
296
+ // lean servers with no hooks skip it entirely.
297
+ const needRequestIdString = !!(
298
+ emitRequestIdHeader ||
299
+ runtime.onRequest ||
300
+ runtime.onError ||
301
+ runtime.onResponse ||
302
+ logger
303
+ )
304
+
305
+ // Reusable output-buffer pool: eliminates the per-request output-buffer
306
+ // allocation by reusing buffers across requests.
307
+ // `maxInFlight` (when set) bounds zero-copy borrowing under slow consumers.
308
+ const outputPool = new BufferPool({
309
+ initialSize: outputBufferSize,
310
+ maxInFlight: runtime.maxInFlight,
311
+ })
312
+
313
+ // Reusable packed-input builder (same zero-alloc discipline as the fast
314
+ // path): the per-request frame is packed into this buffer instead of paying
315
+ // the napi `Vec<Vec<String>>` header marshaling of the full_sync family.
316
+ const inputPacker = new IngressInputPacker()
317
+
318
+ // Per-call state: the output handle backing the current `run()`'s result,
319
+ // and whether a zero-copy Response claimed it (so `run()` must not release
320
+ // it back to the pool before the body is consumed).
321
+ let currentHandle: PooledBuffer | null = null
322
+ let responseBorrowsBuffer = false
323
+
324
+ // FFI circuit breaker (defense-in-depth): the raw C ABI cannot contain
325
+ // non-panic UB (a segfault still crashes the process), but a RECURRING
326
+ // observable failure — a panic that `panic_guard` turned into 0 → throw, or
327
+ // a bun:ffi regression that throws — is caught here and permanently
328
+ // re-routed to the napi fallback (crash-safe via napi-rs catch_unwind), so a
329
+ // flaky FFI path degrades to a correct response instead of a failure storm.
330
+ let ffiFailures = 0
331
+ let ffiDisabled = false
332
+
333
+ // Cache Origin-augmented header arrays keyed by `variant\u0000origin` (the
334
+ // steady-state CORS case: the pre-baked template + one
335
+ // `access-control-allow-origin` row). Callers treat the returned array as
336
+ // read-only (the `Headers`/`Response` constructors copy entries), so every
337
+ // distinct (variant, origin) is reused after its first hit — removing the
338
+ // per-request array alloc + copy when an Origin header is present. Only
339
+ // ALLOWED origins reach the CORS-allowed path (the native pipeline gates the
340
+ // variant on CORS approval), so the map stays bounded by the configured
341
+ // allow-origin list (+ a couple of preflight variants).
342
+ const originHeaderCache = new Map<string, [string, string][]>()
400
343
 
401
344
  // ── Variant-indexed header templates (precomputed once) ──
402
345
  const cors = options.cors as
403
346
  | {
404
- allowOrigin?: string[];
405
- allowMethods?: string[];
406
- allowHeaders?: string[];
407
- exposeHeaders?: string[];
408
- allowCredentials?: boolean;
409
- maxAge?: number;
347
+ allowOrigin?: string[]
348
+ allowMethods?: string[]
349
+ allowHeaders?: string[]
350
+ exposeHeaders?: string[]
351
+ allowCredentials?: boolean
352
+ maxAge?: number
410
353
  }
411
- | undefined;
412
-
354
+ | undefined
355
+
356
+ // Security headers: honor the STRUCTURED `options.security`
357
+ // (SecurityHeadersOptions — the same shape `createIngress` honors) merged
358
+ // with raw `runtime.securityHeaders` pairs (raw wins on name conflicts).
359
+ // This closes the historic gotcha where `options.security` was silently
360
+ // ignored on the pre-baked path. When neither is provided, no security
361
+ // headers are emitted (the baked path's long-standing default).
413
362
  const securityEntries: ReadonlyArray<[string, string]> =
414
363
  runtime.enableSecurityHeaders === false
415
364
  ? Object.freeze([] as [string, string][])
416
365
  : 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
- );
366
+ buildBakedSecurityEntries(options.security, options.https, runtime.securityHeaders),
367
+ )
368
+
369
+ const corsAllowMethods = cors?.allowMethods?.join(', ') ?? ''
370
+ const corsAllowHeaders = cors?.allowHeaders?.join(', ') ?? ''
371
+ const corsExposeHeaders = cors?.exposeHeaders?.join(', ') ?? ''
372
+ const corsMaxAge = cors?.maxAge != null ? String(cors.maxAge) : ''
373
+ const rateLimitStr = rateEnabled ? String(limit) : ''
374
+
375
+ const { regular: headerTemplates, terminal: terminalTemplates } = buildBakedHeaderTemplates({
376
+ securityEntries,
377
+ cors,
378
+ corsAllowMethods,
379
+ corsAllowHeaders,
380
+ corsExposeHeaders,
381
+ corsMaxAge,
382
+ rateLimitStr,
383
+ })
467
384
 
468
385
  function responseHeaders(
469
386
  variant: number,
@@ -474,51 +391,70 @@ export function createIngressHandler(
474
391
  retryAfterSecs?: number,
475
392
  ): [string, string][] {
476
393
  const template: ReadonlyArray<[string, string]> =
477
- headerTemplates[variant & 31] ?? headerTemplates[0] ?? [];
394
+ headerTemplates[variant & 31] ?? headerTemplates[0] ?? []
478
395
 
479
- const needsRequestId = emitRequestIdHeader && requestIdHeader !== null;
396
+ const needsRequestId = emitRequestIdHeader && requestIdHeader !== null
480
397
  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;
398
+ ((variant & HV_CORS_SIMPLE) !== 0 || (variant & HV_CORS_PREFLIGHT) !== 0) && origin !== null
399
+ const needsRate = (variant & HV_RATE_ACTIVE) !== 0
400
+ const needsRetry = (variant & HV_RATE_LIMITED) !== 0
486
401
 
487
402
  if (!needsRequestId && !needsOrigin && !needsRate && !needsRetry) {
488
- return template as unknown as [string, string][];
403
+ return template as unknown as [string, string][]
404
+ }
405
+
406
+ if (!needsRequestId && !needsRate && !needsRetry && needsOrigin) {
407
+ // Steady-state CORS: only the origin varies — cache the augmented array
408
+ // per (variant, origin) so the Origin-present case (the common
409
+ // browser/bench case) stays allocation-free after the first hit for EACH
410
+ // distinct origin (a single-slot memo thrashed when clients alternate
411
+ // between allowed origins, allocating a fresh array on every switch).
412
+ const key = `${variant & 31}\u0000${origin as string}`
413
+ const cached = originHeaderCache.get(key)
414
+ if (cached !== undefined) {
415
+ return cached
416
+ }
417
+ const entries = new Array<[string, string]>(template.length + 1)
418
+ let i = 0
419
+ for (const pair of template) {
420
+ entries[i++] = pair
421
+ }
422
+ entries[i] = ['access-control-allow-origin', origin as string]
423
+ originHeaderCache.set(key, entries)
424
+ return entries
489
425
  }
490
426
 
491
- let extra = 0;
492
- if (needsRequestId) extra++;
493
- if (needsOrigin) extra++;
494
- if (needsRate) extra += 2;
495
- if (needsRetry) extra++;
427
+ let extra = 0
428
+ if (needsRequestId) extra++
429
+ if (needsOrigin) extra++
430
+ if (needsRate) extra += 2
431
+ if (needsRetry) extra++
496
432
 
497
- const entries = new Array<[string, string]>(template.length + extra);
498
- let i = 0;
433
+ const entries = new Array<[string, string]>(template.length + extra)
434
+ let i = 0
499
435
 
500
- for (; i < template.length; i++) {
501
- entries[i] = template[i]!;
436
+ for (const pair of template) {
437
+ entries[i++] = pair
502
438
  }
503
439
 
504
440
  if (needsRequestId) {
505
- entries[i++] = ["x-request-id", requestIdHeader as string];
441
+ entries[i++] = ['x-request-id', requestIdHeader as string]
506
442
  }
507
443
 
508
444
  if (needsOrigin) {
509
- entries[i++] = ["access-control-allow-origin", origin as string];
445
+ entries[i++] = ['access-control-allow-origin', origin as string]
510
446
  }
511
447
 
512
448
  if (needsRate) {
513
- entries[i++] = ["ratelimit-remaining", String(rateRemaining ?? 0)];
514
- entries[i++] = ["ratelimit-reset", String(rateResetSecs ?? 0)];
449
+ entries[i++] = ['ratelimit-remaining', String(rateRemaining ?? 0)]
450
+ entries[i++] = ['ratelimit-reset', String(rateResetSecs ?? 0)]
515
451
  }
516
452
 
517
453
  if (needsRetry) {
518
- entries[i++] = ["retry-after", String(retryAfterSecs ?? 0)];
454
+ entries[i++] = ['retry-after', String(retryAfterSecs ?? 0)]
519
455
  }
520
456
 
521
- return entries;
457
+ return entries
522
458
  }
523
459
 
524
460
  function terminalHeaders(
@@ -526,37 +462,56 @@ export function createIngressHandler(
526
462
  ctx: BakedContext,
527
463
  result: BakedIngressResult | null,
528
464
  ): [string, string][] {
465
+ const v = variant | HV_JSON
466
+ const requestIdHeader = ctx.requestIdHeader
467
+ const origin = ctx.origin
468
+ const rateResetSecs =
469
+ result && result.rateResetMs > 0 ? secondsFromMs(result.rateResetMs) : undefined
470
+ const retryAfterSecs =
471
+ result && result.retryAfterMs > 0 ? secondsFromMs(result.retryAfterMs) : undefined
472
+
473
+ const needsRequestId = emitRequestIdHeader && requestIdHeader !== null
474
+ const needsOrigin =
475
+ ((v & HV_CORS_SIMPLE) !== 0 || (v & HV_CORS_PREFLIGHT) !== 0) && origin !== null
476
+ const needsRate = (v & HV_RATE_ACTIVE) !== 0
477
+ const needsRetry = (v & HV_RATE_LIMITED) !== 0
478
+
479
+ // Steady state: no request-id/origin/rate extras — serve the pre-baked
480
+ // terminal template (JSON + `cache-control: no-store`) directly with zero
481
+ // per-response array allocation/copy.
482
+ if (!needsRequestId && !needsOrigin && !needsRate && !needsRetry) {
483
+ return terminalTemplates[v & 31] as unknown as [string, string][]
484
+ }
485
+
486
+ // Rare path: extras appended after the baked entries, then `cache-control`.
529
487
  const base = responseHeaders(
530
- variant | HV_JSON,
531
- ctx.requestIdHeader,
532
- ctx.origin,
488
+ v,
489
+ requestIdHeader,
490
+ origin,
533
491
  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]!;
492
+ rateResetSecs,
493
+ retryAfterSecs,
494
+ )
495
+
496
+ const out = new Array<[string, string]>(base.length + 1)
497
+ let i = 0
498
+ for (const pair of base) {
499
+ out[i++] = pair
545
500
  }
546
- out[base.length] = ["cache-control", "no-store"];
547
- return out;
501
+ out[base.length] = ['cache-control', 'no-store']
502
+ return out
548
503
  }
549
504
 
550
505
  function terminalResponse(
551
- _req: Request,
506
+ _req: Request | undefined,
552
507
  result: BakedIngressResult,
553
508
  ctx: BakedContext,
554
509
  ): Response | null {
555
510
  if (!result.terminal) {
556
- return null;
511
+ return null
557
512
  }
558
513
 
559
- const preflightAllowed = result.isPreflight && result.corsAllowed;
514
+ const preflightAllowed = result.isPreflight && result.corsAllowed
560
515
 
561
516
  if (preflightAllowed) {
562
517
  return new Response(null, {
@@ -566,31 +521,27 @@ export function createIngressHandler(
566
521
  ctx.requestIdHeader,
567
522
  ctx.origin,
568
523
  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,
524
+ result.rateResetMs > 0 ? secondsFromMs(result.rateResetMs) : undefined,
525
+ result.retryAfterMs > 0 ? secondsFromMs(result.retryAfterMs) : undefined,
575
526
  ),
576
- });
527
+ })
577
528
  }
578
529
 
579
- const status = safeTerminalStatus(result);
530
+ const status = safeTerminalStatus(result)
580
531
 
581
532
  const body: Uint8Array =
582
533
  result.errorCode === ERROR_CODE_RATE_LIMITED
583
534
  ? rateLimitedBody(result.retryAfterMs)
584
- : (ERROR_CODE_BODIES[result.errorCode] ?? ERROR_BODIES.internal!);
535
+ : (ERROR_CODE_BODIES[result.errorCode] ?? ERROR_BODIES.internal ?? EMPTY_BODY)
585
536
 
586
537
  return new Response(body, {
587
538
  status,
588
539
  headers: terminalHeaders(result.headerVariant, ctx, result),
589
- });
540
+ })
590
541
  }
591
542
 
592
543
  function errorResponse(
593
- _req: Request,
544
+ _req: Request | undefined,
594
545
  result: BakedIngressResult | null,
595
546
  status: number,
596
547
  code: string,
@@ -598,46 +549,58 @@ export function createIngressHandler(
598
549
  ctx: BakedContext,
599
550
  ): Response {
600
551
  const body =
601
- ERROR_BODIES[code] ??
602
- encoder.encode(JSON.stringify({ ok: false, error: { code, message } }));
552
+ ERROR_BODIES[code] ?? encoder.encode(JSON.stringify({ ok: false, error: { code, message } }))
603
553
 
604
554
  return new Response(body, {
605
555
  status,
606
556
  headers: terminalHeaders(result?.headerVariant ?? HV_JSON, ctx, result),
607
- });
557
+ })
608
558
  }
609
559
 
610
- function internalErrorResponse(
611
- ctx: BakedContext,
612
- result?: BakedIngressResult,
613
- ): Response {
560
+ function internalErrorResponse(ctx: BakedContext, result?: BakedIngressResult): Response {
614
561
  return new Response(ERROR_BODIES.internal, {
615
562
  status: 500,
616
- headers: terminalHeaders(
617
- result?.headerVariant ?? HV_JSON,
618
- ctx,
619
- result ?? null,
620
- ),
621
- });
563
+ headers: terminalHeaders(result?.headerVariant ?? HV_JSON, ctx, result ?? null),
564
+ })
622
565
  }
623
566
 
624
567
  function withContentType(
625
568
  headers: ReadonlyArray<[string, string]>,
626
569
  contentType: string,
627
570
  ): [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]!;
571
+ const out = new Array<[string, string]>(headers.length + 1)
572
+ let i = 0
573
+ for (const pair of headers) {
574
+ out[i++] = pair
631
575
  }
632
- out[headers.length] = ["content-type", contentType];
633
- return out;
576
+ out[headers.length] = ['content-type', contentType]
577
+ return out
634
578
  }
635
579
 
636
- const result = new BakedIngressResult();
580
+ function zeroCopyResponse(
581
+ result: BakedIngressResult,
582
+ _ctx: BakedContext,
583
+ init: ResponseInit,
584
+ ): Response {
585
+ if (currentHandle === null) {
586
+ // `zeroCopyResponse` must only run inside a live `run()` callback (a
587
+ // pooled output buffer must be in flight). Called outside `run()` the
588
+ // result has already been invalidated and `result.bodyJson` is empty —
589
+ // throw instead of silently serving an empty body.
590
+ throw new Error(
591
+ 'zeroCopyResponse() can only be called from within a run() callback ' +
592
+ '(the pooled output buffer is only live during run()).',
593
+ )
594
+ }
595
+ responseBorrowsBuffer = true
596
+ return pooledBodyResponse(currentHandle, result.bodyJson(false), init, zeroCopyTimeoutMs)
597
+ }
598
+
599
+ const result = new BakedIngressResult()
637
600
  const ctx: BakedContext = {
638
601
  requestIdHeader: null,
639
602
  origin: null,
640
- };
603
+ }
641
604
 
642
605
  function run<T>(
643
606
  req: Request,
@@ -645,98 +608,146 @@ export function createIngressHandler(
645
608
  body: Uint8Array | null,
646
609
  fn: (result: BakedIngressResult, ctx: BakedContext) => T,
647
610
  ): 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
- }
611
+ const startedAt = logger ? performance.now() : 0
612
+ const methodKind = METHOD_KIND[req.method] ?? METHOD_KIND_UNKNOWN
613
+ const ridBytes = generateRequestId()
614
+ const requestIdStr = needRequestIdString ? decoder.decode(ridBytes) : ''
667
615
 
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
- }
616
+ ctx.requestIdHeader = emitRequestIdHeader ? requestIdStr : null
617
+ ctx.origin = headerPlan.cors ? req.headers.get('origin') : null
673
618
 
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
- }
619
+ runtime.onRequest?.(req, requestIdStr, ip)
679
620
 
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
- }
621
+ const ipStr = ip ?? EMPTY_IP
686
622
 
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
- }
623
+ // Pack the request frame in JS (reusing the fast path's zero-alloc packer
624
+ // discipline) and drive the same native core as `handleRequestPacked`.
625
+ // This removes the per-request `[name, value][]` header marshaling + napi
626
+ // `Vec<Vec<String>>` and the request-id string re-encode that the
627
+ // full_sync family paid on every request. The response wire format is
628
+ // unchanged because both entries run the identical `handle_packed` core.
629
+ let handle: PooledBuffer | null = null
630
+ currentHandle = null
631
+ responseBorrowsBuffer = false
692
632
 
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
- }
633
+ try {
634
+ // Pack the request frame (inside the try: an unexpected packing failure
635
+ // is reported by the escaping-error catch below — it must never leak the
636
+ // request without accounting). `ctx.origin` was already fetched once
637
+ // above, so pass it in to avoid a second `req.headers.get('origin')`
638
+ // native→JS string conversion on the hot path.
639
+ const packedHeaders = gatherRawHeadersPacked(req, headerPlan, methodKind, ctx.origin)
640
+ // url/ip are encoded directly into the packer buffer (no intermediate
641
+ // `encoder.encode` Uint8Array + copy); requestId/headers are byte slices
642
+ // copied verbatim (no decode→re-encode of the pre-encoded request id).
643
+ const input = inputPacker.packParts(methodKind, req.url, ip, ridBytes, packedHeaders)
698
644
 
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
- }
645
+ try {
646
+ // Acquire inside the try: pool exhaustion (maxInFlight) becomes a 500
647
+ // via setInternalError — never an uncaught exception out of run().
648
+ handle = outputPool.acquire(outputBufferSize)
649
+ currentHandle = handle
650
+
651
+ // Write into the pooled buffer — no per-request allocation. Under Bun
652
+ // with a live ffi handle this runs the pipeline through `bun:ffi`
653
+ // (identical packed wire format); otherwise the napi call. If the ffi
654
+ // call throws (native reported 0 = error, or a panic `panic_guard`
655
+ // contained), re-dispatch through napi once and, after repeated
656
+ // failures, permanently disable ffi for this handler.
657
+ let written: number
658
+ if (bunFFI !== null && ingressPtr !== 0 && !ffiDisabled) {
659
+ try {
660
+ written = bunFFI.ingressHandlePacked(ingressPtr, input, body, handle.buffer)
661
+ ffiFailures = 0
662
+ } catch {
663
+ ffiFailures++
664
+ if (ffiFailures >= MAX_FFI_FAILURES) {
665
+ ffiDisabled = true
666
+ }
667
+ // napi re-run: crash-safe (napi-rs catch_unwind) and semantically
668
+ // identical — a transient ffi panic still serves the request.
669
+ written = handler.handleRequestPacked(input, body, handle.buffer)
670
+ }
671
+ } else {
672
+ written = handler.handleRequestPacked(input, body, handle.buffer)
673
+ }
705
674
 
706
- const ipStr = ip ?? EMPTY_IP;
675
+ const used = handle.buffer.subarray(0, written)
676
+ // Cached per-ArrayBuffer DataView: no per-request view allocation.
677
+ result.refresh(used, body ?? EMPTY_BODY, viewForArrayBuffer(used.buffer, used.byteOffset))
678
+ } catch (err) {
679
+ result.setInternalError()
680
+ const error = err instanceof Error ? err : new Error(String(err))
681
+ runtime.onError?.(req, requestIdStr, error)
682
+ logger?.error({
683
+ requestId: requestIdStr,
684
+ method: req.method,
685
+ path: pathForLog(req),
686
+ code: 'internal',
687
+ message: error.message,
688
+ })
689
+ }
707
690
 
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);
691
+ try {
692
+ const out = fn(result, ctx)
693
+ // The ingress result is invalidated when run() returns, so an async
694
+ // callback would observe a stale/zeroed result — reject it up front
695
+ // (parity with the fast path / createIngressSync).
696
+ assertSyncCallback(out, 'createIngressHandler().run()')
697
+ // Report observability for every outcome. Non-Response callbacks
698
+ // (e.g. echoHandler's object return) fall back to the decoded terminal
699
+ // status so they are not silently invisible to onResponse/logging.
700
+ const status = out instanceof Response ? out.status : safeTerminalStatus(result)
701
+ runtime.onResponse?.(req, result, status, requestIdStr)
702
+ if (logger) {
703
+ logger.request({
704
+ requestId: requestIdStr,
705
+ method: req.method,
706
+ path: pathForLog(req),
707
+ status,
708
+ durationMs: Math.round((performance.now() - startedAt) * 1000) / 1000,
709
+ ip: ipStr,
710
+ })
711
+ }
712
+ return out
713
+ } finally {
714
+ result.invalidate()
715
+ ctx.requestIdHeader = null
716
+ ctx.origin = null
717
+ }
726
718
  } 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);
719
+ // An unexpected error escaped run() after onRequest (a packing failure
720
+ // before the native call, or a throwing route callback). Report it so
721
+ // request accounting completes (metrics in_flight must not leak), then
722
+ // rethrow for the caller. The native-failure path above does NOT rethrow,
723
+ // so this fires at most once per request.
724
+ if (responseBorrowsBuffer && handle !== null) {
725
+ // `zeroCopyResponse` was called but the Response never escaped (the
726
+ // callback threw): the stream that would release the pooled buffer was
727
+ // never delivered. Release it here so the buffer isn't stuck in flight
728
+ // forever.
729
+ handle.release()
730
+ handle = null
731
+ responseBorrowsBuffer = false
734
732
  }
735
- return out;
733
+ const error = err instanceof Error ? err : new Error(String(err))
734
+ runtime.onError?.(req, requestIdStr, error)
735
+ logger?.error({
736
+ requestId: requestIdStr,
737
+ method: req.method,
738
+ path: pathForLog(req),
739
+ code: 'internal',
740
+ message: error.message,
741
+ })
742
+ throw error
736
743
  } finally {
737
- result.invalidate();
738
- ctx.requestIdHeader = null;
739
- ctx.origin = null;
744
+ // A zero-copy Response owns the handle until its body is consumed; in
745
+ // every other case (copy mode, terminal/error responses) the buffer is
746
+ // safe to return to the pool immediately.
747
+ if (handle !== null && !responseBorrowsBuffer) {
748
+ handle.release()
749
+ }
750
+ currentHandle = null
740
751
  }
741
752
  }
742
753
 
@@ -748,455 +759,6 @@ export function createIngressHandler(
748
759
  errorResponse,
749
760
  internalErrorResponse,
750
761
  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;
762
+ zeroCopyResponse,
1174
763
  }
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
764
  }