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,28 +3,30 @@
3
3
  // Every `create*` here constructs a compiled-once native instance (key / schema
4
4
  // / template precompiled at construction) plus the rayon thread-pool controls.
5
5
 
6
- import { asNumber } from '../options'
7
- import { getBunFFI, type BunFFI } from '../../native/ffi'
8
- import type { RustClientContext } from '../context'
9
6
  import type {
10
- HmacSignerInstance,
11
- SchemaValidatorInstance,
12
- TemplateRendererInstance,
13
- FormParserInstance,
14
- MediaTypeParserInstance,
15
- MediaTypeMatcherInstance,
16
- ConditionalRequestInstance,
17
7
  AcceptNegotiatorInstance,
8
+ AeadCipherInstance,
9
+ Argon2HasherInstance,
18
10
  Base64CodecInstance,
11
+ ConditionalRequestInstance,
19
12
  CookieSignerInstance,
20
13
  CsrfProtectorInstance,
21
- UrlBuilderInstance,
14
+ FormParserInstance,
15
+ HmacSignerInstance,
22
16
  JwtSignerInstance,
23
- AeadCipherInstance,
24
- Argon2HasherInstance,
25
- RateLimiterInstance,
17
+ MediaTypeMatcherInstance,
18
+ MediaTypeParserInstance,
26
19
  PasswordHashOptions,
20
+ RateLimiterInstance,
21
+ SchemaValidatorInstance,
22
+ TemplateRendererInstance,
23
+ UrlBuilderInstance,
27
24
  } from '../../native'
25
+ import type { BunFFI } from '../../native/ffi'
26
+ import { encoder } from '../../shared/bytes'
27
+ import { decodeUtf8, encodeUtf8 } from '../../shared/codec'
28
+ import type { RustClientContext } from '../context'
29
+ import { asNumber } from '../options'
28
30
 
29
31
  // ── FFI-backed instance wrappers ───────────────────────────────────────────
30
32
  // On Bun, instances whose methods have a STATELESS C-ABI sibling are backed by
@@ -41,7 +43,12 @@ import type {
41
43
  function ffiHmacSigner(key: Uint8Array, ffi: BunFFI): HmacSignerInstance {
42
44
  return {
43
45
  sign(data) {
44
- return ffi.hmacSha256(key, data)
46
+ // Pooled `hmacSha256Into`: writes the 64 hex chars directly into a fresh
47
+ // buffer — no cstring return → no string clone → no encodeUtf8 round-trip
48
+ // (the old path cloned the hex string then re-encoded it to bytes).
49
+ const out = new Uint8Array(64)
50
+ const w = ffi.hmacSha256Into(key, data, out)
51
+ return out.subarray(0, w)
45
52
  },
46
53
  verify(data, sig) {
47
54
  return ffi.hmacSha256Verify(key, data, sig)
@@ -56,7 +63,11 @@ function ffiBase64Codec(
56
63
  ): Base64CodecInstance {
57
64
  return {
58
65
  encode(input) {
59
- return ffi.base64Encode(input, urlSafe, padding)
66
+ // Pooled `base64EncodeInto`: writes the base64 bytes directly into a
67
+ // fresh buffer sized for the input — no cstring return / re-encode.
68
+ const out = new Uint8Array(Math.ceil(input.length / 3) * 4 + 3)
69
+ const w = ffi.base64EncodeInto(input, out, urlSafe, padding)
70
+ return out.subarray(0, w)
60
71
  },
61
72
  decode(input) {
62
73
  return ffi.base64Decode(input, urlSafe, padding)
@@ -67,10 +78,178 @@ function ffiBase64Codec(
67
78
  function ffiCookieSigner(secret: Uint8Array, ffi: BunFFI): CookieSignerInstance {
68
79
  return {
69
80
  sign(value) {
70
- return ffi.signCookie(value, secret)
81
+ // Pooled `signCookieInto`: writes `value.<64-hex>` directly into a fresh
82
+ // buffer — no cstring return / re-encode round-trip.
83
+ const out = new Uint8Array(value.length + 65)
84
+ const w = ffi.signCookieInto(value, secret, out)
85
+ return out.subarray(0, w)
71
86
  },
72
87
  verify(signed) {
73
- return ffi.verifyCookie(signed, secret)
88
+ // Pooled `verifyCookieInto`: writes the verified value directly (the
89
+ // value is a substring of `signed`, so `signed.length + 1` always fits).
90
+ const out = new Uint8Array(signed.length + 1)
91
+ const w = ffi.verifyCookieInto(signed, secret, out)
92
+ return w === null ? null : out.subarray(0, w)
93
+ },
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Opaque-handle `ConditionalRequest`: the napi instance compiles the resource
99
+ * state once (construction); per-call `isNotModified` evaluates it through the
100
+ * C-ABI (`castrum_conditional_is_not_modified`) via the inner handle — no napi
101
+ * crossing. The napi instance MUST stay alive for the handle's lifetime (same
102
+ * contract as the ingress handler / `Ingress::inner_ptr`) — `keepAlive` is
103
+ * referenced by the returned method so the native state is never GC'd while
104
+ * the handle is in use.
105
+ */
106
+ function ffiConditionalRequest(
107
+ napi: ConditionalRequestInstance,
108
+ inner: number,
109
+ ffi: BunFFI,
110
+ ): ConditionalRequestInstance {
111
+ // Referenced by the method below → captured → prevents GC of the native
112
+ // state the opaque handle points into.
113
+ const keepAlive = napi
114
+ return {
115
+ isNotModified(ifNoneMatch, ifModifiedSince) {
116
+ void keepAlive
117
+ return ffi.conditionalIsNotModified(inner, ifNoneMatch ?? null, ifModifiedSince ?? null)
118
+ },
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Opaque-handle `UrlBuilder`: the napi instance parses the base ONCE; per-call
124
+ * `resolve` evaluates it through the C-ABI (`castrum_url_builder_resolve`) via
125
+ * the inner handle — no napi crossing. `keepAlive` references the napi instance
126
+ * so the precompiled base is never GC'd while the handle is in use.
127
+ */
128
+ function ffiUrlBuilder(napi: UrlBuilderInstance, inner: number, ffi: BunFFI): UrlBuilderInstance {
129
+ const keepAlive = napi
130
+ return {
131
+ resolve(reference) {
132
+ void keepAlive
133
+ return ffi.urlBuilderResolve(inner, reference)
134
+ },
135
+ }
136
+ }
137
+
138
+ /** Opaque-handle `MediaTypeMatcher` (expected precompiled once). */
139
+ function ffiMediaTypeMatcher(
140
+ napi: MediaTypeMatcherInstance,
141
+ inner: number,
142
+ ffi: BunFFI,
143
+ ): MediaTypeMatcherInstance {
144
+ const keepAlive = napi
145
+ return {
146
+ matches(actual) {
147
+ void keepAlive
148
+ return ffi.mediaTypeMatcherMatches(inner, actual)
149
+ },
150
+ }
151
+ }
152
+
153
+ /** Opaque-handle `AcceptNegotiator` (supported list precompiled once). */
154
+ function ffiAcceptNegotiator(
155
+ napi: AcceptNegotiatorInstance,
156
+ inner: number,
157
+ ffi: BunFFI,
158
+ ): AcceptNegotiatorInstance {
159
+ const keepAlive = napi
160
+ return {
161
+ negotiate(header) {
162
+ void keepAlive
163
+ return ffi.acceptNegotiatorNegotiate(inner, header)
164
+ },
165
+ negotiateServerPreference(header) {
166
+ void keepAlive
167
+ return ffi.acceptNegotiatorNegotiateServer(inner, header)
168
+ },
169
+ }
170
+ }
171
+
172
+ /** Opaque-handle `JwtSigner` (HS256 key + ttl precompiled once). */
173
+ function ffiJwtSigner(napi: JwtSignerInstance, inner: number, ffi: BunFFI): JwtSignerInstance {
174
+ const keepAlive = napi
175
+ return {
176
+ sign(claims, nowSeconds) {
177
+ void keepAlive
178
+ return ffi.jwtSignerSign(inner, encoder.encode(JSON.stringify(claims)), nowSeconds)
179
+ },
180
+ signBytes(claimsJson, nowSeconds) {
181
+ void keepAlive
182
+ return ffi.jwtSignerSign(inner, claimsJson, nowSeconds)
183
+ },
184
+ verify(token, nowSeconds) {
185
+ void keepAlive
186
+ const claims = ffi.jwtSignerVerify(inner, token, nowSeconds)
187
+ return claims === null ? null : (JSON.parse(decodeUtf8(claims)) as unknown)
188
+ },
189
+ }
190
+ }
191
+
192
+ /** Opaque-handle `TemplateRenderer` (template compiled once). */
193
+ function ffiTemplateRenderer(
194
+ napi: TemplateRendererInstance,
195
+ inner: number,
196
+ ffi: BunFFI,
197
+ ): TemplateRendererInstance {
198
+ const keepAlive = napi
199
+ return {
200
+ render(context) {
201
+ void keepAlive
202
+ return ffi.templateRender(inner, encoder.encode(JSON.stringify(context)))
203
+ },
204
+ renderBytes(contextJson) {
205
+ void keepAlive
206
+ return ffi.templateRender(inner, contextJson)
207
+ },
208
+ renderBatchPacked(data) {
209
+ void keepAlive
210
+ return napi.renderBatchPacked(data)
211
+ },
212
+ }
213
+ }
214
+
215
+ /** Opaque-handle `SchemaValidator` (schema compiled once); the hot `validate`
216
+ * is a C-ABI eval; the detailed/batch/derive ops stay on the napi instance. */
217
+ function ffiSchemaValidator(
218
+ napi: SchemaValidatorInstance,
219
+ inner: number,
220
+ ffi: BunFFI,
221
+ ): SchemaValidatorInstance {
222
+ const keepAlive = napi
223
+ return {
224
+ validate(input) {
225
+ void keepAlive
226
+ return ffi.schemaValidatorValidate(inner, input)
227
+ },
228
+ validateDetailed: napi.validateDetailed.bind(napi),
229
+ validateFirstError: napi.validateFirstError.bind(napi),
230
+ validateBatchPackedCount: napi.validateBatchPackedCount.bind(napi),
231
+ validateBatchPackedBitset: napi.validateBatchPackedBitset.bind(napi),
232
+ derive: napi.derive.bind(napi),
233
+ }
234
+ }
235
+
236
+ /** Opaque-handle `RateLimiter` (sharded state compiled once). */
237
+ function ffiRateLimiter(
238
+ napi: RateLimiterInstance,
239
+ inner: number,
240
+ ffi: BunFFI,
241
+ ): RateLimiterInstance {
242
+ const keepAlive = napi
243
+ return {
244
+ check(key, nowMs) {
245
+ void keepAlive
246
+ // `key` is a `cstring` ARG — the engine transcodes the rate-limit key
247
+ // in-engine (no JS-side encode).
248
+ return ffi.rateLimiterCheck(inner, key, nowMs)
249
+ },
250
+ checkKey(key, nowMs) {
251
+ void keepAlive
252
+ return ffi.rateLimiterCheckKey(inner, key, nowMs)
74
253
  },
75
254
  }
76
255
  }
@@ -78,7 +257,11 @@ function ffiCookieSigner(secret: Uint8Array, ffi: BunFFI): CookieSignerInstance
78
257
  function ffiCsrfProtector(secret: Uint8Array, ffi: BunFFI): CsrfProtectorInstance {
79
258
  return {
80
259
  create() {
81
- return ffi.csrfToken(secret)
260
+ // Pooled `csrfTokenInto`: writes the 129-char `hex.hex` directly into a
261
+ // fresh buffer — no cstring return / re-encode round-trip.
262
+ const out = new Uint8Array(129)
263
+ const w = ffi.csrfTokenInto(secret, out)
264
+ return out.subarray(0, w)
82
265
  },
83
266
  verify(token) {
84
267
  return ffi.csrfVerify(token, secret)
@@ -99,7 +282,7 @@ function ffiArgon2Hasher(
99
282
  const outLen = o.outLen ?? 32
100
283
  return {
101
284
  hash(password, salt) {
102
- return ffi.passwordHash(password, salt, mCost, tCost, pCost, outLen)
285
+ return encodeUtf8(ffi.passwordHash(password, salt, mCost, tCost, pCost, outLen))
103
286
  },
104
287
  verify(password, phc) {
105
288
  return ffi.passwordVerify(password, phc)
@@ -148,22 +331,33 @@ function ffiFormParser(ffi: BunFFI): FormParserInstance {
148
331
 
149
332
  /** Compiled-once factory + runtime-control methods (`Pick<RustScalar, ...>`). */
150
333
  export function buildFactories(ctx: RustClientContext) {
334
+ const { transport } = ctx.runtime
151
335
  const { addon } = ctx
152
336
 
153
337
  return {
154
338
  createSchemaValidator(schema: Uint8Array): SchemaValidatorInstance {
339
+ const ffi = transport.ffi
340
+ if (ffi) {
341
+ const napi = new addon.SchemaValidator(schema)
342
+ return ffiSchemaValidator(napi, Number(napi.innerPtr?.() ?? 0n), ffi)
343
+ }
155
344
  return new addon.SchemaValidator(schema)
156
345
  },
157
346
  createHmacSigner(key: Uint8Array): HmacSignerInstance {
158
- const ffi = getBunFFI()
347
+ const ffi = transport.ffi
159
348
  if (ffi) return ffiHmacSigner(key, ffi)
160
349
  return new addon.HmacSigner(key)
161
350
  },
162
351
  createTemplateRenderer(source: string): TemplateRendererInstance {
352
+ const ffi = transport.ffi
353
+ if (ffi) {
354
+ const napi = new addon.TemplateRenderer(source)
355
+ return ffiTemplateRenderer(napi, Number(napi.innerPtr?.() ?? 0n), ffi)
356
+ }
163
357
  return new addon.TemplateRenderer(source)
164
358
  },
165
359
  createFormParser(capacity?: number): FormParserInstance {
166
- const ffi = getBunFFI()
360
+ const ffi = transport.ffi
167
361
  if (ffi) return ffiFormParser(ffi)
168
362
  return new addon.FormParser(capacity)
169
363
  },
@@ -174,43 +368,74 @@ export function buildFactories(ctx: RustClientContext) {
174
368
  etagValue: Uint8Array,
175
369
  lastModifiedSecs?: number,
176
370
  ): ConditionalRequestInstance {
371
+ // FFI-first via the opaque inner handle (the napi instance compiles the
372
+ // state once; per-call `isNotModified` is a C-ABI eval). The wrapper
373
+ // holds the napi instance for the handle's lifetime.
374
+ const ffi = transport.ffi
375
+ if (ffi) {
376
+ const napi = new addon.ConditionalRequest(etagValue, lastModifiedSecs ?? undefined)
377
+ return ffiConditionalRequest(napi, Number(napi.innerPtr?.() ?? 0n), ffi)
378
+ }
177
379
  return new addon.ConditionalRequest(etagValue, lastModifiedSecs ?? undefined)
178
380
  },
179
381
  createAcceptNegotiator(supported: string[]): AcceptNegotiatorInstance {
382
+ const ffi = transport.ffi
383
+ if (ffi) {
384
+ const napi = new addon.AcceptNegotiator(supported)
385
+ return ffiAcceptNegotiator(napi, Number(napi.innerPtr?.() ?? 0n), ffi)
386
+ }
180
387
  return new addon.AcceptNegotiator(supported)
181
388
  },
182
389
  createBase64Codec(urlSafe?: boolean, padding?: boolean): Base64CodecInstance {
183
- const ffi = getBunFFI()
390
+ const ffi = transport.ffi
184
391
  if (ffi) return ffiBase64Codec(urlSafe, padding, ffi)
185
392
  return new addon.Base64Codec(urlSafe ?? undefined, padding ?? undefined)
186
393
  },
187
394
  createCookieSigner(secret: Uint8Array): CookieSignerInstance {
188
- const ffi = getBunFFI()
395
+ const ffi = transport.ffi
189
396
  if (ffi) return ffiCookieSigner(secret, ffi)
190
397
  return new addon.CookieSigner(secret)
191
398
  },
192
399
  createCsrfProtector(secret: Uint8Array): CsrfProtectorInstance {
193
- const ffi = getBunFFI()
400
+ const ffi = transport.ffi
194
401
  if (ffi) return ffiCsrfProtector(secret, ffi)
195
402
  return new addon.CsrfProtector(secret)
196
403
  },
197
404
  createUrlBuilder(base: Uint8Array): UrlBuilderInstance {
405
+ // FFI-first via the opaque inner handle (the napi instance parses the
406
+ // base ONCE; per-call `resolve` is a C-ABI eval). The wrapper holds the
407
+ // napi instance for the handle's lifetime.
408
+ const ffi = transport.ffi
409
+ if (ffi) {
410
+ const napi = new addon.UrlBuilder(base)
411
+ return ffiUrlBuilder(napi, Number(napi.innerPtr?.() ?? 0n), ffi)
412
+ }
198
413
  return new addon.UrlBuilder(base)
199
414
  },
200
415
  createJwtSigner(secret: Uint8Array, ttlSeconds?: number): JwtSignerInstance {
416
+ const ffi = transport.ffi
417
+ if (ffi) {
418
+ const napi = new addon.JwtSigner(secret, ttlSeconds ?? undefined)
419
+ return ffiJwtSigner(napi, Number(napi.innerPtr?.() ?? 0n), ffi)
420
+ }
201
421
  return new addon.JwtSigner(secret, ttlSeconds ?? undefined)
202
422
  },
203
423
  createAeadCipher(key: Uint8Array, algorithm?: string): AeadCipherInstance {
204
- const ffi = getBunFFI()
424
+ const ffi = transport.ffi
205
425
  if (ffi) return ffiAeadCipher(key, algorithm, ffi)
206
426
  return new addon.AeadCipher(key, algorithm ?? undefined)
207
427
  },
208
428
  createArgon2Hasher(options?: PasswordHashOptions | null): Argon2HasherInstance {
209
- const ffi = getBunFFI()
429
+ const ffi = transport.ffi
210
430
  if (ffi) return ffiArgon2Hasher(options, ffi)
211
431
  return new addon.Argon2Hasher(options ?? undefined)
212
432
  },
213
433
  createMediaTypeMatcher(expected: Uint8Array): MediaTypeMatcherInstance {
434
+ const ffi = transport.ffi
435
+ if (ffi) {
436
+ const napi = new addon.MediaTypeMatcher(expected)
437
+ return ffiMediaTypeMatcher(napi, Number(napi.innerPtr?.() ?? 0n), ffi)
438
+ }
214
439
  return new addon.MediaTypeMatcher(expected)
215
440
  },
216
441
  createRateLimiter(
@@ -218,6 +443,11 @@ export function buildFactories(ctx: RustClientContext) {
218
443
  windowMs: number,
219
444
  maxEntries?: number | null,
220
445
  ): RateLimiterInstance {
446
+ const ffi = transport.ffi
447
+ if (ffi) {
448
+ const napi = new addon.RateLimiter(limit, windowMs, maxEntries ?? undefined)
449
+ return ffiRateLimiter(napi, Number(napi.innerPtr?.() ?? 0n), ffi)
450
+ }
221
451
  return new addon.RateLimiter(limit, windowMs, maxEntries ?? undefined)
222
452
  },
223
453
  initThreadPool(threads?: number): void {
@@ -2,71 +2,73 @@
2
2
  //
3
3
  // Mirrors rust/crypto/hashing.rs + hmac_sha256.rs: crc32 / fnv1a64 / HMAC.
4
4
  // (HMAC reuses the context-cached signer to avoid re-constructing the key.)
5
+ //
6
+ // Runtime dispatch is centralized in the adapter (`ctx.runtime`): the Bun
7
+ // built-in delegations (crc32/xxh3/hmacSha256) come from `builtins.has(op)`
8
+ // and the native call from `transport.resolve(op)` / `transport.ffi`
9
+ // (bun:ffi first, napi fallback) — no inline `isBun()` / `getBunFFI()`.
5
10
 
11
+ import { toBytes } from '../../shared/bytes'
12
+ import { writeInto } from '../into'
13
+ import { memoizeFfi, type RustClientContext, resolveNative } from '../context'
6
14
  import { asBigInt, asNumber } from '../options'
7
- import { resolveNative, type RustClientContext } from '../context'
8
- import { getBunFFI } from '../../native/ffi'
9
- import { isBun } from '../../shared/runtime'
10
-
11
- const hexEncoder = new TextEncoder()
12
15
 
13
16
  /** Hashing scalar methods (`Pick<RustScalar, ...>`). */
14
17
  export function buildHashing(ctx: RustClientContext) {
15
- // Bun fast path: `bun:ffi` JIT-calls the C ABI exports with ~1/4 the crossing
16
- // cost of napi for these sub-µs hashes. `getBunFFI()` is lazy (binds on first
17
- // use, ~2ns cached check) and null when unavailable / self-test fails.
18
+ const { builtins, transport } = ctx.runtime
19
+ // Hoist the immutable per-runtime builtin-delegation decisions into a
20
+ // bind-time map (BUILTIN_OPS is a module constant — never mutated after
21
+ // load) so the hot path reads object properties instead of `Set.has`.
22
+ const HAS = {
23
+ crc32: builtins.has('crc32'),
24
+ xxh3: builtins.has('xxh3'),
25
+ hmacSha256: builtins.has('hmacSha256'),
26
+ }
27
+ // Lazy-memoized ffi surface: binds on first call, single local read after.
28
+ const ffi = memoizeFfi(transport)
29
+ // bun:ffi JIT-calls the C ABI exports with ~1/4 the crossing cost of napi
30
+ // for these sub-µs hashes; the transport resolves ffi-first and caches.
18
31
  return {
19
32
  crc32(input: Uint8Array): number {
20
33
  // Optimal by default under Bun: `Bun.hash.crc32` (native SIMD) beats the
21
34
  // rust+FFI crossing (~2.8-8.4x, decision matrix) and matches the
22
- // `BUN_WINS`/`opImpl` recommendation. Node keeps the addon.
23
- // `>>> 0` preserves the unsigned-32 contract.
24
- if (isBun()) return Bun.hash.crc32(input) >>> 0
25
- const ffi = getBunFFI()
26
- if (ffi) return ffi.crc32(input)
35
+ // `opImpl` recommendation. Node keeps the addon.
36
+ if (HAS.crc32) return builtins.crc32(input)
27
37
  return asNumber(resolveNative(ctx, 'crc32')(input)) >>> 0
28
38
  },
29
39
  fnv1a64(input: Uint8Array): bigint {
30
- const ffi = getBunFFI()
31
- if (ffi) return ffi.fnv1a64(input)
32
40
  return asBigInt(resolveNative(ctx, 'fnv1a64')(input))
33
41
  },
34
42
  xxh3(input: Uint8Array): bigint {
35
43
  // Optimal by default under Bun: `Bun.hash.xxHash3` beats the rust+FFI
36
44
  // crossing (~4x, decision matrix). Node keeps the addon.
37
- if (isBun()) return Bun.hash.xxHash3(input)
38
- const ffi = getBunFFI()
39
- if (ffi) return ffi.xxh3(input)
45
+ if (HAS.xxh3) return builtins.xxh3(input)
40
46
  return asBigInt(resolveNative(ctx, 'xxh3')(input))
41
47
  },
42
- hmacSha256(key: Uint8Array, data: Uint8Array): Uint8Array {
48
+ hmacSha256(key: Uint8Array, data: Uint8Array): Uint8Array | string {
43
49
  // Optimal by default under Bun: `Bun.CryptoHasher` (native) beats the
44
- // rust+FFI crossing (~1.2x, decision matrix). Re-encoded to hex to match
45
- // castrum's hex-return contract (64 lowercase-hex bytes — same as the
46
- // addon). Node keeps the FFI path (`hmacSha256` is classified "proven"
47
- // vs the node:crypto baseline). Napi fallback keeps the precompiled-key
48
- // `HmacSigner` (identity semantics).
49
- if (isBun()) {
50
- const hasher = new Bun.CryptoHasher('sha256', key)
51
- hasher.update(data)
52
- return hexEncoder.encode(Buffer.from(hasher.digest()).toString('hex'))
53
- }
54
- const ffi = getBunFFI()
55
- if (ffi) return ffi.hmacSha256(key, data)
50
+ // rust+FFI crossing (~1.2x, decision matrix). Bun returns the 64-char
51
+ // lowercase-hex STRING (native transfer — no TextEncoder round-trip); the
52
+ // addon returns the same hex as bytes. Node keeps the FFI path.
53
+ if (HAS.hmacSha256) return builtins.hmacSha256(key, data)
54
+ const f = ffi()
55
+ if (f) return f.hmacSha256(key, data)
56
56
  return ctx.hmacSigner(key).sign(data)
57
57
  },
58
58
  hmacSha256Into(key: Uint8Array, data: Uint8Array, output: Uint8Array): number {
59
- const ffi = getBunFFI()
60
- if (ffi) return ffi.hmacSha256Into(key, data, output)
61
- const bytes = ctx.hmacSigner(key).sign(data)
62
- if (output.length < bytes.length) throw new Error('hmac sha256: output buffer too small')
63
- output.set(bytes)
64
- return bytes.length
59
+ const f = ffi()
60
+ if (f) return f.hmacSha256Into(key, data, output)
61
+ return writeInto('hmac sha256', output, ctx.hmacSigner(key).sign(data))
65
62
  },
66
- hmacSha256Verify(key: Uint8Array, data: Uint8Array, sig: Uint8Array): boolean {
67
- const ffi = getBunFFI()
68
- if (ffi) return ffi.hmacSha256Verify(key, data, sig)
69
- return ctx.hmacSigner(key).verify(data, sig)
63
+ hmacSha256Verify(key: Uint8Array, data: Uint8Array, sig: Uint8Array | string): boolean {
64
+ // `hmacSha256` returns the signature as a hex STRING under Bun (built-in
65
+ // delegation / cstring) and as hex bytes under napi. The verifier accepts
66
+ // both — normalize the string to hex bytes so the ffi/napi cores see the
67
+ // same input (sign → verify is self-consistent on every transport).
68
+ const sigBytes = toBytes(sig)
69
+ const f = ffi()
70
+ if (f) return f.hmacSha256Verify(key, data, sigBytes)
71
+ return ctx.hmacSigner(key).verify(data, sigBytes)
70
72
  },
71
73
  }
72
74
  }