castrum 0.9.1 → 0.9.3

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,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
+ }
@@ -20,10 +20,10 @@ import {
20
20
  type ServerResponse,
21
21
  } from 'node:http'
22
22
  import { type Duplex, Readable } from 'node:stream'
23
+ import { buildPathMatcher } from './path-matcher'
23
24
  import { fallbackHandler } from './routes'
24
25
  import {
25
26
  type BakedServer,
26
- buildPathMatcher,
27
27
  buildRouteHandlers,
28
28
  type CreateIngressServerOptions,
29
29
  type RouteHandler,
@@ -11,7 +11,10 @@ import {
11
11
  readHandler,
12
12
  } from './routes'
13
13
  import type { BakedHandlerOptions } from './routes/common'
14
+ import { nativeRouteHandler } from './routes/native'
14
15
  import { nativeResponderRoute } from './routes/responder'
16
+ import { createNativeRoute } from './native-route'
17
+ import type { NativeRoutePlan } from './native-route'
15
18
  import type { NativeResponder, OptimizedIngressHandler, TerminalStyle } from './types'
16
19
 
17
20
  /** Server-level default for the socket request-body cap (16 MiB). */
@@ -64,6 +67,22 @@ export interface BakedRoute {
64
67
  /** Read the body for native validation (default false — framework owns it). */
65
68
  readBody?: boolean
66
69
  }
70
+ /**
71
+ * A LEAN native-stack responder route: the route-wire v3 per-route stack
72
+ * (`createNativeRoute`) runs ONLY the plan's stages in ONE native call —
73
+ * no CORS/rate-limit/security/IP/metadata envelope. Wired for `methods`
74
+ * (default GET) by `nativeRouteHandler` (src/ingress/routes/native.ts).
75
+ */
76
+ native?: {
77
+ /** The route-wire v3 plan (parse/validate stages + limits). */
78
+ plan: NativeRoutePlan
79
+ /** The JS 2xx builder. */
80
+ handler: NativeResponder
81
+ /** HTTP methods to wire (default `['GET']`). */
82
+ methods?: ReadonlyArray<string>
83
+ /** Read the body for `requireJsonBody`/`validateBody` (default false). */
84
+ readBody?: boolean
85
+ }
67
86
  /** Overrides `maxBodyBytes` for this route's write/echo handlers. */
68
87
  maxBodyBytes?: number
69
88
  /**
@@ -281,6 +300,20 @@ export function buildRouteHandlers(options: BuildRouteHandlersOptions): {
281
300
  }
282
301
  }
283
302
 
303
+ if (spec.native) {
304
+ // LEAN native-stack responder route: route-wire v3 stack, no envelope.
305
+ // The compiled route is injected into the pure route factory (DI across
306
+ // the purity boundary — the compile touches the dlopen layer here).
307
+ const nativeRoute = nativeRouteHandler(createNativeRoute(spec.native.plan), spec.native.handler, {
308
+ ...routeOpts,
309
+ readBody: spec.native.readBody,
310
+ })
311
+ const nativeMethods = spec.native.methods ?? ['GET']
312
+ for (const m of nativeMethods) {
313
+ methods[m] = nativeRoute
314
+ }
315
+ }
316
+
284
317
  // CORS preflight (OPTIONS) is served for EVERY route with a NATIVE handler
285
318
  // (not just write routes with a fallback), so read-only routes also answer
286
319
  // preflights with 204/403 from the native pipeline. Raw probe handlers
@@ -301,106 +334,12 @@ export function buildRouteHandlers(options: BuildRouteHandlersOptions): {
301
334
  return { routes: serverRoutes, baseOpts }
302
335
  }
303
336
 
304
- /** A matched dynamic route: the handler map + the extracted path params. */
305
- export interface PathMatch {
306
- methods: Record<string, unknown>
307
- params: Record<string, string>
308
- }
337
+ // ── Path matching (shared with server-node.ts + router.ts) ─────────
338
+ // `buildPathMatcher` / `PathMatch` / `safeDecode` live in path-matcher.ts;
339
+ // re-exported here for back-compat (they were historically exported from this
340
+ // module).
309
341
 
310
- interface CompiledDynamicPattern {
311
- re: RegExp
312
- paramNames: string[]
313
- rest: boolean
314
- methods: Record<string, unknown>
315
- /** Number of literal (static) segments — used to prefer the most specific pattern. */
316
- staticSegments: number
317
- }
318
-
319
- /**
320
- * Build a path matcher over a route map, supporting `:param` and `*` (rest)
321
- * segments. Exact (static) paths always win over dynamic patterns; dynamic
322
- * patterns are ordered most-specific-first (most static segments, then fewest
323
- * params). Used by `createIngressServerNode` (Bun's native router already
324
- * matches dynamic patterns itself). The ingress pipeline does not echo path
325
- * params in its response, so matching only selects the handler.
326
- */
327
- export function buildPathMatcher(
328
- routes: Record<string, Record<string, unknown>>,
329
- ): (pathname: string) => PathMatch | undefined {
330
- const exact = new Map<string, Record<string, unknown>>()
331
- const dynamic: CompiledDynamicPattern[] = []
332
-
333
- for (const [path, methods] of Object.entries(routes)) {
334
- const hasParam = path.indexOf(':') !== -1
335
- const hasRest = path.indexOf('*') !== -1
336
- if (!hasParam && !hasRest) {
337
- exact.set(path, methods)
338
- continue
339
- }
340
-
341
- const paramNames: string[] = []
342
- let rest = false
343
- let staticSegments = 0
344
- const parts = path.split('/').filter((s) => s.length > 0)
345
- const source = parts
346
- .map((seg) => {
347
- if (seg === '*') {
348
- rest = true
349
- return '(.*)'
350
- }
351
- if (seg.startsWith(':')) {
352
- paramNames.push(seg.slice(1))
353
- return '([^/]+)'
354
- }
355
- staticSegments++
356
- return seg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
357
- })
358
- .join('/')
359
-
360
- dynamic.push({
361
- re: new RegExp(`^/${source}${rest ? '' : '/?$'}`),
362
- paramNames,
363
- rest,
364
- methods,
365
- staticSegments,
366
- })
367
- }
368
-
369
- dynamic.sort(
370
- (a, b) => b.staticSegments - a.staticSegments || a.paramNames.length - b.paramNames.length,
371
- )
372
-
373
- return (pathname: string) => {
374
- const exactHit = exact.get(pathname)
375
- if (exactHit !== undefined) {
376
- return { methods: exactHit, params: {} }
377
- }
378
- for (const d of dynamic) {
379
- const m = d.re.exec(pathname)
380
- if (m !== null) {
381
- const params: Record<string, string> = {}
382
- for (let i = 0; i < d.paramNames.length; i++) {
383
- const raw = m[i + 1]
384
- if (raw !== undefined) params[d.paramNames[i] ?? ''] = safeDecode(raw)
385
- }
386
- if (d.rest) {
387
- const restRaw = m[d.paramNames.length + 1]
388
- if (restRaw !== undefined) params['*'] = safeDecode(restRaw)
389
- }
390
- return { methods: d.methods, params }
391
- }
392
- }
393
- return undefined
394
- }
395
- }
396
-
397
- function safeDecode(value: string): string {
398
- try {
399
- return decodeURIComponent(value)
400
- } catch {
401
- return value
402
- }
403
- }
342
+ export { buildPathMatcher, type PathMatch, safeDecode } from './path-matcher'
404
343
 
405
344
  /** Typed options forwarded to `Bun.serve`. */
406
345
  interface BunServerOptions {
@@ -0,0 +1,151 @@
1
+ // src/ingress/sync.ts — Sync + async convenience factories (path 1 wrappers)
2
+ //
3
+ // `createIngressSync` and `createIngress` wrap `createIngressFast` (path 1)
4
+ // with the synchronous-callback guard / automatic body reading + error
5
+ // contexts. Kept OUT of index.ts so the public barrel stays a pure re-export
6
+ // hub. Runtime/server selection lives in src/runtime/server.ts — NOT here.
7
+
8
+ import { decoder } from '../shared/bytes'
9
+ import { generateRequestId } from '../shared/request-id'
10
+ import { readRequestBodyOnce } from './body'
11
+ import { ERR_CODE_BODY_TOO_LARGE, ERR_CODE_REQUEST_TIMEOUT } from './constants'
12
+ import { internalContext, snapshotResult, syntheticContext } from './context'
13
+ import { createIngressFast } from './fast'
14
+ import { buildResponseContext } from './headers/fast-templates'
15
+ import { buildTerminalResponse } from './response/terminal'
16
+ import { assertSyncCallback, DEFAULT_BODY_TIMEOUT_MS, DEFAULT_MAX_BODY_BYTES } from './shared'
17
+ import type { IngressContext, IngressHandler, IngressOptions, SyncIngressHandler } from './types'
18
+
19
+ /**
20
+ * Synchronous convenience wrapper over `createIngressFast` (path 1).
21
+ *
22
+ * The callback runs on the fast-path result and MUST be synchronous. Use
23
+ * {@link createIngress} when you want automatic body reading + error contexts.
24
+ *
25
+ * @param options fast-path ingress options
26
+ * @returns a sync handler (callback must be synchronous)
27
+ */
28
+ export function createIngressSync(options: IngressOptions = {}): SyncIngressHandler {
29
+ const fast = createIngressFast(options)
30
+
31
+ return {
32
+ run(req, ip, body, requestId, fn) {
33
+ return fast.run(req, ip, body, requestId, (result) => {
34
+ const out = fn(result)
35
+ assertSyncCallback(out, 'createIngressSync().run()')
36
+ return out
37
+ })
38
+ },
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Return a copy of `options` without the security-header keys.
44
+ *
45
+ * `createIngress` applies security headers itself (via `buildResponseContext`),
46
+ * so they must not be forwarded to the underlying fast handler, which rejects
47
+ * them (see `createIngressFast`).
48
+ */
49
+ function stripSecurityOptions(options: IngressOptions): IngressOptions {
50
+ const copy = { ...options }
51
+ delete copy.security
52
+ delete copy.enableSecurityHeaders
53
+ return copy
54
+ }
55
+
56
+ /**
57
+ * Async ingress factory: reads the request body (size guard + deadline) and
58
+ * returns a snapshot `IngressContext` with a terminal `Response`.
59
+ *
60
+ * Recommended default for serverless/edge-style `fetch` handlers. For maximum
61
+ * throughput use `createIngressFast` / `createIngressSync` directly.
62
+ *
63
+ * @param options fast-path ingress options (security applied here, not forwarded)
64
+ * @returns an async handler returning a snapshot context
65
+ */
66
+ export function createIngress(options: IngressOptions = {}): IngressHandler {
67
+ const sync = createIngressSync(stripSecurityOptions(options))
68
+
69
+ const guard = options.enableBodySizeGuard !== false
70
+ const max = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES
71
+ const bodyTimeoutMs = options.bodyTimeoutMs ?? DEFAULT_BODY_TIMEOUT_MS
72
+
73
+ const wantsBody =
74
+ options.readBody === true ||
75
+ options.requireJsonBody === true ||
76
+ options.schema != null ||
77
+ (options.readBody !== false && guard)
78
+
79
+ const responseCtx = buildResponseContext(options)
80
+
81
+ return async function ingressAsync(req: Request, ip?: string): Promise<IngressContext> {
82
+ const requestId = options.enableRequestIds === false ? '' : decoder.decode(generateRequestId())
83
+ options.onRequest?.(req, requestId, ip)
84
+
85
+ // Every terminal outcome (success, 413, 408, 500) flows through here so a
86
+ // hook consumer sees exactly one onResponse per request.
87
+ const emitResponse = (context: IngressContext): IngressContext => {
88
+ options.onResponse?.(req, context, context.status, requestId)
89
+ return context
90
+ }
91
+
92
+ try {
93
+ if (guard) {
94
+ const rawLen = req.headers.get('content-length')
95
+ const contentLength = Number(rawLen ?? '0')
96
+
97
+ if (Number.isFinite(contentLength) && contentLength > max) {
98
+ return emitResponse(
99
+ syntheticContext(req, requestId, options, responseCtx, 413, ERR_CODE_BODY_TOO_LARGE),
100
+ )
101
+ }
102
+ }
103
+
104
+ let body: Uint8Array | null = null
105
+
106
+ if (wantsBody && req.body !== null) {
107
+ try {
108
+ body = await readRequestBodyOnce(req, max, guard, bodyTimeoutMs)
109
+ } catch (err) {
110
+ const code = (err as { code?: string } | null)?.code
111
+
112
+ if (code === 'BODY_TOO_LARGE') {
113
+ return emitResponse(
114
+ syntheticContext(req, requestId, options, responseCtx, 413, ERR_CODE_BODY_TOO_LARGE),
115
+ )
116
+ }
117
+
118
+ if (code === 'REQUEST_TIMEOUT') {
119
+ // Match the pre-baked route factories: a body-read deadline is a
120
+ // 408, not a 500 (was silently swallowed as 500 before).
121
+ return emitResponse(
122
+ syntheticContext(req, requestId, options, responseCtx, 408, ERR_CODE_REQUEST_TIMEOUT),
123
+ )
124
+ }
125
+
126
+ // Unknown body-read failure: surface to onError, then fall through
127
+ // to the internal-error context below.
128
+ options.onError?.(err instanceof Error ? err : new Error(String(err)))
129
+ throw err
130
+ }
131
+ }
132
+
133
+ return emitResponse(
134
+ sync.run(req, ip, body, requestId, (r) => {
135
+ const snapshot = snapshotResult(r)
136
+ const response = buildTerminalResponse(responseCtx, snapshot, req, requestId)
137
+
138
+ return {
139
+ ...snapshot,
140
+ response,
141
+ }
142
+ }),
143
+ )
144
+ } catch (err) {
145
+ // Native/other failures: never silent — report to onError and emit a
146
+ // structured 500 context so the request still completes observably.
147
+ options.onError?.(err instanceof Error ? err : new Error(String(err)))
148
+ return emitResponse(internalContext(req, requestId, options, responseCtx))
149
+ }
150
+ }
151
+ }
@@ -147,6 +147,15 @@ export interface OptimizedIngressHandler {
147
147
 
148
148
  withContentType(headers: ReadonlyArray<[string, string]>, contentType: string): [string, string][]
149
149
 
150
+ /**
151
+ * Zero-DOM JSON-validity check bound to this handler's native transport
152
+ * (bun:ffi primary, napi fallback). Provided so the pure route factories
153
+ * (`routes/*`) can validate request bodies via dependency injection without
154
+ * importing the native layer. Always present on handlers from
155
+ * `createIngressHandler`; optional so hand-rolled mocks need not provide it.
156
+ */
157
+ jsonValid?: (bytes: Uint8Array) => boolean
158
+
150
159
  /**
151
160
  * Build a zero-copy `Response` whose body is the request's pooled output
152
161
  * slice. The pooled buffer is returned to its pool once the body has been