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.
- package/README.md +1 -1
- package/castrum.linux-x64-gnu.node +0 -0
- package/dist/index.d.ts +239 -5
- package/dist/index.js +3852 -3375
- package/docs/ARCHITECTURE.md +11 -9
- package/docs/BENCHMARKS.md +70 -38
- package/docs/CASE_STUDY.md +10 -7
- package/docs/ENVIRONMENT.md +16 -1
- package/docs/FFI_BUN_GUIDE.md +18 -10
- package/docs/GETTING_STARTED.md +5 -4
- package/docs/HOW_TO_ADD_AN_OP.md +180 -0
- package/docs/INGRESS-ROUTER.md +3 -2
- package/docs/INGRESS.md +6 -3
- package/docs/NATIVE-ROUTE.md +28 -4
- package/docs/REPO_MAP.md +60 -30
- package/docs/adr/0001-two-wire-formats.md +1 -1
- package/docs/ai/LOCAL_DEV.md +81 -0
- package/docs/ai/TREE.md +1070 -0
- package/package.json +24 -23
- package/src/ingress/decode/result-base.ts +10 -2
- package/src/ingress/handlers.ts +45 -290
- package/src/ingress/headers/security.ts +41 -0
- package/src/ingress/index.ts +12 -148
- package/src/ingress/native-route.ts +182 -0
- package/src/ingress/packing/gather-raw-headers.ts +26 -2
- package/src/ingress/packing/route-wire.ts +197 -0
- package/src/ingress/packing/select-headers.ts +8 -1
- package/src/ingress/path-matcher.ts +115 -0
- package/src/ingress/response/baked-response.ts +302 -0
- package/src/ingress/router.ts +42 -2
- package/src/ingress/routes/common.ts +9 -1
- package/src/ingress/routes/json-write.ts +21 -18
- package/src/ingress/routes/native.ts +130 -0
- package/src/ingress/server-node.ts +1 -1
- package/src/ingress/server.ts +38 -99
- package/src/ingress/sync.ts +151 -0
- package/src/ingress/types.ts +9 -0
- package/src/native/ffi/build/codecs.ts +736 -0
- package/src/native/ffi/build/compress.ts +248 -0
- package/src/native/ffi/build/instances.ts +598 -0
- package/src/native/ffi/build/parse.ts +539 -0
- package/src/native/ffi/build/util.ts +140 -0
- package/src/native/ffi/build.ts +94 -0
- package/src/native/ffi/selftest.ts +11 -718
- package/src/native/ffi/types.ts +25 -0
- package/src/native/ffi.ts +36 -1320
- package/src/native/types/instances.ts +5 -0
- package/src/rust-ffi/packed.ts +38 -0
- package/src/rust-ffi/scalar/factories.ts +4 -0
package/src/ingress/index.ts
CHANGED
|
@@ -1,24 +1,14 @@
|
|
|
1
|
-
// src/ingress/index.ts — Public ingress API (barrel
|
|
1
|
+
// src/ingress/index.ts — Public ingress API (barrel).
|
|
2
2
|
//
|
|
3
|
-
// All constants come from Rust via src/ingress/constants.ts.
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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'
|
|
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 &&
|
|
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
|
}
|