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.
Files changed (55) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/castrum.darwin-arm64.node +0 -0
  4. package/castrum.darwin-x64.node +0 -0
  5. package/castrum.linux-arm64-gnu.node +0 -0
  6. package/castrum.linux-arm64-musl.node +0 -0
  7. package/castrum.linux-x64-gnu.node +0 -0
  8. package/castrum.linux-x64-musl.node +0 -0
  9. package/castrum.linux-x64-v3-gnu.node +0 -0
  10. package/castrum.win32-x64-msvc.node +0 -0
  11. package/dist/index.d.ts +164 -4
  12. package/dist/index.js +434 -70
  13. package/docs/ENVIRONMENT.md +7 -0
  14. package/docs/RND-CONCURRENCY.md +20 -4
  15. package/docs/superpowers/plans/2026-09-18-elysia-learnings-adoption.md +634 -0
  16. package/index.ts +5 -0
  17. package/package.json +2 -1
  18. package/rust/ffi/task.rs +19 -18
  19. package/rust/ingress/api.rs +21 -5
  20. package/rust/ingress/mod.rs +7 -0
  21. package/rust/ingress/native_route.rs +4 -2
  22. package/rust/ingress/schema_cache.rs +266 -0
  23. package/rust/task/mod.rs +1 -0
  24. package/rust/task/runtime.rs +55 -7
  25. package/rust/task/tests.rs +98 -1
  26. package/src/ingress/abort.ts +48 -0
  27. package/src/ingress/body.ts +104 -8
  28. package/src/ingress/handlers.ts +1 -0
  29. package/src/ingress/health.ts +22 -0
  30. package/src/ingress/index.ts +3 -1
  31. package/src/ingress/router.ts +36 -1
  32. package/src/ingress/routes/common.ts +9 -1
  33. package/src/ingress/routes/echo.ts +10 -0
  34. package/src/ingress/routes/json-write.ts +11 -0
  35. package/src/ingress/routes/native.ts +24 -5
  36. package/src/ingress/routes/options.ts +6 -2
  37. package/src/ingress/routes/responder.ts +25 -5
  38. package/src/ingress/routes/safe-record.ts +57 -0
  39. package/src/ingress/server-error.ts +111 -0
  40. package/src/ingress/server-node.ts +102 -23
  41. package/src/ingress/server.ts +140 -32
  42. package/src/integration/pipeline.ts +6 -0
  43. package/src/native/ffi/build/task.ts +2 -1
  44. package/src/native/ffi/types.ts +6 -1
  45. package/src/native/types.ts +6 -0
  46. package/src/runtime/detect.ts +14 -0
  47. package/src/rust-ffi/context.ts +46 -3
  48. package/src/rust-ffi/scalar/factories.ts +6 -0
  49. package/src/rust-ffi/scalar/interface.ts +14 -0
  50. package/src/rust-ffi/text.ts +5 -4
  51. package/src/shared/buffer-pool.ts +10 -0
  52. package/src/shared/memory.ts +68 -0
  53. package/src/shared/metrics.ts +86 -3
  54. package/src/shared/runtime.ts +7 -0
  55. 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
+ }
@@ -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
- const bytes = await readBodyBytes(req)
177
- if (guard && bytes.byteLength > maxBytes) {
178
- throw bodyTooLargeError()
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 Promise.race([readBodyBytes(req), dl.promise])
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 Promise.race([buffered, dl.promise])
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
@@ -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'
@@ -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
@@ -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'
@@ -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) => ingress.run<Response>(req, resolveIp(req, srv, opts), null, respond)
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
- /** The `+`-to-space / `%XX` decoding is done natively; keys are last-wins. */
40
- function pairsToRecord(pairs: ReadonlyArray<[string, string]>): Record<string, string> {
41
- const out: Record<string, string> = {}
42
- for (const [k, v] of pairs) out[k] = v
43
- return out
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
- ingress.run<Response>(req, resolveIp(req, srv, opts), null, (result, ctx) => {
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
- /** Parse a `queryJson()`/`cookiesJson()` section into an object (empty on failure). */
72
- function parseSection(json: string): Record<string, unknown> {
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 v: unknown = JSON.parse(json)
75
- return v !== null && typeof v === 'object' && !Array.isArray(v)
76
- ? (v as Record<string, unknown>)
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