castrum 0.9.8 → 0.9.9
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 +1 -1
- package/Cargo.toml +1 -1
- 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/superpowers/plans/2026-09-18-elysia-learnings-adoption.md +634 -0
- package/index.ts +5 -0
- package/package.json +2 -1
- 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/task/mod.rs +1 -0
- package/rust/task/runtime.rs +55 -7
- package/rust/task/tests.rs +98 -1
- 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
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// src/ingress/abort.ts — PURE request-cancellation helpers.
|
|
2
|
+
//
|
|
3
|
+
// The async ingress reads `Request.signal` (fired when the client disconnects)
|
|
4
|
+
// at its genuine async boundaries. A body read or a route wrapper that observes
|
|
5
|
+
// an abort returns the cancelled-client response instead of running more work.
|
|
6
|
+
// PURE: no addon / pool imports — safe for the route factories.
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The `err.code` value a body read carries when it is rejected because the
|
|
10
|
+
* client disconnected (`Request.signal` aborted).
|
|
11
|
+
*/
|
|
12
|
+
export const ABORT_CODE = 'REQUEST_ABORTED'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Whether a thrown value is the cancellation error produced when `req.signal`
|
|
16
|
+
* aborts during a body read (see `readBodyWithLimit` in `./body`).
|
|
17
|
+
*
|
|
18
|
+
* @param err - The caught value.
|
|
19
|
+
* @returns `true` when `err.code === 'REQUEST_ABORTED'`.
|
|
20
|
+
* @example
|
|
21
|
+
* ```ts
|
|
22
|
+
* try {
|
|
23
|
+
* await readBodyWithLimit(req, 1 << 20, true)
|
|
24
|
+
* } catch (err) {
|
|
25
|
+
* if (isAbortError(err)) return abortResponse()
|
|
26
|
+
* throw err
|
|
27
|
+
* }
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export function isAbortError(err: unknown): boolean {
|
|
31
|
+
return (err as { code?: unknown } | null | undefined)?.code === ABORT_CODE
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Build the cancelled-client response: status **499** ("Client Closed
|
|
36
|
+
* Request", a de-facto nginx status) with no body. This is the only new
|
|
37
|
+
* outcome added by cancellation — it never replaces an existing success or
|
|
38
|
+
* error status.
|
|
39
|
+
*
|
|
40
|
+
* @returns A bodyless `Response` with status 499.
|
|
41
|
+
* @example
|
|
42
|
+
* ```ts
|
|
43
|
+
* if (req.signal?.aborted) return abortResponse()
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
export function abortResponse(): Response {
|
|
47
|
+
return new Response(null, { status: 499 })
|
|
48
|
+
}
|
package/src/ingress/body.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// API.
|
|
3
3
|
|
|
4
4
|
import { isBun } from '../shared/runtime'
|
|
5
|
+
import { ABORT_CODE } from './abort'
|
|
5
6
|
|
|
6
7
|
const EMPTY_BODY = new Uint8Array(0)
|
|
7
8
|
|
|
@@ -65,6 +66,59 @@ function bodyTooLargeError(): Error & { code?: string } {
|
|
|
65
66
|
return err
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
function abortError(): Error & { code?: string } {
|
|
70
|
+
const err = new Error(ABORT_CODE) as Error & { code?: string }
|
|
71
|
+
err.code = ABORT_CODE
|
|
72
|
+
return err
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Reject when `signal` is already aborted (the entry guard for every path). */
|
|
76
|
+
function throwIfAborted(signal: AbortSignal | undefined): void {
|
|
77
|
+
if (signal?.aborted) throw abortError()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A reusable rejection promise + listener-cleanup pair for a read race. */
|
|
81
|
+
interface ReadGuard {
|
|
82
|
+
promise: Promise<never>
|
|
83
|
+
cancel: () => void
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Track `req.signal` as a rejection racer: settles with a `REQUEST_ABORTED`
|
|
88
|
+
* error when the client disconnects. Returns `null` when the request carries no
|
|
89
|
+
* signal, so runtimes without `Request.signal` are unaffected. The
|
|
90
|
+
* `{ once: true }` listener self-removes when it fires; `cancel()` detaches it
|
|
91
|
+
* on the read-completed-first path so no listener outlives the read.
|
|
92
|
+
*/
|
|
93
|
+
function createAbortGuard(signal: AbortSignal | undefined): ReadGuard | null {
|
|
94
|
+
if (!signal) return null
|
|
95
|
+
if (signal.aborted) {
|
|
96
|
+
return { promise: Promise.reject(abortError()), cancel: () => {} }
|
|
97
|
+
}
|
|
98
|
+
let listener: (() => void) | null = null
|
|
99
|
+
const promise = new Promise<never>((_resolve, reject) => {
|
|
100
|
+
listener = () => reject(abortError())
|
|
101
|
+
signal.addEventListener('abort', listener, { once: true })
|
|
102
|
+
})
|
|
103
|
+
return {
|
|
104
|
+
promise,
|
|
105
|
+
cancel: () => {
|
|
106
|
+
if (listener !== null) {
|
|
107
|
+
signal.removeEventListener('abort', listener)
|
|
108
|
+
listener = null
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Race a read against guard rejections (a plain await when there are none). */
|
|
115
|
+
function raceRead<T>(read: Promise<T>, guards: ReadonlyArray<ReadGuard>): Promise<T> {
|
|
116
|
+
if (guards.length === 0) return read
|
|
117
|
+
const racers: Promise<unknown>[] = [read]
|
|
118
|
+
for (const guard of guards) racers.push(guard.promise)
|
|
119
|
+
return Promise.race(racers) as Promise<T>
|
|
120
|
+
}
|
|
121
|
+
|
|
68
122
|
// ── Shared body-read deadline watchdog ──────────────────────────────────────
|
|
69
123
|
// One interval timer sweeps ALL in-flight body reads that carry a deadline,
|
|
70
124
|
// replacing a per-request `setTimeout` + `clearTimeout` + `timer.unref` on
|
|
@@ -145,6 +199,9 @@ function createDeadline(deadlineMs: number): { promise: Promise<never>; cancel:
|
|
|
145
199
|
* (thrown as soon as the limit is crossed — the body is never fully
|
|
146
200
|
* buffered first, which is the slowloris/large-body protection).
|
|
147
201
|
* - `REQUEST_TIMEOUT` when `timeoutMs` elapses before the body completes.
|
|
202
|
+
* - `REQUEST_ABORTED` when `req.signal` aborts (client disconnect) before the
|
|
203
|
+
* read settles — including an already-aborted signal at entry. The race's
|
|
204
|
+
* abort listener is removed once the read settles.
|
|
148
205
|
*/
|
|
149
206
|
export async function readBodyWithLimit(
|
|
150
207
|
req: Request,
|
|
@@ -152,6 +209,10 @@ export async function readBodyWithLimit(
|
|
|
152
209
|
guard: boolean,
|
|
153
210
|
timeoutMs?: number,
|
|
154
211
|
): Promise<Uint8Array> {
|
|
212
|
+
// Client disconnect before any read work: reject immediately (also covers an
|
|
213
|
+
// already-aborted signal on every downstream path).
|
|
214
|
+
throwIfAborted(req.signal)
|
|
215
|
+
|
|
155
216
|
const deadline = timeoutMs && timeoutMs > 0 ? timeoutMs : 0
|
|
156
217
|
|
|
157
218
|
// Fast path: a declared Content-Length lets us PROVE the body fits the
|
|
@@ -173,11 +234,34 @@ export async function readBodyWithLimit(
|
|
|
173
234
|
// path — `req.bytes()` (Bun) / `arrayBuffer()` (Node) read the buffered
|
|
174
235
|
// body directly, avoiding a lazily-constructed stream per write request.
|
|
175
236
|
if (deadline <= 0) {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
237
|
+
// No deadline: use the same buffered fast path as the deadline branch.
|
|
238
|
+
// Under Bun a small declared-length body is usually already buffered —
|
|
239
|
+
// `Bun.peek` returns the bytes synchronously, so the common case skips
|
|
240
|
+
// the abort race + microtask entirely. Only a genuinely pending read
|
|
241
|
+
// (slow/trickling body) or a runtime without `req.bytes()` (Node) pays
|
|
242
|
+
// the abort race.
|
|
243
|
+
const buffered = peekBufferedBody(req)
|
|
244
|
+
if (buffered !== false && 'bytes' in buffered) {
|
|
245
|
+
throwIfAborted(req.signal)
|
|
246
|
+
if (guard && buffered.bytes.byteLength > maxBytes) {
|
|
247
|
+
throw bodyTooLargeError()
|
|
248
|
+
}
|
|
249
|
+
return buffered.bytes
|
|
250
|
+
}
|
|
251
|
+
const abort = createAbortGuard(req.signal)
|
|
252
|
+
try {
|
|
253
|
+
const bytes = await raceRead(
|
|
254
|
+
buffered === false ? readBodyBytes(req) : buffered,
|
|
255
|
+
abort !== null ? [abort] : [],
|
|
256
|
+
)
|
|
257
|
+
throwIfAborted(req.signal)
|
|
258
|
+
if (guard && bytes.byteLength > maxBytes) {
|
|
259
|
+
throw bodyTooLargeError()
|
|
260
|
+
}
|
|
261
|
+
return bytes
|
|
262
|
+
} finally {
|
|
263
|
+
abort?.cancel()
|
|
179
264
|
}
|
|
180
|
-
return bytes
|
|
181
265
|
}
|
|
182
266
|
// Deadline path. Under Bun, a small declared-length body is usually
|
|
183
267
|
// already buffered — `Bun.peek` returns the bytes synchronously, so we
|
|
@@ -187,31 +271,38 @@ export async function readBodyWithLimit(
|
|
|
187
271
|
const buffered = peekBufferedBody(req)
|
|
188
272
|
if (buffered === false) {
|
|
189
273
|
const dl = createDeadline(deadline)
|
|
274
|
+
const abort = createAbortGuard(req.signal)
|
|
190
275
|
try {
|
|
191
|
-
const bytes = await
|
|
276
|
+
const bytes = await raceRead(readBodyBytes(req), abort !== null ? [dl, abort] : [dl])
|
|
277
|
+
throwIfAborted(req.signal)
|
|
192
278
|
if (guard && bytes.byteLength > maxBytes) {
|
|
193
279
|
throw bodyTooLargeError()
|
|
194
280
|
}
|
|
195
281
|
return bytes
|
|
196
282
|
} finally {
|
|
197
283
|
dl.cancel()
|
|
284
|
+
abort?.cancel()
|
|
198
285
|
}
|
|
199
286
|
}
|
|
200
287
|
if ('bytes' in buffered) {
|
|
288
|
+
throwIfAborted(req.signal)
|
|
201
289
|
if (guard && buffered.bytes.byteLength > maxBytes) {
|
|
202
290
|
throw bodyTooLargeError()
|
|
203
291
|
}
|
|
204
292
|
return buffered.bytes
|
|
205
293
|
}
|
|
206
294
|
const dl = createDeadline(deadline)
|
|
295
|
+
const abort = createAbortGuard(req.signal)
|
|
207
296
|
try {
|
|
208
|
-
const bytes = await
|
|
297
|
+
const bytes = await raceRead(buffered, abort !== null ? [dl, abort] : [dl])
|
|
298
|
+
throwIfAborted(req.signal)
|
|
209
299
|
if (guard && bytes.byteLength > maxBytes) {
|
|
210
300
|
throw bodyTooLargeError()
|
|
211
301
|
}
|
|
212
302
|
return bytes
|
|
213
303
|
} finally {
|
|
214
304
|
dl.cancel()
|
|
305
|
+
abort?.cancel()
|
|
215
306
|
}
|
|
216
307
|
}
|
|
217
308
|
}
|
|
@@ -220,6 +311,7 @@ export async function readBodyWithLimit(
|
|
|
220
311
|
// is only touched here — the declared-length fast path never needs it.
|
|
221
312
|
const body = req.body
|
|
222
313
|
if (!body) {
|
|
314
|
+
throwIfAborted(req.signal)
|
|
223
315
|
return EMPTY_BODY
|
|
224
316
|
}
|
|
225
317
|
|
|
@@ -231,11 +323,14 @@ export async function readBodyWithLimit(
|
|
|
231
323
|
// per request. It is still required when the loop exits early (timeout/error).
|
|
232
324
|
let completed = false
|
|
233
325
|
const dl = deadline > 0 ? createDeadline(deadline) : null
|
|
326
|
+
const abort = createAbortGuard(req.signal)
|
|
327
|
+
const guards: ReadGuard[] = []
|
|
328
|
+
if (dl !== null) guards.push(dl)
|
|
329
|
+
if (abort !== null) guards.push(abort)
|
|
234
330
|
|
|
235
331
|
try {
|
|
236
332
|
for (;;) {
|
|
237
|
-
const { done, value } =
|
|
238
|
-
dl !== null ? await Promise.race([reader.read(), dl.promise]) : await reader.read()
|
|
333
|
+
const { done, value } = await raceRead(reader.read(), guards)
|
|
239
334
|
|
|
240
335
|
if (done) {
|
|
241
336
|
completed = true
|
|
@@ -253,6 +348,7 @@ export async function readBodyWithLimit(
|
|
|
253
348
|
}
|
|
254
349
|
} finally {
|
|
255
350
|
dl?.cancel()
|
|
351
|
+
abort?.cancel()
|
|
256
352
|
// Release the underlying stream. Cancelling while a read() is still
|
|
257
353
|
// pending (the timeout case) can resolve that pending read() as `done`
|
|
258
354
|
// and RACE the timeout rejection — so reject first, cancel only AFTER
|
package/src/ingress/handlers.ts
CHANGED
|
@@ -94,6 +94,7 @@ export type {
|
|
|
94
94
|
CreateIngressServerOptions,
|
|
95
95
|
GracefulShutdownOptions,
|
|
96
96
|
ServerHandle,
|
|
97
|
+
StaticRoute,
|
|
97
98
|
} from './server'
|
|
98
99
|
export { createIngressServer, gracefulShutdown } from './server'
|
|
99
100
|
export type { RouteHandler as IngressNodeRouteHandler } from './server-node'
|
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
|