castrum 0.9.7 → 0.9.8

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 (84) 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 +501 -5
  12. package/dist/index.js +1648 -1325
  13. package/docs/BENCHMARKS.md +13 -0
  14. package/docs/CASE_STUDY.md +1 -1
  15. package/docs/ENVIRONMENT.md +1 -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/superpowers/plans/2026-09-18-castrum-hardening-cleanup.md +617 -0
  20. package/index.ts +1 -0
  21. package/package.json +3 -2
  22. package/rust/crypto/aead.rs +44 -0
  23. package/rust/crypto/argon2.rs +32 -2
  24. package/rust/crypto/jwt/tests.rs +19 -0
  25. package/rust/crypto/jwt/token.rs +43 -28
  26. package/rust/crypto/session.rs +6 -0
  27. package/rust/ffi/crypto.rs +63 -32
  28. package/rust/ffi/hashing.rs +48 -33
  29. package/rust/ffi/http.rs +62 -36
  30. package/rust/ffi/metrics.rs +6 -2
  31. package/rust/ffi/payload.rs +47 -27
  32. package/rust/ffi/task.rs +51 -32
  33. package/rust/ffi/tests.rs +24 -0
  34. package/rust/ffi/util.rs +40 -0
  35. package/rust/http/headers.rs +13 -0
  36. package/rust/http/multipart.rs +139 -0
  37. package/rust/ingress/native_route.rs +21 -3
  38. package/rust/task/completion.rs +7 -0
  39. package/rust/task/mod.rs +1 -1
  40. package/rust/task/tests.rs +30 -0
  41. package/src/ingress/decode/baked-result.ts +13 -5
  42. package/src/ingress/decode/fast-result.ts +1 -1
  43. package/src/ingress/decode/result-base.ts +16 -2
  44. package/src/ingress/handlers.ts +103 -31
  45. package/src/ingress/headers/memoized-headers.ts +40 -0
  46. package/src/ingress/native-route.ts +25 -6
  47. package/src/ingress/options.ts +26 -2
  48. package/src/ingress/packing/gather-raw-headers.ts +24 -8
  49. package/src/ingress/response/baked-response.ts +118 -28
  50. package/src/ingress/router.ts +14 -2
  51. package/src/ingress/routes/common.ts +26 -10
  52. package/src/ingress/routes/echo.ts +50 -7
  53. package/src/ingress/routes/json-write.ts +21 -8
  54. package/src/ingress/routes/responder.ts +4 -4
  55. package/src/ingress/server-node.ts +33 -14
  56. package/src/ingress/server.ts +3 -4
  57. package/src/ingress/shared.ts +1 -1
  58. package/src/ingress/types.ts +15 -0
  59. package/src/loader/ops.ts +1 -1
  60. package/src/native/ffi/build/codecs-selftest.ts +305 -0
  61. package/src/native/ffi/build/codecs.ts +0 -296
  62. package/src/native/ffi/build/instances-selftest.ts +264 -0
  63. package/src/native/ffi/build/instances.ts +10 -259
  64. package/src/native/ffi/build/parse-selftest.ts +238 -0
  65. package/src/native/ffi/build/parse.ts +11 -234
  66. package/src/native/ffi/build.ts +5 -1
  67. package/src/native/ffi/selftest.ts +3 -3
  68. package/src/native/ffi/symbols.ts +460 -0
  69. package/src/native/ffi.ts +2 -434
  70. package/src/native/ingress-binding.ts +39 -0
  71. package/src/runtime/codec.ts +24 -0
  72. package/src/runtime/types.ts +11 -0
  73. package/src/rust-ffi/batch/build.ts +18 -8
  74. package/src/rust-ffi/batch/types.ts +15 -0
  75. package/src/rust-ffi/options.ts +19 -0
  76. package/src/rust-ffi/scalar/crypto.ts +8 -0
  77. package/src/rust-ffi/scalar/factories.ts +47 -20
  78. package/src/rust-ffi/scalar/http.ts +60 -10
  79. package/src/rust-ffi/scalar/interface.ts +4 -1
  80. package/src/rust-ffi/scalar/payload.ts +27 -1
  81. package/src/shared/buffer-pool.ts +44 -9
  82. package/src/shared/codec.ts +26 -0
  83. package/src/shared/packed/parsers.ts +64 -7
  84. package/src/task/runtime.ts +29 -28
@@ -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,9 +22,9 @@
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
30
  import type { BakedRoute, RouteHandler } from './server'
@@ -199,6 +199,18 @@ export function createIngressRouter(options: CreateIngressRouterOptions): Ingres
199
199
  const handler = createIngressHandler(spec.options ?? {}, runtime)
200
200
  compiled[path] = handler
201
201
 
202
+ // Rate limiting without a getIp resolver collapses every client into ONE
203
+ // bucket; warn once (a global bucket can be deliberate, so don't fail).
204
+ const routeLimit = (spec.options?.rateLimit as { limit?: number } | undefined)?.limit
205
+ if (
206
+ typeof routeLimit === 'number' &&
207
+ routeLimit > 0 &&
208
+ routeLimit !== RATE_LIMIT_U32_MAX &&
209
+ options.getIp === undefined
210
+ ) {
211
+ warnRateLimitWithoutGetIp()
212
+ }
213
+
202
214
  if (spec.responder) {
203
215
  // Responder route: native decides + rejects; JS builds the 2xx.
204
216
  bakedRoutes[path] = {
@@ -1,6 +1,7 @@
1
1
  // src/ingress/routes/common.ts — Shared route-handler options + helpers.
2
2
 
3
3
  import type { BakedIngressResult } from '../decode/baked-result'
4
+ import { memoizedHeaders } from '../headers/memoized-headers'
4
5
  import { secondsFromMs } from '../shared'
5
6
  import type { BakedContext, OptimizedIngressHandler } from '../types'
6
7
 
@@ -52,6 +53,10 @@ export function resolveIp(
52
53
  * every pre-baked success path. A plain function call, so the hot path keeps
53
54
  * its single init-object allocation and nothing else.
54
55
  *
56
+ * The header array is wrapped in a memoized `Headers` (see
57
+ * `headers/memoized-headers.ts`) — identical bytes on the wire, ~4x cheaper
58
+ * Response construction for the recurring header sets.
59
+ *
55
60
  * @param status Success status; defaults to 200.
56
61
  */
57
62
  export function buildSuccessInit(
@@ -60,16 +65,27 @@ export function buildSuccessInit(
60
65
  ctx: BakedContext,
61
66
  status = 200,
62
67
  ): 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
- }
68
+ const rateResetSecs = result.rateResetMs > 0 ? secondsFromMs(result.rateResetMs) : undefined
69
+ // Prefer the `Headers`-producing path (keeps the memoized base even with
70
+ // per-request extras); mocks that only implement `responseHeaders` fall back.
71
+ const headers = ingress.successHeaders
72
+ ? ingress.successHeaders(
73
+ result.headerVariant,
74
+ ctx.requestIdHeader,
75
+ ctx.origin,
76
+ result.rateRemaining,
77
+ rateResetSecs,
78
+ )
79
+ : memoizedHeaders(
80
+ ingress.responseHeaders(
81
+ result.headerVariant,
82
+ ctx.requestIdHeader,
83
+ ctx.origin,
84
+ result.rateRemaining,
85
+ rateResetSecs,
86
+ ),
87
+ )
88
+ return { status, headers }
73
89
  }
74
90
 
75
91
  /**
@@ -7,9 +7,40 @@ import { DEFAULT_BODY_TIMEOUT_MS, DEFAULT_MAX_BODY_BYTES, secondsFromMs } from '
7
7
  import type { OptimizedIngressHandler } from '../types'
8
8
  import { type BakedHandlerOptions, resolveIp } from './common'
9
9
 
10
+ /**
11
+ * Content types safe to echo back for an attacker-supplied body. Anything else
12
+ * — notably `text/html`, `application/xhtml+xml`, `image/svg+xml` and XML — is
13
+ * coerced to `application/octet-stream`: reflecting an active type would let a
14
+ * `POST /echo` execute the request body as script in a browser.
15
+ */
16
+ const SAFE_ECHO_CONTENT_TYPES = new Set([
17
+ 'application/json',
18
+ 'application/octet-stream',
19
+ 'text/plain',
20
+ ])
21
+
22
+ /** Lowercased media type with parameters stripped (`text/html; charset=x` → `text/html`). */
23
+ function echoMediaTypeOf(contentType: string): string {
24
+ const semi = contentType.indexOf(';')
25
+ return (semi >= 0 ? contentType.slice(0, semi) : contentType).trim().toLowerCase()
26
+ }
27
+
28
+ /**
29
+ * Coerce a client Content-Type into a type that is safe to echo back.
30
+ *
31
+ * @param contentType - The raw request `Content-Type` header value.
32
+ * @returns The value unchanged when it is a safe non-active type, else
33
+ * `application/octet-stream`.
34
+ */
35
+ export function safeEchoContentType(contentType: string): string {
36
+ return SAFE_ECHO_CONTENT_TYPES.has(echoMediaTypeOf(contentType))
37
+ ? contentType
38
+ : 'application/octet-stream'
39
+ }
40
+
10
41
  /**
11
42
  * Pre-baked echo handler: streams the request body back with the client's
12
- * Content-Type, bounded by `maxBodyBytes`.
43
+ * Content-Type (coerced to a non-active type), bounded by `maxBodyBytes`.
13
44
  */
14
45
  export function echoHandler(
15
46
  ingress: OptimizedIngressHandler,
@@ -50,7 +81,19 @@ export function echoHandler(
50
81
 
51
82
  const baseHeaders: ReadonlyArray<[string, string]> = prep.headers ?? []
52
83
 
53
- const requestedContentType = req.headers.get('content-type') ?? 'application/octet-stream'
84
+ const requestedContentType = safeEchoContentType(
85
+ req.headers.get('content-type') ?? 'application/octet-stream',
86
+ )
87
+
88
+ // Content-Type + `nosniff` (unless the security config already emitted it),
89
+ // so the echoed body can never be sniffed into an active type.
90
+ const withEchoHeaders = (contentType: string): [string, string][] => {
91
+ const headers = ingress.withContentType(baseHeaders, contentType)
92
+ if (!headers.some(([name]) => name.toLowerCase() === 'x-content-type-options')) {
93
+ headers.push(['x-content-type-options', 'nosniff'])
94
+ }
95
+ return headers
96
+ }
54
97
 
55
98
  const contentLengthHeader = req.headers.get('content-length')
56
99
  const contentLength = contentLengthHeader === null ? NaN : Number(contentLengthHeader)
@@ -59,20 +102,20 @@ export function echoHandler(
59
102
  if (contentLength > maxBodyBytes) {
60
103
  return new Response(ERROR_BODIES.body_too_large, {
61
104
  status: 413,
62
- headers: ingress.withContentType(baseHeaders, 'application/json'),
105
+ headers: withEchoHeaders('application/json'),
63
106
  })
64
107
  }
65
108
 
66
109
  if (contentLength <= 0 || req.body === null) {
67
110
  return new Response(null, {
68
111
  status: 200,
69
- headers: ingress.withContentType(baseHeaders, requestedContentType),
112
+ headers: withEchoHeaders(requestedContentType),
70
113
  })
71
114
  }
72
115
 
73
116
  return new Response(req.body, {
74
117
  status: 200,
75
- headers: ingress.withContentType(baseHeaders, requestedContentType),
118
+ headers: withEchoHeaders(requestedContentType),
76
119
  })
77
120
  }
78
121
 
@@ -84,7 +127,7 @@ export function echoHandler(
84
127
 
85
128
  return new Response(bodyBytes.byteLength > 0 ? bodyBytes : null, {
86
129
  status: 200,
87
- headers: ingress.withContentType(baseHeaders, requestedContentType),
130
+ headers: withEchoHeaders(requestedContentType),
88
131
  })
89
132
  } catch (err) {
90
133
  const code = (err as { code?: string } | null)?.code
@@ -99,7 +142,7 @@ export function echoHandler(
99
142
  : ERROR_BODIES.bad_request,
100
143
  {
101
144
  status: isTooLarge ? 413 : isTimeout ? 408 : 400,
102
- headers: ingress.withContentType(baseHeaders, 'application/json'),
145
+ headers: withEchoHeaders('application/json'),
103
146
  },
104
147
  )
105
148
  }
@@ -23,6 +23,18 @@ function defaultJsonValid(bytes: Uint8Array): boolean {
23
23
  }
24
24
  }
25
25
 
26
+ /**
27
+ * Whether a `Content-Type` header denotes JSON: the media type (the part before
28
+ * any parameters) is exactly `application/json`, or a `+json` structured suffix
29
+ * (e.g. `application/vnd.api+json`). A plain substring test would wrongly
30
+ * accept `text/plain; application/json`.
31
+ */
32
+ function isJsonContentType(contentType: string): boolean {
33
+ const semi = contentType.indexOf(';')
34
+ const mediaType = (semi >= 0 ? contentType.slice(0, semi) : contentType).trim().toLowerCase()
35
+ return mediaType === 'application/json' || mediaType.endsWith('+json')
36
+ }
37
+
26
38
  /**
27
39
  * Pre-baked JSON-write handler (POST/PUT/PATCH): enforces Content-Type,
28
40
  * content-length/body-size limits, JSON validity and (optionally) schema
@@ -47,20 +59,21 @@ export function jsonWriteHandler(
47
59
  const ip = resolveIp(req, srv, opts)
48
60
 
49
61
  const contentType = req.headers.get('content-type') ?? ''
50
- if (!contentType.includes('application/json')) {
51
- return fallback.run(req, ip, null, (result, ctx) => {
52
- const terminal = fallback.terminalResponse(req, result, ctx)
53
- if (terminal) return terminal
54
-
55
- return fallback.errorResponse(
62
+ if (!isJsonContentType(contentType)) {
63
+ // Always 415 for an unsupported media type. Do NOT short-circuit on the
64
+ // native terminal first: running with no body yields a "body required"
65
+ // 400, which previously masked the 415 (the native pipeline still applied
66
+ // rate limiting / CORS headers to this run).
67
+ return fallback.run(req, ip, null, (result, ctx) =>
68
+ fallback.errorResponse(
56
69
  req,
57
70
  result,
58
71
  415,
59
72
  'unsupported_media_type',
60
73
  'Content-Type must be application/json',
61
74
  ctx,
62
- )
63
- })
75
+ ),
76
+ )
64
77
  }
65
78
 
66
79
  // Content-Length / body-size enforcement happens INSIDE readBodyWithLimit
@@ -45,7 +45,7 @@ export interface ResponderRouteOptions extends BakedHandlerOptions {
45
45
  }
46
46
 
47
47
  /** ignex framework security posture for terminal responses. */
48
- const IGNGEX_SECURITY_HEADERS: [string, string][] = [
48
+ const IGNEX_SECURITY_HEADERS: [string, string][] = [
49
49
  ['x-frame-options', 'DENY'],
50
50
  ['x-content-type-options', 'nosniff'],
51
51
  ['referrer-policy', 'no-referrer'],
@@ -55,7 +55,7 @@ const IGNGEX_SECURITY_HEADERS: [string, string][] = [
55
55
  function buildIgngexTerminal(result: BakedIngressResult, _ctx: BakedContext): Response {
56
56
  const preflightAllowed = result.isPreflight && result.corsAllowed
57
57
  if (preflightAllowed) {
58
- return new Response(null, { status: 204, headers: IGNGEX_SECURITY_HEADERS })
58
+ return new Response(null, { status: 204, headers: IGNEX_SECURITY_HEADERS })
59
59
  }
60
60
  const status = safeTerminalStatus(result)
61
61
  const body = JSON.stringify({
@@ -63,7 +63,7 @@ function buildIgngexTerminal(result: BakedIngressResult, _ctx: BakedContext): Re
63
63
  status,
64
64
  code: errorCodeName(result.errorCode),
65
65
  })
66
- const headers = new Headers(IGNGEX_SECURITY_HEADERS)
66
+ const headers = new Headers(IGNEX_SECURITY_HEADERS)
67
67
  headers.set('content-type', 'application/json; charset=utf-8')
68
68
  return new Response(body, { status, headers })
69
69
  }
@@ -112,7 +112,7 @@ export function nativeResponderRoute(
112
112
  const code = (err as Error & { code?: string }).code
113
113
  const status = code === 'REQUEST_TIMEOUT' ? 408 : code === 'BODY_TOO_LARGE' ? 413 : 400
114
114
  const payload = JSON.stringify({ error: 'Bad Request', status, code: 'BAD_REQUEST' })
115
- const headers = new Headers(IGNGEX_SECURITY_HEADERS)
115
+ const headers = new Headers(IGNEX_SECURITY_HEADERS)
116
116
  headers.set('content-type', 'application/json; charset=utf-8')
117
117
  return new Response(payload, { status, headers })
118
118
  }
@@ -73,8 +73,7 @@ function nodeRequestToWebRequest(req: IncomingMessage): Request {
73
73
  // request actually carries one (content-length > 0 or chunked), so DELETE/
74
74
  // OPTIONS-with-a-body are no longer dropped.
75
75
  const bodyForbidden = method === 'GET' || method === 'HEAD'
76
- const hasBody =
77
- Number(req.headers['content-length'] ?? 0) > 0 || req.headers['transfer-encoding'] !== undefined
76
+ const hasBody = requestHasBody(req)
78
77
 
79
78
  const init: RequestInit & { duplex?: 'half' } = { method, headers }
80
79
 
@@ -143,7 +142,7 @@ async function writeResponse(res: ServerResponse, response: Response): Promise<v
143
142
  */
144
143
  function drainUnreadBody(res: ServerResponse): void {
145
144
  const req = res.req
146
- if (req !== undefined && req !== null && !req.complete && hasBody(req)) {
145
+ if (req !== undefined && req !== null && !req.complete && requestHasBody(req)) {
147
146
  req.resume()
148
147
  // Only force-close when the request actually carried a body that was not
149
148
  // consumed. A bodiless GET/HEAD is always safe to keep alive (bun's
@@ -156,7 +155,7 @@ function drainUnreadBody(res: ServerResponse): void {
156
155
  }
157
156
 
158
157
  /** Whether an incoming request carried a body (content-length or chunked). */
159
- function hasBody(req: IncomingMessage): boolean {
158
+ function requestHasBody(req: IncomingMessage): boolean {
160
159
  return (
161
160
  Number(req.headers['content-length'] ?? 0) > 0 || req.headers['transfer-encoding'] !== undefined
162
161
  )
@@ -254,6 +253,35 @@ function makeRequestListener(
254
253
  * (which owns the frame codec — e.g. the `ws` library). Previously a 101 was
255
254
  * written as a normal response and the connection never became a socket.
256
255
  */
256
+ /** RFC 6455 subprotocol is an HTTP token (no separators / control bytes). */
257
+ const WS_PROTOCOL_TOKEN = /^[A-Za-z0-9!#$%&'*+\-.^_`|~]+$/
258
+
259
+ /**
260
+ * Build the RFC 6455 `101 Switching Protocols` response head for a hijacked
261
+ * socket. `protocol` is emitted only when it is a valid HTTP token, so a caller
262
+ * that reflects an untrusted `Sec-WebSocket-Protocol` cannot inject CRLF or
263
+ * extra headers.
264
+ *
265
+ * @param accept - The `Sec-WebSocket-Accept` value (must contain no CR/LF).
266
+ * @param protocol - Optional negotiated subprotocol.
267
+ * @returns The raw response head, terminated by a blank line.
268
+ * @throws If `accept` contains a CR/LF.
269
+ */
270
+ export function formatWebSocketHandshake(accept: string, protocol?: string | null): string {
271
+ if (/[\r\n]/.test(accept)) {
272
+ throw new Error('invalid Sec-WebSocket-Accept value')
273
+ }
274
+ let raw =
275
+ 'HTTP/1.1 101 Switching Protocols\r\n' +
276
+ 'Upgrade: websocket\r\n' +
277
+ 'Connection: Upgrade\r\n' +
278
+ `Sec-WebSocket-Accept: ${accept}\r\n`
279
+ if (protocol && WS_PROTOCOL_TOKEN.test(protocol)) {
280
+ raw += `Sec-WebSocket-Protocol: ${protocol}\r\n`
281
+ }
282
+ return `${raw}\r\n`
283
+ }
284
+
257
285
  function makeUpgradeListener(
258
286
  options: CreateIngressServerOptions,
259
287
  ): (req: IncomingMessage, socket: Duplex, head: Buffer) => Promise<void> {
@@ -268,16 +296,7 @@ function makeUpgradeListener(
268
296
  }
269
297
 
270
298
  // Complete the RFC 6455 handshake on the hijacked socket.
271
- let raw =
272
- 'HTTP/1.1 101 Switching Protocols\r\n' +
273
- 'Upgrade: websocket\r\n' +
274
- 'Connection: Upgrade\r\n' +
275
- `Sec-WebSocket-Accept: ${handshake.accept}\r\n`
276
- if (handshake.protocol) {
277
- raw += `Sec-WebSocket-Protocol: ${handshake.protocol}\r\n`
278
- }
279
- raw += '\r\n'
280
- socket.write(raw)
299
+ socket.write(formatWebSocketHandshake(handshake.accept, handshake.protocol))
281
300
 
282
301
  // Hand the upgraded socket to the caller (frame codec / message loop).
283
302
  options.onUpgrade?.(socket, req, head)
@@ -339,11 +339,10 @@ export function buildRouteHandlers(options: BuildRouteHandlersOptions): {
339
339
  }
340
340
 
341
341
  // ── Path matching (shared with server-node.ts + router.ts) ─────────
342
- // `buildPathMatcher` / `PathMatch` / `safeDecode` live in path-matcher.ts;
343
- // re-exported here for back-compat (they were historically exported from this
344
- // module).
342
+ // `PathMatch` / `safeDecode` live in path-matcher.ts and are not part of this
343
+ // module's surface; only `buildPathMatcher` is re-exported for back-compat.
345
344
 
346
- export { buildPathMatcher, type PathMatch, safeDecode } from './path-matcher'
345
+ export { buildPathMatcher } from './path-matcher'
347
346
 
348
347
  /** Typed options forwarded to `Bun.serve`. */
349
348
  interface BunServerOptions {
@@ -36,7 +36,7 @@ export const DEFAULT_BAKED_OUTPUT_BUFFER_SIZE = 131_072
36
36
  * (`handle_request_full_sync` allocates `vec![0u8; output_size]`), OOM-ing the
37
37
  * process. Callers that genuinely need more can raise it here.
38
38
  */
39
- export const MAX_OUTPUT_BUFFER_SIZE = 64 * 1024 * 1024
39
+ const MAX_OUTPUT_BUFFER_SIZE = 64 * 1024 * 1024
40
40
 
41
41
  /**
42
42
  * Clamp an ingress output-buffer size into `[min, MAX_OUTPUT_BUFFER_SIZE]` so
@@ -120,6 +120,21 @@ export interface OptimizedIngressHandler {
120
120
  retryAfterSecs?: number,
121
121
  ): [string, string][]
122
122
 
123
+ /**
124
+ * Success-path headers as a ready `Headers` instance: the memoized base
125
+ * (template + reflected origin) plus per-request rate/request-id extras,
126
+ * without rebuilding `Headers` from an array on every response. Optional so
127
+ * hand-rolled mocks need not provide it; callers fall back to
128
+ * `memoizedHeaders(responseHeaders(...))`.
129
+ */
130
+ successHeaders?(
131
+ variant: number,
132
+ requestIdHeader: string | null,
133
+ origin: string | null,
134
+ rateRemaining?: number,
135
+ rateResetSecs?: number,
136
+ ): Headers
137
+
123
138
  terminalHeaders(
124
139
  variant: number,
125
140
  ctx: BakedContext,
package/src/loader/ops.ts CHANGED
@@ -26,7 +26,7 @@ import { decoder } from '../shared/bytes'
26
26
  import type { SchemaValidator } from '../shared/packed'
27
27
 
28
28
  /** Empty bytes sentinel for skip-on-error scalar parity (e.g. AEAD decrypt). */
29
- export const EMPTY_BYTES = new Uint8Array(0)
29
+ const EMPTY_BYTES = new Uint8Array(0)
30
30
 
31
31
  /** Result kinds supported by the loader (mirrors the packed batch wire shapes). */
32
32
  export type LoaderResultKind = 'boolean' | 'number' | 'bigint' | 'bytes'