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
@@ -1,24 +1,14 @@
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 { readRequestBodyOnce } from './body'
10
- import { ERR_CODE_BODY_TOO_LARGE, ERR_CODE_REQUEST_TIMEOUT } from './constants'
11
- import { internalContext, snapshotResult, syntheticContext } from './context'
12
- import { createIngressFast } from './fast'
13
- import { buildResponseContext } from './headers/fast-templates'
14
- import { buildTerminalResponse } from './response/terminal'
15
- import { assertSyncCallback, DEFAULT_BODY_TIMEOUT_MS, DEFAULT_MAX_BODY_BYTES } from './shared'
16
- import type { IngressContext, IngressHandler, IngressOptions, SyncIngressHandler } from './types'
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.
17
7
 
18
8
  // Runtime-driven server factory: picks Bun.serve on Bun and the node:http
19
9
  // adapter on Node via the runtime adapter (src/runtime/server.ts). This
20
10
  // explicit re-export SHADOWS the Bun-only `createIngressServer` from
21
- // './server' (re-exported via './handlers' above) so the public API serves
11
+ // './server' (re-exported via './handlers' below) so the public API serves
22
12
  // one backend per runtime. `createIngressServerNode` remains exported for
23
13
  // consumers who want the Node backend pinned.
24
14
  export { createIngressServer } from '../runtime/index'
@@ -44,6 +34,10 @@ export { healthHandler, livenessHandler, readinessHandler } from './health'
44
34
  export type { IngressMetrics } from './metrics'
45
35
  // ── Observability (zero-dep): metrics, health probes ─────────────
46
36
  export { createIngressMetrics, metricsHandler } from './metrics'
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'
47
41
  export type { IngressFastHandler, IngressFastOptions } from './options'
48
42
  export { buildTerminalResponse } from './response/terminal'
49
43
  export type { CreateIngressRouterOptions, IngressRouter, RouterRouteSpec } from './router'
@@ -51,6 +45,8 @@ export { createIngressRouter } from './router'
51
45
  export type { HeaderPlan } from './shared'
52
46
  export { DEFAULT_BODY_TIMEOUT_MS, DEFAULT_MAX_BODY_BYTES, METHOD_KIND } from './shared'
53
47
  export * from './status'
48
+ // ── Convenience factories (see ./sync.ts) ────────────────────────
49
+ export { createIngress, createIngressSync } from './sync'
54
50
  export type {
55
51
  IngressContext,
56
52
  IngressHandler,
@@ -60,135 +56,3 @@ export type {
60
56
  SyncIngressHandler,
61
57
  TerminalStyle,
62
58
  } from './types'
63
-
64
- // ── Sync factory ─────────────────────────────────────────────────
65
-
66
- /**
67
- * Synchronous convenience wrapper over `createIngressFast` (path 1).
68
- *
69
- * The callback runs on the fast-path result and MUST be synchronous. Use
70
- * {@link createIngress} when you want automatic body reading + error contexts.
71
- */
72
- export function createIngressSync(options: IngressOptions = {}): SyncIngressHandler {
73
- const fast = createIngressFast(options)
74
-
75
- return {
76
- run(req, ip, body, requestId, fn) {
77
- return fast.run(req, ip, body, requestId, (result) => {
78
- const out = fn(result)
79
- assertSyncCallback(out, 'createIngressSync().run()')
80
- return out
81
- })
82
- },
83
- }
84
- }
85
-
86
- // ── Async factory ────────────────────────────────────────────────
87
-
88
- /**
89
- * Return a copy of `options` without the security-header keys.
90
- *
91
- * `createIngress` applies security headers itself (via `buildResponseContext`),
92
- * so they must not be forwarded to the underlying fast handler, which rejects
93
- * them (see `createIngressFast`).
94
- */
95
- function stripSecurityOptions(options: IngressOptions): IngressOptions {
96
- const copy = { ...options }
97
- delete copy.security
98
- delete copy.enableSecurityHeaders
99
- return copy
100
- }
101
-
102
- /**
103
- * Async ingress factory: reads the request body (size guard + deadline) and
104
- * returns a snapshot `IngressContext` with a terminal `Response`.
105
- *
106
- * Recommended default for serverless/edge-style `fetch` handlers. For maximum
107
- * throughput use `createIngressFast` / `createIngressSync` directly.
108
- */
109
- export function createIngress(options: IngressOptions = {}): IngressHandler {
110
- const sync = createIngressSync(stripSecurityOptions(options))
111
-
112
- const guard = options.enableBodySizeGuard !== false
113
- const max = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES
114
- const bodyTimeoutMs = options.bodyTimeoutMs ?? DEFAULT_BODY_TIMEOUT_MS
115
-
116
- const wantsBody =
117
- options.readBody === true ||
118
- options.requireJsonBody === true ||
119
- options.schema != null ||
120
- (options.readBody !== false && guard)
121
-
122
- const responseCtx = buildResponseContext(options)
123
-
124
- return async function ingressAsync(req: Request, ip?: string): Promise<IngressContext> {
125
- const requestId = options.enableRequestIds === false ? '' : decoder.decode(generateRequestId())
126
- options.onRequest?.(req, requestId, ip)
127
-
128
- // Every terminal outcome (success, 413, 408, 500) flows through here so a
129
- // hook consumer sees exactly one onResponse per request.
130
- const emitResponse = (context: IngressContext): IngressContext => {
131
- options.onResponse?.(req, context, context.status, requestId)
132
- return context
133
- }
134
-
135
- try {
136
- if (guard) {
137
- const rawLen = req.headers.get('content-length')
138
- const contentLength = Number(rawLen ?? '0')
139
-
140
- if (Number.isFinite(contentLength) && contentLength > max) {
141
- return emitResponse(
142
- syntheticContext(req, requestId, options, responseCtx, 413, ERR_CODE_BODY_TOO_LARGE),
143
- )
144
- }
145
- }
146
-
147
- let body: Uint8Array | null = null
148
-
149
- if (wantsBody && req.body !== null) {
150
- try {
151
- body = await readRequestBodyOnce(req, max, guard, bodyTimeoutMs)
152
- } catch (err) {
153
- const code = (err as { code?: string } | null)?.code
154
-
155
- if (code === 'BODY_TOO_LARGE') {
156
- return emitResponse(
157
- syntheticContext(req, requestId, options, responseCtx, 413, ERR_CODE_BODY_TOO_LARGE),
158
- )
159
- }
160
-
161
- if (code === 'REQUEST_TIMEOUT') {
162
- // Match the pre-baked route factories: a body-read deadline is a
163
- // 408, not a 500 (was silently swallowed as 500 before).
164
- return emitResponse(
165
- syntheticContext(req, requestId, options, responseCtx, 408, ERR_CODE_REQUEST_TIMEOUT),
166
- )
167
- }
168
-
169
- // Unknown body-read failure: surface to onError, then fall through
170
- // to the internal-error context below.
171
- options.onError?.(err instanceof Error ? err : new Error(String(err)))
172
- throw err
173
- }
174
- }
175
-
176
- return emitResponse(
177
- sync.run(req, ip, body, requestId, (r) => {
178
- const snapshot = snapshotResult(r)
179
- const response = buildTerminalResponse(responseCtx, snapshot, req, requestId)
180
-
181
- return {
182
- ...snapshot,
183
- response,
184
- }
185
- }),
186
- )
187
- } catch (err) {
188
- // Native/other failures: never silent — report to onError and emit a
189
- // structured 500 context so the request still completes observably.
190
- options.onError?.(err instanceof Error ? err : new Error(String(err)))
191
- return emitResponse(internalContext(req, requestId, options, responseCtx))
192
- }
193
- }
194
- }
@@ -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
+ }
@@ -60,13 +60,15 @@ export function gatherRawHeadersPacked(
60
60
  return EMPTY_HEADERS_BLOCK
61
61
  }
62
62
 
63
+ const nonPreflight = methodKind !== METHOD_KIND.OPTIONS
64
+
63
65
  // CORS-only fast path: the only selected header is the (typically constant)
64
66
  // Origin, so the whole packed block is a pure function of the origin string.
65
67
  // Skip the per-request encode + scratch write by returning the cached block.
66
68
  // Preflight (OPTIONS) is excluded — it also packs ACRM/ACRH and must take
67
69
  // the general path. The MAX_SMALL_HEADER_BYTES guard mirrors the general
68
70
  // path (an oversized origin is dropped → empty block, not cached).
69
- if (plan.cors && !plan.cookie && !plan.proxy && !plan.proto && methodKind !== METHOD_KIND.OPTIONS) {
71
+ if (plan.cors && !plan.cookie && !plan.proxy && !plan.proto && nonPreflight) {
70
72
  const origin = originValue !== undefined ? originValue : req.headers.get('origin')
71
73
  if (origin === null || origin.length > MAX_SMALL_HEADER_BYTES) {
72
74
  return EMPTY_HEADERS_BLOCK
@@ -74,6 +76,28 @@ export function gatherRawHeadersPacked(
74
76
  return cachedOriginBlock(origin)
75
77
  }
76
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
+
77
101
  let [buf, view] = getHeaderBuf()
78
102
  let pos = 2
79
103
  let count = 0
@@ -83,7 +107,7 @@ export function gatherRawHeadersPacked(
83
107
  count++
84
108
  }
85
109
 
86
- forEachSelectedHeader(req, plan, methodKind, originValue, write)
110
+ forEachSelectedHeader(req, plan, methodKind, originValue, write, preFetchedCookie)
87
111
 
88
112
  // No selected header was actually present (e.g. a route with cookie+cors in
89
113
  // its plan but a request that sends neither) — return the shared empty block
@@ -0,0 +1,197 @@
1
+ // src/ingress/packing/route-wire.ts — route-wire v3 byte helpers (PURE).
2
+ //
3
+ // The per-route native stack (`rust/ingress/native_route.rs`,
4
+ // `castrum_route_*` / napi `Route`) compiles a descriptor ONCE and runs each
5
+ // request frame in ONE native call. This module owns the WIRE on the JS side:
6
+ // descriptor encoding, frame packing, and result decoding — byte-layout
7
+ // constants MUST match `rust/ingress/native_route.rs` and `@ignex/native`
8
+ // `route-wire.ts` EXACTLY (`ROUTE_DESC_VERSION` bumps on any layout change; a
9
+ // mismatched compiler/addon must be a hard reject, never a silent misparse).
10
+ //
11
+ // PURE: no addon import, no module state — safe for any consumer. The
12
+ // addon-touching factory lives in `src/ingress/native-route.ts`.
13
+
14
+ import { decoder, encoder } from '../../shared/bytes'
15
+
16
+ /** Route descriptor magic (`"ROUT"` LE). Must match `ROUTE_DESC_MAGIC` in Rust. */
17
+ export const ROUTE_DESC_MAGIC = 0x524f5554
18
+ /** Wire version — bump on ANY descriptor/frame/result layout change. */
19
+ export const ROUTE_DESC_VERSION = 3
20
+
21
+ /** Descriptor stage tags (the ordered pipeline a route instance runs). */
22
+ export const ROUTE_STAGE = {
23
+ parseQuery: 0,
24
+ parseCookies: 1,
25
+ validateQuery: 2,
26
+ validateCookies: 3,
27
+ validateBody: 4,
28
+ requireJsonBody: 5,
29
+ } as const
30
+ /** A descriptor stage tag (`ROUTE_STAGE` values, must match Rust). */
31
+ export type RouteStageTag = (typeof ROUTE_STAGE)[keyof typeof ROUTE_STAGE]
32
+
33
+ /** Descriptor part tags (`RoutePartKind`): the schema-bearing request parts. */
34
+ export const ROUTE_PART = { body: 3 } as const
35
+
36
+ /** Result flag bits (`ROUTE_RESULT_FLAG_*` in Rust). */
37
+ export const ROUTE_FLAG = {
38
+ OK: 1 << 0,
39
+ BODY_VALID_JSON: 1 << 1,
40
+ QUERY_VALID: 1 << 2,
41
+ COOKIE_VALID: 1 << 3,
42
+ BODY_VALID: 1 << 4,
43
+ } as const
44
+
45
+ /** Frame flag: the body section is present (bit 0 of the frame flags word). */
46
+ export const ROUTE_FRAME_FLAG_HAS_BODY = 1 << 0
47
+
48
+ /** Size limits a route descriptor carries. */
49
+ export interface RouteWireLimits {
50
+ maxBodyBytes: number
51
+ maxQueryBytes: number
52
+ maxCookieBytes: number
53
+ maxPairs: number
54
+ }
55
+
56
+ /** A schema part carried by the descriptor (part tag + draft-07 JSON bytes). */
57
+ export interface RouteWireSchema {
58
+ part: number
59
+ bytes: Uint8Array
60
+ }
61
+
62
+ /**
63
+ * Encode a route plan into the descriptor wire.
64
+ *
65
+ * Layout: `[magic u32][version u32][maxBody u32][maxQuery u32][maxCookie u32]
66
+ * [maxPairs u32][stageCount u32][stages u8…][schemaCount u32]
67
+ * { [part u8][len u32][schema] }…`
68
+ */
69
+ export function encodeRouteDescriptor(
70
+ pipeline: readonly RouteStageTag[],
71
+ schemas: readonly RouteWireSchema[],
72
+ limits: RouteWireLimits,
73
+ ): Uint8Array {
74
+ let total = 8 + 16 + 4 + pipeline.length + 4
75
+ for (const s of schemas) total += 1 + 4 + s.bytes.byteLength
76
+ const out = new Uint8Array(total)
77
+ const view = new DataView(out.buffer)
78
+ let pos = 0
79
+ view.setUint32(pos, ROUTE_DESC_MAGIC, true)
80
+ pos += 4
81
+ view.setUint32(pos, ROUTE_DESC_VERSION, true)
82
+ pos += 4
83
+ for (const v of [
84
+ limits.maxBodyBytes,
85
+ limits.maxQueryBytes,
86
+ limits.maxCookieBytes,
87
+ limits.maxPairs,
88
+ ]) {
89
+ view.setUint32(pos, v, true)
90
+ pos += 4
91
+ }
92
+ view.setUint32(pos, pipeline.length, true)
93
+ pos += 4
94
+ for (const stage of pipeline) {
95
+ out[pos] = stage
96
+ pos += 1
97
+ }
98
+ view.setUint32(pos, schemas.length, true)
99
+ pos += 4
100
+ for (const s of schemas) {
101
+ out[pos] = s.part
102
+ pos += 1
103
+ view.setUint32(pos, s.bytes.byteLength, true)
104
+ pos += 4
105
+ out.set(s.bytes, pos)
106
+ pos += s.bytes.byteLength
107
+ }
108
+ return out
109
+ }
110
+
111
+ /**
112
+ * Encode a request frame: `[flags u32][qLen][query][cLen][cookie]([bLen][body])`.
113
+ * The `flags` word currently only carries `ROUTE_FRAME_FLAG_HAS_BODY`.
114
+ */
115
+ export function packRouteFrame(
116
+ query: string,
117
+ cookie: string,
118
+ body: Uint8Array | null,
119
+ ): Uint8Array {
120
+ const q = encoder.encode(query)
121
+ const c = encoder.encode(cookie)
122
+ const hasBody = body !== null && body.byteLength > 0
123
+ const total =
124
+ 4 + 4 + q.byteLength + 4 + c.byteLength + (hasBody ? 4 + (body?.byteLength ?? 0) : 0)
125
+ const out = new Uint8Array(total)
126
+ const view = new DataView(out.buffer)
127
+ let pos = 0
128
+ view.setUint32(pos, hasBody ? ROUTE_FRAME_FLAG_HAS_BODY : 0, true)
129
+ pos += 4
130
+ view.setUint32(pos, q.byteLength, true)
131
+ pos += 4
132
+ out.set(q, pos)
133
+ pos += q.byteLength
134
+ view.setUint32(pos, c.byteLength, true)
135
+ pos += 4
136
+ out.set(c, pos)
137
+ pos += c.byteLength
138
+ if (hasBody && body) {
139
+ view.setUint32(pos, body.byteLength, true)
140
+ pos += 4
141
+ out.set(body, pos)
142
+ }
143
+ return out
144
+ }
145
+
146
+ /** A decoded `[name, value]` pair from a result section. */
147
+ export type RouteWirePair = [string, string]
148
+
149
+ /** The decoded route result: the verdict header + optional pair sections. */
150
+ export interface RouteWireResult {
151
+ /** `ROUTE_FLAG_*` bits from the result header. */
152
+ flags: number
153
+ /** `0` = ok; `400` = not-JSON under requireJsonBody; `422` = schema fail. */
154
+ errorCode: number
155
+ /** Decoded query pairs (present iff the plan compiled `parseQuery`). */
156
+ query: RouteWirePair[]
157
+ /** Decoded cookie pairs (present iff the plan compiled `parseCookies`). */
158
+ cookie: RouteWirePair[]
159
+ }
160
+
161
+ /**
162
+ * Decode the result wire: `[flags u32][errorCode u32]` + a query pair section
163
+ * iff `query` and a cookie pair section iff `cookie` (the caller knows its own
164
+ * plan). Sections are `[count u32] { [nameLen u32][name][valueLen u32][value] }`.
165
+ */
166
+ export function decodeRouteResult(
167
+ buf: Uint8Array,
168
+ opts: { query: boolean; cookie: boolean },
169
+ ): RouteWireResult {
170
+ const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength)
171
+ const flags = view.getUint32(0, true)
172
+ const errorCode = view.getUint32(4, true)
173
+ let pos = 8
174
+ const readPairs = (): RouteWirePair[] => {
175
+ const count = view.getUint32(pos, true)
176
+ pos += 4
177
+ const out: RouteWirePair[] = []
178
+ for (let i = 0; i < count; i++) {
179
+ const nameLen = view.getUint32(pos, true)
180
+ pos += 4
181
+ const name = decoder.decode(buf.subarray(pos, pos + nameLen))
182
+ pos += nameLen
183
+ const valueLen = view.getUint32(pos, true)
184
+ pos += 4
185
+ const value = decoder.decode(buf.subarray(pos, pos + valueLen))
186
+ pos += valueLen
187
+ out.push([name, value])
188
+ }
189
+ return out
190
+ }
191
+ return {
192
+ flags,
193
+ errorCode,
194
+ query: opts.query ? readPairs() : [],
195
+ cookie: opts.cookie ? readPairs() : [],
196
+ }
197
+ }
@@ -45,6 +45,12 @@ export const HDR_XFP = encoder.encode('x-forwarded-proto')
45
45
  * fetched here. The same `MAX_SMALL_HEADER_BYTES` guard applies either way.
46
46
  * @param visit - Invoked once per selected header with the pre-encoded name
47
47
  * bytes and the raw value string.
48
+ * @param cookieValue - When provided (non-undefined), the already-fetched
49
+ * `Cookie` header value, avoiding a second `req.headers.get('cookie')`
50
+ * native→JS conversion (the caller's cookie+cors fast path already fetched
51
+ * it once to decide between the cached origin block and the general path).
52
+ * When omitted, the cookie is fetched here — so existing callers are
53
+ * byte-for-byte unchanged.
48
54
  */
49
55
  export function forEachSelectedHeader(
50
56
  req: Request,
@@ -52,11 +58,12 @@ export function forEachSelectedHeader(
52
58
  methodKind: number,
53
59
  originValue: string | null | undefined,
54
60
  visit: (name: Uint8Array, value: string) => void,
61
+ cookieValue?: string | null,
55
62
  ): void {
56
63
  const h = req.headers
57
64
 
58
65
  if (plan.cookie) {
59
- const v = h.get('cookie')
66
+ const v = cookieValue !== undefined ? cookieValue : h.get('cookie')
60
67
  if (v !== null && v.length <= MAX_COOKIE_HEADER_BYTES) {
61
68
  visit(HDR_COOKIE, v)
62
69
  }