castrum 0.9.2 → 0.9.4

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 (184) hide show
  1. package/Cargo.lock +2259 -0
  2. package/Cargo.toml +138 -0
  3. package/README.md +6 -0
  4. package/build.rs +5 -0
  5. package/castrum.linux-x64-gnu.node +0 -0
  6. package/dist/index.d.ts +1348 -1295
  7. package/dist/index.js +4307 -3776
  8. package/docs/API.md +1 -1
  9. package/docs/ARCHITECTURE.md +3 -2
  10. package/docs/CASE_STUDY.md +2 -2
  11. package/docs/ENVIRONMENT.md +16 -0
  12. package/docs/FFI_BUN_GUIDE.md +89 -6
  13. package/docs/REPO_MAP.md +2 -2
  14. package/index.ts +19 -25
  15. package/package.json +9 -1
  16. package/rust/crypto/aead.rs +378 -0
  17. package/rust/crypto/argon2.rs +265 -0
  18. package/rust/crypto/base64.rs +420 -0
  19. package/rust/crypto/bcrypt.rs +89 -0
  20. package/rust/crypto/cookie_sign.rs +169 -0
  21. package/rust/crypto/csrf.rs +106 -0
  22. package/rust/crypto/ed25519.rs +215 -0
  23. package/rust/crypto/hashing.rs +140 -0
  24. package/rust/crypto/hmac_sha256.rs +88 -0
  25. package/rust/crypto/jwt/api.rs +286 -0
  26. package/rust/crypto/jwt/mod.rs +22 -0
  27. package/rust/crypto/jwt/tests.rs +312 -0
  28. package/rust/crypto/jwt/token.rs +360 -0
  29. package/rust/crypto/mod.rs +27 -0
  30. package/rust/crypto/pbkdf2.rs +96 -0
  31. package/rust/crypto/random_token.rs +85 -0
  32. package/rust/crypto/session.rs +222 -0
  33. package/rust/ffi/crypto.rs +708 -0
  34. package/rust/ffi/hashing.rs +285 -0
  35. package/rust/ffi/http.rs +752 -0
  36. package/rust/ffi/ingress.rs +303 -0
  37. package/rust/ffi/json.rs +517 -0
  38. package/rust/ffi/jwt.rs +419 -0
  39. package/rust/ffi/metrics.rs +373 -0
  40. package/rust/ffi/mod.rs +68 -0
  41. package/rust/ffi/payload.rs +275 -0
  42. package/rust/ffi/probe.rs +60 -0
  43. package/rust/ffi/rate_limit.rs +77 -0
  44. package/rust/ffi/route.rs +85 -0
  45. package/rust/ffi/tests.rs +2333 -0
  46. package/rust/ffi/util.rs +152 -0
  47. package/rust/ffi/validators.rs +123 -0
  48. package/rust/http/accept.rs +606 -0
  49. package/rust/http/cookie_parser.rs +140 -0
  50. package/rust/http/etag.rs +289 -0
  51. package/rust/http/form.rs +132 -0
  52. package/rust/http/headers.rs +267 -0
  53. package/rust/http/http_date.rs +286 -0
  54. package/rust/http/http_parser.rs +252 -0
  55. package/rust/http/media_type.rs +355 -0
  56. package/rust/http/method.rs +200 -0
  57. package/rust/http/mime_lookup.rs +130 -0
  58. package/rust/http/mod.rs +32 -0
  59. package/rust/http/multipart.rs +685 -0
  60. package/rust/http/query_parser.rs +180 -0
  61. package/rust/http/url_codec.rs +296 -0
  62. package/rust/http/url_join.rs +324 -0
  63. package/rust/ingress/api.rs +355 -0
  64. package/rust/ingress/cors.rs +403 -0
  65. package/rust/ingress/ingress_constants.rs +209 -0
  66. package/rust/ingress/ip_trust.rs +312 -0
  67. package/rust/ingress/mod.rs +48 -0
  68. package/rust/ingress/native_route.rs +779 -0
  69. package/rust/ingress/options.rs +176 -0
  70. package/rust/ingress/output.rs +406 -0
  71. package/rust/ingress/packed.rs +191 -0
  72. package/rust/ingress/pipeline.rs +534 -0
  73. package/rust/ingress/proxy.rs +138 -0
  74. package/rust/ingress/rate_limit.rs +538 -0
  75. package/rust/ingress/terminal.rs +114 -0
  76. package/rust/ingress/tests.rs +566 -0
  77. package/rust/ingress/time.rs +70 -0
  78. package/rust/json/fast_schema/capture.rs +336 -0
  79. package/rust/json/fast_schema/compile.rs +556 -0
  80. package/rust/json/fast_schema/cursor.rs +368 -0
  81. package/rust/json/fast_schema/email.rs +282 -0
  82. package/rust/json/fast_schema/errors.rs +247 -0
  83. package/rust/json/fast_schema/mod.rs +57 -0
  84. package/rust/json/fast_schema/tests.rs +1143 -0
  85. package/rust/json/fast_schema/types.rs +140 -0
  86. package/rust/json/fast_schema/validate.rs +931 -0
  87. package/rust/json/json_ops.rs +121 -0
  88. package/rust/json/json_schema.rs +837 -0
  89. package/rust/json/json_ser.rs +881 -0
  90. package/rust/json/mod.rs +19 -0
  91. package/rust/json/napi_marshal.rs +318 -0
  92. package/rust/json/patch/api.rs +60 -0
  93. package/rust/json/patch/engine.rs +153 -0
  94. package/rust/json/patch/mod.rs +47 -0
  95. package/rust/json/patch/ops.rs +270 -0
  96. package/rust/json/patch/pointer.rs +49 -0
  97. package/rust/json/patch/tests.rs +499 -0
  98. package/rust/lib.rs +72 -0
  99. package/rust/metrics/api.rs +148 -0
  100. package/rust/metrics/mod.rs +21 -0
  101. package/rust/metrics/registry.rs +961 -0
  102. package/rust/panic_safety.rs +40 -0
  103. package/rust/payload/compress.rs +512 -0
  104. package/rust/payload/mod.rs +13 -0
  105. package/rust/payload/sse.rs +214 -0
  106. package/rust/payload/template.rs +253 -0
  107. package/rust/payload/websocket.rs +69 -0
  108. package/rust/payload/ws_frames.rs +354 -0
  109. package/rust/proptest_suite.rs +139 -0
  110. package/rust/selection.rs +95 -0
  111. package/rust/src/bin/native-bench.rs +495 -0
  112. package/rust/test_support.rs +71 -0
  113. package/rust/util/batch/api.rs +266 -0
  114. package/rust/util/batch/core.rs +473 -0
  115. package/rust/util/batch/mod.rs +23 -0
  116. package/rust/util/batch/tests.rs +445 -0
  117. package/rust/util/batch_core.rs +184 -0
  118. package/rust/util/bytes.rs +407 -0
  119. package/rust/util/mod.rs +34 -0
  120. package/rust/util/packed.rs +473 -0
  121. package/rust/util/text.rs +130 -0
  122. package/rust/util/threadpool.rs +128 -0
  123. package/rust/util/validation.rs +419 -0
  124. package/rust-toolchain.toml +17 -0
  125. package/scripts/postinstall.mjs +199 -0
  126. package/src/ingress/constants.ts +1 -1
  127. package/src/ingress/context.ts +2 -2
  128. package/src/ingress/decode/baked-result.ts +13 -13
  129. package/src/ingress/decode/fast-result.ts +17 -17
  130. package/src/ingress/errors.ts +7 -7
  131. package/src/ingress/headers/baked-templates.ts +1 -1
  132. package/src/ingress/headers/fast-templates.ts +3 -3
  133. package/src/ingress/index.ts +1 -1
  134. package/src/ingress/native-route.ts +9 -5
  135. package/src/ingress/options.ts +1 -1
  136. package/src/ingress/packing/gather-raw-headers.ts +1 -1
  137. package/src/ingress/packing/route-wire.ts +1 -5
  138. package/src/ingress/response/error-bodies.ts +5 -5
  139. package/src/ingress/response/terminal.ts +1 -1
  140. package/src/ingress/routes/echo.ts +4 -4
  141. package/src/ingress/routes/fallback.ts +2 -2
  142. package/src/ingress/routes/head.ts +2 -2
  143. package/src/ingress/routes/native.ts +2 -1
  144. package/src/ingress/routes/options.ts +1 -1
  145. package/src/ingress/routes/read.ts +2 -2
  146. package/src/ingress/server.ts +10 -6
  147. package/src/ingress/status.ts +7 -7
  148. package/src/integration/batch.ts +1 -1
  149. package/src/integration/index.ts +9 -9
  150. package/src/integration/pipeline.ts +6 -6
  151. package/src/integration/websocket.ts +2 -2
  152. package/src/loader/index.ts +1 -5
  153. package/src/loader/ops.ts +1 -1
  154. package/src/native/ffi/build/codecs.ts +104 -8
  155. package/src/native/ffi/build/compress.ts +14 -10
  156. package/src/native/ffi/build/instances.ts +40 -3
  157. package/src/native/ffi/build/metrics.ts +224 -0
  158. package/src/native/ffi/build/parse.ts +59 -23
  159. package/src/native/ffi/build/util.ts +14 -1
  160. package/src/native/ffi/build.ts +2 -0
  161. package/src/native/ffi/selftest.ts +10 -3
  162. package/src/native/ffi/types.ts +111 -9
  163. package/src/native/ffi.ts +87 -1
  164. package/src/native/index.ts +1 -1
  165. package/src/native/loader.ts +2 -2
  166. package/src/native/types/instances.ts +34 -0
  167. package/src/native/types.ts +11 -0
  168. package/src/runtime/codec.ts +53 -9
  169. package/src/runtime/types.ts +7 -0
  170. package/src/rust-ffi/batch/build.ts +16 -9
  171. package/src/rust-ffi/index.ts +7 -6
  172. package/src/rust-ffi/options.ts +1 -3
  173. package/src/rust-ffi/proven.ts +3 -3
  174. package/src/rust-ffi/scalar/crypto.ts +1 -1
  175. package/src/rust-ffi/scalar/factories.ts +73 -0
  176. package/src/rust-ffi/scalar/hashing.ts +1 -1
  177. package/src/rust-ffi/scalar/http.ts +17 -11
  178. package/src/rust-ffi/scalar/index.ts +2 -0
  179. package/src/rust-ffi/scalar/interface.ts +20 -0
  180. package/src/rust-ffi/scalar/payload.ts +11 -3
  181. package/src/rust-ffi/scalar/validation.ts +121 -0
  182. package/src/selection.ts +1 -1
  183. package/src/shared/bytes.ts +5 -4
  184. package/src/shared/codec.ts +18 -3
@@ -11,12 +11,29 @@ import { decodeUtf8, encodeUtf8 } from '../../../shared/codec'
11
11
  import { EMPTY_VIEW, SELFTEST_HEX } from '../constants'
12
12
  import type { BunFFI, Raw3, Raw4, Raw6, Raw7, Raw10 } from '../types'
13
13
  import type { BuildCtx } from './util'
14
- import { flag, growExact } from './util'
14
+ import { allocOut, flag, growExact } from './util'
15
15
 
16
16
  /**
17
17
  * Build the parser/wire-format methods of the BunFFI surface. `ctx` is
18
18
  * destructured so the method bodies read exactly as the original `build()`.
19
19
  */
20
+
21
+ // ── SSE event/id encode memo ────────────────────────────────────────────────
22
+ // SSE streams repeat the same event NAME constantly ('message'/'update') and
23
+ // often repeat ids; re-encoding them per event is pure waste (~60ns each).
24
+ // One memo slot each: a `===` hit returns the cached bytes, a miss re-encodes.
25
+ // Per-Worker module state (single-threaded event loop), same safety model as
26
+ // the pooled scratches in BuildCtx.
27
+ const sseMemoEvent = { str: null as string | null, bytes: EMPTY_VIEW }
28
+ const sseMemoId = { str: null as string | null, bytes: EMPTY_VIEW }
29
+ function sseMemoEncode(slot: { str: string | null; bytes: Uint8Array }, s: string): Uint8Array {
30
+ if (s === slot.str) return slot.bytes
31
+ const bytes = encodeUtf8(s)
32
+ slot.str = s
33
+ slot.bytes = bytes
34
+ return bytes
35
+ }
36
+
20
37
  export function buildParse(
21
38
  sym: Record<string, (...a: unknown[]) => unknown>,
22
39
  ctx: BuildCtx,
@@ -67,8 +84,9 @@ export function buildParse(
67
84
  return w
68
85
  },
69
86
  wsFrameEncode(opcode, payload, mask, fin) {
70
- // Header (max 10) + payload + mask key (4).
71
- const out = new Uint8Array(payload.length + 14)
87
+ // Header (max 10) + payload + mask key (4). allocOut skips the zeroing
88
+ // (the native write fills [0,w) and only that slice escapes).
89
+ const out = allocOut(payload.length + 14)
72
90
  const w = Number(
73
91
  wsFrameEncode(
74
92
  opcode,
@@ -182,15 +200,24 @@ export function buildParse(
182
200
  parseMediaType(input) {
183
201
  // Packed verdict: [u32 mediaTypeLen][mediaType][u32 charsetLen
184
202
  // (0xFFFFFFFF = none)][charset][u32 boundaryLen][boundary]
185
- // [u32 paramCount]{[u32 keyLen][key][u32 valLen][val]}. 0 = invalid media
186
- // type (real error → growExact throws); w > output.length = exact needed
187
- // size (one exact retry).
188
- return growExact(
189
- (out) => Number(parseMediaTypeSym(input, lenOrView(input), out, lenOrView(out))),
190
- Math.min(input.length + 64, 64 * 1024),
191
- 1024 * 1024,
192
- 'media type parse: invalid media type or output buffer too small',
193
- )
203
+ // [u32 paramCount]{[u32 keyLen][key][u32 valLen][val]}.
204
+ // POOLED SCRATCH: the only consumer (the scalar `unpackMediaType`)
205
+ // decodes the verdict synchronously into a JS object before the next
206
+ // call, so the packed bytes never need to outlive this call — no
207
+ // growExact fresh-buffer per request. 0 = invalid media type (throw).
208
+ // 2x + 128 covers the worst-case packed expansion (every byte becomes a
209
+ // param key/value length prefix); a still-too-small write falls back to
210
+ // ONE exact-size buffer (needed-size convention).
211
+ let out = scratchFor(input.length * 2 + 128)
212
+ let w = Number(parseMediaTypeSym(input, lenOrView(input), out, lenOrView(out)))
213
+ if (w > out.length) {
214
+ out = new Uint8Array(w)
215
+ w = Number(parseMediaTypeSym(input, lenOrView(input), out, lenOrView(out)))
216
+ }
217
+ if (w === 0 || w > out.length) {
218
+ throw new Error('media type parse: invalid media type or output buffer too small')
219
+ }
220
+ return out.subarray(0, w)
194
221
  },
195
222
  parseHttpDate(input) {
196
223
  // Packed [u8 ok][i64 secs LE] (9 B) / 1 B (ok=0). A too-small buffer is
@@ -207,13 +234,18 @@ export function buildParse(
207
234
  },
208
235
  parseAcceptEncoding(input) {
209
236
  // Packed: [u32 count]{[u32 encLen][enc][f32 q][u32 order]} (empty header →
210
- // count 0, 4 bytes). 0 = too small (real error → growExact throws).
211
- return growExact(
212
- (out) => Number(parseAcceptEncodingSym(input, lenOrView(input), out, lenOrView(out))),
213
- Math.min(input.length + 64, 64 * 1024),
214
- 1024 * 1024,
215
- 'accept encoding parse: output buffer too small',
216
- )
237
+ // count 0, 4 bytes). POOLED SCRATCH: consumed synchronously by the scalar
238
+ // `unpackAcceptEncoding` — never escapes as raw bytes. 0 = too small.
239
+ let out = scratchFor(input.length * 2 + 128)
240
+ let w = Number(parseAcceptEncodingSym(input, lenOrView(input), out, lenOrView(out)))
241
+ if (w > out.length) {
242
+ out = new Uint8Array(w)
243
+ w = Number(parseAcceptEncodingSym(input, lenOrView(input), out, lenOrView(out)))
244
+ }
245
+ if (w === 0 || w > out.length) {
246
+ throw new Error('accept encoding parse: output buffer too small')
247
+ }
248
+ return out.subarray(0, w)
217
249
  },
218
250
  urlEncodeQuery(input) {
219
251
  // Packed pairs `[u32 count]{[u32 keyLen][key][u32 valLen][val]}` (the JS
@@ -255,8 +287,12 @@ export function buildParse(
255
287
  // non-null (flag bits 1/2/4 = present) so a present-but-empty string is
256
288
  // distinct from absent (napi Option parity). growExact with the needed-size
257
289
  // convention; `data.length + 64` covers the common single-line case.
258
- const ev = event === null ? EMPTY_VIEW : encodeUtf8(event)
259
- const idv = id === null ? EMPTY_VIEW : encodeUtf8(id)
290
+ // NOTE (2026-08-24): cstring-ARG variants were once blamed for JIT
291
+ // crashes here — docs/FFI_BUN_GUIDE.md §14 traced every report to
292
+ // dev-loop ABI desync, not bun:ffi. The memo captures the win without
293
+ // an ABI change anyway.
294
+ const ev = event === null ? EMPTY_VIEW : sseMemoEncode(sseMemoEvent, event)
295
+ const idv = id === null ? EMPTY_VIEW : sseMemoEncode(sseMemoId, id)
260
296
  const flags = (event === null ? 0 : 1) | (id === null ? 0 : 2) | (retry === null ? 0 : 4)
261
297
  return growExact(
262
298
  (out) =>
@@ -283,8 +319,8 @@ export function buildParse(
283
319
  // Pooled sibling — caller-owned output buffer (sized ≥ `data.length + 64`
284
320
  // for the common single-line case). Needed-size convention: w > output.length
285
321
  // = exact required size → throw; w === 0 = real error (invalid UTF-8).
286
- const ev = event === null ? EMPTY_VIEW : encodeUtf8(event)
287
- const idv = id === null ? EMPTY_VIEW : encodeUtf8(id)
322
+ const ev = event === null ? EMPTY_VIEW : sseMemoEncode(sseMemoEvent, event)
323
+ const idv = id === null ? EMPTY_VIEW : sseMemoEncode(sseMemoId, id)
288
324
  const flags = (event === null ? 0 : 1) | (id === null ? 0 : 2) | (retry === null ? 0 : 4)
289
325
  const w = Number(
290
326
  sseEncodeIntoRaw(
@@ -42,6 +42,19 @@ export interface BuildCtx {
42
42
  /** Coerce an optional boolean to the C `u8` flag (1/0). */
43
43
  export const flag = (v?: boolean): number => (v ? 1 : 0)
44
44
 
45
+ /**
46
+ * Output-buffer allocation for the FFI wrappers. The native write fills
47
+ * `[0, w)` and only `subarray(0, w)` escapes, so zero-initialization is pure
48
+ * waste: `Buffer.allocUnsafe` skips it (measured ~2x at 4 KB, ~3.4x at 16 KB
49
+ * on Bun 1.4; small sizes stay on the plain constructor where it is faster).
50
+ */
51
+ export function allocOut(n: number): Uint8Array {
52
+ if (n >= 512 && typeof Buffer !== 'undefined' && typeof Buffer.allocUnsafe === 'function') {
53
+ return Buffer.allocUnsafe(n)
54
+ }
55
+ return new Uint8Array(n)
56
+ }
57
+
45
58
  /**
46
59
  * Write with the C ABI's "needed" convention. The C side returns the EXACT
47
60
  * total bytes the result requires: `0` = real error (throw immediately — no
@@ -59,7 +72,7 @@ export function growExact(
59
72
  ): Uint8Array {
60
73
  let cap = Math.min(Math.max(initial, 16), max)
61
74
  for (;;) {
62
- const out = new Uint8Array(cap)
75
+ const out = allocOut(cap)
63
76
  const w = Number(write(out))
64
77
  if (w === 0) throw new Error(error)
65
78
  if (w <= out.length) return out.subarray(0, w)
@@ -14,6 +14,7 @@
14
14
  import { buildCodecs } from './build/codecs'
15
15
  import { buildCompress } from './build/compress'
16
16
  import { buildInstances } from './build/instances'
17
+ import { buildMetrics } from './build/metrics'
17
18
  import { buildParse } from './build/parse'
18
19
  import type { BuildCtx } from './build/util'
19
20
  import type { BunFFI } from './types'
@@ -90,5 +91,6 @@ export function build(
90
91
  ...buildCompress(sym, ctx),
91
92
  ...buildParse(sym, ctx),
92
93
  ...buildInstances(sym, ctx),
94
+ ...buildMetrics(sym, ctx),
93
95
  } as BunFFI
94
96
  }
@@ -7,16 +7,23 @@
7
7
  // the safety net. New C-ABI symbols MUST be added to the self-test for their
8
8
  // domain: each check lives in the same `ffi/build/` file where the method is
9
9
  // bound (`selfTestCodecs` / `selfTestCompress` / `selfTestParse` /
10
- // `selfTestInstances`), and this entry point ANDs them together so any single
11
- // failure disables the whole layer.
10
+ // `selfTestInstances` / `selfTestMetrics`), and this entry point ANDs them
11
+ // together so any single failure disables the whole layer.
12
12
 
13
13
  import { selfTestCodecs } from './build/codecs'
14
14
  import { selfTestCompress } from './build/compress'
15
15
  import { selfTestInstances } from './build/instances'
16
+ import { selfTestMetrics } from './build/metrics'
16
17
  import { selfTestParse } from './build/parse'
17
18
  import type { BunFFI } from './types'
18
19
 
19
20
  /** Verify every bound function against known-good results; false disables ffi. */
20
21
  export function selfTest(b: BunFFI): boolean {
21
- return selfTestCodecs(b) && selfTestCompress(b) && selfTestParse(b) && selfTestInstances(b)
22
+ return (
23
+ selfTestCodecs(b) &&
24
+ selfTestCompress(b) &&
25
+ selfTestParse(b) &&
26
+ selfTestInstances(b) &&
27
+ selfTestMetrics(b)
28
+ )
22
29
  }
@@ -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
@@ -308,7 +313,10 @@ export interface BunFFI {
308
313
  /**
309
314
  * Encode one SSE event → bytes (fresh buffer). event/id null = line omitted
310
315
  * (a present-but-empty string emits the line, matching napi `Option`).
311
- * FFI sibling of the napi `sse_encode_event` — kills the napi crossing.
316
+ * NOTE: event/id stay `(ptr,len)` byte args — a cstring-ARG conversion was
317
+ * attempted and reverted (2026-08-23); see docs/FFI_BUN_GUIDE.md §14 before
318
+ * touching this signature.
319
+ * FFI sibling of the napi `sse_encode_event`.
312
320
  */
313
321
  sseEncodeEvent(
314
322
  event: string | null,
@@ -388,6 +396,98 @@ export interface BunFFI {
388
396
  routeRun(handle: number, frame: Uint8Array, output: Uint8Array): number
389
397
  /** Destroy a compiled route handle (frees the native `Box<NativeRoute>`). */
390
398
  routeDestroy(handle: number): void
399
+
400
+ // ── Metrics registry (`castrum_metrics_*`) ─────────────────────────
401
+ /** Create an empty metrics registry → a caller-owned opaque handle. */
402
+ metricsCreate(): number
403
+ /**
404
+ * Declare a counter family → its `u32` series id (`0xFFFFFFFF` = error).
405
+ * `name` and `labelKeys` are `cstring` ARGs; label keys are
406
+ * `\u001f`-separated (empty string = no labels).
407
+ */
408
+ metricsCounter(handle: number, name: string, labelKeys: string): number
409
+ /** Declare a gauge family → its series id. Same ABI as {@link metricsCounter}. */
410
+ metricsGauge(handle: number, name: string, labelKeys: string): number
411
+ /**
412
+ * Declare a histogram family → its series id. `bucketsCsv` is a
413
+ * comma-separated finite positive list (empty string = default buckets).
414
+ */
415
+ metricsHistogram(handle: number, name: string, labelKeys: string, bucketsCsv: string): number
416
+ /**
417
+ * Counter/gauge `+= amount`, or histogram observe `amount`. `values` is the
418
+ * packed `\u001f`-separated label values. Returns true on success.
419
+ */
420
+ metricsRecord(handle: number, series: number, values: Uint8Array, amount: number): boolean
421
+ /** Gauge assignment (`= value`). Returns true on success. */
422
+ metricsGaugeSet(handle: number, series: number, values: Uint8Array, value: number): boolean
423
+ /**
424
+ * Zero-encode sibling of {@link metricsRecord}: `values` is the JOINED
425
+ * `\u001f`-separated JS string — the engine transcodes it in-engine.
426
+ */
427
+ metricsRecordStr(handle: number, series: number, values: string, amount: number): boolean
428
+ /** Zero-encode sibling of {@link metricsGaugeSet} (joined string values). */
429
+ metricsGaugeSetStr(handle: number, series: number, values: string, value: number): boolean
430
+ /**
431
+ * Render the Prometheus text format into `output`; returns bytes written
432
+ * (`0` = null handle / error; `> output.length` = exact required size).
433
+ */
434
+ metricsRender(handle: number, output: Uint8Array): number
435
+ /**
436
+ * Dump the packed v1 series snapshot into `output`; returns bytes written
437
+ * (`0` = null handle / error; `> output.length` = exact required size).
438
+ */
439
+ metricsSnapshot(handle: number, output: Uint8Array): number
440
+ /** Destroy a registry handle (frees the native `Box<MetricsRegistry>`). */
441
+ metricsDestroy(handle: number): void
442
+ /**
443
+ * Record a BATCH of events in one crossing. Packed layout:
444
+ * `[u32 n]{[u32 series][u32 valsLen][vals][f64 amount]}`; returns true when
445
+ * all entries applied.
446
+ */
447
+ metricsRecordBatch(handle: number, packed: Uint8Array): boolean
448
+ /**
449
+ * Fused wire-level validation: RAW query string → JSON → draft-07 validate
450
+ * against the compiled schema (`inner` from `SchemaValidator.innerPtr()`).
451
+ */
452
+ queryValidate(inner: number, qs: string): boolean
453
+ /** Fused cookie-header variant of {@link queryValidate}. */
454
+ cookieValidate(inner: number, header: string): boolean
455
+ /**
456
+ * Seal a session envelope natively: builds
457
+ * `{"id":"…","data":<dataJson>,"exp":exp}` and HMAC-signs it →
458
+ * `payload.<hex>` token string (`null` = failure).
459
+ */
460
+ sessionSeal(id: string, dataJson: string, expSecs: number, secret: string): string | null
461
+ /**
462
+ * Open a sealed session token: verify + extract in one crossing into
463
+ * `output` as `[u8 ok][i64 exp][u32 idLen][id][u32 dataLen][dataJson]`.
464
+ * Returns bytes written (`0` = bad signature / error; `> output.length` =
465
+ * exact required size).
466
+ */
467
+ sessionOpen(token: string, secret: string, output: Uint8Array): number
468
+
469
+ // ── Batch fixed-width hex validation / RegExp escaping ─────────────
470
+ /**
471
+ * Validate NEWLINE-separated lines as fixed-width hex into `output`
472
+ * (one verdict byte per line); returns bytes written (`0` = bad width /
473
+ * error; `> output.length` = exact required size).
474
+ */
475
+ hexValidateBatchInto(input: Uint8Array, width: number, output: Uint8Array): number
476
+ /**
477
+ * String-input sibling of {@link hexValidateBatchInto}: the NEWLINE-
478
+ * separated ids cross as a `cstring` ARG (engine-transcoded — no encode).
479
+ */
480
+ hexValidateBatchStr(ids: string, width: number, output: Uint8Array): number
481
+ /**
482
+ * Escape JS-RegExp metacharacters into `output`; returns bytes written
483
+ * (`0` = error; `> output.length` = exact required size).
484
+ */
485
+ regexEscapeInto(input: Uint8Array, output: Uint8Array): number
486
+ /**
487
+ * Zero-copy text sibling of {@link regexEscapeInto}: cstring ARG in,
488
+ * cstring return out (engine-cloned — zero JS decode). `null` = failure.
489
+ */
490
+ regexEscapeStr(input: string): string | null
391
491
  }
392
492
 
393
493
  /**
@@ -411,6 +511,8 @@ export interface RateLimiterVerdict {
411
511
  resetMs: number
412
512
  }
413
513
 
514
+ /** Raw 0-arg C-ABI symbol signature (`castrum_metrics_create`). */
515
+ export type Raw0 = () => number | bigint
414
516
  /** Raw 2-arg C-ABI symbol signature (`(ptr,len)` pairs → scalar result). */
415
517
  export type Raw2 = (a: unknown, b: unknown) => number | bigint
416
518
  /** Raw 3-arg C-ABI symbol signature. */
package/src/native/ffi.ts CHANGED
@@ -206,10 +206,17 @@ function bind(): BunFFI | null {
206
206
  castrum_url_decode: { args: abi(['ptr', 'usize', 'ptr', 'usize']), returns: U64_FAST },
207
207
  // String-input validators: `cstring` ARG (the engine transcodes the JS
208
208
  // string in-engine — no JS-side encode; Rust borrows via CStr::from_ptr).
209
+ // The `_bytes` siblings take a `(ptr,len)` pair — zero transcode when
210
+ // the caller already holds bytes (a cstring arg would force a decode +
211
+ // engine re-encode; docs/FFI_BUN_GUIDE.md §3 shape 4).
209
212
  castrum_validate_email: { args: ['cstring'], returns: 'u8' },
210
213
  castrum_validate_uuid: { args: ['cstring'], returns: 'u8' },
211
214
  castrum_validate_ipv4: { args: ['cstring'], returns: 'u8' },
212
215
  castrum_validate_ipv6: { args: ['cstring'], returns: 'u8' },
216
+ castrum_validate_email_bytes: { args: inputAbi, returns: 'u8' },
217
+ castrum_validate_uuid_bytes: { args: inputAbi, returns: 'u8' },
218
+ castrum_validate_ipv4_bytes: { args: inputAbi, returns: 'u8' },
219
+ castrum_validate_ipv6_bytes: { args: inputAbi, returns: 'u8' },
213
220
  castrum_json_sum_ids: { args: abi(['ptr', 'usize', 'ptr', 'usize']), returns: U64_FAST },
214
221
  castrum_hmac_sha256_verify: {
215
222
  args: abi(['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize']),
@@ -357,6 +364,11 @@ function bind(): BunFFI | null {
357
364
  },
358
365
  castrum_http_date_into: { args: ['f64', ...abi(['ptr', 'usize'])], returns: U64_FAST },
359
366
  castrum_sse_encode_into: {
367
+ // event/id stay `(ptr,len)` byte args. A cstring-ARG conversion was
368
+ // tried (2026-08-23) and reverted: the win was unproven once the
369
+ // codec encode path got faster, and ABI-desync segfaults during the
370
+ // experiment were misread as an engine bug — see docs/FFI_BUN_GUIDE.md
371
+ // §14 before touching this signature.
360
372
  args: abi(['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize', 'u8', 'u32', 'ptr', 'usize']),
361
373
  returns: U64_FAST,
362
374
  },
@@ -467,9 +479,83 @@ function bind(): BunFFI | null {
467
479
  returns: U64_FAST,
468
480
  },
469
481
  castrum_route_destroy: { args: ['usize'], returns: 'void' },
482
+ // ── Batch fixed-width hex validation (ObjectId-shaped ids, etc.) ──
483
+ // NEWLINE-separated lines in; one verdict byte (1/0) per line out.
484
+ castrum_hex_validate_batch: {
485
+ args: abi(['ptr', 'usize', 'u32', 'ptr', 'usize']),
486
+ returns: U64_FAST,
487
+ },
488
+ // ── JS-RegExp metacharacter escaping (needed-size convention) ──
489
+ castrum_regex_escape: { args: abi(['ptr', 'usize', 'ptr', 'usize']), returns: U64_FAST },
490
+ // ── Metrics registry (`castrum_metrics_*`) ──────────────────────
491
+ // Caller-owned handle (route-stack ownership model): create returns a
492
+ // Box<Registry> as usize; declare fns take name/label-keys as cstring
493
+ // ARGS (label keys `\x1f`-separated) and return the u32 series id
494
+ // (0xFFFFFFFF = error); record/set cross the packed label VALUES as a
495
+ // `(ptr,len)` pair + the amount as f64.
496
+ castrum_metrics_create: { args: [], returns: U64_FAST },
497
+ castrum_metrics_counter: { args: ['usize', 'cstring', 'cstring'], returns: 'u32' },
498
+ castrum_metrics_gauge: { args: ['usize', 'cstring', 'cstring'], returns: 'u32' },
499
+ castrum_metrics_histogram: {
500
+ args: ['usize', 'cstring', 'cstring', 'cstring'],
501
+ returns: 'u32',
502
+ },
503
+ castrum_metrics_record: {
504
+ args: abi(['usize', 'u32', 'ptr', 'usize', 'f64']),
505
+ returns: 'u8',
506
+ },
507
+ castrum_metrics_gauge_set: {
508
+ args: abi(['usize', 'u32', 'ptr', 'usize', 'f64']),
509
+ returns: 'u8',
510
+ },
511
+ // Zero-encode siblings: the joined label values cross as ONE `cstring`
512
+ // ARG — the engine transcodes the JS string in-engine (no TextEncoder).
513
+ castrum_metrics_record_str: {
514
+ args: ['usize', 'u32', 'cstring', 'f64'],
515
+ returns: 'u8',
516
+ },
517
+ castrum_metrics_gauge_set_str: {
518
+ args: ['usize', 'u32', 'cstring', 'f64'],
519
+ returns: 'u8',
520
+ },
521
+ // Zero-copy text escape: cstring ARG in, cstring return out — the JS
522
+ // side does zero encode AND zero decode (engine clones at return).
523
+ castrum_regex_escape_str: { args: ['cstring'], returns: 'cstring' },
524
+ // String-input batch hex validation: `ids.join('\n')` crosses as a
525
+ // cstring ARG.
526
+ castrum_hex_validate_batch_str: {
527
+ args: abi(['cstring', 'u32', 'ptr', 'usize']),
528
+ returns: U64_FAST,
529
+ },
530
+ castrum_metrics_render: { args: abi(['usize', 'ptr', 'usize']), returns: U64_FAST },
531
+ castrum_metrics_snapshot: { args: abi(['usize', 'ptr', 'usize']), returns: U64_FAST },
532
+ castrum_metrics_record_batch: {
533
+ args: abi(['usize', 'ptr', 'usize']),
534
+ returns: 'u8',
535
+ },
536
+ // Fused wire-level validation: RAW query/cookie → JSON → draft-07 gate.
537
+ // `inner` is the SchemaValidator inner handle; strings cross as cstring
538
+ // ARGs (engine-transcoded).
539
+ castrum_query_validate: { args: ['usize', 'cstring'], returns: 'u8' },
540
+ castrum_cookie_validate: { args: ['usize', 'cstring'], returns: 'u8' },
541
+ // Fused session envelope: seal builds+signs; open verifies + extracts.
542
+ castrum_session_seal: {
543
+ args: ['cstring', 'cstring', 'i64', 'cstring'],
544
+ returns: 'cstring',
545
+ },
546
+ castrum_session_open: {
547
+ args: abi(['cstring', 'cstring', 'ptr', 'usize']),
548
+ returns: U64_FAST,
549
+ },
550
+ castrum_metrics_destroy: { args: ['usize'], returns: 'void' },
470
551
  })
471
552
 
472
- const bindings = build(symbols as Record<string, (...a: unknown[]) => unknown>, useBufferLength)
553
+ // (`as unknown as`: the zero-arg `castrum_metrics_create` infers
554
+ // `(...args: never[])`, which can't overlap `(...a: unknown[])` directly.)
555
+ const bindings = build(
556
+ symbols as unknown as Record<string, (...a: unknown[]) => unknown>,
557
+ useBufferLength,
558
+ )
473
559
  if (!selfTest(bindings)) {
474
560
  if (mode === 'ffi') {
475
561
  throw new Error(
@@ -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
 
@@ -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