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
@@ -47,17 +47,32 @@ export interface RustBatch {
47
47
  base64UrlDecode(items: Uint8Array[]): Uint8Array[]
48
48
 
49
49
  // ── Backend-framework features ──
50
+ /**
51
+ * Parallel password hashing. WARNING: the SAME `salt` is applied to every
52
+ * item. Reusing one salt across real user passwords enables a single
53
+ * precomputation to attack them all — generate a unique salt per password in
54
+ * production (this batch form is for precomputed/benchmark workloads).
55
+ */
50
56
  passwordHash(
51
57
  passwords: Uint8Array[],
52
58
  salt: Uint8Array,
53
59
  options?: PasswordHashOptions | null,
54
60
  ): Uint8Array[]
61
+ /**
62
+ * Parallel AEAD encrypt. `nonce` MUST be exactly 12 bytes and MUST be fresh
63
+ * for every batch call with the same key: a unique per-item nonce is derived
64
+ * by XOR-ing the item index into the low 8 bytes, so reusing a base nonce
65
+ * across calls reuses keystream/tag material for colliding indices
66
+ * (catastrophic for AES-GCM / ChaCha20-Poly1305). A non-12-byte nonce is
67
+ * rejected with an error.
68
+ */
55
69
  aeadEncrypt(
56
70
  key: Uint8Array,
57
71
  nonce: Uint8Array,
58
72
  items: Uint8Array[],
59
73
  algorithm?: string | null,
60
74
  ): Uint8Array[]
75
+ /** Parallel AEAD decrypt. Pass the SAME base nonce and item order as the encrypt call. */
61
76
  aeadDecrypt(
62
77
  key: Uint8Array,
63
78
  nonce: Uint8Array,
@@ -21,6 +21,51 @@ import { normalizeExt, resolveRayonThreads } from './options'
21
21
  // batch.ts uses for its packed entries.
22
22
  const nativeFnCache = new Map<string, unknown>()
23
23
 
24
+ /** Max entries retained per process-wide MIME cache (insertion-order eviction). */
25
+ const MIME_CACHE_MAX = 1024
26
+
27
+ /**
28
+ * Process-wide extension-bytes → MIME-bytes cache shared by every client
29
+ * context: the native addon is a process singleton, so `extension → MIME` is a
30
+ * global pure lookup. Bounded by `MIME_CACHE_MAX`; on overflow the oldest half
31
+ * is evicted via {@link evictOldestHalf}. Exported for the memory-flush path
32
+ * and tests — callers must NOT mutate the returned slices.
33
+ */
34
+ export const mimeByText = new Map<string, Uint8Array>()
35
+
36
+ /**
37
+ * Process-wide extension-string → MIME-string memo (the `text.mimeFromExtension`
38
+ * fast path). Bounded exactly like {@link mimeByText}.
39
+ */
40
+ export const mimeStrCache = new Map<string, string>()
41
+
42
+ /**
43
+ * Evict the oldest half of a cache map (insertion order) and return the keys
44
+ * dropped. Generational eviction — halving at the cap amortizes the cost rather
45
+ * than paying a delete per insertion. Returns an empty array below the cap.
46
+ */
47
+ export function evictOldestHalf<K, V>(map: Map<K, V>): K[] {
48
+ if (map.size < MIME_CACHE_MAX) return []
49
+ const drop = Math.ceil(map.size / 2)
50
+ const dropped: K[] = []
51
+ for (const key of map.keys()) {
52
+ if (dropped.length >= drop) break
53
+ dropped.push(key)
54
+ }
55
+ for (const key of dropped) map.delete(key)
56
+ return dropped
57
+ }
58
+
59
+ /**
60
+ * Clear the process-wide MIME caches (both the bytes and string forms). This is
61
+ * the impure-boundary hook consumed by the public `flushMemory()`; safe to call
62
+ * at any time, including before the addon has ever been loaded.
63
+ */
64
+ export function clearMimeCaches(): void {
65
+ mimeByText.clear()
66
+ mimeStrCache.clear()
67
+ }
68
+
24
69
  /**
25
70
  * Resolve a native op on first use and cache it for all subsequent calls —
26
71
  * FFI-FIRST via the runtime adapter's transport (`bun:ffi` on Bun, napi
@@ -91,7 +136,6 @@ export interface RustClientContext {
91
136
  runtime: NativeRuntimeAdapter
92
137
  /** Per-instance toggles (mime/hmac caching). */
93
138
  state: { mimeCache: boolean; hmacCache: boolean }
94
- mimeByText: Map<string, Uint8Array>
95
139
  hmacSigners: WeakMap<Uint8Array, HmacSignerInstance>
96
140
 
97
141
  /** Initialize the process-wide rayon pool exactly once (no-op after). */
@@ -110,7 +154,6 @@ export interface RustClientContext {
110
154
 
111
155
  /** Build a per-instance context from client options. */
112
156
  export function createContext(options: RustOptions): RustClientContext {
113
- const mimeByText = new Map<string, Uint8Array>()
114
157
  const hmacSigners = new WeakMap<Uint8Array, HmacSignerInstance>()
115
158
  const state = {
116
159
  mimeCache: options.mimeCache !== false,
@@ -152,6 +195,7 @@ export function createContext(options: RustOptions): RustClientContext {
152
195
 
153
196
  let val = mimeByText.get(key)
154
197
  if (!val) {
198
+ evictOldestHalf(mimeByText)
155
199
  val = addon.mimeFromExtension(ext)
156
200
  mimeByText.set(key, val)
157
201
  }
@@ -181,7 +225,6 @@ export function createContext(options: RustOptions): RustClientContext {
181
225
  addon,
182
226
  runtime: runtimeNative,
183
227
  state,
184
- mimeByText,
185
228
  hmacSigners,
186
229
  ensurePool,
187
230
  isPoolInitialized,
@@ -93,6 +93,25 @@ export function asNumber(value: unknown): number {
93
93
  return 0
94
94
  }
95
95
 
96
+ /**
97
+ * Throw when a signing secret/key is empty.
98
+ *
99
+ * An empty HMAC/JWT/CSRF secret is effectively a public function — anyone can
100
+ * compute the MAC — so a blank/missing env var would silently mint forgeable
101
+ * tokens. The raw `rust.hmacSha256` primitive deliberately stays tolerant of an
102
+ * empty key (see the empty-input matrix); this guards the auth surfaces built
103
+ * on top of it.
104
+ *
105
+ * @param secret - The secret/key bytes about to be used.
106
+ * @param op - Operation name for the error message.
107
+ * @throws Error when `secret` is empty.
108
+ */
109
+ export function assertNonEmptySecret(secret: Uint8Array, op: string): void {
110
+ if (secret.byteLength === 0) {
111
+ throw new Error(`${op}: secret must not be empty`)
112
+ }
113
+ }
114
+
96
115
  /** Normalize a file-extension byte slice to a lowercase string w/o a dot. */
97
116
  export function normalizeExt(ext: Uint8Array): string {
98
117
  let s: string
@@ -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
  },
@@ -532,5 +559,11 @@ export function buildFactories(ctx: RustClientContext) {
532
559
  rayonNumThreads(): number {
533
560
  return asNumber(addon.rayonNumThreads() as unknown)
534
561
  },
562
+ clearSchemaCache(): void {
563
+ // Defensive: a stale addon built before this symbol, or a failed bind,
564
+ // must make this a no-op rather than a throw (flushMemory calls it).
565
+ const native = addon as unknown as { clearSchemaCache?: () => void }
566
+ native.clearSchemaCache?.()
567
+ },
535
568
  }
536
569
  }
@@ -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]
@@ -234,6 +237,20 @@ export interface RustScalar {
234
237
  createMetricsRegistry(): MetricsRegistryInstance
235
238
  initThreadPool(rayonThreads?: number): void
236
239
  rayonNumThreads(): number
240
+ /**
241
+ * Drop the process-wide compiled request-body schema cache. Identical schema
242
+ * bytes are compiled once and shared across `Ingress` / `NativeRoute`
243
+ * instances; this releases those compiled validators (already-constructed
244
+ * instances keep their own shared reference and are unaffected). Idempotent,
245
+ * and a safe no-op when the native addon is absent or predates the symbol
246
+ * (the public `flushMemory()` relies on that).
247
+ *
248
+ * Maintenance-only escape hatch — NOT a benchmarked op, so it is intentionally
249
+ * exempt from `PROVEN_SELECTION` / `selection.json` (there is no native-vs-JS
250
+ * winner to classify). `test/unit/contract/proven.test.ts` only requires
251
+ * registry coverage for selection.json ops + Bun built-ins.
252
+ */
253
+ clearSchemaCache(): void
237
254
 
238
255
  // ── Backend-framework scalar features ──
239
256
  jwtSign(
@@ -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) => ({
@@ -14,7 +14,7 @@
14
14
  // napi fallback) — no inline `isBun()` / `getBunFFI()`.
15
15
 
16
16
  import { decoder, encoder, hasNul } from '../shared/bytes'
17
- import { type RustClientContext, resolveNative } from './context'
17
+ import { evictOldestHalf, mimeStrCache, type RustClientContext, resolveNative } from './context'
18
18
 
19
19
  /**
20
20
  * Scratch for the byte-path `Sec-WebSocket-Accept` fallback (28 chars; the
@@ -67,9 +67,9 @@ export function buildText(ctx: RustClientContext): RustText {
67
67
  const { builtins, transport } = ctx.runtime
68
68
  // String memo: the byte path already caches the native bytes; this avoids the
69
69
  // encode → native lookup → slice → decode round-trip for repeated extension
70
- // strings (the common case) — 0 allocations on a cache hit.
71
- const mimeStrCache = new Map<string, string>()
72
-
70
+ // strings (the common case) — 0 allocations on a cache hit. The map is
71
+ // process-wide and bounded by `evictOldestHalf` (MIME_CACHE_MAX) — see
72
+ // ./context.ts.
73
73
  return {
74
74
  mimeFromExtension(ext) {
75
75
  let mime = mimeStrCache.get(ext)
@@ -81,6 +81,7 @@ export function buildText(ctx: RustClientContext): RustText {
81
81
  mime = f
82
82
  ? (f.mimeFromExtension(ext) ?? 'application/octet-stream')
83
83
  : decoder.decode(ctx.cachedMime(encoder.encode(ext)))
84
+ evictOldestHalf(mimeStrCache)
84
85
  mimeStrCache.set(ext, mime)
85
86
  }
86
87
  return mime