castrum 0.9.1 → 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 +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
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// src/ingress/path-matcher.ts — `:param` / `*` path matching for pre-baked routes
|
|
2
|
+
//
|
|
3
|
+
// The dynamic-path matcher shared by the Node adapter (server-node.ts) and the
|
|
4
|
+
// router (router.ts). Bun's native router matches dynamic paths itself, so this
|
|
5
|
+
// only runs on Node / route-manager paths. The ingress pipeline does not echo
|
|
6
|
+
// path params in its response, so matching only selects the handler.
|
|
7
|
+
|
|
8
|
+
/** A matched dynamic route: the handler map + the extracted path params. */
|
|
9
|
+
export interface PathMatch {
|
|
10
|
+
methods: Record<string, unknown>
|
|
11
|
+
params: Record<string, string>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface CompiledDynamicPattern {
|
|
15
|
+
re: RegExp
|
|
16
|
+
paramNames: string[]
|
|
17
|
+
rest: boolean
|
|
18
|
+
methods: Record<string, unknown>
|
|
19
|
+
/** Number of literal (static) segments — used to prefer the most specific pattern. */
|
|
20
|
+
staticSegments: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Build a path matcher over a route map, supporting `:param` and `*` (rest)
|
|
25
|
+
* segments. Exact (static) paths always win over dynamic patterns; dynamic
|
|
26
|
+
* patterns are ordered most-specific-first (most static segments, then fewest
|
|
27
|
+
* params). Used by `createIngressServerNode` (Bun's native router already
|
|
28
|
+
* matches dynamic patterns itself). The ingress pipeline does not echo path
|
|
29
|
+
* params in its response, so matching only selects the handler.
|
|
30
|
+
*
|
|
31
|
+
* @param routes the built route map (`path` → `method` → handler)
|
|
32
|
+
* @returns a matcher: `pathname` → `{ methods, params }` or `undefined`
|
|
33
|
+
*/
|
|
34
|
+
export function buildPathMatcher(
|
|
35
|
+
routes: Record<string, Record<string, unknown>>,
|
|
36
|
+
): (pathname: string) => PathMatch | undefined {
|
|
37
|
+
const exact = new Map<string, Record<string, unknown>>()
|
|
38
|
+
const dynamic: CompiledDynamicPattern[] = []
|
|
39
|
+
|
|
40
|
+
for (const [path, methods] of Object.entries(routes)) {
|
|
41
|
+
const hasParam = path.indexOf(':') !== -1
|
|
42
|
+
const hasRest = path.indexOf('*') !== -1
|
|
43
|
+
if (!hasParam && !hasRest) {
|
|
44
|
+
exact.set(path, methods)
|
|
45
|
+
continue
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const paramNames: string[] = []
|
|
49
|
+
let rest = false
|
|
50
|
+
let staticSegments = 0
|
|
51
|
+
const parts = path.split('/').filter((s) => s.length > 0)
|
|
52
|
+
const source = parts
|
|
53
|
+
.map((seg) => {
|
|
54
|
+
if (seg === '*') {
|
|
55
|
+
rest = true
|
|
56
|
+
return '(.*)'
|
|
57
|
+
}
|
|
58
|
+
if (seg.startsWith(':')) {
|
|
59
|
+
paramNames.push(seg.slice(1))
|
|
60
|
+
return '([^/]+)'
|
|
61
|
+
}
|
|
62
|
+
staticSegments++
|
|
63
|
+
return seg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
64
|
+
})
|
|
65
|
+
.join('/')
|
|
66
|
+
|
|
67
|
+
dynamic.push({
|
|
68
|
+
re: new RegExp(`^/${source}${rest ? '' : '/?$'}`),
|
|
69
|
+
paramNames,
|
|
70
|
+
rest,
|
|
71
|
+
methods,
|
|
72
|
+
staticSegments,
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
dynamic.sort(
|
|
77
|
+
(a, b) => b.staticSegments - a.staticSegments || a.paramNames.length - b.paramNames.length,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
return (pathname: string) => {
|
|
81
|
+
const exactHit = exact.get(pathname)
|
|
82
|
+
if (exactHit !== undefined) {
|
|
83
|
+
return { methods: exactHit, params: {} }
|
|
84
|
+
}
|
|
85
|
+
for (const d of dynamic) {
|
|
86
|
+
const m = d.re.exec(pathname)
|
|
87
|
+
if (m !== null) {
|
|
88
|
+
const params: Record<string, string> = {}
|
|
89
|
+
for (let i = 0; i < d.paramNames.length; i++) {
|
|
90
|
+
const raw = m[i + 1]
|
|
91
|
+
if (raw !== undefined) params[d.paramNames[i] ?? ''] = safeDecode(raw)
|
|
92
|
+
}
|
|
93
|
+
if (d.rest) {
|
|
94
|
+
const restRaw = m[d.paramNames.length + 1]
|
|
95
|
+
if (restRaw !== undefined) params['*'] = safeDecode(restRaw)
|
|
96
|
+
}
|
|
97
|
+
return { methods: d.methods, params }
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return undefined
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Percent-decode a matched path segment, tolerating malformed escapes: a bad
|
|
106
|
+
* `%` sequence is returned raw rather than throwing, so the matcher never
|
|
107
|
+
* rejects a request over a decode error.
|
|
108
|
+
*/
|
|
109
|
+
export function safeDecode(value: string): string {
|
|
110
|
+
try {
|
|
111
|
+
return decodeURIComponent(value)
|
|
112
|
+
} catch {
|
|
113
|
+
return value
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
// src/ingress/response/baked-response.ts — pre-baked response builders (path 2)
|
|
2
|
+
//
|
|
3
|
+
// The seven response-builder methods of `OptimizedIngressHandler`, extracted
|
|
4
|
+
// from createIngressHandler (handlers.ts) so the factory stays focused on
|
|
5
|
+
// packing + pooling. Mirrors fast.ts's response/terminal.ts split — but emits
|
|
6
|
+
// the BENCH wire format (`{"ok":false,"error":{...}}` + `ratelimit-*` headers),
|
|
7
|
+
// NOT the fast path's `x-ratelimit-*` shape (see AGENTS.md — do not unify).
|
|
8
|
+
//
|
|
9
|
+
// The builders are bound to a handler's configuration (header templates,
|
|
10
|
+
// request-id emission, CORS cache) plus a shared mutable `BakedResponseState`
|
|
11
|
+
// that `run()` in handlers.ts owns: `zeroCopyResponse` marks the pooled
|
|
12
|
+
// output buffer as borrowed so `run()` does not release it before the body is
|
|
13
|
+
// consumed.
|
|
14
|
+
|
|
15
|
+
import type { PooledBuffer } from '../../shared/buffer-pool'
|
|
16
|
+
import { encoder } from '../../shared/bytes'
|
|
17
|
+
import { pooledBodyResponse } from '../../shared/response'
|
|
18
|
+
import {
|
|
19
|
+
ERR_CODE_RATE_LIMITED as ERROR_CODE_RATE_LIMITED,
|
|
20
|
+
HV_CORS_PREFLIGHT,
|
|
21
|
+
HV_CORS_SIMPLE,
|
|
22
|
+
HV_JSON,
|
|
23
|
+
HV_RATE_ACTIVE,
|
|
24
|
+
HV_RATE_LIMITED,
|
|
25
|
+
} from '../constants'
|
|
26
|
+
import type { BakedIngressResult } from '../decode/baked-result'
|
|
27
|
+
import { secondsFromMs } from '../shared'
|
|
28
|
+
import { safeTerminalStatus } from '../status'
|
|
29
|
+
import type { BakedContext, OptimizedIngressHandler } from '../types'
|
|
30
|
+
import { ERROR_BODIES, ERROR_CODE_BODIES, rateLimitedBody } from './error-bodies'
|
|
31
|
+
|
|
32
|
+
const EMPTY_BODY = new Uint8Array(0)
|
|
33
|
+
|
|
34
|
+
/** Shared per-request state between `run()` (handlers.ts) and the builders. */
|
|
35
|
+
export interface BakedResponseState {
|
|
36
|
+
/** The pooled output buffer backing the current `run()` (null outside one). */
|
|
37
|
+
currentHandle: PooledBuffer | null
|
|
38
|
+
/** True when a zero-copy Response claimed the buffer (run() must not release). */
|
|
39
|
+
responseBorrowsBuffer: boolean
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Configuration the builder factory needs from a handler. */
|
|
43
|
+
export interface BakedResponseBuildersDeps {
|
|
44
|
+
/** Variant-indexed pre-baked header templates (regular responses). */
|
|
45
|
+
headerTemplates: ReadonlyArray<ReadonlyArray<[string, string]>>
|
|
46
|
+
/** Variant-indexed pre-baked terminal templates (JSON + no-store). */
|
|
47
|
+
terminalTemplates: ReadonlyArray<ReadonlyArray<[string, string]>>
|
|
48
|
+
/** Emit an `x-request-id` header on responses. */
|
|
49
|
+
emitRequestIdHeader: boolean
|
|
50
|
+
/** Abandonment guard (ms) for zero-copy responses. */
|
|
51
|
+
zeroCopyTimeoutMs: number
|
|
52
|
+
/** Cache of Origin-augmented header arrays keyed by `variant\u0000origin`. */
|
|
53
|
+
originHeaderCache: Map<string, [string, string][]>
|
|
54
|
+
/** Shared per-request state (owned by `run()` in handlers.ts). */
|
|
55
|
+
state: BakedResponseState
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Build the seven response-builder methods of `OptimizedIngressHandler`
|
|
60
|
+
* (everything except `run`), bound to this handler's header templates and
|
|
61
|
+
* request-id policy. The returned methods are the exact bodies that lived in
|
|
62
|
+
* `createIngressHandler`.
|
|
63
|
+
*/
|
|
64
|
+
export function buildBakedResponseBuilders(
|
|
65
|
+
deps: BakedResponseBuildersDeps,
|
|
66
|
+
): Omit<OptimizedIngressHandler, 'run'> {
|
|
67
|
+
const {
|
|
68
|
+
headerTemplates,
|
|
69
|
+
terminalTemplates,
|
|
70
|
+
emitRequestIdHeader,
|
|
71
|
+
zeroCopyTimeoutMs,
|
|
72
|
+
originHeaderCache,
|
|
73
|
+
state,
|
|
74
|
+
} = deps
|
|
75
|
+
|
|
76
|
+
function responseHeaders(
|
|
77
|
+
variant: number,
|
|
78
|
+
requestIdHeader: string | null,
|
|
79
|
+
origin: string | null,
|
|
80
|
+
rateRemaining?: number,
|
|
81
|
+
rateResetSecs?: number,
|
|
82
|
+
retryAfterSecs?: number,
|
|
83
|
+
): [string, string][] {
|
|
84
|
+
const template: ReadonlyArray<[string, string]> =
|
|
85
|
+
headerTemplates[variant & 31] ?? headerTemplates[0] ?? []
|
|
86
|
+
|
|
87
|
+
const needsRequestId = emitRequestIdHeader && requestIdHeader !== null
|
|
88
|
+
const needsOrigin =
|
|
89
|
+
((variant & HV_CORS_SIMPLE) !== 0 || (variant & HV_CORS_PREFLIGHT) !== 0) && origin !== null
|
|
90
|
+
const needsRate = (variant & HV_RATE_ACTIVE) !== 0
|
|
91
|
+
const needsRetry = (variant & HV_RATE_LIMITED) !== 0
|
|
92
|
+
|
|
93
|
+
if (!needsRequestId && !needsOrigin && !needsRate && !needsRetry) {
|
|
94
|
+
// Frozen-readonly → mutable: callers only ever read the baked template
|
|
95
|
+
// (zero-copy steady-state path), so the widening is safe in practice.
|
|
96
|
+
return template as unknown as [string, string][]
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!needsRequestId && !needsRate && !needsRetry && needsOrigin) {
|
|
100
|
+
// Steady-state CORS: only the origin varies — cache the augmented array
|
|
101
|
+
// per (variant, origin) so the Origin-present case (the common
|
|
102
|
+
// browser/bench case) stays allocation-free after the first hit for EACH
|
|
103
|
+
// distinct origin (a single-slot memo thrashed when clients alternate
|
|
104
|
+
// between allowed origins, allocating a fresh array on every switch).
|
|
105
|
+
const key = `${variant & 31}\u0000${origin as string}`
|
|
106
|
+
const cached = originHeaderCache.get(key)
|
|
107
|
+
if (cached !== undefined) {
|
|
108
|
+
return cached
|
|
109
|
+
}
|
|
110
|
+
const entries = new Array<[string, string]>(template.length + 1)
|
|
111
|
+
let i = 0
|
|
112
|
+
for (const pair of template) {
|
|
113
|
+
entries[i++] = pair
|
|
114
|
+
}
|
|
115
|
+
entries[i] = ['access-control-allow-origin', origin as string]
|
|
116
|
+
originHeaderCache.set(key, entries)
|
|
117
|
+
return entries
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let extra = 0
|
|
121
|
+
if (needsRequestId) extra++
|
|
122
|
+
if (needsOrigin) extra++
|
|
123
|
+
if (needsRate) extra += 2
|
|
124
|
+
if (needsRetry) extra++
|
|
125
|
+
|
|
126
|
+
const entries = new Array<[string, string]>(template.length + extra)
|
|
127
|
+
let i = 0
|
|
128
|
+
|
|
129
|
+
for (const pair of template) {
|
|
130
|
+
entries[i++] = pair
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (needsRequestId) {
|
|
134
|
+
entries[i++] = ['x-request-id', requestIdHeader as string]
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (needsOrigin) {
|
|
138
|
+
entries[i++] = ['access-control-allow-origin', origin as string]
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (needsRate) {
|
|
142
|
+
entries[i++] = ['ratelimit-remaining', String(rateRemaining ?? 0)]
|
|
143
|
+
entries[i++] = ['ratelimit-reset', String(rateResetSecs ?? 0)]
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (needsRetry) {
|
|
147
|
+
entries[i++] = ['retry-after', String(retryAfterSecs ?? 0)]
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return entries
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function terminalHeaders(
|
|
154
|
+
variant: number,
|
|
155
|
+
ctx: BakedContext,
|
|
156
|
+
result: BakedIngressResult | null,
|
|
157
|
+
): [string, string][] {
|
|
158
|
+
const v = variant | HV_JSON
|
|
159
|
+
const requestIdHeader = ctx.requestIdHeader
|
|
160
|
+
const origin = ctx.origin
|
|
161
|
+
const rateResetSecs =
|
|
162
|
+
result && result.rateResetMs > 0 ? secondsFromMs(result.rateResetMs) : undefined
|
|
163
|
+
const retryAfterSecs =
|
|
164
|
+
result && result.retryAfterMs > 0 ? secondsFromMs(result.retryAfterMs) : undefined
|
|
165
|
+
|
|
166
|
+
const needsRequestId = emitRequestIdHeader && requestIdHeader !== null
|
|
167
|
+
const needsOrigin =
|
|
168
|
+
((v & HV_CORS_SIMPLE) !== 0 || (v & HV_CORS_PREFLIGHT) !== 0) && origin !== null
|
|
169
|
+
const needsRate = (v & HV_RATE_ACTIVE) !== 0
|
|
170
|
+
const needsRetry = (v & HV_RATE_LIMITED) !== 0
|
|
171
|
+
|
|
172
|
+
// Steady state: no request-id/origin/rate extras — serve the pre-baked
|
|
173
|
+
// terminal template (JSON + `cache-control: no-store`) directly with zero
|
|
174
|
+
// per-response array allocation/copy.
|
|
175
|
+
if (!needsRequestId && !needsOrigin && !needsRate && !needsRetry) {
|
|
176
|
+
// Frozen-readonly → mutable: the baked terminal template is only read.
|
|
177
|
+
return terminalTemplates[v & 31] as unknown as [string, string][]
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Rare path: extras appended after the baked entries, then `cache-control`.
|
|
181
|
+
const base = responseHeaders(
|
|
182
|
+
v,
|
|
183
|
+
requestIdHeader,
|
|
184
|
+
origin,
|
|
185
|
+
result?.rateRemaining,
|
|
186
|
+
rateResetSecs,
|
|
187
|
+
retryAfterSecs,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
const out = new Array<[string, string]>(base.length + 1)
|
|
191
|
+
let i = 0
|
|
192
|
+
for (const pair of base) {
|
|
193
|
+
out[i++] = pair
|
|
194
|
+
}
|
|
195
|
+
out[base.length] = ['cache-control', 'no-store']
|
|
196
|
+
return out
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function terminalResponse(
|
|
200
|
+
_req: Request | undefined,
|
|
201
|
+
result: BakedIngressResult,
|
|
202
|
+
ctx: BakedContext,
|
|
203
|
+
): Response | null {
|
|
204
|
+
if (!result.terminal) {
|
|
205
|
+
return null
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const preflightAllowed = result.isPreflight && result.corsAllowed
|
|
209
|
+
|
|
210
|
+
if (preflightAllowed) {
|
|
211
|
+
return new Response(null, {
|
|
212
|
+
status: 204,
|
|
213
|
+
headers: responseHeaders(
|
|
214
|
+
result.headerVariant,
|
|
215
|
+
ctx.requestIdHeader,
|
|
216
|
+
ctx.origin,
|
|
217
|
+
result.rateRemaining,
|
|
218
|
+
result.rateResetMs > 0 ? secondsFromMs(result.rateResetMs) : undefined,
|
|
219
|
+
result.retryAfterMs > 0 ? secondsFromMs(result.retryAfterMs) : undefined,
|
|
220
|
+
),
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const status = safeTerminalStatus(result)
|
|
225
|
+
|
|
226
|
+
const body: Uint8Array =
|
|
227
|
+
result.errorCode === ERROR_CODE_RATE_LIMITED
|
|
228
|
+
? rateLimitedBody(result.retryAfterMs)
|
|
229
|
+
: (ERROR_CODE_BODIES[result.errorCode] ?? ERROR_BODIES.internal ?? EMPTY_BODY)
|
|
230
|
+
|
|
231
|
+
return new Response(body, {
|
|
232
|
+
status,
|
|
233
|
+
headers: terminalHeaders(result.headerVariant, ctx, result),
|
|
234
|
+
})
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function errorResponse(
|
|
238
|
+
_req: Request | undefined,
|
|
239
|
+
result: BakedIngressResult | null,
|
|
240
|
+
status: number,
|
|
241
|
+
code: string,
|
|
242
|
+
message: string,
|
|
243
|
+
ctx: BakedContext,
|
|
244
|
+
): Response {
|
|
245
|
+
const body =
|
|
246
|
+
ERROR_BODIES[code] ?? encoder.encode(JSON.stringify({ ok: false, error: { code, message } }))
|
|
247
|
+
|
|
248
|
+
return new Response(body, {
|
|
249
|
+
status,
|
|
250
|
+
headers: terminalHeaders(result?.headerVariant ?? HV_JSON, ctx, result),
|
|
251
|
+
})
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function internalErrorResponse(ctx: BakedContext, result?: BakedIngressResult): Response {
|
|
255
|
+
return new Response(ERROR_BODIES.internal, {
|
|
256
|
+
status: 500,
|
|
257
|
+
headers: terminalHeaders(result?.headerVariant ?? HV_JSON, ctx, result ?? null),
|
|
258
|
+
})
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function withContentType(
|
|
262
|
+
headers: ReadonlyArray<[string, string]>,
|
|
263
|
+
contentType: string,
|
|
264
|
+
): [string, string][] {
|
|
265
|
+
const out = new Array<[string, string]>(headers.length + 1)
|
|
266
|
+
let i = 0
|
|
267
|
+
for (const pair of headers) {
|
|
268
|
+
out[i++] = pair
|
|
269
|
+
}
|
|
270
|
+
out[headers.length] = ['content-type', contentType]
|
|
271
|
+
return out
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function zeroCopyResponse(
|
|
275
|
+
result: BakedIngressResult,
|
|
276
|
+
_ctx: BakedContext,
|
|
277
|
+
init: ResponseInit,
|
|
278
|
+
): Response {
|
|
279
|
+
if (state.currentHandle === null) {
|
|
280
|
+
// `zeroCopyResponse` must only run inside a live `run()` callback (a
|
|
281
|
+
// pooled output buffer must be in flight). Called outside `run()` the
|
|
282
|
+
// result has already been invalidated and `result.bodyJson` is empty —
|
|
283
|
+
// throw instead of silently serving an empty body.
|
|
284
|
+
throw new Error(
|
|
285
|
+
'zeroCopyResponse() can only be called from within a run() callback ' +
|
|
286
|
+
'(the pooled output buffer is only live during run()).',
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
state.responseBorrowsBuffer = true
|
|
290
|
+
return pooledBodyResponse(state.currentHandle, result.bodyJson(false), init, zeroCopyTimeoutMs)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
responseHeaders,
|
|
295
|
+
terminalHeaders,
|
|
296
|
+
terminalResponse,
|
|
297
|
+
errorResponse,
|
|
298
|
+
internalErrorResponse,
|
|
299
|
+
withContentType,
|
|
300
|
+
zeroCopyResponse,
|
|
301
|
+
}
|
|
302
|
+
}
|
package/src/ingress/router.ts
CHANGED
|
@@ -23,9 +23,12 @@
|
|
|
23
23
|
|
|
24
24
|
import type { BakedIngressResult } from './decode/baked-result'
|
|
25
25
|
import { type BakedIngressRuntime, createIngressHandler } from './handlers'
|
|
26
|
+
import type { NativeRoutePlan } from './native-route'
|
|
26
27
|
import type { IngressHandlerOptions } from './options'
|
|
27
|
-
import type {
|
|
28
|
-
import { buildPathMatcher
|
|
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'
|
|
29
32
|
import type { BakedContext, NativeResponder, OptimizedIngressHandler, TerminalStyle } from './types'
|
|
30
33
|
|
|
31
34
|
/** Per-route spec for {@link createIngressRouter}. */
|
|
@@ -60,6 +63,28 @@ export interface RouterRouteSpec {
|
|
|
60
63
|
/** Read the body for native validation (default false — framework owns it). */
|
|
61
64
|
readBody?: boolean
|
|
62
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
|
+
}
|
|
63
88
|
/** Terminal envelope style for this route's responder (default: router-level
|
|
64
89
|
* `terminalStyle` or `'castrum'`). */
|
|
65
90
|
terminalStyle?: TerminalStyle
|
|
@@ -155,6 +180,21 @@ export function createIngressRouter(options: CreateIngressRouterOptions): Ingres
|
|
|
155
180
|
bakedRoutes[path] = { read: spec.raw as RouteHandler }
|
|
156
181
|
continue
|
|
157
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
|
+
}
|
|
158
198
|
// Compile a dedicated native instance for this route's options.
|
|
159
199
|
const handler = createIngressHandler(spec.options ?? {}, runtime)
|
|
160
200
|
compiled[path] = handler
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// src/ingress/routes/common.ts — Shared route-handler options + helpers.
|
|
2
2
|
|
|
3
|
-
import type { BakedContext, OptimizedIngressHandler } from '../types'
|
|
4
3
|
import type { BakedIngressResult } from '../decode/baked-result'
|
|
5
4
|
import { secondsFromMs } from '../shared'
|
|
5
|
+
import type { BakedContext, OptimizedIngressHandler } from '../types'
|
|
6
6
|
|
|
7
7
|
/** Options accepted by the pre-baked route factories. */
|
|
8
8
|
export interface BakedHandlerOptions {
|
|
@@ -20,6 +20,14 @@ export interface BakedHandlerOptions {
|
|
|
20
20
|
bodyTimeoutMs?: number
|
|
21
21
|
/** Fallback handler used for write error paths. Defaults to the write ingress. */
|
|
22
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
|
|
23
31
|
/**
|
|
24
32
|
* When true, route the JSON-validity fallback check through the higher-order
|
|
25
33
|
* loader (`loader("jsonValid")`) instead of the direct native call —
|
|
@@ -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 { buildSuccessInit, resolveIp, type BakedHandlerOptions } from './common'
|
|
6
|
-
import { DEFAULT_MAX_BODY_BYTES, DEFAULT_BODY_TIMEOUT_MS } 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)
|
|
@@ -115,7 +118,7 @@ export function jsonWriteHandler(
|
|
|
115
118
|
ctx,
|
|
116
119
|
)
|
|
117
120
|
}
|
|
118
|
-
} else if (!
|
|
121
|
+
} else if (!jsonValid(bodyBytes)) {
|
|
119
122
|
return ingress.errorResponse(req, result, 400, 'invalid_json', 'Invalid JSON body', ctx)
|
|
120
123
|
}
|
|
121
124
|
|