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
@@ -1,182 +1,58 @@
1
- // src/ingress/index.ts — Public ingress API (barrel + sync/async factories)
1
+ // src/ingress/index.ts — Public ingress API (barrel).
2
2
  //
3
- // All constants come from Rust via src/ingress/constants.ts.
4
- // For maximum performance, use createIngressFast() from ./fast.ts directly.
5
- // This module wraps createIngressFast with convenience async API (createIngress).
6
-
7
- import { decoder } from '../shared/bytes'
8
- import { generateRequestId } from '../shared/request-id'
9
- import { assertSyncCallback, DEFAULT_MAX_BODY_BYTES, DEFAULT_BODY_TIMEOUT_MS } from './shared'
10
- import { createIngressFast } from './fast'
11
- import { buildResponseContext } from './headers/fast-templates'
12
- import { buildTerminalResponse } from './response/terminal'
13
- import { snapshotResult, syntheticContext, internalContext } from './context'
14
- import { readRequestBodyOnce } from './body'
15
- import { ERR_CODE_BODY_TOO_LARGE, ERR_CODE_REQUEST_TIMEOUT } from './constants'
16
- import type { IngressOptions, IngressContext, SyncIngressHandler, IngressHandler } from './types'
17
-
3
+ // All constants come from Rust via src/ingress/constants.ts. For maximum
4
+ // performance use createIngressFast() from ./fast.ts directly. The convenience
5
+ // factories (`createIngressSync` / `createIngress`) live in ./sync.ts and are
6
+ // re-exported here so this barrel stays a pure re-export hub.
7
+
8
+ // Runtime-driven server factory: picks Bun.serve on Bun and the node:http
9
+ // adapter on Node via the runtime adapter (src/runtime/server.ts). This
10
+ // explicit re-export SHADOWS the Bun-only `createIngressServer` from
11
+ // './server' (re-exported via './handlers' below) so the public API serves
12
+ // one backend per runtime. `createIngressServerNode` remains exported for
13
+ // consumers who want the Node backend pinned.
14
+ export { createIngressServer } from '../runtime/index'
15
+ export { generateRequestId } from '../shared/request-id'
16
+ export type { TraceContext } from '../shared/trace'
17
+ // W3C trace context helpers (parse `traceparent` for log/hook correlation).
18
+ export {
19
+ createSpanId,
20
+ createTraceId,
21
+ parseTraceParent,
22
+ serializeTraceParent,
23
+ } from '../shared/trace'
18
24
  // ── Re-export the full public ingress API ─────────────────────────
19
25
  export * from './constants'
20
- export * from './status'
21
26
  export * from './errors'
22
- export * from './handlers'
23
27
  export { createIngressFast, FastIngressResult } from './fast'
24
- export { buildTerminalResponse } from './response/terminal'
28
+ export * from './handlers'
29
+ export type { CorsOptions, CorsStaticStrings } from './headers/cors'
30
+ export type { HeaderTemplate, ResponseBuildContext } from './headers/fast-templates'
25
31
  export { buildResponseContext, headersForResult } from './headers/fast-templates'
26
- export { generateRequestId } from '../shared/request-id'
27
- export { METHOD_KIND, DEFAULT_MAX_BODY_BYTES, DEFAULT_BODY_TIMEOUT_MS } from './shared'
28
- export type { HeaderPlan } from './shared'
29
- export type { ResponseBuildContext, HeaderTemplate } from './headers/fast-templates'
30
- export type { CorsStaticStrings, CorsOptions } from './headers/cors'
31
32
  export type { SecurityHeadersOptions } from './headers/hsts'
32
- export type { IngressFastHandler, IngressFastOptions } from './options'
33
+ export { healthHandler, livenessHandler, readinessHandler } from './health'
34
+ export type { IngressMetrics } from './metrics'
33
35
  // ── Observability (zero-dep): metrics, health probes ─────────────
34
36
  export { createIngressMetrics, metricsHandler } from './metrics'
35
- export type { IngressMetrics } from './metrics'
36
- export { livenessHandler, readinessHandler, healthHandler } from './health'
37
- // W3C trace context helpers (parse `traceparent` for log/hook correlation).
38
- export {
39
- parseTraceParent,
40
- createTraceId,
41
- createSpanId,
42
- serializeTraceParent,
43
- } from '../shared/trace'
44
- export type { TraceContext } from '../shared/trace'
37
+ export type { NativeRoute, NativeRoutePlan } from './native-route'
38
+ export { createNativeRoute } from './native-route'
39
+ // ── Route-wire v3 byte contract (PURE wire helpers) ───────────────
40
+ export * from './packing/route-wire'
41
+ export type { IngressFastHandler, IngressFastOptions } from './options'
42
+ export { buildTerminalResponse } from './response/terminal'
43
+ export type { CreateIngressRouterOptions, IngressRouter, RouterRouteSpec } from './router'
44
+ export { createIngressRouter } from './router'
45
+ export type { HeaderPlan } from './shared'
46
+ export { DEFAULT_BODY_TIMEOUT_MS, DEFAULT_MAX_BODY_BYTES, METHOD_KIND } from './shared'
47
+ export * from './status'
48
+ // ── Convenience factories (see ./sync.ts) ────────────────────────
49
+ export { createIngress, createIngressSync } from './sync'
45
50
  export type {
46
- IngressOptions,
47
51
  IngressContext,
48
- SyncIngressHandler,
49
52
  IngressHandler,
53
+ IngressOptions,
54
+ NativeRequestContext,
55
+ NativeResponder,
56
+ SyncIngressHandler,
57
+ TerminalStyle,
50
58
  } from './types'
51
-
52
- // ── Sync factory ─────────────────────────────────────────────────
53
-
54
- /**
55
- * Synchronous convenience wrapper over `createIngressFast` (path 1).
56
- *
57
- * The callback runs on the fast-path result and MUST be synchronous. Use
58
- * {@link createIngress} when you want automatic body reading + error contexts.
59
- */
60
- export function createIngressSync(options: IngressOptions = {}): SyncIngressHandler {
61
- const fast = createIngressFast(options)
62
-
63
- return {
64
- run(req, ip, body, requestId, fn) {
65
- return fast.run(req, ip, body, requestId, (result) => {
66
- const out = fn(result)
67
- assertSyncCallback(out, 'createIngressSync().run()')
68
- return out
69
- })
70
- },
71
- }
72
- }
73
-
74
- // ── Async factory ────────────────────────────────────────────────
75
-
76
- /**
77
- * Return a copy of `options` without the security-header keys.
78
- *
79
- * `createIngress` applies security headers itself (via `buildResponseContext`),
80
- * so they must not be forwarded to the underlying fast handler, which rejects
81
- * them (see `createIngressFast`).
82
- */
83
- function stripSecurityOptions(options: IngressOptions): IngressOptions {
84
- const copy = { ...options }
85
- delete copy.security
86
- delete copy.enableSecurityHeaders
87
- return copy
88
- }
89
-
90
- /**
91
- * Async ingress factory: reads the request body (size guard + deadline) and
92
- * returns a snapshot `IngressContext` with a terminal `Response`.
93
- *
94
- * Recommended default for serverless/edge-style `fetch` handlers. For maximum
95
- * throughput use `createIngressFast` / `createIngressSync` directly.
96
- */
97
- export function createIngress(options: IngressOptions = {}): IngressHandler {
98
- const sync = createIngressSync(stripSecurityOptions(options))
99
-
100
- const guard = options.enableBodySizeGuard !== false
101
- const max = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES
102
- const bodyTimeoutMs = options.bodyTimeoutMs ?? DEFAULT_BODY_TIMEOUT_MS
103
-
104
- const wantsBody =
105
- options.readBody === true ||
106
- options.requireJsonBody === true ||
107
- options.schema != null ||
108
- (options.readBody !== false && guard)
109
-
110
- const responseCtx = buildResponseContext(options)
111
-
112
- return async function ingressAsync(req: Request, ip?: string): Promise<IngressContext> {
113
- const requestId = options.enableRequestIds === false ? '' : decoder.decode(generateRequestId())
114
- options.onRequest?.(req, requestId, ip)
115
-
116
- // Every terminal outcome (success, 413, 408, 500) flows through here so a
117
- // hook consumer sees exactly one onResponse per request.
118
- const emitResponse = (context: IngressContext): IngressContext => {
119
- options.onResponse?.(req, context, context.status, requestId)
120
- return context
121
- }
122
-
123
- try {
124
- if (guard) {
125
- const rawLen = req.headers.get('content-length')
126
- const contentLength = Number(rawLen ?? '0')
127
-
128
- if (Number.isFinite(contentLength) && contentLength > max) {
129
- return emitResponse(
130
- syntheticContext(req, requestId, options, responseCtx, 413, ERR_CODE_BODY_TOO_LARGE),
131
- )
132
- }
133
- }
134
-
135
- let body: Uint8Array | null = null
136
-
137
- if (wantsBody && req.body !== null) {
138
- try {
139
- body = await readRequestBodyOnce(req, max, guard, bodyTimeoutMs)
140
- } catch (err) {
141
- const code = (err as { code?: string } | null)?.code
142
-
143
- if (code === 'BODY_TOO_LARGE') {
144
- return emitResponse(
145
- syntheticContext(req, requestId, options, responseCtx, 413, ERR_CODE_BODY_TOO_LARGE),
146
- )
147
- }
148
-
149
- if (code === 'REQUEST_TIMEOUT') {
150
- // Match the pre-baked route factories: a body-read deadline is a
151
- // 408, not a 500 (was silently swallowed as 500 before).
152
- return emitResponse(
153
- syntheticContext(req, requestId, options, responseCtx, 408, ERR_CODE_REQUEST_TIMEOUT),
154
- )
155
- }
156
-
157
- // Unknown body-read failure: surface to onError, then fall through
158
- // to the internal-error context below.
159
- options.onError?.(err instanceof Error ? err : new Error(String(err)))
160
- throw err
161
- }
162
- }
163
-
164
- return emitResponse(
165
- sync.run(req, ip, body, requestId, (r) => {
166
- const snapshot = snapshotResult(r)
167
- const response = buildTerminalResponse(responseCtx, snapshot, req, requestId)
168
-
169
- return {
170
- ...snapshot,
171
- response,
172
- }
173
- }),
174
- )
175
- } catch (err) {
176
- // Native/other failures: never silent — report to onError and emit a
177
- // structured 500 context so the request still completes observably.
178
- options.onError?.(err instanceof Error ? err : new Error(String(err)))
179
- return emitResponse(internalContext(req, requestId, options, responseCtx))
180
- }
181
- }
182
- }
@@ -0,0 +1,182 @@
1
+ // src/ingress/native-route.ts — public per-route native stack factory.
2
+ //
3
+ // `createNativeRoute` compiles a route-wire v3 descriptor ONCE (via the
4
+ // `castrum_route_*` C-ABI / napi `Route` surface over `rust/ingress/
5
+ // native_route.rs`) and runs each request frame in ONE native call — the same
6
+ // lean per-route stack `@ignex/native`'s `createNativeRoute` consumes. This
7
+ // brings that stack into castrum itself: routes that only need query/cookie
8
+ // parse + `requireJsonBody`/`validateBody` verdicts (no CORS/rate-limit/
9
+ // security/IP/metadata envelope) get the leanest possible per-request path —
10
+ // measured `bench/cost/native-route-vs-router.ts`: ~551ns full JS-glue path
11
+ // vs ~1131ns router `run()` on a parseQuery+parseCookies route (−580ns).
12
+ //
13
+ // IMPURE by design (dlopen + FFI): it lives at the boundary like
14
+ // `handlers.ts`/`server.ts`; the byte wire helpers it uses are the PURE
15
+ // `src/ingress/packing/route-wire.ts`.
16
+
17
+ import { getBunFFI } from '../native/ffi'
18
+ import { getAddon } from '../native'
19
+ import type { BunFFI } from '../native/ffi/types'
20
+ import {
21
+ ROUTE_PART,
22
+ ROUTE_STAGE,
23
+ decodeRouteResult,
24
+ encodeRouteDescriptor,
25
+ packRouteFrame,
26
+ type RouteStageTag,
27
+ type RouteWireLimits,
28
+ type RouteWireResult,
29
+ } from './packing/route-wire'
30
+
31
+ /** Plan for a compiled native route (the stage/limit/schema surface). */
32
+ export interface NativeRoutePlan {
33
+ /** Parse + lenient-decode the query string into pairs (stage 0). */
34
+ parseQuery?: boolean
35
+ /** Parse + lenient-decode the Cookie header into pairs (stage 1). */
36
+ parseCookies?: boolean
37
+ /** Reject a missing / non-JSON body with 400 (stage 5, first-failure-wins). */
38
+ requireJsonBody?: boolean
39
+ /** Validate the body against `schema` with 422 on failure (stage 4). */
40
+ validateBody?: boolean
41
+ /** Draft-07 JSON schema bytes for the body (`validateBody`). */
42
+ schema?: Uint8Array
43
+ /** Max body bytes before `requireJsonBody`/`validateBody` fail (default 2 MiB). */
44
+ maxBodyBytes?: number
45
+ /** Max query bytes before the parse VALID bit clears (default 8192). */
46
+ maxQueryBytes?: number
47
+ /** Max cookie bytes before the parse VALID bit clears (default 8192). */
48
+ maxCookieBytes?: number
49
+ /** Max pairs per section (0 = unlimited; default 0). */
50
+ maxPairs?: number
51
+ }
52
+
53
+ /** A compiled per-route native stack (route-wire v3). */
54
+ export interface NativeRoute {
55
+ /** Whether the plan compiled `parseQuery` (the result carries a query section). */
56
+ readonly parseQuery: boolean
57
+ /** Whether the plan compiled `parseCookies` (the result carries a cookie section). */
58
+ readonly parseCookies: boolean
59
+ /**
60
+ * Run one request frame through the compiled stack and return the decoded
61
+ * verdict. The frame is `[flags u32][qLen][query][cLen][cookie]([bLen][body])`
62
+ * — build it with {@link packRouteFrame}. Reuses one growable output buffer
63
+ * (the needed-size convention: `0` = real error → throws; `> out.length` =
64
+ * exact required size → retry once).
65
+ */
66
+ runFrame(frame: Uint8Array): RouteWireResult
67
+ /** Convenience: pack a `(query, cookie, body)` frame then {@link runFrame}. */
68
+ run(query: string, cookie: string, body: Uint8Array | null): RouteWireResult
69
+ /** Free the native handle (idempotent; safe to call once at shutdown). */
70
+ destroy(): void
71
+ }
72
+
73
+ /**
74
+ * Compile a per-route native stack from a plan. Returns a compiled route whose
75
+ * `run`/`runFrame` perform the parse + verdict in ONE native call.
76
+ *
77
+ * @example
78
+ * ```ts
79
+ * const route = createNativeRoute({ parseQuery: true, parseCookies: true })
80
+ * const result = route.run('page=1&limit=20', 'session=abc', null)
81
+ * // result.flags & ROUTE_FLAG.OK → parse succeeded; result.query = [['page','1'],…]
82
+ * ```
83
+ */
84
+ export function createNativeRoute(plan: NativeRoutePlan = {}): NativeRoute {
85
+ const limits: RouteWireLimits = {
86
+ maxBodyBytes: plan.maxBodyBytes ?? 2 * 1024 * 1024,
87
+ maxQueryBytes: plan.maxQueryBytes ?? 8192,
88
+ maxCookieBytes: plan.maxCookieBytes ?? 8192,
89
+ maxPairs: plan.maxPairs ?? 0,
90
+ }
91
+
92
+ // Stage order is first-failure-wins on the native side, so the descriptor
93
+ // pipeline mirrors the plan's semantics: parse stages first, then body
94
+ // verdicts (validateBody before requireJsonBody per the Rust compile — it
95
+ // emits requireJsonBody first defensively; the native side treats the pair
96
+ // as "require then validate" and the compiler guarantees the order).
97
+ const stages: RouteStageTag[] = []
98
+ if (plan.parseQuery) stages.push(ROUTE_STAGE.parseQuery)
99
+ if (plan.parseCookies) stages.push(ROUTE_STAGE.parseCookies)
100
+ if (plan.validateBody) stages.push(ROUTE_STAGE.validateBody)
101
+ if (plan.requireJsonBody) stages.push(ROUTE_STAGE.requireJsonBody)
102
+
103
+ const schemas: Array<{ part: number; bytes: Uint8Array }> = []
104
+ if (plan.validateBody && plan.schema) {
105
+ schemas.push({ part: ROUTE_PART.body, bytes: plan.schema })
106
+ }
107
+
108
+ const descriptor = encodeRouteDescriptor(stages, schemas, limits)
109
+ const parseQuery = plan.parseQuery === true
110
+ const parseCookies = plan.parseCookies === true
111
+
112
+ // Transport: bun:ffi PRIMARY on Bun; napi `Route` on Node / fallback. The
113
+ // compiled route owns exactly one handle; `destroy` frees it.
114
+ const bunFFI: BunFFI | null = getBunFFI()
115
+ const ffiHandle = bunFFI !== null ? compileFfi(bunFFI, descriptor) : 0
116
+ const napiRoute = bunFFI === null ? compileNapi(descriptor) : null
117
+
118
+ // Reusable output buffer for the needed-size convention (grow once, retry).
119
+ let out = new Uint8Array(256)
120
+
121
+ const runFrame = (frame: Uint8Array): RouteWireResult => {
122
+ let written: number
123
+ if (ffiHandle !== 0 && bunFFI !== null) {
124
+ written = bunFFI.routeRun(ffiHandle, frame, out)
125
+ if (written > out.byteLength) {
126
+ // Exact required size reported — grow and retry once.
127
+ out = new Uint8Array(written)
128
+ written = bunFFI.routeRun(ffiHandle, frame, out)
129
+ if (written > out.byteLength) {
130
+ throw new Error('native route: output still too small after grow')
131
+ }
132
+ }
133
+ } else if (napiRoute !== null) {
134
+ written = napiRoute.run(frame, out)
135
+ if (written > out.byteLength) {
136
+ out = new Uint8Array(written)
137
+ written = napiRoute.run(frame, out)
138
+ if (written > out.byteLength) {
139
+ throw new Error('native route: output still too small after grow')
140
+ }
141
+ }
142
+ } else {
143
+ throw new Error('native route: no active transport')
144
+ }
145
+ return decodeRouteResult(out.subarray(0, written), { query: parseQuery, cookie: parseCookies })
146
+ }
147
+
148
+ return {
149
+ parseQuery,
150
+ parseCookies,
151
+ runFrame,
152
+ run: (query, cookie, body) => runFrame(packRouteFrame(query, cookie, body)),
153
+ destroy: () => {
154
+ if (ffiHandle !== 0 && bunFFI !== null) {
155
+ bunFFI.routeDestroy(ffiHandle)
156
+ }
157
+ },
158
+ }
159
+ }
160
+
161
+ /** Compile through bun:ffi (`castrum_route_compile`), returning the handle (0 = error). */
162
+ function compileFfi(bunFFI: BunFFI, descriptor: Uint8Array): number {
163
+ try {
164
+ return bunFFI.routeCompile(descriptor)
165
+ } catch {
166
+ return 0 // routeCompile throws on an invalid descriptor; treat as no-handle
167
+ }
168
+ }
169
+
170
+ /** Compile through the napi `Route` class (Node / fallback transport). */
171
+ function compileNapi(descriptor: Uint8Array): { run: (frame: Uint8Array, out: Uint8Array) => number } | null {
172
+ const addon = getAddon()
173
+ const Route = (addon as { Route?: new (d: Uint8Array) => { run: (f: Uint8Array, o: Uint8Array) => number } }).Route
174
+ if (typeof Route !== 'function') {
175
+ return null // addon without the route stack (pre-rebuild) — caller falls back
176
+ }
177
+ try {
178
+ return new Route(descriptor)
179
+ } catch {
180
+ return null
181
+ }
182
+ }
@@ -7,10 +7,10 @@
7
7
  // guards so oversized values are dropped rather than forwarded.
8
8
 
9
9
  import type { HeaderPlan } from '../shared'
10
+ import { METHOD_KIND } from '../shared'
10
11
  import { writeHeaderPair } from './header-packing'
11
- import { forEachSelectedHeader } from './select-headers'
12
- import { getHeaderBuf } from './scratch'
13
- import { decoder } from '../../shared/bytes'
12
+ import { forEachSelectedHeader, HDR_ORIGIN } from './select-headers'
13
+ import { getHeaderBuf, MAX_SMALL_HEADER_BYTES } from './scratch'
14
14
 
15
15
  // Shared per-header size guards (single source of truth in scratch.ts).
16
16
  // Re-exported for back-compat with imports that referenced them here.
@@ -20,32 +20,29 @@ export {
20
20
  MAX_XFF_HEADER_BYTES,
21
21
  } from './scratch'
22
22
 
23
- /**
24
- * Gather the request headers selected by `plan` as a `[name, value][]` array.
25
- * `methodKind` is the native method-kind enum (see `METHOD_KIND`).
26
- */
27
- export function gatherRawHeaders(
28
- req: Request,
29
- plan: HeaderPlan,
30
- methodKind: number,
31
- ): [string, string][] {
32
- const headers: [string, string][] = []
33
- forEachSelectedHeader(req, plan, methodKind, undefined, (name, value) => {
34
- headers.push([decoder.decode(name), value])
35
- })
36
- return headers
37
- }
38
-
39
23
  /**
40
24
  * Gather the request headers selected by `plan` as a packed byte block
41
25
  * (`[u16 count] { [u16 name_len][name][u32 val_len][value] }`) written into a
42
26
  * reusable thread-local buffer.
43
27
  *
44
- * This is the zero-intermediate-string equivalent of [`gatherRawHeaders`] for
45
- * the packed-input pipeline: identical header selection and per-header size
28
+ * This is the zero-intermediate-string equivalent of the legacy string-array
29
+ * header gathering (a test-local reference in header-packing.test.ts) for the
30
+ * packed-input pipeline: identical header selection and per-header size
46
31
  * guards, but no `[string, string][]` array and no napi string marshaling.
47
32
  * The returned view is valid only until the next call on this call site.
48
33
  *
34
+ * A plan that selects NO headers (no cookie/cors/proxy/proto) short-circuits
35
+ * to a shared empty block — skipping the scratch-buffer round-robin, the
36
+ * `setUint16` and the per-call `subarray` entirely (the common minimal-route
37
+ * case).
38
+ *
39
+ * A CORS-ONLY plan on a non-preflight request (the dominant minimal-route
40
+ * case, e.g. the benchmark server) produces a block that depends ONLY on the
41
+ * `Origin` header value — which is constant per deployment. Such blocks are
42
+ * cached keyed by origin, so the per-request UTF-8 encode of the origin (the
43
+ * largest single JS packing cost) and the scratch write are skipped entirely;
44
+ * the cache is bounded (FIFO eviction) so multi-origin deployments stay safe.
45
+ *
49
46
  * @param originValue - When provided (non-undefined), the already-fetched
50
47
  * `Origin` header value to pack, avoiding a second `req.headers.get('origin')`
51
48
  * native→JS string conversion per request on the hot path (the caller, e.g.
@@ -59,6 +56,48 @@ export function gatherRawHeadersPacked(
59
56
  methodKind: number,
60
57
  originValue?: string | null,
61
58
  ): Uint8Array {
59
+ if (!plan.cookie && !plan.cors && !plan.proxy && !plan.proto) {
60
+ return EMPTY_HEADERS_BLOCK
61
+ }
62
+
63
+ const nonPreflight = methodKind !== METHOD_KIND.OPTIONS
64
+
65
+ // CORS-only fast path: the only selected header is the (typically constant)
66
+ // Origin, so the whole packed block is a pure function of the origin string.
67
+ // Skip the per-request encode + scratch write by returning the cached block.
68
+ // Preflight (OPTIONS) is excluded — it also packs ACRM/ACRH and must take
69
+ // the general path. The MAX_SMALL_HEADER_BYTES guard mirrors the general
70
+ // path (an oversized origin is dropped → empty block, not cached).
71
+ if (plan.cors && !plan.cookie && !plan.proxy && !plan.proto && nonPreflight) {
72
+ const origin = originValue !== undefined ? originValue : req.headers.get('origin')
73
+ if (origin === null || origin.length > MAX_SMALL_HEADER_BYTES) {
74
+ return EMPTY_HEADERS_BLOCK
75
+ }
76
+ return cachedOriginBlock(origin)
77
+ }
78
+
79
+ // cookie+cors fast path: when the request carries NO cookie header (the
80
+ // dominant API/bench case), the packed block is still origin-only, so the
81
+ // (typically constant) origin can reuse the cached block instead of being
82
+ // UTF-8 re-encoded + re-packed on every request. Byte-identical to the
83
+ // general path, which would visit ONLY the origin when the cookie is absent:
84
+ // - cookie null + origin present → `[u16 1][origin pair]` (cached block)
85
+ // - cookie null + origin absent → `[u16 0]` (EMPTY block)
86
+ // - cookie null + origin oversized → dropped → `[u16 0]` (EMPTY block)
87
+ // When a cookie IS present, fall through to the general path but hand the
88
+ // already-fetched value down (no second `req.headers.get('cookie')`).
89
+ let preFetchedCookie: string | null | undefined
90
+ if (plan.cors && plan.cookie && !plan.proxy && !plan.proto && nonPreflight) {
91
+ preFetchedCookie = req.headers.get('cookie')
92
+ if (preFetchedCookie === null) {
93
+ const origin = originValue !== undefined ? originValue : req.headers.get('origin')
94
+ if (origin === null || origin.length > MAX_SMALL_HEADER_BYTES) {
95
+ return EMPTY_HEADERS_BLOCK
96
+ }
97
+ return cachedOriginBlock(origin)
98
+ }
99
+ }
100
+
62
101
  let [buf, view] = getHeaderBuf()
63
102
  let pos = 2
64
103
  let count = 0
@@ -68,8 +107,57 @@ export function gatherRawHeadersPacked(
68
107
  count++
69
108
  }
70
109
 
71
- forEachSelectedHeader(req, plan, methodKind, originValue, write)
110
+ forEachSelectedHeader(req, plan, methodKind, originValue, write, preFetchedCookie)
111
+
112
+ // No selected header was actually present (e.g. a route with cookie+cors in
113
+ // its plan but a request that sends neither) — return the shared empty block
114
+ // instead of touching the scratch buffer again (setUint16 + subarray) on the
115
+ // hot path.
116
+ if (count === 0) {
117
+ return EMPTY_HEADERS_BLOCK
118
+ }
72
119
 
73
120
  view.setUint16(0, count, true)
74
121
  return buf.subarray(0, pos)
75
122
  }
123
+
124
+ /** Shared `[0, 0]` (zero headers) block — returned when a plan selects nothing.
125
+ * Callers treat the result as read-only (the packers copy it verbatim). */
126
+ const EMPTY_HEADERS_BLOCK = new Uint8Array(2)
127
+
128
+ /**
129
+ * Cached packed blocks for the CORS-only case, keyed by origin string.
130
+ * Bounded (FIFO eviction) — a multi-origin deployment never grows it
131
+ * unboundedly, and a single-origin deployment hits a stable single entry.
132
+ * Module state is per-thread in Bun workers, so no cross-thread aliasing.
133
+ */
134
+ const ORIGIN_BLOCK_CACHE = new Map<string, Uint8Array>()
135
+ const ORIGIN_CACHE_MAX = 8
136
+
137
+ /**
138
+ * Return (building and caching on first use) the packed header block for a
139
+ * CORS-only plan whose only selected header is `origin`.
140
+ *
141
+ * The block is byte-identical to what the general path would produce
142
+ * (`[u16 1][u16 name_len]['origin'][u32 val_len][value]`), built once with the
143
+ * shared `writeHeaderPair` and copied out of the scratch buffer into a stable
144
+ * allocation — never aliased to the round-robin scratch, so it stays valid
145
+ * across subsequent gathers.
146
+ */
147
+ function cachedOriginBlock(origin: string): Uint8Array {
148
+ const hit = ORIGIN_BLOCK_CACHE.get(origin)
149
+ if (hit !== undefined) return hit
150
+
151
+ let [buf, view] = getHeaderBuf()
152
+ let pos = 2
153
+ ;[pos, buf, view] = writeHeaderPair(buf, view, pos, HDR_ORIGIN, origin)
154
+ view.setUint16(0, 1, true)
155
+ const block = buf.slice(0, pos)
156
+
157
+ if (ORIGIN_BLOCK_CACHE.size >= ORIGIN_CACHE_MAX) {
158
+ const firstKey = ORIGIN_BLOCK_CACHE.keys().next().value
159
+ if (firstKey !== undefined) ORIGIN_BLOCK_CACHE.delete(firstKey)
160
+ }
161
+ ORIGIN_BLOCK_CACHE.set(origin, block)
162
+ return block
163
+ }
@@ -5,12 +5,11 @@
5
5
  // scratch buffers keeps this allocation-free on the hot path while isolating
6
6
  // the buffers per call-site sequence.
7
7
 
8
- import { METHOD_KIND, METHOD_KIND_UNKNOWN, type HeaderPlan } from '../shared'
8
+ import { encodeUtf8Into } from '../../shared/codec'
9
+ import { type HeaderPlan, METHOD_KIND, METHOD_KIND_UNKNOWN } from '../shared'
9
10
  import { getHeaderBuf } from './scratch'
10
11
  import { forEachSelectedHeader } from './select-headers'
11
12
 
12
- const encoder = new TextEncoder()
13
-
14
13
  /**
15
14
  * Write one header pair into `buf`/`view`, growing them if needed.
16
15
  *
@@ -43,8 +42,9 @@ export function writeHeaderPair(
43
42
  const valueLenPos = pos
44
43
  pos += 4
45
44
 
46
- const dest = buf.subarray(pos)
47
- const { written } = encoder.encodeInto(value, dest)
45
+ // Number-returning codec write directly at `pos` (no `{read, written}`
46
+ // object, no intermediate `subarray` — both were per-header allocations).
47
+ const written = encodeUtf8Into(value, buf, pos)
48
48
 
49
49
  view.setUint32(valueLenPos, written, true)
50
50
  pos += written
@@ -4,7 +4,8 @@
4
4
  // headers) into a single growable Uint8Array in the layout the native
5
5
  // `Ingress.handleRequestPacked` expects.
6
6
 
7
- const encoder = new TextEncoder()
7
+ import { encoder } from '../../shared/bytes'
8
+
8
9
  const EMPTY_BYTES = new Uint8Array(0)
9
10
  const EMPTY_IP_BYTES = encoder.encode('0.0.0.0')
10
11
 
@@ -12,6 +13,10 @@ const EMPTY_IP_BYTES = encoder.encode('0.0.0.0')
12
13
  export class IngressInputPacker {
13
14
  private buf: Uint8Array
14
15
  private view: DataView
16
+ /** Cached zero-copy `Buffer` view over `buf`'s backing store (invalidated
17
+ * when `buf` grows). `Buffer.write` needs a Buffer; caching it here avoids
18
+ * a per-string view/`Buffer.from` allocation on the hot pack path. */
19
+ private bufView: Buffer | null = null
15
20
  private pos = 0
16
21
 
17
22
  constructor(initialSize = 65536) {
@@ -33,6 +38,7 @@ export class IngressInputPacker {
33
38
 
34
39
  this.buf = next
35
40
  this.view = new DataView(next.buffer)
41
+ this.bufView = null
36
42
  }
37
43
 
38
44
  private writeU8(value: number): void {
@@ -76,8 +82,16 @@ export class IngressInputPacker {
76
82
  this.ensure(4 + value.length * 3)
77
83
  const valueLenPos = this.pos
78
84
  this.pos += 4
79
- const dest = this.buf.subarray(this.pos)
80
- const { written } = encoder.encodeInto(value, dest)
85
+ // Reused `Buffer` view over this buffer: `Buffer.write` encodes the string
86
+ // in place at the current position and returns bytes written — no
87
+ // `{read, written}` object, no `Buffer.from`/subarray per string (those
88
+ // were per-request allocations on this hot path).
89
+ let view = this.bufView
90
+ if (view === null) {
91
+ view = Buffer.from(this.buf.buffer, this.buf.byteOffset, this.buf.byteLength)
92
+ this.bufView = view
93
+ }
94
+ const written = view.write(value, this.pos, 'utf8')
81
95
  this.view.setUint32(valueLenPos, written, true)
82
96
  this.pos += written
83
97
  }