castrum 0.9.3 → 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
@@ -2,7 +2,10 @@
2
2
  //
3
3
  // The UTF-8 codec branches (formerly in `src/shared/codec.ts`) are centralized
4
4
  // here and selected ONCE at module load:
5
- // - encode: Bun `Bun.ArrayBufferSink` vs `TextEncoder`.
5
+ // - encode: shared `TextEncoder` singleton (Bun's native implementation
6
+ // measures ~3x faster than the old `Bun.ArrayBufferSink` start/write/flush
7
+ // path on Bun 1.4 — 34-50ns vs 188-215ns for small strings) vs `TextEncoder`
8
+ // under Node.
6
9
  // - encodeInto: zero-copy `Buffer` view `.write` vs `TextEncoder.encodeInto`.
7
10
  // - decode: `new CString(ptr, 0, len)` from `bun:ffi` vs `TextDecoder`.
8
11
  // - decodeFatal: replacement-mode (Bun) vs fatal `TextDecoder` (Node).
@@ -36,7 +39,6 @@ function getNodeFatalDecoder(): TextDecoder {
36
39
  }
37
40
 
38
41
  // ── Bun codec ─────────────────────────────────────────────────────
39
- let bunSink: Bun.ArrayBufferSink | null = null
40
42
  let bunFfiCodec: typeof import('bun:ffi') | null | undefined
41
43
 
42
44
  function getBunFfiCodec(): typeof import('bun:ffi') | null {
@@ -60,15 +62,13 @@ function createBunCodec(): Utf8Codec {
60
62
  }
61
63
  return getNodeDecoder().decode(bytes)
62
64
  }
65
+ // Shared encoder singleton — Bun's native TextEncoder implementation
66
+ // allocates the exact-size buffer in-engine; measured ~3x faster than the
67
+ // previous ArrayBufferSink start/write/flush path on Bun 1.4 (see header).
68
+ const bunEncoder = new TextEncoder()
63
69
  return {
64
70
  encodeUtf8(s: string): Uint8Array {
65
- if (bunSink === null) bunSink = new Bun.ArrayBufferSink()
66
- const sink = bunSink
67
- // `stream` resets the buffer on flush (reusable singleton); `flush()`
68
- // returns the written data as a `Uint8Array`.
69
- sink.start({ asUint8Array: true, stream: true })
70
- sink.write(s)
71
- return sink.flush() as Uint8Array
71
+ return bunEncoder.encode(s)
72
72
  },
73
73
  encodeUtf8Into(s: string, dest: Uint8Array, offset = 0): number {
74
74
  return bunEncodeUtf8Into(s, dest, offset)
@@ -78,6 +78,7 @@ function createBunCodec(): Utf8Codec {
78
78
  // `castrum_utf8_valid` probe (callers check it first via the transport's
79
79
  // ffi surface). This fallback matches the historical behavior.
80
80
  decodeUtf8Fatal: decodeUtf8Bun,
81
+ decodeUtf8Range: decodeUtf8RangeBun,
81
82
  }
82
83
  }
83
84
 
@@ -120,6 +121,39 @@ function bunEncodeUtf8Into(s: string, dest: Uint8Array, offset = 0): number {
120
121
  return view.write(s, start, 'utf8')
121
122
  }
122
123
 
124
+ // Shared cached full-backing view for the ranged DECODE fast path (same
125
+ // WeakMap pattern as the encode views above — one Buffer per ArrayBuffer,
126
+ // never per call).
127
+ const bunDecodeViewCache = new WeakMap<ArrayBufferLike, Buffer>()
128
+
129
+ function bunDecodeView(bytes: Uint8Array): Buffer {
130
+ const backing = bytes.buffer
131
+ let view = bunDecodeViewCache.get(backing)
132
+ if (view === undefined || view.byteLength !== backing.byteLength) {
133
+ view = Buffer.from(backing)
134
+ bunDecodeViewCache.set(backing, view)
135
+ }
136
+ return view
137
+ }
138
+
139
+ /**
140
+ * Ranged decode: ASCII-only ranges take the latin1 `toString` path (JSC
141
+ * builds the string without the UTF-8 decoder machinery — measured ~2x the
142
+ * CString path on Bun 1.4); any high byte falls back to a real UTF-8 decode
143
+ * of exactly that range. Replacement-mode parity with `decodeUtf8` holds for
144
+ * both branches (latin1 is byte-identical to UTF-8 when no byte exceeds 0x7F).
145
+ */
146
+ function decodeUtf8RangeBun(bytes: Uint8Array, start: number, end: number): string {
147
+ const view = bunDecodeView(bytes)
148
+ const base = bytes.byteOffset
149
+ for (let i = start; i < end; i++) {
150
+ if ((bytes[i] ?? 0) >= 0x80) {
151
+ return view.toString('utf8', base + start, base + end)
152
+ }
153
+ }
154
+ return view.toString('latin1', base + start, base + end)
155
+ }
156
+
123
157
  // ── Node codec ────────────────────────────────────────────────────
124
158
  function createNodeCodec(): Utf8Codec {
125
159
  return {
@@ -135,6 +169,16 @@ function createNodeCodec(): Utf8Codec {
135
169
  decodeUtf8Fatal(bytes: Uint8Array): string {
136
170
  return getNodeFatalDecoder().decode(bytes)
137
171
  },
172
+ decodeUtf8Range(bytes: Uint8Array, start: number, end: number): string {
173
+ // Node: ranged Buffer toString with the same ASCII fast path.
174
+ const view = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength)
175
+ for (let i = start; i < end; i++) {
176
+ if ((bytes[i] ?? 0) >= 0x80) {
177
+ return view.toString('utf8', start, end)
178
+ }
179
+ }
180
+ return view.toString('latin1', start, end)
181
+ },
138
182
  }
139
183
  }
140
184
 
@@ -31,6 +31,13 @@ export interface Utf8Codec {
31
31
  decodeUtf8(bytes: Uint8Array): string
32
32
  /** Decode UTF-8 bytes to a string, throwing on invalid UTF-8. */
33
33
  decodeUtf8Fatal(bytes: Uint8Array): string
34
+ /**
35
+ * Decode the byte RANGE `[start, end)` of `bytes` to a string (replacement
36
+ * mode on invalid). Zero-copy ranged decode for packed-wire unpackers —
37
+ * avoids the per-field `subarray` view allocation. ASCII-only ranges take
38
+ * a latin1 fast path (~2x on Bun); multi-byte ranges fall back to UTF-8.
39
+ */
40
+ decodeUtf8Range(bytes: Uint8Array, start: number, end: number): string
34
41
  }
35
42
 
36
43
  /** UUIDv7 generation (Bun built-in vs crypto.randomUUID on Node). */
@@ -82,8 +82,7 @@ export function buildBatch(ctx: RustClientContext): RustBatch {
82
82
  via(ctx, 'signCookieBatchPacked', unpackByteResults, [secret])(items),
83
83
  verifyCookie: (items, secret) =>
84
84
  via(ctx, 'verifyCookieBatchPacked', unpackBitset, [secret])(items),
85
- csrfVerify: (items, secret) =>
86
- via(ctx, 'csrfVerifyBatchPacked', unpackBitset, [secret])(items),
85
+ csrfVerify: (items, secret) => via(ctx, 'csrfVerifyBatchPacked', unpackBitset, [secret])(items),
87
86
  httpParseRequest: via(ctx, 'httpParseRequestBatchPacked', unpackByteResults),
88
87
  fnv1a64: via(ctx, 'fnv1A64BatchPacked', unpackU64ArrayAsBigInt),
89
88
  etag: (items, weak) => via(ctx, 'etagBatchPacked', unpackByteResults, [weak ?? null])(items),
@@ -96,13 +95,18 @@ export function buildBatch(ctx: RustClientContext): RustBatch {
96
95
  via(ctx, 'base64EncodeBatchPacked', unpackByteResults, [true, false])(items),
97
96
  base64UrlDecode: (items) =>
98
97
  via(ctx, 'base64DecodeBatchPacked', unpackByteResults, [true, false])(items),
99
- jsonPatch: (docs, patches) => via2(ctx, 'jsonPatchBatchPacked', unpackByteResults)(docs, patches),
98
+ jsonPatch: (docs, patches) =>
99
+ via2(ctx, 'jsonPatchBatchPacked', unpackByteResults)(docs, patches),
100
100
  hexEncode: via(ctx, 'hexEncodeBatchPacked', unpackByteResults),
101
101
  hexDecode: via(ctx, 'hexDecodeBatchPacked', unpackByteResults),
102
102
  base64Encode: (items, urlSafe, padding) =>
103
- via(ctx, 'base64EncodeBatchPacked', unpackByteResults, [urlSafe ?? null, padding ?? null])(items),
103
+ via(ctx, 'base64EncodeBatchPacked', unpackByteResults, [urlSafe ?? null, padding ?? null])(
104
+ items,
105
+ ),
104
106
  base64Decode: (items, urlSafe, padding) =>
105
- via(ctx, 'base64DecodeBatchPacked', unpackByteResults, [urlSafe ?? null, padding ?? null])(items),
107
+ via(ctx, 'base64DecodeBatchPacked', unpackByteResults, [urlSafe ?? null, padding ?? null])(
108
+ items,
109
+ ),
106
110
  schemaValidate(validator, items) {
107
111
  return schemaValidateBatch(validator, items)
108
112
  },
@@ -131,7 +135,8 @@ export function buildBatch(ctx: RustClientContext): RustBatch {
131
135
  hmacSha256Verify: (items, sigs, key) =>
132
136
  via2(ctx, 'hmacSha256VerifyBatchPacked', unpackBitset, [key])(items, sigs),
133
137
  // ── Two-list ──
134
- passwordVerify: (items, phcs) => via2(ctx, 'passwordVerifyBatchPacked', unpackBitset)(items, phcs),
138
+ passwordVerify: (items, phcs) =>
139
+ via2(ctx, 'passwordVerifyBatchPacked', unpackBitset)(items, phcs),
135
140
  urlResolve: (bases, references) =>
136
141
  via2(ctx, 'urlResolveBatchPacked', unpackByteResults)(bases, references),
137
142
  templateRender: (source, contexts) =>
@@ -143,9 +148,11 @@ export function buildBatch(ctx: RustClientContext): RustBatch {
143
148
  nowSeconds ?? Math.floor(Date.now() / 1000),
144
149
  ])(items),
145
150
  sseEncode: (items, event, id, retry) =>
146
- via(ctx, 'sseEncodeBatchPacked', unpackByteResults, [event ?? null, id ?? null, retry ?? null])(
147
- items,
148
- ),
151
+ via(ctx, 'sseEncodeBatchPacked', unpackByteResults, [
152
+ event ?? null,
153
+ id ?? null,
154
+ retry ?? null,
155
+ ])(items),
149
156
  wsFrameEncode: (items, opcode, mask, fin) =>
150
157
  via(ctx, 'wsFrameEncodeBatchPacked', unpackByteResults, [opcode, mask, fin])(items),
151
158
  wsFrameDecode: via(ctx, 'wsFrameDecodeBatchPacked', unpackByteResults),
@@ -37,6 +37,7 @@ export type {
37
37
  HmacSignerInstance,
38
38
  MediaTypeParserInstance,
39
39
  MediaTypeResult,
40
+ MetricsRegistryInstance,
40
41
  MultipartPart,
41
42
  PasswordHashOptions,
42
43
  SchemaError,
@@ -46,20 +47,20 @@ export type {
46
47
  WsFrame,
47
48
  } from '../native'
48
49
  export type { SchemaValidator } from '../shared/packed'
49
- export type { RustBatch } from './batch'
50
- export type { RustClient } from './client'
51
- export { createRust, rust } from './client'
52
- export { proven } from './proven'
50
+ export type { ProvenEntry, ProvenImpl, ProvenStatus } from '../shared/proven'
53
51
  export {
54
- PROVEN_SELECTION,
55
52
  isProven,
53
+ PROVEN_SELECTION,
56
54
  provenEntry,
57
55
  provenImpl,
58
56
  provenStatus,
59
57
  provenSummary,
60
58
  provenSurface,
61
59
  } from '../shared/proven'
62
- export type { ProvenEntry, ProvenImpl, ProvenStatus } from '../shared/proven'
60
+ export type { RustBatch } from './batch'
61
+ export type { RustClient } from './client'
62
+ export { createRust, rust } from './client'
63
63
  export type { RustOptions } from './options'
64
64
  export type { RustPacked } from './packed'
65
+ export { proven } from './proven'
65
66
  export type { RustText } from './text'
@@ -50,9 +50,7 @@ export function resolveRayonThreads(explicit?: number): number {
50
50
  return Math.max(1, Math.min(cores, Math.floor(explicit)))
51
51
  }
52
52
 
53
- const envThreads = Number(
54
- resolveEnvVar('CASTRUM_RAYON_THREADS'),
55
- )
53
+ const envThreads = Number(resolveEnvVar('CASTRUM_RAYON_THREADS'))
56
54
 
57
55
  if (Number.isFinite(envThreads) && envThreads > 0) {
58
56
  return Math.max(1, Math.min(cores, Math.floor(envThreads)))
@@ -8,7 +8,7 @@
8
8
  // live benchmark audit and no `@deprecated` JSDoc machinery (the old
9
9
  // `check:proven` / `check:annotate` friction, intentionally not re-added).
10
10
 
11
- import { rust, type RustClient } from './client'
11
+ import { type RustClient, rust } from './client'
12
12
 
13
13
  /** The full `rust.*` surface — identical to `rust`; the proven wiring is baked (see {@link ../shared/proven}). */
14
14
  export type ProvenClient = RustClient
@@ -16,13 +16,13 @@ export type ProvenClient = RustClient
16
16
  /** The full `rust.*` surface, exposed for audit. Same object as `rust`. */
17
17
  export const proven: ProvenClient = rust
18
18
 
19
+ export type { ProvenEntry, ProvenImpl, ProvenStatus } from '../shared/proven'
19
20
  export {
20
- PROVEN_SELECTION,
21
21
  isProven,
22
+ PROVEN_SELECTION,
22
23
  provenEntry,
23
24
  provenImpl,
24
25
  provenStatus,
25
26
  provenSummary,
26
27
  provenSurface,
27
28
  } from '../shared/proven'
28
- export type { ProvenEntry, ProvenImpl, ProvenStatus } from '../shared/proven'
@@ -10,8 +10,8 @@
10
10
 
11
11
  import type { PasswordHashOptions } from '../../native'
12
12
  import { encoder } from '../../shared/bytes'
13
- import { writeInto } from '../into'
14
13
  import { memoizeFfi, type RustClientContext, resolveNative } from '../context'
14
+ import { writeInto } from '../into'
15
15
 
16
16
  /** Auth / crypto scalar methods (`Pick<RustScalar, ...>`). */
17
17
  export function buildCrypto(ctx: RustClientContext) {
@@ -16,6 +16,7 @@ import type {
16
16
  JwtSignerInstance,
17
17
  MediaTypeMatcherInstance,
18
18
  MediaTypeParserInstance,
19
+ MetricsRegistryInstance,
19
20
  PasswordHashOptions,
20
21
  RateLimiterInstance,
21
22
  SchemaValidatorInstance,
@@ -329,6 +330,71 @@ function ffiFormParser(ffi: BunFFI): FormParserInstance {
329
330
  }
330
331
  }
331
332
 
333
+ /**
334
+ * Caller-owned-handle `MetricsRegistry`: on Bun the whole surface runs through
335
+ * the C-ABI (`castrum_metrics_*`) with a native handle — no napi instance is
336
+ * involved at all (the route-stack ownership model). Label values cross as a
337
+ * JOINED `\u001f` string via the cstring-ARG `_str` symbols: the engine
338
+ * transcodes them in-engine, so the per-event JS cost is just
339
+ * `values.join('\u001f')` — ZERO TextEncoder work (Bun 1.4 zero-copy text).
340
+ */
341
+ function ffiMetricsRegistry(ffi: BunFFI): MetricsRegistryInstance {
342
+ const handle = ffi.metricsCreate()
343
+ return {
344
+ counter(name, labelKeys) {
345
+ return ffi.metricsCounter(handle, name, (labelKeys ?? []).join('\u001f'))
346
+ },
347
+ gauge(name, labelKeys) {
348
+ return ffi.metricsGauge(handle, name, (labelKeys ?? []).join('\u001f'))
349
+ },
350
+ histogram(name, labelKeys, buckets) {
351
+ return ffi.metricsHistogram(
352
+ handle,
353
+ name,
354
+ (labelKeys ?? []).join('\u001f'),
355
+ (buckets ?? []).join(','),
356
+ )
357
+ },
358
+ record(series, values, amount) {
359
+ const joined = (values ?? []).join('\u001f')
360
+ if (!ffi.metricsRecordStr(handle, series, joined, amount ?? 1)) {
361
+ throw new Error('metrics record: unknown series / arity mismatch / invalid amount')
362
+ }
363
+ },
364
+ gaugeSet(series, values, value) {
365
+ const joined = (values ?? []).join('\u001f')
366
+ if (!ffi.metricsGaugeSetStr(handle, series, joined, value)) {
367
+ throw new Error('metrics gauge set: unknown series / arity mismatch')
368
+ }
369
+ },
370
+ render() {
371
+ // Probe large enough for typical registries (one native pass), then
372
+ // grow exactly once if ever exceeded (needed-size convention).
373
+ let out = new Uint8Array(8192)
374
+ let w = ffi.metricsRender(handle, out)
375
+ if (w > out.length) {
376
+ out = new Uint8Array(w)
377
+ w = ffi.metricsRender(handle, out)
378
+ }
379
+ return decodeUtf8(out.subarray(0, w))
380
+ },
381
+ destroy() {
382
+ ffi.metricsDestroy(handle)
383
+ },
384
+ snapshot() {
385
+ // Packed v1 dump — decoded by consumers (the @ignex/native metrics
386
+ // wrapper); probe large, grow exactly once.
387
+ let out = new Uint8Array(4096)
388
+ let w = ffi.metricsSnapshot(handle, out)
389
+ if (w > out.length) {
390
+ out = new Uint8Array(w)
391
+ w = ffi.metricsSnapshot(handle, out)
392
+ }
393
+ return out.subarray(0, w)
394
+ },
395
+ }
396
+ }
397
+
332
398
  /** Compiled-once factory + runtime-control methods (`Pick<RustScalar, ...>`). */
333
399
  export function buildFactories(ctx: RustClientContext) {
334
400
  const { transport } = ctx.runtime
@@ -450,6 +516,13 @@ export function buildFactories(ctx: RustClientContext) {
450
516
  }
451
517
  return new addon.RateLimiter(limit, windowMs, maxEntries ?? undefined)
452
518
  },
519
+ createMetricsRegistry(): MetricsRegistryInstance {
520
+ // Fully C-ABI-backed on Bun (native handle owned by the wrapper — no
521
+ // napi instance involved); napi class on Node / fallback.
522
+ const ffi = transport.ffi
523
+ if (ffi) return ffiMetricsRegistry(ffi)
524
+ return new addon.MetricsRegistry()
525
+ },
453
526
  initThreadPool(threads?: number): void {
454
527
  // Explicit user call also establishes the pool state locally.
455
528
  ctx.markPoolInitialized()
@@ -9,8 +9,8 @@
9
9
  // (bun:ffi first, napi fallback) — no inline `isBun()` / `getBunFFI()`.
10
10
 
11
11
  import { toBytes } from '../../shared/bytes'
12
- import { writeInto } from '../into'
13
12
  import { memoizeFfi, type RustClientContext, resolveNative } from '../context'
13
+ import { writeInto } from '../into'
14
14
  import { asBigInt, asNumber } from '../options'
15
15
 
16
16
  /** Hashing scalar methods (`Pick<RustScalar, ...>`). */
@@ -12,10 +12,10 @@
12
12
  import type { EncodingPrefResult, MediaTypeResult } from '../../native'
13
13
  import type { BunFFI } from '../../native/ffi'
14
14
  import { decoder } from '../../shared/bytes'
15
- import { decodeUtf8Fatal } from '../../shared/codec'
15
+ import { decodeUtf8Fatal, decodeUtf8Range } from '../../shared/codec'
16
16
  import { packPairs } from '../../shared/packed'
17
- import { writeInto } from '../into'
18
17
  import { memoizeFfi, type RustClientContext, resolveNative } from '../context'
18
+ import { writeInto } from '../into'
19
19
 
20
20
  // Mirror napi `url_decode`'s UTF-8 validation (rust/http/url_codec.rs): only
21
21
  // validate when a high byte is present (the ASCII fast path skips it — same as
@@ -60,23 +60,25 @@ function u32LE(b: Uint8Array, off: number): number {
60
60
  * napi `HashMap` (which keeps every param including charset/boundary).
61
61
  */
62
62
  function unpackMediaType(packed: Uint8Array): MediaTypeResult {
63
+ // Ranged decode straight off the (pooled) packed buffer — no per-field
64
+ // subarray views. ASCII fields take the latin1 fast path.
63
65
  let off = 0
64
66
  const mtLen = u32LE(packed, off)
65
67
  off += 4
66
- const mediaType = decoder.decode(packed.subarray(off, off + mtLen))
68
+ const mediaType = decodeUtf8Range(packed, off, off + mtLen)
67
69
  off += mtLen
68
70
  let charset: string | null = null
69
71
  const csLen = u32LE(packed, off)
70
72
  off += 4
71
73
  if (csLen !== 0xffffffff) {
72
- charset = decoder.decode(packed.subarray(off, off + csLen))
74
+ charset = decodeUtf8Range(packed, off, off + csLen)
73
75
  off += csLen
74
76
  }
75
77
  let boundary: string | null = null
76
78
  const bLen = u32LE(packed, off)
77
79
  off += 4
78
80
  if (bLen !== 0xffffffff) {
79
- boundary = decoder.decode(packed.subarray(off, off + bLen))
81
+ boundary = decodeUtf8Range(packed, off, off + bLen)
80
82
  off += bLen
81
83
  }
82
84
  const count = u32LE(packed, off)
@@ -85,11 +87,11 @@ function unpackMediaType(packed: Uint8Array): MediaTypeResult {
85
87
  for (let i = 0; i < count; i++) {
86
88
  const kLen = u32LE(packed, off)
87
89
  off += 4
88
- const key = decoder.decode(packed.subarray(off, off + kLen))
90
+ const key = decodeUtf8Range(packed, off, off + kLen)
89
91
  off += kLen
90
92
  const vLen = u32LE(packed, off)
91
93
  off += 4
92
- const val = decoder.decode(packed.subarray(off, off + vLen))
94
+ const val = decodeUtf8Range(packed, off, off + vLen)
93
95
  off += vLen
94
96
  params[key] = val
95
97
  }
@@ -97,18 +99,22 @@ function unpackMediaType(packed: Uint8Array): MediaTypeResult {
97
99
  }
98
100
 
99
101
  /** Unpack the `castrum_parse_accept_encoding` verdict (f32 q-values) into the
100
- * napi-shaped array. Layout: `[u32 count]{[u32 encLen][enc][f32 q][u32 order]}`. */
102
+ * napi-shaped array. Layout: `[u32 count]{[u32 encLen][enc][f32 q][u32 order]}`.
103
+ * Ranged decode off the pooled buffer + shared f32 bit-reinterpret views —
104
+ * no per-field subarrays, no per-call DataView. */
105
+ const f32Bits = new Float32Array(1)
106
+ const f32BitsU32 = new Uint32Array(f32Bits.buffer)
101
107
  function unpackAcceptEncoding(packed: Uint8Array): EncodingPrefResult[] {
102
108
  const count = u32LE(packed, 0)
103
109
  let off = 4
104
110
  const out: EncodingPrefResult[] = []
105
- const view = new DataView(packed.buffer, packed.byteOffset, packed.byteLength)
106
111
  for (let i = 0; i < count; i++) {
107
112
  const encLen = u32LE(packed, off)
108
113
  off += 4
109
- const encoding = decoder.decode(packed.subarray(off, off + encLen))
114
+ const encoding = decodeUtf8Range(packed, off, off + encLen)
110
115
  off += encLen
111
- const q = view.getFloat32(off, true)
116
+ f32BitsU32[0] = u32LE(packed, off)
117
+ const q: number = f32Bits[0] ?? 0
112
118
  off += 4
113
119
  const order = u32LE(packed, off)
114
120
  off += 4
@@ -15,6 +15,7 @@ import { buildHashing } from './hashing'
15
15
  import { buildHttp } from './http'
16
16
  import { buildJson } from './json'
17
17
  import { buildPayload } from './payload'
18
+ import { buildValidation } from './validation'
18
19
 
19
20
  export type { RustScalar } from './interface'
20
21
 
@@ -26,6 +27,7 @@ export function buildScalar(ctx: RustClientContext): import('./interface').RustS
26
27
  ...buildHttp(ctx),
27
28
  ...buildCrypto(ctx),
28
29
  ...buildPayload(ctx),
30
+ ...buildValidation(ctx),
29
31
  ...buildFactories(ctx),
30
32
  }
31
33
  }
@@ -19,6 +19,7 @@ import type {
19
19
  MediaTypeMatcherInstance,
20
20
  MediaTypeParserInstance,
21
21
  MediaTypeResult,
22
+ MetricsRegistryInstance,
22
23
  MultipartPart,
23
24
  PasswordHashOptions,
24
25
  RateLimiterInstance,
@@ -67,6 +68,18 @@ export interface RustScalar {
67
68
  validateUuid(input: Uint8Array): boolean
68
69
  validateIpv4(input: Uint8Array): boolean
69
70
  validateIpv6(input: Uint8Array): boolean
71
+ /**
72
+ * Batch fixed-width hex validation: NEWLINE-separated lines in → one
73
+ * verdict byte (1/0) per line. One native call replaces per-item
74
+ * `/^[0-9a-f]{width}$/` regex loops (ObjectIds = width 24). Accepts bytes,
75
+ * a joined string, or an array of id strings (joined internally).
76
+ */
77
+ hexValidateBatch(input: Uint8Array | readonly string[], width: number): Uint8Array
78
+ /**
79
+ * Escape JS-RegExp metacharacters so `new RegExp(escaped, flags)` matches
80
+ * the input literally — the safe literal-substring-search primitive.
81
+ */
82
+ regexEscape(input: Uint8Array | string): string
70
83
  wsAcceptKey(key: Uint8Array): Uint8Array | string
71
84
  wsAcceptKeyInto(key: Uint8Array, output: Uint8Array): number
72
85
 
@@ -212,6 +225,13 @@ export interface RustScalar {
212
225
  windowMs: number,
213
226
  maxEntries?: number | null,
214
227
  ): RateLimiterInstance
228
+ /**
229
+ * Create a sharded metrics registry (counters / gauges / histograms +
230
+ * Prometheus render). Fully C-ABI-backed on Bun (caller-owned native
231
+ * handle); the napi class on Node. Call `destroy()` on ffi-backed
232
+ * registries at shutdown.
233
+ */
234
+ createMetricsRegistry(): MetricsRegistryInstance
215
235
  initThreadPool(rayonThreads?: number): void
216
236
  rayonNumThreads(): number
217
237
 
@@ -10,8 +10,8 @@
10
10
 
11
11
  import type { MultipartPart, WsFrame } from '../../native'
12
12
  import { decoder } from '../../shared/bytes'
13
- import { writeInto } from '../into'
14
13
  import { memoizeFfi, type RustClientContext, resolveNative } from '../context'
14
+ import { writeInto } from '../into'
15
15
 
16
16
  /** Read an unsigned little-endian u32 at `off`. */
17
17
  function u32LE(b: Uint8Array, off: number): number {
@@ -102,7 +102,11 @@ export function buildPayload(ctx: RustClientContext) {
102
102
  // it via the `_into` core; keeps the 64 MiB decompression-bomb cap).
103
103
  const f = ffi()
104
104
  if (f) return f.gzipDecompressInto(data, output, maxDecompressed ?? undefined)
105
- return writeInto('gzip decompress', output, addon.gzipDecompress(data, maxDecompressed ?? null))
105
+ return writeInto(
106
+ 'gzip decompress',
107
+ output,
108
+ addon.gzipDecompress(data, maxDecompressed ?? null),
109
+ )
106
110
  },
107
111
  brotliCompress(data: Uint8Array, quality?: number | null): Uint8Array {
108
112
  const f = ffi()
@@ -127,7 +131,11 @@ export function buildPayload(ctx: RustClientContext) {
127
131
  // Pooled sibling — caller-owned output buffer (keeps the 64 MiB cap).
128
132
  const f = ffi()
129
133
  if (f) return f.brotliDecompressInto(data, output, maxDecompressed ?? undefined)
130
- return writeInto('brotli decompress', output, addon.brotliDecompress(data, maxDecompressed ?? null))
134
+ return writeInto(
135
+ 'brotli decompress',
136
+ output,
137
+ addon.brotliDecompress(data, maxDecompressed ?? null),
138
+ )
131
139
  },
132
140
  multipartParse(body: Uint8Array, boundary: Uint8Array): MultipartPart[] {
133
141
  // FFI-first: packed parts (castrum_multipart_parse_packed) → unpack to
@@ -0,0 +1,121 @@
1
+ // src/rust-ffi/scalar/validation.ts — Validation & text-utility scalar methods.
2
+ //
3
+ // Mirrors rust/util/validation.rs (batch fixed-width hex validation) and
4
+ // rust/util/text.rs (JS-RegExp metacharacter escaping). Stateless; on Bun the
5
+ // string paths cross as cstring ARGs (engine-transcoded — zero TextEncoder)
6
+ // and regexEscape returns via a cstring return (engine-cloned — zero decode).
7
+
8
+ import { encoder, toBytes } from '../../shared/bytes'
9
+ import { decodeUtf8 } from '../../shared/codec'
10
+ import type { RustClientContext } from '../context'
11
+ import { memoizeFfi, resolveNative } from '../context'
12
+
13
+ /**
14
+ * Non-zeroing output allocation for the batch verdicts (`Buffer.allocUnsafe`
15
+ * skips the 4 KB memset a plain `new Uint8Array` pays; every byte is
16
+ * overwritten by the native write anyway).
17
+ */
18
+ const allocOut = (n: number): Uint8Array =>
19
+ typeof Buffer !== 'undefined' && typeof Buffer.allocUnsafe === 'function'
20
+ ? Buffer.allocUnsafe(n)
21
+ : new Uint8Array(n)
22
+
23
+ /** Validation / text-utility methods (`Pick<RustScalar, ...>`). */
24
+ export function buildValidation(ctx: RustClientContext) {
25
+ const { transport } = ctx.runtime
26
+ const n = (name: string) => resolveNative(ctx, name)
27
+ // Lazy-memoized ffi surface: binds on first call (no eager dlopen at import).
28
+ const ffi = memoizeFfi(transport)
29
+
30
+ return {
31
+ /**
32
+ * Validate NEWLINE-separated strings as fixed-width hex (e.g. Mongo
33
+ * ObjectIds at width 24) in ONE native call → one verdict byte per line.
34
+ * Replaces the per-item `/^[0-9a-f]{24}$/` regex loops list endpoints
35
+ * scatter everywhere. Accepts an array of id strings directly — the join
36
+ * crosses as a `cstring` ARG, so the JS side never touches an encoder.
37
+ *
38
+ * @param input Newline-separated candidates as bytes, or an array of
39
+ * candidate strings (joined internally with `\n`)
40
+ * @param width Exact expected length of each line (1..=4096)
41
+ * @returns Uint8Array of 1/0 verdicts, one per line (trailing newline
42
+ * does not add an extra verdict)
43
+ * @throws On `width` outside 1..=4096
44
+ * @example
45
+ * ```ts
46
+ * rust.hexValidateBatch(['507f1f77bcf86cd799439011', 'nope'], 24) // [1, 0]
47
+ * ```
48
+ */
49
+ hexValidateBatch(input: Uint8Array | readonly string[], width: number): Uint8Array {
50
+ if (width === 0 || width > 4096 || !Number.isInteger(width)) {
51
+ throw new Error('hex validate batch: width must be an integer 1..=4096')
52
+ }
53
+ const f = ffi()
54
+ if (f) {
55
+ // Verdict count ≤ line count ≤ input length — one native pass.
56
+ const joined =
57
+ typeof input === 'string'
58
+ ? input
59
+ : Array.isArray(input)
60
+ ? (input as readonly string[]).join('\n')
61
+ : null
62
+ let out = allocOut(Math.min(joined !== null ? joined.length + 1 : input.length + 1, 4096))
63
+ let w =
64
+ joined !== null
65
+ ? f.hexValidateBatchStr(joined, width, out)
66
+ : f.hexValidateBatchInto(input as Uint8Array, width, out)
67
+ if (w > out.length) {
68
+ out = new Uint8Array(w)
69
+ w =
70
+ joined !== null
71
+ ? f.hexValidateBatchStr(joined, width, out)
72
+ : f.hexValidateBatchInto(input as Uint8Array, width, out)
73
+ }
74
+ return out.subarray(0, w)
75
+ }
76
+ const bytes = Array.isArray(input)
77
+ ? encoder.encode((input as readonly string[]).join('\n'))
78
+ : (input as Uint8Array)
79
+ return n('hexValidateBatch')(bytes, width) as Uint8Array
80
+ },
81
+
82
+ /**
83
+ * Escape JS-RegExp metacharacters (`\ . * + ? ^ $ { } | ( ) [ ]`) so a
84
+ * pattern built with `new RegExp(rust.regexEscape(userInput), 'i')`
85
+ * matches the input LITERALLY. Under Bun the whole call is the FFI
86
+ * crossing + the escape: the input crosses as a `cstring` ARG (engine-
87
+ * transcoded, zero encode) and the result returns as a `cstring`
88
+ * (engine-cloned, zero decode).
89
+ *
90
+ * @param input Arbitrary text or bytes (multi-byte UTF-8 passes through)
91
+ * @returns The escaped pattern string
92
+ * @example
93
+ * ```ts
94
+ * const re = new RegExp(rust.regexEscape('a.c*(x)'), 'i')
95
+ * re.test('A.C(X)') // true — literal match, not wildcard semantics
96
+ * ```
97
+ */
98
+ regexEscape(input: Uint8Array | string): string {
99
+ const f = ffi()
100
+ if (f) {
101
+ if (typeof input === 'string') {
102
+ const escaped = f.regexEscapeStr(input)
103
+ if (escaped === null) throw new Error('regex escape failed')
104
+ return escaped
105
+ }
106
+ // Bytes path: needed-size convention with a generous first guess.
107
+ const bytes = toBytes(input)
108
+ let out = new Uint8Array(bytes.length + 16)
109
+ let w = f.regexEscapeInto(bytes, out)
110
+ if (w > out.length) {
111
+ out = new Uint8Array(w)
112
+ w = f.regexEscapeInto(bytes, out)
113
+ }
114
+ return decodeUtf8(out.subarray(0, w))
115
+ }
116
+ // napi takes/returns strings directly.
117
+ const s = typeof input === 'string' ? input : decodeUtf8(toBytes(input))
118
+ return n('regexEscape')(s) as string
119
+ },
120
+ }
121
+ }