castrum 0.9.7 → 0.9.9

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 (108) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/castrum.darwin-arm64.node +0 -0
  4. package/castrum.darwin-x64.node +0 -0
  5. package/castrum.linux-arm64-gnu.node +0 -0
  6. package/castrum.linux-arm64-musl.node +0 -0
  7. package/castrum.linux-x64-gnu.node +0 -0
  8. package/castrum.linux-x64-musl.node +0 -0
  9. package/castrum.linux-x64-v3-gnu.node +0 -0
  10. package/castrum.win32-x64-msvc.node +0 -0
  11. package/dist/index.d.ts +664 -8
  12. package/dist/index.js +2014 -1327
  13. package/docs/BENCHMARKS.md +13 -0
  14. package/docs/CASE_STUDY.md +1 -1
  15. package/docs/ENVIRONMENT.md +8 -1
  16. package/docs/FFI_BUN_GUIDE.md +1 -1
  17. package/docs/INGRESS.md +4 -4
  18. package/docs/REPO_MAP.md +7 -5
  19. package/docs/RND-CONCURRENCY.md +20 -4
  20. package/docs/superpowers/plans/2026-09-18-castrum-hardening-cleanup.md +617 -0
  21. package/docs/superpowers/plans/2026-09-18-elysia-learnings-adoption.md +634 -0
  22. package/index.ts +6 -0
  23. package/package.json +4 -2
  24. package/rust/crypto/aead.rs +44 -0
  25. package/rust/crypto/argon2.rs +32 -2
  26. package/rust/crypto/jwt/tests.rs +19 -0
  27. package/rust/crypto/jwt/token.rs +43 -28
  28. package/rust/crypto/session.rs +6 -0
  29. package/rust/ffi/crypto.rs +63 -32
  30. package/rust/ffi/hashing.rs +48 -33
  31. package/rust/ffi/http.rs +62 -36
  32. package/rust/ffi/metrics.rs +6 -2
  33. package/rust/ffi/payload.rs +47 -27
  34. package/rust/ffi/task.rs +55 -35
  35. package/rust/ffi/tests.rs +24 -0
  36. package/rust/ffi/util.rs +40 -0
  37. package/rust/http/headers.rs +13 -0
  38. package/rust/http/multipart.rs +139 -0
  39. package/rust/ingress/api.rs +21 -5
  40. package/rust/ingress/mod.rs +7 -0
  41. package/rust/ingress/native_route.rs +25 -5
  42. package/rust/ingress/schema_cache.rs +266 -0
  43. package/rust/task/completion.rs +7 -0
  44. package/rust/task/mod.rs +2 -1
  45. package/rust/task/runtime.rs +55 -7
  46. package/rust/task/tests.rs +128 -1
  47. package/src/ingress/abort.ts +48 -0
  48. package/src/ingress/body.ts +104 -8
  49. package/src/ingress/decode/baked-result.ts +13 -5
  50. package/src/ingress/decode/fast-result.ts +1 -1
  51. package/src/ingress/decode/result-base.ts +16 -2
  52. package/src/ingress/handlers.ts +104 -31
  53. package/src/ingress/headers/memoized-headers.ts +40 -0
  54. package/src/ingress/health.ts +22 -0
  55. package/src/ingress/index.ts +3 -1
  56. package/src/ingress/native-route.ts +25 -6
  57. package/src/ingress/options.ts +26 -2
  58. package/src/ingress/packing/gather-raw-headers.ts +24 -8
  59. package/src/ingress/response/baked-response.ts +118 -28
  60. package/src/ingress/router.ts +50 -3
  61. package/src/ingress/routes/common.ts +35 -11
  62. package/src/ingress/routes/echo.ts +60 -7
  63. package/src/ingress/routes/json-write.ts +32 -8
  64. package/src/ingress/routes/native.ts +24 -5
  65. package/src/ingress/routes/options.ts +6 -2
  66. package/src/ingress/routes/responder.ts +29 -9
  67. package/src/ingress/routes/safe-record.ts +57 -0
  68. package/src/ingress/server-error.ts +111 -0
  69. package/src/ingress/server-node.ts +135 -37
  70. package/src/ingress/server.ts +143 -36
  71. package/src/ingress/shared.ts +1 -1
  72. package/src/ingress/types.ts +15 -0
  73. package/src/integration/pipeline.ts +6 -0
  74. package/src/loader/ops.ts +1 -1
  75. package/src/native/ffi/build/codecs-selftest.ts +305 -0
  76. package/src/native/ffi/build/codecs.ts +0 -296
  77. package/src/native/ffi/build/instances-selftest.ts +264 -0
  78. package/src/native/ffi/build/instances.ts +10 -259
  79. package/src/native/ffi/build/parse-selftest.ts +238 -0
  80. package/src/native/ffi/build/parse.ts +11 -234
  81. package/src/native/ffi/build/task.ts +2 -1
  82. package/src/native/ffi/build.ts +5 -1
  83. package/src/native/ffi/selftest.ts +3 -3
  84. package/src/native/ffi/symbols.ts +460 -0
  85. package/src/native/ffi/types.ts +6 -1
  86. package/src/native/ffi.ts +2 -434
  87. package/src/native/ingress-binding.ts +39 -0
  88. package/src/native/types.ts +6 -0
  89. package/src/runtime/codec.ts +24 -0
  90. package/src/runtime/detect.ts +14 -0
  91. package/src/runtime/types.ts +11 -0
  92. package/src/rust-ffi/batch/build.ts +18 -8
  93. package/src/rust-ffi/batch/types.ts +15 -0
  94. package/src/rust-ffi/context.ts +46 -3
  95. package/src/rust-ffi/options.ts +19 -0
  96. package/src/rust-ffi/scalar/crypto.ts +8 -0
  97. package/src/rust-ffi/scalar/factories.ts +53 -20
  98. package/src/rust-ffi/scalar/http.ts +60 -10
  99. package/src/rust-ffi/scalar/interface.ts +18 -1
  100. package/src/rust-ffi/scalar/payload.ts +27 -1
  101. package/src/rust-ffi/text.ts +5 -4
  102. package/src/shared/buffer-pool.ts +54 -9
  103. package/src/shared/codec.ts +26 -0
  104. package/src/shared/memory.ts +68 -0
  105. package/src/shared/metrics.ts +86 -3
  106. package/src/shared/packed/parsers.ts +64 -7
  107. package/src/shared/runtime.ts +7 -0
  108. package/src/task/runtime.ts +81 -37
@@ -22,6 +22,8 @@ export {
22
22
  serializeTraceParent,
23
23
  } from '../shared/trace'
24
24
  // ── Re-export the full public ingress API ─────────────────────────
25
+ // Request cancellation helpers (client-disconnect → 499).
26
+ export { ABORT_CODE, abortResponse, isAbortError } from './abort'
25
27
  export * from './constants'
26
28
  export * from './errors'
27
29
  export { createIngressFast, FastIngressResult } from './fast'
@@ -30,7 +32,7 @@ export type { CorsOptions, CorsStaticStrings } from './headers/cors'
30
32
  export type { HeaderTemplate, ResponseBuildContext } from './headers/fast-templates'
31
33
  export { buildResponseContext, headersForResult } from './headers/fast-templates'
32
34
  export type { SecurityHeadersOptions } from './headers/hsts'
33
- export { healthHandler, livenessHandler, readinessHandler } from './health'
35
+ export { healthHandler, livenessHandler, readinessHandler, staticLiveness } from './health'
34
36
  export type { IngressMetrics } from './metrics'
35
37
  // ── Observability (zero-dep): metrics, health probes ─────────────
36
38
  export { createIngressMetrics, metricsHandler } from './metrics'
@@ -66,7 +66,11 @@ export interface NativeRoute {
66
66
  runFrame(frame: Uint8Array): RouteWireResult
67
67
  /** Convenience: pack a `(query, cookie, body)` frame then {@link runFrame}. */
68
68
  run(query: string, cookie: string, body: Uint8Array | null): RouteWireResult
69
- /** Free the native handle (idempotent; safe to call once at shutdown). */
69
+ /**
70
+ * Free the native handle. Idempotent: a second call is a no-op. After it,
71
+ * {@link runFrame}/{@link run} throw rather than pass a freed handle into the
72
+ * native stack.
73
+ */
70
74
  destroy(): void
71
75
  }
72
76
 
@@ -109,16 +113,21 @@ export function createNativeRoute(plan: NativeRoutePlan = {}): NativeRoute {
109
113
  const parseQuery = plan.parseQuery === true
110
114
  const parseCookies = plan.parseCookies === true
111
115
 
112
- // Transport: bun:ffi PRIMARY on Bun; napi `Route` on Node / fallback. The
116
+ // Transport: bun:ffi PRIMARY on Bun; napi `Route` on Node / fallback. An FFI
117
+ // compile failure (invalid/unsupported descriptor) ALSO falls back to napi so
118
+ // the route stays usable instead of throwing "no active transport". The
113
119
  // compiled route owns exactly one handle; `destroy` frees it.
114
120
  const bunFFI: BunFFI | null = getBunFFI()
115
- const ffiHandle = bunFFI !== null ? compileFfi(bunFFI, descriptor) : 0
116
- const napiRoute = bunFFI === null ? compileNapi(descriptor) : null
121
+ let ffiHandle = bunFFI !== null ? compileFfi(bunFFI, descriptor) : 0
122
+ let napiRoute = bunFFI === null || ffiHandle === 0 ? compileNapi(descriptor) : null
117
123
 
118
124
  // Reusable output buffer for the needed-size convention (grow once, retry).
119
125
  let out = new Uint8Array(256)
120
126
 
121
127
  const runFrame = (frame: Uint8Array): RouteWireResult => {
128
+ if (ffiHandle === 0 && napiRoute === null) {
129
+ throw new Error('native route: already destroyed')
130
+ }
122
131
  let written: number
123
132
  if (ffiHandle !== 0 && bunFFI !== null) {
124
133
  written = bunFFI.routeRun(ffiHandle, frame, out)
@@ -153,7 +162,10 @@ export function createNativeRoute(plan: NativeRoutePlan = {}): NativeRoute {
153
162
  destroy: () => {
154
163
  if (ffiHandle !== 0 && bunFFI !== null) {
155
164
  bunFFI.routeDestroy(ffiHandle)
165
+ ffiHandle = 0
156
166
  }
167
+ napiRoute?.destroy?.()
168
+ napiRoute = null
157
169
  },
158
170
  }
159
171
  }
@@ -170,10 +182,17 @@ function compileFfi(bunFFI: BunFFI, descriptor: Uint8Array): number {
170
182
  /** Compile through the napi `Route` class (Node / fallback transport). */
171
183
  function compileNapi(
172
184
  descriptor: Uint8Array,
173
- ): { run: (frame: Uint8Array, out: Uint8Array) => number } | null {
185
+ ): { run: (frame: Uint8Array, out: Uint8Array) => number; destroy?: () => void } | null {
174
186
  const addon = getAddon()
175
187
  const Route = (
176
- addon as { Route?: new (d: Uint8Array) => { run: (f: Uint8Array, o: Uint8Array) => number } }
188
+ addon as {
189
+ Route?: new (
190
+ d: Uint8Array,
191
+ ) => {
192
+ run: (f: Uint8Array, o: Uint8Array) => number
193
+ destroy?: () => void
194
+ }
195
+ }
177
196
  ).Route
178
197
  if (typeof Route !== 'function') {
179
198
  return null // addon without the route stack (pre-rebuild) — caller falls back
@@ -184,6 +184,10 @@ let trustProxyWarned = false
184
184
  * `X-Forwarded-For`/`X-Real-IP`, so a client can forge its IP and bypass
185
185
  * IP-based rate limiting. Prefer the `trustedProxies` network-list API and
186
186
  * only enable proxy trust behind a trusted edge.
187
+ *
188
+ * Precedence: when `trustedProxies` is also set it wins, so
189
+ * `{ trustProxy: true, trustedProxies: { enabled: false } }` trusts NOTHING —
190
+ * the safer mode, not the "trust everything" this warning describes.
187
191
  */
188
192
  export function warnTrustProxyDeprecated(): void {
189
193
  if (trustProxyWarned) return
@@ -191,7 +195,27 @@ export function warnTrustProxyDeprecated(): void {
191
195
  console.warn(
192
196
  '[castrum] WARN: `trustProxy: true` is deprecated and trusts EVERY hop — ' +
193
197
  'clients can spoof X-Forwarded-For / X-Real-IP to bypass IP-based rate ' +
194
- 'limiting. Use `trustedProxies: { enabled: true, networks: [...] }` and ' +
195
- 'only enable proxy trust behind a trusted edge.',
198
+ 'limiting. Use `trustedProxies: { enabled: true, networks: [...] }` ' +
199
+ '(which takes precedence) and only enable proxy trust behind a trusted edge.',
200
+ )
201
+ }
202
+
203
+ let rateLimitNoIpWarned = false
204
+
205
+ /**
206
+ * Warn once (per process) when rate limiting is enabled without a `getIp`
207
+ * resolver. The native pipeline then resolves an empty client IP, so EVERY
208
+ * client shares one bucket — one attacker can exhaust it and 429 everyone.
209
+ *
210
+ * A single global bucket is a legitimate choice, so this warns rather than
211
+ * fails; provide `getIp` for per-client isolation.
212
+ */
213
+ export function warnRateLimitWithoutGetIp(): void {
214
+ if (rateLimitNoIpWarned) return
215
+ rateLimitNoIpWarned = true
216
+ console.warn(
217
+ '[castrum] WARN: rate limiting is enabled without `getIp` — every client ' +
218
+ 'shares ONE bucket, so one client can exhaust it for everyone. Provide ' +
219
+ '`getIp` for per-client limits (or use a global limit deliberately).',
196
220
  )
197
221
  }
@@ -12,14 +12,6 @@ import { writeHeaderPair } from './header-packing'
12
12
  import { getHeaderBuf, MAX_SMALL_HEADER_BYTES } from './scratch'
13
13
  import { forEachSelectedHeader, HDR_ORIGIN } from './select-headers'
14
14
 
15
- // Shared per-header size guards (single source of truth in scratch.ts).
16
- // Re-exported for back-compat with imports that referenced them here.
17
- export {
18
- MAX_COOKIE_HEADER_BYTES,
19
- MAX_SMALL_HEADER_BYTES,
20
- MAX_XFF_HEADER_BYTES,
21
- } from './scratch'
22
-
23
15
  /**
24
16
  * Gather the request headers selected by `plan` as a packed byte block
25
17
  * (`[u16 count] { [u16 name_len][name][u32 val_len][value] }`) written into a
@@ -161,3 +153,27 @@ function cachedOriginBlock(origin: string): Uint8Array {
161
153
  ORIGIN_BLOCK_CACHE.set(origin, block)
162
154
  return block
163
155
  }
156
+
157
+ /**
158
+ * Pre-warm the origin-packed-block cache for the configured exact
159
+ * `allow-origin` values (load-time-for-RPS trade): the FIRST request from each
160
+ * known origin then skips the shared encode + pack + scratch write entirely,
161
+ * like every later request does. Oversized values are skipped (they map to the
162
+ * EMPTY block anyway) and wildcard/pattern values are skipped (they never
163
+ * resolve to their own literal origin), while the module-level cache stays
164
+ * bounded by `ORIGIN_CACHE_MAX`. No-op for any plan that never consults it.
165
+ */
166
+ export function prewarmOriginBlocks(origins: readonly string[]): void {
167
+ for (const origin of origins) {
168
+ if (
169
+ typeof origin === 'string' &&
170
+ origin.length > 0 &&
171
+ origin.length <= MAX_SMALL_HEADER_BYTES &&
172
+ // A wildcard/pattern never resolves to its own literal origin string, so
173
+ // caching it would only burn a slot (the handler filters these too).
174
+ !origin.includes('*')
175
+ ) {
176
+ cachedOriginBlock(origin)
177
+ }
178
+ }
179
+ }
@@ -24,6 +24,7 @@ import {
24
24
  HV_RATE_LIMITED,
25
25
  } from '../constants'
26
26
  import type { BakedIngressResult } from '../decode/baked-result'
27
+ import { memoizedHeaders } from '../headers/memoized-headers'
27
28
  import { secondsFromMs } from '../shared'
28
29
  import { safeTerminalStatus } from '../status'
29
30
  import type { BakedContext, OptimizedIngressHandler } from '../types'
@@ -31,6 +32,14 @@ import { ERROR_BODIES, ERROR_CODE_BODIES, rateLimitedBody } from './error-bodies
31
32
 
32
33
  const EMPTY_BODY = new Uint8Array(0)
33
34
 
35
+ /**
36
+ * Upper bound on the per-`(variant, origin)` memoized header arrays. Under
37
+ * wildcard CORS EVERY Origin is allowed, so this cache is attacker-keyed —
38
+ * without a cap a stream of distinct Origins grows it without bound (remote
39
+ * memory exhaustion). Evicts the oldest entry (Map preserves insertion order).
40
+ */
41
+ export const ORIGIN_HEADER_CACHE_MAX = 64
42
+
34
43
  /** Shared per-request state between `run()` (handlers.ts) and the builders. */
35
44
  export interface BakedResponseState {
36
45
  /** The pooled output buffer backing the current `run()` (null outside one). */
@@ -55,6 +64,33 @@ export interface BakedResponseBuildersDeps {
55
64
  state: BakedResponseState
56
65
  }
57
66
 
67
+ /**
68
+ * Pre-warm the per-origin header-array cache AND the memoized `Headers`
69
+ * `WeakMap` for the configured exact allow-origins at the success `variant`.
70
+ *
71
+ * `variant` MUST be the one the success path actually returns
72
+ * (`HV_JSON | HV_CORS_SIMPLE`, plus `HV_RATE_ACTIVE` when rate limiting is
73
+ * enabled). Warming a different variant populates an unreachable cache entry
74
+ * and buys nothing.
75
+ *
76
+ * @param responseHeaders The handler's header builder (from this module).
77
+ * @param origins Exact, concrete allow-origins (wildcards excluded by caller).
78
+ * @param variant The success header variant to warm.
79
+ */
80
+ export function prewarmOriginHeaders(
81
+ responseHeaders: (
82
+ variant: number,
83
+ requestIdHeader: string | null,
84
+ origin: string | null,
85
+ ) => [string, string][],
86
+ origins: readonly string[],
87
+ variant: number,
88
+ ): void {
89
+ for (const origin of origins) {
90
+ memoizedHeaders(responseHeaders(variant, null, origin))
91
+ }
92
+ }
93
+
58
94
  /**
59
95
  * Build the seven response-builder methods of `OptimizedIngressHandler`
60
96
  * (everything except `run`), bound to this handler's header templates and
@@ -73,6 +109,72 @@ export function buildBakedResponseBuilders(
73
109
  state,
74
110
  } = deps
75
111
 
112
+ /**
113
+ * The cacheable base entries for a `(variant, origin)`: the baked template
114
+ * plus the reflected `access-control-allow-origin`. Memoized per origin so
115
+ * the Origin-present case (the common browser/bench case) stays
116
+ * allocation-free after the first hit for EACH distinct origin (a single-slot
117
+ * memo thrashed when clients alternated between allowed origins). Bounded by
118
+ * `ORIGIN_HEADER_CACHE_MAX`.
119
+ */
120
+ function originBaseEntries(variant: number, origin: string): [string, string][] {
121
+ const key = `${variant & 31}\u0000${origin}`
122
+ const cached = originHeaderCache.get(key)
123
+ if (cached !== undefined) {
124
+ return cached
125
+ }
126
+ const template = headerTemplates[variant & 31] ?? headerTemplates[0] ?? []
127
+ const entries = new Array<[string, string]>(template.length + 1)
128
+ let i = 0
129
+ for (const pair of template) {
130
+ entries[i++] = pair
131
+ }
132
+ entries[i] = ['access-control-allow-origin', origin]
133
+ if (originHeaderCache.size >= ORIGIN_HEADER_CACHE_MAX) {
134
+ const oldest = originHeaderCache.keys().next().value
135
+ if (oldest !== undefined) originHeaderCache.delete(oldest)
136
+ }
137
+ originHeaderCache.set(key, entries)
138
+ return entries
139
+ }
140
+
141
+ /**
142
+ * Success-path headers as a `Headers` instance. Uses the memoized base
143
+ * (template + reflected origin) so the memoized-`Headers` fast path is kept
144
+ * even when per-request rate / request-id extras are present — a fresh array
145
+ * there would force a `new Headers` on every response. Extras are applied to
146
+ * a CLONE of the base, so the memoized instance is never mutated.
147
+ */
148
+ function successHeaders(
149
+ variant: number,
150
+ requestIdHeader: string | null,
151
+ origin: string | null,
152
+ rateRemaining?: number,
153
+ rateResetSecs?: number,
154
+ ): Headers {
155
+ const needsRequestId = emitRequestIdHeader && requestIdHeader !== null
156
+ const needsOrigin =
157
+ ((variant & HV_CORS_SIMPLE) !== 0 || (variant & HV_CORS_PREFLIGHT) !== 0) && origin !== null
158
+ const needsRate = (variant & HV_RATE_ACTIVE) !== 0
159
+
160
+ const base = needsOrigin
161
+ ? memoizedHeaders(originBaseEntries(variant, origin as string))
162
+ : memoizedHeaders(headerTemplates[variant & 31] ?? headerTemplates[0] ?? [])
163
+
164
+ if (!needsRequestId && !needsRate) {
165
+ return base
166
+ }
167
+ const headers = new Headers(base)
168
+ if (needsRequestId) {
169
+ headers.set('x-request-id', requestIdHeader as string)
170
+ }
171
+ if (needsRate) {
172
+ headers.set('ratelimit-remaining', String(rateRemaining ?? 0))
173
+ headers.set('ratelimit-reset', String(rateResetSecs ?? 0))
174
+ }
175
+ return headers
176
+ }
177
+
76
178
  function responseHeaders(
77
179
  variant: number,
78
180
  requestIdHeader: string | null,
@@ -97,24 +199,7 @@ export function buildBakedResponseBuilders(
97
199
  }
98
200
 
99
201
  if (!needsRequestId && !needsRate && !needsRetry && needsOrigin) {
100
- // Steady-state CORS: only the origin varies — cache the augmented array
101
- // per (variant, origin) so the Origin-present case (the common
102
- // browser/bench case) stays allocation-free after the first hit for EACH
103
- // distinct origin (a single-slot memo thrashed when clients alternate
104
- // between allowed origins, allocating a fresh array on every switch).
105
- const key = `${variant & 31}\u0000${origin as string}`
106
- const cached = originHeaderCache.get(key)
107
- if (cached !== undefined) {
108
- return cached
109
- }
110
- const entries = new Array<[string, string]>(template.length + 1)
111
- let i = 0
112
- for (const pair of template) {
113
- entries[i++] = pair
114
- }
115
- entries[i] = ['access-control-allow-origin', origin as string]
116
- originHeaderCache.set(key, entries)
117
- return entries
202
+ return originBaseEntries(variant, origin as string)
118
203
  }
119
204
 
120
205
  let extra = 0
@@ -210,13 +295,15 @@ export function buildBakedResponseBuilders(
210
295
  if (preflightAllowed) {
211
296
  return new Response(null, {
212
297
  status: 204,
213
- headers: responseHeaders(
214
- result.headerVariant,
215
- ctx.requestIdHeader,
216
- ctx.origin,
217
- result.rateRemaining,
218
- result.rateResetMs > 0 ? secondsFromMs(result.rateResetMs) : undefined,
219
- result.retryAfterMs > 0 ? secondsFromMs(result.retryAfterMs) : undefined,
298
+ headers: memoizedHeaders(
299
+ responseHeaders(
300
+ result.headerVariant,
301
+ ctx.requestIdHeader,
302
+ ctx.origin,
303
+ result.rateRemaining,
304
+ result.rateResetMs > 0 ? secondsFromMs(result.rateResetMs) : undefined,
305
+ result.retryAfterMs > 0 ? secondsFromMs(result.retryAfterMs) : undefined,
306
+ ),
220
307
  ),
221
308
  })
222
309
  }
@@ -230,7 +317,7 @@ export function buildBakedResponseBuilders(
230
317
 
231
318
  return new Response(body, {
232
319
  status,
233
- headers: terminalHeaders(result.headerVariant, ctx, result),
320
+ headers: memoizedHeaders(terminalHeaders(result.headerVariant, ctx, result)),
234
321
  })
235
322
  }
236
323
 
@@ -247,14 +334,16 @@ export function buildBakedResponseBuilders(
247
334
 
248
335
  return new Response(body, {
249
336
  status,
250
- headers: terminalHeaders(result?.headerVariant ?? HV_JSON, ctx, result),
337
+ headers: memoizedHeaders(terminalHeaders(result?.headerVariant ?? HV_JSON, ctx, result)),
251
338
  })
252
339
  }
253
340
 
254
341
  function internalErrorResponse(ctx: BakedContext, result?: BakedIngressResult): Response {
255
342
  return new Response(ERROR_BODIES.internal, {
256
343
  status: 500,
257
- headers: terminalHeaders(result?.headerVariant ?? HV_JSON, ctx, result ?? null),
344
+ headers: memoizedHeaders(
345
+ terminalHeaders(result?.headerVariant ?? HV_JSON, ctx, result ?? null),
346
+ ),
258
347
  })
259
348
  }
260
349
 
@@ -292,6 +381,7 @@ export function buildBakedResponseBuilders(
292
381
 
293
382
  return {
294
383
  responseHeaders,
384
+ successHeaders,
295
385
  terminalHeaders,
296
386
  terminalResponse,
297
387
  errorResponse,
@@ -22,12 +22,12 @@
22
22
  // the deleted dead wire).
23
23
 
24
24
  import type { BakedIngressResult } from './decode/baked-result'
25
- import { type BakedIngressRuntime, createIngressHandler } from './handlers'
25
+ import { type BakedIngressRuntime, createIngressHandler, RATE_LIMIT_U32_MAX } from './handlers'
26
26
  import type { NativeRoutePlan } from './native-route'
27
- import type { IngressHandlerOptions } from './options'
27
+ import { type IngressHandlerOptions, warnRateLimitWithoutGetIp } from './options'
28
28
  import type { PathMatch } from './path-matcher'
29
29
  import { buildPathMatcher } from './path-matcher'
30
- import type { BakedRoute, RouteHandler } from './server'
30
+ import type { BakedRoute, RouteHandler, StaticRoute } from './server'
31
31
  import { buildRouteHandlers } from './server'
32
32
  import type { BakedContext, NativeResponder, OptimizedIngressHandler, TerminalStyle } from './types'
33
33
 
@@ -88,6 +88,20 @@ export interface RouterRouteSpec {
88
88
  /** Terminal envelope style for this route's responder (default: router-level
89
89
  * `terminalStyle` or `'castrum'`). */
90
90
  terminalStyle?: TerminalStyle
91
+ /**
92
+ * A prebuilt constant response served directly for GET, OUTSIDE the ingress
93
+ * pipeline (static route promotion). Like `raw`, it OWNS THE WHOLE SPEC: the
94
+ * router compiles no `IngressInner` for a `static` route, so `read`/`write`/
95
+ * `echo`/etc. on the same spec are ignored.
96
+ *
97
+ * This differs from `BakedRoute.static` in src/ingress/server.ts, where
98
+ * `static` only takes GET precedence and leaves `write`/`echo`/`delete`
99
+ * wired — the router cannot mirror that because a `static`-only spec has no
100
+ * compiled ingress to hand those methods. Do not unify the two semantics.
101
+ *
102
+ * See `BakedRoute.static` for the one-shot-body contract.
103
+ */
104
+ static?: StaticRoute
91
105
  /**
92
106
  * A raw request→Response handler served directly for GET, OUTSIDE the
93
107
  * ingress pipeline (health/metrics probes, /metrics). When set, `read`/
@@ -175,6 +189,17 @@ export function createIngressRouter(options: CreateIngressRouterOptions): Ingres
175
189
  if (options.copyBody !== undefined) baseOpts.copyBody = options.copyBody
176
190
 
177
191
  for (const [path, spec] of Object.entries(options.routes)) {
192
+ if (spec.static !== undefined) {
193
+ // Static promotion: a prebuilt constant response owns the route. Placed
194
+ // verbatim so Bun.serve can serve it from its native table (a bare
195
+ // Response) with zero JS.
196
+ bakedRoutes[path] = {
197
+ static: spec.static,
198
+ maxBodyBytes: spec.maxBodyBytes,
199
+ bodyTimeoutMs: spec.bodyTimeoutMs,
200
+ }
201
+ continue
202
+ }
178
203
  if (spec.raw) {
179
204
  // Raw handler: served directly, outside the pipeline.
180
205
  bakedRoutes[path] = { read: spec.raw as RouteHandler }
@@ -199,6 +224,18 @@ export function createIngressRouter(options: CreateIngressRouterOptions): Ingres
199
224
  const handler = createIngressHandler(spec.options ?? {}, runtime)
200
225
  compiled[path] = handler
201
226
 
227
+ // Rate limiting without a getIp resolver collapses every client into ONE
228
+ // bucket; warn once (a global bucket can be deliberate, so don't fail).
229
+ const routeLimit = (spec.options?.rateLimit as { limit?: number } | undefined)?.limit
230
+ if (
231
+ typeof routeLimit === 'number' &&
232
+ routeLimit > 0 &&
233
+ routeLimit !== RATE_LIMIT_U32_MAX &&
234
+ options.getIp === undefined
235
+ ) {
236
+ warnRateLimitWithoutGetIp()
237
+ }
238
+
202
239
  if (spec.responder) {
203
240
  // Responder route: native decides + rejects; JS builds the 2xx.
204
241
  bakedRoutes[path] = {
@@ -265,6 +302,16 @@ export function createIngressRouter(options: CreateIngressRouterOptions): Ingres
265
302
  if (typeof methodHandler === 'function') {
266
303
  return (methodHandler as RouteHandler)(req, srv, matched.params)
267
304
  }
305
+ if (methodHandler instanceof Response) {
306
+ // Static route (bare Response value). `instanceof Response` assumes the
307
+ // value was built with the global `Response` constructor (the one this
308
+ // router also uses). `clone()` tees the body without consuming the
309
+ // original, so every request gets a fresh, complete body. A `static`
310
+ // factory is a function and is handled above. The cast bridges Bun's
311
+ // `Response.clone()` return type (which resolves against the bundled
312
+ // fetch types) to this module's global `Response`.
313
+ return methodHandler.clone() as unknown as Response
314
+ }
268
315
  // A route matched but this method isn't wired — 405, or a bare OPTIONS 204
269
316
  // when the route answers preflights.
270
317
  if (req.method === 'OPTIONS' && typeof matched.methods.OPTIONS === 'function') {
@@ -1,6 +1,8 @@
1
1
  // src/ingress/routes/common.ts — Shared route-handler options + helpers.
2
2
 
3
+ import { abortResponse } from '../abort'
3
4
  import type { BakedIngressResult } from '../decode/baked-result'
5
+ import { memoizedHeaders } from '../headers/memoized-headers'
4
6
  import { secondsFromMs } from '../shared'
5
7
  import type { BakedContext, OptimizedIngressHandler } from '../types'
6
8
 
@@ -52,6 +54,10 @@ export function resolveIp(
52
54
  * every pre-baked success path. A plain function call, so the hot path keeps
53
55
  * its single init-object allocation and nothing else.
54
56
  *
57
+ * The header array is wrapped in a memoized `Headers` (see
58
+ * `headers/memoized-headers.ts`) — identical bytes on the wire, ~4x cheaper
59
+ * Response construction for the recurring header sets.
60
+ *
55
61
  * @param status Success status; defaults to 200.
56
62
  */
57
63
  export function buildSuccessInit(
@@ -60,16 +66,27 @@ export function buildSuccessInit(
60
66
  ctx: BakedContext,
61
67
  status = 200,
62
68
  ): ResponseInit {
63
- return {
64
- status,
65
- headers: ingress.responseHeaders(
66
- result.headerVariant,
67
- ctx.requestIdHeader,
68
- ctx.origin,
69
- result.rateRemaining,
70
- result.rateResetMs > 0 ? secondsFromMs(result.rateResetMs) : undefined,
71
- ),
72
- }
69
+ const rateResetSecs = result.rateResetMs > 0 ? secondsFromMs(result.rateResetMs) : undefined
70
+ // Prefer the `Headers`-producing path (keeps the memoized base even with
71
+ // per-request extras); mocks that only implement `responseHeaders` fall back.
72
+ const headers = ingress.successHeaders
73
+ ? ingress.successHeaders(
74
+ result.headerVariant,
75
+ ctx.requestIdHeader,
76
+ ctx.origin,
77
+ result.rateRemaining,
78
+ rateResetSecs,
79
+ )
80
+ : memoizedHeaders(
81
+ ingress.responseHeaders(
82
+ result.headerVariant,
83
+ ctx.requestIdHeader,
84
+ ctx.origin,
85
+ result.rateRemaining,
86
+ rateResetSecs,
87
+ ),
88
+ )
89
+ return { status, headers }
73
90
  }
74
91
 
75
92
  /**
@@ -77,11 +94,18 @@ export function buildSuccessInit(
77
94
  * runner: resolve the client IP once and dispatch through the native pipeline
78
95
  * (`ingress.run` with a null body). Used by the read / head / fallback route
79
96
  * factories whose responder never needs the request object.
97
+ *
98
+ * A disconnected client (`req.signal?.aborted`) short-circuits to the 499
99
+ * cancelled response BEFORE `ingress.run`, so no pooled output buffer is
100
+ * acquired for a request that can no longer be delivered.
80
101
  */
81
102
  export function runBaked(
82
103
  ingress: OptimizedIngressHandler,
83
104
  opts: BakedHandlerOptions,
84
105
  respond: (result: BakedIngressResult, ctx: BakedContext) => Response,
85
106
  ): (req: Request, srv?: unknown) => Response {
86
- return (req, srv) => ingress.run<Response>(req, resolveIp(req, srv, opts), null, respond)
107
+ return (req, srv) => {
108
+ if (req.signal?.aborted) return abortResponse()
109
+ return ingress.run<Response>(req, resolveIp(req, srv, opts), null, respond)
110
+ }
87
111
  }