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
package/src/selection.ts CHANGED
@@ -8,13 +8,23 @@
8
8
  //
9
9
  // Consumers bind each operation to a fixed implementation at startup by
10
10
  // reading `opImpl(op)` here — they do NOT swap native↔js per call.
11
+ //
12
+ // The baked default comes from the PROVEN SELECTION registry
13
+ // (`src/shared/proven.ts`, `PROVEN_SELECTION`): for each op the
14
+ // benchmark-proven winner is 'native' | 'js' | 'bun' (Bun built-in
15
+ // delegation). Bun built-in delegation is runtime-aware via the adapter's
16
+ // `builtins` registry (`runtimeNative.builtins.has(op)` — empty under Node).
17
+ // `test/unit/shared/proven.test.ts` verifies this baked registry matches the
18
+ // live wiring, so it can't drift.
11
19
 
12
- import { lazyAddon, getAddon } from './native'
13
- import { isBun } from './shared/runtime'
20
+ import { getAddon, lazyAddon } from './native'
21
+ import { provenImpl } from './shared/proven'
22
+ import { runtimeNative } from './runtime/native'
14
23
 
15
24
  /** Recommended implementation for an operation. */
16
25
  export type OpImpl = 'native' | 'js'
17
26
 
27
+ /** A selection decision: recommended implementation + rationale note. */
18
28
  export interface OpDecision {
19
29
  /** The recommended implementation when the addon is available. */
20
30
  readonly impl: OpImpl
@@ -22,32 +32,6 @@ export interface OpDecision {
22
32
  readonly note?: string
23
33
  }
24
34
 
25
- /**
26
- * Ops where Bun's NATIVE built-in beats the Rust addon (measured in
27
- * `docs/bun-builtins-decision-matrix.md`: `Bun.gzipSync` ~2.0x, `Bun.hash.crc32`
28
- * 2.8–8.4x, `Bun.CryptoHasher` ~1.2x, `Bun.hash.xxHash3` ~4.2x, and
29
- * `crypto.getRandomValues` for token-sized random). Under Bun the fastest
30
- * choice for these is the pure-JS path that DELEGATES to the Bun built-in
31
- * (`"js"`), not the Rust addon — so we never ship something slower than what
32
- * Bun natively provides. Under Node the base benchmark decision stands (Rust
33
- * wins for gzip/random/hmac there).
34
- *
35
- * NOTE: `gzipDecompress` is deliberately NOT in this set — the rust surface
36
- * keeps the native path (with its 64 MiB decompression-bomb cap) even under
37
- * Bun, because `Bun.gunzipSync` has no output-size bound.
38
- */
39
- const BUN_WINS = new Set([
40
- 'gzipCompress',
41
- 'crc32',
42
- 'randomToken',
43
- 'hmacSha256',
44
- 'xxh3',
45
- 'urlEncode',
46
- 'urlDecode',
47
- 'base64Encode',
48
- 'httpDate',
49
- ])
50
-
51
35
  // Lazy: importing this module does NOT dlopen the addon; `opImpl` reads the
52
36
  // baked decision from Rust on first call.
53
37
  const addon = lazyAddon(getAddon)
@@ -58,11 +42,23 @@ const addon = lazyAddon(getAddon)
58
42
  * Returns `"native"` when the Rust addon is the recommended implementation,
59
43
  * `"js"` when a pure-JS (or Bun built-in) implementation wins, or `null` for
60
44
  * unknown ops (or when the addon is unavailable — with no addon there is no
61
- * "native" choice). Under Bun, ops in {@link BUN_WINS} resolve to `"js"` so the
62
- * consumer's JS path delegates to the faster Bun built-in.
45
+ * "native" choice). Under Bun, ops in the adapter's builtins registry resolve
46
+ * to `"js"` so the consumer's JS path delegates to the faster Bun built-in.
47
+ *
48
+ * The default is the BAKED proven selection (`src/shared/proven.ts`):
49
+ * `native` / `js` directly, `bun` resolves to `"js"` via the builtins check
50
+ * above. Unknown ops (and `bun` entries on a non-Bun runtime, where no
51
+ * built-in exists) fall back to the addon's embedded decision
52
+ * (`rust/selection.rs` ← `src/selection.json`).
63
53
  */
64
54
  export const opImpl = (op: string): OpImpl | null => {
65
- if (isBun() && BUN_WINS.has(op)) return 'js'
55
+ // Bun built-in delegation (runtime-aware — the registry is empty under Node).
56
+ if (runtimeNative.builtins.has(op)) return 'js'
57
+ // Baked proven winners are the default selection.
58
+ const proven = provenImpl(op)
59
+ if (proven === 'native') return 'native'
60
+ if (proven === 'js') return 'js'
61
+ // 'bun' on a non-Bun runtime (no built-in) or unknown ops → addon decision.
66
62
  return addon.opImpl(op)
67
63
  }
68
64
 
@@ -7,8 +7,7 @@
7
7
  // observed request size to pre-size future buffers (`adaptive` option), and
8
8
  // consumers can drive their own runtime decisions (batch-vs-loop, delegate
9
9
  // vs-native, cache-size) with a bounded, noise-smoothed estimate.
10
-
11
- export interface AdaptiveEstimateOptions {
10
+ /** Options for an `AdaptiveEstimate`: EWMA smoothing, bounds, starting value. */ export interface AdaptiveEstimateOptions {
12
11
  /**
13
12
  * EWMA smoothing factor in (0, 1]. Higher reacts faster to new samples;
14
13
  * lower smooths more. Default: 0.25.
@@ -11,8 +11,7 @@
11
11
  // hold the buffer past the call). Memory is bounded by `maxBuffers` retained
12
12
  // buffers; under heavy concurrency, extra temporary buffers are allocated and
13
13
  // discarded on release.
14
-
15
- export interface BufferPoolOptions {
14
+ /** Options for configuring a reusable byte-buffer pool. */ export interface BufferPoolOptions {
16
15
  /**
17
16
  * Initial buffer size in bytes. Default: 131072.
18
17
  */
@@ -42,6 +41,7 @@ export interface BufferPoolOptions {
42
41
  adaptive?: boolean
43
42
  }
44
43
 
44
+ /** A borrowed buffer handle handed out by a pool; `release()` returns it. */
45
45
  export interface PooledBuffer {
46
46
  /**
47
47
  * The underlying buffer. Its byteLength is guaranteed to be at least the
@@ -59,6 +59,43 @@ export interface PooledBuffer {
59
59
  release(): void
60
60
  }
61
61
 
62
+ /**
63
+ * Concrete `PooledBuffer` handle.
64
+ *
65
+ * Implemented as a class (not a per-acquire object literal with a getter +
66
+ * closure) so that `acquire()` allocates a single small object instead of an
67
+ * object + getter + closure per request. The hot ingress `run()` path
68
+ * acquires/releases once per request, so this removes one object allocation
69
+ * and one closure allocation from the per-request cost. `release()` lives on
70
+ * the prototype; `pool`/`buffer` are the only per-instance fields.
71
+ *
72
+ * Handles are RECYCLED by the pool (see `BufferPool.freeHandles`): a released
73
+ * handle object is re-pointed at a fresh buffer on the next `acquire()` so the
74
+ * steady state allocates zero handle objects. `buffer` and `released` are the
75
+ * only fields that change across a recycle. Recycling is safe because it only
76
+ * happens after `release()` and every internal + documented usage releases
77
+ * each checkout exactly once; the `released` guard still makes a double
78
+ * release within a single checkout a no-op.
79
+ */
80
+ class PooledBufferHandle implements PooledBuffer {
81
+ readonly pool: BufferPool
82
+ buffer: Uint8Array
83
+ released = false
84
+
85
+ constructor(pool: BufferPool, buffer: Uint8Array) {
86
+ this.pool = pool
87
+ this.buffer = buffer
88
+ }
89
+
90
+ release(): void {
91
+ if (this.released) {
92
+ return
93
+ }
94
+ this.released = true
95
+ this.pool.returnHandle(this)
96
+ }
97
+ }
98
+
62
99
  import { AdaptiveEstimate } from './adaptive'
63
100
 
64
101
  /**
@@ -79,9 +116,13 @@ import { AdaptiveEstimate } from './adaptive'
79
116
  */
80
117
  export class BufferPool {
81
118
  private readonly free: Uint8Array[] = []
119
+ /** Released handle objects available for recycling (bounded — see `maxHandles`). */
120
+ private readonly freeHandles: PooledBufferHandle[] = []
82
121
  private readonly initialSize: number
83
122
  private readonly maxBuffers: number
84
123
  private readonly maxInFlight: number
124
+ /** Upper bound on recycled handle objects retained (peak concurrency). */
125
+ private readonly maxHandles: number
85
126
  private readonly estimate: AdaptiveEstimate | null
86
127
  private created = 0
87
128
  private inFlight = 0
@@ -90,6 +131,7 @@ export class BufferPool {
90
131
  this.initialSize = Math.max(1, Math.floor(options.initialSize ?? 131_072))
91
132
  this.maxBuffers = Math.max(1, Math.floor(options.maxBuffers ?? 16))
92
133
  this.maxInFlight = Math.max(0, Math.floor(options.maxInFlight ?? 0))
134
+ this.maxHandles = this.maxBuffers + Math.max(1, this.maxInFlight)
93
135
  this.estimate =
94
136
  options.adaptive === true
95
137
  ? new AdaptiveEstimate({ alpha: 0.25, min: this.initialSize })
@@ -126,21 +168,34 @@ export class BufferPool {
126
168
  }
127
169
  const buffer = this.take(Math.max(0, Math.floor(minSize)))
128
170
  this.inFlight++
129
- let released = false
130
-
131
- return {
132
- buffer,
133
- get released(): boolean {
134
- return released
135
- },
136
- release: () => {
137
- if (released) {
138
- return
139
- }
140
- released = true
141
- this.inFlight--
142
- this.releaseBuffer(buffer)
143
- },
171
+ // Recycle a released handle object in the steady state instead of
172
+ // allocating a fresh one per acquire (removes one per-request object from
173
+ // the hot ingress `run()` path). Safe: handles enter `freeHandles` only
174
+ // after `release()`, and each checkout is released exactly once by every
175
+ // internal + documented usage. `buffer`/`released` are the only fields
176
+ // that change across a recycle.
177
+ const handle = this.freeHandles.pop()
178
+ if (handle !== undefined) {
179
+ handle.buffer = buffer
180
+ handle.released = false
181
+ return handle
182
+ }
183
+ return new PooledBufferHandle(this, buffer)
184
+ }
185
+
186
+ /**
187
+ * Return a borrowed handle's buffer to the pool (called by
188
+ * `PooledBufferHandle.release`). Internal to the pool — do not call from
189
+ * outside.
190
+ */
191
+ returnHandle(handle: PooledBufferHandle): void {
192
+ this.inFlight--
193
+ this.releaseBuffer(handle.buffer)
194
+ // Recycle the handle object for a later acquire, bounding retention so
195
+ // memory stays proportional to concurrency (peak in-flight), not the
196
+ // cumulative number of acquisitions.
197
+ if (this.freeHandles.length < this.maxHandles) {
198
+ this.freeHandles.push(handle)
144
199
  }
145
200
  }
146
201
 
@@ -1,16 +1,85 @@
1
+ // src/shared/bytes.ts — shared UTF-8 / byte helpers (encoder, decoder, toBytes, toText).
2
+
3
+ import { decodeUtf8, encodeUtf8, encodeUtf8Into } from './codec'
4
+
5
+ /**
6
+ * Shared UTF-8 encoder (encode / encodeInto), backed by the runtime-native
7
+ * codec (`src/shared/codec.ts`): on Bun the work is done by Bun's native
8
+ * transfer machinery (`Bun.ArrayBufferSink` / a zero-copy `Buffer` view
9
+ * write); under Node it falls back to `TextEncoder`. Reusing a single object
10
+ * avoids allocation overhead in hot paths.
11
+ *
12
+ * `encode` accepts the string-or-bytes union the text-returning `rust.*` ops
13
+ * surface (Bun returns strings, Node returns bytes) and normalizes to bytes —
14
+ * a string is UTF-8 encoded, bytes pass through unchanged.
15
+ */
16
+ export const encoder: {
17
+ encode(s: Uint8Array | string): Uint8Array
18
+ encodeInto(s: string, dest: Uint8Array): { read: number; written: number }
19
+ } = {
20
+ encode(s) {
21
+ return typeof s === 'string' ? encodeUtf8(s) : s
22
+ },
23
+ encodeInto(s, dest) {
24
+ const written = encodeUtf8Into(s, dest)
25
+ // `read` mirrors `TextEncoder.encodeInto`'s shape; callers here only use
26
+ // `written` (a truncated write consumes the whole source in our call
27
+ // sites), so this is a documented approximation.
28
+ return { read: s.length, written }
29
+ },
30
+ }
31
+
32
+ /**
33
+ * Shared UTF-8 decoder, backed by the runtime-native codec
34
+ * (`src/shared/codec.ts`): on Bun it uses `new CString(ptr, 0, len)` from
35
+ * `bun:ffi` (JSC-native, exact-length clone); under Node it falls back to
36
+ * `TextDecoder`. Reusing a single object avoids allocation overhead in hot
37
+ * paths.
38
+ *
39
+ * `decode` accepts the string-or-bytes union the text-returning `rust.*` ops
40
+ * surface and normalizes to a string — bytes are UTF-8 decoded, strings pass
41
+ * through unchanged.
42
+ */
43
+ export const decoder: {
44
+ decode(bytes: Uint8Array | string): string
45
+ } = {
46
+ decode(bytes) {
47
+ return typeof bytes === 'string' ? bytes : decodeUtf8(bytes)
48
+ },
49
+ }
50
+
1
51
  /**
2
- * Shared TextEncoder instance for encoding strings to UTF-8 bytes.
52
+ * Normalize the string-or-bytes union (the text-returning `rust.*` ops return
53
+ * STRINGS on the Bun path — native transfer — and bytes on the napi path) to
54
+ * bytes. Codec-backed — no `TextEncoder` runs on the Bun path.
55
+ *
56
+ * @param v - A string or UTF-8 byte buffer.
57
+ * @returns The UTF-8 bytes of `v` (identity when `v` is already bytes).
3
58
  *
4
- * Reusing a single encoder avoids allocation overhead in hot paths.
59
+ * @example
60
+ * ```ts
61
+ * toBytes('aGVsbG8=') // Uint8Array(8)
62
+ * ```
5
63
  */
6
- export const encoder = new TextEncoder()
64
+ export function toBytes(v: Uint8Array | string): Uint8Array {
65
+ return typeof v === 'string' ? encodeUtf8(v) : v
66
+ }
7
67
 
8
68
  /**
9
- * Shared TextDecoder instance for decoding UTF-8 bytes to strings.
69
+ * Normalize the string-or-bytes union to a string. Codec-backed — no
70
+ * `TextDecoder` runs on the Bun path.
10
71
  *
11
- * Reusing a single decoder avoids allocation overhead in hot paths.
72
+ * @param v - A string or UTF-8 byte buffer.
73
+ * @returns The decoded string (identity when `v` is already a string).
74
+ *
75
+ * @example
76
+ * ```ts
77
+ * toText(new Uint8Array([104, 105])) // 'hi'
78
+ * ```
12
79
  */
13
- export const decoder = new TextDecoder()
80
+ export function toText(v: Uint8Array | string): string {
81
+ return typeof v === 'string' ? v : decodeUtf8(v)
82
+ }
14
83
 
15
84
  /**
16
85
  * Cache of `DataView`s keyed by their backing buffer + creation offset.
@@ -0,0 +1,78 @@
1
+ // src/shared/codec.ts — runtime-native UTF-8 codec (facade).
2
+ //
3
+ // The runtime-native UTF-8 codec (Bun transfer machinery vs
4
+ // `TextEncoder`/`TextDecoder`) is implemented in `src/runtime/codec.ts` and
5
+ // selected ONCE at module load by the runtime adapter. This module re-exports
6
+ // it so existing callers are unchanged. The Bun/Node branches live in
7
+ // `src/runtime/codec.ts` — do NOT add `TextEncoder`/`TextDecoder` or `bun:ffi`
8
+ // calls here.
9
+
10
+ import { runtimeCodec } from '../runtime/codec'
11
+
12
+ /**
13
+ * Encode a string to UTF-8 bytes.
14
+ *
15
+ * On Bun this uses `Bun.ArrayBufferSink` (native UTF-8 writing — no
16
+ * `TextEncoder`). Under Node it falls back to the standard `TextEncoder`.
17
+ * The implementation is selected once at load via the runtime adapter.
18
+ *
19
+ * @param s - The string to encode to UTF-8 bytes.
20
+ * @returns A fresh `Uint8Array` of the UTF-8 encoding of `s`.
21
+ *
22
+ * @example
23
+ * ```ts
24
+ * encodeUtf8('hello') // Uint8Array [104, 101, 108, 108, 111]
25
+ * ```
26
+ */
27
+ export const encodeUtf8: (s: string) => Uint8Array = runtimeCodec.encodeUtf8
28
+
29
+ /**
30
+ * Encode a string into `dest` starting at `offset`, returning bytes written.
31
+ *
32
+ * On Bun this writes through a zero-copy `Buffer` view over `dest`'s backing
33
+ * store (native `.write` — never splits a multibyte character); under Node it
34
+ * uses `TextEncoder.prototype.encodeInto`.
35
+ *
36
+ * @param s - The string to encode.
37
+ * @param dest - The destination buffer.
38
+ * @param offset - Byte offset into `dest` at which to start writing. Default 0.
39
+ * @returns The number of UTF-8 bytes written.
40
+ *
41
+ * @example
42
+ * ```ts
43
+ * const out = new Uint8Array(8)
44
+ * encodeUtf8Into('hi', out) // 2; out[0..1] = 104, 105
45
+ * ```
46
+ */
47
+ export const encodeUtf8Into: (s: string, dest: Uint8Array, offset?: number) => number =
48
+ runtimeCodec.encodeUtf8Into
49
+
50
+ /**
51
+ * Decode UTF-8 bytes to a string.
52
+ *
53
+ * On Bun this uses `new CString(ptr(bytes), 0, len)` from `bun:ffi` (JSC-native
54
+ * exact-length clone); under Node it falls back to the standard `TextDecoder`.
55
+ *
56
+ * @param bytes - The UTF-8 bytes to decode.
57
+ * @returns The decoded string (invalid sequences decode to U+FFFD replacement
58
+ * characters, matching `TextDecoder` replacement mode).
59
+ *
60
+ * @example
61
+ * ```ts
62
+ * decodeUtf8(new Uint8Array([104, 105])) // 'hi'
63
+ * ```
64
+ */
65
+ export const decodeUtf8: (bytes: Uint8Array) => string = runtimeCodec.decodeUtf8
66
+
67
+ /**
68
+ * Decode UTF-8 bytes to a string, throwing on invalid UTF-8.
69
+ *
70
+ * Under Node this uses a fatal `TextDecoder`. On Bun (whose `CString` is
71
+ * replacement-mode) strictness is normally enforced by the native
72
+ * `castrum_utf8_valid` C-ABI probe (callers check it first via the transport's
73
+ * ffi surface); this fallback uses the replacement-mode decode.
74
+ *
75
+ * @param bytes - The UTF-8 bytes to decode.
76
+ * @returns The decoded string (throws on invalid UTF-8 under Node).
77
+ */
78
+ export const decodeUtf8Fatal: (bytes: Uint8Array) => string = runtimeCodec.decodeUtf8Fatal
package/src/shared/env.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  // src/shared/env.ts — Centralized environment-variable resolution.
2
2
  //
3
- // Runtime env vars use the `CASTRUM_*` prefix; legacy `RUST_BENCH_*` / `RUST_*`
4
- // names are still read as aliases (see docs/ENVIRONMENT.md). All alias-chain
5
- // reads MUST go through `resolveEnvVar` so the chains can never drift apart
6
- // (they previously lived inline at several call sites with different shapes).
3
+ // Runtime env vars use the `CASTRUM_*` prefix (plus the napi-rs standard
4
+ // `NAPI_RS_NATIVE_LIBRARY_PATH`). All reads MUST go through `resolveEnvVar` so
5
+ // they can never drift apart (they previously lived inline at several call
6
+ // sites with different shapes).
7
7
 
8
8
  /** First set value among `preferred` and its legacy aliases, or `undefined`. */
9
9
  export function resolveEnvVar(
package/src/shared/log.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  // without wiring OpenTelemetry. For full control, use the `onRequest` /
7
7
  // `onResponse` / `onError` runtime hooks instead.
8
8
 
9
+ /** Structured-logging severity levels (least to most severe). */
9
10
  export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'silent'
10
11
 
11
12
  const LEVEL_ORDER: Record<LogLevel, number> = {
@@ -0,0 +1,10 @@
1
+ // src/shared/packed/index.ts — Packed wire-format helpers (barrel).
2
+ //
3
+ // Re-exports the pure wire encode/decode helpers (./wire.ts), the native
4
+ // JSON-Schema validator + batch helpers (./schema.ts), and the ergonomic
5
+ // string parsers (./parsers.ts). Importing this barrel does NOT dlopen the
6
+ // addon — only calling a parser in ./parsers.ts does (lazy).
7
+
8
+ export * from './parsers'
9
+ export * from './schema'
10
+ export * from './wire'
@@ -0,0 +1,31 @@
1
+ // src/shared/packed/parsers.ts — High-level string parsers (convenience).
2
+ //
3
+ // Wrap the native packed parsers + decoders for ergonomic use, so consumers
4
+ // don't have to hand-pack buffers or decode packed pairs:
5
+ // parseQueryString("a=1&b=2") // { a: "1", b: "2" }
6
+ // parseCookieHeader("a=1; b=2") // { a: "1", b: "2" }
7
+
8
+ import { getAddon, lazyAddon } from '../../native'
9
+ import { encoder } from '../bytes'
10
+ import { pairsToObject, readPairsPacked } from './wire'
11
+
12
+ // Lazy: importing this module does not dlopen the addon until first use.
13
+ const addon = lazyAddon(getAddon)
14
+
15
+ /** Parse a query string (`a=1&b=2`) into an object via the native parser. */
16
+ export function parseQueryString(query: string): Record<string, string | string[]> {
17
+ const packed = addon.queryParsePacked(encoder.encode(query))
18
+ return pairsToObject(readPairsPacked(packed))
19
+ }
20
+
21
+ /** Parse a cookie header (`a=1; b=2`) into an object via the native parser. */
22
+ export function parseCookieHeader(header: string): Record<string, string | string[]> {
23
+ const packed = addon.cookieParsePacked(encoder.encode(header))
24
+ return pairsToObject(readPairsPacked(packed))
25
+ }
26
+
27
+ /** Parse an application/x-www-form-urlencoded body into a key/value object. */
28
+ export function parseFormBody(body: Uint8Array): Record<string, string | string[]> {
29
+ const packed = addon.formParsePacked(body)
30
+ return pairsToObject(readPairsPacked(packed))
31
+ }
@@ -0,0 +1,23 @@
1
+ // src/shared/packed/schema.ts — Native JSON-Schema validator + batch helpers.
2
+ //
3
+ // Thin wrapper over the native `SchemaValidator` instance: single-item handle
4
+ // alias plus packed-batch validate helpers (bitset / count). The batch entry
5
+ // points reuse the pure pack-scratch pool from ./wire.ts.
6
+
7
+ import type { SchemaValidatorInstance } from '../../native'
8
+ import { unpackBitset, withPackScratch } from './wire'
9
+
10
+ /** Native JSON-Schema validator handle (see `createSchemaValidator`). */
11
+ export type SchemaValidator = SchemaValidatorInstance
12
+
13
+ /** Validate each item against `validator`; returns a packed validity bitset. */
14
+ export function schemaValidateBatch(validator: SchemaValidator, items: Uint8Array[]): Uint8Array {
15
+ return withPackScratch(items, (packed) =>
16
+ unpackBitset(validator.validateBatchPackedBitset(packed)),
17
+ )
18
+ }
19
+
20
+ /** Validate each item against `validator`; returns the count of valid items. */
21
+ export function schemaValidateBatchCount(validator: SchemaValidator, items: Uint8Array[]): number {
22
+ return withPackScratch(items, (packed) => validator.validateBatchPackedCount(packed))
23
+ }
@@ -1,10 +1,16 @@
1
- import { decoder, encoder, viewForArrayBuffer } from './bytes'
2
- import { getAddon, lazyAddon, type MultipartPart, type SchemaValidatorInstance } from '../native'
3
-
4
- // Lazy: importing this module does not dlopen the addon until first use.
5
- const addon = lazyAddon(getAddon)
6
-
7
- export type SchemaValidator = SchemaValidatorInstance
1
+ // src/shared/packed/wire.ts — Pure packed wire-format encode/decode helpers.
2
+ //
3
+ // The zero-allocation byte layouts shared by the batch/loader/rust-ffi layers:
4
+ // - unpackers: u32 arrays, bitsets, i64/u64 arrays, byte-results, multipart
5
+ // - packers: `[u32 count]{[u32 len][bytes]}` batches + string pairs
6
+ // - decoders: pairs, HTTP request, and the reusable pack-scratch pool
7
+ //
8
+ // This module is PURE (no addon dlopen): consumers that only need these
9
+ // helpers never touch the native layer. The native-backed ergonomic parsers
10
+ // live in ./parsers.ts.
11
+
12
+ import type { MultipartPart } from '../../native'
13
+ import { decoder, encoder, viewForArrayBuffer } from '../bytes'
8
14
 
9
15
  function dataView(bytes: Uint8Array): DataView {
10
16
  return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
@@ -234,8 +240,13 @@ function readMultipartPartsPacked(bytes: Uint8Array): MultipartPart[] {
234
240
  return out
235
241
  }
236
242
 
243
+ /** A decoded `[name, value]` pair from a packed buffer. */
237
244
  export type Pair = [string, string]
238
245
 
246
+ /**
247
+ * Decode a packed `[u32 count][u32 keyLen][key][u32 valLen][val]...` buffer
248
+ * into `[name, value]` string pairs (see `packPairs` for the layout).
249
+ */
239
250
  export function readPairsPacked(bytes: Uint8Array): Pair[] {
240
251
  if (bytes.byteLength < 4) {
241
252
  return []
@@ -270,6 +281,10 @@ export function readPairsPacked(bytes: Uint8Array): Pair[] {
270
281
  return out
271
282
  }
272
283
 
284
+ /**
285
+ * Fold `[name, value]` pairs into an object; repeated names collect into a
286
+ * string array (query-string/cookie semantics).
287
+ */
273
288
  export function pairsToObject(pairs: Pair[]): Record<string, string | string[]> {
274
289
  const out: Record<string, string | string[]> = {}
275
290
 
@@ -288,6 +303,7 @@ export function pairsToObject(pairs: Pair[]): Record<string, string | string[]>
288
303
  return out
289
304
  }
290
305
 
306
+ /** A decoded HTTP request from the native packed parser. */
291
307
  export interface ParsedHttpRequestPacked {
292
308
  method: string
293
309
  path: string
@@ -295,6 +311,10 @@ export interface ParsedHttpRequestPacked {
295
311
  headers: Record<string, string>
296
312
  }
297
313
 
314
+ /**
315
+ * Decode a native `httpParseRequestPacked` buffer into a plain request object
316
+ * (zero-alloc on the Rust side; this builds the JS strings once).
317
+ */
298
318
  export function readHttpPacked(bytes: Uint8Array): ParsedHttpRequestPacked {
299
319
  const dv = dataView(bytes)
300
320
  let offset = 0
@@ -448,6 +468,10 @@ export function withPackScratch2<T>(
448
468
  }
449
469
  }
450
470
 
471
+ /**
472
+ * Pack `[name, value]` string pairs into the native `[u32 count]...` layout
473
+ * that `readPairsPacked` / the batch validators consume.
474
+ */
451
475
  export function packPairs(pairs: Array<[string, string]>): Uint8Array {
452
476
  const encoded = pairs.map(([key, value]) => [encoder.encode(key), encoder.encode(value)] as const)
453
477
 
@@ -478,35 +502,3 @@ export function packPairs(pairs: Array<[string, string]>): Uint8Array {
478
502
 
479
503
  return out
480
504
  }
481
-
482
- export function schemaValidateBatch(validator: SchemaValidator, items: Uint8Array[]): Uint8Array {
483
- return withPackScratch(items, (packed) =>
484
- unpackBitset(validator.validateBatchPackedBitset(packed)),
485
- )
486
- }
487
-
488
- export function schemaValidateBatchCount(validator: SchemaValidator, items: Uint8Array[]): number {
489
- return withPackScratch(items, (packed) => validator.validateBatchPackedCount(packed))
490
- }
491
-
492
- // ── High-level string parsers (convenience) ──────────────────────
493
- // Wrap the native packed parsers + decoders for ergonomic use, so consumers
494
- // don't have to hand-pack buffers or decode packed pairs:
495
- // parseQueryString("a=1&b=2") // { a: "1", b: "2" }
496
- // parseCookieHeader("a=1; b=2") // { a: "1", b: "2" }
497
-
498
- export function parseQueryString(query: string): Record<string, string | string[]> {
499
- const packed = addon.queryParsePacked(encoder.encode(query))
500
- return pairsToObject(readPairsPacked(packed))
501
- }
502
-
503
- export function parseCookieHeader(header: string): Record<string, string | string[]> {
504
- const packed = addon.cookieParsePacked(encoder.encode(header))
505
- return pairsToObject(readPairsPacked(packed))
506
- }
507
-
508
- /** Parse an application/x-www-form-urlencoded body into a key/value object. */
509
- export function parseFormBody(body: Uint8Array): Record<string, string | string[]> {
510
- const packed = addon.formParsePacked(body)
511
- return pairsToObject(readPairsPacked(packed))
512
- }