castrum 0.9.7 → 0.9.8

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 (84) 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 +501 -5
  12. package/dist/index.js +1648 -1325
  13. package/docs/BENCHMARKS.md +13 -0
  14. package/docs/CASE_STUDY.md +1 -1
  15. package/docs/ENVIRONMENT.md +1 -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/superpowers/plans/2026-09-18-castrum-hardening-cleanup.md +617 -0
  20. package/index.ts +1 -0
  21. package/package.json +3 -2
  22. package/rust/crypto/aead.rs +44 -0
  23. package/rust/crypto/argon2.rs +32 -2
  24. package/rust/crypto/jwt/tests.rs +19 -0
  25. package/rust/crypto/jwt/token.rs +43 -28
  26. package/rust/crypto/session.rs +6 -0
  27. package/rust/ffi/crypto.rs +63 -32
  28. package/rust/ffi/hashing.rs +48 -33
  29. package/rust/ffi/http.rs +62 -36
  30. package/rust/ffi/metrics.rs +6 -2
  31. package/rust/ffi/payload.rs +47 -27
  32. package/rust/ffi/task.rs +51 -32
  33. package/rust/ffi/tests.rs +24 -0
  34. package/rust/ffi/util.rs +40 -0
  35. package/rust/http/headers.rs +13 -0
  36. package/rust/http/multipart.rs +139 -0
  37. package/rust/ingress/native_route.rs +21 -3
  38. package/rust/task/completion.rs +7 -0
  39. package/rust/task/mod.rs +1 -1
  40. package/rust/task/tests.rs +30 -0
  41. package/src/ingress/decode/baked-result.ts +13 -5
  42. package/src/ingress/decode/fast-result.ts +1 -1
  43. package/src/ingress/decode/result-base.ts +16 -2
  44. package/src/ingress/handlers.ts +103 -31
  45. package/src/ingress/headers/memoized-headers.ts +40 -0
  46. package/src/ingress/native-route.ts +25 -6
  47. package/src/ingress/options.ts +26 -2
  48. package/src/ingress/packing/gather-raw-headers.ts +24 -8
  49. package/src/ingress/response/baked-response.ts +118 -28
  50. package/src/ingress/router.ts +14 -2
  51. package/src/ingress/routes/common.ts +26 -10
  52. package/src/ingress/routes/echo.ts +50 -7
  53. package/src/ingress/routes/json-write.ts +21 -8
  54. package/src/ingress/routes/responder.ts +4 -4
  55. package/src/ingress/server-node.ts +33 -14
  56. package/src/ingress/server.ts +3 -4
  57. package/src/ingress/shared.ts +1 -1
  58. package/src/ingress/types.ts +15 -0
  59. package/src/loader/ops.ts +1 -1
  60. package/src/native/ffi/build/codecs-selftest.ts +305 -0
  61. package/src/native/ffi/build/codecs.ts +0 -296
  62. package/src/native/ffi/build/instances-selftest.ts +264 -0
  63. package/src/native/ffi/build/instances.ts +10 -259
  64. package/src/native/ffi/build/parse-selftest.ts +238 -0
  65. package/src/native/ffi/build/parse.ts +11 -234
  66. package/src/native/ffi/build.ts +5 -1
  67. package/src/native/ffi/selftest.ts +3 -3
  68. package/src/native/ffi/symbols.ts +460 -0
  69. package/src/native/ffi.ts +2 -434
  70. package/src/native/ingress-binding.ts +39 -0
  71. package/src/runtime/codec.ts +24 -0
  72. package/src/runtime/types.ts +11 -0
  73. package/src/rust-ffi/batch/build.ts +18 -8
  74. package/src/rust-ffi/batch/types.ts +15 -0
  75. package/src/rust-ffi/options.ts +19 -0
  76. package/src/rust-ffi/scalar/crypto.ts +8 -0
  77. package/src/rust-ffi/scalar/factories.ts +47 -20
  78. package/src/rust-ffi/scalar/http.ts +60 -10
  79. package/src/rust-ffi/scalar/interface.ts +4 -1
  80. package/src/rust-ffi/scalar/payload.ts +27 -1
  81. package/src/shared/buffer-pool.ts +44 -9
  82. package/src/shared/codec.ts +26 -0
  83. package/src/shared/packed/parsers.ts +64 -7
  84. package/src/task/runtime.ts +29 -28
@@ -12,6 +12,7 @@ import type { PasswordHashOptions } from '../../native'
12
12
  import { encoder } from '../../shared/bytes'
13
13
  import { memoizeFfi, type RustClientContext, resolveNative } from '../context'
14
14
  import { writeInto } from '../into'
15
+ import { assertNonEmptySecret } from '../options'
15
16
 
16
17
  /** Auth / crypto scalar methods (`Pick<RustScalar, ...>`). */
17
18
  export function buildCrypto(ctx: RustClientContext) {
@@ -152,14 +153,17 @@ export function buildCrypto(ctx: RustClientContext) {
152
153
  return writeInto('verify cookie', output, value)
153
154
  },
154
155
  csrfToken(secret: Uint8Array): Uint8Array | string {
156
+ assertNonEmptySecret(secret, 'csrfToken')
155
157
  return resolveNative(ctx, 'csrfToken')(secret) as Uint8Array | string
156
158
  },
157
159
  csrfTokenInto(secret: Uint8Array, output: Uint8Array): number {
160
+ assertNonEmptySecret(secret, 'csrfTokenInto')
158
161
  const f = ffi()
159
162
  if (f) return f.csrfTokenInto(secret, output)
160
163
  return writeInto('csrf token', output, addon.csrfToken(secret))
161
164
  },
162
165
  csrfVerify(token: Uint8Array, secret: Uint8Array): boolean {
166
+ assertNonEmptySecret(secret, 'csrfVerify')
163
167
  return resolveNative(ctx, 'csrfVerify')(token, secret) as boolean
164
168
  },
165
169
  jwtSign(
@@ -175,6 +179,7 @@ export function buildCrypto(ctx: RustClientContext) {
175
179
  // preserves JS insertion order while napi's serde_json sorts keys — for
176
180
  // multi-key claims the two transports emit different-but-equally-valid
177
181
  // tokens (both verify; parity tests cross-verify semantically).
182
+ assertNonEmptySecret(secret, 'jwtSign')
178
183
  const f = ffi()
179
184
  if (f) {
180
185
  const token = f.jwtSignBytes(
@@ -198,6 +203,7 @@ export function buildCrypto(ctx: RustClientContext) {
198
203
  ttlSeconds?: number | null,
199
204
  nowSeconds?: number,
200
205
  ): Uint8Array | string {
206
+ assertNonEmptySecret(secret, 'jwtSignBytes')
201
207
  const f = ffi()
202
208
  if (f) {
203
209
  // The C-ABI `ttl <= 0` sentinel matches napi's Option<i64> (inject only
@@ -223,6 +229,7 @@ export function buildCrypto(ctx: RustClientContext) {
223
229
  ttlSeconds?: number | null,
224
230
  nowSeconds?: number,
225
231
  ): number {
232
+ assertNonEmptySecret(secret, 'jwtSignBytesInto')
226
233
  const f = ffi()
227
234
  if (f) {
228
235
  return f.jwtSignBytesInto(
@@ -245,6 +252,7 @@ export function buildCrypto(ctx: RustClientContext) {
245
252
  )
246
253
  },
247
254
  jwtVerify(token: Uint8Array, secret: Uint8Array, nowSeconds?: number): unknown {
255
+ assertNonEmptySecret(secret, 'jwtVerify')
248
256
  const f = ffi()
249
257
  if (f) {
250
258
  // Verify via the FFI cstring path (castrum_jwt_verify): the engine
@@ -27,7 +27,20 @@ import type { BunFFI } from '../../native/ffi'
27
27
  import { encoder } from '../../shared/bytes'
28
28
  import { decodeUtf8, encodeUtf8 } from '../../shared/codec'
29
29
  import type { RustClientContext } from '../context'
30
- import { asNumber } from '../options'
30
+ import { asNumber, assertNonEmptySecret } from '../options'
31
+
32
+ /**
33
+ * Convert a napi `innerPtr()` (u64) into a JS number handle, rejecting values
34
+ * above `Number.MAX_SAFE_INTEGER`: the `Number()` conversion would silently
35
+ * round a high canonical address into a wild pointer.
36
+ */
37
+ function handlePtr(inner: bigint | null | undefined): number {
38
+ const v = inner ?? 0n
39
+ if (v < 0n || v > BigInt(Number.MAX_SAFE_INTEGER)) {
40
+ throw new Error('castrum: native handle exceeds Number.MAX_SAFE_INTEGER')
41
+ }
42
+ return Number(v)
43
+ }
31
44
 
32
45
  // ── FFI-backed instance wrappers ───────────────────────────────────────────
33
46
  // On Bun, instances whose methods have a STATELESS C-ABI sibling are backed by
@@ -339,17 +352,24 @@ function ffiFormParser(ffi: BunFFI): FormParserInstance {
339
352
  * `values.join('\u001f')` — ZERO TextEncoder work (Bun 1.4 zero-copy text).
340
353
  */
341
354
  function ffiMetricsRegistry(ffi: BunFFI): MetricsRegistryInstance {
342
- const handle = ffi.metricsCreate()
355
+ let handle = ffi.metricsCreate()
356
+ // Destroyed state is terminal: every method that would pass the handle to
357
+ // native memory throws instead of dereferencing a freed pointer, and a
358
+ // second destroy is a no-op (not a double-free).
359
+ const live = (): number => {
360
+ if (handle === 0) throw new Error('metrics registry: already destroyed')
361
+ return handle
362
+ }
343
363
  return {
344
364
  counter(name, labelKeys) {
345
- return ffi.metricsCounter(handle, name, (labelKeys ?? []).join('\u001f'))
365
+ return ffi.metricsCounter(live(), name, (labelKeys ?? []).join('\u001f'))
346
366
  },
347
367
  gauge(name, labelKeys) {
348
- return ffi.metricsGauge(handle, name, (labelKeys ?? []).join('\u001f'))
368
+ return ffi.metricsGauge(live(), name, (labelKeys ?? []).join('\u001f'))
349
369
  },
350
370
  histogram(name, labelKeys, buckets) {
351
371
  return ffi.metricsHistogram(
352
- handle,
372
+ live(),
353
373
  name,
354
374
  (labelKeys ?? []).join('\u001f'),
355
375
  (buckets ?? []).join(','),
@@ -357,13 +377,13 @@ function ffiMetricsRegistry(ffi: BunFFI): MetricsRegistryInstance {
357
377
  },
358
378
  record(series, values, amount) {
359
379
  const joined = (values ?? []).join('\u001f')
360
- if (!ffi.metricsRecordStr(handle, series, joined, amount ?? 1)) {
380
+ if (!ffi.metricsRecordStr(live(), series, joined, amount ?? 1)) {
361
381
  throw new Error('metrics record: unknown series / arity mismatch / invalid amount')
362
382
  }
363
383
  },
364
384
  gaugeSet(series, values, value) {
365
385
  const joined = (values ?? []).join('\u001f')
366
- if (!ffi.metricsGaugeSetStr(handle, series, joined, value)) {
386
+ if (!ffi.metricsGaugeSetStr(live(), series, joined, value)) {
367
387
  throw new Error('metrics gauge set: unknown series / arity mismatch')
368
388
  }
369
389
  },
@@ -371,24 +391,27 @@ function ffiMetricsRegistry(ffi: BunFFI): MetricsRegistryInstance {
371
391
  // Probe large enough for typical registries (one native pass), then
372
392
  // grow exactly once if ever exceeded (needed-size convention).
373
393
  let out = new Uint8Array(8192)
374
- let w = ffi.metricsRender(handle, out)
394
+ let w = ffi.metricsRender(live(), out)
375
395
  if (w > out.length) {
376
396
  out = new Uint8Array(w)
377
- w = ffi.metricsRender(handle, out)
397
+ w = ffi.metricsRender(live(), out)
378
398
  }
379
399
  return decodeUtf8(out.subarray(0, w))
380
400
  },
381
401
  destroy() {
382
- ffi.metricsDestroy(handle)
402
+ if (handle !== 0) {
403
+ ffi.metricsDestroy(handle)
404
+ handle = 0
405
+ }
383
406
  },
384
407
  snapshot() {
385
408
  // Packed v1 dump — decoded by consumers (the @ignex/native metrics
386
409
  // wrapper); probe large, grow exactly once.
387
410
  let out = new Uint8Array(4096)
388
- let w = ffi.metricsSnapshot(handle, out)
411
+ let w = ffi.metricsSnapshot(live(), out)
389
412
  if (w > out.length) {
390
413
  out = new Uint8Array(w)
391
- w = ffi.metricsSnapshot(handle, out)
414
+ w = ffi.metricsSnapshot(live(), out)
392
415
  }
393
416
  return out.subarray(0, w)
394
417
  },
@@ -405,11 +428,12 @@ export function buildFactories(ctx: RustClientContext) {
405
428
  const ffi = transport.ffi
406
429
  if (ffi) {
407
430
  const napi = new addon.SchemaValidator(schema)
408
- return ffiSchemaValidator(napi, Number(napi.innerPtr?.() ?? 0n), ffi)
431
+ return ffiSchemaValidator(napi, handlePtr(napi.innerPtr?.()), ffi)
409
432
  }
410
433
  return new addon.SchemaValidator(schema)
411
434
  },
412
435
  createHmacSigner(key: Uint8Array): HmacSignerInstance {
436
+ assertNonEmptySecret(key, 'createHmacSigner')
413
437
  const ffi = transport.ffi
414
438
  if (ffi) return ffiHmacSigner(key, ffi)
415
439
  return new addon.HmacSigner(key)
@@ -418,7 +442,7 @@ export function buildFactories(ctx: RustClientContext) {
418
442
  const ffi = transport.ffi
419
443
  if (ffi) {
420
444
  const napi = new addon.TemplateRenderer(source)
421
- return ffiTemplateRenderer(napi, Number(napi.innerPtr?.() ?? 0n), ffi)
445
+ return ffiTemplateRenderer(napi, handlePtr(napi.innerPtr?.()), ffi)
422
446
  }
423
447
  return new addon.TemplateRenderer(source)
424
448
  },
@@ -440,7 +464,7 @@ export function buildFactories(ctx: RustClientContext) {
440
464
  const ffi = transport.ffi
441
465
  if (ffi) {
442
466
  const napi = new addon.ConditionalRequest(etagValue, lastModifiedSecs ?? undefined)
443
- return ffiConditionalRequest(napi, Number(napi.innerPtr?.() ?? 0n), ffi)
467
+ return ffiConditionalRequest(napi, handlePtr(napi.innerPtr?.()), ffi)
444
468
  }
445
469
  return new addon.ConditionalRequest(etagValue, lastModifiedSecs ?? undefined)
446
470
  },
@@ -448,7 +472,7 @@ export function buildFactories(ctx: RustClientContext) {
448
472
  const ffi = transport.ffi
449
473
  if (ffi) {
450
474
  const napi = new addon.AcceptNegotiator(supported)
451
- return ffiAcceptNegotiator(napi, Number(napi.innerPtr?.() ?? 0n), ffi)
475
+ return ffiAcceptNegotiator(napi, handlePtr(napi.innerPtr?.()), ffi)
452
476
  }
453
477
  return new addon.AcceptNegotiator(supported)
454
478
  },
@@ -458,11 +482,13 @@ export function buildFactories(ctx: RustClientContext) {
458
482
  return new addon.Base64Codec(urlSafe ?? undefined, padding ?? undefined)
459
483
  },
460
484
  createCookieSigner(secret: Uint8Array): CookieSignerInstance {
485
+ assertNonEmptySecret(secret, 'createCookieSigner')
461
486
  const ffi = transport.ffi
462
487
  if (ffi) return ffiCookieSigner(secret, ffi)
463
488
  return new addon.CookieSigner(secret)
464
489
  },
465
490
  createCsrfProtector(secret: Uint8Array): CsrfProtectorInstance {
491
+ assertNonEmptySecret(secret, 'createCsrfProtector')
466
492
  const ffi = transport.ffi
467
493
  if (ffi) return ffiCsrfProtector(secret, ffi)
468
494
  return new addon.CsrfProtector(secret)
@@ -474,15 +500,16 @@ export function buildFactories(ctx: RustClientContext) {
474
500
  const ffi = transport.ffi
475
501
  if (ffi) {
476
502
  const napi = new addon.UrlBuilder(base)
477
- return ffiUrlBuilder(napi, Number(napi.innerPtr?.() ?? 0n), ffi)
503
+ return ffiUrlBuilder(napi, handlePtr(napi.innerPtr?.()), ffi)
478
504
  }
479
505
  return new addon.UrlBuilder(base)
480
506
  },
481
507
  createJwtSigner(secret: Uint8Array, ttlSeconds?: number): JwtSignerInstance {
508
+ assertNonEmptySecret(secret, 'createJwtSigner')
482
509
  const ffi = transport.ffi
483
510
  if (ffi) {
484
511
  const napi = new addon.JwtSigner(secret, ttlSeconds ?? undefined)
485
- return ffiJwtSigner(napi, Number(napi.innerPtr?.() ?? 0n), ffi)
512
+ return ffiJwtSigner(napi, handlePtr(napi.innerPtr?.()), ffi)
486
513
  }
487
514
  return new addon.JwtSigner(secret, ttlSeconds ?? undefined)
488
515
  },
@@ -500,7 +527,7 @@ export function buildFactories(ctx: RustClientContext) {
500
527
  const ffi = transport.ffi
501
528
  if (ffi) {
502
529
  const napi = new addon.MediaTypeMatcher(expected)
503
- return ffiMediaTypeMatcher(napi, Number(napi.innerPtr?.() ?? 0n), ffi)
530
+ return ffiMediaTypeMatcher(napi, handlePtr(napi.innerPtr?.()), ffi)
504
531
  }
505
532
  return new addon.MediaTypeMatcher(expected)
506
533
  },
@@ -512,7 +539,7 @@ export function buildFactories(ctx: RustClientContext) {
512
539
  const ffi = transport.ffi
513
540
  if (ffi) {
514
541
  const napi = new addon.RateLimiter(limit, windowMs, maxEntries ?? undefined)
515
- return ffiRateLimiter(napi, Number(napi.innerPtr?.() ?? 0n), ffi)
542
+ return ffiRateLimiter(napi, handlePtr(napi.innerPtr?.()), ffi)
516
543
  }
517
544
  return new addon.RateLimiter(limit, windowMs, maxEntries ?? undefined)
518
545
  },
@@ -12,7 +12,7 @@
12
12
  import type { EncodingPrefResult, MediaTypeResult } from '../../native'
13
13
  import type { BunFFI } from '../../native/ffi'
14
14
  import { decoder, hasNul } from '../../shared/bytes'
15
- import { decodeUtf8Fatal, decodeUtf8Range } from '../../shared/codec'
15
+ import { decodeUtf8Fatal, decodeUtf8Range, decodeUtf8RangeView } from '../../shared/codec'
16
16
  import { packPairs } from '../../shared/packed'
17
17
  import { memoizeFfi, type RustClientContext, resolveNative } from '../context'
18
18
  import { writeInto } from '../into'
@@ -59,6 +59,19 @@ function u32LE(b: Uint8Array, off: number): number {
59
59
  )
60
60
  }
61
61
 
62
+ /** ASCII byte form of the two known media-type param keys (byte-compare
63
+ * targets — avoids decoding the key string for every packed param). */
64
+ const CHARSET_KEY_BYTES = new Uint8Array([0x63, 0x68, 0x61, 0x72, 0x73, 0x65, 0x74]) // 'charset'
65
+ const BOUNDARY_KEY_BYTES = new Uint8Array([0x62, 0x6f, 0x75, 0x6e, 0x64, 0x61, 0x72, 0x79]) // 'boundary'
66
+
67
+ /** Byte-equality of `b[off..off+len]` with the literal `lit` (no allocation). */
68
+ function keyBytesEqual(b: Uint8Array, off: number, lit: Uint8Array): boolean {
69
+ for (let i = 0; i < lit.length; i++) {
70
+ if ((b[off + i] ?? 0) !== (lit[i] ?? 0)) return false
71
+ }
72
+ return true
73
+ }
74
+
62
75
  /**
63
76
  * Unpack the `castrum_parse_media_type` verdict into the napi-shaped result.
64
77
  * Layout: `[u32 mediaTypeLen][mediaType][u32 charsetLen (0xFFFFFFFF = none)]
@@ -69,42 +82,75 @@ function u32LE(b: Uint8Array, off: number): number {
69
82
  function unpackMediaType(packed: Uint8Array): MediaTypeResult {
70
83
  // Ranged decode straight off the (pooled) packed buffer — no per-field
71
84
  // subarray views. ASCII fields take the latin1 fast path.
85
+ // ONE view per result: `decodeUtf8RangeView` decodes at absolute offsets
86
+ // into a bounded Buffer resolved once here — the per-field WeakMap lookup
87
+ // + offset arithmetic in `decodeUtf8Range` disappear (profile: unpackMediaType
88
+ // called the cached-view path 5x per result).
89
+ const view = Buffer.from(packed.buffer, packed.byteOffset, packed.byteLength)
72
90
  let off = 0
73
91
  const mtLen = u32LE(packed, off)
74
92
  off += 4
75
- const mediaType = decodeUtf8Range(packed, off, off + mtLen)
93
+ const mediaType = decodeUtf8RangeView(view, off, off + mtLen)
76
94
  off += mtLen
77
95
  let charset: string | null = null
78
96
  const csLen = u32LE(packed, off)
79
97
  off += 4
80
98
  if (csLen !== 0xffffffff) {
81
- charset = decodeUtf8Range(packed, off, off + csLen)
99
+ charset = decodeUtf8RangeView(view, off, off + csLen)
82
100
  off += csLen
83
101
  }
84
102
  let boundary: string | null = null
85
103
  const bLen = u32LE(packed, off)
86
104
  off += 4
87
105
  if (bLen !== 0xffffffff) {
88
- boundary = decodeUtf8Range(packed, off, off + bLen)
106
+ boundary = decodeUtf8RangeView(view, off, off + bLen)
89
107
  off += bLen
90
108
  }
91
109
  const count = u32LE(packed, off)
92
110
  off += 4
93
111
  const params: Record<string, string> = {}
112
+ // The summary slots above hold the FIRST charset/boundary occurrence (the
113
+ // Rust core fills them via `params.iter().find(...)`), while `params` keeps
114
+ // the LAST duplicate (JS assignment order). Reusing the already-decoded
115
+ // summary string for the first matching param skips one redundant ranged
116
+ // decode per named field without changing any observable value.
117
+ let charsetTaken = charset === null
118
+ let boundaryTaken = boundary === null
94
119
  for (let i = 0; i < count; i++) {
95
120
  const kLen = u32LE(packed, off)
96
121
  off += 4
97
- const key = decodeUtf8Range(packed, off, off + kLen)
122
+ // Byte-compare the param KEY against the ASCII literals BEFORE decoding:
123
+ // on a match the key decode is skipped (`params` is keyed by the identical
124
+ // literal) and the summary/decided-value path handles the value. A
125
+ // byte-match to 'charset'/'boundary' implies valid UTF-8 for those bytes,
126
+ // so the skipped decode was exact — observable behavior unchanged
127
+ // (non-matching keys decode normally, casing preserved).
128
+ let key: string
129
+ if (kLen === 7 && keyBytesEqual(packed, off, CHARSET_KEY_BYTES)) {
130
+ key = 'charset'
131
+ } else if (kLen === 8 && keyBytesEqual(packed, off, BOUNDARY_KEY_BYTES)) {
132
+ key = 'boundary'
133
+ } else {
134
+ key = decodeUtf8RangeView(view, off, off + kLen)
135
+ }
98
136
  off += kLen
99
137
  const vLen = u32LE(packed, off)
100
138
  off += 4
101
- const val = decodeUtf8Range(packed, off, off + vLen)
139
+ let val: string
140
+ if (!charsetTaken && key === 'charset' && charset !== null) {
141
+ val = charset
142
+ charsetTaken = true
143
+ } else if (!boundaryTaken && key === 'boundary' && boundary !== null) {
144
+ val = boundary
145
+ boundaryTaken = true
146
+ } else {
147
+ val = decodeUtf8RangeView(view, off, off + vLen)
148
+ }
102
149
  off += vLen
103
150
  params[key] = val
104
151
  }
105
152
  return { mediaType, charset, boundary, params }
106
153
  }
107
-
108
154
  /** Unpack the `castrum_parse_accept_encoding` verdict (f32 q-values) into the
109
155
  * napi-shaped array. Layout: `[u32 count]{[u32 encLen][enc][f32 q][u32 order]}`.
110
156
  * Ranged decode off the pooled buffer + shared f32 bit-reinterpret views —
@@ -276,7 +322,11 @@ export function buildHttp(ctx: RustClientContext) {
276
322
  queryParsePacked(input: Uint8Array): Uint8Array {
277
323
  const f = ffi()
278
324
  if (f) {
279
- const out = new Uint8Array(input.length * 9 + 16)
325
+ // For k nonempty pairs in n bytes, k <= (n + 1) / 2: each
326
+ // pair needs a byte and adjacent pairs need a separator. Packed size
327
+ // <= 4 + 8k + (n - k + 1) <= 5n + 8; decoding never expands bytes.
328
+ // Cookie/form siblings use the same bound and retain fresh ownership.
329
+ const out = new Uint8Array(input.length * 5 + 8)
280
330
  const w = f.queryParsePackedInto(input, out)
281
331
  return out.subarray(0, w)
282
332
  }
@@ -288,7 +338,7 @@ export function buildHttp(ctx: RustClientContext) {
288
338
  cookieParsePacked(input: Uint8Array): Uint8Array {
289
339
  const f = ffi()
290
340
  if (f) {
291
- const out = new Uint8Array(input.length * 9 + 16)
341
+ const out = new Uint8Array(input.length * 5 + 8)
292
342
  const w = f.cookieParsePackedInto(input, out)
293
343
  return out.subarray(0, w)
294
344
  }
@@ -301,7 +351,7 @@ export function buildHttp(ctx: RustClientContext) {
301
351
  const f = ffi()
302
352
  if (f) {
303
353
  // x-www-form-urlencoded uses the SAME packed-pairs core as query parse.
304
- const out = new Uint8Array(input.length * 9 + 16)
354
+ const out = new Uint8Array(input.length * 5 + 8)
305
355
  const w = f.formParsePackedInto(input, out)
306
356
  return out.subarray(0, w)
307
357
  }
@@ -215,7 +215,10 @@ export interface RustScalar {
215
215
  createMediaTypeMatcher(expected: Uint8Array): MediaTypeMatcherInstance
216
216
  /**
217
217
  * Sharded fixed-window per-key rate limiter. `maxEntries` clamps internally
218
- * (default 1,048,576). Each instance owns an independent budget.
218
+ * (default 1,048,576) and has an effective floor of 16,384
219
+ * (`max_per_shard = max(maxEntries / 256, 64)` × 256 shards) — values below
220
+ * that are silently raised, so don't rely on it to bound a tiny key space.
221
+ * Each instance owns an independent budget.
219
222
  * @remarks Scalar per-check cost: ~3-14x SLOWER than the JS Map baseline —
220
223
  * prefer the ingress pipeline (one FFI for all stages) for native rate
221
224
  * limiting in a request path. [measured 2026-08]
@@ -72,6 +72,11 @@ export function buildPayload(ctx: RustClientContext) {
72
72
  // Lazy-memoized ffi surface: binds on first call, single local read after.
73
73
  const ffi = memoizeFfi(transport)
74
74
 
75
+ // Per-Worker reused scratch for the object-returning multipartParse (the
76
+ // packed bytes are consumed synchronously — data is copied out, so the
77
+ // scratch never escapes; NOT shared with the byte-returning packed paths).
78
+ let SCRATCH: Uint8Array | null = null
79
+
75
80
  return {
76
81
  gzipCompress(data: Uint8Array, level?: number | null): Uint8Array {
77
82
  // Optimal by default under Bun: `Bun.gzipSync` (native zlib) is ~2x
@@ -140,8 +145,29 @@ export function buildPayload(ctx: RustClientContext) {
140
145
  multipartParse(body: Uint8Array, boundary: Uint8Array): MultipartPart[] {
141
146
  // FFI-first: packed parts (castrum_multipart_parse_packed) → unpack to
142
147
  // the object shape. napi keeps its object path.
148
+ //
149
+ // REUSED SCRATCH: the unpack consumes the packed bytes synchronously and
150
+ // each part's `data` is COPIED out (napi parity) — nothing aliases the
151
+ // scratch after return, so the escaping-buffer rule does not apply here.
152
+ // Sizing matches the allocating sibling (body + boundary + 64).
143
153
  const f = ffi()
144
- if (f) return unpackMultipart(f.multipartParsePacked(body, boundary))
154
+ if (f) {
155
+ const need = body.length + boundary.length + 64
156
+ // Keep retained scratch bounded; the allocating transport handles exact
157
+ // growth and the output cap for large uploads. The public Into wrapper
158
+ // THROWS on insufficient capacity, rather than returning a needed size.
159
+ if (need > 64 * 1024) return unpackMultipart(f.multipartParsePacked(body, boundary))
160
+ if (!SCRATCH || SCRATCH.length < need) SCRATCH = new Uint8Array(need)
161
+ let w: number
162
+ try {
163
+ w = f.multipartParsePackedInto(body, boundary, SCRATCH)
164
+ } catch {
165
+ // An estimate miss (e.g. UTF-8 replacement expansion) uses the same
166
+ // checked allocating path. Malformed input still fails there.
167
+ return unpackMultipart(f.multipartParsePacked(body, boundary))
168
+ }
169
+ return unpackMultipart(SCRATCH.subarray(0, w))
170
+ }
145
171
  // Normalize napi `Option<String>` (undefined) → null and expose the
146
172
  // camelCase `contentType` key (napi renames `content_type` to camelCase).
147
173
  return addon.multipartParse(body, boundary).map((p) => ({
@@ -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. */
@@ -204,11 +235,14 @@ export class BufferPool {
204
235
  // Learn the observed demand so future allocations pre-size to it.
205
236
  this.estimate?.sample(minSize)
206
237
 
207
- // 1) Reuse a free buffer that is already large enough.
238
+ // 1) Reuse a free buffer that is already large enough. Free-list ORDER is
239
+ // irrelevant, so remove by swap-with-last (O(1), no splice compaction/
240
+ // allocation) — this is a per-request hot path.
208
241
  for (let i = 0; i < this.free.length; i++) {
209
242
  const candidate = this.free[i] as Uint8Array
210
243
  if (candidate.byteLength >= minSize) {
211
- this.free.splice(i, 1)
244
+ const last = this.free.pop() as Uint8Array
245
+ if (i < this.free.length) this.free[i] = last
212
246
  return candidate
213
247
  }
214
248
  }
@@ -227,7 +261,8 @@ export class BufferPool {
227
261
  }
228
262
  }
229
263
  if (largestBuf !== null) {
230
- this.free.splice(largest, 1)
264
+ const last = this.free.pop() as Uint8Array
265
+ if (largest < this.free.length) this.free[largest] = last
231
266
  this.created++
232
267
  return new Uint8Array(target)
233
268
  }
@@ -241,16 +276,16 @@ export class BufferPool {
241
276
  private releaseBuffer(buffer: Uint8Array): void {
242
277
  if (this.free.length >= this.maxBuffers) {
243
278
  // Free list is full: drop the smallest retained buffer to stay bounded.
244
- let smallestBuf: Uint8Array | null = null
245
279
  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
280
+ for (let i = 1; i < this.free.length; i++) {
281
+ if (
282
+ (this.free[i] as Uint8Array).byteLength < (this.free[smallest] as Uint8Array).byteLength
283
+ ) {
250
284
  smallest = i
251
285
  }
252
286
  }
253
- this.free.splice(smallest, 1)
287
+ const last = this.free.pop() as Uint8Array
288
+ if (smallest < this.free.length) this.free[smallest] = last
254
289
  }
255
290
  this.free.push(buffer)
256
291
  }
@@ -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
  *
@@ -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
  }