castrum 0.9.8 → 0.9.10
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/Cargo.lock +11 -1
- package/Cargo.toml +17 -3
- package/castrum.darwin-arm64.node +0 -0
- package/castrum.darwin-x64.node +0 -0
- package/castrum.linux-arm64-gnu.node +0 -0
- package/castrum.linux-arm64-musl.node +0 -0
- package/castrum.linux-x64-gnu.node +0 -0
- package/castrum.linux-x64-musl.node +0 -0
- package/castrum.linux-x64-v3-gnu.node +0 -0
- package/castrum.win32-x64-msvc.node +0 -0
- package/dist/index.d.ts +164 -4
- package/dist/index.js +434 -70
- package/docs/ENVIRONMENT.md +7 -0
- package/docs/RND-CONCURRENCY.md +20 -4
- package/docs/bun-builtins-decision-matrix.md +17 -7
- package/docs/superpowers/plans/2026-09-18-elysia-learnings-adoption.md +634 -0
- package/index.ts +5 -0
- package/package.json +2 -1
- package/rust/crypto/base64.rs +472 -57
- package/rust/crypto/hashing.rs +68 -0
- package/rust/ffi/task.rs +19 -18
- package/rust/ingress/api.rs +21 -5
- package/rust/ingress/mod.rs +7 -0
- package/rust/ingress/native_route.rs +4 -2
- package/rust/ingress/schema_cache.rs +266 -0
- package/rust/payload/sse.rs +78 -5
- package/rust/task/mod.rs +1 -0
- package/rust/task/runtime.rs +55 -7
- package/rust/task/tests.rs +98 -1
- package/rust/util/text.rs +112 -10
- package/rust/util/validation.rs +34 -0
- package/src/ingress/abort.ts +48 -0
- package/src/ingress/body.ts +104 -8
- package/src/ingress/handlers.ts +1 -0
- package/src/ingress/health.ts +22 -0
- package/src/ingress/index.ts +3 -1
- package/src/ingress/router.ts +36 -1
- package/src/ingress/routes/common.ts +9 -1
- package/src/ingress/routes/echo.ts +10 -0
- package/src/ingress/routes/json-write.ts +11 -0
- package/src/ingress/routes/native.ts +24 -5
- package/src/ingress/routes/options.ts +6 -2
- package/src/ingress/routes/responder.ts +25 -5
- package/src/ingress/routes/safe-record.ts +57 -0
- package/src/ingress/server-error.ts +111 -0
- package/src/ingress/server-node.ts +102 -23
- package/src/ingress/server.ts +140 -32
- package/src/integration/pipeline.ts +6 -0
- package/src/native/ffi/build/task.ts +2 -1
- package/src/native/ffi/types.ts +6 -1
- package/src/native/types.ts +6 -0
- package/src/runtime/detect.ts +14 -0
- package/src/rust-ffi/context.ts +46 -3
- package/src/rust-ffi/scalar/factories.ts +6 -0
- package/src/rust-ffi/scalar/interface.ts +14 -0
- package/src/rust-ffi/text.ts +5 -4
- package/src/shared/buffer-pool.ts +10 -0
- package/src/shared/memory.ts +68 -0
- package/src/shared/metrics.ts +86 -3
- package/src/shared/runtime.ts +7 -0
- package/src/task/runtime.ts +52 -9
package/src/ingress/health.ts
CHANGED
|
@@ -11,6 +11,11 @@
|
|
|
11
11
|
// "/livez": { read: healthHandler() },
|
|
12
12
|
// }});
|
|
13
13
|
//
|
|
14
|
+
// For probes that are truly constant, prefer static promotion — it skips the
|
|
15
|
+
// JS ingress pipeline entirely:
|
|
16
|
+
//
|
|
17
|
+
// createIngressServer({ routes: { "/livez": { static: staticLiveness() } } });
|
|
18
|
+
//
|
|
14
19
|
// The liveness probe NEVER touches dependencies (process alive? -> 200).
|
|
15
20
|
// The readiness probe runs the optional `check` (deps up? -> 200/503).
|
|
16
21
|
// The health probe is an alias of liveness (process up) plus optional check.
|
|
@@ -36,6 +41,23 @@ export function livenessHandler() {
|
|
|
36
41
|
return (_req: Request): Response => jsonResponse(200, { status: 'ok' })
|
|
37
42
|
}
|
|
38
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Liveness probe as a PREBUILT `Response`, for static route promotion.
|
|
46
|
+
*
|
|
47
|
+
* Wire it as `{ static: staticLiveness() }` so `Bun.serve` snapshots the
|
|
48
|
+
* constant body into its native route table and serves `/livez` without
|
|
49
|
+
* entering the JavaScript ingress pipeline at all (see `BakedRoute.static` in
|
|
50
|
+
* src/ingress/server.ts). On the node:http adapter the bare `Response` is
|
|
51
|
+
* re-materialized per request from cached body bytes, so the one-shot body is
|
|
52
|
+
* never replayed. Unlike {@link readinessHandler}, it never runs a dependency
|
|
53
|
+
* check.
|
|
54
|
+
*
|
|
55
|
+
* @returns A fresh 200 `{"status":"ok"}` `Response`.
|
|
56
|
+
*/
|
|
57
|
+
export function staticLiveness(): Response {
|
|
58
|
+
return jsonResponse(200, { status: 'ok' })
|
|
59
|
+
}
|
|
60
|
+
|
|
39
61
|
/**
|
|
40
62
|
* Readiness probe: runs the optional async `check` (dependency readiness).
|
|
41
63
|
* Returns `200 {"status":"ready"}` when the check passes (or no check is
|
package/src/ingress/index.ts
CHANGED
|
@@ -22,6 +22,8 @@ export {
|
|
|
22
22
|
serializeTraceParent,
|
|
23
23
|
} from '../shared/trace'
|
|
24
24
|
// ── Re-export the full public ingress API ─────────────────────────
|
|
25
|
+
// Request cancellation helpers (client-disconnect → 499).
|
|
26
|
+
export { ABORT_CODE, abortResponse, isAbortError } from './abort'
|
|
25
27
|
export * from './constants'
|
|
26
28
|
export * from './errors'
|
|
27
29
|
export { createIngressFast, FastIngressResult } from './fast'
|
|
@@ -30,7 +32,7 @@ export type { CorsOptions, CorsStaticStrings } from './headers/cors'
|
|
|
30
32
|
export type { HeaderTemplate, ResponseBuildContext } from './headers/fast-templates'
|
|
31
33
|
export { buildResponseContext, headersForResult } from './headers/fast-templates'
|
|
32
34
|
export type { SecurityHeadersOptions } from './headers/hsts'
|
|
33
|
-
export { healthHandler, livenessHandler, readinessHandler } from './health'
|
|
35
|
+
export { healthHandler, livenessHandler, readinessHandler, staticLiveness } from './health'
|
|
34
36
|
export type { IngressMetrics } from './metrics'
|
|
35
37
|
// ── Observability (zero-dep): metrics, health probes ─────────────
|
|
36
38
|
export { createIngressMetrics, metricsHandler } from './metrics'
|
package/src/ingress/router.ts
CHANGED
|
@@ -27,7 +27,7 @@ import type { NativeRoutePlan } from './native-route'
|
|
|
27
27
|
import { type IngressHandlerOptions, warnRateLimitWithoutGetIp } from './options'
|
|
28
28
|
import type { PathMatch } from './path-matcher'
|
|
29
29
|
import { buildPathMatcher } from './path-matcher'
|
|
30
|
-
import type { BakedRoute, RouteHandler } from './server'
|
|
30
|
+
import type { BakedRoute, RouteHandler, StaticRoute } from './server'
|
|
31
31
|
import { buildRouteHandlers } from './server'
|
|
32
32
|
import type { BakedContext, NativeResponder, OptimizedIngressHandler, TerminalStyle } from './types'
|
|
33
33
|
|
|
@@ -88,6 +88,20 @@ export interface RouterRouteSpec {
|
|
|
88
88
|
/** Terminal envelope style for this route's responder (default: router-level
|
|
89
89
|
* `terminalStyle` or `'castrum'`). */
|
|
90
90
|
terminalStyle?: TerminalStyle
|
|
91
|
+
/**
|
|
92
|
+
* A prebuilt constant response served directly for GET, OUTSIDE the ingress
|
|
93
|
+
* pipeline (static route promotion). Like `raw`, it OWNS THE WHOLE SPEC: the
|
|
94
|
+
* router compiles no `IngressInner` for a `static` route, so `read`/`write`/
|
|
95
|
+
* `echo`/etc. on the same spec are ignored.
|
|
96
|
+
*
|
|
97
|
+
* This differs from `BakedRoute.static` in src/ingress/server.ts, where
|
|
98
|
+
* `static` only takes GET precedence and leaves `write`/`echo`/`delete`
|
|
99
|
+
* wired — the router cannot mirror that because a `static`-only spec has no
|
|
100
|
+
* compiled ingress to hand those methods. Do not unify the two semantics.
|
|
101
|
+
*
|
|
102
|
+
* See `BakedRoute.static` for the one-shot-body contract.
|
|
103
|
+
*/
|
|
104
|
+
static?: StaticRoute
|
|
91
105
|
/**
|
|
92
106
|
* A raw request→Response handler served directly for GET, OUTSIDE the
|
|
93
107
|
* ingress pipeline (health/metrics probes, /metrics). When set, `read`/
|
|
@@ -175,6 +189,17 @@ export function createIngressRouter(options: CreateIngressRouterOptions): Ingres
|
|
|
175
189
|
if (options.copyBody !== undefined) baseOpts.copyBody = options.copyBody
|
|
176
190
|
|
|
177
191
|
for (const [path, spec] of Object.entries(options.routes)) {
|
|
192
|
+
if (spec.static !== undefined) {
|
|
193
|
+
// Static promotion: a prebuilt constant response owns the route. Placed
|
|
194
|
+
// verbatim so Bun.serve can serve it from its native table (a bare
|
|
195
|
+
// Response) with zero JS.
|
|
196
|
+
bakedRoutes[path] = {
|
|
197
|
+
static: spec.static,
|
|
198
|
+
maxBodyBytes: spec.maxBodyBytes,
|
|
199
|
+
bodyTimeoutMs: spec.bodyTimeoutMs,
|
|
200
|
+
}
|
|
201
|
+
continue
|
|
202
|
+
}
|
|
178
203
|
if (spec.raw) {
|
|
179
204
|
// Raw handler: served directly, outside the pipeline.
|
|
180
205
|
bakedRoutes[path] = { read: spec.raw as RouteHandler }
|
|
@@ -277,6 +302,16 @@ export function createIngressRouter(options: CreateIngressRouterOptions): Ingres
|
|
|
277
302
|
if (typeof methodHandler === 'function') {
|
|
278
303
|
return (methodHandler as RouteHandler)(req, srv, matched.params)
|
|
279
304
|
}
|
|
305
|
+
if (methodHandler instanceof Response) {
|
|
306
|
+
// Static route (bare Response value). `instanceof Response` assumes the
|
|
307
|
+
// value was built with the global `Response` constructor (the one this
|
|
308
|
+
// router also uses). `clone()` tees the body without consuming the
|
|
309
|
+
// original, so every request gets a fresh, complete body. A `static`
|
|
310
|
+
// factory is a function and is handled above. The cast bridges Bun's
|
|
311
|
+
// `Response.clone()` return type (which resolves against the bundled
|
|
312
|
+
// fetch types) to this module's global `Response`.
|
|
313
|
+
return methodHandler.clone() as unknown as Response
|
|
314
|
+
}
|
|
280
315
|
// A route matched but this method isn't wired — 405, or a bare OPTIONS 204
|
|
281
316
|
// when the route answers preflights.
|
|
282
317
|
if (req.method === 'OPTIONS' && typeof matched.methods.OPTIONS === 'function') {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// src/ingress/routes/common.ts — Shared route-handler options + helpers.
|
|
2
2
|
|
|
3
|
+
import { abortResponse } from '../abort'
|
|
3
4
|
import type { BakedIngressResult } from '../decode/baked-result'
|
|
4
5
|
import { memoizedHeaders } from '../headers/memoized-headers'
|
|
5
6
|
import { secondsFromMs } from '../shared'
|
|
@@ -93,11 +94,18 @@ export function buildSuccessInit(
|
|
|
93
94
|
* runner: resolve the client IP once and dispatch through the native pipeline
|
|
94
95
|
* (`ingress.run` with a null body). Used by the read / head / fallback route
|
|
95
96
|
* factories whose responder never needs the request object.
|
|
97
|
+
*
|
|
98
|
+
* A disconnected client (`req.signal?.aborted`) short-circuits to the 499
|
|
99
|
+
* cancelled response BEFORE `ingress.run`, so no pooled output buffer is
|
|
100
|
+
* acquired for a request that can no longer be delivered.
|
|
96
101
|
*/
|
|
97
102
|
export function runBaked(
|
|
98
103
|
ingress: OptimizedIngressHandler,
|
|
99
104
|
opts: BakedHandlerOptions,
|
|
100
105
|
respond: (result: BakedIngressResult, ctx: BakedContext) => Response,
|
|
101
106
|
): (req: Request, srv?: unknown) => Response {
|
|
102
|
-
return (req, srv) =>
|
|
107
|
+
return (req, srv) => {
|
|
108
|
+
if (req.signal?.aborted) return abortResponse()
|
|
109
|
+
return ingress.run<Response>(req, resolveIp(req, srv, opts), null, respond)
|
|
110
|
+
}
|
|
103
111
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// src/ingress/routes/echo.ts — Pre-baked echo handler.
|
|
2
2
|
|
|
3
|
+
import { abortResponse, isAbortError } from '../abort'
|
|
3
4
|
import { readBodyWithLimit } from '../body'
|
|
4
5
|
import { HV_JSON } from '../constants'
|
|
5
6
|
import { ERROR_BODIES } from '../response/error-bodies'
|
|
@@ -53,6 +54,8 @@ export function echoHandler(
|
|
|
53
54
|
return async (req, srv) => {
|
|
54
55
|
const ip = resolveIp(req, srv, opts)
|
|
55
56
|
|
|
57
|
+
if (req.signal?.aborted) return abortResponse()
|
|
58
|
+
|
|
56
59
|
const prep = ingress.run<{
|
|
57
60
|
terminal?: Response
|
|
58
61
|
headers?: ReadonlyArray<[string, string]>
|
|
@@ -79,6 +82,9 @@ export function echoHandler(
|
|
|
79
82
|
|
|
80
83
|
if (prep.terminal) return prep.terminal
|
|
81
84
|
|
|
85
|
+
// The client may have disconnected while the native prep ran.
|
|
86
|
+
if (req.signal?.aborted) return abortResponse()
|
|
87
|
+
|
|
82
88
|
const baseHeaders: ReadonlyArray<[string, string]> = prep.headers ?? []
|
|
83
89
|
|
|
84
90
|
const requestedContentType = safeEchoContentType(
|
|
@@ -125,11 +131,15 @@ export function echoHandler(
|
|
|
125
131
|
// oversized or trickling body before rejecting it.
|
|
126
132
|
const bodyBytes = await readBodyWithLimit(req, maxBodyBytes, true, bodyTimeoutMs)
|
|
127
133
|
|
|
134
|
+
// Disconnect during the read must not be answered with a body.
|
|
135
|
+
if (req.signal?.aborted) return abortResponse()
|
|
136
|
+
|
|
128
137
|
return new Response(bodyBytes.byteLength > 0 ? bodyBytes : null, {
|
|
129
138
|
status: 200,
|
|
130
139
|
headers: withEchoHeaders(requestedContentType),
|
|
131
140
|
})
|
|
132
141
|
} catch (err) {
|
|
142
|
+
if (isAbortError(err)) return abortResponse()
|
|
133
143
|
const code = (err as { code?: string } | null)?.code
|
|
134
144
|
const isTooLarge = code === 'BODY_TOO_LARGE'
|
|
135
145
|
const isTimeout = code === 'REQUEST_TIMEOUT'
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// (POST/PUT/PATCH).
|
|
3
3
|
|
|
4
4
|
import { decodeUtf8 } from '../../shared/codec'
|
|
5
|
+
import { abortResponse, isAbortError } from '../abort'
|
|
5
6
|
import { readBodyWithLimit } from '../body'
|
|
6
7
|
import { DEFAULT_BODY_TIMEOUT_MS, DEFAULT_MAX_BODY_BYTES } from '../shared'
|
|
7
8
|
import type { OptimizedIngressHandler } from '../types'
|
|
@@ -58,6 +59,10 @@ export function jsonWriteHandler(
|
|
|
58
59
|
return async (req, srv) => {
|
|
59
60
|
const ip = resolveIp(req, srv, opts)
|
|
60
61
|
|
|
62
|
+
// Client already gone: do no work (no content-type check, no body read,
|
|
63
|
+
// no native run). A disconnect DURING the body read is handled below.
|
|
64
|
+
if (req.signal?.aborted) return abortResponse()
|
|
65
|
+
|
|
61
66
|
const contentType = req.headers.get('content-type') ?? ''
|
|
62
67
|
if (!isJsonContentType(contentType)) {
|
|
63
68
|
// Always 415 for an unsupported media type. Do NOT short-circuit on the
|
|
@@ -87,6 +92,9 @@ export function jsonWriteHandler(
|
|
|
87
92
|
// never fully buffered first.
|
|
88
93
|
bodyBytes = await readBodyWithLimit(req, maxBodyBytes, true, bodyTimeoutMs)
|
|
89
94
|
} catch (err) {
|
|
95
|
+
// Client disconnect is NOT a bad request / timeout: return the cancelled
|
|
96
|
+
// response without running the native pipeline.
|
|
97
|
+
if (isAbortError(err)) return abortResponse()
|
|
90
98
|
const code = (err as { code?: string } | null)?.code
|
|
91
99
|
const isTooLarge = code === 'BODY_TOO_LARGE'
|
|
92
100
|
return fallback.run(req, ip, null, (result, ctx) => {
|
|
@@ -104,6 +112,9 @@ export function jsonWriteHandler(
|
|
|
104
112
|
})
|
|
105
113
|
}
|
|
106
114
|
|
|
115
|
+
// The client may have disconnected while the body was streaming in.
|
|
116
|
+
if (req.signal?.aborted) return abortResponse()
|
|
117
|
+
|
|
107
118
|
return ingress.run(req, ip, bodyBytes, (result, ctx) => {
|
|
108
119
|
const terminal = ingress.terminalResponse(req, result, ctx)
|
|
109
120
|
if (terminal) return terminal
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
|
|
22
22
|
import { decoder } from '../../shared/bytes'
|
|
23
23
|
import { generateRequestId } from '../../shared/request-id'
|
|
24
|
+
import { abortResponse, isAbortError } from '../abort'
|
|
24
25
|
import { readBodyWithLimit } from '../body'
|
|
25
26
|
import type { NativeRoute } from '../native-route'
|
|
26
27
|
import type { RouteWireResult } from '../packing/route-wire'
|
|
@@ -29,6 +30,7 @@ import { DEFAULT_BODY_TIMEOUT_MS, DEFAULT_MAX_BODY_BYTES } from '../shared'
|
|
|
29
30
|
import type { NativeRequestContext, NativeResponder } from '../types'
|
|
30
31
|
import type { BakedHandlerOptions } from './common'
|
|
31
32
|
import { resolveIp } from './common'
|
|
33
|
+
import { assignOwn, safeRecord } from './safe-record'
|
|
32
34
|
|
|
33
35
|
/** Options for {@link nativeRouteHandler}. */
|
|
34
36
|
export interface NativeRouteHandlerOptions extends BakedHandlerOptions {
|
|
@@ -36,11 +38,22 @@ export interface NativeRouteHandlerOptions extends BakedHandlerOptions {
|
|
|
36
38
|
readBody?: boolean
|
|
37
39
|
}
|
|
38
40
|
|
|
39
|
-
/**
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
/**
|
|
42
|
+
* Build a prototype-safe record from native packed pairs. The `+`-to-space /
|
|
43
|
+
* `%XX` decoding is done natively; keys are last-wins. Keys are written as OWN
|
|
44
|
+
* data via {@link assignOwn}: `__proto__` is defined (never routed through the
|
|
45
|
+
* inherited setter), and other keys — including `constructor` / `prototype` —
|
|
46
|
+
* are plain own shadowing properties. The record keeps `Object.prototype`.
|
|
47
|
+
*
|
|
48
|
+
* Exported for the prototype-pollution tests.
|
|
49
|
+
*
|
|
50
|
+
* @param pairs - Decoded `[key, value]` pairs from the native route stack.
|
|
51
|
+
* @returns A record with `Object.prototype` preserved (last value wins per key).
|
|
52
|
+
*/
|
|
53
|
+
export function pairsToRecord(pairs: ReadonlyArray<[string, string]>): Record<string, string> {
|
|
54
|
+
const out = safeRecord()
|
|
55
|
+
for (const [k, v] of pairs) assignOwn(out, k, v)
|
|
56
|
+
return out as Record<string, string>
|
|
44
57
|
}
|
|
45
58
|
|
|
46
59
|
/**
|
|
@@ -78,6 +91,8 @@ export function nativeRouteHandler(
|
|
|
78
91
|
|
|
79
92
|
return async (req, srv, _params) => {
|
|
80
93
|
const ip = resolveIp(req, srv, opts)
|
|
94
|
+
// Client already gone: no request-id work, body read, or native run.
|
|
95
|
+
if (req.signal?.aborted) return abortResponse()
|
|
81
96
|
const requestId = decoder.decode(generateRequestId())
|
|
82
97
|
|
|
83
98
|
let body: Uint8Array | null = null
|
|
@@ -85,10 +100,14 @@ export function nativeRouteHandler(
|
|
|
85
100
|
try {
|
|
86
101
|
body = await readBodyWithLimit(req, maxBodyBytes, true, bodyTimeoutMs)
|
|
87
102
|
} catch (err) {
|
|
103
|
+
// Client disconnect: the cancelled response, never a 400/408/413.
|
|
104
|
+
if (isAbortError(err)) return abortResponse()
|
|
88
105
|
const code = (err as Error & { code?: string }).code
|
|
89
106
|
const status = code === 'REQUEST_TIMEOUT' ? 408 : code === 'BODY_TOO_LARGE' ? 413 : 400
|
|
90
107
|
return terminal(status, 'BAD_REQUEST', 'Request body read failed')
|
|
91
108
|
}
|
|
109
|
+
// Disconnect while the body was streaming in.
|
|
110
|
+
if (req.signal?.aborted) return abortResponse()
|
|
92
111
|
}
|
|
93
112
|
|
|
94
113
|
// Extract the query substring + Cookie header (the only request inputs the
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// src/ingress/routes/options.ts — Pre-baked OPTIONS handler (CORS preflight).
|
|
2
2
|
|
|
3
|
+
import { abortResponse } from '../abort'
|
|
3
4
|
import type { OptimizedIngressHandler } from '../types'
|
|
4
5
|
import { type BakedHandlerOptions, buildSuccessInit, resolveIp } from './common'
|
|
5
6
|
|
|
@@ -16,11 +17,14 @@ export function optionsHandler(
|
|
|
16
17
|
ingress: OptimizedIngressHandler,
|
|
17
18
|
opts: BakedHandlerOptions = {},
|
|
18
19
|
): (req: Request, srv?: unknown) => Response {
|
|
19
|
-
return (req, srv) =>
|
|
20
|
-
|
|
20
|
+
return (req, srv) => {
|
|
21
|
+
// Client already gone: no pipeline run, no pooled output buffer.
|
|
22
|
+
if (req.signal?.aborted) return abortResponse()
|
|
23
|
+
return ingress.run<Response>(req, resolveIp(req, srv, opts), null, (result, ctx) => {
|
|
21
24
|
const terminal = ingress.terminalResponse(req, result, ctx)
|
|
22
25
|
if (terminal) return terminal
|
|
23
26
|
|
|
24
27
|
return new Response(null, buildSuccessInit(ingress, result, ctx, 204))
|
|
25
28
|
})
|
|
29
|
+
}
|
|
26
30
|
}
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import { decoder } from '../../shared/bytes'
|
|
18
18
|
import { generateRequestId } from '../../shared/request-id'
|
|
19
|
+
import { abortResponse, isAbortError } from '../abort'
|
|
19
20
|
import { readBodyWithLimit } from '../body'
|
|
20
21
|
import type { BakedIngressResult } from '../decode/baked-result'
|
|
21
22
|
import { errorCodeName, errorMessage } from '../errors'
|
|
@@ -68,12 +69,25 @@ function buildIgngexTerminal(result: BakedIngressResult, _ctx: BakedContext): Re
|
|
|
68
69
|
return new Response(body, { status, headers })
|
|
69
70
|
}
|
|
70
71
|
|
|
71
|
-
/**
|
|
72
|
-
|
|
72
|
+
/**
|
|
73
|
+
* Parse a `queryJson()`/`cookiesJson()` section into a record (empty on
|
|
74
|
+
* failure).
|
|
75
|
+
*
|
|
76
|
+
* Prototype-safe by construction: `JSON.parse` creates `__proto__` as an OWN
|
|
77
|
+
* enumerable data property (it never invokes the setter), so the returned
|
|
78
|
+
* object keeps `Object.prototype` and carries `__proto__` as inert own data.
|
|
79
|
+
*
|
|
80
|
+
* Exported for the prototype-pollution tests.
|
|
81
|
+
*
|
|
82
|
+
* @param json - The section JSON emitted by the native pipeline.
|
|
83
|
+
* @returns The decoded object, or an empty record for a non-object / malformed
|
|
84
|
+
* section.
|
|
85
|
+
*/
|
|
86
|
+
export function parseSection(json: string): Record<string, unknown> {
|
|
73
87
|
try {
|
|
74
|
-
const
|
|
75
|
-
return
|
|
76
|
-
? (
|
|
88
|
+
const parsed: unknown = JSON.parse(json)
|
|
89
|
+
return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
90
|
+
? (parsed as Record<string, unknown>)
|
|
77
91
|
: {}
|
|
78
92
|
} catch {
|
|
79
93
|
return {}
|
|
@@ -97,6 +111,8 @@ export function nativeResponderRoute(
|
|
|
97
111
|
|
|
98
112
|
return async (req, srv, _params) => {
|
|
99
113
|
const ip = resolveIp(req, srv, opts)
|
|
114
|
+
// Client already gone: no request-id work, body read, or native run.
|
|
115
|
+
if (req.signal?.aborted) return abortResponse()
|
|
100
116
|
// The request-id the framework surfaces (same source as the pre-baked path's
|
|
101
117
|
// `ctx.requestIdHeader`): the baked result does not carry it, so generate it
|
|
102
118
|
// here — the native pipeline uses the same rid for its internal frame.
|
|
@@ -109,6 +125,8 @@ export function nativeResponderRoute(
|
|
|
109
125
|
try {
|
|
110
126
|
body = await readBodyWithLimit(req, maxBodyBytes, true, bodyTimeoutMs)
|
|
111
127
|
} catch (err) {
|
|
128
|
+
// Client disconnect: the cancelled response, never a 400/408/413.
|
|
129
|
+
if (isAbortError(err)) return abortResponse()
|
|
112
130
|
const code = (err as Error & { code?: string }).code
|
|
113
131
|
const status = code === 'REQUEST_TIMEOUT' ? 408 : code === 'BODY_TOO_LARGE' ? 413 : 400
|
|
114
132
|
const payload = JSON.stringify({ error: 'Bad Request', status, code: 'BAD_REQUEST' })
|
|
@@ -116,6 +134,8 @@ export function nativeResponderRoute(
|
|
|
116
134
|
headers.set('content-type', 'application/json; charset=utf-8')
|
|
117
135
|
return new Response(payload, { status, headers })
|
|
118
136
|
}
|
|
137
|
+
// Disconnect while the body was streaming in.
|
|
138
|
+
if (req.signal?.aborted) return abortResponse()
|
|
119
139
|
}
|
|
120
140
|
|
|
121
141
|
let terminal: Response | null = null
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// src/ingress/routes/safe-record.ts — prototype-safe key assignment for
|
|
2
|
+
// request-derived objects.
|
|
3
|
+
//
|
|
4
|
+
// PURE: no addon and no pooled-buffer state. Request keys are attacker
|
|
5
|
+
// controlled, so a key of `__proto__` must not invoke the inherited setter on a
|
|
6
|
+
// plain object. `assignOwn` writes `__proto__` with `Object.defineProperty` (an
|
|
7
|
+
// own enumerable data property) and every other key with plain assignment,
|
|
8
|
+
// which is already safe: `constructor` / `prototype` just become own shadowing
|
|
9
|
+
// properties.
|
|
10
|
+
//
|
|
11
|
+
// Scope: this protects the RECORD itself — it keeps `Object.prototype` (so
|
|
12
|
+
// `hasOwnProperty` / `instanceof Object` still work), gains an own `__proto__`
|
|
13
|
+
// data key, and is never mutated through the setter. It does NOT protect a
|
|
14
|
+
// consumer that blindly merges the record into another object, e.g.
|
|
15
|
+
// `Object.assign({}, rec)`: the own `__proto__` key is still enumerable, so a
|
|
16
|
+
// merge target's inherited setter can fire there. Such consumers must guard
|
|
17
|
+
// their own merge.
|
|
18
|
+
|
|
19
|
+
/** A request-derived key that would invoke the inherited `__proto__` setter. */
|
|
20
|
+
const PROTO_KEY = '__proto__'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Create an empty plain record (`Object.prototype` is preserved, so
|
|
24
|
+
* `hasOwnProperty` / `instanceof Object` keep working). Safety comes from
|
|
25
|
+
* {@link assignOwn}, not from the container.
|
|
26
|
+
*
|
|
27
|
+
* @returns A fresh, empty `Record<string, unknown>`.
|
|
28
|
+
*/
|
|
29
|
+
export function safeRecord(): Record<string, unknown> {
|
|
30
|
+
return {}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Assign a request-derived key as an OWN enumerable data property.
|
|
35
|
+
*
|
|
36
|
+
* Only `__proto__` is written with `Object.defineProperty` — a plain
|
|
37
|
+
* `target[key] = value` would invoke the inherited `__proto__` setter and
|
|
38
|
+
* mutate the prototype. Every other key (including `constructor` /
|
|
39
|
+
* `prototype`) uses plain assignment: faster, and it merely shadows on the
|
|
40
|
+
* object.
|
|
41
|
+
*
|
|
42
|
+
* @param target - Record to write into (normally from {@link safeRecord}).
|
|
43
|
+
* @param key - Request-derived key (may be `__proto__` / `constructor`).
|
|
44
|
+
* @param value - Request-derived value.
|
|
45
|
+
*/
|
|
46
|
+
export function assignOwn(target: Record<string, unknown>, key: string, value: unknown): void {
|
|
47
|
+
if (key === PROTO_KEY) {
|
|
48
|
+
Object.defineProperty(target, key, {
|
|
49
|
+
value,
|
|
50
|
+
enumerable: true,
|
|
51
|
+
writable: true,
|
|
52
|
+
configurable: true,
|
|
53
|
+
})
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
target[key] = value
|
|
57
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// src/ingress/server-error.ts — request-handler fault containment.
|
|
2
|
+
//
|
|
3
|
+
// User-supplied route handlers (a raw `spec.read`, a `responder`, a `native`
|
|
4
|
+
// responder) run OUTSIDE the native pipeline. On Bun a sync throw / async
|
|
5
|
+
// rejection would otherwise escape to Bun's default handler (which is not
|
|
6
|
+
// castrum-controlled and may surface stack/internal detail); on Node it hit a
|
|
7
|
+
// local generic-500 construction. This module provides ONE masked-500 responder
|
|
8
|
+
// shared by both runtimes plus a guard that contains sync throws and async
|
|
9
|
+
// rejections. The response body is the pre-encoded internal error body — the
|
|
10
|
+
// thrown message/stack is NEVER placed on the wire (info-leak rule).
|
|
11
|
+
|
|
12
|
+
import { memoizedHeaders } from './headers/memoized-headers'
|
|
13
|
+
import { ERROR_BODIES } from './response/error-bodies'
|
|
14
|
+
import type { RouteHandler } from './server'
|
|
15
|
+
|
|
16
|
+
/** Options for {@link createServerErrorHandler}. */
|
|
17
|
+
export interface ServerErrorOptions {
|
|
18
|
+
/**
|
|
19
|
+
* Observability hook for an escaped handler error. Receives the real `Error`
|
|
20
|
+
* (unmasked — server-side only). Never throws: a failing hook is swallowed so
|
|
21
|
+
* it cannot break the masked response.
|
|
22
|
+
*/
|
|
23
|
+
onError?: (info: { error: Error; request?: Request }) => void
|
|
24
|
+
/**
|
|
25
|
+
* Structured-log sink: one JSON line per escaped error (message only, no
|
|
26
|
+
* stack, no response leak). Never throws.
|
|
27
|
+
*/
|
|
28
|
+
logger?: (line: string) => void
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Coerce an unknown thrown value to an `Error` for the hook/logger. */
|
|
32
|
+
function toError(value: unknown): Error {
|
|
33
|
+
return value instanceof Error ? value : new Error(String(value))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const INTERNAL_HEADERS: readonly [string, string][] = [
|
|
37
|
+
['content-type', 'application/json; charset=utf-8'],
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Build a masked-500 responder. The returned function never throws, always
|
|
42
|
+
* answers with the pre-encoded internal error body, and runs the optional
|
|
43
|
+
* `onError`/`logger` hooks in a contained way.
|
|
44
|
+
*
|
|
45
|
+
* @param opts - Optional observability hooks.
|
|
46
|
+
* @returns A `(error, req?) => Response` responder for escaped handler errors.
|
|
47
|
+
* @example
|
|
48
|
+
* ```ts
|
|
49
|
+
* const onServerError = createServerErrorHandler({ onError: console.error })
|
|
50
|
+
* onServerError(new Error('boom')) // Response(500)
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
export function createServerErrorHandler(
|
|
54
|
+
opts: ServerErrorOptions,
|
|
55
|
+
): (error: unknown, req?: Request) => Response {
|
|
56
|
+
const headers = memoizedHeaders(INTERNAL_HEADERS)
|
|
57
|
+
return (error, req) => {
|
|
58
|
+
const err = toError(error)
|
|
59
|
+
if (opts.onError) {
|
|
60
|
+
try {
|
|
61
|
+
opts.onError({ error: err, request: req })
|
|
62
|
+
} catch {
|
|
63
|
+
// Observability must never break the response.
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (opts.logger) {
|
|
67
|
+
try {
|
|
68
|
+
opts.logger(
|
|
69
|
+
JSON.stringify({
|
|
70
|
+
level: 'error',
|
|
71
|
+
msg: 'unhandled_handler_error',
|
|
72
|
+
error: err.message,
|
|
73
|
+
}),
|
|
74
|
+
)
|
|
75
|
+
} catch {
|
|
76
|
+
// Logging must never break the response.
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return new Response(ERROR_BODIES.internal, { status: 500, headers })
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Wrap a route handler so a sync throw or an async rejection is contained and
|
|
85
|
+
* turned into the shared masked 500. The success path is passed through
|
|
86
|
+
* unchanged.
|
|
87
|
+
*
|
|
88
|
+
* @param handler - The route handler to guard.
|
|
89
|
+
* @param onServerError - The masked-500 responder (see
|
|
90
|
+
* {@link createServerErrorHandler}).
|
|
91
|
+
* @returns A `RouteHandler` with the same signature and success semantics.
|
|
92
|
+
*/
|
|
93
|
+
export function guardRouteHandler(
|
|
94
|
+
handler: RouteHandler,
|
|
95
|
+
onServerError: (error: unknown, req?: Request) => Response,
|
|
96
|
+
): RouteHandler {
|
|
97
|
+
return (req, srv, params) => {
|
|
98
|
+
try {
|
|
99
|
+
const out = handler(req, srv, params)
|
|
100
|
+
// Duck-type thenables (cross-realm Promises/custom thenables fail
|
|
101
|
+
// `instanceof Promise`). A `Response` never exposes `then`.
|
|
102
|
+
const thenable = out as Promise<Response> | null | undefined
|
|
103
|
+
if (thenable !== null && thenable !== undefined && typeof thenable.then === 'function') {
|
|
104
|
+
return thenable.catch((e) => onServerError(e, req))
|
|
105
|
+
}
|
|
106
|
+
return out
|
|
107
|
+
} catch (e) {
|
|
108
|
+
return onServerError(e, req)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|