castrum 0.9.1 → 0.9.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/castrum.linux-x64-gnu.node +0 -0
- package/dist/index.d.ts +239 -5
- package/dist/index.js +3852 -3375
- package/docs/ARCHITECTURE.md +11 -9
- package/docs/BENCHMARKS.md +70 -38
- package/docs/CASE_STUDY.md +10 -7
- package/docs/ENVIRONMENT.md +16 -1
- package/docs/FFI_BUN_GUIDE.md +18 -10
- package/docs/GETTING_STARTED.md +5 -4
- package/docs/HOW_TO_ADD_AN_OP.md +180 -0
- package/docs/INGRESS-ROUTER.md +3 -2
- package/docs/INGRESS.md +6 -3
- package/docs/NATIVE-ROUTE.md +28 -4
- package/docs/REPO_MAP.md +60 -30
- package/docs/adr/0001-two-wire-formats.md +1 -1
- package/docs/ai/LOCAL_DEV.md +81 -0
- package/docs/ai/TREE.md +1070 -0
- package/package.json +24 -23
- package/src/ingress/decode/result-base.ts +10 -2
- package/src/ingress/handlers.ts +45 -290
- package/src/ingress/headers/security.ts +41 -0
- package/src/ingress/index.ts +12 -148
- package/src/ingress/native-route.ts +182 -0
- package/src/ingress/packing/gather-raw-headers.ts +26 -2
- package/src/ingress/packing/route-wire.ts +197 -0
- package/src/ingress/packing/select-headers.ts +8 -1
- package/src/ingress/path-matcher.ts +115 -0
- package/src/ingress/response/baked-response.ts +302 -0
- package/src/ingress/router.ts +42 -2
- package/src/ingress/routes/common.ts +9 -1
- package/src/ingress/routes/json-write.ts +21 -18
- package/src/ingress/routes/native.ts +130 -0
- package/src/ingress/server-node.ts +1 -1
- package/src/ingress/server.ts +38 -99
- package/src/ingress/sync.ts +151 -0
- package/src/ingress/types.ts +9 -0
- package/src/native/ffi/build/codecs.ts +736 -0
- package/src/native/ffi/build/compress.ts +248 -0
- package/src/native/ffi/build/instances.ts +598 -0
- package/src/native/ffi/build/parse.ts +539 -0
- package/src/native/ffi/build/util.ts +140 -0
- package/src/native/ffi/build.ts +94 -0
- package/src/native/ffi/selftest.ts +11 -718
- package/src/native/ffi/types.ts +25 -0
- package/src/native/ffi.ts +36 -1320
- package/src/native/types/instances.ts +5 -0
- package/src/rust-ffi/packed.ts +38 -0
- package/src/rust-ffi/scalar/factories.ts +4 -0
|
@@ -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
|
+
}
|
|
@@ -20,10 +20,10 @@ import {
|
|
|
20
20
|
type ServerResponse,
|
|
21
21
|
} from 'node:http'
|
|
22
22
|
import { type Duplex, Readable } from 'node:stream'
|
|
23
|
+
import { buildPathMatcher } from './path-matcher'
|
|
23
24
|
import { fallbackHandler } from './routes'
|
|
24
25
|
import {
|
|
25
26
|
type BakedServer,
|
|
26
|
-
buildPathMatcher,
|
|
27
27
|
buildRouteHandlers,
|
|
28
28
|
type CreateIngressServerOptions,
|
|
29
29
|
type RouteHandler,
|
package/src/ingress/server.ts
CHANGED
|
@@ -11,7 +11,10 @@ import {
|
|
|
11
11
|
readHandler,
|
|
12
12
|
} from './routes'
|
|
13
13
|
import type { BakedHandlerOptions } from './routes/common'
|
|
14
|
+
import { nativeRouteHandler } from './routes/native'
|
|
14
15
|
import { nativeResponderRoute } from './routes/responder'
|
|
16
|
+
import { createNativeRoute } from './native-route'
|
|
17
|
+
import type { NativeRoutePlan } from './native-route'
|
|
15
18
|
import type { NativeResponder, OptimizedIngressHandler, TerminalStyle } from './types'
|
|
16
19
|
|
|
17
20
|
/** Server-level default for the socket request-body cap (16 MiB). */
|
|
@@ -64,6 +67,22 @@ export interface BakedRoute {
|
|
|
64
67
|
/** Read the body for native validation (default false — framework owns it). */
|
|
65
68
|
readBody?: boolean
|
|
66
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* A LEAN native-stack responder route: the route-wire v3 per-route stack
|
|
72
|
+
* (`createNativeRoute`) runs ONLY the plan's stages in ONE native call —
|
|
73
|
+
* no CORS/rate-limit/security/IP/metadata envelope. Wired for `methods`
|
|
74
|
+
* (default GET) by `nativeRouteHandler` (src/ingress/routes/native.ts).
|
|
75
|
+
*/
|
|
76
|
+
native?: {
|
|
77
|
+
/** The route-wire v3 plan (parse/validate stages + limits). */
|
|
78
|
+
plan: NativeRoutePlan
|
|
79
|
+
/** The JS 2xx builder. */
|
|
80
|
+
handler: NativeResponder
|
|
81
|
+
/** HTTP methods to wire (default `['GET']`). */
|
|
82
|
+
methods?: ReadonlyArray<string>
|
|
83
|
+
/** Read the body for `requireJsonBody`/`validateBody` (default false). */
|
|
84
|
+
readBody?: boolean
|
|
85
|
+
}
|
|
67
86
|
/** Overrides `maxBodyBytes` for this route's write/echo handlers. */
|
|
68
87
|
maxBodyBytes?: number
|
|
69
88
|
/**
|
|
@@ -281,6 +300,20 @@ export function buildRouteHandlers(options: BuildRouteHandlersOptions): {
|
|
|
281
300
|
}
|
|
282
301
|
}
|
|
283
302
|
|
|
303
|
+
if (spec.native) {
|
|
304
|
+
// LEAN native-stack responder route: route-wire v3 stack, no envelope.
|
|
305
|
+
// The compiled route is injected into the pure route factory (DI across
|
|
306
|
+
// the purity boundary — the compile touches the dlopen layer here).
|
|
307
|
+
const nativeRoute = nativeRouteHandler(createNativeRoute(spec.native.plan), spec.native.handler, {
|
|
308
|
+
...routeOpts,
|
|
309
|
+
readBody: spec.native.readBody,
|
|
310
|
+
})
|
|
311
|
+
const nativeMethods = spec.native.methods ?? ['GET']
|
|
312
|
+
for (const m of nativeMethods) {
|
|
313
|
+
methods[m] = nativeRoute
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
284
317
|
// CORS preflight (OPTIONS) is served for EVERY route with a NATIVE handler
|
|
285
318
|
// (not just write routes with a fallback), so read-only routes also answer
|
|
286
319
|
// preflights with 204/403 from the native pipeline. Raw probe handlers
|
|
@@ -301,106 +334,12 @@ export function buildRouteHandlers(options: BuildRouteHandlersOptions): {
|
|
|
301
334
|
return { routes: serverRoutes, baseOpts }
|
|
302
335
|
}
|
|
303
336
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
}
|
|
337
|
+
// ── Path matching (shared with server-node.ts + router.ts) ─────────
|
|
338
|
+
// `buildPathMatcher` / `PathMatch` / `safeDecode` live in path-matcher.ts;
|
|
339
|
+
// re-exported here for back-compat (they were historically exported from this
|
|
340
|
+
// module).
|
|
309
341
|
|
|
310
|
-
|
|
311
|
-
re: RegExp
|
|
312
|
-
paramNames: string[]
|
|
313
|
-
rest: boolean
|
|
314
|
-
methods: Record<string, unknown>
|
|
315
|
-
/** Number of literal (static) segments — used to prefer the most specific pattern. */
|
|
316
|
-
staticSegments: number
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
/**
|
|
320
|
-
* Build a path matcher over a route map, supporting `:param` and `*` (rest)
|
|
321
|
-
* segments. Exact (static) paths always win over dynamic patterns; dynamic
|
|
322
|
-
* patterns are ordered most-specific-first (most static segments, then fewest
|
|
323
|
-
* params). Used by `createIngressServerNode` (Bun's native router already
|
|
324
|
-
* matches dynamic patterns itself). The ingress pipeline does not echo path
|
|
325
|
-
* params in its response, so matching only selects the handler.
|
|
326
|
-
*/
|
|
327
|
-
export function buildPathMatcher(
|
|
328
|
-
routes: Record<string, Record<string, unknown>>,
|
|
329
|
-
): (pathname: string) => PathMatch | undefined {
|
|
330
|
-
const exact = new Map<string, Record<string, unknown>>()
|
|
331
|
-
const dynamic: CompiledDynamicPattern[] = []
|
|
332
|
-
|
|
333
|
-
for (const [path, methods] of Object.entries(routes)) {
|
|
334
|
-
const hasParam = path.indexOf(':') !== -1
|
|
335
|
-
const hasRest = path.indexOf('*') !== -1
|
|
336
|
-
if (!hasParam && !hasRest) {
|
|
337
|
-
exact.set(path, methods)
|
|
338
|
-
continue
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
const paramNames: string[] = []
|
|
342
|
-
let rest = false
|
|
343
|
-
let staticSegments = 0
|
|
344
|
-
const parts = path.split('/').filter((s) => s.length > 0)
|
|
345
|
-
const source = parts
|
|
346
|
-
.map((seg) => {
|
|
347
|
-
if (seg === '*') {
|
|
348
|
-
rest = true
|
|
349
|
-
return '(.*)'
|
|
350
|
-
}
|
|
351
|
-
if (seg.startsWith(':')) {
|
|
352
|
-
paramNames.push(seg.slice(1))
|
|
353
|
-
return '([^/]+)'
|
|
354
|
-
}
|
|
355
|
-
staticSegments++
|
|
356
|
-
return seg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
357
|
-
})
|
|
358
|
-
.join('/')
|
|
359
|
-
|
|
360
|
-
dynamic.push({
|
|
361
|
-
re: new RegExp(`^/${source}${rest ? '' : '/?$'}`),
|
|
362
|
-
paramNames,
|
|
363
|
-
rest,
|
|
364
|
-
methods,
|
|
365
|
-
staticSegments,
|
|
366
|
-
})
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
dynamic.sort(
|
|
370
|
-
(a, b) => b.staticSegments - a.staticSegments || a.paramNames.length - b.paramNames.length,
|
|
371
|
-
)
|
|
372
|
-
|
|
373
|
-
return (pathname: string) => {
|
|
374
|
-
const exactHit = exact.get(pathname)
|
|
375
|
-
if (exactHit !== undefined) {
|
|
376
|
-
return { methods: exactHit, params: {} }
|
|
377
|
-
}
|
|
378
|
-
for (const d of dynamic) {
|
|
379
|
-
const m = d.re.exec(pathname)
|
|
380
|
-
if (m !== null) {
|
|
381
|
-
const params: Record<string, string> = {}
|
|
382
|
-
for (let i = 0; i < d.paramNames.length; i++) {
|
|
383
|
-
const raw = m[i + 1]
|
|
384
|
-
if (raw !== undefined) params[d.paramNames[i] ?? ''] = safeDecode(raw)
|
|
385
|
-
}
|
|
386
|
-
if (d.rest) {
|
|
387
|
-
const restRaw = m[d.paramNames.length + 1]
|
|
388
|
-
if (restRaw !== undefined) params['*'] = safeDecode(restRaw)
|
|
389
|
-
}
|
|
390
|
-
return { methods: d.methods, params }
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
return undefined
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
function safeDecode(value: string): string {
|
|
398
|
-
try {
|
|
399
|
-
return decodeURIComponent(value)
|
|
400
|
-
} catch {
|
|
401
|
-
return value
|
|
402
|
-
}
|
|
403
|
-
}
|
|
342
|
+
export { buildPathMatcher, type PathMatch, safeDecode } from './path-matcher'
|
|
404
343
|
|
|
405
344
|
/** Typed options forwarded to `Bun.serve`. */
|
|
406
345
|
interface BunServerOptions {
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// src/ingress/sync.ts — Sync + async convenience factories (path 1 wrappers)
|
|
2
|
+
//
|
|
3
|
+
// `createIngressSync` and `createIngress` wrap `createIngressFast` (path 1)
|
|
4
|
+
// with the synchronous-callback guard / automatic body reading + error
|
|
5
|
+
// contexts. Kept OUT of index.ts so the public barrel stays a pure re-export
|
|
6
|
+
// hub. Runtime/server selection lives in src/runtime/server.ts — NOT here.
|
|
7
|
+
|
|
8
|
+
import { decoder } from '../shared/bytes'
|
|
9
|
+
import { generateRequestId } from '../shared/request-id'
|
|
10
|
+
import { readRequestBodyOnce } from './body'
|
|
11
|
+
import { ERR_CODE_BODY_TOO_LARGE, ERR_CODE_REQUEST_TIMEOUT } from './constants'
|
|
12
|
+
import { internalContext, snapshotResult, syntheticContext } from './context'
|
|
13
|
+
import { createIngressFast } from './fast'
|
|
14
|
+
import { buildResponseContext } from './headers/fast-templates'
|
|
15
|
+
import { buildTerminalResponse } from './response/terminal'
|
|
16
|
+
import { assertSyncCallback, DEFAULT_BODY_TIMEOUT_MS, DEFAULT_MAX_BODY_BYTES } from './shared'
|
|
17
|
+
import type { IngressContext, IngressHandler, IngressOptions, SyncIngressHandler } from './types'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Synchronous convenience wrapper over `createIngressFast` (path 1).
|
|
21
|
+
*
|
|
22
|
+
* The callback runs on the fast-path result and MUST be synchronous. Use
|
|
23
|
+
* {@link createIngress} when you want automatic body reading + error contexts.
|
|
24
|
+
*
|
|
25
|
+
* @param options fast-path ingress options
|
|
26
|
+
* @returns a sync handler (callback must be synchronous)
|
|
27
|
+
*/
|
|
28
|
+
export function createIngressSync(options: IngressOptions = {}): SyncIngressHandler {
|
|
29
|
+
const fast = createIngressFast(options)
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
run(req, ip, body, requestId, fn) {
|
|
33
|
+
return fast.run(req, ip, body, requestId, (result) => {
|
|
34
|
+
const out = fn(result)
|
|
35
|
+
assertSyncCallback(out, 'createIngressSync().run()')
|
|
36
|
+
return out
|
|
37
|
+
})
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Return a copy of `options` without the security-header keys.
|
|
44
|
+
*
|
|
45
|
+
* `createIngress` applies security headers itself (via `buildResponseContext`),
|
|
46
|
+
* so they must not be forwarded to the underlying fast handler, which rejects
|
|
47
|
+
* them (see `createIngressFast`).
|
|
48
|
+
*/
|
|
49
|
+
function stripSecurityOptions(options: IngressOptions): IngressOptions {
|
|
50
|
+
const copy = { ...options }
|
|
51
|
+
delete copy.security
|
|
52
|
+
delete copy.enableSecurityHeaders
|
|
53
|
+
return copy
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Async ingress factory: reads the request body (size guard + deadline) and
|
|
58
|
+
* returns a snapshot `IngressContext` with a terminal `Response`.
|
|
59
|
+
*
|
|
60
|
+
* Recommended default for serverless/edge-style `fetch` handlers. For maximum
|
|
61
|
+
* throughput use `createIngressFast` / `createIngressSync` directly.
|
|
62
|
+
*
|
|
63
|
+
* @param options fast-path ingress options (security applied here, not forwarded)
|
|
64
|
+
* @returns an async handler returning a snapshot context
|
|
65
|
+
*/
|
|
66
|
+
export function createIngress(options: IngressOptions = {}): IngressHandler {
|
|
67
|
+
const sync = createIngressSync(stripSecurityOptions(options))
|
|
68
|
+
|
|
69
|
+
const guard = options.enableBodySizeGuard !== false
|
|
70
|
+
const max = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES
|
|
71
|
+
const bodyTimeoutMs = options.bodyTimeoutMs ?? DEFAULT_BODY_TIMEOUT_MS
|
|
72
|
+
|
|
73
|
+
const wantsBody =
|
|
74
|
+
options.readBody === true ||
|
|
75
|
+
options.requireJsonBody === true ||
|
|
76
|
+
options.schema != null ||
|
|
77
|
+
(options.readBody !== false && guard)
|
|
78
|
+
|
|
79
|
+
const responseCtx = buildResponseContext(options)
|
|
80
|
+
|
|
81
|
+
return async function ingressAsync(req: Request, ip?: string): Promise<IngressContext> {
|
|
82
|
+
const requestId = options.enableRequestIds === false ? '' : decoder.decode(generateRequestId())
|
|
83
|
+
options.onRequest?.(req, requestId, ip)
|
|
84
|
+
|
|
85
|
+
// Every terminal outcome (success, 413, 408, 500) flows through here so a
|
|
86
|
+
// hook consumer sees exactly one onResponse per request.
|
|
87
|
+
const emitResponse = (context: IngressContext): IngressContext => {
|
|
88
|
+
options.onResponse?.(req, context, context.status, requestId)
|
|
89
|
+
return context
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
if (guard) {
|
|
94
|
+
const rawLen = req.headers.get('content-length')
|
|
95
|
+
const contentLength = Number(rawLen ?? '0')
|
|
96
|
+
|
|
97
|
+
if (Number.isFinite(contentLength) && contentLength > max) {
|
|
98
|
+
return emitResponse(
|
|
99
|
+
syntheticContext(req, requestId, options, responseCtx, 413, ERR_CODE_BODY_TOO_LARGE),
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let body: Uint8Array | null = null
|
|
105
|
+
|
|
106
|
+
if (wantsBody && req.body !== null) {
|
|
107
|
+
try {
|
|
108
|
+
body = await readRequestBodyOnce(req, max, guard, bodyTimeoutMs)
|
|
109
|
+
} catch (err) {
|
|
110
|
+
const code = (err as { code?: string } | null)?.code
|
|
111
|
+
|
|
112
|
+
if (code === 'BODY_TOO_LARGE') {
|
|
113
|
+
return emitResponse(
|
|
114
|
+
syntheticContext(req, requestId, options, responseCtx, 413, ERR_CODE_BODY_TOO_LARGE),
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (code === 'REQUEST_TIMEOUT') {
|
|
119
|
+
// Match the pre-baked route factories: a body-read deadline is a
|
|
120
|
+
// 408, not a 500 (was silently swallowed as 500 before).
|
|
121
|
+
return emitResponse(
|
|
122
|
+
syntheticContext(req, requestId, options, responseCtx, 408, ERR_CODE_REQUEST_TIMEOUT),
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Unknown body-read failure: surface to onError, then fall through
|
|
127
|
+
// to the internal-error context below.
|
|
128
|
+
options.onError?.(err instanceof Error ? err : new Error(String(err)))
|
|
129
|
+
throw err
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return emitResponse(
|
|
134
|
+
sync.run(req, ip, body, requestId, (r) => {
|
|
135
|
+
const snapshot = snapshotResult(r)
|
|
136
|
+
const response = buildTerminalResponse(responseCtx, snapshot, req, requestId)
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
...snapshot,
|
|
140
|
+
response,
|
|
141
|
+
}
|
|
142
|
+
}),
|
|
143
|
+
)
|
|
144
|
+
} catch (err) {
|
|
145
|
+
// Native/other failures: never silent — report to onError and emit a
|
|
146
|
+
// structured 500 context so the request still completes observably.
|
|
147
|
+
options.onError?.(err instanceof Error ? err : new Error(String(err)))
|
|
148
|
+
return emitResponse(internalContext(req, requestId, options, responseCtx))
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
package/src/ingress/types.ts
CHANGED
|
@@ -147,6 +147,15 @@ export interface OptimizedIngressHandler {
|
|
|
147
147
|
|
|
148
148
|
withContentType(headers: ReadonlyArray<[string, string]>, contentType: string): [string, string][]
|
|
149
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Zero-DOM JSON-validity check bound to this handler's native transport
|
|
152
|
+
* (bun:ffi primary, napi fallback). Provided so the pure route factories
|
|
153
|
+
* (`routes/*`) can validate request bodies via dependency injection without
|
|
154
|
+
* importing the native layer. Always present on handlers from
|
|
155
|
+
* `createIngressHandler`; optional so hand-rolled mocks need not provide it.
|
|
156
|
+
*/
|
|
157
|
+
jsonValid?: (bytes: Uint8Array) => boolean
|
|
158
|
+
|
|
150
159
|
/**
|
|
151
160
|
* Build a zero-copy `Response` whose body is the request's pooled output
|
|
152
161
|
* slice. The pooled buffer is returned to its pool once the body has been
|