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.
- package/README.md +153 -630
- package/castrum.linux-x64-gnu.node +0 -0
- package/dist/index.d.ts +1653 -1817
- package/dist/index.js +6172 -4294
- package/docs/API.md +337 -0
- package/docs/ARCHITECTURE.md +111 -17
- package/docs/BENCHMARKS.md +117 -49
- package/docs/CASE_STUDY.md +243 -0
- package/docs/ENVIRONMENT.md +24 -7
- package/docs/FFI_BUN_GUIDE.md +335 -0
- package/docs/GETTING_STARTED.md +18 -34
- package/docs/HOW_TO_ADD_AN_OP.md +180 -0
- package/docs/INGRESS-ROUTER.md +82 -0
- package/docs/INGRESS.md +11 -7
- package/docs/NATIVE-ROUTE.md +77 -0
- package/docs/REPO_MAP.md +103 -53
- package/docs/adr/0001-two-wire-formats.md +1 -1
- package/docs/adr/0003-bun-builtins-delegation.md +6 -5
- package/docs/ai/LOCAL_DEV.md +81 -0
- package/docs/ai/TREE.md +1070 -0
- package/docs/bun-builtins-decision-matrix.md +25 -18
- package/index.ts +9 -16
- package/package.json +41 -28
- package/src/ingress/body.ts +167 -27
- package/src/ingress/decode/result-base.ts +10 -2
- package/src/ingress/handlers.ts +116 -285
- package/src/ingress/headers/security.ts +41 -0
- package/src/ingress/health.ts +1 -0
- package/src/ingress/index.ts +45 -169
- package/src/ingress/native-route.ts +182 -0
- package/src/ingress/packing/gather-raw-headers.ts +110 -22
- package/src/ingress/packing/header-packing.ts +5 -5
- package/src/ingress/packing/input-packer.ts +17 -3
- package/src/ingress/packing/route-wire.ts +197 -0
- package/src/ingress/packing/select-headers.ts +20 -7
- package/src/ingress/path-matcher.ts +115 -0
- package/src/ingress/response/baked-response.ts +302 -0
- package/src/ingress/router.ts +277 -0
- package/src/ingress/routes/common.ts +50 -1
- package/src/ingress/routes/fallback.ts +2 -2
- package/src/ingress/routes/head.ts +3 -13
- package/src/ingress/routes/index.ts +5 -4
- package/src/ingress/routes/json-write.ts +26 -47
- package/src/ingress/routes/native.ts +130 -0
- package/src/ingress/routes/options.ts +2 -12
- package/src/ingress/routes/read.ts +3 -13
- package/src/ingress/routes/responder.ts +159 -0
- package/src/ingress/server-node.ts +7 -7
- package/src/ingress/server.ts +83 -112
- package/src/ingress/sync.ts +151 -0
- package/src/ingress/types.ts +44 -1
- package/src/integration/pipeline.ts +3 -0
- package/src/integration/streaming.ts +2 -3
- package/src/integration/websocket.ts +4 -3
- package/src/loader/batch.ts +4 -0
- package/src/loader/cost.ts +1 -0
- package/src/loader/index.ts +43 -228
- package/src/loader/state.ts +84 -0
- package/src/loader/types.ts +145 -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/constants.ts +58 -0
- package/src/native/ffi/selftest.ts +22 -0
- package/src/native/ffi/types.ts +499 -0
- package/src/native/ffi.ts +307 -1357
- package/src/native/loader.ts +52 -6
- package/src/native/types/instances.ts +279 -0
- package/src/native/types.ts +41 -238
- package/src/runtime/builtins.ts +131 -0
- package/src/runtime/codec.ts +147 -0
- package/src/runtime/detect.ts +36 -0
- package/src/runtime/index.ts +28 -0
- package/src/runtime/native.ts +35 -0
- package/src/runtime/server.ts +31 -0
- package/src/runtime/transport.ts +64 -0
- package/src/runtime/types.ts +173 -0
- package/src/runtime/uuid.ts +45 -0
- package/src/runtime/websocket.ts +30 -0
- package/src/rust-ffi/addon.ts +1 -1
- package/src/rust-ffi/batch/build.ts +155 -0
- package/src/rust-ffi/batch/index.ts +8 -0
- package/src/rust-ffi/batch/types.ts +92 -0
- package/src/rust-ffi/client.ts +5 -6
- package/src/rust-ffi/context.ts +39 -10
- package/src/rust-ffi/index.ts +28 -17
- package/src/rust-ffi/into.ts +21 -0
- package/src/rust-ffi/options.ts +1 -1
- package/src/rust-ffi/packed.ts +40 -2
- package/src/rust-ffi/proven.ts +16 -30
- package/src/rust-ffi/scalar/crypto.ts +188 -107
- package/src/rust-ffi/scalar/factories.ts +257 -27
- package/src/rust-ffi/scalar/hashing.ts +43 -41
- package/src/rust-ffi/scalar/http.ts +208 -77
- package/src/rust-ffi/scalar/index.ts +3 -3
- package/src/rust-ffi/scalar/interface.ts +88 -213
- package/src/rust-ffi/scalar/json-packed.ts +175 -0
- package/src/rust-ffi/scalar/json.ts +28 -12
- package/src/rust-ffi/scalar/payload.ts +159 -42
- package/src/rust-ffi/text.ts +31 -5
- package/src/selection.ts +27 -31
- package/src/shared/adaptive.ts +1 -2
- package/src/shared/buffer-pool.ts +72 -17
- package/src/shared/bytes.ts +75 -6
- package/src/shared/codec.ts +78 -0
- package/src/shared/env.ts +4 -4
- package/src/shared/log.ts +1 -0
- package/src/shared/packed/index.ts +10 -0
- package/src/shared/packed/parsers.ts +31 -0
- package/src/shared/packed/schema.ts +23 -0
- package/src/shared/{packed.ts → packed/wire.ts} +31 -39
- package/src/shared/proven.ts +189 -496
- package/src/shared/runtime.ts +20 -25
- package/src/shared/uuid.ts +12 -28
- package/src/data/json-rows.ts +0 -33
- package/src/rust-ffi/batch.ts +0 -431
- package/src/shared/bench-classify.ts +0 -209
package/src/ingress/index.ts
CHANGED
|
@@ -1,182 +1,58 @@
|
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
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
|
|
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 {
|
|
36
|
-
export {
|
|
37
|
-
//
|
|
38
|
-
export
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
} from '
|
|
44
|
-
export
|
|
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
|
|
45
|
-
*
|
|
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 {
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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
|
}
|