castrum 0.9.0 → 0.9.2

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 (120) hide show
  1. package/README.md +153 -630
  2. package/castrum.linux-x64-gnu.node +0 -0
  3. package/dist/index.d.ts +1653 -1817
  4. package/dist/index.js +6172 -4294
  5. package/docs/API.md +337 -0
  6. package/docs/ARCHITECTURE.md +111 -17
  7. package/docs/BENCHMARKS.md +117 -49
  8. package/docs/CASE_STUDY.md +243 -0
  9. package/docs/ENVIRONMENT.md +24 -7
  10. package/docs/FFI_BUN_GUIDE.md +335 -0
  11. package/docs/GETTING_STARTED.md +18 -34
  12. package/docs/HOW_TO_ADD_AN_OP.md +180 -0
  13. package/docs/INGRESS-ROUTER.md +82 -0
  14. package/docs/INGRESS.md +11 -7
  15. package/docs/NATIVE-ROUTE.md +77 -0
  16. package/docs/REPO_MAP.md +103 -53
  17. package/docs/adr/0001-two-wire-formats.md +1 -1
  18. package/docs/adr/0003-bun-builtins-delegation.md +6 -5
  19. package/docs/ai/LOCAL_DEV.md +81 -0
  20. package/docs/ai/TREE.md +1070 -0
  21. package/docs/bun-builtins-decision-matrix.md +25 -18
  22. package/index.ts +9 -16
  23. package/package.json +41 -28
  24. package/src/ingress/body.ts +167 -27
  25. package/src/ingress/decode/result-base.ts +10 -2
  26. package/src/ingress/handlers.ts +116 -285
  27. package/src/ingress/headers/security.ts +41 -0
  28. package/src/ingress/health.ts +1 -0
  29. package/src/ingress/index.ts +45 -169
  30. package/src/ingress/native-route.ts +182 -0
  31. package/src/ingress/packing/gather-raw-headers.ts +110 -22
  32. package/src/ingress/packing/header-packing.ts +5 -5
  33. package/src/ingress/packing/input-packer.ts +17 -3
  34. package/src/ingress/packing/route-wire.ts +197 -0
  35. package/src/ingress/packing/select-headers.ts +20 -7
  36. package/src/ingress/path-matcher.ts +115 -0
  37. package/src/ingress/response/baked-response.ts +302 -0
  38. package/src/ingress/router.ts +277 -0
  39. package/src/ingress/routes/common.ts +50 -1
  40. package/src/ingress/routes/fallback.ts +2 -2
  41. package/src/ingress/routes/head.ts +3 -13
  42. package/src/ingress/routes/index.ts +5 -4
  43. package/src/ingress/routes/json-write.ts +26 -47
  44. package/src/ingress/routes/native.ts +130 -0
  45. package/src/ingress/routes/options.ts +2 -12
  46. package/src/ingress/routes/read.ts +3 -13
  47. package/src/ingress/routes/responder.ts +159 -0
  48. package/src/ingress/server-node.ts +7 -7
  49. package/src/ingress/server.ts +83 -112
  50. package/src/ingress/sync.ts +151 -0
  51. package/src/ingress/types.ts +44 -1
  52. package/src/integration/pipeline.ts +3 -0
  53. package/src/integration/streaming.ts +2 -3
  54. package/src/integration/websocket.ts +4 -3
  55. package/src/loader/batch.ts +4 -0
  56. package/src/loader/cost.ts +1 -0
  57. package/src/loader/index.ts +43 -228
  58. package/src/loader/state.ts +84 -0
  59. package/src/loader/types.ts +145 -0
  60. package/src/native/ffi/build/codecs.ts +736 -0
  61. package/src/native/ffi/build/compress.ts +248 -0
  62. package/src/native/ffi/build/instances.ts +598 -0
  63. package/src/native/ffi/build/parse.ts +539 -0
  64. package/src/native/ffi/build/util.ts +140 -0
  65. package/src/native/ffi/build.ts +94 -0
  66. package/src/native/ffi/constants.ts +58 -0
  67. package/src/native/ffi/selftest.ts +22 -0
  68. package/src/native/ffi/types.ts +499 -0
  69. package/src/native/ffi.ts +307 -1357
  70. package/src/native/loader.ts +52 -6
  71. package/src/native/types/instances.ts +279 -0
  72. package/src/native/types.ts +41 -238
  73. package/src/runtime/builtins.ts +131 -0
  74. package/src/runtime/codec.ts +147 -0
  75. package/src/runtime/detect.ts +36 -0
  76. package/src/runtime/index.ts +28 -0
  77. package/src/runtime/native.ts +35 -0
  78. package/src/runtime/server.ts +31 -0
  79. package/src/runtime/transport.ts +64 -0
  80. package/src/runtime/types.ts +173 -0
  81. package/src/runtime/uuid.ts +45 -0
  82. package/src/runtime/websocket.ts +30 -0
  83. package/src/rust-ffi/addon.ts +1 -1
  84. package/src/rust-ffi/batch/build.ts +155 -0
  85. package/src/rust-ffi/batch/index.ts +8 -0
  86. package/src/rust-ffi/batch/types.ts +92 -0
  87. package/src/rust-ffi/client.ts +5 -6
  88. package/src/rust-ffi/context.ts +39 -10
  89. package/src/rust-ffi/index.ts +28 -17
  90. package/src/rust-ffi/into.ts +21 -0
  91. package/src/rust-ffi/options.ts +1 -1
  92. package/src/rust-ffi/packed.ts +40 -2
  93. package/src/rust-ffi/proven.ts +16 -30
  94. package/src/rust-ffi/scalar/crypto.ts +188 -107
  95. package/src/rust-ffi/scalar/factories.ts +257 -27
  96. package/src/rust-ffi/scalar/hashing.ts +43 -41
  97. package/src/rust-ffi/scalar/http.ts +208 -77
  98. package/src/rust-ffi/scalar/index.ts +3 -3
  99. package/src/rust-ffi/scalar/interface.ts +88 -213
  100. package/src/rust-ffi/scalar/json-packed.ts +175 -0
  101. package/src/rust-ffi/scalar/json.ts +28 -12
  102. package/src/rust-ffi/scalar/payload.ts +159 -42
  103. package/src/rust-ffi/text.ts +31 -5
  104. package/src/selection.ts +27 -31
  105. package/src/shared/adaptive.ts +1 -2
  106. package/src/shared/buffer-pool.ts +72 -17
  107. package/src/shared/bytes.ts +75 -6
  108. package/src/shared/codec.ts +78 -0
  109. package/src/shared/env.ts +4 -4
  110. package/src/shared/log.ts +1 -0
  111. package/src/shared/packed/index.ts +10 -0
  112. package/src/shared/packed/parsers.ts +31 -0
  113. package/src/shared/packed/schema.ts +23 -0
  114. package/src/shared/{packed.ts → packed/wire.ts} +31 -39
  115. package/src/shared/proven.ts +189 -496
  116. package/src/shared/runtime.ts +20 -25
  117. package/src/shared/uuid.ts +12 -28
  118. package/src/data/json-rows.ts +0 -33
  119. package/src/rust-ffi/batch.ts +0 -431
  120. package/src/shared/bench-classify.ts +0 -209
@@ -3,22 +3,32 @@
3
3
  // `rust.packed.*` is the zero-copy API: callers pass already-packed buffers and
4
4
  // receive the raw packed output, plus the batch metadata/count helpers.
5
5
 
6
- import { asNumber } from './options'
7
- import { resolvePoolNative, type RustClientContext } from './context'
8
6
  import type { PasswordHashOptions } from '../native'
7
+ import { type RustClientContext, resolvePoolNative } from './context'
8
+ import { asNumber } from './options'
9
9
 
10
10
  /** Raw packed-wire FFI namespace. */
11
11
  export interface RustPacked {
12
12
  // ── Sync batch (packed in → packed out) ──
13
+ /** Packed CRC32 batch: packed `[u8[]]` items → packed u32 results. */
13
14
  crc32BatchPacked(input: Uint8Array): Uint8Array
15
+ /** Packed JSON-validity batch: packed `[u8[]]` items → packed 0/1 bitset. */
14
16
  jsonValidBatchPacked(input: Uint8Array): Uint8Array
17
+ /** Packed email-validator batch: packed `[u8[]]` items → packed 0/1 bitset. */
15
18
  validateEmailBatchPacked(input: Uint8Array): Uint8Array
19
+ /** Packed UUID-validator batch: packed `[u8[]]` items → packed 0/1 bitset. */
16
20
  validateUuidBatchPacked(input: Uint8Array): Uint8Array
21
+ /** Packed IPv4-validator batch: packed `[u8[]]` items → packed 0/1 bitset. */
17
22
  validateIpv4BatchPacked(input: Uint8Array): Uint8Array
23
+ /** Packed IPv6-validator batch: packed `[u8[]]` items → packed 0/1 bitset. */
18
24
  validateIpv6BatchPacked(input: Uint8Array): Uint8Array
25
+ /** Packed JSON-sum batch: packed `[{id}]` docs → packed `[ok u8][sum i64 LE]`. */
19
26
  jsonSumBatchPacked(input: Uint8Array): Uint8Array
27
+ /** Packed query-string parser batch: packed `[u8[]]` queries → packed pair lists. */
20
28
  queryParseBatchPacked(input: Uint8Array): Uint8Array
29
+ /** Packed cookie-header parser batch: packed `[u8[]]` headers → packed pair lists. */
21
30
  cookieParseBatchPacked(input: Uint8Array): Uint8Array
31
+ /** Packed HTTP-request parser batch: packed `[u8[]]` requests → packed request sections. */
22
32
  httpParseRequestBatchPacked(input: Uint8Array): Uint8Array
23
33
  /** Batch RFC 6902 JSON Patch (two packed lists, zipped) → packed results. */
24
34
  jsonPatchBatchPacked(docs: Uint8Array, patches: Uint8Array): Uint8Array
@@ -26,24 +36,39 @@ export interface RustPacked {
26
36
  fnv1A64BatchPacked(input: Uint8Array): Uint8Array
27
37
  /** Packed ETag batch (10 strong / 12 weak bytes per item). */
28
38
  etagBatchPacked(input: Uint8Array, weak?: boolean | null): Uint8Array
39
+ /** Packed percent-encoding batch: packed `[u8[]]` items → packed hex-percent bytes. */
29
40
  urlEncodeBatchPacked(input: Uint8Array): Uint8Array
41
+ /** Packed percent-decoding batch: packed `[u8[]]` items → packed decoded bytes (string-safe). */
30
42
  urlDecodeBatchPacked(input: Uint8Array): Uint8Array
43
+ /** Packed percent-decoding batch returning raw bytes (`%FF` preserved). */
31
44
  urlDecodeBytesBatchPacked(input: Uint8Array): Uint8Array
45
+ /** Packed MIME-lookup batch: packed `[u8[]]` extensions → packed media-type strings. */
32
46
  mimeFromExtensionBatchPacked(input: Uint8Array): Uint8Array
47
+ /** Packed WebSocket accept-key batch: packed `[u8[]]` keys → packed base64 SHA-1 results. */
33
48
  wsAcceptKeyBatchPacked(input: Uint8Array): Uint8Array
34
49
 
35
50
  // ── Batch metadata / counts ──
51
+ /** Valid JSON count in a packed JSON batch (0..n; invalid items not counted). */
36
52
  jsonValidBatchCountPacked(input: Uint8Array): number
53
+ /** Valid-email count in a packed email batch. */
37
54
  validateEmailBatchCountPacked(input: Uint8Array): number
55
+ /** Valid-UUID count in a packed UUID batch. */
38
56
  validateUuidBatchCountPacked(input: Uint8Array): number
57
+ /** Valid-IPv4 count in a packed IPv4 batch. */
39
58
  validateIpv4BatchCountPacked(input: Uint8Array): number
59
+ /** Valid-IPv6 count in a packed IPv6 batch. */
40
60
  validateIpv6BatchCountPacked(input: Uint8Array): number
61
+ /** Total `id` sum across a packed JSON batch (valid items only). */
41
62
  jsonSumBatchTotalPacked(input: Uint8Array): number
63
+ /** Total packed-output length (bytes) a query batch will produce. */
42
64
  queryParseBatchTotalLenPacked(input: Uint8Array): number
65
+ /** Total packed-output length (bytes) a cookie batch will produce. */
43
66
  cookieParseBatchTotalLenPacked(input: Uint8Array): number
67
+ /** Total packed-output length (bytes) an HTTP-request batch will produce. */
44
68
  httpParseRequestBatchTotalLenPacked(input: Uint8Array): number
45
69
 
46
70
  // ── Backend-framework features ──
71
+ /** Packed Argon2id-hash batch: packed `[u8[]]` passwords + shared salt → packed PHC strings. */
47
72
  passwordHashBatchPacked(
48
73
  input: Uint8Array,
49
74
  salt: Uint8Array,
@@ -53,47 +78,60 @@ export interface RustPacked {
53
78
  passwordVerifyBatchPacked(passwords: Uint8Array, phcs: Uint8Array): Uint8Array
54
79
  /** Packed URL-resolve batch (two packed lists, zipped) → packed results. */
55
80
  urlResolveBatchPacked(bases: Uint8Array, references: Uint8Array): Uint8Array
81
+ /** Packed AEAD-encrypt batch (packed inputs + shared key/nonce) → packed ciphertexts. */
56
82
  aeadEncryptBatchPacked(
57
83
  input: Uint8Array,
58
84
  key: Uint8Array,
59
85
  nonce: Uint8Array,
60
86
  algorithm?: string | null,
61
87
  ): Uint8Array
88
+ /** Packed AEAD-decrypt batch (packed inputs + shared key/nonce) → packed plaintexts. */
62
89
  aeadDecryptBatchPacked(
63
90
  input: Uint8Array,
64
91
  key: Uint8Array,
65
92
  nonce: Uint8Array,
66
93
  algorithm?: string | null,
67
94
  ): Uint8Array
95
+ /** Packed gzip-compress batch: packed `[u8[]]` items → packed gzip streams. */
68
96
  gzipCompressBatchPacked(input: Uint8Array, level?: number | null): Uint8Array
97
+ /** Packed gzip-decompress batch (64 MiB cap per item) → packed plaintexts. */
69
98
  gzipDecompressBatchPacked(input: Uint8Array): Uint8Array
99
+ /** Packed brotli-compress batch: packed `[u8[]]` items → packed brotli streams. */
70
100
  brotliCompressBatchPacked(input: Uint8Array, quality?: number | null): Uint8Array
101
+ /** Packed brotli-decompress batch (64 MiB cap per item) → packed plaintexts. */
71
102
  brotliDecompressBatchPacked(input: Uint8Array): Uint8Array
103
+ /** Packed HS256 JWT-sign batch (packed claims + shared secret) → packed tokens. */
72
104
  jwtSignBatchPacked(
73
105
  input: Uint8Array,
74
106
  secret: Uint8Array,
75
107
  ttlSeconds: number | null,
76
108
  nowSeconds: number,
77
109
  ): Uint8Array
110
+ /** Packed HS256 JWT-verify batch (packed tokens + shared secret) → packed claims/validity. */
78
111
  jwtVerifyBatchPacked(input: Uint8Array, secret: Uint8Array, nowSeconds: number): Uint8Array
79
112
  /** Batch HMAC-SHA256 sign (packed in → hex-signature byte results). */
80
113
  hmacSha256BatchPacked(input: Uint8Array, key: Uint8Array): Uint8Array
81
114
  /** Batch HMAC-SHA256 verify (two packed lists: data + hex sigs, zipped) → bitset. */
82
115
  hmacSha256VerifyBatchPacked(input: Uint8Array, sigs: Uint8Array, key: Uint8Array): Uint8Array
116
+ /** Packed multipart/form-data parser batch (packed bodies + shared boundary) → packed parts. */
83
117
  multipartParseBatchPacked(input: Uint8Array, boundary: Uint8Array): Uint8Array
118
+ /** Packed minijinja template batch: packed contexts + shared source → packed renders. */
84
119
  templateRenderBatchPacked(input: Uint8Array, source: string): Uint8Array
120
+ /** Packed SSE-frame batch: packed data + shared event/id/retry → packed frames. */
85
121
  sseEncodeBatchPacked(
86
122
  input: Uint8Array,
87
123
  event: string | null,
88
124
  id: string | null,
89
125
  retry: number | null,
90
126
  ): Uint8Array
127
+ /** Packed WebSocket-frame encode batch: packed payloads + shared opcode/mask/fin → packed frames. */
91
128
  wsFrameEncodeBatchPacked(
92
129
  input: Uint8Array,
93
130
  opcode: number,
94
131
  mask: boolean,
95
132
  fin: boolean,
96
133
  ): Uint8Array
134
+ /** Packed WebSocket-frame decode batch: packed frames → packed (fin, opcode, payload) results. */
97
135
  wsFrameDecodeBatchPacked(input: Uint8Array): Uint8Array
98
136
  }
99
137
 
@@ -1,42 +1,28 @@
1
- // src/rust-ffi/proven.ts — The performance-annotated export surface.
1
+ // src/rust-ffi/proven.ts — the baked "proven selection" surface.
2
2
  //
3
- // `proven` exposes the FULL `rust.*` surface. Each exported function's JSDoc
4
- // carries its measured performance vs the native JS baseline, annotated from
5
- // the CPU benchmark report by `scripts/annotate-performance.ts` (run via
6
- // `bun run check:annotate`). Functions that are SLOWER than the JS baseline
7
- // (e.g. `jsonParse` vs Bun's JSON.parse, or schema validation vs ajv) are
8
- // marked `@deprecated`.
9
- //
10
- // The registry (`src/shared/proven.ts`) is the single source of truth for the
11
- // annotations; `bun run check:proven` audits it against the latest benchmark
12
- // report (with `--fail` to gate CI).
3
+ // `proven` re-exposes the FULL `rust.*` surface (identical object) alongside
4
+ // the pure-data registry (`src/shared/proven.ts`) that states which
5
+ // implementation is the benchmark-proven winner for every op: 'native',
6
+ // 'js', or 'bun' (Bun built-in delegation). The winners are BAKED (committed,
7
+ // benchmark-derived) and verified by `test/unit/shared/proven.test.ts` — no
8
+ // live benchmark audit and no `@deprecated` JSDoc machinery (the old
9
+ // `check:proven` / `check:annotate` friction, intentionally not re-added).
13
10
 
14
11
  import { rust, type RustClient } from './client'
15
12
 
16
- /**
17
- * The performance-annotated client: the full {@link RustClient} surface.
18
- * Each exported function's JSDoc notes its measured performance vs the JS
19
- * baseline; functions slower than the baseline carry `@deprecated`.
20
- */
13
+ /** The full `rust.*` surface — identical to `rust`; the proven wiring is baked (see {@link ../shared/proven}). */
21
14
  export type ProvenClient = RustClient
22
15
 
23
- /**
24
- * The full `rust.*` surface with benchmark-annotated JSDoc. Identical to
25
- * `rust` — the annotations and `@deprecated` markers are what communicate
26
- * performance now, not a curated subset.
27
- */
16
+ /** The full `rust.*` surface, exposed for audit. Same object as `rust`. */
28
17
  export const proven: ProvenClient = rust
29
18
 
30
- // Re-export the registry + helpers so consumers can audit the surface.
31
19
  export {
32
- PROVEN_SURFACE,
33
- provenStatus,
20
+ PROVEN_SELECTION,
34
21
  isProven,
35
- provenSurface,
22
+ provenEntry,
23
+ provenImpl,
24
+ provenStatus,
36
25
  provenSummary,
26
+ provenSurface,
37
27
  } from '../shared/proven'
38
- export type {
39
- PerformanceStatus,
40
- ProvenEntry,
41
- ProvenKey,
42
- } from '../shared/proven'
28
+ export type { ProvenEntry, ProvenImpl, ProvenStatus } from '../shared/proven'
@@ -2,48 +2,71 @@
2
2
  //
3
3
  // Mirrors rust/crypto/*: base64/hex codecs, signed cookies, CSRF, random
4
4
  // tokens, JWT, password hashing and AEAD.
5
+ //
6
+ // Runtime dispatch is centralized in the adapter (`ctx.runtime`): Bun built-in
7
+ // delegations (randomToken/base64*/hexEncode) come from `builtins.has(op)` and
8
+ // the native call from `transport.resolve(op)` / `transport.ffi` (bun:ffi
9
+ // first, napi fallback) — no inline `isBun()` / `getBunFFI()`.
5
10
 
6
11
  import type { PasswordHashOptions } from '../../native'
7
- import { getBunFFI } from '../../native/ffi'
8
- import { isBun } from '../../shared/runtime'
9
- import type { RustClientContext } from '../context'
10
-
11
- const encoder = new TextEncoder()
12
- const decoder = new TextDecoder()
12
+ import { encoder } from '../../shared/bytes'
13
+ import { writeInto } from '../into'
14
+ import { memoizeFfi, type RustClientContext, resolveNative } from '../context'
13
15
 
14
16
  /** Auth / crypto scalar methods (`Pick<RustScalar, ...>`). */
15
17
  export function buildCrypto(ctx: RustClientContext) {
16
18
  const { addon } = ctx
19
+ const { builtins, transport } = ctx.runtime
20
+ // Hoist the immutable per-runtime builtin-delegation decisions into a
21
+ // bind-time map (BUILTIN_OPS is a module constant — never mutated after
22
+ // load) so the hot path reads object properties instead of `Set.has`.
23
+ const HAS = {
24
+ randomToken: builtins.has('randomToken'),
25
+ base64Encode: builtins.has('base64Encode'),
26
+ base64UrlEncode: builtins.has('base64UrlEncode'),
27
+ hexEncode: builtins.has('hexEncode'),
28
+ }
29
+ // Lazy-memoized ffi surface: binds on first call, single local read after.
30
+ const ffi = memoizeFfi(transport)
17
31
 
18
32
  return {
19
- randomToken(byteLen: number): Uint8Array {
33
+ randomToken(byteLen: number): Uint8Array | string {
20
34
  // Optimal by default under Bun: `crypto.getRandomValues` + native hex
21
- // beats the rust+FFI crossing for token-sized draws (decision matrix).
22
- // Preserves the native 16 MiB allocation guard and the hex format
23
- // (2n bytes). Node keeps the addon.
24
- if (isBun()) {
25
- if (byteLen > 16 * 1024 * 1024) {
26
- throw new RangeError('randomToken: byteLen exceeds 16 MiB limit')
27
- }
28
- return encoder.encode(
29
- Buffer.from(crypto.getRandomValues(new Uint8Array(byteLen))).toString('hex'),
30
- )
35
+ // beats the rust+FFI crossing for token-sized draws (decision matrix);
36
+ // the builtin carries the 16 MiB guard and returns the hex STRING. Node
37
+ // keeps the addon.
38
+ if (HAS.randomToken) return builtins.randomToken(byteLen)
39
+ return resolveNative(ctx, 'randomToken')(byteLen) as Uint8Array | string
40
+ },
41
+ randomTokenInto(byteLen: number, output: Uint8Array): number {
42
+ // Pooled sibling — writes `byteLen*2` hex chars into `output`. FFI path
43
+ // uses the native `_into` (no cstring round-trip); without ffi the Bun
44
+ // builtin (or addon) hex is written into the caller buffer.
45
+ const f = ffi()
46
+ if (f) return f.randomTokenInto(byteLen, output)
47
+ if (HAS.randomToken) {
48
+ const need = byteLen * 2
49
+ if (output.length < need) throw new Error('random token: output buffer too small')
50
+ output.set(encoder.encode(builtins.randomToken(byteLen) as string))
51
+ return need
31
52
  }
32
- const ffi = getBunFFI()
33
- if (ffi) return ffi.randomToken(byteLen)
34
- return addon.randomToken(byteLen)
53
+ const bytes = addon.randomToken(byteLen)
54
+ return writeInto('random token', output, bytes)
35
55
  },
36
- base64Encode(input: Uint8Array, urlSafe?: boolean, padding?: boolean): Uint8Array {
56
+ base64Encode(input: Uint8Array, urlSafe?: boolean, padding?: boolean): Uint8Array | string {
37
57
  // Bun's Buffer base64 (SIMD) beats the rust+FFI crossing (~2x, measured)
38
- // for the standard (url-safe=false, padded) case; byte-parity pinned by
39
- // test/unit/features/encoding.test.ts. url-safe/unpadded falls through to
40
- // the native path (Buffer can't express base64url without string surgery).
41
- if (isBun() && !urlSafe && padding !== false) {
42
- return encoder.encode(Buffer.from(input).toString('base64'))
58
+ // for the standard (url-safe=false, padded) case; the builtin returns
59
+ // `undefined` for url-safe/unpadded so those fall through to native.
60
+ // Parity pinned by test/unit/features/encoding.test.ts.
61
+ if (HAS.base64Encode) {
62
+ const encoded = builtins.base64Encode(input, urlSafe, padding)
63
+ if (encoded !== undefined) return encoded
43
64
  }
44
- const ffi = getBunFFI()
45
- if (ffi) return ffi.base64Encode(input, urlSafe, padding)
46
- return addon.base64Encode(input, urlSafe ?? undefined, padding ?? undefined)
65
+ return resolveNative(ctx, 'base64Encode')(
66
+ input,
67
+ urlSafe ?? undefined,
68
+ padding ?? undefined,
69
+ ) as Uint8Array | string
47
70
  },
48
71
  base64EncodeInto(
49
72
  input: Uint8Array,
@@ -51,14 +74,19 @@ export function buildCrypto(ctx: RustClientContext) {
51
74
  urlSafe?: boolean,
52
75
  padding?: boolean,
53
76
  ): number {
54
- const ffi = getBunFFI()
55
- if (ffi) return ffi.base64EncodeInto(input, output, urlSafe, padding)
56
- return addon.base64EncodeInto(input, output, urlSafe ?? undefined, padding ?? undefined)
77
+ return resolveNative(ctx, 'base64EncodeInto')(
78
+ input,
79
+ output,
80
+ urlSafe ?? undefined,
81
+ padding ?? undefined,
82
+ ) as number
57
83
  },
58
84
  base64Decode(input: Uint8Array, urlSafe?: boolean, padding?: boolean): Uint8Array {
59
- const ffi = getBunFFI()
60
- if (ffi) return ffi.base64Decode(input, urlSafe, padding)
61
- return addon.base64Decode(input, urlSafe ?? undefined, padding ?? undefined)
85
+ return resolveNative(ctx, 'base64Decode')(
86
+ input,
87
+ urlSafe ?? undefined,
88
+ padding ?? undefined,
89
+ ) as Uint8Array
62
90
  },
63
91
  base64DecodeInto(
64
92
  input: Uint8Array,
@@ -66,69 +94,73 @@ export function buildCrypto(ctx: RustClientContext) {
66
94
  urlSafe?: boolean,
67
95
  padding?: boolean,
68
96
  ): number {
69
- const ffi = getBunFFI()
70
- if (ffi) return ffi.base64DecodeInto(input, output, urlSafe, padding)
71
- return addon.base64DecodeInto(input, output, urlSafe ?? undefined, padding ?? undefined)
97
+ return resolveNative(ctx, 'base64DecodeInto')(
98
+ input,
99
+ output,
100
+ urlSafe ?? undefined,
101
+ padding ?? undefined,
102
+ ) as number
72
103
  },
73
- base64UrlEncode(input: Uint8Array): Uint8Array {
74
- const ffi = getBunFFI()
75
- if (ffi) return ffi.base64Encode(input, true, false)
104
+ base64UrlEncode(input: Uint8Array): Uint8Array | string {
105
+ // Bun's native `Buffer.toString('base64url')` (SIMD) beats the rust+FFI
106
+ // crossing — same delegation rationale as `base64Encode`; verified
107
+ // byte-parity with the Rust `URL_SAFE_NO_PAD` engine
108
+ // (test/unit/features/encoding.test.ts). The ffi path reuses
109
+ // `base64Encode(input, true, false)` (no dedicated base64UrlEncode symbol).
110
+ if (HAS.base64UrlEncode) return builtins.base64UrlEncode(input)
111
+ const f = ffi()
112
+ if (f) return f.base64Encode(input, true, false)
76
113
  return addon.base64UrlEncode(input)
77
114
  },
78
115
  base64UrlDecode(input: Uint8Array): Uint8Array {
79
- const ffi = getBunFFI()
80
- if (ffi) return ffi.base64Decode(input, true, false)
116
+ const f = ffi()
117
+ if (f) return f.base64Decode(input, true, false)
81
118
  return addon.base64UrlDecode(input)
82
119
  },
83
- hexEncode(input: Uint8Array): Uint8Array {
84
- // Bun fast path writes straight into a fresh buffer — no napi Buffer
85
- // alloc + copy (~13x cheaper crossing for small inputs).
86
- const ffi = getBunFFI()
87
- if (ffi) return ffi.hexEncode(input)
88
- return addon.hexEncode(input)
120
+ hexEncode(input: Uint8Array): Uint8Array | string {
121
+ // Bun's native `Buffer.toString('hex')` (SIMD) beats the rust+FFI
122
+ // crossing, especially for large inputs. Verified byte-parity with the
123
+ // Rust hex encoder. Bun returns the hex STRING; Node keeps the addon bytes.
124
+ if (HAS.hexEncode) return builtins.hexEncode(input)
125
+ return resolveNative(ctx, 'hexEncode')(input) as Uint8Array | string
89
126
  },
90
127
  hexEncodeInto(input: Uint8Array, output: Uint8Array): number {
91
- const ffi = getBunFFI()
92
- if (ffi) return ffi.hexEncodeInto(input, output)
93
- return addon.hexEncodeInto(input, output)
128
+ return resolveNative(ctx, 'hexEncodeInto')(input, output) as number
94
129
  },
95
130
  hexDecode(input: Uint8Array): Uint8Array {
96
- const ffi = getBunFFI()
97
- if (ffi) return ffi.hexDecode(input)
98
- return addon.hexDecode(input)
131
+ return resolveNative(ctx, 'hexDecode')(input) as Uint8Array
99
132
  },
100
133
  hexDecodeInto(input: Uint8Array, output: Uint8Array): number {
101
- const ffi = getBunFFI()
102
- if (ffi) return ffi.hexDecodeInto(input, output)
103
- return addon.hexDecodeInto(input, output)
134
+ return resolveNative(ctx, 'hexDecodeInto')(input, output) as number
104
135
  },
105
- signCookie(value: Uint8Array, secret: Uint8Array): Uint8Array {
106
- const ffi = getBunFFI()
107
- if (ffi) return ffi.signCookie(value, secret)
108
- return addon.signCookie(value, secret)
136
+ signCookie(value: Uint8Array, secret: Uint8Array): Uint8Array | string {
137
+ return resolveNative(ctx, 'signCookie')(value, secret) as Uint8Array | string
109
138
  },
110
139
  signCookieInto(value: Uint8Array, secret: Uint8Array, output: Uint8Array): number {
111
- const ffi = getBunFFI()
112
- if (ffi) return ffi.signCookieInto(value, secret, output)
113
- const bytes = addon.signCookie(value, secret)
114
- if (output.length < bytes.length) throw new Error('sign cookie: output buffer too small')
115
- output.set(bytes)
116
- return bytes.length
140
+ const f = ffi()
141
+ if (f) return f.signCookieInto(value, secret, output)
142
+ return writeInto('sign cookie', output, addon.signCookie(value, secret))
143
+ },
144
+ verifyCookie(signed: Uint8Array, secret: Uint8Array): Uint8Array | string | null {
145
+ return resolveNative(ctx, 'verifyCookie')(signed, secret) as Uint8Array | string | null
146
+ },
147
+ verifyCookieInto(signed: Uint8Array, secret: Uint8Array, output: Uint8Array): number | null {
148
+ const f = ffi()
149
+ if (f) return f.verifyCookieInto(signed, secret, output)
150
+ const value = addon.verifyCookie(signed, secret)
151
+ if (value === null) return null
152
+ return writeInto('verify cookie', output, value)
117
153
  },
118
- verifyCookie(signed: Uint8Array, secret: Uint8Array): Uint8Array | null {
119
- const ffi = getBunFFI()
120
- if (ffi) return ffi.verifyCookie(signed, secret)
121
- return addon.verifyCookie(signed, secret)
154
+ csrfToken(secret: Uint8Array): Uint8Array | string {
155
+ return resolveNative(ctx, 'csrfToken')(secret) as Uint8Array | string
122
156
  },
123
- csrfToken(secret: Uint8Array): Uint8Array {
124
- const ffi = getBunFFI()
125
- if (ffi) return ffi.csrfToken(secret)
126
- return addon.csrfToken(secret)
157
+ csrfTokenInto(secret: Uint8Array, output: Uint8Array): number {
158
+ const f = ffi()
159
+ if (f) return f.csrfTokenInto(secret, output)
160
+ return writeInto('csrf token', output, addon.csrfToken(secret))
127
161
  },
128
162
  csrfVerify(token: Uint8Array, secret: Uint8Array): boolean {
129
- const ffi = getBunFFI()
130
- if (ffi) return ffi.csrfVerify(token, secret)
131
- return addon.csrfVerify(token, secret)
163
+ return resolveNative(ctx, 'csrfVerify')(token, secret) as boolean
132
164
  },
133
165
  jwtSign(
134
166
  claims: Record<string, unknown>,
@@ -136,6 +168,23 @@ export function buildCrypto(ctx: RustClientContext) {
136
168
  ttlSeconds?: number | null,
137
169
  nowSeconds?: number,
138
170
  ): Uint8Array {
171
+ // FFI-first: route the object claims through the C-ABI `jwtSignBytes`
172
+ // (pre-serialized JSON). The engine returns the token as a string
173
+ // (native transfer); re-encode to bytes to keep the public BYTE contract
174
+ // (batch/benchmark consumers compare bytes). NOTE: JSON.stringify
175
+ // preserves JS insertion order while napi's serde_json sorts keys — for
176
+ // multi-key claims the two transports emit different-but-equally-valid
177
+ // tokens (both verify; parity tests cross-verify semantically).
178
+ const f = ffi()
179
+ if (f) {
180
+ const token = f.jwtSignBytes(
181
+ encoder.encode(JSON.stringify(claims)),
182
+ secret,
183
+ ttlSeconds ?? 0,
184
+ nowSeconds ?? Math.floor(Date.now() / 1000),
185
+ )
186
+ return encoder.encode(token)
187
+ }
139
188
  return addon.jwtSign(
140
189
  claims,
141
190
  secret,
@@ -148,12 +197,12 @@ export function buildCrypto(ctx: RustClientContext) {
148
197
  secret: Uint8Array,
149
198
  ttlSeconds?: number | null,
150
199
  nowSeconds?: number,
151
- ): Uint8Array {
152
- const ffi = getBunFFI()
153
- if (ffi) {
200
+ ): Uint8Array | string {
201
+ const f = ffi()
202
+ if (f) {
154
203
  // The C-ABI `ttl <= 0` sentinel matches napi's Option<i64> (inject only
155
204
  // when positive), and nowSeconds defaults identically.
156
- return ffi.jwtSignBytes(
205
+ return f.jwtSignBytes(
157
206
  claimsJson,
158
207
  secret,
159
208
  ttlSeconds ?? 0,
@@ -167,19 +216,55 @@ export function buildCrypto(ctx: RustClientContext) {
167
216
  nowSeconds ?? Math.floor(Date.now() / 1000),
168
217
  )
169
218
  },
219
+ jwtSignBytesInto(
220
+ claimsJson: Uint8Array,
221
+ secret: Uint8Array,
222
+ output: Uint8Array,
223
+ ttlSeconds?: number | null,
224
+ nowSeconds?: number,
225
+ ): number {
226
+ const f = ffi()
227
+ if (f) {
228
+ return f.jwtSignBytesInto(
229
+ claimsJson,
230
+ secret,
231
+ ttlSeconds ?? 0,
232
+ nowSeconds ?? Math.floor(Date.now() / 1000),
233
+ output,
234
+ )
235
+ }
236
+ return writeInto(
237
+ 'jwt sign',
238
+ output,
239
+ addon.jwtSignBytes(
240
+ claimsJson,
241
+ secret,
242
+ ttlSeconds ?? null,
243
+ nowSeconds ?? Math.floor(Date.now() / 1000),
244
+ ),
245
+ )
246
+ },
170
247
  jwtVerify(token: Uint8Array, secret: Uint8Array, nowSeconds?: number): unknown {
248
+ const f = ffi()
249
+ if (f) {
250
+ // Verify via the FFI cstring path (castrum_jwt_verify): the engine
251
+ // clones the claims JSON string at return (zero decode), and `null` =
252
+ // invalid signature / expired / malformed → null (napi Option parity).
253
+ const claims = f.jwtVerify(token, secret, nowSeconds ?? Math.floor(Date.now() / 1000))
254
+ return claims === null ? null : (JSON.parse(claims) as unknown)
255
+ }
171
256
  return addon.jwtVerify(token, secret, nowSeconds ?? Math.floor(Date.now() / 1000))
172
257
  },
173
258
  passwordHash(
174
259
  password: Uint8Array,
175
260
  salt: Uint8Array,
176
261
  options?: PasswordHashOptions | null,
177
- ): Uint8Array {
178
- const ffi = getBunFFI()
179
- if (ffi) {
262
+ ): Uint8Array | string {
263
+ const f = ffi()
264
+ if (f) {
180
265
  // Resolve the napi defaults (rust/crypto/argon2.rs resolve_opts).
181
266
  const o = options ?? {}
182
- return ffi.passwordHash(
267
+ return f.passwordHash(
183
268
  password,
184
269
  salt,
185
270
  o.mCost ?? 19456,
@@ -191,18 +276,16 @@ export function buildCrypto(ctx: RustClientContext) {
191
276
  return addon.passwordHash(password, salt, options ?? null)
192
277
  },
193
278
  passwordVerify(password: Uint8Array, phc: Uint8Array): boolean {
194
- const ffi = getBunFFI()
195
- if (ffi) return ffi.passwordVerify(password, phc)
196
- return addon.passwordVerify(password, phc)
279
+ return resolveNative(ctx, 'passwordVerify')(password, phc) as boolean
197
280
  },
198
281
  passwordHashBcrypt(password: Uint8Array, cost: number): string {
199
- const ffi = getBunFFI()
200
- if (ffi) return decoder.decode(ffi.passwordHashBcrypt(password, cost))
201
- return addon.passwordHashBcrypt(password, cost)
282
+ return resolveNative(ctx, 'passwordHashBcrypt')(password, cost) as string
202
283
  },
203
284
  passwordVerifyBcrypt(password: Uint8Array, hash: string): boolean {
204
- const ffi = getBunFFI()
205
- if (ffi) return ffi.passwordVerifyBcrypt(password, encoder.encode(hash))
285
+ // FFI-first: `hash` is a `cstring` ARG (the engine transcodes the PHC
286
+ // string in-engine — no JS encode); `password` stays `(ptr,len)` bytes.
287
+ const f = ffi()
288
+ if (f) return f.passwordVerifyBcrypt(password, hash)
206
289
  return addon.passwordVerifyBcrypt(password, hash)
207
290
  },
208
291
  pbkdf2Sha256(
@@ -211,9 +294,7 @@ export function buildCrypto(ctx: RustClientContext) {
211
294
  rounds: number,
212
295
  dkLen: number,
213
296
  ): Uint8Array {
214
- const ffi = getBunFFI()
215
- if (ffi) return ffi.pbkdf2Sha256(password, salt, rounds, dkLen)
216
- return addon.pbkdf2Sha256(password, salt, rounds, dkLen)
297
+ return resolveNative(ctx, 'pbkdf2Sha256')(password, salt, rounds, dkLen) as Uint8Array
217
298
  },
218
299
  aeadEncrypt(
219
300
  key: Uint8Array,
@@ -221,9 +302,9 @@ export function buildCrypto(ctx: RustClientContext) {
221
302
  plaintext: Uint8Array,
222
303
  algorithm?: string | null,
223
304
  ): Uint8Array {
224
- const ffi = getBunFFI()
225
- if (ffi) {
226
- return ffi.aeadEncrypt(key, nonce, plaintext, algorithm === 'chacha20-poly1305' ? 1 : 0)
305
+ const f = ffi()
306
+ if (f) {
307
+ return f.aeadEncrypt(key, nonce, plaintext, algorithm === 'chacha20-poly1305' ? 1 : 0)
227
308
  }
228
309
  return addon.aeadEncrypt(key, nonce, plaintext, algorithm ?? null)
229
310
  },
@@ -234,9 +315,9 @@ export function buildCrypto(ctx: RustClientContext) {
234
315
  output: Uint8Array,
235
316
  algorithm?: string | null,
236
317
  ): number {
237
- const ffi = getBunFFI()
238
- if (ffi) {
239
- return ffi.aeadEncryptInto(
318
+ const f = ffi()
319
+ if (f) {
320
+ return f.aeadEncryptInto(
240
321
  key,
241
322
  nonce,
242
323
  plaintext,
@@ -255,9 +336,9 @@ export function buildCrypto(ctx: RustClientContext) {
255
336
  ciphertext: Uint8Array,
256
337
  algorithm?: string | null,
257
338
  ): Uint8Array | null {
258
- const ffi = getBunFFI()
259
- if (ffi) {
260
- return ffi.aeadDecrypt(key, nonce, ciphertext, algorithm === 'chacha20-poly1305' ? 1 : 0)
339
+ const f = ffi()
340
+ if (f) {
341
+ return f.aeadDecrypt(key, nonce, ciphertext, algorithm === 'chacha20-poly1305' ? 1 : 0)
261
342
  }
262
343
  return addon.aeadDecrypt(key, nonce, ciphertext, algorithm ?? null)
263
344
  },