castrum 0.9.7 → 0.9.9

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 (108) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/castrum.darwin-arm64.node +0 -0
  4. package/castrum.darwin-x64.node +0 -0
  5. package/castrum.linux-arm64-gnu.node +0 -0
  6. package/castrum.linux-arm64-musl.node +0 -0
  7. package/castrum.linux-x64-gnu.node +0 -0
  8. package/castrum.linux-x64-musl.node +0 -0
  9. package/castrum.linux-x64-v3-gnu.node +0 -0
  10. package/castrum.win32-x64-msvc.node +0 -0
  11. package/dist/index.d.ts +664 -8
  12. package/dist/index.js +2014 -1327
  13. package/docs/BENCHMARKS.md +13 -0
  14. package/docs/CASE_STUDY.md +1 -1
  15. package/docs/ENVIRONMENT.md +8 -1
  16. package/docs/FFI_BUN_GUIDE.md +1 -1
  17. package/docs/INGRESS.md +4 -4
  18. package/docs/REPO_MAP.md +7 -5
  19. package/docs/RND-CONCURRENCY.md +20 -4
  20. package/docs/superpowers/plans/2026-09-18-castrum-hardening-cleanup.md +617 -0
  21. package/docs/superpowers/plans/2026-09-18-elysia-learnings-adoption.md +634 -0
  22. package/index.ts +6 -0
  23. package/package.json +4 -2
  24. package/rust/crypto/aead.rs +44 -0
  25. package/rust/crypto/argon2.rs +32 -2
  26. package/rust/crypto/jwt/tests.rs +19 -0
  27. package/rust/crypto/jwt/token.rs +43 -28
  28. package/rust/crypto/session.rs +6 -0
  29. package/rust/ffi/crypto.rs +63 -32
  30. package/rust/ffi/hashing.rs +48 -33
  31. package/rust/ffi/http.rs +62 -36
  32. package/rust/ffi/metrics.rs +6 -2
  33. package/rust/ffi/payload.rs +47 -27
  34. package/rust/ffi/task.rs +55 -35
  35. package/rust/ffi/tests.rs +24 -0
  36. package/rust/ffi/util.rs +40 -0
  37. package/rust/http/headers.rs +13 -0
  38. package/rust/http/multipart.rs +139 -0
  39. package/rust/ingress/api.rs +21 -5
  40. package/rust/ingress/mod.rs +7 -0
  41. package/rust/ingress/native_route.rs +25 -5
  42. package/rust/ingress/schema_cache.rs +266 -0
  43. package/rust/task/completion.rs +7 -0
  44. package/rust/task/mod.rs +2 -1
  45. package/rust/task/runtime.rs +55 -7
  46. package/rust/task/tests.rs +128 -1
  47. package/src/ingress/abort.ts +48 -0
  48. package/src/ingress/body.ts +104 -8
  49. package/src/ingress/decode/baked-result.ts +13 -5
  50. package/src/ingress/decode/fast-result.ts +1 -1
  51. package/src/ingress/decode/result-base.ts +16 -2
  52. package/src/ingress/handlers.ts +104 -31
  53. package/src/ingress/headers/memoized-headers.ts +40 -0
  54. package/src/ingress/health.ts +22 -0
  55. package/src/ingress/index.ts +3 -1
  56. package/src/ingress/native-route.ts +25 -6
  57. package/src/ingress/options.ts +26 -2
  58. package/src/ingress/packing/gather-raw-headers.ts +24 -8
  59. package/src/ingress/response/baked-response.ts +118 -28
  60. package/src/ingress/router.ts +50 -3
  61. package/src/ingress/routes/common.ts +35 -11
  62. package/src/ingress/routes/echo.ts +60 -7
  63. package/src/ingress/routes/json-write.ts +32 -8
  64. package/src/ingress/routes/native.ts +24 -5
  65. package/src/ingress/routes/options.ts +6 -2
  66. package/src/ingress/routes/responder.ts +29 -9
  67. package/src/ingress/routes/safe-record.ts +57 -0
  68. package/src/ingress/server-error.ts +111 -0
  69. package/src/ingress/server-node.ts +135 -37
  70. package/src/ingress/server.ts +143 -36
  71. package/src/ingress/shared.ts +1 -1
  72. package/src/ingress/types.ts +15 -0
  73. package/src/integration/pipeline.ts +6 -0
  74. package/src/loader/ops.ts +1 -1
  75. package/src/native/ffi/build/codecs-selftest.ts +305 -0
  76. package/src/native/ffi/build/codecs.ts +0 -296
  77. package/src/native/ffi/build/instances-selftest.ts +264 -0
  78. package/src/native/ffi/build/instances.ts +10 -259
  79. package/src/native/ffi/build/parse-selftest.ts +238 -0
  80. package/src/native/ffi/build/parse.ts +11 -234
  81. package/src/native/ffi/build/task.ts +2 -1
  82. package/src/native/ffi/build.ts +5 -1
  83. package/src/native/ffi/selftest.ts +3 -3
  84. package/src/native/ffi/symbols.ts +460 -0
  85. package/src/native/ffi/types.ts +6 -1
  86. package/src/native/ffi.ts +2 -434
  87. package/src/native/ingress-binding.ts +39 -0
  88. package/src/native/types.ts +6 -0
  89. package/src/runtime/codec.ts +24 -0
  90. package/src/runtime/detect.ts +14 -0
  91. package/src/runtime/types.ts +11 -0
  92. package/src/rust-ffi/batch/build.ts +18 -8
  93. package/src/rust-ffi/batch/types.ts +15 -0
  94. package/src/rust-ffi/context.ts +46 -3
  95. package/src/rust-ffi/options.ts +19 -0
  96. package/src/rust-ffi/scalar/crypto.ts +8 -0
  97. package/src/rust-ffi/scalar/factories.ts +53 -20
  98. package/src/rust-ffi/scalar/http.ts +60 -10
  99. package/src/rust-ffi/scalar/interface.ts +18 -1
  100. package/src/rust-ffi/scalar/payload.ts +27 -1
  101. package/src/rust-ffi/text.ts +5 -4
  102. package/src/shared/buffer-pool.ts +54 -9
  103. package/src/shared/codec.ts +26 -0
  104. package/src/shared/memory.ts +68 -0
  105. package/src/shared/metrics.ts +86 -3
  106. package/src/shared/packed/parsers.ts +64 -7
  107. package/src/shared/runtime.ts +7 -0
  108. package/src/task/runtime.ts +81 -37
package/src/native/ffi.ts CHANGED
@@ -26,6 +26,7 @@
26
26
  // `(view, view.length)` and bun:ffi converts the TypedArray to its pointer.
27
27
 
28
28
  import type { FFITypeOrString } from 'bun:ffi'
29
+ import { buildFFISymbolMap } from './ffi/symbols'
29
30
  import { resolveEnvVar } from '../shared/env'
30
31
  import { isBun } from '../shared/runtime'
31
32
  import { build } from './ffi/build'
@@ -177,440 +178,7 @@ function bind(forcedPath?: string, tried: string[] = []): BunFFI | null {
177
178
  const useBufferLength = probeBufferLength(dlopen, path)
178
179
  bufferAbiMode = useBufferLength ? 'buffer-pair' : 'ptr-len'
179
180
 
180
- // String ABI specs ("ptr"/"usize"/"u32"...): accepted by bun:ffi and typed
181
- // via `FFITypeOrString` in bun-types. usize == uint64 on the host ABI.
182
- // Symbol keys must be the exact `#[no_mangle] extern "C"` names in
183
- // rust/ffi/. Every `(ptr,len)` pair is passed as `(view, view.length)`.
184
- // The 8 input-only single-buffer fns use `inputAbi`; everything else keeps
185
- // `(ptr,len)` because the output length is the caller's write cap.
186
- const inputAbi: readonly FFITypeOrString[] = useBufferLength
187
- ? (['buffer', 'buffer_length'] as unknown as readonly FFITypeOrString[])
188
- : ['ptr', 'usize']
189
- // Phase 1.1: extend `buffer`/`buffer_length` to EVERY `(ptr,len)` pair, not
190
- // just the input-only fns. The engine reads ptr + byteLength off the SAME
191
- // view at call time (atomic snapshot), so call sites pass the view twice
192
- // (see `lenOrView` below). Conversion is POSITIONAL and pair-aware: each
193
- // `ptr` consumes its following `usize` as a length. Scalar args that happen
194
- // to be `usize` (the opaque `inner` handle of `castrum_ingress_handle_packed`
195
- // and the `max` param of gzip/brotli decompress) are passed through
196
- // unchanged — a blind `usize`→`buffer_length` map would corrupt them.
197
- const abi = (shape: readonly string[]): readonly FFITypeOrString[] => {
198
- if (!useBufferLength) return shape as readonly FFITypeOrString[]
199
- const out: FFITypeOrString[] = []
200
- for (let i = 0; i < shape.length; i++) {
201
- const t = shape[i]
202
- if (t === 'ptr') {
203
- out.push('buffer' as unknown as FFITypeOrString)
204
- out.push('buffer_length' as unknown as FFITypeOrString)
205
- i++ // consume the paired `usize`
206
- } else {
207
- out.push(t as FFITypeOrString)
208
- }
209
- }
210
- return out
211
- }
212
- // `u64_fast` returns a plain `number` when the value fits (< 2^53) instead
213
- // of a BigInt — bun-types lacks the literal, so bind it via a typed const
214
- // (same cast pattern as the `buffer_length` probe above). Every bytes-written
215
- // return fits in a double, so this removes the per-call BigInt boxing on the
216
- // hot scalar / `*_into` / packed-parser / ingress calls.
217
- const U64_FAST = 'u64_fast' as unknown as FFITypeOrString
218
- const { symbols } = dlopen(path, {
219
- castrum_crc32: { args: inputAbi, returns: 'u32' },
220
- castrum_fnv1a64: { args: inputAbi, returns: 'u64' },
221
- castrum_xxh3: { args: inputAbi, returns: 'u64' },
222
- castrum_json_valid: { args: inputAbi, returns: 'u8' },
223
- castrum_utf8_valid: { args: inputAbi, returns: 'u8' },
224
- castrum_hex_encode: { args: abi(['ptr', 'usize', 'ptr', 'usize']), returns: U64_FAST },
225
- castrum_hex_decode: { args: abi(['ptr', 'usize', 'ptr', 'usize']), returns: U64_FAST },
226
- castrum_url_encode: { args: abi(['ptr', 'usize', 'ptr', 'usize']), returns: U64_FAST },
227
- castrum_url_decode: { args: abi(['ptr', 'usize', 'ptr', 'usize']), returns: U64_FAST },
228
- // String-input validators: `cstring` ARG (the engine transcodes the JS
229
- // string in-engine — no JS-side encode; Rust borrows via CStr::from_ptr).
230
- // The `_bytes` siblings take a `(ptr,len)` pair — zero transcode when
231
- // the caller already holds bytes (a cstring arg would force a decode +
232
- // engine re-encode; docs/FFI_BUN_GUIDE.md §3 shape 4).
233
- //
234
- // SAFETY RULE for every `cstring` ARG added here: the transcode is
235
- // NUL-TERMINATED, so an embedded U+0000 truncates the value in-engine. A
236
- // verdict op bound this way can be fooled (`validateEmail` on
237
- // 'a@b.com\0<script>' returned TRUE before the byte re-route). Either
238
- // give the op a `_bytes` sibling and route byte callers to it, or guard
239
- // the string form with `hasNul` — see docs/FFI_BUN_GUIDE.md §6.1,
240
- // pinned by test/unit/native/cstring-nul.test.ts.
241
- castrum_validate_email: { args: ['cstring'], returns: 'u8' },
242
- castrum_validate_uuid: { args: ['cstring'], returns: 'u8' },
243
- castrum_validate_ipv4: { args: ['cstring'], returns: 'u8' },
244
- castrum_validate_ipv6: { args: ['cstring'], returns: 'u8' },
245
- castrum_validate_email_bytes: { args: inputAbi, returns: 'u8' },
246
- castrum_validate_uuid_bytes: { args: inputAbi, returns: 'u8' },
247
- castrum_validate_ipv4_bytes: { args: inputAbi, returns: 'u8' },
248
- castrum_validate_ipv6_bytes: { args: inputAbi, returns: 'u8' },
249
- castrum_json_sum_ids: { args: abi(['ptr', 'usize', 'ptr', 'usize']), returns: U64_FAST },
250
- castrum_hmac_sha256_verify: {
251
- args: abi(['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize']),
252
- returns: 'u8',
253
- },
254
- castrum_csrf_verify: { args: abi(['ptr', 'usize', 'ptr', 'usize']), returns: 'u8' },
255
- castrum_password_verify: { args: abi(['ptr', 'usize', 'ptr', 'usize']), returns: 'u8' },
256
- castrum_password_verify_bcrypt: {
257
- // `phc` crosses as a `cstring` ARG; `password` stays a `(ptr,len)` pair.
258
- args: abi(['ptr', 'usize', 'cstring']),
259
- returns: 'u8',
260
- },
261
- castrum_ws_accept_key: { args: ['cstring'], returns: 'cstring' },
262
- castrum_ws_accept_key_into: {
263
- args: abi(['ptr', 'usize', 'ptr', 'usize']),
264
- returns: U64_FAST,
265
- },
266
- castrum_etag: { args: abi(['ptr', 'usize', 'u8']), returns: 'cstring' },
267
- castrum_etag_into: { args: abi(['ptr', 'usize', 'u8', 'ptr', 'usize']), returns: U64_FAST },
268
- castrum_conditional_is_not_modified: {
269
- args: abi(['usize', 'ptr', 'usize', 'ptr', 'usize', 'u8']),
270
- returns: 'u8',
271
- },
272
- castrum_media_type_matcher_matches: {
273
- args: abi(['usize', 'ptr', 'usize']),
274
- returns: 'u8',
275
- },
276
- castrum_accept_negotiator_negotiate: {
277
- args: abi(['usize', 'ptr', 'usize']),
278
- returns: 'cstring',
279
- },
280
- // Server-preference sibling (RFC 7231 server semantics): `header` is a
281
- // `cstring` ARG — the engine transcodes the JS header string in-engine.
282
- castrum_accept_negotiator_negotiate_server: {
283
- args: ['usize', 'cstring'],
284
- returns: 'cstring',
285
- },
286
- castrum_jwt_signer_sign: {
287
- args: abi(['usize', 'ptr', 'usize', 'i64', 'ptr', 'usize']),
288
- returns: U64_FAST,
289
- },
290
- castrum_jwt_signer_verify: {
291
- args: abi(['usize', 'ptr', 'usize', 'i64', 'ptr', 'usize']),
292
- returns: U64_FAST,
293
- },
294
- castrum_template_render: {
295
- args: abi(['usize', 'ptr', 'usize', 'ptr', 'usize']),
296
- returns: U64_FAST,
297
- },
298
- castrum_schema_validator_validate: {
299
- args: abi(['usize', 'ptr', 'usize']),
300
- returns: 'u8',
301
- },
302
- castrum_rate_limiter_check: {
303
- // `key` crosses as a `cstring` ARG (text, no NUL in the intended use).
304
- args: abi(['usize', 'cstring', 'i64', 'ptr', 'usize']),
305
- returns: U64_FAST,
306
- },
307
- castrum_rate_limiter_check_key: {
308
- args: abi(['usize', 'i64', 'i64', 'ptr', 'usize']),
309
- returns: U64_FAST,
310
- },
311
- castrum_random_token: { args: ['u32'], returns: 'cstring' },
312
- castrum_random_token_into: { args: ['u32', ...abi(['ptr', 'usize'])], returns: U64_FAST },
313
- castrum_base64_encode: {
314
- args: abi(['ptr', 'usize', 'ptr', 'usize', 'u8', 'u8']),
315
- returns: U64_FAST,
316
- },
317
- castrum_base64_decode: {
318
- args: abi(['ptr', 'usize', 'ptr', 'usize', 'u8', 'u8']),
319
- returns: U64_FAST,
320
- },
321
- castrum_hmac_sha256: {
322
- args: abi(['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize']),
323
- returns: U64_FAST,
324
- },
325
- castrum_sign_cookie: { args: abi(['ptr', 'usize', 'ptr', 'usize']), returns: 'cstring' },
326
- castrum_sign_cookie_into: {
327
- args: abi(['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize']),
328
- returns: U64_FAST,
329
- },
330
- castrum_verify_cookie: { args: abi(['ptr', 'usize', 'ptr', 'usize']), returns: 'cstring' },
331
- castrum_verify_cookie_into: {
332
- args: abi(['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize']),
333
- returns: U64_FAST,
334
- },
335
- castrum_csrf_token: { args: abi(['ptr', 'usize']), returns: 'cstring' },
336
- castrum_csrf_token_into: { args: abi(['ptr', 'usize', 'ptr', 'usize']), returns: U64_FAST },
337
- castrum_password_hash: {
338
- args: abi(['ptr', 'usize', 'ptr', 'usize', 'u32', 'u32', 'u32', 'u32', 'ptr', 'usize']),
339
- returns: U64_FAST,
340
- },
341
- castrum_password_hash_bcrypt: {
342
- args: abi(['ptr', 'usize', 'u32', 'ptr', 'usize']),
343
- returns: U64_FAST,
344
- },
345
- castrum_pbkdf2_sha256: {
346
- args: abi(['ptr', 'usize', 'ptr', 'usize', 'u32', 'u32', 'ptr', 'usize']),
347
- returns: U64_FAST,
348
- },
349
- castrum_aead_encrypt: {
350
- args: abi(['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize', 'u8', 'ptr', 'usize']),
351
- returns: U64_FAST,
352
- },
353
- castrum_aead_decrypt: {
354
- args: abi(['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize', 'u8', 'ptr', 'usize']),
355
- returns: U64_FAST,
356
- },
357
- castrum_ws_frame_encode: {
358
- args: abi(['u8', 'ptr', 'usize', 'u8', 'u8', 'ptr', 'usize']),
359
- returns: U64_FAST,
360
- },
361
- castrum_json_patch: {
362
- args: abi(['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize']),
363
- returns: U64_FAST,
364
- },
365
- castrum_gzip_compress: {
366
- args: abi(['ptr', 'usize', 'u32', 'ptr', 'usize']),
367
- returns: U64_FAST,
368
- },
369
- castrum_gzip_decompress: {
370
- args: abi(['ptr', 'usize', 'usize', 'ptr', 'usize']),
371
- returns: U64_FAST,
372
- },
373
- castrum_brotli_compress: {
374
- args: abi(['ptr', 'usize', 'u32', 'ptr', 'usize']),
375
- returns: U64_FAST,
376
- },
377
- castrum_brotli_decompress: {
378
- args: abi(['ptr', 'usize', 'usize', 'ptr', 'usize']),
379
- returns: U64_FAST,
380
- },
381
- castrum_gzip_isize: { args: abi(['ptr', 'usize']), returns: 'u32' },
382
- castrum_http_parse_request_packed: {
383
- args: abi(['ptr', 'usize', 'ptr', 'usize']),
384
- returns: U64_FAST,
385
- },
386
- castrum_query_parse_packed: {
387
- args: abi(['ptr', 'usize', 'ptr', 'usize']),
388
- returns: U64_FAST,
389
- },
390
- castrum_cookie_parse_packed: {
391
- args: abi(['ptr', 'usize', 'ptr', 'usize']),
392
- returns: U64_FAST,
393
- },
394
- castrum_http_date_into: { args: ['f64', ...abi(['ptr', 'usize'])], returns: U64_FAST },
395
- castrum_sse_encode_into: {
396
- // event/id stay `(ptr,len)` byte args. A cstring-ARG conversion was
397
- // tried (2026-08-23) and reverted: the win was unproven once the
398
- // codec encode path got faster, and ABI-desync segfaults during the
399
- // experiment were misread as an engine bug — see docs/FFI_BUN_GUIDE.md
400
- // §14 before touching this signature.
401
- args: abi(['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize', 'u8', 'u32', 'ptr', 'usize']),
402
- returns: U64_FAST,
403
- },
404
- // ── Excluded-surface additions ──
405
- castrum_jwt_sign_bytes: {
406
- args: abi(['ptr', 'usize', 'ptr', 'usize', 'i64', 'i64']),
407
- returns: 'cstring',
408
- },
409
- castrum_jwt_sign_bytes_into: {
410
- args: abi(['ptr', 'usize', 'ptr', 'usize', 'i64', 'i64', 'ptr', 'usize']),
411
- returns: U64_FAST,
412
- },
413
- // ── Ed25519 / EdDSA JWT (RBAC auth) — bound + self-tested; the public
414
- // surface is deferred to the auth-module consumer ────────────────
415
- castrum_ed25519_generate_keypair: {
416
- args: abi(['ptr', 'usize']),
417
- returns: U64_FAST,
418
- },
419
- castrum_ed25519_sign: {
420
- args: abi(['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize']),
421
- returns: U64_FAST,
422
- },
423
- castrum_ed25519_verify: {
424
- args: abi(['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize']),
425
- returns: 'u32',
426
- },
427
- castrum_jwt_eddsa_sign: {
428
- args: abi(['ptr', 'usize', 'ptr', 'usize', 'i64', 'i64']),
429
- returns: 'cstring',
430
- },
431
- castrum_jwt_eddsa_verify: {
432
- args: abi(['ptr', 'usize', 'ptr', 'usize', 'i64']),
433
- returns: 'cstring',
434
- },
435
- castrum_ws_frame_decode_packed: {
436
- args: abi(['ptr', 'usize', 'ptr', 'usize']),
437
- returns: U64_FAST,
438
- },
439
- castrum_multipart_parse_packed: {
440
- args: abi(['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize']),
441
- returns: U64_FAST,
442
- },
443
- castrum_form_parse_packed: { args: abi(['ptr', 'usize', 'ptr', 'usize']), returns: U64_FAST },
444
- // ── Phase 3 — stateless napi-only scalars (now FFI) ──────────
445
- // Structural parse: parses ONCE, emits a packed token stream with a
446
- // deduped string table so JS assembles the value with NO second parse.
447
- castrum_json_parse_packed: {
448
- args: abi(['ptr', 'usize', 'ptr', 'usize']),
449
- returns: U64_FAST,
450
- },
451
- castrum_parse_media_type: {
452
- args: abi(['ptr', 'usize', 'ptr', 'usize']),
453
- returns: U64_FAST,
454
- },
455
- castrum_parse_http_date: {
456
- args: abi(['ptr', 'usize', 'ptr', 'usize']),
457
- returns: U64_FAST,
458
- },
459
- castrum_parse_accept_encoding: {
460
- args: abi(['ptr', 'usize', 'ptr', 'usize']),
461
- returns: U64_FAST,
462
- },
463
- castrum_url_encode_query: { args: abi(['ptr', 'usize']), returns: 'cstring' },
464
- castrum_url_resolve: {
465
- args: abi(['ptr', 'usize', 'ptr', 'usize']),
466
- returns: 'cstring',
467
- },
468
- castrum_url_builder_resolve: {
469
- args: abi(['usize', 'ptr', 'usize', 'ptr', 'usize']),
470
- returns: U64_FAST,
471
- },
472
- castrum_mime_from_extension: { args: ['cstring'], returns: 'cstring' },
473
- castrum_jwt_verify: {
474
- args: abi(['ptr', 'usize', 'ptr', 'usize', 'i64']),
475
- returns: 'cstring',
476
- },
477
- castrum_ingress_handle_packed: {
478
- args: abi(['usize', 'ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize']),
479
- returns: U64_FAST,
480
- },
481
- // Raw-components sibling of `castrum_ingress_handle_packed`: `url`/`ip`
482
- // are `cstring` ARGs (Bun transcodes the JS strings to call-scoped
483
- // NUL-terminated buffers in-engine — no JS-side Buffer.write encode);
484
- // rid/headers/body are `(ptr,len)` byte slices; out is `(ptr,len)`.
485
- castrum_ingress_handle_components: {
486
- args: abi([
487
- 'usize',
488
- 'u8',
489
- 'cstring',
490
- 'cstring',
491
- 'ptr',
492
- 'usize',
493
- 'ptr',
494
- 'usize',
495
- 'ptr',
496
- 'usize',
497
- 'ptr',
498
- 'usize',
499
- ]),
500
- returns: U64_FAST,
501
- },
502
- castrum_ingress_layout: { args: abi(['ptr', 'usize']), returns: U64_FAST },
503
- // Per-route native stack (`@ignex/native` also dlopens these exact names
504
- // itself; castrum binds them for the bind-time self-test + parity guard).
505
- castrum_route_compile: { args: abi(['ptr', 'usize']), returns: U64_FAST },
506
- castrum_route_run: {
507
- args: abi(['usize', 'ptr', 'usize', 'ptr', 'usize']),
508
- returns: U64_FAST,
509
- },
510
- castrum_route_destroy: { args: ['usize'], returns: 'void' },
511
- // ── Batch fixed-width hex validation (ObjectId-shaped ids, etc.) ──
512
- // NEWLINE-separated lines in; one verdict byte (1/0) per line out.
513
- castrum_hex_validate_batch: {
514
- args: abi(['ptr', 'usize', 'u32', 'ptr', 'usize']),
515
- returns: U64_FAST,
516
- },
517
- // ── JS-RegExp metacharacter escaping (needed-size convention) ──
518
- castrum_regex_escape: { args: abi(['ptr', 'usize', 'ptr', 'usize']), returns: U64_FAST },
519
- // ── Metrics registry (`castrum_metrics_*`) ──────────────────────
520
- // Caller-owned handle (route-stack ownership model): create returns a
521
- // Box<Registry> as usize; declare fns take name/label-keys as cstring
522
- // ARGS (label keys `\x1f`-separated) and return the u32 series id
523
- // (0xFFFFFFFF = error); record/set cross the packed label VALUES as a
524
- // `(ptr,len)` pair + the amount as f64.
525
- castrum_metrics_create: { args: [], returns: U64_FAST },
526
- castrum_metrics_counter: { args: ['usize', 'cstring', 'cstring'], returns: 'u32' },
527
- castrum_metrics_gauge: { args: ['usize', 'cstring', 'cstring'], returns: 'u32' },
528
- castrum_metrics_histogram: {
529
- args: ['usize', 'cstring', 'cstring', 'cstring'],
530
- returns: 'u32',
531
- },
532
- castrum_metrics_record: {
533
- args: abi(['usize', 'u32', 'ptr', 'usize', 'f64']),
534
- returns: 'u8',
535
- },
536
- castrum_metrics_gauge_set: {
537
- args: abi(['usize', 'u32', 'ptr', 'usize', 'f64']),
538
- returns: 'u8',
539
- },
540
- // Zero-encode siblings: the joined label values cross as ONE `cstring`
541
- // ARG — the engine transcodes the JS string in-engine (no TextEncoder).
542
- castrum_metrics_record_str: {
543
- args: ['usize', 'u32', 'cstring', 'f64'],
544
- returns: 'u8',
545
- },
546
- castrum_metrics_gauge_set_str: {
547
- args: ['usize', 'u32', 'cstring', 'f64'],
548
- returns: 'u8',
549
- },
550
- // Zero-copy text escape: cstring ARG in, cstring return out — the JS
551
- // side does zero encode AND zero decode (engine clones at return).
552
- castrum_regex_escape_str: { args: ['cstring'], returns: 'cstring' },
553
- // String-input batch hex validation: `ids.join('\n')` crosses as a
554
- // cstring ARG.
555
- castrum_hex_validate_batch_str: {
556
- args: abi(['cstring', 'u32', 'ptr', 'usize']),
557
- returns: U64_FAST,
558
- },
559
- castrum_metrics_render: { args: abi(['usize', 'ptr', 'usize']), returns: U64_FAST },
560
- castrum_metrics_snapshot: { args: abi(['usize', 'ptr', 'usize']), returns: U64_FAST },
561
- castrum_metrics_record_batch: {
562
- args: abi(['usize', 'ptr', 'usize']),
563
- returns: 'u8',
564
- },
565
- // Fused wire-level validation: RAW query/cookie → JSON → draft-07 gate.
566
- // `inner` is the SchemaValidator inner handle; strings cross as cstring
567
- // ARGs (engine-transcoded).
568
- castrum_query_validate: { args: ['usize', 'cstring'], returns: 'u8' },
569
- castrum_cookie_validate: { args: ['usize', 'cstring'], returns: 'u8' },
570
- // Fused session envelope: seal builds+signs; open verifies + extracts.
571
- castrum_session_seal: {
572
- args: ['cstring', 'cstring', 'i64', 'cstring'],
573
- returns: 'cstring',
574
- },
575
- castrum_session_open: {
576
- args: abi(['cstring', 'cstring', 'ptr', 'usize']),
577
- returns: U64_FAST,
578
- },
579
- // Byte-arg siblings of the pair above: identical core and wire format,
580
- // but every INPUT crosses as `(ptr,len)`. A `cstring` ARG costs the
581
- // engine a transcode per arg AND silently truncates at an embedded
582
- // U+0000 — reachable here because `id`/`data_json` are user-derived.
583
- castrum_session_seal_bytes: {
584
- args: abi(['ptr', 'usize', 'ptr', 'usize', 'i64', 'ptr', 'usize']),
585
- returns: 'cstring',
586
- },
587
- castrum_session_open_bytes: {
588
- args: abi(['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize']),
589
- returns: U64_FAST,
590
- },
591
- // Off-thread task runtime: `submit` takes a packed `(ptr,len)` arg blob
592
- // plus a JS-assigned id; `drain` writes the packed completion batch into
593
- // a caller buffer (needed-size convention). `set_doorbell` takes the raw
594
- // `JSCallback({threadsafe:true}).ptr` as a bare pointer (`abi` leaves a
595
- // lone `ptr` untouched), so the pool can wake JS once per batch.
596
- castrum_task_init: { args: ['u32'], returns: 'u32' },
597
- castrum_task_submit: { args: abi(['u32', 'ptr', 'usize', 'usize']), returns: 'u32' },
598
- castrum_task_submit_slice: {
599
- args: abi(['u32', 'ptr', 'usize', 'ptr', 'usize', 'usize']),
600
- returns: 'u32',
601
- },
602
- castrum_task_submit_out: {
603
- args: abi(['u32', 'ptr', 'usize', 'usize', 'ptr', 'usize']),
604
- returns: 'u32',
605
- },
606
- castrum_task_drain: { args: abi(['ptr', 'usize']), returns: U64_FAST },
607
- castrum_task_pending: { args: [], returns: 'u32' },
608
- castrum_task_cancel: { args: ['usize'], returns: 'u32' },
609
- castrum_task_set_doorbell: { args: ['ptr'], returns: 'u32' },
610
- castrum_task_shutdown: { args: [], returns: 'u32' },
611
- castrum_task_threads: { args: [], returns: 'u32' },
612
- castrum_metrics_destroy: { args: ['usize'], returns: 'void' },
613
- })
181
+ const { symbols } = dlopen(path, buildFFISymbolMap(useBufferLength))
614
182
 
615
183
  // (`as unknown as`: the zero-arg `castrum_metrics_create` infers
616
184
  // `(...args: never[])`, which can't overlap `(...a: unknown[])` directly.)
@@ -0,0 +1,39 @@
1
+ // src/native/ingress-binding.ts — shared self-tested ingress transport for framework adapters.
2
+
3
+ import { type BunFFI, ffiAddonPath, getBunFFI } from './ffi'
4
+
5
+ /**
6
+ * Synchronous ingress writers backed by castrum's process-lifetime FFI binding.
7
+ * Handles MUST originate from addonPath and remain owned/alive while in use.
8
+ * Writers throw on native failure; a return larger than output.byteLength is
9
+ * the exact required capacity, not a completed write. No buffer is retained.
10
+ */
11
+ export type IngressBinding = Pick<
12
+ BunFFI,
13
+ 'ingressHandleComponents' | 'ingressHandlePacked' | 'ingressLayout'
14
+ > & { readonly addonPath: string }
15
+
16
+ let cached: IngressBinding | null | undefined
17
+
18
+ /**
19
+ * Share castrum's self-tested ingress writers without a second dlopen/map.
20
+ * @returns The binding and actual binary path, or null when FFI is unavailable.
21
+ * @example
22
+ * const binding = getIngressBinding()
23
+ * // Verify binding.addonPath matches the addon that owns your ingress handle.
24
+ */
25
+ export function getIngressBinding(): IngressBinding | null {
26
+ if (cached !== undefined) return cached
27
+ const ffi = getBunFFI()
28
+ const addonPath = ffiAddonPath()
29
+ cached =
30
+ ffi && addonPath
31
+ ? {
32
+ addonPath,
33
+ ingressHandleComponents: ffi.ingressHandleComponents,
34
+ ingressHandlePacked: ffi.ingressHandlePacked,
35
+ ingressLayout: ffi.ingressLayout,
36
+ }
37
+ : null
38
+ return cached
39
+ }
@@ -344,6 +344,12 @@ export interface NativeAddon {
344
344
 
345
345
  initThreadPool(rayonThreads?: number): void
346
346
  rayonNumThreads(): number
347
+ /**
348
+ * Drop the process-wide compiled request-body schema cache (identical schema
349
+ * bytes are compiled once and shared across ingress instances/routes).
350
+ * Maintenance hook for the public `flushMemory()`; idempotent.
351
+ */
352
+ clearSchemaCache(): void
347
353
 
348
354
  jsonValidBatchPacked(input: Uint8Array): Uint8Array
349
355
  validateEmailBatchPacked(input: Uint8Array): Uint8Array
@@ -79,6 +79,7 @@ function createBunCodec(): Utf8Codec {
79
79
  // ffi surface). This fallback matches the historical behavior.
80
80
  decodeUtf8Fatal: decodeUtf8Bun,
81
81
  decodeUtf8Range: decodeUtf8RangeBun,
82
+ decodeUtf8RangeView: decodeUtf8RangeViewBun,
82
83
  }
83
84
  }
84
85
 
@@ -154,6 +155,21 @@ function decodeUtf8RangeBun(bytes: Uint8Array, start: number, end: number): stri
154
155
  return view.toString('latin1', base + start, base + end)
155
156
  }
156
157
 
158
+ /**
159
+ * View-based ranged decode (Bun): same ASCII-scan + latin1/utf8 split as
160
+ * `decodeUtf8RangeBun`, but the caller supplies the bounded Buffer view so the
161
+ * WeakMap lookup + `byteOffset` arithmetic happen once per packed RESULT, not
162
+ * once per field. Offsets are relative to the view (0 = view start).
163
+ */
164
+ function decodeUtf8RangeViewBun(view: Buffer, start: number, end: number): string {
165
+ for (let i = start; i < end; i++) {
166
+ if ((view[i] ?? 0) >= 0x80) {
167
+ return view.toString('utf8', start, end)
168
+ }
169
+ }
170
+ return view.toString('latin1', start, end)
171
+ }
172
+
157
173
  // ── Node codec ────────────────────────────────────────────────────
158
174
  function createNodeCodec(): Utf8Codec {
159
175
  return {
@@ -179,6 +195,14 @@ function createNodeCodec(): Utf8Codec {
179
195
  }
180
196
  return view.toString('latin1', start, end)
181
197
  },
198
+ decodeUtf8RangeView(view: Buffer, start: number, end: number): string {
199
+ for (let i = start; i < end; i++) {
200
+ if ((view[i] ?? 0) >= 0x80) {
201
+ return view.toString('utf8', start, end)
202
+ }
203
+ }
204
+ return view.toString('latin1', start, end)
205
+ },
182
206
  }
183
207
  }
184
208
 
@@ -27,6 +27,20 @@ export function isNodeRuntime(): boolean {
27
27
  return detectedRuntime === 'node'
28
28
  }
29
29
 
30
+ /**
31
+ * Request a synchronous full garbage collection when the host runtime exposes
32
+ * one. On Bun this is `Bun.gc(true)`; on Node it is `globalThis.gc?.()`, which
33
+ * is only defined when the process runs with `--expose-gc`. A no-op otherwise.
34
+ */
35
+ export function forceGc(): void {
36
+ if (detectedRuntime === 'bun') {
37
+ Bun.gc(true)
38
+ return
39
+ }
40
+ // Node only defines `globalThis.gc` under `--expose-gc`.
41
+ ;(globalThis as { gc?: () => void }).gc?.()
42
+ }
43
+
30
44
  /** The Node.js major version when running under Node, otherwise `null`. */
31
45
  export function nodeMajorVersion(): number | null {
32
46
  if (detectedRuntime !== 'node') return null
@@ -38,6 +38,17 @@ export interface Utf8Codec {
38
38
  * a latin1 fast path (~2x on Bun); multi-byte ranges fall back to UTF-8.
39
39
  */
40
40
  decodeUtf8Range(bytes: Uint8Array, start: number, end: number): string
41
+ /**
42
+ * Decode the byte RANGE `[start, end)` of a PREPARED bounded `Buffer` view
43
+ * using ABSOLUTE offsets into the view (0 = the view's first byte).
44
+ * `unpackMediaType` resolves the WeakMap/Buffer view ONCE per packed result
45
+ * and calls this per field — removing the per-field cache lookup + view
46
+ * arithmetic. Identical semantics to `decodeUtf8Range` (replacement mode,
47
+ * ASCII latin1 fast path); offsets are relative to the view, not the view's
48
+ * backing buffer. Optional: runtimes/tests may leave it undefined — callers
49
+ * fall back to `decodeUtf8Range` on the original bytes.
50
+ */
51
+ decodeUtf8RangeView?: (view: Buffer, start: number, end: number) => string
41
52
  }
42
53
 
43
54
  /** UUIDv7 generation (Bun built-in vs crypto.randomUUID on Node). */
@@ -22,6 +22,7 @@ import {
22
22
  withPackScratch2,
23
23
  } from '../../shared/packed'
24
24
  import { type RustClientContext, resolvePoolNative } from '../context'
25
+ import { assertNonEmptySecret } from '../options'
25
26
  import type { RustBatch } from './types'
26
27
 
27
28
  // Native batch fns are process-wide singletons (one lazy addon). `resolvePoolNative`
@@ -129,11 +130,18 @@ export function buildBatch(ctx: RustClientContext): RustBatch {
129
130
  via(ctx, 'brotliCompressBatchPacked', unpackByteResults, [quality ?? null])(items),
130
131
  brotliDecompress: (items, maxDecompressed) =>
131
132
  via(ctx, 'brotliDecompressBatchPacked', unpackByteResults, [maxDecompressed ?? null])(items),
132
- jwtVerify: (tokens, secret, nowSeconds) =>
133
- via(ctx, 'jwtVerifyBatchPacked', unpackBitset, [secret, nowSeconds])(tokens),
134
- hmacSha256: (items, key) => via(ctx, 'hmacSha256BatchPacked', unpackByteResults, [key])(items),
135
- hmacSha256Verify: (items, sigs, key) =>
136
- via2(ctx, 'hmacSha256VerifyBatchPacked', unpackBitset, [key])(items, sigs),
133
+ jwtVerify: (tokens, secret, nowSeconds) => {
134
+ assertNonEmptySecret(secret, 'batch.jwtVerify')
135
+ return via(ctx, 'jwtVerifyBatchPacked', unpackBitset, [secret, nowSeconds])(tokens)
136
+ },
137
+ hmacSha256: (items, key) => {
138
+ assertNonEmptySecret(key, 'batch.hmacSha256')
139
+ return via(ctx, 'hmacSha256BatchPacked', unpackByteResults, [key])(items)
140
+ },
141
+ hmacSha256Verify: (items, sigs, key) => {
142
+ assertNonEmptySecret(key, 'batch.hmacSha256Verify')
143
+ return via2(ctx, 'hmacSha256VerifyBatchPacked', unpackBitset, [key])(items, sigs)
144
+ },
137
145
  // ── Two-list ──
138
146
  passwordVerify: (items, phcs) =>
139
147
  via2(ctx, 'passwordVerifyBatchPacked', unpackBitset)(items, phcs),
@@ -141,12 +149,14 @@ export function buildBatch(ctx: RustClientContext): RustBatch {
141
149
  via2(ctx, 'urlResolveBatchPacked', unpackByteResults)(bases, references),
142
150
  templateRender: (source, contexts) =>
143
151
  via(ctx, 'templateRenderBatchPacked', unpackByteResults, [source])(contexts),
144
- jwtSign: (items, secret, ttlSeconds, nowSeconds) =>
145
- via(ctx, 'jwtSignBatchPacked', unpackByteResults, [
152
+ jwtSign: (items, secret, ttlSeconds, nowSeconds) => {
153
+ assertNonEmptySecret(secret, 'batch.jwtSign')
154
+ return via(ctx, 'jwtSignBatchPacked', unpackByteResults, [
146
155
  secret,
147
156
  ttlSeconds ?? null,
148
157
  nowSeconds ?? Math.floor(Date.now() / 1000),
149
- ])(items),
158
+ ])(items)
159
+ },
150
160
  sseEncode: (items, event, id, retry) =>
151
161
  via(ctx, 'sseEncodeBatchPacked', unpackByteResults, [
152
162
  event ?? null,