castrum 0.9.0 → 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 (120) hide show
  1. package/README.md +153 -630
  2. package/castrum.linux-x64-gnu.node +0 -0
  3. package/dist/index.d.ts +1653 -1817
  4. package/dist/index.js +6172 -4294
  5. package/docs/API.md +337 -0
  6. package/docs/ARCHITECTURE.md +111 -17
  7. package/docs/BENCHMARKS.md +117 -49
  8. package/docs/CASE_STUDY.md +243 -0
  9. package/docs/ENVIRONMENT.md +24 -7
  10. package/docs/FFI_BUN_GUIDE.md +335 -0
  11. package/docs/GETTING_STARTED.md +18 -34
  12. package/docs/HOW_TO_ADD_AN_OP.md +180 -0
  13. package/docs/INGRESS-ROUTER.md +82 -0
  14. package/docs/INGRESS.md +11 -7
  15. package/docs/NATIVE-ROUTE.md +77 -0
  16. package/docs/REPO_MAP.md +103 -53
  17. package/docs/adr/0001-two-wire-formats.md +1 -1
  18. package/docs/adr/0003-bun-builtins-delegation.md +6 -5
  19. package/docs/ai/LOCAL_DEV.md +81 -0
  20. package/docs/ai/TREE.md +1070 -0
  21. package/docs/bun-builtins-decision-matrix.md +25 -18
  22. package/index.ts +9 -16
  23. package/package.json +41 -28
  24. package/src/ingress/body.ts +167 -27
  25. package/src/ingress/decode/result-base.ts +10 -2
  26. package/src/ingress/handlers.ts +116 -285
  27. package/src/ingress/headers/security.ts +41 -0
  28. package/src/ingress/health.ts +1 -0
  29. package/src/ingress/index.ts +45 -169
  30. package/src/ingress/native-route.ts +182 -0
  31. package/src/ingress/packing/gather-raw-headers.ts +110 -22
  32. package/src/ingress/packing/header-packing.ts +5 -5
  33. package/src/ingress/packing/input-packer.ts +17 -3
  34. package/src/ingress/packing/route-wire.ts +197 -0
  35. package/src/ingress/packing/select-headers.ts +20 -7
  36. package/src/ingress/path-matcher.ts +115 -0
  37. package/src/ingress/response/baked-response.ts +302 -0
  38. package/src/ingress/router.ts +277 -0
  39. package/src/ingress/routes/common.ts +50 -1
  40. package/src/ingress/routes/fallback.ts +2 -2
  41. package/src/ingress/routes/head.ts +3 -13
  42. package/src/ingress/routes/index.ts +5 -4
  43. package/src/ingress/routes/json-write.ts +26 -47
  44. package/src/ingress/routes/native.ts +130 -0
  45. package/src/ingress/routes/options.ts +2 -12
  46. package/src/ingress/routes/read.ts +3 -13
  47. package/src/ingress/routes/responder.ts +159 -0
  48. package/src/ingress/server-node.ts +7 -7
  49. package/src/ingress/server.ts +83 -112
  50. package/src/ingress/sync.ts +151 -0
  51. package/src/ingress/types.ts +44 -1
  52. package/src/integration/pipeline.ts +3 -0
  53. package/src/integration/streaming.ts +2 -3
  54. package/src/integration/websocket.ts +4 -3
  55. package/src/loader/batch.ts +4 -0
  56. package/src/loader/cost.ts +1 -0
  57. package/src/loader/index.ts +43 -228
  58. package/src/loader/state.ts +84 -0
  59. package/src/loader/types.ts +145 -0
  60. package/src/native/ffi/build/codecs.ts +736 -0
  61. package/src/native/ffi/build/compress.ts +248 -0
  62. package/src/native/ffi/build/instances.ts +598 -0
  63. package/src/native/ffi/build/parse.ts +539 -0
  64. package/src/native/ffi/build/util.ts +140 -0
  65. package/src/native/ffi/build.ts +94 -0
  66. package/src/native/ffi/constants.ts +58 -0
  67. package/src/native/ffi/selftest.ts +22 -0
  68. package/src/native/ffi/types.ts +499 -0
  69. package/src/native/ffi.ts +307 -1357
  70. package/src/native/loader.ts +52 -6
  71. package/src/native/types/instances.ts +279 -0
  72. package/src/native/types.ts +41 -238
  73. package/src/runtime/builtins.ts +131 -0
  74. package/src/runtime/codec.ts +147 -0
  75. package/src/runtime/detect.ts +36 -0
  76. package/src/runtime/index.ts +28 -0
  77. package/src/runtime/native.ts +35 -0
  78. package/src/runtime/server.ts +31 -0
  79. package/src/runtime/transport.ts +64 -0
  80. package/src/runtime/types.ts +173 -0
  81. package/src/runtime/uuid.ts +45 -0
  82. package/src/runtime/websocket.ts +30 -0
  83. package/src/rust-ffi/addon.ts +1 -1
  84. package/src/rust-ffi/batch/build.ts +155 -0
  85. package/src/rust-ffi/batch/index.ts +8 -0
  86. package/src/rust-ffi/batch/types.ts +92 -0
  87. package/src/rust-ffi/client.ts +5 -6
  88. package/src/rust-ffi/context.ts +39 -10
  89. package/src/rust-ffi/index.ts +28 -17
  90. package/src/rust-ffi/into.ts +21 -0
  91. package/src/rust-ffi/options.ts +1 -1
  92. package/src/rust-ffi/packed.ts +40 -2
  93. package/src/rust-ffi/proven.ts +16 -30
  94. package/src/rust-ffi/scalar/crypto.ts +188 -107
  95. package/src/rust-ffi/scalar/factories.ts +257 -27
  96. package/src/rust-ffi/scalar/hashing.ts +43 -41
  97. package/src/rust-ffi/scalar/http.ts +208 -77
  98. package/src/rust-ffi/scalar/index.ts +3 -3
  99. package/src/rust-ffi/scalar/interface.ts +88 -213
  100. package/src/rust-ffi/scalar/json-packed.ts +175 -0
  101. package/src/rust-ffi/scalar/json.ts +28 -12
  102. package/src/rust-ffi/scalar/payload.ts +159 -42
  103. package/src/rust-ffi/text.ts +31 -5
  104. package/src/selection.ts +27 -31
  105. package/src/shared/adaptive.ts +1 -2
  106. package/src/shared/buffer-pool.ts +72 -17
  107. package/src/shared/bytes.ts +75 -6
  108. package/src/shared/codec.ts +78 -0
  109. package/src/shared/env.ts +4 -4
  110. package/src/shared/log.ts +1 -0
  111. package/src/shared/packed/index.ts +10 -0
  112. package/src/shared/packed/parsers.ts +31 -0
  113. package/src/shared/packed/schema.ts +23 -0
  114. package/src/shared/{packed.ts → packed/wire.ts} +31 -39
  115. package/src/shared/proven.ts +189 -496
  116. package/src/shared/runtime.ts +20 -25
  117. package/src/shared/uuid.ts +12 -28
  118. package/src/data/json-rows.ts +0 -33
  119. package/src/rust-ffi/batch.ts +0 -431
  120. package/src/shared/bench-classify.ts +0 -209
@@ -0,0 +1,277 @@
1
+ // src/ingress/router.ts — createIngressRouter: per-route compiled ingress.
2
+ //
3
+ // The "one super solution" over the existing ingress core. Instead of one
4
+ // global `IngressInner` serving every path (with a superset of options), each
5
+ // route in the table carries its OWN `IngressHandlerOptions` and compiles a
6
+ // DEDICATED native `IngressInner` (via `createIngressHandler`) — so the native
7
+ // pipeline prunes to exactly that route's stages (parseCookies, parseQuery,
8
+ // schema, cors, rateLimit, limits...), and `buildHeaderPlan` (inside
9
+ // `createIngressHandler`) produces a per-route header plan that reads ZERO
10
+ // headers for routes that need none. All routes share the same wire format
11
+ // (the existing packed `handle_request_packed`), the shared process-wide rate
12
+ // limiter budgets, and pre-warm on construction (`warmOnCreate`).
13
+ //
14
+ // This is the completion of what `rust/route.rs` (now deleted — it was dead,
15
+ // external-project wire) attempted: per-route native stage pruning, WITHOUT a
16
+ // second/third wire format. The route factories (`readHandler`,
17
+ // `jsonWriteHandler`, ...) and the dispatch machinery (`buildRouteHandlers` /
18
+ // `buildPathMatcher`) are reused unchanged. The per-route native-compile idea
19
+ // is ALSO exposed as the standalone per-route stack in
20
+ // `rust/ingress/native_route.rs` (the `castrum_route_*`/napi `Route` surface
21
+ // consumed by `@ignex/native`'s `createNativeRoute` — the LIVE successor to
22
+ // the deleted dead wire).
23
+
24
+ import type { BakedIngressResult } from './decode/baked-result'
25
+ import { type BakedIngressRuntime, createIngressHandler } from './handlers'
26
+ import type { NativeRoutePlan } from './native-route'
27
+ import type { IngressHandlerOptions } from './options'
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'
32
+ import type { BakedContext, NativeResponder, OptimizedIngressHandler, TerminalStyle } from './types'
33
+
34
+ /** Per-route spec for {@link createIngressRouter}. */
35
+ export interface RouterRouteSpec {
36
+ /**
37
+ * Per-route ingress options — compiled into a DEDICATED native `IngressInner`
38
+ * for this route (parseCookies, parseQuery, schema, cors, rateLimit, limits,
39
+ * requireJsonBody, ...). This is what makes the pipeline prune per route.
40
+ */
41
+ options?: IngressHandlerOptions
42
+ /** Wire GET + HEAD read handlers over this route's compiled handler. */
43
+ read?: boolean
44
+ /** Wire POST/PUT/PATCH JSON-write handlers over this route's compiled handler. */
45
+ write?: boolean
46
+ /** Wire a POST echo handler over this route's compiled handler. */
47
+ echo?: boolean
48
+ /** Wire a GET read handler (cookies-style) over this route's compiled handler. */
49
+ cookies?: boolean
50
+ /** Wire a DELETE read-style handler over this route's compiled handler. */
51
+ delete?: boolean
52
+ /**
53
+ * A JS responder route: the native pipeline decides + rejects (terminal
54
+ * response in {@link terminalStyle}); on success the responder builds the
55
+ * 2xx body (async OK) from a decoded snapshot. Wired for `methods`
56
+ * (default `['GET']`). When set, the standard `read`/`write`/etc. flags for
57
+ * this route are ignored.
58
+ */
59
+ responder?: {
60
+ handler: NativeResponder
61
+ /** HTTP methods to wire (default `['GET']`). */
62
+ methods?: ReadonlyArray<string>
63
+ /** Read the body for native validation (default false — framework owns it). */
64
+ readBody?: boolean
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
+ }
88
+ /** Terminal envelope style for this route's responder (default: router-level
89
+ * `terminalStyle` or `'castrum'`). */
90
+ terminalStyle?: TerminalStyle
91
+ /**
92
+ * A raw request→Response handler served directly for GET, OUTSIDE the
93
+ * ingress pipeline (health/metrics probes, /metrics). When set, `read`/
94
+ * `write`/etc. for this route are ignored.
95
+ */
96
+ raw?: RouteHandler
97
+ /** Overrides `maxBodyBytes` for this route's write/echo handlers. */
98
+ maxBodyBytes?: number
99
+ /** Overrides `bodyTimeoutMs` for this route's write/echo handlers. */
100
+ bodyTimeoutMs?: number
101
+ }
102
+
103
+ /** Options for {@link createIngressRouter}. */
104
+ export interface CreateIngressRouterOptions {
105
+ /** Route table: path → per-route spec. */
106
+ routes: Record<string, RouterRouteSpec>
107
+ /** Shared runtime hooks (onRequest/onResponse/onError, security headers,
108
+ * output buffer size) applied to EVERY compiled route handler. */
109
+ runtime?: BakedIngressRuntime
110
+ /** Shared `getIp` resolver for route handlers. */
111
+ getIp?: (req: Request, srv: unknown) => string | undefined
112
+ /** Shared `copyBody` default for route handlers. */
113
+ copyBody?: boolean
114
+ /** Default terminal envelope style for responder routes (`'castrum'`). */
115
+ terminalStyle?: TerminalStyle
116
+ /** Pre-warm every compiled route at construction (JIT the packed pipeline +
117
+ * the FFI ingress call before the first real request). Default: false. */
118
+ warmOnCreate?: boolean
119
+ }
120
+
121
+ /** A compiled per-route router. */
122
+ export interface IngressRouter {
123
+ /** The per-route compiled handlers keyed by path (for custom route wiring). */
124
+ routeHandlers: Record<string, OptimizedIngressHandler>
125
+ /** The route → method → handler map (compatible with Bun.serve `routes` and
126
+ * `createIngressServerNode`). */
127
+ routes: Record<string, Record<string, unknown>>
128
+ /** The path matcher (`:param`/`*` dynamic routes). */
129
+ match: (pathname: string) => PathMatch | undefined
130
+ /** A fetch-style dispatcher: match path + method → the route's handler. */
131
+ fetch: (req: Request, srv?: unknown) => Response | Promise<Response>
132
+ /** Pre-warm every compiled route (JIT the packed pipeline + FFI call). */
133
+ prewarm: () => void
134
+ }
135
+
136
+ /**
137
+ * Compile a route table into per-route native ingress instances.
138
+ *
139
+ * Each route with a pipeline-backed spec (`read`/`write`/`echo`/`cookies`/
140
+ * `delete`) compiles its OWN `createIngressHandler(options)` — a dedicated
141
+ * `IngressInner` pruned to that route's stages + a per-route header plan.
142
+ * Routes that need nothing (a bare `read` with no cookies/query/cors) therefore
143
+ * gather zero headers and run a near-empty native pipeline. All routes share
144
+ * the same wire format and the shared process-wide rate-limiter budgets.
145
+ *
146
+ * @example
147
+ * ```ts
148
+ * const router = createIngressRouter({
149
+ * warmOnCreate: true,
150
+ * runtime: ingressMetrics?.runtime,
151
+ * routes: {
152
+ * '/health': { read: true, options: { parseCookies: false, parseQuery: false } },
153
+ * '/api/users': {
154
+ * read: true,
155
+ * write: true,
156
+ * options: { parseCookies: true, parseQuery: true, schema: USER_SCHEMA_BYTES },
157
+ * },
158
+ * '/metrics': { raw: metricsHandler(ingressMetrics) },
159
+ * },
160
+ * })
161
+ * const server = Bun.serve({ routes: router.routes }) // or router.fetch
162
+ * ```
163
+ */
164
+ export function createIngressRouter(options: CreateIngressRouterOptions): IngressRouter {
165
+ const runtime = options.runtime ?? {}
166
+
167
+ // Per-route compiled handlers + the BakedRoute table the shared wiring reads.
168
+ const compiled: Record<string, OptimizedIngressHandler> = {}
169
+ const bakedRoutes: Record<string, BakedRoute> = {}
170
+ const baseOpts: {
171
+ getIp?: (req: Request, srv: unknown) => string | undefined
172
+ copyBody?: boolean
173
+ } = {}
174
+ if (options.getIp !== undefined) baseOpts.getIp = options.getIp
175
+ if (options.copyBody !== undefined) baseOpts.copyBody = options.copyBody
176
+
177
+ for (const [path, spec] of Object.entries(options.routes)) {
178
+ if (spec.raw) {
179
+ // Raw handler: served directly, outside the pipeline.
180
+ bakedRoutes[path] = { read: spec.raw as RouteHandler }
181
+ continue
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
+ }
198
+ // Compile a dedicated native instance for this route's options.
199
+ const handler = createIngressHandler(spec.options ?? {}, runtime)
200
+ compiled[path] = handler
201
+
202
+ if (spec.responder) {
203
+ // Responder route: native decides + rejects; JS builds the 2xx.
204
+ bakedRoutes[path] = {
205
+ responder: {
206
+ ingress: handler,
207
+ handler: spec.responder.handler,
208
+ methods: spec.responder.methods,
209
+ terminalStyle: spec.terminalStyle ?? options.terminalStyle,
210
+ readBody: spec.responder.readBody,
211
+ },
212
+ maxBodyBytes: spec.maxBodyBytes,
213
+ bodyTimeoutMs: spec.bodyTimeoutMs,
214
+ }
215
+ continue
216
+ }
217
+
218
+ const routeSpec: BakedRoute = {
219
+ read: spec.read ? handler : undefined,
220
+ write: spec.write ? handler : undefined,
221
+ echo: spec.echo ? handler : undefined,
222
+ cookies: spec.cookies ? handler : undefined,
223
+ delete: spec.delete ? handler : undefined,
224
+ maxBodyBytes: spec.maxBodyBytes,
225
+ bodyTimeoutMs: spec.bodyTimeoutMs,
226
+ }
227
+ bakedRoutes[path] = routeSpec
228
+ }
229
+
230
+ const { routes } = buildRouteHandlers({ ...baseOpts, routes: bakedRoutes })
231
+ const matcher = buildPathMatcher(routes)
232
+
233
+ const prewarm = (): void => {
234
+ // Run one probe GET through each compiled handler so the packed pipeline,
235
+ // header plan and FFI ingress call are JIT-warmed before the first real
236
+ // request. `run` never throws for a bad request (native failures become a
237
+ // 500 via setInternalError), so this is safe on any handler.
238
+ for (const handler of Object.values(compiled)) {
239
+ const probe = new Request('http://localhost:0/prewarm', { method: 'GET' })
240
+ handler.run<unknown>(
241
+ probe,
242
+ '127.0.0.1',
243
+ null,
244
+ (_result: BakedIngressResult, _ctx: BakedContext) => 0,
245
+ )
246
+ }
247
+ }
248
+
249
+ if (options.warmOnCreate === true) {
250
+ prewarm()
251
+ }
252
+
253
+ const fetch = (req: Request, srv?: unknown): Response | Promise<Response> => {
254
+ let pathname: string
255
+ try {
256
+ pathname = new URL(req.url).pathname
257
+ } catch {
258
+ return new Response('Bad Request', { status: 400 })
259
+ }
260
+ const matched = matcher(pathname)
261
+ if (!matched) {
262
+ return new Response('Not Found', { status: 404 })
263
+ }
264
+ const methodHandler = matched.methods[req.method]
265
+ if (typeof methodHandler === 'function') {
266
+ return (methodHandler as RouteHandler)(req, srv, matched.params)
267
+ }
268
+ // A route matched but this method isn't wired — 405, or a bare OPTIONS 204
269
+ // when the route answers preflights.
270
+ if (req.method === 'OPTIONS' && typeof matched.methods.OPTIONS === 'function') {
271
+ return (matched.methods.OPTIONS as RouteHandler)(req, srv, matched.params)
272
+ }
273
+ return new Response('Method Not Allowed', { status: 405 })
274
+ }
275
+
276
+ return { routeHandlers: compiled, routes, match: matcher, fetch, prewarm }
277
+ }
@@ -1,6 +1,8 @@
1
1
  // src/ingress/routes/common.ts — Shared route-handler options + helpers.
2
2
 
3
- import type { OptimizedIngressHandler } from '../types'
3
+ import type { BakedIngressResult } from '../decode/baked-result'
4
+ import { secondsFromMs } from '../shared'
5
+ import type { BakedContext, OptimizedIngressHandler } from '../types'
4
6
 
5
7
  /** Options accepted by the pre-baked route factories. */
6
8
  export interface BakedHandlerOptions {
@@ -18,6 +20,14 @@ export interface BakedHandlerOptions {
18
20
  bodyTimeoutMs?: number
19
21
  /** Fallback handler used for write error paths. Defaults to the write ingress. */
20
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
21
31
  /**
22
32
  * When true, route the JSON-validity fallback check through the higher-order
23
33
  * loader (`loader("jsonValid")`) instead of the direct native call —
@@ -36,3 +46,42 @@ export function resolveIp(
36
46
  ): string | undefined {
37
47
  return opts.getIp ? opts.getIp(req, srv) : undefined
38
48
  }
49
+
50
+ /**
51
+ * Build the success `ResponseInit` (status + ratelimit headers) shared by
52
+ * every pre-baked success path. A plain function call, so the hot path keeps
53
+ * its single init-object allocation and nothing else.
54
+ *
55
+ * @param status Success status; defaults to 200.
56
+ */
57
+ export function buildSuccessInit(
58
+ ingress: OptimizedIngressHandler,
59
+ result: BakedIngressResult,
60
+ ctx: BakedContext,
61
+ status = 200,
62
+ ): 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
+ }
73
+ }
74
+
75
+ /**
76
+ * Wrap a `(result, ctx) => Response` responder in the shared sync route
77
+ * runner: resolve the client IP once and dispatch through the native pipeline
78
+ * (`ingress.run` with a null body). Used by the read / head / fallback route
79
+ * factories whose responder never needs the request object.
80
+ */
81
+ export function runBaked(
82
+ ingress: OptimizedIngressHandler,
83
+ opts: BakedHandlerOptions,
84
+ respond: (result: BakedIngressResult, ctx: BakedContext) => Response,
85
+ ): (req: Request, srv?: unknown) => Response {
86
+ return (req, srv) => ingress.run<Response>(req, resolveIp(req, srv, opts), null, respond)
87
+ }
@@ -2,7 +2,7 @@
2
2
 
3
3
  import type { BakedContext, OptimizedIngressHandler } from '../types'
4
4
  import type { BakedIngressResult } from '../decode/baked-result'
5
- import { resolveIp, type BakedHandlerOptions } from './common'
5
+ import { runBaked, type BakedHandlerOptions } from './common'
6
6
 
7
7
  /**
8
8
  * Pre-baked fallback handler: 404 for unmatched routes / OPTIONS.
@@ -19,5 +19,5 @@ export function fallbackHandler(
19
19
  return ingress.errorResponse(undefined, result, 404, 'not_found', 'Not found', ctx)
20
20
  }
21
21
 
22
- return (req, srv) => ingress.run<Response>(req, resolveIp(req, srv, opts), null, respond)
22
+ return runBaked(ingress, opts, respond)
23
23
  }
@@ -2,8 +2,7 @@
2
2
 
3
3
  import type { BakedContext, OptimizedIngressHandler } from '../types'
4
4
  import type { BakedIngressResult } from '../decode/baked-result'
5
- import { resolveIp, type BakedHandlerOptions } from './common'
6
- import { secondsFromMs } from '../shared'
5
+ import { buildSuccessInit, runBaked, type BakedHandlerOptions } from './common'
7
6
 
8
7
  /**
9
8
  * Pre-baked HEAD read handler: headers only, no body.
@@ -21,17 +20,8 @@ export function headHandler(
21
20
  return ingress.internalErrorResponse(ctx, result)
22
21
  }
23
22
 
24
- return new Response(null, {
25
- status: 200,
26
- headers: ingress.responseHeaders(
27
- result.headerVariant,
28
- ctx.requestIdHeader,
29
- ctx.origin,
30
- result.rateRemaining,
31
- result.rateResetMs > 0 ? secondsFromMs(result.rateResetMs) : undefined,
32
- ),
33
- })
23
+ return new Response(null, buildSuccessInit(ingress, result, ctx))
34
24
  }
35
25
 
36
- return (req, srv) => ingress.run<Response>(req, resolveIp(req, srv, opts), null, respond)
26
+ return runBaked(ingress, opts, respond)
37
27
  }
@@ -1,10 +1,11 @@
1
1
  // src/ingress/routes/index.ts — Route-handler factory composer.
2
2
 
3
3
  export * from './common'
4
- export * from './read'
4
+ export * from './delete'
5
+ export * from './echo'
6
+ export * from './fallback'
5
7
  export * from './head'
6
8
  export * from './json-write'
7
- export * from './echo'
8
- export * from './delete'
9
9
  export * from './options'
10
- export * from './fallback'
10
+ export * from './read'
11
+ export * from './responder'
@@ -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 { resolveIp, type BakedHandlerOptions } from './common'
6
- import { DEFAULT_MAX_BODY_BYTES, DEFAULT_BODY_TIMEOUT_MS, secondsFromMs } 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)
@@ -60,25 +63,10 @@ export function jsonWriteHandler(
60
63
  })
61
64
  }
62
65
 
63
- const contentLengthHeader = req.headers.get('content-length')
64
- const contentLength = contentLengthHeader === null ? NaN : Number(contentLengthHeader)
65
-
66
- if (Number.isFinite(contentLength) && contentLength > maxBodyBytes) {
67
- return fallback.run(req, ip, null, (result, ctx) => {
68
- const terminal = fallback.terminalResponse(req, result, ctx)
69
- if (terminal) return terminal
70
-
71
- return fallback.errorResponse(
72
- req,
73
- result,
74
- 413,
75
- 'body_too_large',
76
- 'Request body is too large',
77
- ctx,
78
- )
79
- })
80
- }
81
-
66
+ // Content-Length / body-size enforcement happens INSIDE readBodyWithLimit
67
+ // (guard on): the declared length is checked before reading and re-checked
68
+ // after, so a client lying about Content-Length still gets a 413. Not
69
+ // re-reading the header here saves a per-POST headers.get on the hot path.
82
70
  let bodyBytes: Uint8Array
83
71
  try {
84
72
  // Stream-read with the limit enforced WHILE reading — a body that
@@ -130,20 +118,11 @@ export function jsonWriteHandler(
130
118
  ctx,
131
119
  )
132
120
  }
133
- } else if (!isValidJsonBytes(bodyBytes)) {
121
+ } else if (!jsonValid(bodyBytes)) {
134
122
  return ingress.errorResponse(req, result, 400, 'invalid_json', 'Invalid JSON body', ctx)
135
123
  }
136
124
 
137
- const init: ResponseInit = {
138
- status: 200,
139
- headers: ingress.responseHeaders(
140
- result.headerVariant,
141
- ctx.requestIdHeader,
142
- ctx.origin,
143
- result.rateRemaining,
144
- result.rateResetMs > 0 ? secondsFromMs(result.rateResetMs) : undefined,
145
- ),
146
- }
125
+ const init = buildSuccessInit(ingress, result, ctx)
147
126
 
148
127
  return copyBody
149
128
  ? new Response(result.bodyJson(true), init)
@@ -0,0 +1,130 @@
1
+ // src/ingress/routes/native.ts — lean per-route native-stack route factory.
2
+ //
3
+ // A `RouteHandler` backed by the route-wire v3 stack (`createNativeRoute`)
4
+ // instead of the full 8-stage `IngressInner`: the native side runs ONLY the
5
+ // stages the plan compiled (parseQuery/parseCookies/requireJsonBody/
6
+ // validateBody) and returns a packed verdict + pair sections; the responder
7
+ // builds the 2xx from the decoded snapshot. This is the leanest per-request
8
+ // path — measured `bench/cost/native-route-vs-router.ts`: ~551ns full JS-glue
9
+ // path vs ~1131ns router `run()` on a parseQuery+parseCookies route (−580ns).
10
+ //
11
+ // PURE: the compiled `NativeRoute` is injected (dependency injection across
12
+ // the purity boundary) — this factory never touches the dlopen layer itself;
13
+ // the impure `createNativeRoute(plan)` call happens in the boundary
14
+ // (`router.ts` / `server.ts` `buildRouteHandlers`).
15
+ //
16
+ // Trade-off (deliberate): the native route stack does NOT do CORS, rate
17
+ // limiting, security headers, IP trust, or the castrum metadata envelope — a
18
+ // route that needs those must use the full pipeline (`readHandler` /
19
+ // `nativeResponderRoute`). This factory is for routes where the framework
20
+ // owns the response body and only needs parse + verdict.
21
+
22
+ import { decoder } from '../../shared/bytes'
23
+ import { generateRequestId } from '../../shared/request-id'
24
+ import { readBodyWithLimit } from '../body'
25
+ import type { NativeRoute } from '../native-route'
26
+ import type { RouteHandler } from '../server'
27
+ import { DEFAULT_BODY_TIMEOUT_MS, DEFAULT_MAX_BODY_BYTES } from '../shared'
28
+ import type { NativeRequestContext, NativeResponder } from '../types'
29
+ import type { BakedHandlerOptions } from './common'
30
+ import { resolveIp } from './common'
31
+
32
+ /** Options for {@link nativeRouteHandler}. */
33
+ export interface NativeRouteHandlerOptions extends BakedHandlerOptions {
34
+ /** Read the request body and pass it for `requireJsonBody`/`validateBody`. */
35
+ readBody?: boolean
36
+ }
37
+
38
+ /** The `+`-to-space / `%XX` decoding is done natively; keys are last-wins. */
39
+ function pairsToRecord(pairs: ReadonlyArray<[string, string]>): Record<string, string> {
40
+ const out: Record<string, string> = {}
41
+ for (const [k, v] of pairs) out[k] = v
42
+ return out
43
+ }
44
+
45
+ /**
46
+ * Build a lean native-stack route handler over a COMPILED route (injected —
47
+ * compile via `createNativeRoute` in the boundary, then pass the route here).
48
+ *
49
+ * Terminal verdicts (`errorCode !== 0`) become JSON rejections: 400 for
50
+ * `requireJsonBody` (missing / non-JSON body), 422 for `validateBody` schema
51
+ * failure. On success the responder builds the 2xx from the snapshot.
52
+ *
53
+ * @example
54
+ * ```ts
55
+ * const route = createNativeRoute({ parseQuery: true, parseCookies: true })
56
+ * const handler = nativeRouteHandler(route, (snap) =>
57
+ * Response.json({ ok: true, query: snap.query, cookies: snap.cookies }),
58
+ * )
59
+ * ```
60
+ */
61
+ export function nativeRouteHandler(
62
+ route: NativeRoute,
63
+ responder: NativeResponder,
64
+ opts: NativeRouteHandlerOptions = {},
65
+ ): RouteHandler {
66
+ const readBody = opts.readBody ?? false
67
+ const maxBodyBytes = opts.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES
68
+ const bodyTimeoutMs = opts.bodyTimeoutMs ?? DEFAULT_BODY_TIMEOUT_MS
69
+ const parseQuery = route.parseQuery
70
+ const parseCookies = route.parseCookies
71
+
72
+ const terminal = (status: number, code: string, message: string): Response =>
73
+ Response.json(
74
+ { ok: false, error: { code, message } },
75
+ { status, headers: { 'content-type': 'application/json' } },
76
+ )
77
+
78
+ return async (req, srv, _params) => {
79
+ const ip = resolveIp(req, srv, opts)
80
+ const requestId = decoder.decode(generateRequestId())
81
+
82
+ let body: Uint8Array | null = null
83
+ if (readBody) {
84
+ try {
85
+ body = await readBodyWithLimit(req, maxBodyBytes, true, bodyTimeoutMs)
86
+ } catch (err) {
87
+ const code = (err as Error & { code?: string }).code
88
+ const status = code === 'REQUEST_TIMEOUT' ? 408 : code === 'BODY_TOO_LARGE' ? 413 : 400
89
+ return terminal(status, 'BAD_REQUEST', 'Request body read failed')
90
+ }
91
+ }
92
+
93
+ // Extract the query substring + Cookie header (the only request inputs the
94
+ // native stack reads) and run the tiny frame in ONE native call.
95
+ const url = req.url
96
+ const qIndex = url.indexOf('?')
97
+ const queryStr = qIndex >= 0 ? url.slice(qIndex + 1) : ''
98
+ const cookieStr = req.headers.get('cookie') ?? ''
99
+
100
+ let result
101
+ try {
102
+ result = route.run(queryStr, cookieStr, body)
103
+ } catch {
104
+ return terminal(500, 'INTERNAL', 'Native route run failed')
105
+ }
106
+
107
+ if (result.errorCode !== 0) {
108
+ if (result.errorCode === 400) {
109
+ return terminal(400, 'INVALID_JSON', 'Request body must be valid JSON')
110
+ }
111
+ if (result.errorCode === 422) {
112
+ return terminal(422, 'VALIDATION_FAILED', 'Request body failed schema validation')
113
+ }
114
+ return terminal(400, 'BAD_REQUEST', 'Bad request')
115
+ }
116
+
117
+ const snapshot: NativeRequestContext = {
118
+ requestId,
119
+ status: 200,
120
+ rateLimited: false,
121
+ retryAfterMs: 0,
122
+ query: parseQuery ? pairsToRecord(result.query) : {},
123
+ cookies: parseCookies ? pairsToRecord(result.cookie) : {},
124
+ body: body ?? new Uint8Array(0),
125
+ ip,
126
+ req,
127
+ }
128
+ return responder(snapshot)
129
+ }
130
+ }
@@ -1,8 +1,7 @@
1
1
  // src/ingress/routes/options.ts — Pre-baked OPTIONS handler (CORS preflight).
2
2
 
3
3
  import type { OptimizedIngressHandler } from '../types'
4
- import { resolveIp, type BakedHandlerOptions } from './common'
5
- import { secondsFromMs } from '../shared'
4
+ import { buildSuccessInit, resolveIp, type BakedHandlerOptions } from './common'
6
5
 
7
6
  /**
8
7
  * Pre-baked OPTIONS handler: serves the native pipeline's CORS preflight
@@ -22,15 +21,6 @@ export function optionsHandler(
22
21
  const terminal = ingress.terminalResponse(req, result, ctx)
23
22
  if (terminal) return terminal
24
23
 
25
- return new Response(null, {
26
- status: 204,
27
- headers: ingress.responseHeaders(
28
- result.headerVariant,
29
- ctx.requestIdHeader,
30
- ctx.origin,
31
- result.rateRemaining,
32
- result.rateResetMs > 0 ? secondsFromMs(result.rateResetMs) : undefined,
33
- ),
34
- })
24
+ return new Response(null, buildSuccessInit(ingress, result, ctx, 204))
35
25
  })
36
26
  }