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.
- package/README.md +153 -630
- package/castrum.linux-x64-gnu.node +0 -0
- package/dist/index.d.ts +1653 -1817
- package/dist/index.js +6172 -4294
- package/docs/API.md +337 -0
- package/docs/ARCHITECTURE.md +111 -17
- package/docs/BENCHMARKS.md +117 -49
- package/docs/CASE_STUDY.md +243 -0
- package/docs/ENVIRONMENT.md +24 -7
- package/docs/FFI_BUN_GUIDE.md +335 -0
- package/docs/GETTING_STARTED.md +18 -34
- package/docs/HOW_TO_ADD_AN_OP.md +180 -0
- package/docs/INGRESS-ROUTER.md +82 -0
- package/docs/INGRESS.md +11 -7
- package/docs/NATIVE-ROUTE.md +77 -0
- package/docs/REPO_MAP.md +103 -53
- package/docs/adr/0001-two-wire-formats.md +1 -1
- package/docs/adr/0003-bun-builtins-delegation.md +6 -5
- package/docs/ai/LOCAL_DEV.md +81 -0
- package/docs/ai/TREE.md +1070 -0
- package/docs/bun-builtins-decision-matrix.md +25 -18
- package/index.ts +9 -16
- package/package.json +41 -28
- package/src/ingress/body.ts +167 -27
- package/src/ingress/decode/result-base.ts +10 -2
- package/src/ingress/handlers.ts +116 -285
- package/src/ingress/headers/security.ts +41 -0
- package/src/ingress/health.ts +1 -0
- package/src/ingress/index.ts +45 -169
- package/src/ingress/native-route.ts +182 -0
- package/src/ingress/packing/gather-raw-headers.ts +110 -22
- package/src/ingress/packing/header-packing.ts +5 -5
- package/src/ingress/packing/input-packer.ts +17 -3
- package/src/ingress/packing/route-wire.ts +197 -0
- package/src/ingress/packing/select-headers.ts +20 -7
- package/src/ingress/path-matcher.ts +115 -0
- package/src/ingress/response/baked-response.ts +302 -0
- package/src/ingress/router.ts +277 -0
- package/src/ingress/routes/common.ts +50 -1
- package/src/ingress/routes/fallback.ts +2 -2
- package/src/ingress/routes/head.ts +3 -13
- package/src/ingress/routes/index.ts +5 -4
- package/src/ingress/routes/json-write.ts +26 -47
- package/src/ingress/routes/native.ts +130 -0
- package/src/ingress/routes/options.ts +2 -12
- package/src/ingress/routes/read.ts +3 -13
- package/src/ingress/routes/responder.ts +159 -0
- package/src/ingress/server-node.ts +7 -7
- package/src/ingress/server.ts +83 -112
- package/src/ingress/sync.ts +151 -0
- package/src/ingress/types.ts +44 -1
- package/src/integration/pipeline.ts +3 -0
- package/src/integration/streaming.ts +2 -3
- package/src/integration/websocket.ts +4 -3
- package/src/loader/batch.ts +4 -0
- package/src/loader/cost.ts +1 -0
- package/src/loader/index.ts +43 -228
- package/src/loader/state.ts +84 -0
- package/src/loader/types.ts +145 -0
- package/src/native/ffi/build/codecs.ts +736 -0
- package/src/native/ffi/build/compress.ts +248 -0
- package/src/native/ffi/build/instances.ts +598 -0
- package/src/native/ffi/build/parse.ts +539 -0
- package/src/native/ffi/build/util.ts +140 -0
- package/src/native/ffi/build.ts +94 -0
- package/src/native/ffi/constants.ts +58 -0
- package/src/native/ffi/selftest.ts +22 -0
- package/src/native/ffi/types.ts +499 -0
- package/src/native/ffi.ts +307 -1357
- package/src/native/loader.ts +52 -6
- package/src/native/types/instances.ts +279 -0
- package/src/native/types.ts +41 -238
- package/src/runtime/builtins.ts +131 -0
- package/src/runtime/codec.ts +147 -0
- package/src/runtime/detect.ts +36 -0
- package/src/runtime/index.ts +28 -0
- package/src/runtime/native.ts +35 -0
- package/src/runtime/server.ts +31 -0
- package/src/runtime/transport.ts +64 -0
- package/src/runtime/types.ts +173 -0
- package/src/runtime/uuid.ts +45 -0
- package/src/runtime/websocket.ts +30 -0
- package/src/rust-ffi/addon.ts +1 -1
- package/src/rust-ffi/batch/build.ts +155 -0
- package/src/rust-ffi/batch/index.ts +8 -0
- package/src/rust-ffi/batch/types.ts +92 -0
- package/src/rust-ffi/client.ts +5 -6
- package/src/rust-ffi/context.ts +39 -10
- package/src/rust-ffi/index.ts +28 -17
- package/src/rust-ffi/into.ts +21 -0
- package/src/rust-ffi/options.ts +1 -1
- package/src/rust-ffi/packed.ts +40 -2
- package/src/rust-ffi/proven.ts +16 -30
- package/src/rust-ffi/scalar/crypto.ts +188 -107
- package/src/rust-ffi/scalar/factories.ts +257 -27
- package/src/rust-ffi/scalar/hashing.ts +43 -41
- package/src/rust-ffi/scalar/http.ts +208 -77
- package/src/rust-ffi/scalar/index.ts +3 -3
- package/src/rust-ffi/scalar/interface.ts +88 -213
- package/src/rust-ffi/scalar/json-packed.ts +175 -0
- package/src/rust-ffi/scalar/json.ts +28 -12
- package/src/rust-ffi/scalar/payload.ts +159 -42
- package/src/rust-ffi/text.ts +31 -5
- package/src/selection.ts +27 -31
- package/src/shared/adaptive.ts +1 -2
- package/src/shared/buffer-pool.ts +72 -17
- package/src/shared/bytes.ts +75 -6
- package/src/shared/codec.ts +78 -0
- package/src/shared/env.ts +4 -4
- package/src/shared/log.ts +1 -0
- package/src/shared/packed/index.ts +10 -0
- package/src/shared/packed/parsers.ts +31 -0
- package/src/shared/packed/schema.ts +23 -0
- package/src/shared/{packed.ts → packed/wire.ts} +31 -39
- package/src/shared/proven.ts +189 -496
- package/src/shared/runtime.ts +20 -25
- package/src/shared/uuid.ts +12 -28
- package/src/data/json-rows.ts +0 -33
- package/src/rust-ffi/batch.ts +0 -431
- 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 {
|
|
13
|
-
import {
|
|
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
|
|
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
|
-
|
|
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
|
|
package/src/shared/adaptive.ts
CHANGED
|
@@ -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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
|
package/src/shared/bytes.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
59
|
+
* @example
|
|
60
|
+
* ```ts
|
|
61
|
+
* toBytes('aGVsbG8=') // Uint8Array(8)
|
|
62
|
+
* ```
|
|
5
63
|
*/
|
|
6
|
-
export
|
|
64
|
+
export function toBytes(v: Uint8Array | string): Uint8Array {
|
|
65
|
+
return typeof v === 'string' ? encodeUtf8(v) : v
|
|
66
|
+
}
|
|
7
67
|
|
|
8
68
|
/**
|
|
9
|
-
*
|
|
69
|
+
* Normalize the string-or-bytes union to a string. Codec-backed — no
|
|
70
|
+
* `TextDecoder` runs on the Bun path.
|
|
10
71
|
*
|
|
11
|
-
*
|
|
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
|
|
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
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
}
|