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
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// src/ingress/router.ts — createIngressRouter: per-route compiled ingress.
|
|
2
|
+
//
|
|
3
|
+
// The "one super solution" over the existing ingress core. Instead of one
|
|
4
|
+
// global `IngressInner` serving every path (with a superset of options), each
|
|
5
|
+
// route in the table carries its OWN `IngressHandlerOptions` and compiles a
|
|
6
|
+
// DEDICATED native `IngressInner` (via `createIngressHandler`) — so the native
|
|
7
|
+
// pipeline prunes to exactly that route's stages (parseCookies, parseQuery,
|
|
8
|
+
// schema, cors, rateLimit, limits...), and `buildHeaderPlan` (inside
|
|
9
|
+
// `createIngressHandler`) produces a per-route header plan that reads ZERO
|
|
10
|
+
// headers for routes that need none. All routes share the same wire format
|
|
11
|
+
// (the existing packed `handle_request_packed`), the shared process-wide rate
|
|
12
|
+
// limiter budgets, and pre-warm on construction (`warmOnCreate`).
|
|
13
|
+
//
|
|
14
|
+
// This is the completion of what `rust/route.rs` (now deleted — it was dead,
|
|
15
|
+
// external-project wire) attempted: per-route native stage pruning, WITHOUT a
|
|
16
|
+
// second/third wire format. The route factories (`readHandler`,
|
|
17
|
+
// `jsonWriteHandler`, ...) and the dispatch machinery (`buildRouteHandlers` /
|
|
18
|
+
// `buildPathMatcher`) are reused unchanged. The per-route native-compile idea
|
|
19
|
+
// is ALSO exposed as the standalone per-route stack in
|
|
20
|
+
// `rust/ingress/native_route.rs` (the `castrum_route_*`/napi `Route` surface
|
|
21
|
+
// consumed by `@ignex/native`'s `createNativeRoute` — the LIVE successor to
|
|
22
|
+
// the deleted dead wire).
|
|
23
|
+
|
|
24
|
+
import type { BakedIngressResult } from './decode/baked-result'
|
|
25
|
+
import { type BakedIngressRuntime, createIngressHandler } from './handlers'
|
|
26
|
+
import type { NativeRoutePlan } from './native-route'
|
|
27
|
+
import type { IngressHandlerOptions } from './options'
|
|
28
|
+
import type { PathMatch } from './path-matcher'
|
|
29
|
+
import { buildPathMatcher } from './path-matcher'
|
|
30
|
+
import type { BakedRoute, RouteHandler } from './server'
|
|
31
|
+
import { buildRouteHandlers } from './server'
|
|
32
|
+
import type { BakedContext, NativeResponder, OptimizedIngressHandler, TerminalStyle } from './types'
|
|
33
|
+
|
|
34
|
+
/** Per-route spec for {@link createIngressRouter}. */
|
|
35
|
+
export interface RouterRouteSpec {
|
|
36
|
+
/**
|
|
37
|
+
* Per-route ingress options — compiled into a DEDICATED native `IngressInner`
|
|
38
|
+
* for this route (parseCookies, parseQuery, schema, cors, rateLimit, limits,
|
|
39
|
+
* requireJsonBody, ...). This is what makes the pipeline prune per route.
|
|
40
|
+
*/
|
|
41
|
+
options?: IngressHandlerOptions
|
|
42
|
+
/** Wire GET + HEAD read handlers over this route's compiled handler. */
|
|
43
|
+
read?: boolean
|
|
44
|
+
/** Wire POST/PUT/PATCH JSON-write handlers over this route's compiled handler. */
|
|
45
|
+
write?: boolean
|
|
46
|
+
/** Wire a POST echo handler over this route's compiled handler. */
|
|
47
|
+
echo?: boolean
|
|
48
|
+
/** Wire a GET read handler (cookies-style) over this route's compiled handler. */
|
|
49
|
+
cookies?: boolean
|
|
50
|
+
/** Wire a DELETE read-style handler over this route's compiled handler. */
|
|
51
|
+
delete?: boolean
|
|
52
|
+
/**
|
|
53
|
+
* A JS responder route: the native pipeline decides + rejects (terminal
|
|
54
|
+
* response in {@link terminalStyle}); on success the responder builds the
|
|
55
|
+
* 2xx body (async OK) from a decoded snapshot. Wired for `methods`
|
|
56
|
+
* (default `['GET']`). When set, the standard `read`/`write`/etc. flags for
|
|
57
|
+
* this route are ignored.
|
|
58
|
+
*/
|
|
59
|
+
responder?: {
|
|
60
|
+
handler: NativeResponder
|
|
61
|
+
/** HTTP methods to wire (default `['GET']`). */
|
|
62
|
+
methods?: ReadonlyArray<string>
|
|
63
|
+
/** Read the body for native validation (default false — framework owns it). */
|
|
64
|
+
readBody?: boolean
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* A LEAN native-stack responder route: the route-wire v3 per-route stack
|
|
68
|
+
* (`createNativeRoute` over `castrum_route_*`) runs ONLY the stages in
|
|
69
|
+
* `plan` (parseQuery/parseCookies/requireJsonBody/validateBody) in ONE
|
|
70
|
+
* native call — no CORS/rate-limit/security/IP/metadata envelope. On a
|
|
71
|
+
* verdict failure the route rejects (400 non-JSON / 422 schema); on success
|
|
72
|
+
* the responder builds the 2xx from the decoded snapshot. Measured ~580ns
|
|
73
|
+
* cheaper per request than the full-pipeline responder on a
|
|
74
|
+
* parseQuery+parseCookies route (bench/cost/native-route-vs-router.ts).
|
|
75
|
+
* Wired for `methods` (default `['GET']`). When set, `read`/`write`/etc.
|
|
76
|
+
* for this route are ignored.
|
|
77
|
+
*/
|
|
78
|
+
native?: {
|
|
79
|
+
/** The route-wire v3 plan (parse/validate stages + limits). */
|
|
80
|
+
plan: NativeRoutePlan
|
|
81
|
+
/** The JS 2xx builder (receives the decoded query/cookies/body snapshot). */
|
|
82
|
+
handler: NativeResponder
|
|
83
|
+
/** HTTP methods to wire (default `['GET']`). */
|
|
84
|
+
methods?: ReadonlyArray<string>
|
|
85
|
+
/** Read the body for `requireJsonBody`/`validateBody` (default false). */
|
|
86
|
+
readBody?: boolean
|
|
87
|
+
}
|
|
88
|
+
/** Terminal envelope style for this route's responder (default: router-level
|
|
89
|
+
* `terminalStyle` or `'castrum'`). */
|
|
90
|
+
terminalStyle?: TerminalStyle
|
|
91
|
+
/**
|
|
92
|
+
* A raw request→Response handler served directly for GET, OUTSIDE the
|
|
93
|
+
* ingress pipeline (health/metrics probes, /metrics). When set, `read`/
|
|
94
|
+
* `write`/etc. for this route are ignored.
|
|
95
|
+
*/
|
|
96
|
+
raw?: RouteHandler
|
|
97
|
+
/** Overrides `maxBodyBytes` for this route's write/echo handlers. */
|
|
98
|
+
maxBodyBytes?: number
|
|
99
|
+
/** Overrides `bodyTimeoutMs` for this route's write/echo handlers. */
|
|
100
|
+
bodyTimeoutMs?: number
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Options for {@link createIngressRouter}. */
|
|
104
|
+
export interface CreateIngressRouterOptions {
|
|
105
|
+
/** Route table: path → per-route spec. */
|
|
106
|
+
routes: Record<string, RouterRouteSpec>
|
|
107
|
+
/** Shared runtime hooks (onRequest/onResponse/onError, security headers,
|
|
108
|
+
* output buffer size) applied to EVERY compiled route handler. */
|
|
109
|
+
runtime?: BakedIngressRuntime
|
|
110
|
+
/** Shared `getIp` resolver for route handlers. */
|
|
111
|
+
getIp?: (req: Request, srv: unknown) => string | undefined
|
|
112
|
+
/** Shared `copyBody` default for route handlers. */
|
|
113
|
+
copyBody?: boolean
|
|
114
|
+
/** Default terminal envelope style for responder routes (`'castrum'`). */
|
|
115
|
+
terminalStyle?: TerminalStyle
|
|
116
|
+
/** Pre-warm every compiled route at construction (JIT the packed pipeline +
|
|
117
|
+
* the FFI ingress call before the first real request). Default: false. */
|
|
118
|
+
warmOnCreate?: boolean
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** A compiled per-route router. */
|
|
122
|
+
export interface IngressRouter {
|
|
123
|
+
/** The per-route compiled handlers keyed by path (for custom route wiring). */
|
|
124
|
+
routeHandlers: Record<string, OptimizedIngressHandler>
|
|
125
|
+
/** The route → method → handler map (compatible with Bun.serve `routes` and
|
|
126
|
+
* `createIngressServerNode`). */
|
|
127
|
+
routes: Record<string, Record<string, unknown>>
|
|
128
|
+
/** The path matcher (`:param`/`*` dynamic routes). */
|
|
129
|
+
match: (pathname: string) => PathMatch | undefined
|
|
130
|
+
/** A fetch-style dispatcher: match path + method → the route's handler. */
|
|
131
|
+
fetch: (req: Request, srv?: unknown) => Response | Promise<Response>
|
|
132
|
+
/** Pre-warm every compiled route (JIT the packed pipeline + FFI call). */
|
|
133
|
+
prewarm: () => void
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Compile a route table into per-route native ingress instances.
|
|
138
|
+
*
|
|
139
|
+
* Each route with a pipeline-backed spec (`read`/`write`/`echo`/`cookies`/
|
|
140
|
+
* `delete`) compiles its OWN `createIngressHandler(options)` — a dedicated
|
|
141
|
+
* `IngressInner` pruned to that route's stages + a per-route header plan.
|
|
142
|
+
* Routes that need nothing (a bare `read` with no cookies/query/cors) therefore
|
|
143
|
+
* gather zero headers and run a near-empty native pipeline. All routes share
|
|
144
|
+
* the same wire format and the shared process-wide rate-limiter budgets.
|
|
145
|
+
*
|
|
146
|
+
* @example
|
|
147
|
+
* ```ts
|
|
148
|
+
* const router = createIngressRouter({
|
|
149
|
+
* warmOnCreate: true,
|
|
150
|
+
* runtime: ingressMetrics?.runtime,
|
|
151
|
+
* routes: {
|
|
152
|
+
* '/health': { read: true, options: { parseCookies: false, parseQuery: false } },
|
|
153
|
+
* '/api/users': {
|
|
154
|
+
* read: true,
|
|
155
|
+
* write: true,
|
|
156
|
+
* options: { parseCookies: true, parseQuery: true, schema: USER_SCHEMA_BYTES },
|
|
157
|
+
* },
|
|
158
|
+
* '/metrics': { raw: metricsHandler(ingressMetrics) },
|
|
159
|
+
* },
|
|
160
|
+
* })
|
|
161
|
+
* const server = Bun.serve({ routes: router.routes }) // or router.fetch
|
|
162
|
+
* ```
|
|
163
|
+
*/
|
|
164
|
+
export function createIngressRouter(options: CreateIngressRouterOptions): IngressRouter {
|
|
165
|
+
const runtime = options.runtime ?? {}
|
|
166
|
+
|
|
167
|
+
// Per-route compiled handlers + the BakedRoute table the shared wiring reads.
|
|
168
|
+
const compiled: Record<string, OptimizedIngressHandler> = {}
|
|
169
|
+
const bakedRoutes: Record<string, BakedRoute> = {}
|
|
170
|
+
const baseOpts: {
|
|
171
|
+
getIp?: (req: Request, srv: unknown) => string | undefined
|
|
172
|
+
copyBody?: boolean
|
|
173
|
+
} = {}
|
|
174
|
+
if (options.getIp !== undefined) baseOpts.getIp = options.getIp
|
|
175
|
+
if (options.copyBody !== undefined) baseOpts.copyBody = options.copyBody
|
|
176
|
+
|
|
177
|
+
for (const [path, spec] of Object.entries(options.routes)) {
|
|
178
|
+
if (spec.raw) {
|
|
179
|
+
// Raw handler: served directly, outside the pipeline.
|
|
180
|
+
bakedRoutes[path] = { read: spec.raw as RouteHandler }
|
|
181
|
+
continue
|
|
182
|
+
}
|
|
183
|
+
if (spec.native) {
|
|
184
|
+
// LEAN native-stack responder route: the route-wire v3 stack runs ONLY
|
|
185
|
+
// the plan's stages (no full IngressInner, no CORS/rate-limit/security).
|
|
186
|
+
bakedRoutes[path] = {
|
|
187
|
+
native: {
|
|
188
|
+
plan: spec.native.plan,
|
|
189
|
+
handler: spec.native.handler,
|
|
190
|
+
methods: spec.native.methods,
|
|
191
|
+
readBody: spec.native.readBody,
|
|
192
|
+
},
|
|
193
|
+
maxBodyBytes: spec.maxBodyBytes,
|
|
194
|
+
bodyTimeoutMs: spec.bodyTimeoutMs,
|
|
195
|
+
}
|
|
196
|
+
continue
|
|
197
|
+
}
|
|
198
|
+
// Compile a dedicated native instance for this route's options.
|
|
199
|
+
const handler = createIngressHandler(spec.options ?? {}, runtime)
|
|
200
|
+
compiled[path] = handler
|
|
201
|
+
|
|
202
|
+
if (spec.responder) {
|
|
203
|
+
// Responder route: native decides + rejects; JS builds the 2xx.
|
|
204
|
+
bakedRoutes[path] = {
|
|
205
|
+
responder: {
|
|
206
|
+
ingress: handler,
|
|
207
|
+
handler: spec.responder.handler,
|
|
208
|
+
methods: spec.responder.methods,
|
|
209
|
+
terminalStyle: spec.terminalStyle ?? options.terminalStyle,
|
|
210
|
+
readBody: spec.responder.readBody,
|
|
211
|
+
},
|
|
212
|
+
maxBodyBytes: spec.maxBodyBytes,
|
|
213
|
+
bodyTimeoutMs: spec.bodyTimeoutMs,
|
|
214
|
+
}
|
|
215
|
+
continue
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const routeSpec: BakedRoute = {
|
|
219
|
+
read: spec.read ? handler : undefined,
|
|
220
|
+
write: spec.write ? handler : undefined,
|
|
221
|
+
echo: spec.echo ? handler : undefined,
|
|
222
|
+
cookies: spec.cookies ? handler : undefined,
|
|
223
|
+
delete: spec.delete ? handler : undefined,
|
|
224
|
+
maxBodyBytes: spec.maxBodyBytes,
|
|
225
|
+
bodyTimeoutMs: spec.bodyTimeoutMs,
|
|
226
|
+
}
|
|
227
|
+
bakedRoutes[path] = routeSpec
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const { routes } = buildRouteHandlers({ ...baseOpts, routes: bakedRoutes })
|
|
231
|
+
const matcher = buildPathMatcher(routes)
|
|
232
|
+
|
|
233
|
+
const prewarm = (): void => {
|
|
234
|
+
// Run one probe GET through each compiled handler so the packed pipeline,
|
|
235
|
+
// header plan and FFI ingress call are JIT-warmed before the first real
|
|
236
|
+
// request. `run` never throws for a bad request (native failures become a
|
|
237
|
+
// 500 via setInternalError), so this is safe on any handler.
|
|
238
|
+
for (const handler of Object.values(compiled)) {
|
|
239
|
+
const probe = new Request('http://localhost:0/prewarm', { method: 'GET' })
|
|
240
|
+
handler.run<unknown>(
|
|
241
|
+
probe,
|
|
242
|
+
'127.0.0.1',
|
|
243
|
+
null,
|
|
244
|
+
(_result: BakedIngressResult, _ctx: BakedContext) => 0,
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (options.warmOnCreate === true) {
|
|
250
|
+
prewarm()
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const fetch = (req: Request, srv?: unknown): Response | Promise<Response> => {
|
|
254
|
+
let pathname: string
|
|
255
|
+
try {
|
|
256
|
+
pathname = new URL(req.url).pathname
|
|
257
|
+
} catch {
|
|
258
|
+
return new Response('Bad Request', { status: 400 })
|
|
259
|
+
}
|
|
260
|
+
const matched = matcher(pathname)
|
|
261
|
+
if (!matched) {
|
|
262
|
+
return new Response('Not Found', { status: 404 })
|
|
263
|
+
}
|
|
264
|
+
const methodHandler = matched.methods[req.method]
|
|
265
|
+
if (typeof methodHandler === 'function') {
|
|
266
|
+
return (methodHandler as RouteHandler)(req, srv, matched.params)
|
|
267
|
+
}
|
|
268
|
+
// A route matched but this method isn't wired — 405, or a bare OPTIONS 204
|
|
269
|
+
// when the route answers preflights.
|
|
270
|
+
if (req.method === 'OPTIONS' && typeof matched.methods.OPTIONS === 'function') {
|
|
271
|
+
return (matched.methods.OPTIONS as RouteHandler)(req, srv, matched.params)
|
|
272
|
+
}
|
|
273
|
+
return new Response('Method Not Allowed', { status: 405 })
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return { routeHandlers: compiled, routes, match: matcher, fetch, prewarm }
|
|
277
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// src/ingress/routes/common.ts — Shared route-handler options + helpers.
|
|
2
2
|
|
|
3
|
-
import type {
|
|
3
|
+
import type { BakedIngressResult } from '../decode/baked-result'
|
|
4
|
+
import { secondsFromMs } from '../shared'
|
|
5
|
+
import type { BakedContext, OptimizedIngressHandler } from '../types'
|
|
4
6
|
|
|
5
7
|
/** Options accepted by the pre-baked route factories. */
|
|
6
8
|
export interface BakedHandlerOptions {
|
|
@@ -18,6 +20,14 @@ export interface BakedHandlerOptions {
|
|
|
18
20
|
bodyTimeoutMs?: number
|
|
19
21
|
/** Fallback handler used for write error paths. Defaults to the write ingress. */
|
|
20
22
|
fallback?: OptimizedIngressHandler
|
|
23
|
+
/**
|
|
24
|
+
* Zero-DOM JSON-validity check for the write-route fallback validation
|
|
25
|
+
* (`jsonWriteHandler` when neither `requireJsonBody` nor a `schema` is
|
|
26
|
+
* configured). Injected so the pure route factories never import the native
|
|
27
|
+
* layer (dependency injection across the purity boundary). Defaults to the
|
|
28
|
+
* `ingress.jsonValid` capability that `createIngressHandler` provides.
|
|
29
|
+
*/
|
|
30
|
+
jsonValid?: (bytes: Uint8Array) => boolean
|
|
21
31
|
/**
|
|
22
32
|
* When true, route the JSON-validity fallback check through the higher-order
|
|
23
33
|
* loader (`loader("jsonValid")`) instead of the direct native call —
|
|
@@ -36,3 +46,42 @@ export function resolveIp(
|
|
|
36
46
|
): string | undefined {
|
|
37
47
|
return opts.getIp ? opts.getIp(req, srv) : undefined
|
|
38
48
|
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Build the success `ResponseInit` (status + ratelimit headers) shared by
|
|
52
|
+
* every pre-baked success path. A plain function call, so the hot path keeps
|
|
53
|
+
* its single init-object allocation and nothing else.
|
|
54
|
+
*
|
|
55
|
+
* @param status Success status; defaults to 200.
|
|
56
|
+
*/
|
|
57
|
+
export function buildSuccessInit(
|
|
58
|
+
ingress: OptimizedIngressHandler,
|
|
59
|
+
result: BakedIngressResult,
|
|
60
|
+
ctx: BakedContext,
|
|
61
|
+
status = 200,
|
|
62
|
+
): ResponseInit {
|
|
63
|
+
return {
|
|
64
|
+
status,
|
|
65
|
+
headers: ingress.responseHeaders(
|
|
66
|
+
result.headerVariant,
|
|
67
|
+
ctx.requestIdHeader,
|
|
68
|
+
ctx.origin,
|
|
69
|
+
result.rateRemaining,
|
|
70
|
+
result.rateResetMs > 0 ? secondsFromMs(result.rateResetMs) : undefined,
|
|
71
|
+
),
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Wrap a `(result, ctx) => Response` responder in the shared sync route
|
|
77
|
+
* runner: resolve the client IP once and dispatch through the native pipeline
|
|
78
|
+
* (`ingress.run` with a null body). Used by the read / head / fallback route
|
|
79
|
+
* factories whose responder never needs the request object.
|
|
80
|
+
*/
|
|
81
|
+
export function runBaked(
|
|
82
|
+
ingress: OptimizedIngressHandler,
|
|
83
|
+
opts: BakedHandlerOptions,
|
|
84
|
+
respond: (result: BakedIngressResult, ctx: BakedContext) => Response,
|
|
85
|
+
): (req: Request, srv?: unknown) => Response {
|
|
86
|
+
return (req, srv) => ingress.run<Response>(req, resolveIp(req, srv, opts), null, respond)
|
|
87
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import type { BakedContext, OptimizedIngressHandler } from '../types'
|
|
4
4
|
import type { BakedIngressResult } from '../decode/baked-result'
|
|
5
|
-
import {
|
|
5
|
+
import { runBaked, type BakedHandlerOptions } from './common'
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Pre-baked fallback handler: 404 for unmatched routes / OPTIONS.
|
|
@@ -19,5 +19,5 @@ export function fallbackHandler(
|
|
|
19
19
|
return ingress.errorResponse(undefined, result, 404, 'not_found', 'Not found', ctx)
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
return (
|
|
22
|
+
return runBaked(ingress, opts, respond)
|
|
23
23
|
}
|
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import type { BakedContext, OptimizedIngressHandler } from '../types'
|
|
4
4
|
import type { BakedIngressResult } from '../decode/baked-result'
|
|
5
|
-
import {
|
|
6
|
-
import { secondsFromMs } from '../shared'
|
|
5
|
+
import { buildSuccessInit, runBaked, type BakedHandlerOptions } from './common'
|
|
7
6
|
|
|
8
7
|
/**
|
|
9
8
|
* Pre-baked HEAD read handler: headers only, no body.
|
|
@@ -21,17 +20,8 @@ export function headHandler(
|
|
|
21
20
|
return ingress.internalErrorResponse(ctx, result)
|
|
22
21
|
}
|
|
23
22
|
|
|
24
|
-
return new Response(null,
|
|
25
|
-
status: 200,
|
|
26
|
-
headers: ingress.responseHeaders(
|
|
27
|
-
result.headerVariant,
|
|
28
|
-
ctx.requestIdHeader,
|
|
29
|
-
ctx.origin,
|
|
30
|
-
result.rateRemaining,
|
|
31
|
-
result.rateResetMs > 0 ? secondsFromMs(result.rateResetMs) : undefined,
|
|
32
|
-
),
|
|
33
|
-
})
|
|
23
|
+
return new Response(null, buildSuccessInit(ingress, result, ctx))
|
|
34
24
|
}
|
|
35
25
|
|
|
36
|
-
return (
|
|
26
|
+
return runBaked(ingress, opts, respond)
|
|
37
27
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// src/ingress/routes/index.ts — Route-handler factory composer.
|
|
2
2
|
|
|
3
3
|
export * from './common'
|
|
4
|
-
export * from './
|
|
4
|
+
export * from './delete'
|
|
5
|
+
export * from './echo'
|
|
6
|
+
export * from './fallback'
|
|
5
7
|
export * from './head'
|
|
6
8
|
export * from './json-write'
|
|
7
|
-
export * from './echo'
|
|
8
|
-
export * from './delete'
|
|
9
9
|
export * from './options'
|
|
10
|
-
export * from './
|
|
10
|
+
export * from './read'
|
|
11
|
+
export * from './responder'
|
|
@@ -1,27 +1,26 @@
|
|
|
1
1
|
// src/ingress/routes/json-write.ts — Pre-baked JSON-write handler
|
|
2
2
|
// (POST/PUT/PATCH).
|
|
3
3
|
|
|
4
|
-
import
|
|
5
|
-
import { resolveIp, type BakedHandlerOptions } from './common'
|
|
6
|
-
import { DEFAULT_MAX_BODY_BYTES, DEFAULT_BODY_TIMEOUT_MS, secondsFromMs } from '../shared'
|
|
4
|
+
import { decodeUtf8 } from '../../shared/codec'
|
|
7
5
|
import { readBodyWithLimit } from '../body'
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
6
|
+
import { DEFAULT_BODY_TIMEOUT_MS, DEFAULT_MAX_BODY_BYTES } from '../shared'
|
|
7
|
+
import type { OptimizedIngressHandler } from '../types'
|
|
8
|
+
import { type BakedHandlerOptions, buildSuccessInit, resolveIp } from './common'
|
|
10
9
|
|
|
11
10
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* on the ingress (see the `bodyValidJson` handling in the route).
|
|
18
|
-
*
|
|
19
|
-
* bun:ffi is primary on Bun; the napi addon is the fallback (Node, forced
|
|
20
|
-
* `CASTRUM_FFI_MODE=napi`, or a failed ffi self-test).
|
|
11
|
+
* Pure JSON-validity fallback used ONLY for hand-rolled
|
|
12
|
+
* `OptimizedIngressHandler` mocks that carry no native `jsonValid` capability.
|
|
13
|
+
* Real handlers from `createIngressHandler` always provide the zero-DOM native
|
|
14
|
+
* check (`ingress.jsonValid`), so this never runs on the hot path — it exists
|
|
15
|
+
* to keep the security property (invalid JSON must be rejected) for mocks.
|
|
21
16
|
*/
|
|
22
|
-
function
|
|
23
|
-
|
|
24
|
-
|
|
17
|
+
function defaultJsonValid(bytes: Uint8Array): boolean {
|
|
18
|
+
try {
|
|
19
|
+
JSON.parse(decodeUtf8(bytes))
|
|
20
|
+
return true
|
|
21
|
+
} catch {
|
|
22
|
+
return false
|
|
23
|
+
}
|
|
25
24
|
}
|
|
26
25
|
|
|
27
26
|
/**
|
|
@@ -39,6 +38,10 @@ export function jsonWriteHandler(
|
|
|
39
38
|
// Non-zero default: protect against slowloris / trickling bodies. Set
|
|
40
39
|
// `bodyTimeoutMs: 0` to disable.
|
|
41
40
|
const bodyTimeoutMs = opts.bodyTimeoutMs ?? DEFAULT_BODY_TIMEOUT_MS
|
|
41
|
+
// JSON-validity check via DI across the purity boundary: an explicit option
|
|
42
|
+
// wins, otherwise the handler's own native capability (from
|
|
43
|
+
// `createIngressHandler`), otherwise the pure mock fallback.
|
|
44
|
+
const jsonValid = opts.jsonValid ?? ingress.jsonValid ?? defaultJsonValid
|
|
42
45
|
|
|
43
46
|
return async (req, srv) => {
|
|
44
47
|
const ip = resolveIp(req, srv, opts)
|
|
@@ -60,25 +63,10 @@ export function jsonWriteHandler(
|
|
|
60
63
|
})
|
|
61
64
|
}
|
|
62
65
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
return fallback.run(req, ip, null, (result, ctx) => {
|
|
68
|
-
const terminal = fallback.terminalResponse(req, result, ctx)
|
|
69
|
-
if (terminal) return terminal
|
|
70
|
-
|
|
71
|
-
return fallback.errorResponse(
|
|
72
|
-
req,
|
|
73
|
-
result,
|
|
74
|
-
413,
|
|
75
|
-
'body_too_large',
|
|
76
|
-
'Request body is too large',
|
|
77
|
-
ctx,
|
|
78
|
-
)
|
|
79
|
-
})
|
|
80
|
-
}
|
|
81
|
-
|
|
66
|
+
// Content-Length / body-size enforcement happens INSIDE readBodyWithLimit
|
|
67
|
+
// (guard on): the declared length is checked before reading and re-checked
|
|
68
|
+
// after, so a client lying about Content-Length still gets a 413. Not
|
|
69
|
+
// re-reading the header here saves a per-POST headers.get on the hot path.
|
|
82
70
|
let bodyBytes: Uint8Array
|
|
83
71
|
try {
|
|
84
72
|
// Stream-read with the limit enforced WHILE reading — a body that
|
|
@@ -130,20 +118,11 @@ export function jsonWriteHandler(
|
|
|
130
118
|
ctx,
|
|
131
119
|
)
|
|
132
120
|
}
|
|
133
|
-
} else if (!
|
|
121
|
+
} else if (!jsonValid(bodyBytes)) {
|
|
134
122
|
return ingress.errorResponse(req, result, 400, 'invalid_json', 'Invalid JSON body', ctx)
|
|
135
123
|
}
|
|
136
124
|
|
|
137
|
-
const init
|
|
138
|
-
status: 200,
|
|
139
|
-
headers: ingress.responseHeaders(
|
|
140
|
-
result.headerVariant,
|
|
141
|
-
ctx.requestIdHeader,
|
|
142
|
-
ctx.origin,
|
|
143
|
-
result.rateRemaining,
|
|
144
|
-
result.rateResetMs > 0 ? secondsFromMs(result.rateResetMs) : undefined,
|
|
145
|
-
),
|
|
146
|
-
}
|
|
125
|
+
const init = buildSuccessInit(ingress, result, ctx)
|
|
147
126
|
|
|
148
127
|
return copyBody
|
|
149
128
|
? new Response(result.bodyJson(true), init)
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// src/ingress/routes/native.ts — lean per-route native-stack route factory.
|
|
2
|
+
//
|
|
3
|
+
// A `RouteHandler` backed by the route-wire v3 stack (`createNativeRoute`)
|
|
4
|
+
// instead of the full 8-stage `IngressInner`: the native side runs ONLY the
|
|
5
|
+
// stages the plan compiled (parseQuery/parseCookies/requireJsonBody/
|
|
6
|
+
// validateBody) and returns a packed verdict + pair sections; the responder
|
|
7
|
+
// builds the 2xx from the decoded snapshot. This is the leanest per-request
|
|
8
|
+
// path — measured `bench/cost/native-route-vs-router.ts`: ~551ns full JS-glue
|
|
9
|
+
// path vs ~1131ns router `run()` on a parseQuery+parseCookies route (−580ns).
|
|
10
|
+
//
|
|
11
|
+
// PURE: the compiled `NativeRoute` is injected (dependency injection across
|
|
12
|
+
// the purity boundary) — this factory never touches the dlopen layer itself;
|
|
13
|
+
// the impure `createNativeRoute(plan)` call happens in the boundary
|
|
14
|
+
// (`router.ts` / `server.ts` `buildRouteHandlers`).
|
|
15
|
+
//
|
|
16
|
+
// Trade-off (deliberate): the native route stack does NOT do CORS, rate
|
|
17
|
+
// limiting, security headers, IP trust, or the castrum metadata envelope — a
|
|
18
|
+
// route that needs those must use the full pipeline (`readHandler` /
|
|
19
|
+
// `nativeResponderRoute`). This factory is for routes where the framework
|
|
20
|
+
// owns the response body and only needs parse + verdict.
|
|
21
|
+
|
|
22
|
+
import { decoder } from '../../shared/bytes'
|
|
23
|
+
import { generateRequestId } from '../../shared/request-id'
|
|
24
|
+
import { readBodyWithLimit } from '../body'
|
|
25
|
+
import type { NativeRoute } from '../native-route'
|
|
26
|
+
import type { RouteHandler } from '../server'
|
|
27
|
+
import { DEFAULT_BODY_TIMEOUT_MS, DEFAULT_MAX_BODY_BYTES } from '../shared'
|
|
28
|
+
import type { NativeRequestContext, NativeResponder } from '../types'
|
|
29
|
+
import type { BakedHandlerOptions } from './common'
|
|
30
|
+
import { resolveIp } from './common'
|
|
31
|
+
|
|
32
|
+
/** Options for {@link nativeRouteHandler}. */
|
|
33
|
+
export interface NativeRouteHandlerOptions extends BakedHandlerOptions {
|
|
34
|
+
/** Read the request body and pass it for `requireJsonBody`/`validateBody`. */
|
|
35
|
+
readBody?: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The `+`-to-space / `%XX` decoding is done natively; keys are last-wins. */
|
|
39
|
+
function pairsToRecord(pairs: ReadonlyArray<[string, string]>): Record<string, string> {
|
|
40
|
+
const out: Record<string, string> = {}
|
|
41
|
+
for (const [k, v] of pairs) out[k] = v
|
|
42
|
+
return out
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Build a lean native-stack route handler over a COMPILED route (injected —
|
|
47
|
+
* compile via `createNativeRoute` in the boundary, then pass the route here).
|
|
48
|
+
*
|
|
49
|
+
* Terminal verdicts (`errorCode !== 0`) become JSON rejections: 400 for
|
|
50
|
+
* `requireJsonBody` (missing / non-JSON body), 422 for `validateBody` schema
|
|
51
|
+
* failure. On success the responder builds the 2xx from the snapshot.
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* ```ts
|
|
55
|
+
* const route = createNativeRoute({ parseQuery: true, parseCookies: true })
|
|
56
|
+
* const handler = nativeRouteHandler(route, (snap) =>
|
|
57
|
+
* Response.json({ ok: true, query: snap.query, cookies: snap.cookies }),
|
|
58
|
+
* )
|
|
59
|
+
* ```
|
|
60
|
+
*/
|
|
61
|
+
export function nativeRouteHandler(
|
|
62
|
+
route: NativeRoute,
|
|
63
|
+
responder: NativeResponder,
|
|
64
|
+
opts: NativeRouteHandlerOptions = {},
|
|
65
|
+
): RouteHandler {
|
|
66
|
+
const readBody = opts.readBody ?? false
|
|
67
|
+
const maxBodyBytes = opts.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES
|
|
68
|
+
const bodyTimeoutMs = opts.bodyTimeoutMs ?? DEFAULT_BODY_TIMEOUT_MS
|
|
69
|
+
const parseQuery = route.parseQuery
|
|
70
|
+
const parseCookies = route.parseCookies
|
|
71
|
+
|
|
72
|
+
const terminal = (status: number, code: string, message: string): Response =>
|
|
73
|
+
Response.json(
|
|
74
|
+
{ ok: false, error: { code, message } },
|
|
75
|
+
{ status, headers: { 'content-type': 'application/json' } },
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
return async (req, srv, _params) => {
|
|
79
|
+
const ip = resolveIp(req, srv, opts)
|
|
80
|
+
const requestId = decoder.decode(generateRequestId())
|
|
81
|
+
|
|
82
|
+
let body: Uint8Array | null = null
|
|
83
|
+
if (readBody) {
|
|
84
|
+
try {
|
|
85
|
+
body = await readBodyWithLimit(req, maxBodyBytes, true, bodyTimeoutMs)
|
|
86
|
+
} catch (err) {
|
|
87
|
+
const code = (err as Error & { code?: string }).code
|
|
88
|
+
const status = code === 'REQUEST_TIMEOUT' ? 408 : code === 'BODY_TOO_LARGE' ? 413 : 400
|
|
89
|
+
return terminal(status, 'BAD_REQUEST', 'Request body read failed')
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Extract the query substring + Cookie header (the only request inputs the
|
|
94
|
+
// native stack reads) and run the tiny frame in ONE native call.
|
|
95
|
+
const url = req.url
|
|
96
|
+
const qIndex = url.indexOf('?')
|
|
97
|
+
const queryStr = qIndex >= 0 ? url.slice(qIndex + 1) : ''
|
|
98
|
+
const cookieStr = req.headers.get('cookie') ?? ''
|
|
99
|
+
|
|
100
|
+
let result
|
|
101
|
+
try {
|
|
102
|
+
result = route.run(queryStr, cookieStr, body)
|
|
103
|
+
} catch {
|
|
104
|
+
return terminal(500, 'INTERNAL', 'Native route run failed')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (result.errorCode !== 0) {
|
|
108
|
+
if (result.errorCode === 400) {
|
|
109
|
+
return terminal(400, 'INVALID_JSON', 'Request body must be valid JSON')
|
|
110
|
+
}
|
|
111
|
+
if (result.errorCode === 422) {
|
|
112
|
+
return terminal(422, 'VALIDATION_FAILED', 'Request body failed schema validation')
|
|
113
|
+
}
|
|
114
|
+
return terminal(400, 'BAD_REQUEST', 'Bad request')
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const snapshot: NativeRequestContext = {
|
|
118
|
+
requestId,
|
|
119
|
+
status: 200,
|
|
120
|
+
rateLimited: false,
|
|
121
|
+
retryAfterMs: 0,
|
|
122
|
+
query: parseQuery ? pairsToRecord(result.query) : {},
|
|
123
|
+
cookies: parseCookies ? pairsToRecord(result.cookie) : {},
|
|
124
|
+
body: body ?? new Uint8Array(0),
|
|
125
|
+
ip,
|
|
126
|
+
req,
|
|
127
|
+
}
|
|
128
|
+
return responder(snapshot)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
// src/ingress/routes/options.ts — Pre-baked OPTIONS handler (CORS preflight).
|
|
2
2
|
|
|
3
3
|
import type { OptimizedIngressHandler } from '../types'
|
|
4
|
-
import { resolveIp, type BakedHandlerOptions } from './common'
|
|
5
|
-
import { secondsFromMs } from '../shared'
|
|
4
|
+
import { buildSuccessInit, resolveIp, type BakedHandlerOptions } from './common'
|
|
6
5
|
|
|
7
6
|
/**
|
|
8
7
|
* Pre-baked OPTIONS handler: serves the native pipeline's CORS preflight
|
|
@@ -22,15 +21,6 @@ export function optionsHandler(
|
|
|
22
21
|
const terminal = ingress.terminalResponse(req, result, ctx)
|
|
23
22
|
if (terminal) return terminal
|
|
24
23
|
|
|
25
|
-
return new Response(null,
|
|
26
|
-
status: 204,
|
|
27
|
-
headers: ingress.responseHeaders(
|
|
28
|
-
result.headerVariant,
|
|
29
|
-
ctx.requestIdHeader,
|
|
30
|
-
ctx.origin,
|
|
31
|
-
result.rateRemaining,
|
|
32
|
-
result.rateResetMs > 0 ? secondsFromMs(result.rateResetMs) : undefined,
|
|
33
|
-
),
|
|
34
|
-
})
|
|
24
|
+
return new Response(null, buildSuccessInit(ingress, result, ctx, 204))
|
|
35
25
|
})
|
|
36
26
|
}
|