castrum 0.9.3 → 0.9.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (206) hide show
  1. package/Cargo.lock +2242 -0
  2. package/Cargo.toml +155 -0
  3. package/README.md +14 -6
  4. package/build.rs +5 -0
  5. package/castrum.darwin-arm64.node +0 -0
  6. package/castrum.darwin-x64.node +0 -0
  7. package/castrum.linux-arm64-gnu.node +0 -0
  8. package/castrum.linux-arm64-musl.node +0 -0
  9. package/castrum.linux-x64-gnu.node +0 -0
  10. package/castrum.linux-x64-musl.node +0 -0
  11. package/castrum.linux-x64-v3-gnu.node +0 -0
  12. package/castrum.win32-x64-msvc.node +0 -0
  13. package/dist/index.d.ts +1508 -1291
  14. package/dist/index.js +3391 -2069
  15. package/docs/API.md +2 -1
  16. package/docs/ARCHITECTURE.md +3 -2
  17. package/docs/CASE_STUDY.md +5 -3
  18. package/docs/ENVIRONMENT.md +25 -2
  19. package/docs/FFI_BUN_GUIDE.md +242 -14
  20. package/docs/HOW_TO_ADD_AN_OP.md +10 -0
  21. package/docs/REPO_MAP.md +17 -6
  22. package/docs/RND-CONCURRENCY.md +772 -0
  23. package/docs/bun-builtins-decision-matrix.md +4 -1
  24. package/index.ts +24 -25
  25. package/package.json +173 -156
  26. package/rust/crypto/aead.rs +378 -0
  27. package/rust/crypto/argon2.rs +265 -0
  28. package/rust/crypto/base64.rs +420 -0
  29. package/rust/crypto/bcrypt.rs +89 -0
  30. package/rust/crypto/cookie_sign.rs +169 -0
  31. package/rust/crypto/csrf.rs +106 -0
  32. package/rust/crypto/ed25519.rs +215 -0
  33. package/rust/crypto/hashing.rs +140 -0
  34. package/rust/crypto/hmac_sha256.rs +88 -0
  35. package/rust/crypto/jwt/api.rs +286 -0
  36. package/rust/crypto/jwt/mod.rs +22 -0
  37. package/rust/crypto/jwt/tests.rs +312 -0
  38. package/rust/crypto/jwt/token.rs +360 -0
  39. package/rust/crypto/mod.rs +27 -0
  40. package/rust/crypto/pbkdf2.rs +105 -0
  41. package/rust/crypto/random_token.rs +85 -0
  42. package/rust/crypto/session.rs +222 -0
  43. package/rust/ffi/crypto.rs +708 -0
  44. package/rust/ffi/hashing.rs +285 -0
  45. package/rust/ffi/http.rs +752 -0
  46. package/rust/ffi/ingress.rs +303 -0
  47. package/rust/ffi/json.rs +517 -0
  48. package/rust/ffi/jwt.rs +419 -0
  49. package/rust/ffi/metrics.rs +373 -0
  50. package/rust/ffi/mod.rs +69 -0
  51. package/rust/ffi/payload.rs +275 -0
  52. package/rust/ffi/probe.rs +60 -0
  53. package/rust/ffi/rate_limit.rs +77 -0
  54. package/rust/ffi/route.rs +85 -0
  55. package/rust/ffi/task.rs +172 -0
  56. package/rust/ffi/tests.rs +2333 -0
  57. package/rust/ffi/util.rs +152 -0
  58. package/rust/ffi/validators.rs +123 -0
  59. package/rust/http/accept.rs +606 -0
  60. package/rust/http/cookie_parser.rs +140 -0
  61. package/rust/http/etag.rs +289 -0
  62. package/rust/http/form.rs +136 -0
  63. package/rust/http/headers.rs +267 -0
  64. package/rust/http/http_date.rs +286 -0
  65. package/rust/http/http_parser.rs +252 -0
  66. package/rust/http/media_type.rs +355 -0
  67. package/rust/http/method.rs +200 -0
  68. package/rust/http/mime_lookup.rs +130 -0
  69. package/rust/http/mod.rs +32 -0
  70. package/rust/http/multipart.rs +685 -0
  71. package/rust/http/query_parser.rs +211 -0
  72. package/rust/http/url_codec.rs +296 -0
  73. package/rust/http/url_join.rs +324 -0
  74. package/rust/ingress/api.rs +355 -0
  75. package/rust/ingress/cors.rs +403 -0
  76. package/rust/ingress/ingress_constants.rs +209 -0
  77. package/rust/ingress/ip_trust.rs +312 -0
  78. package/rust/ingress/mod.rs +48 -0
  79. package/rust/ingress/native_route.rs +770 -0
  80. package/rust/ingress/options.rs +176 -0
  81. package/rust/ingress/output.rs +406 -0
  82. package/rust/ingress/packed.rs +191 -0
  83. package/rust/ingress/pipeline.rs +534 -0
  84. package/rust/ingress/proxy.rs +138 -0
  85. package/rust/ingress/rate_limit.rs +538 -0
  86. package/rust/ingress/terminal.rs +114 -0
  87. package/rust/ingress/tests.rs +567 -0
  88. package/rust/ingress/time.rs +70 -0
  89. package/rust/json/fast_schema/capture.rs +336 -0
  90. package/rust/json/fast_schema/compile.rs +556 -0
  91. package/rust/json/fast_schema/cursor.rs +368 -0
  92. package/rust/json/fast_schema/email.rs +282 -0
  93. package/rust/json/fast_schema/errors.rs +247 -0
  94. package/rust/json/fast_schema/mod.rs +57 -0
  95. package/rust/json/fast_schema/tests.rs +1143 -0
  96. package/rust/json/fast_schema/types.rs +140 -0
  97. package/rust/json/fast_schema/validate.rs +931 -0
  98. package/rust/json/json_ops.rs +121 -0
  99. package/rust/json/json_schema.rs +837 -0
  100. package/rust/json/json_ser.rs +883 -0
  101. package/rust/json/mod.rs +19 -0
  102. package/rust/json/napi_marshal.rs +318 -0
  103. package/rust/json/patch/api.rs +60 -0
  104. package/rust/json/patch/engine.rs +153 -0
  105. package/rust/json/patch/mod.rs +47 -0
  106. package/rust/json/patch/ops.rs +270 -0
  107. package/rust/json/patch/pointer.rs +49 -0
  108. package/rust/json/patch/tests.rs +499 -0
  109. package/rust/lib.rs +73 -0
  110. package/rust/metrics/api.rs +148 -0
  111. package/rust/metrics/mod.rs +21 -0
  112. package/rust/metrics/registry.rs +961 -0
  113. package/rust/panic_safety.rs +40 -0
  114. package/rust/payload/compress.rs +512 -0
  115. package/rust/payload/mod.rs +13 -0
  116. package/rust/payload/sse.rs +214 -0
  117. package/rust/payload/template.rs +253 -0
  118. package/rust/payload/websocket.rs +69 -0
  119. package/rust/payload/ws_frames.rs +354 -0
  120. package/rust/proptest_suite.rs +139 -0
  121. package/rust/selection.rs +95 -0
  122. package/rust/src/bin/native-bench.rs +495 -0
  123. package/rust/task/completion.rs +119 -0
  124. package/rust/task/mod.rs +65 -0
  125. package/rust/task/ops.rs +503 -0
  126. package/rust/task/runtime.rs +220 -0
  127. package/rust/task/tests.rs +231 -0
  128. package/rust/test_support.rs +71 -0
  129. package/rust/util/batch/api.rs +266 -0
  130. package/rust/util/batch/core.rs +473 -0
  131. package/rust/util/batch/mod.rs +23 -0
  132. package/rust/util/batch/tests.rs +445 -0
  133. package/rust/util/batch_core.rs +184 -0
  134. package/rust/util/bytes.rs +594 -0
  135. package/rust/util/mod.rs +34 -0
  136. package/rust/util/packed.rs +473 -0
  137. package/rust/util/text.rs +130 -0
  138. package/rust/util/threadpool.rs +128 -0
  139. package/rust/util/validation.rs +419 -0
  140. package/rust-toolchain.toml +17 -0
  141. package/scripts/postinstall.mjs +199 -0
  142. package/src/ingress/constants.ts +1 -1
  143. package/src/ingress/context.ts +2 -2
  144. package/src/ingress/decode/baked-result.ts +13 -13
  145. package/src/ingress/decode/fast-result.ts +17 -17
  146. package/src/ingress/errors.ts +7 -7
  147. package/src/ingress/headers/baked-templates.ts +1 -1
  148. package/src/ingress/headers/fast-templates.ts +3 -3
  149. package/src/ingress/health.ts +1 -2
  150. package/src/ingress/index.ts +1 -1
  151. package/src/ingress/native-route.ts +9 -5
  152. package/src/ingress/options.ts +1 -1
  153. package/src/ingress/packing/gather-raw-headers.ts +1 -1
  154. package/src/ingress/packing/route-wire.ts +1 -5
  155. package/src/ingress/response/error-bodies.ts +5 -5
  156. package/src/ingress/response/terminal.ts +1 -1
  157. package/src/ingress/routes/echo.ts +4 -4
  158. package/src/ingress/routes/fallback.ts +2 -2
  159. package/src/ingress/routes/head.ts +2 -2
  160. package/src/ingress/routes/native.ts +2 -1
  161. package/src/ingress/routes/options.ts +1 -1
  162. package/src/ingress/routes/read.ts +2 -2
  163. package/src/ingress/server.ts +10 -6
  164. package/src/ingress/status.ts +7 -7
  165. package/src/integration/batch.ts +1 -1
  166. package/src/integration/index.ts +9 -9
  167. package/src/integration/pipeline.ts +6 -6
  168. package/src/integration/websocket.ts +2 -2
  169. package/src/loader/index.ts +1 -5
  170. package/src/loader/ops.ts +1 -1
  171. package/src/native/ffi/build/codecs.ts +130 -12
  172. package/src/native/ffi/build/compress.ts +22 -10
  173. package/src/native/ffi/build/instances.ts +46 -4
  174. package/src/native/ffi/build/metrics.ts +247 -0
  175. package/src/native/ffi/build/parse.ts +59 -23
  176. package/src/native/ffi/build/task.ts +79 -0
  177. package/src/native/ffi/build/util.ts +32 -1
  178. package/src/native/ffi/build.ts +4 -0
  179. package/src/native/ffi/selftest.ts +12 -3
  180. package/src/native/ffi/types.ts +151 -9
  181. package/src/native/ffi.ts +160 -15
  182. package/src/native/index.ts +1 -1
  183. package/src/native/loader.ts +55 -5
  184. package/src/native/types/instances.ts +34 -0
  185. package/src/native/types.ts +11 -0
  186. package/src/runtime/codec.ts +53 -9
  187. package/src/runtime/types.ts +7 -0
  188. package/src/rust-ffi/batch/build.ts +16 -9
  189. package/src/rust-ffi/index.ts +7 -6
  190. package/src/rust-ffi/options.ts +1 -3
  191. package/src/rust-ffi/proven.ts +3 -3
  192. package/src/rust-ffi/scalar/crypto.ts +1 -1
  193. package/src/rust-ffi/scalar/factories.ts +73 -0
  194. package/src/rust-ffi/scalar/hashing.ts +1 -1
  195. package/src/rust-ffi/scalar/http.ts +71 -27
  196. package/src/rust-ffi/scalar/index.ts +2 -0
  197. package/src/rust-ffi/scalar/interface.ts +20 -0
  198. package/src/rust-ffi/scalar/payload.ts +11 -3
  199. package/src/rust-ffi/scalar/validation.ts +121 -0
  200. package/src/rust-ffi/text.ts +25 -3
  201. package/src/selection.ts +1 -1
  202. package/src/shared/bytes.ts +24 -4
  203. package/src/shared/codec.ts +18 -3
  204. package/src/task/index.ts +24 -0
  205. package/src/task/op.ts +171 -0
  206. package/src/task/runtime.ts +568 -0
@@ -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, ...>`). */
@@ -11,11 +11,18 @@
11
11
 
12
12
  import type { EncodingPrefResult, MediaTypeResult } from '../../native'
13
13
  import type { BunFFI } from '../../native/ffi'
14
- import { decoder } from '../../shared/bytes'
15
- import { decodeUtf8Fatal } from '../../shared/codec'
14
+ import { decoder, hasNul } from '../../shared/bytes'
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
+
20
+ /**
21
+ * RFC 6455 `Sec-WebSocket-Accept` is always 28 chars. Reused across calls
22
+ * because the value is decoded to a string synchronously before returning —
23
+ * the same discipline as the other pooled scratches in this layer.
24
+ */
25
+ const WS_ACCEPT_SCRATCH = new Uint8Array(64)
19
26
 
20
27
  // Mirror napi `url_decode`'s UTF-8 validation (rust/http/url_codec.rs): only
21
28
  // validate when a high byte is present (the ASCII fast path skips it — same as
@@ -60,23 +67,25 @@ function u32LE(b: Uint8Array, off: number): number {
60
67
  * napi `HashMap` (which keeps every param including charset/boundary).
61
68
  */
62
69
  function unpackMediaType(packed: Uint8Array): MediaTypeResult {
70
+ // Ranged decode straight off the (pooled) packed buffer — no per-field
71
+ // subarray views. ASCII fields take the latin1 fast path.
63
72
  let off = 0
64
73
  const mtLen = u32LE(packed, off)
65
74
  off += 4
66
- const mediaType = decoder.decode(packed.subarray(off, off + mtLen))
75
+ const mediaType = decodeUtf8Range(packed, off, off + mtLen)
67
76
  off += mtLen
68
77
  let charset: string | null = null
69
78
  const csLen = u32LE(packed, off)
70
79
  off += 4
71
80
  if (csLen !== 0xffffffff) {
72
- charset = decoder.decode(packed.subarray(off, off + csLen))
81
+ charset = decodeUtf8Range(packed, off, off + csLen)
73
82
  off += csLen
74
83
  }
75
84
  let boundary: string | null = null
76
85
  const bLen = u32LE(packed, off)
77
86
  off += 4
78
87
  if (bLen !== 0xffffffff) {
79
- boundary = decoder.decode(packed.subarray(off, off + bLen))
88
+ boundary = decodeUtf8Range(packed, off, off + bLen)
80
89
  off += bLen
81
90
  }
82
91
  const count = u32LE(packed, off)
@@ -85,11 +94,11 @@ function unpackMediaType(packed: Uint8Array): MediaTypeResult {
85
94
  for (let i = 0; i < count; i++) {
86
95
  const kLen = u32LE(packed, off)
87
96
  off += 4
88
- const key = decoder.decode(packed.subarray(off, off + kLen))
97
+ const key = decodeUtf8Range(packed, off, off + kLen)
89
98
  off += kLen
90
99
  const vLen = u32LE(packed, off)
91
100
  off += 4
92
- const val = decoder.decode(packed.subarray(off, off + vLen))
101
+ const val = decodeUtf8Range(packed, off, off + vLen)
93
102
  off += vLen
94
103
  params[key] = val
95
104
  }
@@ -97,18 +106,22 @@ function unpackMediaType(packed: Uint8Array): MediaTypeResult {
97
106
  }
98
107
 
99
108
  /** 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]}`. */
109
+ * napi-shaped array. Layout: `[u32 count]{[u32 encLen][enc][f32 q][u32 order]}`.
110
+ * Ranged decode off the pooled buffer + shared f32 bit-reinterpret views —
111
+ * no per-field subarrays, no per-call DataView. */
112
+ const f32Bits = new Float32Array(1)
113
+ const f32BitsU32 = new Uint32Array(f32Bits.buffer)
101
114
  function unpackAcceptEncoding(packed: Uint8Array): EncodingPrefResult[] {
102
115
  const count = u32LE(packed, 0)
103
116
  let off = 4
104
117
  const out: EncodingPrefResult[] = []
105
- const view = new DataView(packed.buffer, packed.byteOffset, packed.byteLength)
106
118
  for (let i = 0; i < count; i++) {
107
119
  const encLen = u32LE(packed, off)
108
120
  off += 4
109
- const encoding = decoder.decode(packed.subarray(off, off + encLen))
121
+ const encoding = decodeUtf8Range(packed, off, off + encLen)
110
122
  off += encLen
111
- const q = view.getFloat32(off, true)
123
+ f32BitsU32[0] = u32LE(packed, off)
124
+ const q: number = f32Bits[0] ?? 0
112
125
  off += 4
113
126
  const order = u32LE(packed, off)
114
127
  off += 4
@@ -138,11 +151,22 @@ export function buildHttp(ctx: RustClientContext) {
138
151
 
139
152
  return {
140
153
  mimeFromExtension(ext: Uint8Array): Uint8Array | string {
141
- // FFI-first: the `cstring` ARG is string-only, so bytes are decoded once
142
- // (cheap CString clone) before the call. napi keeps the memoized-bytes
143
- // path (cachedMime).
154
+ // FFI-first. This op has no `*_bytes` sibling, so the bytes must be decoded
155
+ // for the `cstring` ARG — the decode is the price of the cstring form, not
156
+ // a speed trick (route new byte-shaped ops to a byte sibling instead; see
157
+ // docs/FFI_BUN_GUIDE.md §6.1). napi keeps the memoized-bytes path
158
+ // (cachedMime).
159
+ //
160
+ // A NUL would truncate the DECODED string native-side, so `.js\0evil`
161
+ // would resolve as `.js` — a Content-Type from a prefix. An extension
162
+ // containing a NUL is not an extension, so it takes the unknown sentinel
163
+ // (the guard is `indexOf` on an already-decoded string: ~4 ns).
144
164
  const f = ffi()
145
- if (f) return f.mimeFromExtension(decoder.decode(ext)) ?? 'application/octet-stream'
165
+ if (f) {
166
+ const name = decoder.decode(ext)
167
+ if (hasNul(name)) return 'application/octet-stream'
168
+ return f.mimeFromExtension(name) ?? 'application/octet-stream'
169
+ }
146
170
  return ctx.cachedMime(ext)
147
171
  },
148
172
  urlEncode(input: Uint8Array): Uint8Array | string {
@@ -182,34 +206,54 @@ export function buildHttp(ctx: RustClientContext) {
182
206
  return n('urlDecodeInto')(input, output) as number
183
207
  },
184
208
  validateEmail(input: Uint8Array): boolean {
185
- // FFI-first: the `cstring` ARG is string-only, so bytes are decoded once
186
- // (cheap CString clone) before the call — still faster than the old
187
- // `(ptr,len)` view path (see bench:margin cstring scenario). napi
188
- // fallback keeps the byte path.
209
+ // FFI-first. The bytes cross as a `buffer`/`buffer_length` pair (an atomic
210
+ // ptr+len snapshot, zero transcode) rather than being decoded to a JS
211
+ // string for the `cstring` ARG. Measured on Bun 1.4.2 over 400k calls:
212
+ // 101 ns vs 234 ns (email), 65 vs 164 (uuid), 31 vs 138 (ipv4) — and the
213
+ // byte form cannot truncate at an embedded NUL the way a cstring ARG
214
+ // does (a NUL byte used to make "a@b.com\0<script>" validate as TRUE).
189
215
  const f = ffi()
190
- if (f) return f.validateEmail(decoder.decode(input))
216
+ if (f) return f.validateEmailBytes(input)
191
217
  return n('validateEmail')(input) as boolean
192
218
  },
193
219
  validateUuid(input: Uint8Array): boolean {
194
220
  const f = ffi()
195
- if (f) return f.validateUuid(decoder.decode(input))
221
+ if (f) return f.validateUuidBytes(input)
196
222
  return n('validateUuid')(input) as boolean
197
223
  },
198
224
  validateIpv4(input: Uint8Array): boolean {
199
225
  const f = ffi()
200
- if (f) return f.validateIpv4(decoder.decode(input))
226
+ if (f) return f.validateIpv4Bytes(input)
201
227
  return n('validateIpv4')(input) as boolean
202
228
  },
203
229
  validateIpv6(input: Uint8Array): boolean {
204
230
  const f = ffi()
205
- if (f) return f.validateIpv6(decoder.decode(input))
231
+ if (f) return f.validateIpv6Bytes(input)
206
232
  return n('validateIpv6')(input) as boolean
207
233
  },
208
234
  wsAcceptKey(key: Uint8Array): Uint8Array | string {
209
- // FFI-first: the `cstring` ARG is string-only, so bytes are decoded once
210
- // (cheap CString clone) before the call. napi fallback keeps the bytes.
235
+ // FFI-first: the `cstring` ARG + cstring return is the fast path (no JS
236
+ // encode, engine-cloned result) — end-to-end it measures ~367 ns, while
237
+ // routing every call through the byte sibling costs ~381 ns (the extra
238
+ // `subarray` + decode outweighs the transcode saved; measured over 300k
239
+ // calls on Bun 1.4.2 through THIS function, not the raw binding).
240
+ //
241
+ // Correctness rider: a NUL would truncate the `cstring` ARG, so the accept
242
+ // value would be computed for a PREFIX of the key — verified:
243
+ // `'dGhlIHNhbXBsZSBub25jZQ==\0junk'` returned exactly the value for the
244
+ // key WITHOUT the suffix. That case takes the byte sibling (exact length).
211
245
  const f = ffi()
212
- if (f) return f.wsAcceptKey(decoder.decode(key))
246
+ if (f) {
247
+ const text = decoder.decode(key)
248
+ if (!hasNul(text)) return f.wsAcceptKey(text)
249
+ const w = f.wsAcceptKeyInto(key, WS_ACCEPT_SCRATCH)
250
+ if (w > 0 && w <= WS_ACCEPT_SCRATCH.length) {
251
+ return decoder.decode(WS_ACCEPT_SCRATCH.subarray(0, w))
252
+ }
253
+ // Unreachable for a live view (the native side writes 28 for any key);
254
+ // the napi path is byte-exact, so it can never resurrect truncation.
255
+ return n('wsAcceptKey')(key) as Uint8Array | string
256
+ }
213
257
  return n('wsAcceptKey')(key) as Uint8Array | string
214
258
  },
215
259
  wsAcceptKeyInto(key: Uint8Array, output: Uint8Array): number {
@@ -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