castrum 0.9.7 → 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 (108) 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 +664 -8
  12. package/dist/index.js +2014 -1327
  13. package/docs/BENCHMARKS.md +13 -0
  14. package/docs/CASE_STUDY.md +1 -1
  15. package/docs/ENVIRONMENT.md +8 -1
  16. package/docs/FFI_BUN_GUIDE.md +1 -1
  17. package/docs/INGRESS.md +4 -4
  18. package/docs/REPO_MAP.md +7 -5
  19. package/docs/RND-CONCURRENCY.md +20 -4
  20. package/docs/superpowers/plans/2026-09-18-castrum-hardening-cleanup.md +617 -0
  21. package/docs/superpowers/plans/2026-09-18-elysia-learnings-adoption.md +634 -0
  22. package/index.ts +6 -0
  23. package/package.json +4 -2
  24. package/rust/crypto/aead.rs +44 -0
  25. package/rust/crypto/argon2.rs +32 -2
  26. package/rust/crypto/jwt/tests.rs +19 -0
  27. package/rust/crypto/jwt/token.rs +43 -28
  28. package/rust/crypto/session.rs +6 -0
  29. package/rust/ffi/crypto.rs +63 -32
  30. package/rust/ffi/hashing.rs +48 -33
  31. package/rust/ffi/http.rs +62 -36
  32. package/rust/ffi/metrics.rs +6 -2
  33. package/rust/ffi/payload.rs +47 -27
  34. package/rust/ffi/task.rs +55 -35
  35. package/rust/ffi/tests.rs +24 -0
  36. package/rust/ffi/util.rs +40 -0
  37. package/rust/http/headers.rs +13 -0
  38. package/rust/http/multipart.rs +139 -0
  39. package/rust/ingress/api.rs +21 -5
  40. package/rust/ingress/mod.rs +7 -0
  41. package/rust/ingress/native_route.rs +25 -5
  42. package/rust/ingress/schema_cache.rs +266 -0
  43. package/rust/task/completion.rs +7 -0
  44. package/rust/task/mod.rs +2 -1
  45. package/rust/task/runtime.rs +55 -7
  46. package/rust/task/tests.rs +128 -1
  47. package/src/ingress/abort.ts +48 -0
  48. package/src/ingress/body.ts +104 -8
  49. package/src/ingress/decode/baked-result.ts +13 -5
  50. package/src/ingress/decode/fast-result.ts +1 -1
  51. package/src/ingress/decode/result-base.ts +16 -2
  52. package/src/ingress/handlers.ts +104 -31
  53. package/src/ingress/headers/memoized-headers.ts +40 -0
  54. package/src/ingress/health.ts +22 -0
  55. package/src/ingress/index.ts +3 -1
  56. package/src/ingress/native-route.ts +25 -6
  57. package/src/ingress/options.ts +26 -2
  58. package/src/ingress/packing/gather-raw-headers.ts +24 -8
  59. package/src/ingress/response/baked-response.ts +118 -28
  60. package/src/ingress/router.ts +50 -3
  61. package/src/ingress/routes/common.ts +35 -11
  62. package/src/ingress/routes/echo.ts +60 -7
  63. package/src/ingress/routes/json-write.ts +32 -8
  64. package/src/ingress/routes/native.ts +24 -5
  65. package/src/ingress/routes/options.ts +6 -2
  66. package/src/ingress/routes/responder.ts +29 -9
  67. package/src/ingress/routes/safe-record.ts +57 -0
  68. package/src/ingress/server-error.ts +111 -0
  69. package/src/ingress/server-node.ts +135 -37
  70. package/src/ingress/server.ts +143 -36
  71. package/src/ingress/shared.ts +1 -1
  72. package/src/ingress/types.ts +15 -0
  73. package/src/integration/pipeline.ts +6 -0
  74. package/src/loader/ops.ts +1 -1
  75. package/src/native/ffi/build/codecs-selftest.ts +305 -0
  76. package/src/native/ffi/build/codecs.ts +0 -296
  77. package/src/native/ffi/build/instances-selftest.ts +264 -0
  78. package/src/native/ffi/build/instances.ts +10 -259
  79. package/src/native/ffi/build/parse-selftest.ts +238 -0
  80. package/src/native/ffi/build/parse.ts +11 -234
  81. package/src/native/ffi/build/task.ts +2 -1
  82. package/src/native/ffi/build.ts +5 -1
  83. package/src/native/ffi/selftest.ts +3 -3
  84. package/src/native/ffi/symbols.ts +460 -0
  85. package/src/native/ffi/types.ts +6 -1
  86. package/src/native/ffi.ts +2 -434
  87. package/src/native/ingress-binding.ts +39 -0
  88. package/src/native/types.ts +6 -0
  89. package/src/runtime/codec.ts +24 -0
  90. package/src/runtime/detect.ts +14 -0
  91. package/src/runtime/types.ts +11 -0
  92. package/src/rust-ffi/batch/build.ts +18 -8
  93. package/src/rust-ffi/batch/types.ts +15 -0
  94. package/src/rust-ffi/context.ts +46 -3
  95. package/src/rust-ffi/options.ts +19 -0
  96. package/src/rust-ffi/scalar/crypto.ts +8 -0
  97. package/src/rust-ffi/scalar/factories.ts +53 -20
  98. package/src/rust-ffi/scalar/http.ts +60 -10
  99. package/src/rust-ffi/scalar/interface.ts +18 -1
  100. package/src/rust-ffi/scalar/payload.ts +27 -1
  101. package/src/rust-ffi/text.ts +5 -4
  102. package/src/shared/buffer-pool.ts +54 -9
  103. package/src/shared/codec.ts +26 -0
  104. package/src/shared/memory.ts +68 -0
  105. package/src/shared/metrics.ts +86 -3
  106. package/src/shared/packed/parsers.ts +64 -7
  107. package/src/shared/runtime.ts +7 -0
  108. package/src/task/runtime.ts +81 -37
@@ -39,6 +39,21 @@
39
39
  * converge on one buffer size instead of re-growing each time. Default false.
40
40
  */
41
41
  adaptive?: boolean
42
+ /**
43
+ * Number of buffers (beyond the initial one) to ALLOCATE UP FRONT at
44
+ * construction, so a burst-ramp (e.g. a load-test connection flood) never
45
+ * pays per-request `new Uint8Array` + GC during its first wave. A pure
46
+ * memory-for-latency trade: retained memory becomes `prefill + 1` ×
47
+ * `initialSize` (still bounded by `maxBuffers`). Default 0.
48
+ */
49
+ prefill?: number
50
+ /**
51
+ * Byte budget for `prefill`: the prefilled count is additionally capped to
52
+ * `floor(prefillBytes / initialSize)`, so a large `initialSize` (e.g. a
53
+ * 64 MiB output buffer) cannot multiply into gigabytes when a router creates
54
+ * one pool per route. Default `Infinity` (only `prefill`/`maxBuffers` apply).
55
+ */
56
+ prefillBytes?: number
42
57
  }
43
58
 
44
59
  /** A borrowed buffer handle handed out by a pool; `release()` returns it. */
@@ -138,6 +153,22 @@ export class BufferPool {
138
153
  : null
139
154
  this.free.push(new Uint8Array(this.initialSize))
140
155
  this.created = 1
156
+ // Pre-fill (memory-for-latency trade, see `prefill` in BufferPoolOptions):
157
+ // spend up-front allocation so a burst ramp never allocates during its
158
+ // first wave. Capped at maxBuffers so the documented retention bound
159
+ // (free list ≤ maxBuffers) still holds, and by `prefillBytes` so a large
160
+ // `initialSize` cannot multiply into an unbounded up-front allocation.
161
+ const prefill = Math.min(
162
+ Math.max(0, Math.floor(options.prefill ?? 0)),
163
+ this.maxBuffers - 1,
164
+ options.prefillBytes !== undefined
165
+ ? Math.max(0, Math.floor(options.prefillBytes / this.initialSize))
166
+ : Number.POSITIVE_INFINITY,
167
+ )
168
+ for (let i = 0; i < prefill; i++) {
169
+ this.free.push(new Uint8Array(this.initialSize))
170
+ this.created++
171
+ }
141
172
  }
142
173
 
143
174
  /** Number of buffers currently available for immediate reuse. */
@@ -150,6 +181,16 @@ export class BufferPool {
150
181
  return this.created
151
182
  }
152
183
 
184
+ /**
185
+ * Drop every retained free buffer. In-flight (borrowed) buffers are
186
+ * unaffected and return to an empty pool on `release()`, so the next
187
+ * `acquire()` allocates (or grows) as needed. Used by an explicit memory
188
+ * flush; the pool stays fully functional afterwards.
189
+ */
190
+ shrink(): void {
191
+ this.free.length = 0
192
+ }
193
+
153
194
  /**
154
195
  * Acquire a buffer of at least `minSize` bytes. The returned handle must be
155
196
  * released (directly, or via a zero-copy Response) once the caller is done.
@@ -204,11 +245,14 @@ export class BufferPool {
204
245
  // Learn the observed demand so future allocations pre-size to it.
205
246
  this.estimate?.sample(minSize)
206
247
 
207
- // 1) Reuse a free buffer that is already large enough.
248
+ // 1) Reuse a free buffer that is already large enough. Free-list ORDER is
249
+ // irrelevant, so remove by swap-with-last (O(1), no splice compaction/
250
+ // allocation) — this is a per-request hot path.
208
251
  for (let i = 0; i < this.free.length; i++) {
209
252
  const candidate = this.free[i] as Uint8Array
210
253
  if (candidate.byteLength >= minSize) {
211
- this.free.splice(i, 1)
254
+ const last = this.free.pop() as Uint8Array
255
+ if (i < this.free.length) this.free[i] = last
212
256
  return candidate
213
257
  }
214
258
  }
@@ -227,7 +271,8 @@ export class BufferPool {
227
271
  }
228
272
  }
229
273
  if (largestBuf !== null) {
230
- this.free.splice(largest, 1)
274
+ const last = this.free.pop() as Uint8Array
275
+ if (largest < this.free.length) this.free[largest] = last
231
276
  this.created++
232
277
  return new Uint8Array(target)
233
278
  }
@@ -241,16 +286,16 @@ export class BufferPool {
241
286
  private releaseBuffer(buffer: Uint8Array): void {
242
287
  if (this.free.length >= this.maxBuffers) {
243
288
  // Free list is full: drop the smallest retained buffer to stay bounded.
244
- let smallestBuf: Uint8Array | null = null
245
289
  let smallest = 0
246
- for (let i = 0; i < this.free.length; i++) {
247
- const candidate = this.free[i] as Uint8Array
248
- if (smallestBuf === null || candidate.byteLength < smallestBuf.byteLength) {
249
- smallestBuf = candidate
290
+ for (let i = 1; i < this.free.length; i++) {
291
+ if (
292
+ (this.free[i] as Uint8Array).byteLength < (this.free[smallest] as Uint8Array).byteLength
293
+ ) {
250
294
  smallest = i
251
295
  }
252
296
  }
253
- this.free.splice(smallest, 1)
297
+ const last = this.free.pop() as Uint8Array
298
+ if (smallest < this.free.length) this.free[smallest] = last
254
299
  }
255
300
  this.free.push(buffer)
256
301
  }
@@ -79,6 +79,32 @@ export const decodeUtf8: (bytes: Uint8Array) => string = runtimeCodec.decodeUtf8
79
79
  export const decodeUtf8Range: (bytes: Uint8Array, start: number, end: number) => string =
80
80
  runtimeCodec.decodeUtf8Range
81
81
 
82
+ /**
83
+ * Decode a range of a PREPARED bounded Buffer `view` (absolute offsets into
84
+ * the view) to a string — replacement mode on invalid. Companion to
85
+ * `decodeUtf8Range` for packed-wire unpackers that decode MANY fields from
86
+ * ONE packed result: the caller resolves the Buffer view once and every field
87
+ * decode skips the per-field cache lookup + offset arithmetic.
88
+ *
89
+ * Falls back to `decodeUtf8Range` when the runtime codec does not provide the
90
+ * view variant (older adapters / custom codecs).
91
+ *
92
+ * @param view - A bounded Buffer over the packed bytes.
93
+ * @param start - Range start (inclusive, relative to the view).
94
+ * @param end - Range end (exclusive, relative to the view).
95
+ * @returns The decoded string.
96
+ *
97
+ * @example
98
+ * ```ts
99
+ * const view = Buffer.from(packed.buffer, packed.byteOffset, packed.byteLength)
100
+ * decodeUtf8RangeView(view, 4, 24) // → 'multipart/form-data'
101
+ * ```
102
+ */
103
+ export const decodeUtf8RangeView: (view: Buffer, start: number, end: number) => string =
104
+ runtimeCodec.decodeUtf8RangeView
105
+ ? runtimeCodec.decodeUtf8RangeView
106
+ : (view, start, end) => decodeUtf8Range(view, start, end)
107
+
82
108
  /**
83
109
  * Decode UTF-8 bytes to a string, throwing on invalid UTF-8.
84
110
  *
@@ -0,0 +1,68 @@
1
+ // src/shared/memory.ts — Public memory-flush escape hatch.
2
+ //
3
+ // Impure boundary module: it owns no state of its own but reaches the cache
4
+ // owners (the global loader LRU and the process-wide MIME caches) to drop their
5
+ // retained entries, then optionally requests a runtime GC. The metrics registry
6
+ // is deliberately NOT flushed — an observability registry is not a cache, and
7
+ // clearing it would erase the counters an operator is watching.
8
+
9
+ import { loader } from '../loader'
10
+ import { rust } from '../rust-ffi'
11
+ import { clearMimeCaches } from '../rust-ffi/context'
12
+ import { forceGc } from './runtime'
13
+
14
+ /** Options for {@link flushMemory}. */
15
+ export interface FlushMemoryOptions {
16
+ /**
17
+ * When true (the default), also request a full garbage collection from the
18
+ * runtime: `Bun.gc(true)` on Bun, `globalThis.gc?.()` on Node when the
19
+ * process runs with `--expose-gc`. Set false to flush caches only.
20
+ */
21
+ gc?: boolean
22
+ }
23
+
24
+ /**
25
+ * Flush process-level caches and (by default) request a full GC. Intended for
26
+ * explicit memory-pressure handling — e.g. after a burst or before a snapshot.
27
+ *
28
+ * Clears:
29
+ * - the global loader LRU cache (`loader.clear()`),
30
+ * - the process-wide MIME caches (`clearMimeCaches()`),
31
+ * - the native compiled-schema cache (`rust.clearSchemaCache()`).
32
+ *
33
+ * The metrics registry is intentionally left untouched.
34
+ *
35
+ * Idempotent, and safe to call before the native addon has ever been loaded
36
+ * (every cache starts empty, the loader cache is a plain Map, and the native
37
+ * schema-cache clear is a defensive no-op when the addon/symbol is absent).
38
+ *
39
+ * @param options - `gc: false` skips the runtime GC request. Defaults to `{}`
40
+ * (GC requested).
41
+ * @example
42
+ * ```ts
43
+ * import { flushMemory } from 'castrum'
44
+ *
45
+ * flushMemory() // drop caches + request GC
46
+ * flushMemory({ gc: false }) // drop caches only
47
+ * ```
48
+ */
49
+ export function flushMemory(options: FlushMemoryOptions = {}): void {
50
+ // Global loader LRU (createLoader() instances own their own caches and are
51
+ // not registered globally — flush the shared singleton only).
52
+ loader.clear()
53
+
54
+ // Process-wide MIME caches (bytes + string forms).
55
+ clearMimeCaches()
56
+
57
+ // Process-wide native compiled-schema cache. Defensive: an absent or stale
58
+ // addon (no `clearSchemaCache` symbol) must be a no-op, never a throw.
59
+ try {
60
+ rust.clearSchemaCache()
61
+ } catch {
62
+ // addon not loaded / stale build — nothing to clear.
63
+ }
64
+
65
+ if (options.gc !== false) {
66
+ forceGc()
67
+ }
68
+ }
@@ -12,6 +12,27 @@ export const DEFAULT_BUCKETS: readonly number[] = [
12
12
  0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10,
13
13
  ]
14
14
 
15
+ /**
16
+ * Per-family cap on the number of distinct label sets (series) retained by a
17
+ * single counter/gauge/histogram. A metrics registry is keyed by caller-supplied
18
+ * label values, so an unbounded label (e.g. a raw path or user id) would grow a
19
+ * `Map` without limit. At the cap the oldest half of that family is evicted
20
+ * (insertion order) and the count is accumulated into the synthetic counter
21
+ * `castrum_metrics_series_dropped_total`, making the loss observable instead of
22
+ * silent. That name is RESERVED — a caller declaring a counter/gauge/histogram
23
+ * with it suppresses the synthetic emission (see {@link SERIES_DROPPED_METRIC}).
24
+ */
25
+ export const MAX_SERIES_PER_METRIC = 10_000
26
+
27
+ /**
28
+ * Reserved exposition name of the synthetic counter reporting series evicted by
29
+ * {@link MAX_SERIES_PER_METRIC}. Callers must not register a metric family with
30
+ * this name; if one exists, `render()` skips the synthetic emission so the
31
+ * scrape never carries two `# HELP`/`# TYPE` lines for one family (Prometheus
32
+ * rejects that).
33
+ */
34
+ const SERIES_DROPPED_METRIC = 'castrum_metrics_series_dropped_total'
35
+
15
36
  type LabelValues = readonly string[]
16
37
  type Labels = Readonly<Record<string, string>>
17
38
 
@@ -29,6 +50,24 @@ function escapeLabel(v: string): string {
29
50
  return v.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n')
30
51
  }
31
52
 
53
+ /**
54
+ * Once a series map is at {@link MAX_SERIES_PER_METRIC}, evict its oldest half
55
+ * (insertion order) and return the keys dropped; returns `[]` below the cap.
56
+ * Mirrors Elysia's `evictOldestHalf` generational policy: halving at the cap
57
+ * amortizes the eviction cost instead of paying a delete per insertion.
58
+ */
59
+ function evictOldestHalf<K, V>(map: Map<K, V>): K[] {
60
+ if (map.size < MAX_SERIES_PER_METRIC) return []
61
+ const drop = Math.ceil(map.size / 2)
62
+ const dropped: K[] = []
63
+ for (const key of map.keys()) {
64
+ if (dropped.length >= drop) break
65
+ dropped.push(key)
66
+ }
67
+ for (const key of dropped) map.delete(key)
68
+ return dropped
69
+ }
70
+
32
71
  /** A single labelled counter value. */
33
72
  export interface Counter {
34
73
  /** Increment by 1 (or `by`). `labels` must match the label names exactly. */
@@ -97,6 +136,8 @@ export function createMetrics(): MetricsRegistry {
97
136
  const counters = new Map<string, CounterEntry>()
98
137
  const gauges = new Map<string, GaugeEntry>()
99
138
  const histograms = new Map<string, HistogramEntry>()
139
+ /** Series evicted by the per-family cardinality cap, rendered as a counter. */
140
+ let seriesDropped = 0
100
141
 
101
142
  const bucketUpper = (buckets: number[]): number[] => [
102
143
  ...buckets.map((b) => b),
@@ -144,6 +185,22 @@ export function createMetrics(): MetricsRegistry {
144
185
  for (const e of counters.values()) out.push(...renderCounter(e))
145
186
  for (const e of gauges.values()) out.push(...renderGauge(e))
146
187
  for (const e of histograms.values()) out.push(...renderHistogram(e))
188
+ // Only emitted once a family has actually overflowed, so the output for a
189
+ // well-behaved registry is byte-identical to before the cap existed. Guard
190
+ // against a caller-registered family of the same (reserved) name — two
191
+ // `# HELP`/`# TYPE` lines for one family make the scrape invalid.
192
+ if (
193
+ seriesDropped > 0 &&
194
+ !counters.has(SERIES_DROPPED_METRIC) &&
195
+ !gauges.has(SERIES_DROPPED_METRIC) &&
196
+ !histograms.has(SERIES_DROPPED_METRIC)
197
+ ) {
198
+ out.push(
199
+ `# HELP ${SERIES_DROPPED_METRIC} Total metric series evicted by the per-family cardinality cap.`,
200
+ )
201
+ out.push(`# TYPE ${SERIES_DROPPED_METRIC} counter`)
202
+ out.push(`${SERIES_DROPPED_METRIC} ${seriesDropped}`)
203
+ }
147
204
  return `${out.join('\n')}\n`
148
205
  }
149
206
 
@@ -151,6 +208,7 @@ export function createMetrics(): MetricsRegistry {
151
208
  counters.clear()
152
209
  gauges.clear()
153
210
  histograms.clear()
211
+ seriesDropped = 0
154
212
  }
155
213
 
156
214
  return {
@@ -172,7 +230,14 @@ export function createMetrics(): MetricsRegistry {
172
230
  names,
173
231
  names.map((n) => labels?.[n] ?? ''),
174
232
  )
175
- entry.values.set(key, (entry.values.get(key) ?? 0) + by)
233
+ const prev = entry.values.get(key)
234
+ if (prev === undefined) {
235
+ // New series: make room first (bounded family) and count evictions.
236
+ seriesDropped += evictOldestHalf(entry.values).length
237
+ entry.values.set(key, by)
238
+ } else {
239
+ entry.values.set(key, prev + by)
240
+ }
176
241
  },
177
242
  }
178
243
  },
@@ -192,14 +257,27 @@ export function createMetrics(): MetricsRegistry {
192
257
  names,
193
258
  names.map((n) => labels?.[n] ?? ''),
194
259
  )
260
+ const add = (labels: Labels | undefined, delta: number): void => {
261
+ const key = keyOf(labels)
262
+ const prev = entry.values.get(key)
263
+ if (prev === undefined) {
264
+ seriesDropped += evictOldestHalf(entry.values).length
265
+ entry.values.set(key, delta)
266
+ } else {
267
+ entry.values.set(key, prev + delta)
268
+ }
269
+ }
195
270
  return {
196
271
  inc(labels, by = 1) {
197
- entry.values.set(keyOf(labels), (entry.values.get(keyOf(labels)) ?? 0) + by)
272
+ add(labels, by)
198
273
  },
199
274
  dec(labels, by = 1) {
200
- entry.values.set(keyOf(labels), (entry.values.get(keyOf(labels)) ?? 0) - by)
275
+ add(labels, -by)
201
276
  },
202
277
  set(labels, value) {
278
+ if (!entry.values.has(keyOf(labels))) {
279
+ seriesDropped += evictOldestHalf(entry.values).length
280
+ }
203
281
  entry.values.set(keyOf(labels), value)
204
282
  },
205
283
  }
@@ -225,6 +303,11 @@ export function createMetrics(): MetricsRegistry {
225
303
  )
226
304
  let counts = entry.counts.get(key)
227
305
  if (!counts) {
306
+ // New series: bound the family and drop the matching sum entries
307
+ // (counts and sums always share the same key set).
308
+ const dropped = evictOldestHalf(entry.counts)
309
+ for (const k of dropped) entry.sums.delete(k)
310
+ seriesDropped += dropped.length
228
311
  // One slot per bucket PLUS the implicit +Inf slot.
229
312
  counts = new Array<number>(entry.buckets.length + 1).fill(0)
230
313
  entry.counts.set(key, counts)
@@ -4,28 +4,85 @@
4
4
  // don't have to hand-pack buffers or decode packed pairs:
5
5
  // parseQueryString("a=1&b=2") // { a: "1", b: "2" }
6
6
  // parseCookieHeader("a=1; b=2") // { a: "1", b: "2" }
7
+ //
8
+ // Transport: bun:ffi PRIMARY — the packed pairs are consumed SYNCHRONOUSLY
9
+ // (readPairsPacked + pairsToObject materialize strings before return), so a
10
+ // reused per-parser scratch is safe (same model as the scalar unpackers);
11
+ // the bytes never escape. Node / `CASTRUM_FFI_MODE=napi` / bind failure fall
12
+ // back to the napi addon (same wire output — pinned by packed-parsers-routing).
7
13
 
14
+ import { getBunFFI } from '../../native/ffi'
8
15
  import { getAddon, lazyAddon } from '../../native'
9
16
  import { encoder } from '../bytes'
10
17
  import { pairsToObject, readPairsPacked } from './wire'
11
18
 
12
- // Lazy: importing this module does not dlopen the addon until first use.
19
+ // Lazy: importing this module does not dlopen the addon until first use
20
+ // (getBunFFI binds on first call; the napi proxy loads on first get).
13
21
  const addon = lazyAddon(getAddon)
14
22
 
23
+ // FFI scratch (documented impure module state, per-Worker): grown lazily and
24
+ // reused across calls — the parsed result is a JS object, the packed bytes
25
+ // never escape.
26
+ const ffiCaches: Record<'query' | 'cookie' | 'form', Uint8Array | null> = {
27
+ query: null,
28
+ cookie: null,
29
+ form: null,
30
+ }
31
+ function scratchFor(kind: 'query' | 'cookie' | 'form', input: Uint8Array): Uint8Array {
32
+ // Packed pairs worst case ≈ 9× input (u32 len + bytes per key/value) + header.
33
+ const need = input.length * 9 + 16
34
+ let s = ffiCaches[kind]
35
+ if (s === null || s.length < need) {
36
+ s = new Uint8Array(need)
37
+ ffiCaches[kind] = s
38
+ }
39
+ return s
40
+ }
41
+
42
+ function parsePackedInto(
43
+ kind: 'query' | 'cookie' | 'form',
44
+ input: Uint8Array,
45
+ ): Record<string, string | string[]> | null {
46
+ const f = getBunFFI()
47
+ if (!f) return null
48
+ const out = scratchFor(kind, input)
49
+ const w =
50
+ kind === 'query'
51
+ ? f.queryParsePackedInto(input, out)
52
+ : kind === 'cookie'
53
+ ? f.cookieParsePackedInto(input, out)
54
+ : f.formParsePackedInto(input, out)
55
+ if (w === 0 || w > out.length) return null
56
+ return pairsToObject(readPairsPacked(out.subarray(0, w)))
57
+ }
58
+
59
+ /** Shared FFI-first parse with an napi fallback (one encode, per kind). */
60
+ function parseVia(
61
+ kind: 'query' | 'cookie' | 'form',
62
+ input: Uint8Array,
63
+ ): Record<string, string | string[]> {
64
+ const fast = parsePackedInto(kind, input)
65
+ if (fast) return fast
66
+ const packed =
67
+ kind === 'query'
68
+ ? addon.queryParsePacked(input)
69
+ : kind === 'cookie'
70
+ ? addon.cookieParsePacked(input)
71
+ : addon.formParsePacked(input)
72
+ return pairsToObject(readPairsPacked(packed))
73
+ }
74
+
15
75
  /** Parse a query string (`a=1&b=2`) into an object via the native parser. */
16
76
  export function parseQueryString(query: string): Record<string, string | string[]> {
17
- const packed = addon.queryParsePacked(encoder.encode(query))
18
- return pairsToObject(readPairsPacked(packed))
77
+ return parseVia('query', encoder.encode(query))
19
78
  }
20
79
 
21
80
  /** Parse a cookie header (`a=1; b=2`) into an object via the native parser. */
22
81
  export function parseCookieHeader(header: string): Record<string, string | string[]> {
23
- const packed = addon.cookieParsePacked(encoder.encode(header))
24
- return pairsToObject(readPairsPacked(packed))
82
+ return parseVia('cookie', encoder.encode(header))
25
83
  }
26
84
 
27
85
  /** Parse an application/x-www-form-urlencoded body into a key/value object. */
28
86
  export function parseFormBody(body: Uint8Array): Record<string, string | string[]> {
29
- const packed = addon.formParsePacked(body)
30
- return pairsToObject(readPairsPacked(packed))
87
+ return parseVia('form', body)
31
88
  }
@@ -7,6 +7,7 @@
7
7
 
8
8
  import {
9
9
  detectedRuntime,
10
+ forceGc as forceGcDetected,
10
11
  isBunRuntime,
11
12
  isNodeRuntime,
12
13
  nodeMajorVersion as nodeMajorVersionDetected,
@@ -27,3 +28,9 @@ export const runtimeName: () => RuntimeName = () => detectedRuntime
27
28
 
28
29
  /** The Node.js major version when running under Node, otherwise `null`. */
29
30
  export const nodeMajorVersion: () => number | null = nodeMajorVersionDetected
31
+
32
+ /**
33
+ * Request a synchronous full GC when the runtime exposes one (`Bun.gc(true)` on
34
+ * Bun, `globalThis.gc?.()` under Node `--expose-gc`). A no-op otherwise.
35
+ */
36
+ export const forceGc: () => void = forceGcDetected
@@ -75,11 +75,7 @@ export interface TaskRuntime {
75
75
  /** gzip-compress `data` on a pool thread. */
76
76
  gzipCompress(data: Uint8Array, options?: TaskRunOptions): Promise<Uint8Array>
77
77
  /** Verify a password against a PHC string on a pool thread (10-200 ms CPU). */
78
- argon2Verify(
79
- password: Uint8Array,
80
- phc: Uint8Array,
81
- options?: TaskRunOptions,
82
- ): Promise<boolean>
78
+ argon2Verify(password: Uint8Array, phc: Uint8Array, options?: TaskRunOptions): Promise<boolean>
83
79
  /** PBKDF2-HMAC-SHA256 on a pool thread (10-200 ms CPU, 1-64 B output). */
84
80
  pbkdf2Sha256(
85
81
  password: Uint8Array,
@@ -156,6 +152,37 @@ function cancelledError(): Error {
156
152
  return err
157
153
  }
158
154
 
155
+ /** Native `taskSubmit*` return: the task was accepted onto the pool. */
156
+ const TASK_SUBMIT_ACCEPTED = 1
157
+ /** Native `taskSubmit*` return: the bounded work queue is full (overload). */
158
+ const TASK_SUBMIT_OVERLOADED = 2
159
+
160
+ /** Error raised when the bounded native task queue is at its admission bound. */
161
+ interface TaskOverloadedError extends Error {
162
+ /** Stable discriminant so callers can branch on overload. */
163
+ code: 'OVERLOADED'
164
+ }
165
+
166
+ /**
167
+ * Map a native `taskSubmit*` return code to the reason a task promise should
168
+ * reject with, or `null` when the submission was accepted. Pure and exported
169
+ * for tests, so the rejection mapping can be checked without driving the native
170
+ * pool into a real overload.
171
+ *
172
+ * @param code Native submit return: `1` accepted, `2` overloaded, else rejected.
173
+ * @returns The typed overload error, the generic rejection, or `null`.
174
+ */
175
+ export function taskSubmitError(code: number): Error | null {
176
+ if (code === TASK_SUBMIT_ACCEPTED) return null
177
+ if (code === TASK_SUBMIT_OVERLOADED) {
178
+ const err = new Error('task rejected: native work queue is full') as TaskOverloadedError
179
+ err.name = 'TaskOverloadedError'
180
+ err.code = 'OVERLOADED'
181
+ return err
182
+ }
183
+ return new Error('task rejected by the native runtime')
184
+ }
185
+
159
186
  /** Bunny-shaped doorbell handle (structural, so it needs no bun-types dep). */
160
187
  interface Doorbell {
161
188
  ptr: number | null
@@ -345,9 +372,7 @@ export function createTaskRuntime(options: TaskRuntimeOptions = {}): TaskRuntime
345
372
  } else if (status === TASK_STATUS_CANCELLED) {
346
373
  deferred.reject(cancelledError())
347
374
  } else {
348
- deferred.reject(
349
- new Error(decodeUtf8(bodyOf(buf, bodyOff, len, pooled)) || 'task failed'),
350
- )
375
+ deferred.reject(new Error(decodeUtf8(bodyOf(buf, bodyOff, len, pooled)) || 'task failed'))
351
376
  }
352
377
  completed++
353
378
  }
@@ -361,19 +386,20 @@ export function createTaskRuntime(options: TaskRuntimeOptions = {}): TaskRuntime
361
386
  queueMicrotask(drainNow)
362
387
  }
363
388
 
364
- function submit(
365
- op: number,
366
- args: Uint8Array,
367
- runOptions?: TaskRunOptions,
368
- ): Promise<Uint8Array> {
389
+ function submit(op: number, args: Uint8Array, runOptions?: TaskRunOptions): Promise<Uint8Array> {
390
+ ensureDoorbell()
369
391
  const id = nextId++
370
392
  return new Promise<Uint8Array>((resolve, reject) => {
371
393
  inflight.set(id, { out: null, keep: null, resolve, reject })
372
394
  retain()
373
- if (ffi.taskSubmit(op, args, id) === 0) {
395
+ const rejected = taskSubmitError(ffi.taskSubmit(op, args, id))
396
+ if (rejected) {
374
397
  inflight.delete(id)
375
- release()
376
- reject(new Error('task rejected by the native runtime'))
398
+ // Release the keep-alive only when this was the last in-flight task.
399
+ // An overload/invalid rejection must not tear down the interval (and
400
+ // its polling safety net) while other tasks are still pending.
401
+ if (inflight.size === 0) release()
402
+ reject(rejected)
377
403
  return
378
404
  }
379
405
  const signal = runOptions?.signal
@@ -398,14 +424,19 @@ export function createTaskRuntime(options: TaskRuntimeOptions = {}): TaskRuntime
398
424
  output: Uint8Array,
399
425
  runOptions?: TaskRunOptions,
400
426
  ): Promise<Uint8Array> {
427
+ ensureDoorbell()
401
428
  const id = nextId++
402
429
  return new Promise<Uint8Array>((resolve, reject) => {
403
430
  inflight.set(id, { out: output, keep: null, resolve, reject })
404
431
  retain()
405
- if (ffi.taskSubmitOut(op, args, id, output) === 0) {
432
+ const rejected = taskSubmitError(ffi.taskSubmitOut(op, args, id, output))
433
+ if (rejected) {
406
434
  inflight.delete(id)
407
- release()
408
- reject(new Error('task rejected by the native runtime'))
435
+ // Release the keep-alive only when this was the last in-flight task.
436
+ // An overload/invalid rejection must not tear down the interval (and
437
+ // its polling safety net) while other tasks are still pending.
438
+ if (inflight.size === 0) release()
439
+ reject(rejected)
409
440
  return
410
441
  }
411
442
  const signal = runOptions?.signal
@@ -431,14 +462,19 @@ export function createTaskRuntime(options: TaskRuntimeOptions = {}): TaskRuntime
431
462
  data: Uint8Array,
432
463
  runOptions?: TaskRunOptions,
433
464
  ): Promise<Uint8Array> {
465
+ ensureDoorbell()
434
466
  const id = nextId++
435
467
  return new Promise<Uint8Array>((resolve, reject) => {
436
468
  inflight.set(id, { out: null, keep: data, resolve, reject })
437
469
  retain()
438
- if (ffi.taskSubmitSlice(op, hdr, data, id) === 0) {
470
+ const rejected = taskSubmitError(ffi.taskSubmitSlice(op, hdr, data, id))
471
+ if (rejected) {
439
472
  inflight.delete(id)
440
- release()
441
- reject(new Error('task rejected by the native runtime'))
473
+ // Release the keep-alive only when this was the last in-flight task.
474
+ // An overload/invalid rejection must not tear down the interval (and
475
+ // its polling safety net) while other tasks are still pending.
476
+ if (inflight.size === 0) release()
477
+ reject(rejected)
442
478
  return
443
479
  }
444
480
  const signal = runOptions?.signal
@@ -453,19 +489,26 @@ export function createTaskRuntime(options: TaskRuntimeOptions = {}): TaskRuntime
453
489
  }
454
490
 
455
491
  // One process-wide trampoline; Bun marshals calls from pool threads onto the
456
- // JS thread. Rust coalesces so a burst rings it once.
492
+ // JS thread. Rust coalesces so a burst rings it once. Created lazily (and
493
+ // re-created after a shutdown) so the native side NEVER holds the address of
494
+ // a closed `JSCallback` — calling a freed trampoline is a use-after-free.
457
495
  // eslint-disable-next-line @typescript-eslint/no-require-imports
458
496
  const { JSCallback } = require('bun:ffi') as typeof import('bun:ffi')
459
- const cb = new JSCallback(
460
- () => {
461
- drainNow()
462
- },
463
- { args: [], returns: 'void', threadsafe: true } as unknown as ConstructorParameters<
464
- typeof JSCallback
465
- >[1],
466
- )
467
- doorbell = cb
468
- ffi.taskSetDoorbell(Number(cb.ptr))
497
+ function ensureDoorbell(): void {
498
+ if (doorbell !== null) return
499
+ const cb = new JSCallback(
500
+ () => {
501
+ drainNow()
502
+ },
503
+ { args: [], returns: 'void', threadsafe: true } as unknown as ConstructorParameters<
504
+ typeof JSCallback
505
+ >[1],
506
+ )
507
+ doorbell = cb
508
+ ffi.taskSetDoorbell(Number(cb.ptr))
509
+ }
510
+
511
+ ensureDoorbell()
469
512
 
470
513
  // A zero-copy result handoff via `toArrayBuffer(ptr, 0, len, dealloc)` was
471
514
  // prototyped here and REVERTED: Bun 1.4.2's deallocator hook segfaults
@@ -492,8 +535,7 @@ export function createTaskRuntime(options: TaskRuntimeOptions = {}): TaskRuntime
492
535
  // UNSUPPORTED from an older addon) fall back, so behavior can't regress.
493
536
  // Args are re-submitted on the retry step, so they own their memory.
494
537
  const args = encodeGzipDecompressArgs(data, runOptions?.maxDecompressed)
495
- const copyPath = (): Promise<Uint8Array> =>
496
- submit(TASK_OP.gzipDecompress, args, runOptions)
538
+ const copyPath = (): Promise<Uint8Array> => submit(TASK_OP.gzipDecompress, args, runOptions)
497
539
  const zeroCopyOnce = (cap: number): Promise<Uint8Array> =>
498
540
  submitInto(TASK_OP.gzipDecompressInto, args, Buffer.allocUnsafe(cap), runOptions)
499
541
  return zeroCopyOnce(size)
@@ -521,8 +563,7 @@ export function createTaskRuntime(options: TaskRuntimeOptions = {}): TaskRuntime
521
563
  },
522
564
  brotliDecompress(data, runOptions) {
523
565
  const args = encodeBrotliDecompressArgs(data, runOptions?.maxDecompressed)
524
- const copyPath = (): Promise<Uint8Array> =>
525
- submit(TASK_OP.brotliDecompress, args, runOptions)
566
+ const copyPath = (): Promise<Uint8Array> => submit(TASK_OP.brotliDecompress, args, runOptions)
526
567
  // Brotli has no ISIZE trailer, so there is no exact size hint: start from
527
568
  // a ratio guess and let the needed-size retry land it exactly (at most
528
569
  // one extra pass; on anything but TOO_SMALL, fall back to the copy op).
@@ -557,6 +598,9 @@ export function createTaskRuntime(options: TaskRuntimeOptions = {}): TaskRuntime
557
598
  deferred.reject(cancelledError())
558
599
  }
559
600
  inflight.clear()
601
+ // Zero the native doorbell BEFORE closing the JS trampoline: a worker
602
+ // completing after this point must never call a freed callback.
603
+ ffi.taskSetDoorbell(0)
560
604
  ffi.taskShutdown()
561
605
  release()
562
606
  doorbell?.close()