castrum 0.9.3 → 0.9.6

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 (206) hide show
  1. package/Cargo.lock +2242 -0
  2. package/Cargo.toml +155 -0
  3. package/README.md +14 -6
  4. package/build.rs +5 -0
  5. package/castrum.darwin-arm64.node +0 -0
  6. package/castrum.darwin-x64.node +0 -0
  7. package/castrum.linux-arm64-gnu.node +0 -0
  8. package/castrum.linux-arm64-musl.node +0 -0
  9. package/castrum.linux-x64-gnu.node +0 -0
  10. package/castrum.linux-x64-musl.node +0 -0
  11. package/castrum.linux-x64-v3-gnu.node +0 -0
  12. package/castrum.win32-x64-msvc.node +0 -0
  13. package/dist/index.d.ts +1508 -1291
  14. package/dist/index.js +3391 -2069
  15. package/docs/API.md +2 -1
  16. package/docs/ARCHITECTURE.md +3 -2
  17. package/docs/CASE_STUDY.md +5 -3
  18. package/docs/ENVIRONMENT.md +25 -2
  19. package/docs/FFI_BUN_GUIDE.md +242 -14
  20. package/docs/HOW_TO_ADD_AN_OP.md +10 -0
  21. package/docs/REPO_MAP.md +17 -6
  22. package/docs/RND-CONCURRENCY.md +772 -0
  23. package/docs/bun-builtins-decision-matrix.md +4 -1
  24. package/index.ts +24 -25
  25. package/package.json +173 -156
  26. package/rust/crypto/aead.rs +378 -0
  27. package/rust/crypto/argon2.rs +265 -0
  28. package/rust/crypto/base64.rs +420 -0
  29. package/rust/crypto/bcrypt.rs +89 -0
  30. package/rust/crypto/cookie_sign.rs +169 -0
  31. package/rust/crypto/csrf.rs +106 -0
  32. package/rust/crypto/ed25519.rs +215 -0
  33. package/rust/crypto/hashing.rs +140 -0
  34. package/rust/crypto/hmac_sha256.rs +88 -0
  35. package/rust/crypto/jwt/api.rs +286 -0
  36. package/rust/crypto/jwt/mod.rs +22 -0
  37. package/rust/crypto/jwt/tests.rs +312 -0
  38. package/rust/crypto/jwt/token.rs +360 -0
  39. package/rust/crypto/mod.rs +27 -0
  40. package/rust/crypto/pbkdf2.rs +105 -0
  41. package/rust/crypto/random_token.rs +85 -0
  42. package/rust/crypto/session.rs +222 -0
  43. package/rust/ffi/crypto.rs +708 -0
  44. package/rust/ffi/hashing.rs +285 -0
  45. package/rust/ffi/http.rs +752 -0
  46. package/rust/ffi/ingress.rs +303 -0
  47. package/rust/ffi/json.rs +517 -0
  48. package/rust/ffi/jwt.rs +419 -0
  49. package/rust/ffi/metrics.rs +373 -0
  50. package/rust/ffi/mod.rs +69 -0
  51. package/rust/ffi/payload.rs +275 -0
  52. package/rust/ffi/probe.rs +60 -0
  53. package/rust/ffi/rate_limit.rs +77 -0
  54. package/rust/ffi/route.rs +85 -0
  55. package/rust/ffi/task.rs +172 -0
  56. package/rust/ffi/tests.rs +2333 -0
  57. package/rust/ffi/util.rs +152 -0
  58. package/rust/ffi/validators.rs +123 -0
  59. package/rust/http/accept.rs +606 -0
  60. package/rust/http/cookie_parser.rs +140 -0
  61. package/rust/http/etag.rs +289 -0
  62. package/rust/http/form.rs +136 -0
  63. package/rust/http/headers.rs +267 -0
  64. package/rust/http/http_date.rs +286 -0
  65. package/rust/http/http_parser.rs +252 -0
  66. package/rust/http/media_type.rs +355 -0
  67. package/rust/http/method.rs +200 -0
  68. package/rust/http/mime_lookup.rs +130 -0
  69. package/rust/http/mod.rs +32 -0
  70. package/rust/http/multipart.rs +685 -0
  71. package/rust/http/query_parser.rs +211 -0
  72. package/rust/http/url_codec.rs +296 -0
  73. package/rust/http/url_join.rs +324 -0
  74. package/rust/ingress/api.rs +355 -0
  75. package/rust/ingress/cors.rs +403 -0
  76. package/rust/ingress/ingress_constants.rs +209 -0
  77. package/rust/ingress/ip_trust.rs +312 -0
  78. package/rust/ingress/mod.rs +48 -0
  79. package/rust/ingress/native_route.rs +770 -0
  80. package/rust/ingress/options.rs +176 -0
  81. package/rust/ingress/output.rs +406 -0
  82. package/rust/ingress/packed.rs +191 -0
  83. package/rust/ingress/pipeline.rs +534 -0
  84. package/rust/ingress/proxy.rs +138 -0
  85. package/rust/ingress/rate_limit.rs +538 -0
  86. package/rust/ingress/terminal.rs +114 -0
  87. package/rust/ingress/tests.rs +567 -0
  88. package/rust/ingress/time.rs +70 -0
  89. package/rust/json/fast_schema/capture.rs +336 -0
  90. package/rust/json/fast_schema/compile.rs +556 -0
  91. package/rust/json/fast_schema/cursor.rs +368 -0
  92. package/rust/json/fast_schema/email.rs +282 -0
  93. package/rust/json/fast_schema/errors.rs +247 -0
  94. package/rust/json/fast_schema/mod.rs +57 -0
  95. package/rust/json/fast_schema/tests.rs +1143 -0
  96. package/rust/json/fast_schema/types.rs +140 -0
  97. package/rust/json/fast_schema/validate.rs +931 -0
  98. package/rust/json/json_ops.rs +121 -0
  99. package/rust/json/json_schema.rs +837 -0
  100. package/rust/json/json_ser.rs +883 -0
  101. package/rust/json/mod.rs +19 -0
  102. package/rust/json/napi_marshal.rs +318 -0
  103. package/rust/json/patch/api.rs +60 -0
  104. package/rust/json/patch/engine.rs +153 -0
  105. package/rust/json/patch/mod.rs +47 -0
  106. package/rust/json/patch/ops.rs +270 -0
  107. package/rust/json/patch/pointer.rs +49 -0
  108. package/rust/json/patch/tests.rs +499 -0
  109. package/rust/lib.rs +73 -0
  110. package/rust/metrics/api.rs +148 -0
  111. package/rust/metrics/mod.rs +21 -0
  112. package/rust/metrics/registry.rs +961 -0
  113. package/rust/panic_safety.rs +40 -0
  114. package/rust/payload/compress.rs +512 -0
  115. package/rust/payload/mod.rs +13 -0
  116. package/rust/payload/sse.rs +214 -0
  117. package/rust/payload/template.rs +253 -0
  118. package/rust/payload/websocket.rs +69 -0
  119. package/rust/payload/ws_frames.rs +354 -0
  120. package/rust/proptest_suite.rs +139 -0
  121. package/rust/selection.rs +95 -0
  122. package/rust/src/bin/native-bench.rs +495 -0
  123. package/rust/task/completion.rs +119 -0
  124. package/rust/task/mod.rs +65 -0
  125. package/rust/task/ops.rs +503 -0
  126. package/rust/task/runtime.rs +220 -0
  127. package/rust/task/tests.rs +231 -0
  128. package/rust/test_support.rs +71 -0
  129. package/rust/util/batch/api.rs +266 -0
  130. package/rust/util/batch/core.rs +473 -0
  131. package/rust/util/batch/mod.rs +23 -0
  132. package/rust/util/batch/tests.rs +445 -0
  133. package/rust/util/batch_core.rs +184 -0
  134. package/rust/util/bytes.rs +594 -0
  135. package/rust/util/mod.rs +34 -0
  136. package/rust/util/packed.rs +473 -0
  137. package/rust/util/text.rs +130 -0
  138. package/rust/util/threadpool.rs +128 -0
  139. package/rust/util/validation.rs +419 -0
  140. package/rust-toolchain.toml +17 -0
  141. package/scripts/postinstall.mjs +199 -0
  142. package/src/ingress/constants.ts +1 -1
  143. package/src/ingress/context.ts +2 -2
  144. package/src/ingress/decode/baked-result.ts +13 -13
  145. package/src/ingress/decode/fast-result.ts +17 -17
  146. package/src/ingress/errors.ts +7 -7
  147. package/src/ingress/headers/baked-templates.ts +1 -1
  148. package/src/ingress/headers/fast-templates.ts +3 -3
  149. package/src/ingress/health.ts +1 -2
  150. package/src/ingress/index.ts +1 -1
  151. package/src/ingress/native-route.ts +9 -5
  152. package/src/ingress/options.ts +1 -1
  153. package/src/ingress/packing/gather-raw-headers.ts +1 -1
  154. package/src/ingress/packing/route-wire.ts +1 -5
  155. package/src/ingress/response/error-bodies.ts +5 -5
  156. package/src/ingress/response/terminal.ts +1 -1
  157. package/src/ingress/routes/echo.ts +4 -4
  158. package/src/ingress/routes/fallback.ts +2 -2
  159. package/src/ingress/routes/head.ts +2 -2
  160. package/src/ingress/routes/native.ts +2 -1
  161. package/src/ingress/routes/options.ts +1 -1
  162. package/src/ingress/routes/read.ts +2 -2
  163. package/src/ingress/server.ts +10 -6
  164. package/src/ingress/status.ts +7 -7
  165. package/src/integration/batch.ts +1 -1
  166. package/src/integration/index.ts +9 -9
  167. package/src/integration/pipeline.ts +6 -6
  168. package/src/integration/websocket.ts +2 -2
  169. package/src/loader/index.ts +1 -5
  170. package/src/loader/ops.ts +1 -1
  171. package/src/native/ffi/build/codecs.ts +130 -12
  172. package/src/native/ffi/build/compress.ts +22 -10
  173. package/src/native/ffi/build/instances.ts +46 -4
  174. package/src/native/ffi/build/metrics.ts +247 -0
  175. package/src/native/ffi/build/parse.ts +59 -23
  176. package/src/native/ffi/build/task.ts +79 -0
  177. package/src/native/ffi/build/util.ts +32 -1
  178. package/src/native/ffi/build.ts +4 -0
  179. package/src/native/ffi/selftest.ts +12 -3
  180. package/src/native/ffi/types.ts +151 -9
  181. package/src/native/ffi.ts +160 -15
  182. package/src/native/index.ts +1 -1
  183. package/src/native/loader.ts +55 -5
  184. package/src/native/types/instances.ts +34 -0
  185. package/src/native/types.ts +11 -0
  186. package/src/runtime/codec.ts +53 -9
  187. package/src/runtime/types.ts +7 -0
  188. package/src/rust-ffi/batch/build.ts +16 -9
  189. package/src/rust-ffi/index.ts +7 -6
  190. package/src/rust-ffi/options.ts +1 -3
  191. package/src/rust-ffi/proven.ts +3 -3
  192. package/src/rust-ffi/scalar/crypto.ts +1 -1
  193. package/src/rust-ffi/scalar/factories.ts +73 -0
  194. package/src/rust-ffi/scalar/hashing.ts +1 -1
  195. package/src/rust-ffi/scalar/http.ts +71 -27
  196. package/src/rust-ffi/scalar/index.ts +2 -0
  197. package/src/rust-ffi/scalar/interface.ts +20 -0
  198. package/src/rust-ffi/scalar/payload.ts +11 -3
  199. package/src/rust-ffi/scalar/validation.ts +121 -0
  200. package/src/rust-ffi/text.ts +25 -3
  201. package/src/selection.ts +1 -1
  202. package/src/shared/bytes.ts +24 -4
  203. package/src/shared/codec.ts +18 -3
  204. package/src/task/index.ts +24 -0
  205. package/src/task/op.ts +171 -0
  206. package/src/task/runtime.ts +568 -0
@@ -29,14 +29,23 @@ export interface BunFFI {
29
29
  /** RFC 3986 percent-encode into `output`; returns bytes written. */
30
30
  urlEncodeInto(input: Uint8Array, output: Uint8Array): number
31
31
 
32
- // ── Validators (u8 → boolean) — `cstring` ARG ────────────────────
33
- // The input crosses as a `cstring` ARG: the engine transcodes the JS string
32
+ // ── Validators (u8 → boolean) ─────────────────────────────────────
33
+ // String forms cross as a `cstring` ARG: the engine transcodes the JS string
34
34
  // to a call-scoped NUL-terminated UTF-8 buffer in-engine, so the JS side does
35
- // ZERO `encoder.encode` work and Rust borrows via `CStr::from_ptr`.
35
+ // ZERO encode work and Rust borrows via `CStr::from_ptr`. Byte forms are the
36
+ // `(ptr,len)` siblings — zero transcode when the caller already holds bytes.
36
37
  validateEmail(input: string): boolean
37
38
  validateUuid(input: string): boolean
38
39
  validateIpv4(input: string): boolean
39
40
  validateIpv6(input: string): boolean
41
+ /** Byte-input sibling of {@link validateEmail} (`(ptr,len)` — no decode). */
42
+ validateEmailBytes(input: Uint8Array): boolean
43
+ /** Byte-input sibling of {@link validateUuid} (`(ptr,len)` — no decode). */
44
+ validateUuidBytes(input: Uint8Array): boolean
45
+ /** Byte-input sibling of {@link validateIpv4} (`(ptr,len)` — no decode). */
46
+ validateIpv4Bytes(input: Uint8Array): boolean
47
+ /** Byte-input sibling of {@link validateIpv6} (`(ptr,len)` — no decode). */
48
+ validateIpv6Bytes(input: Uint8Array): boolean
40
49
  /** Sum of `id` fields across a JSON array → bigint (throws on non-array input). */
41
50
  jsonSumIds(input: Uint8Array): bigint
42
51
 
@@ -114,11 +123,7 @@ export interface BunFFI {
114
123
  nowSeconds: number,
115
124
  ): string | null
116
125
  /** EdDSA (Ed25519) JWT verify → claims JSON string; `null` = invalid. */
117
- jwtEdDSAVerify(
118
- token: Uint8Array,
119
- publicKey: Uint8Array,
120
- nowSeconds: number,
121
- ): string | null
126
+ jwtEdDSAVerify(token: Uint8Array, publicKey: Uint8Array, nowSeconds: number): string | null
122
127
  /** Compiled `TemplateRenderer` → UTF-8 bytes from a pre-serialized JSON
123
128
  * context (opaque `inner` handle); throws on invalid context / render error. */
124
129
  templateRender(inner: number, contextJson: Uint8Array): Uint8Array
@@ -213,6 +218,12 @@ export interface BunFFI {
213
218
  gzipDecompress(data: Uint8Array, maxDecompressed?: number): Uint8Array
214
219
  /** gzip-decompress into `output`; returns bytes written (throws if too small). */
215
220
  gzipDecompressInto(data: Uint8Array, output: Uint8Array, maxDecompressed?: number): number
221
+ /**
222
+ * gzip ISIZE trailer probe → the uncompressed size for a single-member gzip
223
+ * stream, or `0` when unknown (invalid stream / multi-member). Used to
224
+ * PRE-SIZE a destination buffer for the zero-copy task path.
225
+ */
226
+ gzipIsize(data: Uint8Array): number
216
227
  /** brotli-compress into a fresh buffer (quality clamped 0..=11, default 5). */
217
228
  brotliCompress(data: Uint8Array, quality?: number): Uint8Array
218
229
  /** brotli-compress into `output`; returns bytes written (throws if too small). */
@@ -308,7 +319,10 @@ export interface BunFFI {
308
319
  /**
309
320
  * Encode one SSE event → bytes (fresh buffer). event/id null = line omitted
310
321
  * (a present-but-empty string emits the line, matching napi `Option`).
311
- * FFI sibling of the napi `sse_encode_event` — kills the napi crossing.
322
+ * NOTE: event/id stay `(ptr,len)` byte args — a cstring-ARG conversion was
323
+ * attempted and reverted (2026-08-23); see docs/FFI_BUN_GUIDE.md §14 before
324
+ * touching this signature.
325
+ * FFI sibling of the napi `sse_encode_event`.
312
326
  */
313
327
  sseEncodeEvent(
314
328
  event: string | null,
@@ -388,6 +402,132 @@ export interface BunFFI {
388
402
  routeRun(handle: number, frame: Uint8Array, output: Uint8Array): number
389
403
  /** Destroy a compiled route handle (frees the native `Box<NativeRoute>`). */
390
404
  routeDestroy(handle: number): void
405
+
406
+ // ── Metrics registry (`castrum_metrics_*`) ─────────────────────────
407
+ /** Create an empty metrics registry → a caller-owned opaque handle. */
408
+ metricsCreate(): number
409
+ /**
410
+ * Declare a counter family → its `u32` series id (`0xFFFFFFFF` = error).
411
+ * `name` and `labelKeys` are `cstring` ARGs; label keys are
412
+ * `\u001f`-separated (empty string = no labels).
413
+ */
414
+ metricsCounter(handle: number, name: string, labelKeys: string): number
415
+ /** Declare a gauge family → its series id. Same ABI as {@link metricsCounter}. */
416
+ metricsGauge(handle: number, name: string, labelKeys: string): number
417
+ /**
418
+ * Declare a histogram family → its series id. `bucketsCsv` is a
419
+ * comma-separated finite positive list (empty string = default buckets).
420
+ */
421
+ metricsHistogram(handle: number, name: string, labelKeys: string, bucketsCsv: string): number
422
+ /**
423
+ * Counter/gauge `+= amount`, or histogram observe `amount`. `values` is the
424
+ * packed `\u001f`-separated label values. Returns true on success.
425
+ */
426
+ metricsRecord(handle: number, series: number, values: Uint8Array, amount: number): boolean
427
+ /** Gauge assignment (`= value`). Returns true on success. */
428
+ metricsGaugeSet(handle: number, series: number, values: Uint8Array, value: number): boolean
429
+ /**
430
+ * Zero-encode sibling of {@link metricsRecord}: `values` is the JOINED
431
+ * `\u001f`-separated JS string — the engine transcodes it in-engine.
432
+ */
433
+ metricsRecordStr(handle: number, series: number, values: string, amount: number): boolean
434
+ /** Zero-encode sibling of {@link metricsGaugeSet} (joined string values). */
435
+ metricsGaugeSetStr(handle: number, series: number, values: string, value: number): boolean
436
+ /**
437
+ * Render the Prometheus text format into `output`; returns bytes written
438
+ * (`0` = null handle / error; `> output.length` = exact required size).
439
+ */
440
+ metricsRender(handle: number, output: Uint8Array): number
441
+ /**
442
+ * Dump the packed v1 series snapshot into `output`; returns bytes written
443
+ * (`0` = null handle / error; `> output.length` = exact required size).
444
+ */
445
+ metricsSnapshot(handle: number, output: Uint8Array): number
446
+ /** Destroy a registry handle (frees the native `Box<MetricsRegistry>`). */
447
+ metricsDestroy(handle: number): void
448
+ /**
449
+ * Record a BATCH of events in one crossing. Packed layout:
450
+ * `[u32 n]{[u32 series][u32 valsLen][vals][f64 amount]}`; returns true when
451
+ * all entries applied.
452
+ */
453
+ metricsRecordBatch(handle: number, packed: Uint8Array): boolean
454
+ /**
455
+ * Fused wire-level validation: RAW query string → JSON → draft-07 validate
456
+ * against the compiled schema (`inner` from `SchemaValidator.innerPtr()`).
457
+ */
458
+ queryValidate(inner: number, qs: string): boolean
459
+ /** Fused cookie-header variant of {@link queryValidate}. */
460
+ cookieValidate(inner: number, header: string): boolean
461
+ /**
462
+ * Seal a session envelope natively: builds
463
+ * `{"id":"…","data":<dataJson>,"exp":exp}` and HMAC-signs it →
464
+ * `payload.<hex>` token string (`null` = failure).
465
+ */
466
+ sessionSeal(id: string, dataJson: string, expSecs: number, secret: string): string | null
467
+ /**
468
+ * Open a sealed session token: verify + extract in one crossing into
469
+ * `output` as `[u8 ok][i64 exp][u32 idLen][id][u32 dataLen][dataJson]`.
470
+ * Returns bytes written (`0` = bad signature / error; `> output.length` =
471
+ * exact required size).
472
+ */
473
+ sessionOpen(token: string, secret: string, output: Uint8Array): number
474
+
475
+ // ── Batch fixed-width hex validation / RegExp escaping ─────────────
476
+ /**
477
+ * Validate NEWLINE-separated lines as fixed-width hex into `output`
478
+ * (one verdict byte per line); returns bytes written (`0` = bad width /
479
+ * error; `> output.length` = exact required size).
480
+ */
481
+ hexValidateBatchInto(input: Uint8Array, width: number, output: Uint8Array): number
482
+ /**
483
+ * String-input sibling of {@link hexValidateBatchInto}: the NEWLINE-
484
+ * separated ids cross as a `cstring` ARG (engine-transcoded — no encode).
485
+ */
486
+ hexValidateBatchStr(ids: string, width: number, output: Uint8Array): number
487
+ /**
488
+ * Escape JS-RegExp metacharacters into `output`; returns bytes written
489
+ * (`0` = error; `> output.length` = exact required size).
490
+ */
491
+ regexEscapeInto(input: Uint8Array, output: Uint8Array): number
492
+ /**
493
+ * Zero-copy text sibling of {@link regexEscapeInto}: cstring ARG in,
494
+ * cstring return out (engine-cloned — zero JS decode). `null` = failure.
495
+ */
496
+ regexEscapeStr(input: string): string | null
497
+
498
+ // ── Off-thread task runtime (castrum_task_*) ──────────────────────
499
+ /** Start the shared native task pool; 0 = started, 1 = already running. */
500
+ taskInit(threads: number): number
501
+ /** Submit a task op with packed `args` under `taskId`; 1 = accepted. */
502
+ taskSubmit(op: number, args: Uint8Array, taskId: number): number
503
+ /**
504
+ * Zero-copy submit: the op writes its result DIRECTLY into `output` on the
505
+ * pool thread. `output` must be a stable, caller-owned buffer that stays
506
+ * alive (and untouched) until the completion is drained — the runtime holds
507
+ * the reference. The completion body is the 8-byte LE written length.
508
+ */
509
+ taskSubmitOut(op: number, args: Uint8Array, taskId: number, output: Uint8Array): number
510
+ /**
511
+ * Zero-copy INPUT: only `hdr` is copied; `data` is read in place by the pool
512
+ * thread, so the caller must keep it alive (and untouched) until completion.
513
+ */
514
+ taskSubmitSlice(op: number, hdr: Uint8Array, data: Uint8Array, taskId: number): number
515
+ /**
516
+ * Drain finished tasks into `output` (packed `[u32 count][entry…]`).
517
+ * Returns bytes written, `0` when nothing is pending, or the EXACT required
518
+ * size when `output` is too small (nothing is consumed in that case).
519
+ */
520
+ taskDrain(output: Uint8Array): number
521
+ /** Finished-but-undrained completion count. */
522
+ taskPending(): number
523
+ /** Mark a task cancelled; 1 = newly marked, 0 = unknown/already finished. */
524
+ taskCancel(taskId: number): number
525
+ /** Register the thread-safe doorbell trampoline address (`0` disables it). */
526
+ taskSetDoorbell(ptr: number): number
527
+ /** Stop the task pool and drop pending completions. */
528
+ taskShutdown(): number
529
+ /** Configured task-pool worker count (0 before the pool starts). */
530
+ taskThreads(): number
391
531
  }
392
532
 
393
533
  /**
@@ -411,6 +551,8 @@ export interface RateLimiterVerdict {
411
551
  resetMs: number
412
552
  }
413
553
 
554
+ /** Raw 0-arg C-ABI symbol signature (`castrum_metrics_create`). */
555
+ export type Raw0 = () => number | bigint
414
556
  /** Raw 2-arg C-ABI symbol signature (`(ptr,len)` pairs → scalar result). */
415
557
  export type Raw2 = (a: unknown, b: unknown) => number | bigint
416
558
  /** Raw 3-arg C-ABI symbol signature. */
package/src/native/ffi.ts CHANGED
@@ -34,7 +34,7 @@ import type { BunFFI, FfiMode } from './ffi/types'
34
34
  // Static import: loader.ts has no side effects (path resolution only — no
35
35
  // dlopen), so this adds zero import-time cost while letting `bind()` reuse the
36
36
  // exact same resolved `.node` path the napi fallback uses (the shared seam).
37
- import { getAddonPath } from './loader'
37
+ import { getAddonPath, getAddonPathCandidates } from './loader'
38
38
 
39
39
  // Re-export the type surface so `import type { BunFFI } from '../native/ffi'`
40
40
  // keeps working (existing call sites).
@@ -49,6 +49,12 @@ let cached: BunFFI | null | undefined
49
49
  */
50
50
  let bufferAbiMode: 'buffer-pair' | 'ptr-len' | null = null
51
51
 
52
+ /**
53
+ * The addon file the live `bun:ffi` binding was opened from, or `null` while
54
+ * unbound. Diagnostics only — a v3→baseline fallback is visible here.
55
+ */
56
+ let boundAddonPath: string | null = null
57
+
52
58
  // ── Transport selection (CASTRUM_FFI_MODE) ───────────────────────
53
59
 
54
60
  function resolveFfiMode(): FfiMode {
@@ -96,6 +102,20 @@ export function ffiBufferMode(): 'buffer-pair' | 'ptr-len' | null {
96
102
  return bufferAbiMode
97
103
  }
98
104
 
105
+ /**
106
+ * The addon file path the live `bun:ffi` binding was opened from, or `null`
107
+ * when the transport is unavailable (Node, `CASTRUM_FFI_MODE=napi`, or every
108
+ * candidate failed). Lazy — triggers the same one-time bind as
109
+ * {@link getBunFFI}. Use it to confirm WHICH artifact bound, e.g. when a stale
110
+ * v3 SIMD binary forced the baseline fallback.
111
+ */
112
+ export function ffiAddonPath(): string | null {
113
+ if (cached === undefined) {
114
+ getBunFFI()
115
+ }
116
+ return boundAddonPath
117
+ }
118
+
99
119
  /**
100
120
  * Probe whether this Bun build accepts the `buffer`/`buffer_length` ABI pair in
101
121
  * `dlopen`. Bun's docs list `buffer_length` as engine-native (dlopen-supported),
@@ -127,7 +147,7 @@ function probeBufferLength(dlopen: typeof import('bun:ffi')['dlopen'], path: str
127
147
  }
128
148
  }
129
149
 
130
- function bind(): BunFFI | null {
150
+ function bind(forcedPath?: string, tried: string[] = []): BunFFI | null {
131
151
  bufferAbiMode = null // reset — the mode below is only valid for a live bind
132
152
  const mode = resolveFfiMode()
133
153
  if (!isBun()) {
@@ -143,12 +163,13 @@ function bind(): BunFFI | null {
143
163
  if (mode === 'napi') {
144
164
  return null
145
165
  }
166
+ // Resolve the SAME addon file napi uses (./loader.ts — statically imported
167
+ // above; it only resolves the path, it never dlopens the addon). On a retry,
168
+ // `forcedPath` is the next candidate from `getAddonPathCandidates()`.
169
+ const path = forcedPath ?? getAddonPath()
146
170
  try {
147
171
  // eslint-disable-next-line @typescript-eslint/no-require-imports
148
172
  const { dlopen } = require('bun:ffi') as typeof import('bun:ffi')
149
- // Resolve the SAME addon file napi uses (./loader.ts — statically imported
150
- // above; it only resolves the path, it never dlopens the addon).
151
- const path = getAddonPath()
152
173
 
153
174
  // Probe `buffer`/`buffer_length` (the engine reads ptr + len off the SAME
154
175
  // TypedArray at call time — an atomic snapshot) once; when supported we use
@@ -206,10 +227,25 @@ function bind(): BunFFI | null {
206
227
  castrum_url_decode: { args: abi(['ptr', 'usize', 'ptr', 'usize']), returns: U64_FAST },
207
228
  // String-input validators: `cstring` ARG (the engine transcodes the JS
208
229
  // string in-engine — no JS-side encode; Rust borrows via CStr::from_ptr).
230
+ // The `_bytes` siblings take a `(ptr,len)` pair — zero transcode when
231
+ // the caller already holds bytes (a cstring arg would force a decode +
232
+ // engine re-encode; docs/FFI_BUN_GUIDE.md §3 shape 4).
233
+ //
234
+ // SAFETY RULE for every `cstring` ARG added here: the transcode is
235
+ // NUL-TERMINATED, so an embedded U+0000 truncates the value in-engine. A
236
+ // verdict op bound this way can be fooled (`validateEmail` on
237
+ // 'a@b.com\0<script>' returned TRUE before the byte re-route). Either
238
+ // give the op a `_bytes` sibling and route byte callers to it, or guard
239
+ // the string form with `hasNul` — see docs/FFI_BUN_GUIDE.md §6.1,
240
+ // pinned by test/unit/native/cstring-nul.test.ts.
209
241
  castrum_validate_email: { args: ['cstring'], returns: 'u8' },
210
242
  castrum_validate_uuid: { args: ['cstring'], returns: 'u8' },
211
243
  castrum_validate_ipv4: { args: ['cstring'], returns: 'u8' },
212
244
  castrum_validate_ipv6: { args: ['cstring'], returns: 'u8' },
245
+ castrum_validate_email_bytes: { args: inputAbi, returns: 'u8' },
246
+ castrum_validate_uuid_bytes: { args: inputAbi, returns: 'u8' },
247
+ castrum_validate_ipv4_bytes: { args: inputAbi, returns: 'u8' },
248
+ castrum_validate_ipv6_bytes: { args: inputAbi, returns: 'u8' },
213
249
  castrum_json_sum_ids: { args: abi(['ptr', 'usize', 'ptr', 'usize']), returns: U64_FAST },
214
250
  castrum_hmac_sha256_verify: {
215
251
  args: abi(['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize']),
@@ -357,6 +393,11 @@ function bind(): BunFFI | null {
357
393
  },
358
394
  castrum_http_date_into: { args: ['f64', ...abi(['ptr', 'usize'])], returns: U64_FAST },
359
395
  castrum_sse_encode_into: {
396
+ // event/id stay `(ptr,len)` byte args. A cstring-ARG conversion was
397
+ // tried (2026-08-23) and reverted: the win was unproven once the
398
+ // codec encode path got faster, and ABI-desync segfaults during the
399
+ // experiment were misread as an engine bug — see docs/FFI_BUN_GUIDE.md
400
+ // §14 before touching this signature.
360
401
  args: abi(['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize', 'u8', 'u32', 'ptr', 'usize']),
361
402
  returns: U64_FAST,
362
403
  },
@@ -467,24 +508,128 @@ function bind(): BunFFI | null {
467
508
  returns: U64_FAST,
468
509
  },
469
510
  castrum_route_destroy: { args: ['usize'], returns: 'void' },
511
+ // ── Batch fixed-width hex validation (ObjectId-shaped ids, etc.) ──
512
+ // NEWLINE-separated lines in; one verdict byte (1/0) per line out.
513
+ castrum_hex_validate_batch: {
514
+ args: abi(['ptr', 'usize', 'u32', 'ptr', 'usize']),
515
+ returns: U64_FAST,
516
+ },
517
+ // ── JS-RegExp metacharacter escaping (needed-size convention) ──
518
+ castrum_regex_escape: { args: abi(['ptr', 'usize', 'ptr', 'usize']), returns: U64_FAST },
519
+ // ── Metrics registry (`castrum_metrics_*`) ──────────────────────
520
+ // Caller-owned handle (route-stack ownership model): create returns a
521
+ // Box<Registry> as usize; declare fns take name/label-keys as cstring
522
+ // ARGS (label keys `\x1f`-separated) and return the u32 series id
523
+ // (0xFFFFFFFF = error); record/set cross the packed label VALUES as a
524
+ // `(ptr,len)` pair + the amount as f64.
525
+ castrum_metrics_create: { args: [], returns: U64_FAST },
526
+ castrum_metrics_counter: { args: ['usize', 'cstring', 'cstring'], returns: 'u32' },
527
+ castrum_metrics_gauge: { args: ['usize', 'cstring', 'cstring'], returns: 'u32' },
528
+ castrum_metrics_histogram: {
529
+ args: ['usize', 'cstring', 'cstring', 'cstring'],
530
+ returns: 'u32',
531
+ },
532
+ castrum_metrics_record: {
533
+ args: abi(['usize', 'u32', 'ptr', 'usize', 'f64']),
534
+ returns: 'u8',
535
+ },
536
+ castrum_metrics_gauge_set: {
537
+ args: abi(['usize', 'u32', 'ptr', 'usize', 'f64']),
538
+ returns: 'u8',
539
+ },
540
+ // Zero-encode siblings: the joined label values cross as ONE `cstring`
541
+ // ARG — the engine transcodes the JS string in-engine (no TextEncoder).
542
+ castrum_metrics_record_str: {
543
+ args: ['usize', 'u32', 'cstring', 'f64'],
544
+ returns: 'u8',
545
+ },
546
+ castrum_metrics_gauge_set_str: {
547
+ args: ['usize', 'u32', 'cstring', 'f64'],
548
+ returns: 'u8',
549
+ },
550
+ // Zero-copy text escape: cstring ARG in, cstring return out — the JS
551
+ // side does zero encode AND zero decode (engine clones at return).
552
+ castrum_regex_escape_str: { args: ['cstring'], returns: 'cstring' },
553
+ // String-input batch hex validation: `ids.join('\n')` crosses as a
554
+ // cstring ARG.
555
+ castrum_hex_validate_batch_str: {
556
+ args: abi(['cstring', 'u32', 'ptr', 'usize']),
557
+ returns: U64_FAST,
558
+ },
559
+ castrum_metrics_render: { args: abi(['usize', 'ptr', 'usize']), returns: U64_FAST },
560
+ castrum_metrics_snapshot: { args: abi(['usize', 'ptr', 'usize']), returns: U64_FAST },
561
+ castrum_metrics_record_batch: {
562
+ args: abi(['usize', 'ptr', 'usize']),
563
+ returns: 'u8',
564
+ },
565
+ // Fused wire-level validation: RAW query/cookie → JSON → draft-07 gate.
566
+ // `inner` is the SchemaValidator inner handle; strings cross as cstring
567
+ // ARGs (engine-transcoded).
568
+ castrum_query_validate: { args: ['usize', 'cstring'], returns: 'u8' },
569
+ castrum_cookie_validate: { args: ['usize', 'cstring'], returns: 'u8' },
570
+ // Fused session envelope: seal builds+signs; open verifies + extracts.
571
+ castrum_session_seal: {
572
+ args: ['cstring', 'cstring', 'i64', 'cstring'],
573
+ returns: 'cstring',
574
+ },
575
+ castrum_session_open: {
576
+ args: abi(['cstring', 'cstring', 'ptr', 'usize']),
577
+ returns: U64_FAST,
578
+ },
579
+ // Off-thread task runtime: `submit` takes a packed `(ptr,len)` arg blob
580
+ // plus a JS-assigned id; `drain` writes the packed completion batch into
581
+ // a caller buffer (needed-size convention). `set_doorbell` takes the raw
582
+ // `JSCallback({threadsafe:true}).ptr` as a bare pointer (`abi` leaves a
583
+ // lone `ptr` untouched), so the pool can wake JS once per batch.
584
+ castrum_task_init: { args: ['u32'], returns: 'u32' },
585
+ castrum_task_submit: { args: abi(['u32', 'ptr', 'usize', 'usize']), returns: 'u32' },
586
+ castrum_task_submit_slice: {
587
+ args: abi(['u32', 'ptr', 'usize', 'ptr', 'usize', 'usize']),
588
+ returns: 'u32',
589
+ },
590
+ castrum_task_submit_out: {
591
+ args: abi(['u32', 'ptr', 'usize', 'usize', 'ptr', 'usize']),
592
+ returns: 'u32',
593
+ },
594
+ castrum_task_drain: { args: abi(['ptr', 'usize']), returns: U64_FAST },
595
+ castrum_task_pending: { args: [], returns: 'u32' },
596
+ castrum_task_cancel: { args: ['usize'], returns: 'u32' },
597
+ castrum_task_set_doorbell: { args: ['ptr'], returns: 'u32' },
598
+ castrum_task_shutdown: { args: [], returns: 'u32' },
599
+ castrum_task_threads: { args: [], returns: 'u32' },
600
+ castrum_metrics_destroy: { args: ['usize'], returns: 'void' },
470
601
  })
471
602
 
472
- const bindings = build(symbols as Record<string, (...a: unknown[]) => unknown>, useBufferLength)
603
+ // (`as unknown as`: the zero-arg `castrum_metrics_create` infers
604
+ // `(...args: never[])`, which can't overlap `(...a: unknown[])` directly.)
605
+ const bindings = build(
606
+ symbols as unknown as Record<string, (...a: unknown[]) => unknown>,
607
+ useBufferLength,
608
+ )
473
609
  if (!selfTest(bindings)) {
474
- if (mode === 'ffi') {
475
- throw new Error(
476
- 'CASTRUM_FFI_MODE=ffi: the bun:ffi bind-time self-test failed, so the ' +
477
- 'primary transport cannot be trusted on this Bun/addon combination. ' +
478
- 'Unset CASTRUM_FFI_MODE (or use CASTRUM_FFI_MODE=auto) to fall back to napi.',
479
- )
480
- }
481
- return null
610
+ throw new Error(`bun:ffi bind-time self-test failed on ${path}`)
482
611
  }
612
+ boundAddonPath = path
483
613
  return bindings
484
614
  } catch (err) {
615
+ bufferAbiMode = null
616
+ // The preferred artifact failed to dlopen/bind/self-test. The common cause
617
+ // is a STALE v3 SIMD binary (`castrum.linux-x64-v3-gnu.node`) that predates
618
+ // a newly added `castrum_*` symbol: dlopen fails, and without this walk the
619
+ // whole FFI transport would be disabled (every call silently downgraded to
620
+ // napi, and the task runtime silently made synchronous). Try the remaining
621
+ // existing candidates — the baseline binary — before giving up.
622
+ for (const next of getAddonPathCandidates()) {
623
+ if (next === path || tried.includes(next)) continue
624
+ const bound = bind(next, [...tried, path])
625
+ if (bound) return bound
626
+ }
485
627
  if (mode === 'ffi') {
486
628
  const cause = err instanceof Error ? `: ${err.message}` : ''
487
- throw new Error(`CASTRUM_FFI_MODE=ffi: failed to bind bun:ffi${cause}`)
629
+ throw new Error(
630
+ `CASTRUM_FFI_MODE=ffi: failed to bind bun:ffi${cause} ` +
631
+ `(tried: ${[...tried, path].join(', ')})`,
632
+ )
488
633
  }
489
634
  return null
490
635
  }
@@ -4,5 +4,5 @@
4
4
  // (./loader.ts). Importing this barrel does NOT dlopen the addon — use
5
5
  // `getAddon()` / `lazyAddon` at call time.
6
6
 
7
+ export { getAddon, getAddonPath, lazyAddon } from './loader'
7
8
  export * from './types'
8
- export { getAddonPath, getAddon, lazyAddon } from './loader'
@@ -5,9 +5,9 @@
5
5
  // `import castrum` as cheap as possible on Bun.
6
6
 
7
7
  import { existsSync, readFileSync, statSync } from 'node:fs'
8
- import { fileURLToPath } from 'node:url'
9
- import { join, dirname } from 'node:path'
10
8
  import { createRequire } from 'node:module'
9
+ import { dirname, join } from 'node:path'
10
+ import { fileURLToPath } from 'node:url'
11
11
  import { resolveEnvVar } from '../shared/env'
12
12
  import type { NativeAddon } from './types'
13
13
 
@@ -67,15 +67,16 @@ export function supportsX8664V3(cpuinfo?: string): boolean {
67
67
  * @param arch `process.arch`
68
68
  * @param cpuinfo optional `/proc/cpuinfo` text (tests inject it; the real call
69
69
  * omits it and reads the file lazily on linux/x64 only)
70
- * @returns the first existing candidate path (throws if none exists)
70
+ * @returns every candidate path, in preference order (v3 SIMD first on a
71
+ * capable linux/x64 host, then the baseline names)
71
72
  */
72
- export function resolveAddonPathFrom(
73
+ export function addonCandidatesFrom(
73
74
  envOverride: string | undefined,
74
75
  baseDir: string,
75
76
  platform: string,
76
77
  arch: string,
77
78
  cpuinfo?: string,
78
- ): string {
79
+ ): string[] {
79
80
  // napi-rs artifact naming: castrum.<platform>-<arch>[-<libc>].node
80
81
  // e.g. linux-x64-gnu, linux-x64-musl, win32-x64-msvc, darwin-arm64.
81
82
  const libcVariants =
@@ -156,6 +157,22 @@ export function resolveAddonPathFrom(
156
157
  candidates.push(join(baseDir, '..', '..', 'target', 'release', name))
157
158
  }
158
159
 
160
+ return candidates
161
+ }
162
+
163
+ /**
164
+ * Resolve the addon path: the FIRST existing candidate from
165
+ * {@link addonCandidatesFrom} (v3 SIMD first on a capable linux/x64 host).
166
+ * Throws when nothing exists.
167
+ */
168
+ export function resolveAddonPathFrom(
169
+ envOverride: string | undefined,
170
+ baseDir: string,
171
+ platform: string,
172
+ arch: string,
173
+ cpuinfo?: string,
174
+ ): string {
175
+ const candidates = addonCandidatesFrom(envOverride, baseDir, platform, arch, cpuinfo)
159
176
  for (const candidate of candidates) {
160
177
  if (existsSync(candidate)) {
161
178
  return candidate
@@ -169,6 +186,39 @@ export function resolveAddonPathFrom(
169
186
  )
170
187
  }
171
188
 
189
+ /** Cached result of {@link getAddonPathCandidates} (existing files only). */
190
+ let cachedCandidates: string[] | undefined
191
+
192
+ /**
193
+ * Every EXISTING addon candidate, in preference order (v3 SIMD first on a
194
+ * capable linux/x64 host, then the baseline names).
195
+ *
196
+ * The bun:ffi binder walks this list so a stale or partial *preferred* artifact
197
+ * — e.g. a `castrum.linux-x64-v3-gnu.node` built before a newly added
198
+ * `castrum_*` symbol — falls back to the baseline binary instead of silently
199
+ * disabling the whole FFI transport (which would downgrade every native call to
200
+ * napi and make the off-thread task runtime run synchronously).
201
+ */
202
+ export function getAddonPathCandidates(): string[] {
203
+ if (cachedCandidates) return cachedCandidates
204
+ const all = addonCandidatesFrom(
205
+ resolveEnvVar('CASTRUM_NATIVE_LIBRARY_PATH', ['NAPI_RS_NATIVE_LIBRARY_PATH']),
206
+ dirname(fileURLToPath(import.meta.url)),
207
+ process.platform,
208
+ process.arch,
209
+ )
210
+ const seen = new Set<string>()
211
+ const existing: string[] = []
212
+ for (const candidate of all) {
213
+ if (!seen.has(candidate) && existsSync(candidate)) {
214
+ seen.add(candidate)
215
+ existing.push(candidate)
216
+ }
217
+ }
218
+ cachedCandidates = existing
219
+ return existing
220
+ }
221
+
172
222
  function resolveAddonPath(): string {
173
223
  return resolveAddonPathFrom(
174
224
  resolveEnvVar('CASTRUM_NATIVE_LIBRARY_PATH', ['NAPI_RS_NATIVE_LIBRARY_PATH']),
@@ -57,6 +57,40 @@ export interface RouteInstance {
57
57
  destroy?(): void
58
58
  }
59
59
 
60
+ /**
61
+ * The native `MetricsRegistry` class instance — sharded counters / gauges /
62
+ * histograms with a Prometheus text-format render. Series are declared once
63
+ * (`counter`/`gauge`/`histogram` return a reusable id); per-event updates
64
+ * take the id + the label values for that event.
65
+ */
66
+ export interface MetricsRegistryInstance {
67
+ /** Declare a counter family → its series id (idempotent per shape). */
68
+ counter(name: string, labelKeys?: string[]): number
69
+ /** Declare a gauge family → its series id (idempotent per shape). */
70
+ gauge(name: string, labelKeys?: string[]): number
71
+ /**
72
+ * Declare a histogram family → its series id. Omitted/empty `buckets`
73
+ * selects the default latency buckets.
74
+ */
75
+ histogram(name: string, labelKeys?: string[], buckets?: number[]): number
76
+ /** Counter/gauge `+= amount`, or histogram observe `amount`. */
77
+ record(series: number, values?: string[], amount?: number): void
78
+ /** Gauge assignment (`= value`). */
79
+ gaugeSet(series: number, values: string[] | undefined, value: number): void
80
+ /** Render the Prometheus text exposition format (deterministic order). */
81
+ render(): string
82
+ /**
83
+ * Packed v1 series snapshot bytes (families then series — see the Rust
84
+ * registry docs). bun:ffi-backed wrappers only; napi exposes
85
+ * `MetricsRegistry.snapshot()` directly.
86
+ */
87
+ snapshot?(): Uint8Array
88
+ /** Number of live series (tests / introspection; napi transport only). */
89
+ seriesCount?(): number
90
+ /** Free the native handle (bun:ffi-backed wrappers only). */
91
+ destroy?(): void
92
+ }
93
+
60
94
  /** A single JSON Schema validation error (fast path + DOM fallback). */
61
95
  export interface SchemaError {
62
96
  /** RFC 6901 JSON pointer to the failing instance value ("" = root). */
@@ -25,6 +25,7 @@ import type {
25
25
  MediaTypeMatcherInstance,
26
26
  MediaTypeParserInstance,
27
27
  MediaTypeResult,
28
+ MetricsRegistryInstance,
28
29
  MultipartPart,
29
30
  PasswordHashOptions,
30
31
  RateLimiterInstance,
@@ -145,6 +146,10 @@ export interface NativeAddon {
145
146
  validateUuid(input: Uint8Array): boolean
146
147
  validateIpv4(input: Uint8Array): boolean
147
148
  validateIpv6(input: Uint8Array): boolean
149
+ /** Batch fixed-width hex validation → one verdict byte per newline-separated line. */
150
+ hexValidateBatch(input: Uint8Array, width: number): Uint8Array
151
+ /** Escape JS-RegExp metacharacters so a pattern matches the input literally. */
152
+ regexEscape(input: string): string
148
153
 
149
154
  wsAcceptKey(key: Uint8Array): Uint8Array
150
155
  wsAcceptKeyBatchPacked(input: Uint8Array): Uint8Array
@@ -244,6 +249,12 @@ export interface NativeAddon {
244
249
  windowMs: number,
245
250
  maxEntries?: number | null,
246
251
  ) => RateLimiterInstance
252
+ /**
253
+ * Sharded counters / gauges / histograms registry with a Prometheus
254
+ * text-format render (the napi fallback transport for the `castrum_metrics_*`
255
+ * C-ABI surface).
256
+ */
257
+ MetricsRegistry: new () => MetricsRegistryInstance
247
258
 
248
259
  wsFrameEncode(opcode: number, payload: Uint8Array, mask: boolean, fin: boolean): Uint8Array
249
260
  wsFrameDecode(data: Uint8Array): WsFrame | null