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.
Files changed (61) hide show
  1. package/Cargo.lock +11 -1
  2. package/Cargo.toml +17 -3
  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/bun-builtins-decision-matrix.md +17 -7
  16. package/docs/superpowers/plans/2026-09-18-elysia-learnings-adoption.md +634 -0
  17. package/index.ts +5 -0
  18. package/package.json +2 -1
  19. package/rust/crypto/base64.rs +472 -57
  20. package/rust/crypto/hashing.rs +68 -0
  21. package/rust/ffi/task.rs +19 -18
  22. package/rust/ingress/api.rs +21 -5
  23. package/rust/ingress/mod.rs +7 -0
  24. package/rust/ingress/native_route.rs +4 -2
  25. package/rust/ingress/schema_cache.rs +266 -0
  26. package/rust/payload/sse.rs +78 -5
  27. package/rust/task/mod.rs +1 -0
  28. package/rust/task/runtime.rs +55 -7
  29. package/rust/task/tests.rs +98 -1
  30. package/rust/util/text.rs +112 -10
  31. package/rust/util/validation.rs +34 -0
  32. package/src/ingress/abort.ts +48 -0
  33. package/src/ingress/body.ts +104 -8
  34. package/src/ingress/handlers.ts +1 -0
  35. package/src/ingress/health.ts +22 -0
  36. package/src/ingress/index.ts +3 -1
  37. package/src/ingress/router.ts +36 -1
  38. package/src/ingress/routes/common.ts +9 -1
  39. package/src/ingress/routes/echo.ts +10 -0
  40. package/src/ingress/routes/json-write.ts +11 -0
  41. package/src/ingress/routes/native.ts +24 -5
  42. package/src/ingress/routes/options.ts +6 -2
  43. package/src/ingress/routes/responder.ts +25 -5
  44. package/src/ingress/routes/safe-record.ts +57 -0
  45. package/src/ingress/server-error.ts +111 -0
  46. package/src/ingress/server-node.ts +102 -23
  47. package/src/ingress/server.ts +140 -32
  48. package/src/integration/pipeline.ts +6 -0
  49. package/src/native/ffi/build/task.ts +2 -1
  50. package/src/native/ffi/types.ts +6 -1
  51. package/src/native/types.ts +6 -0
  52. package/src/runtime/detect.ts +14 -0
  53. package/src/rust-ffi/context.ts +46 -3
  54. package/src/rust-ffi/scalar/factories.ts +6 -0
  55. package/src/rust-ffi/scalar/interface.ts +14 -0
  56. package/src/rust-ffi/text.ts +5 -4
  57. package/src/shared/buffer-pool.ts +10 -0
  58. package/src/shared/memory.ts +68 -0
  59. package/src/shared/metrics.ts +86 -3
  60. package/src/shared/runtime.ts +7 -0
  61. package/src/task/runtime.ts +52 -9
@@ -10,7 +10,7 @@
10
10
 
11
11
  use parking_lot::{Condvar, Mutex};
12
12
  use std::collections::VecDeque;
13
- use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
13
+ use std::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering};
14
14
  use std::sync::OnceLock;
15
15
  use std::time::Duration;
16
16
 
@@ -25,6 +25,11 @@ const SPIN_ROUNDS: u32 = 64;
25
25
  /// Idle park timeout. A bounded wait (instead of an indefinite one) means a
26
26
  /// missed wakeup costs at most this much latency and can never wedge a worker.
27
27
  const PARK_TIMEOUT: Duration = Duration::from_micros(200);
28
+ /// Default admission bound for the work queue. A submission that would exceed
29
+ /// it is rejected (the C ABI returns `2 = overloaded`) instead of growing
30
+ /// memory without bound. Overridable with `CASTRUM_TASK_QUEUE_MAX` (minimum 1)
31
+ /// when the pool is first created.
32
+ pub const DEFAULT_QUEUE_MAX: usize = 4096;
28
33
 
29
34
  /// A unit of offloaded work. Owns its input bytes (`'static`, `Send`).
30
35
  pub type Job = Box<dyn FnOnce() + Send + 'static>;
@@ -35,6 +40,9 @@ pub struct TaskPool {
35
40
  cv: Condvar,
36
41
  running: AtomicBool,
37
42
  threads: AtomicU32,
43
+ /// Admission bound for `queue`; read once when the pool is created (tests
44
+ /// override it through [`set_queue_max`]).
45
+ queue_max: AtomicUsize,
38
46
  }
39
47
 
40
48
  static POOL: OnceLock<TaskPool> = OnceLock::new();
@@ -46,6 +54,7 @@ impl TaskPool {
46
54
  cv: Condvar::new(),
47
55
  running: AtomicBool::new(false),
48
56
  threads: AtomicU32::new(0),
57
+ queue_max: AtomicUsize::new(default_queue_max()),
49
58
  }
50
59
  }
51
60
 
@@ -121,9 +130,17 @@ impl TaskPool {
121
130
  }
122
131
  }
123
132
 
124
- fn enqueue(&'static self, job: Job) {
125
- self.queue.lock().push_back(job);
133
+ /// Push `job` only while the queue is below its admission bound. Returns
134
+ /// `false` when full — the caller drops the job (overload).
135
+ fn try_enqueue(&'static self, job: Job) -> bool {
136
+ let mut q = self.queue.lock();
137
+ if q.len() >= self.queue_max.load(Ordering::Relaxed) {
138
+ return false;
139
+ }
140
+ q.push_back(job);
141
+ drop(q);
126
142
  self.cv.notify_one();
143
+ true
127
144
  }
128
145
 
129
146
  fn shutdown(&self) {
@@ -173,6 +190,16 @@ pub fn default_threads() -> usize {
173
190
  })
174
191
  }
175
192
 
193
+ /// Default admission bound: `CASTRUM_TASK_QUEUE_MAX` when set to a valid value
194
+ /// `>= 1`, else {@link DEFAULT_QUEUE_MAX}.
195
+ fn default_queue_max() -> usize {
196
+ std::env::var("CASTRUM_TASK_QUEUE_MAX")
197
+ .ok()
198
+ .and_then(|v| v.parse::<usize>().ok())
199
+ .filter(|&n| n > 0)
200
+ .unwrap_or(DEFAULT_QUEUE_MAX)
201
+ }
202
+
176
203
  /// Start the pool with `threads` workers (`None`/`0` → {@link default_threads}).
177
204
  ///
178
205
  /// Returns `0` when this call started the pool and `1` when it was already
@@ -190,8 +217,9 @@ pub fn init(threads: Option<u32>) -> u32 {
190
217
  0
191
218
  }
192
219
 
193
- /// Enqueue `job`, auto-starting the pool on first use. Always accepted in v1
194
- /// (the queue is unbounded; admission/backpressure is a later milestone).
220
+ /// Enqueue `job`, auto-starting the pool on first use. Returns `false` (the C
221
+ /// ABI's `2 = overloaded`) when the bounded work queue is full — admission is
222
+ /// capped by `CASTRUM_TASK_QUEUE_MAX` (default {@link DEFAULT_QUEUE_MAX}).
195
223
  pub fn submit<F>(job: F) -> bool
196
224
  where
197
225
  F: FnOnce() + Send + 'static,
@@ -201,8 +229,28 @@ where
201
229
  let n = default_threads();
202
230
  p.start(n);
203
231
  }
204
- p.enqueue(Box::new(job));
205
- true
232
+ p.try_enqueue(Box::new(job))
233
+ }
234
+
235
+ /// Jobs queued but not yet started (monitoring / tests).
236
+ pub fn queue_depth() -> usize {
237
+ POOL.get_or_init(TaskPool::new).queue.lock().len()
238
+ }
239
+
240
+ /// Configured admission bound for the work queue (monitoring / tests).
241
+ pub fn queue_max() -> usize {
242
+ POOL.get_or_init(TaskPool::new)
243
+ .queue_max
244
+ .load(Ordering::Relaxed)
245
+ }
246
+
247
+ /// Override the admission bound. TEST ONLY: production reads
248
+ /// `CASTRUM_TASK_QUEUE_MAX` once when the pool is created.
249
+ #[cfg(test)]
250
+ pub fn set_queue_max(max: usize) {
251
+ POOL.get_or_init(TaskPool::new)
252
+ .queue_max
253
+ .store(max.max(1), Ordering::Relaxed);
206
254
  }
207
255
 
208
256
  /// Configured worker count (0 before the pool starts).
@@ -5,7 +5,9 @@
5
5
  // threads.
6
6
 
7
7
  use parking_lot::Mutex;
8
- use std::sync::atomic::{AtomicU32, Ordering};
8
+ use std::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering};
9
+ use std::sync::Arc;
10
+ use std::time::Duration;
9
11
 
10
12
  use super::completion::{self, Completion};
11
13
  use super::ops::{
@@ -259,3 +261,98 @@ fn pool_starts_with_a_nonzero_thread_count() {
259
261
  runtime::init(None);
260
262
  assert!(runtime::threads() >= 1);
261
263
  }
264
+
265
+ /// Block until `counter` reaches `target` (10 s guard).
266
+ fn wait_for_counter(counter: &AtomicUsize, target: usize) {
267
+ let deadline = std::time::Instant::now() + Duration::from_secs(10);
268
+ while counter.load(Ordering::SeqCst) < target {
269
+ assert!(
270
+ std::time::Instant::now() < deadline,
271
+ "counter never reached {target}"
272
+ );
273
+ std::thread::sleep(Duration::from_millis(1));
274
+ }
275
+ }
276
+
277
+ /// Block until the admission queue is empty (10 s guard).
278
+ fn wait_for_drained_queue() {
279
+ let deadline = std::time::Instant::now() + Duration::from_secs(10);
280
+ while runtime::queue_depth() != 0 {
281
+ assert!(std::time::Instant::now() < deadline, "queue never drained");
282
+ std::thread::sleep(Duration::from_millis(1));
283
+ }
284
+ }
285
+
286
+ /// The queue is a hard-bounded admission gate: once every worker is occupied
287
+ /// and the queue holds `queue_max` jobs, further submits are rejected rather
288
+ /// than growing memory; releasing the workers restores capacity.
289
+ #[test]
290
+ fn queue_admission_is_bounded_and_recovers() {
291
+ let _g = LOCK.lock();
292
+ completion::clear();
293
+ runtime::init(None);
294
+ let workers = runtime::threads().max(1) as usize;
295
+
296
+ const CAP: usize = 4;
297
+ runtime::set_queue_max(CAP);
298
+
299
+ let started = Arc::new(AtomicUsize::new(0));
300
+ let finished = Arc::new(AtomicUsize::new(0));
301
+ let release = Arc::new(AtomicBool::new(false));
302
+
303
+ let make_blocker = || {
304
+ let started = Arc::clone(&started);
305
+ let finished = Arc::clone(&finished);
306
+ let release = Arc::clone(&release);
307
+ move || {
308
+ started.fetch_add(1, Ordering::SeqCst);
309
+ while !release.load(Ordering::SeqCst) {
310
+ std::thread::sleep(Duration::from_millis(1));
311
+ }
312
+ finished.fetch_add(1, Ordering::SeqCst);
313
+ }
314
+ };
315
+
316
+ // Occupy every worker, one submission at a time, waiting for each to START
317
+ // so it has left the queue. (Submitting them all at once would let one
318
+ // worker batch-pop several, which still occupies it but muddies the depth
319
+ // arithmetic this test asserts.)
320
+ for i in 0..workers {
321
+ assert!(
322
+ runtime::submit(make_blocker()),
323
+ "worker {i} submit rejected"
324
+ );
325
+ wait_for_counter(&started, i + 1);
326
+ }
327
+ assert_eq!(runtime::queue_depth(), 0);
328
+ assert_eq!(runtime::queue_max(), CAP);
329
+
330
+ // Every worker is busy: fill the queue to the cap.
331
+ for i in 0..CAP {
332
+ assert!(
333
+ runtime::submit(make_blocker()),
334
+ "queued submit {i} rejected"
335
+ );
336
+ }
337
+ assert_eq!(runtime::queue_depth(), CAP);
338
+
339
+ // The cap is a HARD bound — the next submit is rejected, not queued.
340
+ assert!(
341
+ !runtime::submit(make_blocker()),
342
+ "submit accepted at the cap"
343
+ );
344
+ assert_eq!(runtime::queue_depth(), CAP);
345
+
346
+ // Release: the queue drains and admission recovers.
347
+ release.store(true, Ordering::SeqCst);
348
+ wait_for_counter(&finished, workers + CAP);
349
+ wait_for_drained_queue();
350
+ assert!(
351
+ runtime::submit(make_blocker()),
352
+ "capacity did not recover after drain"
353
+ );
354
+ wait_for_counter(&finished, workers + CAP + 1);
355
+
356
+ // Restore the default bound so a later test cannot inherit the tiny cap.
357
+ runtime::set_queue_max(4096);
358
+ }
package/rust/util/text.rs CHANGED
@@ -14,23 +14,50 @@ use napi_derive::napi;
14
14
  /// [`regex_escape_write`].
15
15
  const REGEX_META: &[u8; 14] = b"\\.*+?^${}()|[]";
16
16
 
17
+ /// 256-entry membership lookup for [`REGEX_META`] — one table load per byte.
18
+ /// The previous per-byte `REGEX_META.contains(&c)` did a linear 14-element
19
+ /// scan PER byte (O(n·14)); a 16KB payload took ~100µs (~160MB/s) and the
20
+ /// win margin vs JS regex-replace collapsed with size. The LUT makes the hot
21
+ /// loop branch-predictable (~1 load/byte), and safe runs are copied in bulk
22
+ /// (memcpy) instead of one push per byte.
23
+ const REGEX_META_LUT: [u8; 256] = {
24
+ let mut t = [0u8; 256];
25
+ let mut i = 0usize;
26
+ while i < REGEX_META.len() {
27
+ t[REGEX_META[i] as usize] = 1;
28
+ i += 1;
29
+ }
30
+ t
31
+ };
32
+
33
+ #[inline(always)]
34
+ fn is_meta(b: u8) -> bool {
35
+ REGEX_META_LUT[b as usize] != 0
36
+ }
37
+
17
38
  /// Append the RegExp-escaped form of `input` to `out`. ASCII-only transform
18
39
  /// (backslash before metachar bytes) — multi-byte UTF-8 sequences pass
19
40
  /// through byte-exact because no UTF-8 continuation byte is a metachar.
41
+ /// Safe runs are bulk-copied (memcpy) between metachars, so throughput scales
42
+ /// with memory bandwidth, not the metachar-set size.
20
43
  pub fn regex_escape_into(input: &[u8], out: &mut Vec<u8>) {
21
- for &c in input {
22
- if REGEX_META.contains(&c) {
44
+ let mut run_start = 0usize;
45
+ for (i, &c) in input.iter().enumerate() {
46
+ if is_meta(c) {
47
+ out.extend_from_slice(&input[run_start..i]);
23
48
  out.push(b'\\');
49
+ out.push(c);
50
+ run_start = i + 1;
24
51
  }
25
- out.push(c);
26
52
  }
53
+ out.extend_from_slice(&input[run_start..]);
27
54
  }
28
55
 
29
56
  /// Exact escaped length (metachar count + input length) — lets callers size
30
57
  /// output buffers without running the escape twice.
31
58
  #[inline]
32
59
  pub fn regex_escape_len(input: &[u8]) -> usize {
33
- input.len() + input.iter().filter(|b| REGEX_META.contains(b)).count()
60
+ input.len() + input.iter().filter(|&&b| is_meta(b)).count()
34
61
  }
35
62
 
36
63
  /// Write the escaped form of `input` into `out` (which must be at least
@@ -38,15 +65,21 @@ pub fn regex_escape_len(input: &[u8]) -> usize {
38
65
  /// written.
39
66
  pub fn regex_escape_write(input: &[u8], out: &mut [u8]) -> usize {
40
67
  let mut pos = 0usize;
41
- for &c in input {
42
- if REGEX_META.contains(&c) {
68
+ let mut run_start = 0usize;
69
+ for (i, &c) in input.iter().enumerate() {
70
+ if is_meta(c) {
71
+ let run = &input[run_start..i];
72
+ out[pos..pos + run.len()].copy_from_slice(run);
73
+ pos += run.len();
43
74
  out[pos] = b'\\';
44
- pos += 1;
75
+ out[pos + 1] = c;
76
+ pos += 2;
77
+ run_start = i + 1;
45
78
  }
46
- out[pos] = c;
47
- pos += 1;
48
79
  }
49
- pos
80
+ let tail = &input[run_start..];
81
+ out[pos..pos + tail.len()].copy_from_slice(tail);
82
+ pos + tail.len()
50
83
  }
51
84
 
52
85
  /// Escape JS RegExp metacharacters in a string → a pattern that matches the
@@ -127,4 +160,73 @@ mod tests {
127
160
  let escaped = String::from_utf8(out).expect("utf8");
128
161
  assert_eq!(escaped, r"a\.c");
129
162
  }
163
+
164
+ /// Reference escape (the previous per-byte `REGEX_META.contains` loop).
165
+ fn ref_escape(input: &[u8]) -> Vec<u8> {
166
+ const META: &[u8] = b"\\.*+?^${}()|[]";
167
+ let mut out = Vec::new();
168
+ for &c in input {
169
+ if META.contains(&c) {
170
+ out.push(b'\\');
171
+ }
172
+ out.push(c);
173
+ }
174
+ out
175
+ }
176
+
177
+ #[test]
178
+ fn regex_escape_matches_reference_for_all_lengths() {
179
+ // Every length 0..=96 plus larger payloads: the LUT + run-copy core
180
+ // (bulk memcpy between metachars) must be byte-identical to the naive
181
+ // per-byte reference. Lengths 15/16/17… straddle no SIMD chunk here
182
+ // (the transform is scalar), but the sweep locks positional behavior:
183
+ // a run-copy off-by-one (dropping or duplicating a safe byte) surfaces.
184
+ let mut lens: Vec<usize> = (0..=96).collect();
185
+ lens.extend([128, 512, 16 * 1024, 256 * 1024]);
186
+ for n in lens {
187
+ let input: Vec<u8> = (0..n).map(|i| (i.wrapping_mul(31) + 5) as u8).collect();
188
+ let expect = ref_escape(&input);
189
+ // allocating path
190
+ let mut out = Vec::new();
191
+ regex_escape_into(&input, &mut out);
192
+ assert_eq!(out, expect, "len {n}");
193
+ // len formula
194
+ assert_eq!(regex_escape_len(&input), expect.len(), "len {n}");
195
+ // write path (exact-size buffer, like the C-ABI caller)
196
+ let mut buf = vec![0u8; expect.len()];
197
+ let written = regex_escape_write(&input, &mut buf);
198
+ assert_eq!(written, expect.len(), "len {n}");
199
+ assert_eq!(&buf[..written], &expect[..], "len {n}");
200
+ }
201
+ }
202
+
203
+ #[test]
204
+ fn regex_escape_large_payload_edges() {
205
+ // Three 256 KiB extremes: every byte a metachar (no bulk runs — the
206
+ // worst case for the run-copy core), no metachars at all (pure bulk
207
+ // copy), and a dense alternating pattern. All must match the reference
208
+ // and the exact-length contract.
209
+ let all_meta: Vec<u8> = (0..256 * 1024)
210
+ .map(|i| b"\\.*+?^${}()|[]"[i % 14])
211
+ .collect();
212
+ let none: Vec<u8> = (0..256 * 1024).map(|i| b"abcXYZ0123_-/"[i % 13]).collect();
213
+ let dense: Vec<u8> = (0..256 * 1024)
214
+ .map(|i| if i % 2 == 0 { b'.' } else { b'a' })
215
+ .collect();
216
+ for input in [all_meta, none, dense] {
217
+ let expect = ref_escape(&input);
218
+ let mut out = Vec::new();
219
+ regex_escape_into(&input, &mut out);
220
+ assert_eq!(out, expect);
221
+ assert_eq!(regex_escape_len(&input), expect.len());
222
+ let mut buf = vec![0u8; expect.len()];
223
+ let written = regex_escape_write(&input, &mut buf);
224
+ assert_eq!(written, expect.len());
225
+ assert_eq!(&buf[..written], &expect[..]);
226
+ }
227
+ // All-metachar bound: escaped length is exactly 2x (every byte gets a
228
+ // backslash); all-safe bound: escaped length equals input length.
229
+ assert_eq!(regex_escape_len(&vec![b'.'; 1024]), 2048);
230
+ assert_eq!(regex_escape_len(&vec![b'a'; 1024]), 1024);
231
+ }
130
232
  }
@@ -317,6 +317,40 @@ mod tests {
317
317
  assert!(!validate_uuid_bytes(b"550e8400e29b41d4a716446655440000")); // no dashes
318
318
  }
319
319
 
320
+ /// Byte-for-byte pins the accept set: replacing ANY single hex position
321
+ /// (0..7, 9..12, 15..17, 20..22, 24..35) with a non-hex digit must reject,
322
+ /// and every one of the 32 hex positions must accept uppercase + lowercase
323
+ /// digits. Guards against a future rewrite (e.g. SIMD/run-split) silently
324
+ /// dropping or mis-scoping a position — measured perf-neutral, kept as a
325
+ /// spec-lock regression net.
326
+ #[test]
327
+ fn validate_uuid_hex_run_coverage() {
328
+ let dash = |i: usize| matches!(i, 8 | 13 | 18 | 23);
329
+ for pos in 0..36 {
330
+ let valid = b"550e8400-e29b-41d4-a716-446655440000";
331
+ if dash(pos) {
332
+ continue; // dash positions are pinned by their own check
333
+ }
334
+ // every non-dash position is a hex position (version/variant cols
335
+ // included — 14 must stay '4', 19 must stay in 8/9/a/b set)
336
+ if valid[pos].is_ascii_hexdigit() {
337
+ let mut upper = *valid;
338
+ upper[pos] = valid[pos].to_ascii_uppercase();
339
+ assert!(validate_uuid_bytes(&upper), "uppercase at {pos}");
340
+ }
341
+ let mut bad = *valid;
342
+ bad[pos] = b'g'; // 'g' is not a hex digit
343
+ assert!(!validate_uuid_bytes(&bad), "g at {pos}");
344
+ }
345
+ // and the version / variant columns still reject wrong values
346
+ let mut v = *b"550e8400-e29b-41d4-a716-446655440000";
347
+ v[14] = b'5';
348
+ assert!(!validate_uuid_bytes(&v));
349
+ v = *b"550e8400-e29b-41d4-a716-446655440000";
350
+ v[19] = b'c';
351
+ assert!(!validate_uuid_bytes(&v));
352
+ }
353
+
320
354
  #[test]
321
355
  fn validate_ipv4_cases() {
322
356
  assert!(validate_ipv4_bytes(b"192.168.0.1"));
@@ -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'