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
@@ -8,22 +8,22 @@
8
8
  // - sseResponse → server-sent-events response framed on the fast path
9
9
 
10
10
  export {
11
+ runMany,
12
+ runOne,
13
+ validateCount,
14
+ validateMany,
15
+ } from './batch'
16
+ export {
17
+ type CreatePipelineOptions,
11
18
  createPipeline,
12
19
  type IngressPipeline,
13
- type PipelineResult,
14
20
  type PipelineContext,
21
+ type PipelineResult,
15
22
  type PreprocessOutcome,
16
- type CreatePipelineOptions,
17
23
  } from './pipeline'
24
+ export { type SseEvent, sseResponse } from './streaming'
18
25
  export {
19
26
  createWebSocketUpgrade,
20
27
  type WebSocketUpgradeOptions,
21
28
  type WebSocketUpgradeResult,
22
29
  } from './websocket'
23
- export { sseResponse, type SseEvent } from './streaming'
24
- export {
25
- validateMany,
26
- validateCount,
27
- runMany,
28
- runOne,
29
- } from './batch'
@@ -7,18 +7,18 @@
7
7
  // context to the app's own handler. `handleRequest` is a fetch-compatible
8
8
  // convenience that serves the terminal response or a rendered OK response.
9
9
 
10
+ import { readBodyWithLimit } from '../ingress/body'
11
+ import type { BakedIngressResult } from '../ingress/decode/baked-result'
10
12
  import {
11
- createIngressHandler,
12
13
  type BakedIngressRuntime,
14
+ createIngressHandler,
13
15
  type OptimizedIngressHandler,
14
16
  } from '../ingress/handlers'
15
- import type { BakedContext } from '../ingress/types'
16
17
  import type { IngressHandlerOptions } from '../ingress/options'
17
- import { readBodyWithLimit } from '../ingress/body'
18
- import { DEFAULT_MAX_BODY_BYTES, DEFAULT_BODY_TIMEOUT_MS } from '../ingress/shared'
19
- import { generateRequestId } from '../shared/request-id'
18
+ import { DEFAULT_BODY_TIMEOUT_MS, DEFAULT_MAX_BODY_BYTES } from '../ingress/shared'
19
+ import type { BakedContext } from '../ingress/types'
20
20
  import { decoder } from '../shared/bytes'
21
- import type { BakedIngressResult } from '../ingress/decode/baked-result'
21
+ import { generateRequestId } from '../shared/request-id'
22
22
 
23
23
  /** Readonly snapshot of the ingress decision, safe after `run()` returns. */
24
24
  export interface PipelineResult {
@@ -1,8 +1,8 @@
1
1
  // src/integration/websocket.ts — WebSocket upgrade handshake helper.
2
2
 
3
- import { isBun } from '../shared/runtime'
4
3
  import { rust } from '../rust-ffi'
5
- /** Options for `createWebSocketUpgrade` (supported subprotocols). */export interface WebSocketUpgradeOptions {
4
+ import { isBun } from '../shared/runtime'
5
+ /** Options for `createWebSocketUpgrade` (supported subprotocols). */ export interface WebSocketUpgradeOptions {
6
6
  /** Subprotocols this server supports; the first client-requested match wins. */
7
7
  protocols?: ReadonlyArray<string>
8
8
  }
@@ -27,11 +27,7 @@
27
27
  import { rust } from '../rust-ffi'
28
28
  import { encoder, viewForArrayBuffer } from '../shared/bytes'
29
29
  import type { SchemaValidator } from '../shared/packed'
30
- import {
31
- createLruCache,
32
- createTickCoalescer,
33
- type LoadRequest,
34
- } from './batch'
30
+ import { createLruCache, createTickCoalescer, type LoadRequest } from './batch'
35
31
  import { createCostModel, nowNs } from './cost'
36
32
  import {
37
33
  fnvBatchKeysInto,
package/src/loader/ops.ts CHANGED
@@ -19,10 +19,10 @@
19
19
  // the loader never pays a conversion cost on the hot path. This module is the
20
20
  // single source of truth for what the loader can dispatch.
21
21
 
22
+ import type { PasswordHashOptions } from '../native'
22
23
  import { rust } from '../rust-ffi'
23
24
  import { addon } from '../rust-ffi/addon'
24
25
  import { decoder } from '../shared/bytes'
25
- import type { PasswordHashOptions } from '../native'
26
26
  import type { SchemaValidator } from '../shared/packed'
27
27
 
28
28
  /** Empty bytes sentinel for skip-on-error scalar parity (e.g. AEAD decrypt). */
@@ -9,7 +9,7 @@ import { decodeUtf8, encodeUtf8 } from '../../../shared/codec'
9
9
  import { SELFTEST_HEX, SELFTEST_JSON } from '../constants'
10
10
  import type { BunFFI, Raw3, Raw4, Raw5, Raw6, Raw8, Raw9, Raw10, RawCStr } from '../types'
11
11
  import type { BuildCtx } from './util'
12
- import { argon2PhcLength, cstr, flag, growExact, writeOrThrow } from './util'
12
+ import { allocOut, argon2PhcLength, cstr, flag, growExact, writeOrThrow } from './util'
13
13
 
14
14
  /**
15
15
  * Build the codec/crypto/auth methods of the BunFFI surface. `ctx` is
@@ -34,7 +34,17 @@ export function buildCodecs(
34
34
  const validateUuid = sym.castrum_validate_uuid as (...a: unknown[]) => number | bigint
35
35
  const validateIpv4 = sym.castrum_validate_ipv4 as (...a: unknown[]) => number | bigint
36
36
  const validateIpv6 = sym.castrum_validate_ipv6 as (...a: unknown[]) => number | bigint
37
+ const validateEmailBytes = sym.castrum_validate_email_bytes as (
38
+ ...a: unknown[]
39
+ ) => number | bigint
40
+ const validateUuidBytes = sym.castrum_validate_uuid_bytes as (...a: unknown[]) => number | bigint
41
+ const validateIpv4Bytes = sym.castrum_validate_ipv4_bytes as (...a: unknown[]) => number | bigint
42
+ const validateIpv6Bytes = sym.castrum_validate_ipv6_bytes as (...a: unknown[]) => number | bigint
37
43
  const jsonSumRaw = sym.castrum_json_sum_ids as Raw4
44
+ const hexBatchRaw = sym.castrum_hex_validate_batch as Raw5
45
+ const hexBatchStrRaw = sym.castrum_hex_validate_batch_str as Raw4
46
+ const regexEscapeRaw = sym.castrum_regex_escape as Raw4
47
+ const regexEscapeStrRaw = sym.castrum_regex_escape_str as RawCStr
38
48
  const hmacVerify = sym.castrum_hmac_sha256_verify as Raw6
39
49
  const csrfVerify = sym.castrum_csrf_verify as Raw4
40
50
  const passwordVerify = sym.castrum_password_verify as Raw4
@@ -176,6 +186,41 @@ export function buildCodecs(
176
186
  validateUuid: (input) => Number(validateUuid(input)) === 1,
177
187
  validateIpv4: (input) => Number(validateIpv4(input)) === 1,
178
188
  validateIpv6: (input) => Number(validateIpv6(input)) === 1,
189
+ // Byte-input validators: `(ptr,len)` — zero transcode (no CString decode
190
+ // + engine re-encode like the cstring-ARG string forms above).
191
+ validateEmailBytes: (input) => Number(oneArg(validateEmailBytes, input)) === 1,
192
+ validateUuidBytes: (input) => Number(oneArg(validateUuidBytes, input)) === 1,
193
+ validateIpv4Bytes: (input) => Number(oneArg(validateIpv4Bytes, input)) === 1,
194
+ validateIpv6Bytes: (input) => Number(oneArg(validateIpv6Bytes, input)) === 1,
195
+ hexValidateBatchInto(input, width, output) {
196
+ // Batch fixed-width hex validation: NEWLINE-separated lines in; one
197
+ // verdict byte (1/0) per line out. Needed-size convention: `0` = bad
198
+ // width / null pointers (real error → throw); `> output.length` = the
199
+ // exact required size (caller grows once and retries). Width is
200
+ // validated here so an empty input can't mask it.
201
+ if (width === 0 || width > 4096) {
202
+ throw new Error('hex validate batch: width must be 1..=4096')
203
+ }
204
+ const w = Number(hexBatchRaw(input, lenOrView(input), width >>> 0, output, lenOrView(output)))
205
+ return w
206
+ },
207
+ regexEscapeInto(input, output) {
208
+ const w = Number(regexEscapeRaw(input, lenOrView(input), output, lenOrView(output)))
209
+ if (w === 0) {
210
+ throw new Error('regex escape: output buffer too small')
211
+ }
212
+ return w
213
+ },
214
+ // Zero-copy text path: cstring ARG in (engine-transcoded), cstring return
215
+ // out (engine-cloned) — the JS side does zero encode AND zero decode.
216
+ regexEscapeStr: (input) => regexEscapeStrRaw(input),
217
+ hexValidateBatchStr(ids, width, output) {
218
+ if (width === 0 || width > 4096) {
219
+ throw new Error('hex validate batch: width must be 1..=4096')
220
+ }
221
+ const w = Number(hexBatchStrRaw(ids, width >>> 0, output, lenOrView(output)))
222
+ return w
223
+ },
179
224
  jsonSumIds: (input) => {
180
225
  // Packed [u8 ok][i64 sum LE] output (9 B): ok=1 → valid array (the sum
181
226
  // may be 0); ok=0 → invalid input. Bytes written: 9/1/0 (0 = real error).
@@ -202,19 +247,19 @@ export function buildCodecs(
202
247
  Number(passwordVerifyBcrypt(password, lenOrView(password), phc)) === 1,
203
248
 
204
249
  hexDecode(input) {
205
- const out = new Uint8Array(Math.floor(input.length / 2))
250
+ const out = allocOut(Math.floor(input.length / 2))
206
251
  const w = hexDecodeInto(input, out)
207
252
  return out.subarray(0, w)
208
253
  },
209
254
  hexDecodeInto,
210
255
  urlDecode(input) {
211
- const out = new Uint8Array(input.length)
256
+ const out = allocOut(input.length)
212
257
  const w = urlDecodeInto(input, out)
213
258
  return out.subarray(0, w)
214
259
  },
215
260
  urlDecodeInto,
216
261
  base64Decode(input, urlSafe, padding) {
217
- const out = new Uint8Array(Math.ceil((input.length * 3) / 4))
262
+ const out = allocOut(Math.ceil((input.length * 3) / 4))
218
263
  const w = base64DecodeInto(input, out, urlSafe, padding)
219
264
  return out.subarray(0, w)
220
265
  },
@@ -398,7 +443,7 @@ export function buildCodecs(
398
443
  },
399
444
  passwordHashBcrypt(password, cost) {
400
445
  // `$2b$CC$` + 22 salt chars + 31 hash chars = 60 chars.
401
- const out = new Uint8Array(64)
446
+ const out = allocOut(64)
402
447
  const w = Number(passwordHashBcrypt(password, lenOrView(password), cost, out, lenOrView(out)))
403
448
  if (w === 0) {
404
449
  throw new Error('password hash bcrypt: output buffer too small')
@@ -409,7 +454,7 @@ export function buildCodecs(
409
454
  // Rust clamps dkLen to [1, 1MiB] (PBKDF2_MIN_LEN/MAX_LEN) AFTER sizing its
410
455
  // own buffer — so pre-clamp here so dkLen 0 still yields a 1-byte result.
411
456
  const dk = Math.min(Math.max(dkLen, 1), 1024 * 1024)
412
- const out = new Uint8Array(dk)
457
+ const out = allocOut(dk)
413
458
  const w = Number(
414
459
  pbkdf2(
415
460
  password,
@@ -429,7 +474,7 @@ export function buildCodecs(
429
474
  },
430
475
  aeadEncrypt(key, nonce, plaintext, algorithm = 0) {
431
476
  // ciphertext + 16-byte auth tag.
432
- const out = new Uint8Array(plaintext.length + 16)
477
+ const out = allocOut(plaintext.length + 16)
433
478
  const w = Number(
434
479
  aeadEncrypt(
435
480
  key,
@@ -472,7 +517,7 @@ export function buildCodecs(
472
517
  return w
473
518
  },
474
519
  aeadDecrypt(key, nonce, ciphertext, algorithm = 0) {
475
- const out = new Uint8Array(ciphertext.length)
520
+ const out = allocOut(ciphertext.length)
476
521
  const w = Number(
477
522
  aeadDecrypt(
478
523
  key,
@@ -534,6 +579,17 @@ export function selfTestCodecs(b: BunFFI): boolean {
534
579
  ) {
535
580
  return false
536
581
  }
582
+ // Byte-input validator siblings: `(ptr,len)` parity with the string forms.
583
+ if (
584
+ !b.validateEmailBytes(enc.encode('a@b.com')) ||
585
+ !b.validateUuidBytes(enc.encode('550e8400-e29b-41d4-a716-446655440000')) ||
586
+ !b.validateIpv4Bytes(enc.encode('192.168.0.1')) ||
587
+ !b.validateIpv6Bytes(enc.encode('2001:db8::1')) ||
588
+ b.validateEmailBytes(enc.encode('not-an-email')) ||
589
+ b.validateIpv6Bytes(enc.encode('999'))
590
+ ) {
591
+ return false
592
+ }
537
593
  // Packed `[u8 ok][i64 sum LE]` ABI: legit zero-sum is ok, invalid input throws.
538
594
  if (b.jsonSumIds(enc.encode(`[{"id":1},{"id":2}]`)) !== 3n) return false
539
595
  if (b.jsonSumIds(enc.encode(`[{"id":0},{"id":0}]`)) !== 0n) return false
@@ -545,6 +601,46 @@ export function selfTestCodecs(b: BunFFI): boolean {
545
601
  }
546
602
  if (!sumInvalidThrew) return false
547
603
 
604
+ // Batch fixed-width hex validation (needed-size convention + verdicts).
605
+ {
606
+ const ids = enc.encode('507f1f77bcf86cd799439011\nzz\n507F1F77BCF86CD799439012')
607
+ const tiny = new Uint8Array(2)
608
+ const needed = b.hexValidateBatchInto(ids, 24, tiny)
609
+ if (needed !== 3) return false // exact required size on a too-small buffer
610
+ const out = new Uint8Array(needed)
611
+ if (b.hexValidateBatchInto(ids, 24, out) !== 3) return false
612
+ if (out[0] !== 1 || out[1] !== 0 || out[2] !== 1) return false
613
+ let widthThrew = false
614
+ try {
615
+ b.hexValidateBatchInto(ids, 0, new Uint8Array(3))
616
+ } catch {
617
+ widthThrew = true
618
+ }
619
+ if (!widthThrew) return false
620
+ }
621
+
622
+ // RegExp escaping: metachars get backslashes; plain text is untouched.
623
+ {
624
+ const out = new Uint8Array(64)
625
+ const w = b.regexEscapeInto(enc.encode('a.c(x)'), out)
626
+ if (w !== 9 || dec.decode(out.subarray(0, w)) !== 'a\\.c\\(x\\)') return false
627
+ const plain = new Uint8Array(16)
628
+ const wp = b.regexEscapeInto(enc.encode('hello'), plain)
629
+ if (wp !== 5 || dec.decode(plain.subarray(0, wp)) !== 'hello') return false
630
+ // too-small buffer reports the exact required size
631
+ if (b.regexEscapeInto(enc.encode('a.b'), new Uint8Array(2)) !== 4) return false
632
+ // zero-copy str sibling must produce the identical escaped string
633
+ if (b.regexEscapeStr('a.c(x)') !== 'a\\.c\\(x\\)') return false
634
+ if (b.regexEscapeStr('plain text') !== 'plain text') return false
635
+ }
636
+
637
+ // String-input batch hex validation (cstring ARG sibling).
638
+ {
639
+ const out = new Uint8Array(8)
640
+ const w = b.hexValidateBatchStr('507f1f77bcf86cd799439011\nzz', 24, out)
641
+ if (w !== 2 || out[0] !== 1 || out[1] !== 0) return false
642
+ }
643
+
548
644
  // HMAC RFC 4231 test case 1 (0x0b × 20 key, "Hi There" data).
549
645
  const hmacKey = new Uint8Array(20).fill(0x0b)
550
646
  const hmacData = enc.encode('Hi There')
@@ -29,7 +29,7 @@ export function buildCompress(
29
29
  sym: Record<string, (...a: unknown[]) => unknown>,
30
30
  ctx: BuildCtx,
31
31
  ): Partial<BunFFI> {
32
- const { lenOrView } = ctx
32
+ const { lenOrView, scratchFor } = ctx
33
33
 
34
34
  const gzipCompress = sym.castrum_gzip_compress as Raw5
35
35
  const gzipDecompress = sym.castrum_gzip_decompress as Raw5
@@ -40,15 +40,19 @@ export function buildCompress(
40
40
 
41
41
  return {
42
42
  jsonPatch(doc, patch) {
43
- return decodeUtf8(
44
- growExact(
45
- (out) =>
46
- Number(jsonPatch(doc, lenOrView(doc), patch, lenOrView(patch), out, lenOrView(out))),
47
- Math.min(Math.max(doc.length, patch.length) + 16, 64 * 1024),
48
- MAX_JSON_PATCH_OUTPUT,
49
- 'json patch: output buffer too small or patch inapplicable',
50
- ),
51
- )
43
+ // Pooled scratch — the patched doc is decoded to an immutable string
44
+ // synchronously (same call-scope contract as the cstring returns), so
45
+ // the bytes never need a fresh buffer. One exact retry on miss.
46
+ let out = scratchFor(Math.min(Math.max(doc.length, patch.length) + 16, 64 * 1024))
47
+ let w = Number(jsonPatch(doc, lenOrView(doc), patch, lenOrView(patch), out, lenOrView(out)))
48
+ if (w > out.length && w <= MAX_JSON_PATCH_OUTPUT) {
49
+ out = new Uint8Array(w)
50
+ w = Number(jsonPatch(doc, lenOrView(doc), patch, lenOrView(patch), out, lenOrView(out)))
51
+ }
52
+ if (w === 0 || w > out.length) {
53
+ throw new Error('json patch: output buffer too small or patch inapplicable')
54
+ }
55
+ return decodeUtf8(out.subarray(0, w))
52
56
  },
53
57
  gzipCompress(data, level = 6) {
54
58
  // The C ABI streams the compressed output directly into `out` (no internal
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { decodeUtf8, encodeUtf8 } from '../../../shared/codec'
10
10
  import { EMPTY_VIEW } from '../constants'
11
- import type { BunFFI, Raw2, Raw3, Raw5, Raw6, Raw7, Raw8, Raw12, RawCStr } from '../types'
11
+ import type { BunFFI, Raw2, Raw3, Raw4, Raw5, Raw6, Raw7, Raw8, Raw12, RawCStr } from '../types'
12
12
  import type { BuildCtx } from './util'
13
13
  import { cstr, growExact, unpackRateCheck } from './util'
14
14
 
@@ -25,8 +25,9 @@ export function buildInstances(
25
25
  const conditionalIsNotModifiedRaw = sym.castrum_conditional_is_not_modified as Raw6
26
26
  const mediaTypeMatcherMatchesRaw = sym.castrum_media_type_matcher_matches as Raw3
27
27
  const acceptNegotiatorNegotiateRaw = sym.castrum_accept_negotiator_negotiate as RawCStr
28
- const acceptNegotiatorNegotiateServerRaw = sym
29
- .castrum_accept_negotiator_negotiate_server as (...a: unknown[]) => string | null
28
+ const acceptNegotiatorNegotiateServerRaw = sym.castrum_accept_negotiator_negotiate_server as (
29
+ ...a: unknown[]
30
+ ) => string | null
30
31
  const ed25519GenerateKeypairRaw = sym.castrum_ed25519_generate_keypair as Raw2
31
32
  const ed25519SignRaw = sym.castrum_ed25519_sign as Raw6
32
33
  const ed25519VerifyRaw = sym.castrum_ed25519_verify as Raw6
@@ -36,6 +37,10 @@ export function buildInstances(
36
37
  const jwtSignerVerifyRaw = sym.castrum_jwt_signer_verify as Raw6
37
38
  const templateRenderRaw = sym.castrum_template_render as Raw5
38
39
  const schemaValidatorValidateRaw = sym.castrum_schema_validator_validate as Raw3
40
+ const queryValidateSym = sym.castrum_query_validate as (...a: unknown[]) => number | bigint
41
+ const cookieValidateSym = sym.castrum_cookie_validate as (...a: unknown[]) => number | bigint
42
+ const sessionSealSym = sym.castrum_session_seal as (...a: unknown[]) => string | null
43
+ const sessionOpenSym = sym.castrum_session_open as Raw4
39
44
  const rateLimiterCheckRaw = sym.castrum_rate_limiter_check as Raw5
40
45
  const rateLimiterCheckKeyRaw = sym.castrum_rate_limiter_check_key as Raw5
41
46
  const jwtSignBytes = sym.castrum_jwt_sign_bytes as RawCStr
@@ -138,6 +143,22 @@ export function buildInstances(
138
143
  schemaValidatorValidate(inner, doc) {
139
144
  return Number(schemaValidatorValidateRaw(inner, doc, lenOrView(doc))) === 1
140
145
  },
146
+ queryValidate(inner, qs) {
147
+ // `qs` is a cstring ARG — engine-transcoded (zero JS encode).
148
+ return Number(queryValidateSym(inner, qs)) === 1
149
+ },
150
+ cookieValidate(inner, header) {
151
+ return Number(cookieValidateSym(inner, header)) === 1
152
+ },
153
+ sessionSeal(id, dataJson, expSecs, secret) {
154
+ // exp is an i64 C arg → BigInt.
155
+ return sessionSealSym(id, dataJson, BigInt(expSecs), secret)
156
+ },
157
+ sessionOpen(token, secret, output) {
158
+ // Needed-size convention; 0 = bad signature / malformed.
159
+ const w = Number(sessionOpenSym(token, secret, output, lenOrView(output)))
160
+ return w
161
+ },
141
162
  rateLimiterCheck(inner, key, nowMs) {
142
163
  // Packed [u8 allowed][u32 remaining LE][i64 reset_ms LE] (13 bytes).
143
164
  // Reused scratch + cached DataView (no per-call allocs). `key` is a
@@ -583,6 +604,22 @@ export function selfTestInstances(b: BunFFI): boolean {
583
604
  if (!urlBuilderThrew) {
584
605
  return false
585
606
  }
607
+ // Wire-validate: null inner (0) → false (ABI exercise); real parity lives
608
+ // in ffi.test.ts with a live compiled instance.
609
+ if (b.queryValidate(0, 'route=/a') !== false) return false
610
+ if (b.cookieValidate(0, 'route=/a') !== false) return false
611
+
612
+ // Session seal/open round trip through the C ABI.
613
+ {
614
+ const tok = b.sessionSeal('sess-9', '{"n":1}', 1234567, 'sekrit')
615
+ if (tok === null || !tok.includes('.') || !tok.startsWith('{"id":"sess-9"')) return false
616
+ const out = new Uint8Array(256)
617
+ const w = b.sessionOpen(tok, 'sekrit', out)
618
+ if (w <= 13 || out[0] !== 1) return false
619
+ // bad signature → 0
620
+ if (b.sessionOpen(tok, 'wrong', out) !== 0) return false
621
+ }
622
+
586
623
  // rateLimiterCheckKey: null (0) handle → throw (ABI exercise).
587
624
  let rlKeyThrew = false
588
625
  try {
@@ -0,0 +1,224 @@
1
+ // src/native/ffi/build/metrics.ts — metrics-registry BunFFI methods.
2
+ //
3
+ // Wraps the `castrum_metrics_*` caller-owned-handle surface (route-stack
4
+ // ownership model): create returns a native `Box<MetricsRegistry>` handle,
5
+ // declare fns cross name/label-keys as cstring ARGS and return the u32 series
6
+ // id, and record/set/render take packed byte slices. Receives the raw dlopen'd
7
+ // symbols from `build()`.
8
+
9
+ import { decodeUtf8, encodeUtf8 } from '../../../shared/codec'
10
+ import type { BunFFI, Raw0, Raw3, Raw4, Raw5 } from '../types'
11
+ import type { BuildCtx } from './util'
12
+
13
+ /**
14
+ * Sentinel returned by the declare fns (`u32::MAX`) on invalid input — fits
15
+ * f64 exactly and can never collide with a real dense family id.
16
+ */
17
+ export const METRICS_DECLARE_ERR = 0xffffffff
18
+
19
+ /** Empty label-values view (zero-label families). */
20
+ const EMPTY = new Uint8Array(0)
21
+
22
+ /**
23
+ * Build the metrics-registry methods of the BunFFI surface.
24
+ */
25
+ export function buildMetrics(
26
+ sym: Record<string, (...a: unknown[]) => unknown>,
27
+ ctx: BuildCtx,
28
+ ): Partial<BunFFI> {
29
+ const { lenOrView } = ctx
30
+ const metricsCreateRaw = sym.castrum_metrics_create as Raw0
31
+ const metricsCounterRaw = sym.castrum_metrics_counter as Raw3
32
+ const metricsGaugeRaw = sym.castrum_metrics_gauge as Raw3
33
+ const metricsHistogramRaw = sym.castrum_metrics_histogram as Raw4
34
+ const metricsRecordRaw = sym.castrum_metrics_record as Raw5
35
+ const metricsGaugeSetRaw = sym.castrum_metrics_gauge_set as Raw5
36
+ // Zero-encode siblings: joined values cross as a cstring ARG.
37
+ const metricsRecordStrRaw = sym.castrum_metrics_record_str as (...a: unknown[]) => number | bigint
38
+ const metricsGaugeSetStrRaw = sym.castrum_metrics_gauge_set_str as (
39
+ ...a: unknown[]
40
+ ) => number | bigint
41
+ const metricsRenderRaw = sym.castrum_metrics_render as Raw3
42
+ const metricsSnapshotRaw = sym.castrum_metrics_snapshot as Raw3
43
+ const metricsRecordBatchRaw = sym.castrum_metrics_record_batch as Raw3
44
+ const metricsDestroyRaw = sym.castrum_metrics_destroy as (h: unknown) => void
45
+
46
+ /** Declare helper: maps the C sentinel to a throw (fail fast at declare). */
47
+ const declareOrThrow = (id: number | bigint, what: string): number => {
48
+ const n = Number(id)
49
+ if (n === METRICS_DECLARE_ERR) {
50
+ throw new Error(`metrics: failed to declare ${what} (invalid name/labels/buckets)`)
51
+ }
52
+ return n
53
+ }
54
+
55
+ return {
56
+ metricsCreate() {
57
+ const h = Number(metricsCreateRaw())
58
+ if (h === 0) throw new Error('metrics: registry allocation failed')
59
+ return h
60
+ },
61
+ metricsCounter(handle, name, labelKeys) {
62
+ return declareOrThrow(metricsCounterRaw(handle, name, labelKeys), `counter "${name}"`)
63
+ },
64
+ metricsGauge(handle, name, labelKeys) {
65
+ return declareOrThrow(metricsGaugeRaw(handle, name, labelKeys), `gauge "${name}"`)
66
+ },
67
+ metricsHistogram(handle, name, labelKeys, bucketsCsv) {
68
+ return declareOrThrow(
69
+ metricsHistogramRaw(handle, name, labelKeys, bucketsCsv),
70
+ `histogram "${name}"`,
71
+ )
72
+ },
73
+ metricsRecord(handle, series, values, amount) {
74
+ // Packed `\x1f`-separated values cross as `(ptr,len)`; u8 verdict.
75
+ return Number(metricsRecordRaw(handle, series, values, lenOrView(values), amount)) === 1
76
+ },
77
+ metricsGaugeSet(handle, series, values, value) {
78
+ return Number(metricsGaugeSetRaw(handle, series, values, lenOrView(values), value)) === 1
79
+ },
80
+ metricsRecordStr(handle, series, values, amount) {
81
+ // `values` is the JOINED `\x1f` string — engine-transcoded (zero encode).
82
+ return Number(metricsRecordStrRaw(handle, series, values, amount)) === 1
83
+ },
84
+ metricsGaugeSetStr(handle, series, values, value) {
85
+ return Number(metricsGaugeSetStrRaw(handle, series, values, value)) === 1
86
+ },
87
+ metricsRender(handle, output) {
88
+ // Needed-size convention (routeRun parity): `0` = real error → throw;
89
+ // `> output.length` = the EXACT required size (caller grows once and
90
+ // retries — never a doubling re-run loop).
91
+ const w = Number(metricsRenderRaw(handle, output, lenOrView(output)))
92
+ if (w === 0) throw new Error('metrics render: registry handle invalid or render failed')
93
+ return w
94
+ },
95
+ metricsSnapshot(handle, output) {
96
+ // Needed-size convention (routeRun parity): 0 throws; > length = exact.
97
+ const w = Number(metricsSnapshotRaw(handle, output, lenOrView(output)))
98
+ if (w === 0) throw new Error('metrics snapshot: registry handle invalid')
99
+ return w
100
+ },
101
+ metricsRecordBatch(handle, packed) {
102
+ return Number(metricsRecordBatchRaw(handle, packed, lenOrView(packed))) === 1
103
+ },
104
+ metricsDestroy(handle) {
105
+ metricsDestroyRaw(handle)
106
+ },
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Bind-time self-test for the metrics surface (unique `ct.selftest.*` names).
112
+ * Exercises every symbol's ABI at bind time; `false` disables the ffi layer
113
+ * and forces the napi fallback.
114
+ */
115
+ export function selfTestMetrics(b: BunFFI): boolean {
116
+ let handle = 0
117
+ try {
118
+ handle = b.metricsCreate()
119
+ if (handle === 0) return false
120
+
121
+ const counter = b.metricsCounter(handle, 'ct_selftest_hits', 'route\u001fstatus')
122
+ if (!Number.isInteger(counter) || counter < 0 || counter === METRICS_DECLARE_ERR) {
123
+ console.error('FAIL: declare')
124
+ return false
125
+ }
126
+ if (!b.metricsRecord(handle, counter, encodeUtf8('/a\u001f200'), 2)) {
127
+ console.error('FAIL: record1')
128
+ return false
129
+ }
130
+ if (!b.metricsRecord(handle, counter, encodeUtf8('/a\u001f200'), 1)) {
131
+ console.error('FAIL: record2')
132
+ return false
133
+ }
134
+ if (!b.metricsRecordStr(handle, counter, '/b\u001f200', 2)) {
135
+ console.error('FAIL: recordStr')
136
+ return false
137
+ }
138
+
139
+ console.error('CK: counter')
140
+ const gauge = b.metricsGauge(handle, 'ct_selftest_depth', 'q')
141
+ if (gauge === METRICS_DECLARE_ERR) return false
142
+ if (!b.metricsGaugeSet(handle, gauge, encodeUtf8('jobs'), 4)) return false
143
+ if (!b.metricsGaugeSetStr(handle, gauge, 'io', 6)) return false
144
+
145
+ console.error('CK: gauge')
146
+ const hist = b.metricsHistogram(handle, 'ct_selftest_latency', '', '0.1,0.5')
147
+ if (hist === METRICS_DECLARE_ERR) return false
148
+ if (!b.metricsRecord(handle, hist, EMPTY, 0.25)) return false
149
+ if (b.metricsRecord(handle, hist, EMPTY, -1)) return false // negatives rejected
150
+
151
+ console.error('CK: hist')
152
+ // Arity mismatch must fail safely.
153
+ if (b.metricsRecord(handle, counter, encodeUtf8('/a'), 1)) return false
154
+
155
+ console.error('CK: arity')
156
+ // Render: probe with a small buffer → exact required size; then render.
157
+ const probe = new Uint8Array(16)
158
+ const needed = b.metricsRender(handle, probe)
159
+ if (needed <= probe.length) return false
160
+ const out = new Uint8Array(needed)
161
+ const written = b.metricsRender(handle, out)
162
+ if (written !== needed) return false
163
+ const text = decodeUtf8(out.subarray(0, written))
164
+ if (
165
+ !text.includes('ct_selftest_hits{route="/a",status="200"} 3\n') ||
166
+ !text.includes('ct_selftest_hits{route="/b",status="200"} 2\n') ||
167
+ !text.includes('ct_selftest_depth{q="jobs"} 4\n') ||
168
+ !text.includes('ct_selftest_depth{q="io"} 6\n') ||
169
+ !text.includes('ct_selftest_latency_bucket{le="0.5"} 1\n') ||
170
+ !text.includes('ct_selftest_latency_count 1\n')
171
+ ) {
172
+ return false
173
+ }
174
+
175
+ console.error('CK: render')
176
+ // Snapshot: packed dump starts with version=1 + familyCount>=3.
177
+ {
178
+ const probe2 = new Uint8Array(16)
179
+ const need = b.metricsSnapshot(handle, probe2)
180
+ if (need <= probe2.length) return false
181
+ const snap = new Uint8Array(need)
182
+ if (b.metricsSnapshot(handle, snap) !== need) return false
183
+ const sv = new DataView(snap.buffer)
184
+ if (sv.getUint32(0, true) !== 1) return false
185
+ if (sv.getUint32(4, true) < 3) return false
186
+ }
187
+
188
+ console.error('CK: snapshot')
189
+ // Batch: two entries in ONE crossing.
190
+ {
191
+ const c2 = b.metricsCounter(handle, 'ct_selftest_batch', 'k')
192
+ const buf = new Uint8Array(64)
193
+ const dv = new DataView(buf.buffer)
194
+ dv.setUint32(0, 1, true)
195
+ dv.setUint32(4, c2, true)
196
+ dv.setUint32(8, 1, true)
197
+ buf[12] = 'v'.charCodeAt(0)
198
+ dv.setFloat64(13, 4, true)
199
+ if (!b.metricsRecordBatch(handle, buf.subarray(0, 21))) return false
200
+ const probe3 = new Uint8Array(16)
201
+ const need3 = b.metricsRender(handle, probe3)
202
+ const out3 = new Uint8Array(need3)
203
+ b.metricsRender(handle, out3)
204
+ if (!decodeUtf8(out3.subarray(0, need3)).includes('ct_selftest_batch{k="v"} 4')) {
205
+ return false
206
+ }
207
+ }
208
+
209
+ console.error('CK: batch')
210
+ // A null handle must throw on declare (never dereference freed state).
211
+ try {
212
+ b.metricsCounter(0, 'ct_selftest_x', '')
213
+ return false
214
+ } catch {
215
+ // expected
216
+ }
217
+ return true
218
+ } catch (err) {
219
+ console.error('METRICS SELFTEST THROW:', err)
220
+ return false
221
+ } finally {
222
+ b.metricsDestroy(handle)
223
+ }
224
+ }