castrum 0.9.1 → 0.9.2

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 (49) hide show
  1. package/README.md +1 -1
  2. package/castrum.linux-x64-gnu.node +0 -0
  3. package/dist/index.d.ts +239 -5
  4. package/dist/index.js +3852 -3375
  5. package/docs/ARCHITECTURE.md +11 -9
  6. package/docs/BENCHMARKS.md +70 -38
  7. package/docs/CASE_STUDY.md +10 -7
  8. package/docs/ENVIRONMENT.md +16 -1
  9. package/docs/FFI_BUN_GUIDE.md +18 -10
  10. package/docs/GETTING_STARTED.md +5 -4
  11. package/docs/HOW_TO_ADD_AN_OP.md +180 -0
  12. package/docs/INGRESS-ROUTER.md +3 -2
  13. package/docs/INGRESS.md +6 -3
  14. package/docs/NATIVE-ROUTE.md +28 -4
  15. package/docs/REPO_MAP.md +60 -30
  16. package/docs/adr/0001-two-wire-formats.md +1 -1
  17. package/docs/ai/LOCAL_DEV.md +81 -0
  18. package/docs/ai/TREE.md +1070 -0
  19. package/package.json +24 -23
  20. package/src/ingress/decode/result-base.ts +10 -2
  21. package/src/ingress/handlers.ts +45 -290
  22. package/src/ingress/headers/security.ts +41 -0
  23. package/src/ingress/index.ts +12 -148
  24. package/src/ingress/native-route.ts +182 -0
  25. package/src/ingress/packing/gather-raw-headers.ts +26 -2
  26. package/src/ingress/packing/route-wire.ts +197 -0
  27. package/src/ingress/packing/select-headers.ts +8 -1
  28. package/src/ingress/path-matcher.ts +115 -0
  29. package/src/ingress/response/baked-response.ts +302 -0
  30. package/src/ingress/router.ts +42 -2
  31. package/src/ingress/routes/common.ts +9 -1
  32. package/src/ingress/routes/json-write.ts +21 -18
  33. package/src/ingress/routes/native.ts +130 -0
  34. package/src/ingress/server-node.ts +1 -1
  35. package/src/ingress/server.ts +38 -99
  36. package/src/ingress/sync.ts +151 -0
  37. package/src/ingress/types.ts +9 -0
  38. package/src/native/ffi/build/codecs.ts +736 -0
  39. package/src/native/ffi/build/compress.ts +248 -0
  40. package/src/native/ffi/build/instances.ts +598 -0
  41. package/src/native/ffi/build/parse.ts +539 -0
  42. package/src/native/ffi/build/util.ts +140 -0
  43. package/src/native/ffi/build.ts +94 -0
  44. package/src/native/ffi/selftest.ts +11 -718
  45. package/src/native/ffi/types.ts +25 -0
  46. package/src/native/ffi.ts +36 -1320
  47. package/src/native/types/instances.ts +5 -0
  48. package/src/rust-ffi/packed.ts +38 -0
  49. package/src/rust-ffi/scalar/factories.ts +4 -0
@@ -0,0 +1,115 @@
1
+ // src/ingress/path-matcher.ts — `:param` / `*` path matching for pre-baked routes
2
+ //
3
+ // The dynamic-path matcher shared by the Node adapter (server-node.ts) and the
4
+ // router (router.ts). Bun's native router matches dynamic paths itself, so this
5
+ // only runs on Node / route-manager paths. The ingress pipeline does not echo
6
+ // path params in its response, so matching only selects the handler.
7
+
8
+ /** A matched dynamic route: the handler map + the extracted path params. */
9
+ export interface PathMatch {
10
+ methods: Record<string, unknown>
11
+ params: Record<string, string>
12
+ }
13
+
14
+ interface CompiledDynamicPattern {
15
+ re: RegExp
16
+ paramNames: string[]
17
+ rest: boolean
18
+ methods: Record<string, unknown>
19
+ /** Number of literal (static) segments — used to prefer the most specific pattern. */
20
+ staticSegments: number
21
+ }
22
+
23
+ /**
24
+ * Build a path matcher over a route map, supporting `:param` and `*` (rest)
25
+ * segments. Exact (static) paths always win over dynamic patterns; dynamic
26
+ * patterns are ordered most-specific-first (most static segments, then fewest
27
+ * params). Used by `createIngressServerNode` (Bun's native router already
28
+ * matches dynamic patterns itself). The ingress pipeline does not echo path
29
+ * params in its response, so matching only selects the handler.
30
+ *
31
+ * @param routes the built route map (`path` → `method` → handler)
32
+ * @returns a matcher: `pathname` → `{ methods, params }` or `undefined`
33
+ */
34
+ export function buildPathMatcher(
35
+ routes: Record<string, Record<string, unknown>>,
36
+ ): (pathname: string) => PathMatch | undefined {
37
+ const exact = new Map<string, Record<string, unknown>>()
38
+ const dynamic: CompiledDynamicPattern[] = []
39
+
40
+ for (const [path, methods] of Object.entries(routes)) {
41
+ const hasParam = path.indexOf(':') !== -1
42
+ const hasRest = path.indexOf('*') !== -1
43
+ if (!hasParam && !hasRest) {
44
+ exact.set(path, methods)
45
+ continue
46
+ }
47
+
48
+ const paramNames: string[] = []
49
+ let rest = false
50
+ let staticSegments = 0
51
+ const parts = path.split('/').filter((s) => s.length > 0)
52
+ const source = parts
53
+ .map((seg) => {
54
+ if (seg === '*') {
55
+ rest = true
56
+ return '(.*)'
57
+ }
58
+ if (seg.startsWith(':')) {
59
+ paramNames.push(seg.slice(1))
60
+ return '([^/]+)'
61
+ }
62
+ staticSegments++
63
+ return seg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
64
+ })
65
+ .join('/')
66
+
67
+ dynamic.push({
68
+ re: new RegExp(`^/${source}${rest ? '' : '/?$'}`),
69
+ paramNames,
70
+ rest,
71
+ methods,
72
+ staticSegments,
73
+ })
74
+ }
75
+
76
+ dynamic.sort(
77
+ (a, b) => b.staticSegments - a.staticSegments || a.paramNames.length - b.paramNames.length,
78
+ )
79
+
80
+ return (pathname: string) => {
81
+ const exactHit = exact.get(pathname)
82
+ if (exactHit !== undefined) {
83
+ return { methods: exactHit, params: {} }
84
+ }
85
+ for (const d of dynamic) {
86
+ const m = d.re.exec(pathname)
87
+ if (m !== null) {
88
+ const params: Record<string, string> = {}
89
+ for (let i = 0; i < d.paramNames.length; i++) {
90
+ const raw = m[i + 1]
91
+ if (raw !== undefined) params[d.paramNames[i] ?? ''] = safeDecode(raw)
92
+ }
93
+ if (d.rest) {
94
+ const restRaw = m[d.paramNames.length + 1]
95
+ if (restRaw !== undefined) params['*'] = safeDecode(restRaw)
96
+ }
97
+ return { methods: d.methods, params }
98
+ }
99
+ }
100
+ return undefined
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Percent-decode a matched path segment, tolerating malformed escapes: a bad
106
+ * `%` sequence is returned raw rather than throwing, so the matcher never
107
+ * rejects a request over a decode error.
108
+ */
109
+ export function safeDecode(value: string): string {
110
+ try {
111
+ return decodeURIComponent(value)
112
+ } catch {
113
+ return value
114
+ }
115
+ }
@@ -0,0 +1,302 @@
1
+ // src/ingress/response/baked-response.ts — pre-baked response builders (path 2)
2
+ //
3
+ // The seven response-builder methods of `OptimizedIngressHandler`, extracted
4
+ // from createIngressHandler (handlers.ts) so the factory stays focused on
5
+ // packing + pooling. Mirrors fast.ts's response/terminal.ts split — but emits
6
+ // the BENCH wire format (`{"ok":false,"error":{...}}` + `ratelimit-*` headers),
7
+ // NOT the fast path's `x-ratelimit-*` shape (see AGENTS.md — do not unify).
8
+ //
9
+ // The builders are bound to a handler's configuration (header templates,
10
+ // request-id emission, CORS cache) plus a shared mutable `BakedResponseState`
11
+ // that `run()` in handlers.ts owns: `zeroCopyResponse` marks the pooled
12
+ // output buffer as borrowed so `run()` does not release it before the body is
13
+ // consumed.
14
+
15
+ import type { PooledBuffer } from '../../shared/buffer-pool'
16
+ import { encoder } from '../../shared/bytes'
17
+ import { pooledBodyResponse } from '../../shared/response'
18
+ import {
19
+ ERR_CODE_RATE_LIMITED as ERROR_CODE_RATE_LIMITED,
20
+ HV_CORS_PREFLIGHT,
21
+ HV_CORS_SIMPLE,
22
+ HV_JSON,
23
+ HV_RATE_ACTIVE,
24
+ HV_RATE_LIMITED,
25
+ } from '../constants'
26
+ import type { BakedIngressResult } from '../decode/baked-result'
27
+ import { secondsFromMs } from '../shared'
28
+ import { safeTerminalStatus } from '../status'
29
+ import type { BakedContext, OptimizedIngressHandler } from '../types'
30
+ import { ERROR_BODIES, ERROR_CODE_BODIES, rateLimitedBody } from './error-bodies'
31
+
32
+ const EMPTY_BODY = new Uint8Array(0)
33
+
34
+ /** Shared per-request state between `run()` (handlers.ts) and the builders. */
35
+ export interface BakedResponseState {
36
+ /** The pooled output buffer backing the current `run()` (null outside one). */
37
+ currentHandle: PooledBuffer | null
38
+ /** True when a zero-copy Response claimed the buffer (run() must not release). */
39
+ responseBorrowsBuffer: boolean
40
+ }
41
+
42
+ /** Configuration the builder factory needs from a handler. */
43
+ export interface BakedResponseBuildersDeps {
44
+ /** Variant-indexed pre-baked header templates (regular responses). */
45
+ headerTemplates: ReadonlyArray<ReadonlyArray<[string, string]>>
46
+ /** Variant-indexed pre-baked terminal templates (JSON + no-store). */
47
+ terminalTemplates: ReadonlyArray<ReadonlyArray<[string, string]>>
48
+ /** Emit an `x-request-id` header on responses. */
49
+ emitRequestIdHeader: boolean
50
+ /** Abandonment guard (ms) for zero-copy responses. */
51
+ zeroCopyTimeoutMs: number
52
+ /** Cache of Origin-augmented header arrays keyed by `variant\u0000origin`. */
53
+ originHeaderCache: Map<string, [string, string][]>
54
+ /** Shared per-request state (owned by `run()` in handlers.ts). */
55
+ state: BakedResponseState
56
+ }
57
+
58
+ /**
59
+ * Build the seven response-builder methods of `OptimizedIngressHandler`
60
+ * (everything except `run`), bound to this handler's header templates and
61
+ * request-id policy. The returned methods are the exact bodies that lived in
62
+ * `createIngressHandler`.
63
+ */
64
+ export function buildBakedResponseBuilders(
65
+ deps: BakedResponseBuildersDeps,
66
+ ): Omit<OptimizedIngressHandler, 'run'> {
67
+ const {
68
+ headerTemplates,
69
+ terminalTemplates,
70
+ emitRequestIdHeader,
71
+ zeroCopyTimeoutMs,
72
+ originHeaderCache,
73
+ state,
74
+ } = deps
75
+
76
+ function responseHeaders(
77
+ variant: number,
78
+ requestIdHeader: string | null,
79
+ origin: string | null,
80
+ rateRemaining?: number,
81
+ rateResetSecs?: number,
82
+ retryAfterSecs?: number,
83
+ ): [string, string][] {
84
+ const template: ReadonlyArray<[string, string]> =
85
+ headerTemplates[variant & 31] ?? headerTemplates[0] ?? []
86
+
87
+ const needsRequestId = emitRequestIdHeader && requestIdHeader !== null
88
+ const needsOrigin =
89
+ ((variant & HV_CORS_SIMPLE) !== 0 || (variant & HV_CORS_PREFLIGHT) !== 0) && origin !== null
90
+ const needsRate = (variant & HV_RATE_ACTIVE) !== 0
91
+ const needsRetry = (variant & HV_RATE_LIMITED) !== 0
92
+
93
+ if (!needsRequestId && !needsOrigin && !needsRate && !needsRetry) {
94
+ // Frozen-readonly → mutable: callers only ever read the baked template
95
+ // (zero-copy steady-state path), so the widening is safe in practice.
96
+ return template as unknown as [string, string][]
97
+ }
98
+
99
+ 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
118
+ }
119
+
120
+ let extra = 0
121
+ if (needsRequestId) extra++
122
+ if (needsOrigin) extra++
123
+ if (needsRate) extra += 2
124
+ if (needsRetry) extra++
125
+
126
+ const entries = new Array<[string, string]>(template.length + extra)
127
+ let i = 0
128
+
129
+ for (const pair of template) {
130
+ entries[i++] = pair
131
+ }
132
+
133
+ if (needsRequestId) {
134
+ entries[i++] = ['x-request-id', requestIdHeader as string]
135
+ }
136
+
137
+ if (needsOrigin) {
138
+ entries[i++] = ['access-control-allow-origin', origin as string]
139
+ }
140
+
141
+ if (needsRate) {
142
+ entries[i++] = ['ratelimit-remaining', String(rateRemaining ?? 0)]
143
+ entries[i++] = ['ratelimit-reset', String(rateResetSecs ?? 0)]
144
+ }
145
+
146
+ if (needsRetry) {
147
+ entries[i++] = ['retry-after', String(retryAfterSecs ?? 0)]
148
+ }
149
+
150
+ return entries
151
+ }
152
+
153
+ function terminalHeaders(
154
+ variant: number,
155
+ ctx: BakedContext,
156
+ result: BakedIngressResult | null,
157
+ ): [string, string][] {
158
+ const v = variant | HV_JSON
159
+ const requestIdHeader = ctx.requestIdHeader
160
+ const origin = ctx.origin
161
+ const rateResetSecs =
162
+ result && result.rateResetMs > 0 ? secondsFromMs(result.rateResetMs) : undefined
163
+ const retryAfterSecs =
164
+ result && result.retryAfterMs > 0 ? secondsFromMs(result.retryAfterMs) : undefined
165
+
166
+ const needsRequestId = emitRequestIdHeader && requestIdHeader !== null
167
+ const needsOrigin =
168
+ ((v & HV_CORS_SIMPLE) !== 0 || (v & HV_CORS_PREFLIGHT) !== 0) && origin !== null
169
+ const needsRate = (v & HV_RATE_ACTIVE) !== 0
170
+ const needsRetry = (v & HV_RATE_LIMITED) !== 0
171
+
172
+ // Steady state: no request-id/origin/rate extras — serve the pre-baked
173
+ // terminal template (JSON + `cache-control: no-store`) directly with zero
174
+ // per-response array allocation/copy.
175
+ if (!needsRequestId && !needsOrigin && !needsRate && !needsRetry) {
176
+ // Frozen-readonly → mutable: the baked terminal template is only read.
177
+ return terminalTemplates[v & 31] as unknown as [string, string][]
178
+ }
179
+
180
+ // Rare path: extras appended after the baked entries, then `cache-control`.
181
+ const base = responseHeaders(
182
+ v,
183
+ requestIdHeader,
184
+ origin,
185
+ result?.rateRemaining,
186
+ rateResetSecs,
187
+ retryAfterSecs,
188
+ )
189
+
190
+ const out = new Array<[string, string]>(base.length + 1)
191
+ let i = 0
192
+ for (const pair of base) {
193
+ out[i++] = pair
194
+ }
195
+ out[base.length] = ['cache-control', 'no-store']
196
+ return out
197
+ }
198
+
199
+ function terminalResponse(
200
+ _req: Request | undefined,
201
+ result: BakedIngressResult,
202
+ ctx: BakedContext,
203
+ ): Response | null {
204
+ if (!result.terminal) {
205
+ return null
206
+ }
207
+
208
+ const preflightAllowed = result.isPreflight && result.corsAllowed
209
+
210
+ if (preflightAllowed) {
211
+ return new Response(null, {
212
+ 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,
220
+ ),
221
+ })
222
+ }
223
+
224
+ const status = safeTerminalStatus(result)
225
+
226
+ const body: Uint8Array =
227
+ result.errorCode === ERROR_CODE_RATE_LIMITED
228
+ ? rateLimitedBody(result.retryAfterMs)
229
+ : (ERROR_CODE_BODIES[result.errorCode] ?? ERROR_BODIES.internal ?? EMPTY_BODY)
230
+
231
+ return new Response(body, {
232
+ status,
233
+ headers: terminalHeaders(result.headerVariant, ctx, result),
234
+ })
235
+ }
236
+
237
+ function errorResponse(
238
+ _req: Request | undefined,
239
+ result: BakedIngressResult | null,
240
+ status: number,
241
+ code: string,
242
+ message: string,
243
+ ctx: BakedContext,
244
+ ): Response {
245
+ const body =
246
+ ERROR_BODIES[code] ?? encoder.encode(JSON.stringify({ ok: false, error: { code, message } }))
247
+
248
+ return new Response(body, {
249
+ status,
250
+ headers: terminalHeaders(result?.headerVariant ?? HV_JSON, ctx, result),
251
+ })
252
+ }
253
+
254
+ function internalErrorResponse(ctx: BakedContext, result?: BakedIngressResult): Response {
255
+ return new Response(ERROR_BODIES.internal, {
256
+ status: 500,
257
+ headers: terminalHeaders(result?.headerVariant ?? HV_JSON, ctx, result ?? null),
258
+ })
259
+ }
260
+
261
+ function withContentType(
262
+ headers: ReadonlyArray<[string, string]>,
263
+ contentType: string,
264
+ ): [string, string][] {
265
+ const out = new Array<[string, string]>(headers.length + 1)
266
+ let i = 0
267
+ for (const pair of headers) {
268
+ out[i++] = pair
269
+ }
270
+ out[headers.length] = ['content-type', contentType]
271
+ return out
272
+ }
273
+
274
+ function zeroCopyResponse(
275
+ result: BakedIngressResult,
276
+ _ctx: BakedContext,
277
+ init: ResponseInit,
278
+ ): Response {
279
+ if (state.currentHandle === null) {
280
+ // `zeroCopyResponse` must only run inside a live `run()` callback (a
281
+ // pooled output buffer must be in flight). Called outside `run()` the
282
+ // result has already been invalidated and `result.bodyJson` is empty —
283
+ // throw instead of silently serving an empty body.
284
+ throw new Error(
285
+ 'zeroCopyResponse() can only be called from within a run() callback ' +
286
+ '(the pooled output buffer is only live during run()).',
287
+ )
288
+ }
289
+ state.responseBorrowsBuffer = true
290
+ return pooledBodyResponse(state.currentHandle, result.bodyJson(false), init, zeroCopyTimeoutMs)
291
+ }
292
+
293
+ return {
294
+ responseHeaders,
295
+ terminalHeaders,
296
+ terminalResponse,
297
+ errorResponse,
298
+ internalErrorResponse,
299
+ withContentType,
300
+ zeroCopyResponse,
301
+ }
302
+ }
@@ -23,9 +23,12 @@
23
23
 
24
24
  import type { BakedIngressResult } from './decode/baked-result'
25
25
  import { type BakedIngressRuntime, createIngressHandler } from './handlers'
26
+ import type { NativeRoutePlan } from './native-route'
26
27
  import type { IngressHandlerOptions } from './options'
27
- import type { BakedRoute, PathMatch, RouteHandler } from './server'
28
- import { buildPathMatcher, buildRouteHandlers } from './server'
28
+ import type { PathMatch } from './path-matcher'
29
+ import { buildPathMatcher } from './path-matcher'
30
+ import type { BakedRoute, RouteHandler } from './server'
31
+ import { buildRouteHandlers } from './server'
29
32
  import type { BakedContext, NativeResponder, OptimizedIngressHandler, TerminalStyle } from './types'
30
33
 
31
34
  /** Per-route spec for {@link createIngressRouter}. */
@@ -60,6 +63,28 @@ export interface RouterRouteSpec {
60
63
  /** Read the body for native validation (default false — framework owns it). */
61
64
  readBody?: boolean
62
65
  }
66
+ /**
67
+ * A LEAN native-stack responder route: the route-wire v3 per-route stack
68
+ * (`createNativeRoute` over `castrum_route_*`) runs ONLY the stages in
69
+ * `plan` (parseQuery/parseCookies/requireJsonBody/validateBody) in ONE
70
+ * native call — no CORS/rate-limit/security/IP/metadata envelope. On a
71
+ * verdict failure the route rejects (400 non-JSON / 422 schema); on success
72
+ * the responder builds the 2xx from the decoded snapshot. Measured ~580ns
73
+ * cheaper per request than the full-pipeline responder on a
74
+ * parseQuery+parseCookies route (bench/cost/native-route-vs-router.ts).
75
+ * Wired for `methods` (default `['GET']`). When set, `read`/`write`/etc.
76
+ * for this route are ignored.
77
+ */
78
+ native?: {
79
+ /** The route-wire v3 plan (parse/validate stages + limits). */
80
+ plan: NativeRoutePlan
81
+ /** The JS 2xx builder (receives the decoded query/cookies/body snapshot). */
82
+ handler: NativeResponder
83
+ /** HTTP methods to wire (default `['GET']`). */
84
+ methods?: ReadonlyArray<string>
85
+ /** Read the body for `requireJsonBody`/`validateBody` (default false). */
86
+ readBody?: boolean
87
+ }
63
88
  /** Terminal envelope style for this route's responder (default: router-level
64
89
  * `terminalStyle` or `'castrum'`). */
65
90
  terminalStyle?: TerminalStyle
@@ -155,6 +180,21 @@ export function createIngressRouter(options: CreateIngressRouterOptions): Ingres
155
180
  bakedRoutes[path] = { read: spec.raw as RouteHandler }
156
181
  continue
157
182
  }
183
+ if (spec.native) {
184
+ // LEAN native-stack responder route: the route-wire v3 stack runs ONLY
185
+ // the plan's stages (no full IngressInner, no CORS/rate-limit/security).
186
+ bakedRoutes[path] = {
187
+ native: {
188
+ plan: spec.native.plan,
189
+ handler: spec.native.handler,
190
+ methods: spec.native.methods,
191
+ readBody: spec.native.readBody,
192
+ },
193
+ maxBodyBytes: spec.maxBodyBytes,
194
+ bodyTimeoutMs: spec.bodyTimeoutMs,
195
+ }
196
+ continue
197
+ }
158
198
  // Compile a dedicated native instance for this route's options.
159
199
  const handler = createIngressHandler(spec.options ?? {}, runtime)
160
200
  compiled[path] = handler
@@ -1,8 +1,8 @@
1
1
  // src/ingress/routes/common.ts — Shared route-handler options + helpers.
2
2
 
3
- import type { BakedContext, OptimizedIngressHandler } from '../types'
4
3
  import type { BakedIngressResult } from '../decode/baked-result'
5
4
  import { secondsFromMs } from '../shared'
5
+ import type { BakedContext, OptimizedIngressHandler } from '../types'
6
6
 
7
7
  /** Options accepted by the pre-baked route factories. */
8
8
  export interface BakedHandlerOptions {
@@ -20,6 +20,14 @@ export interface BakedHandlerOptions {
20
20
  bodyTimeoutMs?: number
21
21
  /** Fallback handler used for write error paths. Defaults to the write ingress. */
22
22
  fallback?: OptimizedIngressHandler
23
+ /**
24
+ * Zero-DOM JSON-validity check for the write-route fallback validation
25
+ * (`jsonWriteHandler` when neither `requireJsonBody` nor a `schema` is
26
+ * configured). Injected so the pure route factories never import the native
27
+ * layer (dependency injection across the purity boundary). Defaults to the
28
+ * `ingress.jsonValid` capability that `createIngressHandler` provides.
29
+ */
30
+ jsonValid?: (bytes: Uint8Array) => boolean
23
31
  /**
24
32
  * When true, route the JSON-validity fallback check through the higher-order
25
33
  * loader (`loader("jsonValid")`) instead of the direct native call —
@@ -1,27 +1,26 @@
1
1
  // src/ingress/routes/json-write.ts — Pre-baked JSON-write handler
2
2
  // (POST/PUT/PATCH).
3
3
 
4
- import type { OptimizedIngressHandler } from '../types'
5
- import { buildSuccessInit, resolveIp, type BakedHandlerOptions } from './common'
6
- import { DEFAULT_MAX_BODY_BYTES, DEFAULT_BODY_TIMEOUT_MS } from '../shared'
4
+ import { decodeUtf8 } from '../../shared/codec'
7
5
  import { readBodyWithLimit } from '../body'
8
- import { getAddon } from '../../native'
9
- import { getBunFFI } from '../../native/ffi'
6
+ import { DEFAULT_BODY_TIMEOUT_MS, DEFAULT_MAX_BODY_BYTES } from '../shared'
7
+ import type { OptimizedIngressHandler } from '../types'
8
+ import { type BakedHandlerOptions, buildSuccessInit, resolveIp } from './common'
10
9
 
11
10
  /**
12
- * Zero-DOM JSON-validity check — the same native fast path the ingress
13
- * pipeline uses internally (`json_valid_bytes`).
14
- *
15
- * Used by `jsonWriteHandler` as a fallback when the pipeline skipped JSON
16
- * validation because neither `requireJsonBody` nor a `schema` is configured
17
- * on the ingress (see the `bodyValidJson` handling in the route).
18
- *
19
- * bun:ffi is primary on Bun; the napi addon is the fallback (Node, forced
20
- * `CASTRUM_FFI_MODE=napi`, or a failed ffi self-test).
11
+ * Pure JSON-validity fallback used ONLY for hand-rolled
12
+ * `OptimizedIngressHandler` mocks that carry no native `jsonValid` capability.
13
+ * Real handlers from `createIngressHandler` always provide the zero-DOM native
14
+ * check (`ingress.jsonValid`), so this never runs on the hot path — it exists
15
+ * to keep the security property (invalid JSON must be rejected) for mocks.
21
16
  */
22
- function isValidJsonBytes(bytes: Uint8Array): boolean {
23
- const ffi = getBunFFI()
24
- return ffi ? ffi.jsonValid(bytes) : getAddon().jsonValid(bytes)
17
+ function defaultJsonValid(bytes: Uint8Array): boolean {
18
+ try {
19
+ JSON.parse(decodeUtf8(bytes))
20
+ return true
21
+ } catch {
22
+ return false
23
+ }
25
24
  }
26
25
 
27
26
  /**
@@ -39,6 +38,10 @@ export function jsonWriteHandler(
39
38
  // Non-zero default: protect against slowloris / trickling bodies. Set
40
39
  // `bodyTimeoutMs: 0` to disable.
41
40
  const bodyTimeoutMs = opts.bodyTimeoutMs ?? DEFAULT_BODY_TIMEOUT_MS
41
+ // JSON-validity check via DI across the purity boundary: an explicit option
42
+ // wins, otherwise the handler's own native capability (from
43
+ // `createIngressHandler`), otherwise the pure mock fallback.
44
+ const jsonValid = opts.jsonValid ?? ingress.jsonValid ?? defaultJsonValid
42
45
 
43
46
  return async (req, srv) => {
44
47
  const ip = resolveIp(req, srv, opts)
@@ -115,7 +118,7 @@ export function jsonWriteHandler(
115
118
  ctx,
116
119
  )
117
120
  }
118
- } else if (!isValidJsonBytes(bodyBytes)) {
121
+ } else if (!jsonValid(bodyBytes)) {
119
122
  return ingress.errorResponse(req, result, 400, 'invalid_json', 'Invalid JSON body', ctx)
120
123
  }
121
124