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.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/castrum.darwin-arm64.node +0 -0
- package/castrum.darwin-x64.node +0 -0
- package/castrum.linux-arm64-gnu.node +0 -0
- package/castrum.linux-arm64-musl.node +0 -0
- package/castrum.linux-x64-gnu.node +0 -0
- package/castrum.linux-x64-musl.node +0 -0
- package/castrum.linux-x64-v3-gnu.node +0 -0
- package/castrum.win32-x64-msvc.node +0 -0
- package/dist/index.d.ts +664 -8
- package/dist/index.js +2014 -1327
- package/docs/BENCHMARKS.md +13 -0
- package/docs/CASE_STUDY.md +1 -1
- package/docs/ENVIRONMENT.md +8 -1
- package/docs/FFI_BUN_GUIDE.md +1 -1
- package/docs/INGRESS.md +4 -4
- package/docs/REPO_MAP.md +7 -5
- package/docs/RND-CONCURRENCY.md +20 -4
- package/docs/superpowers/plans/2026-09-18-castrum-hardening-cleanup.md +617 -0
- package/docs/superpowers/plans/2026-09-18-elysia-learnings-adoption.md +634 -0
- package/index.ts +6 -0
- package/package.json +4 -2
- package/rust/crypto/aead.rs +44 -0
- package/rust/crypto/argon2.rs +32 -2
- package/rust/crypto/jwt/tests.rs +19 -0
- package/rust/crypto/jwt/token.rs +43 -28
- package/rust/crypto/session.rs +6 -0
- package/rust/ffi/crypto.rs +63 -32
- package/rust/ffi/hashing.rs +48 -33
- package/rust/ffi/http.rs +62 -36
- package/rust/ffi/metrics.rs +6 -2
- package/rust/ffi/payload.rs +47 -27
- package/rust/ffi/task.rs +55 -35
- package/rust/ffi/tests.rs +24 -0
- package/rust/ffi/util.rs +40 -0
- package/rust/http/headers.rs +13 -0
- package/rust/http/multipart.rs +139 -0
- package/rust/ingress/api.rs +21 -5
- package/rust/ingress/mod.rs +7 -0
- package/rust/ingress/native_route.rs +25 -5
- package/rust/ingress/schema_cache.rs +266 -0
- package/rust/task/completion.rs +7 -0
- package/rust/task/mod.rs +2 -1
- package/rust/task/runtime.rs +55 -7
- package/rust/task/tests.rs +128 -1
- package/src/ingress/abort.ts +48 -0
- package/src/ingress/body.ts +104 -8
- package/src/ingress/decode/baked-result.ts +13 -5
- package/src/ingress/decode/fast-result.ts +1 -1
- package/src/ingress/decode/result-base.ts +16 -2
- package/src/ingress/handlers.ts +104 -31
- package/src/ingress/headers/memoized-headers.ts +40 -0
- package/src/ingress/health.ts +22 -0
- package/src/ingress/index.ts +3 -1
- package/src/ingress/native-route.ts +25 -6
- package/src/ingress/options.ts +26 -2
- package/src/ingress/packing/gather-raw-headers.ts +24 -8
- package/src/ingress/response/baked-response.ts +118 -28
- package/src/ingress/router.ts +50 -3
- package/src/ingress/routes/common.ts +35 -11
- package/src/ingress/routes/echo.ts +60 -7
- package/src/ingress/routes/json-write.ts +32 -8
- package/src/ingress/routes/native.ts +24 -5
- package/src/ingress/routes/options.ts +6 -2
- package/src/ingress/routes/responder.ts +29 -9
- package/src/ingress/routes/safe-record.ts +57 -0
- package/src/ingress/server-error.ts +111 -0
- package/src/ingress/server-node.ts +135 -37
- package/src/ingress/server.ts +143 -36
- package/src/ingress/shared.ts +1 -1
- package/src/ingress/types.ts +15 -0
- package/src/integration/pipeline.ts +6 -0
- package/src/loader/ops.ts +1 -1
- package/src/native/ffi/build/codecs-selftest.ts +305 -0
- package/src/native/ffi/build/codecs.ts +0 -296
- package/src/native/ffi/build/instances-selftest.ts +264 -0
- package/src/native/ffi/build/instances.ts +10 -259
- package/src/native/ffi/build/parse-selftest.ts +238 -0
- package/src/native/ffi/build/parse.ts +11 -234
- package/src/native/ffi/build/task.ts +2 -1
- package/src/native/ffi/build.ts +5 -1
- package/src/native/ffi/selftest.ts +3 -3
- package/src/native/ffi/symbols.ts +460 -0
- package/src/native/ffi/types.ts +6 -1
- package/src/native/ffi.ts +2 -434
- package/src/native/ingress-binding.ts +39 -0
- package/src/native/types.ts +6 -0
- package/src/runtime/codec.ts +24 -0
- package/src/runtime/detect.ts +14 -0
- package/src/runtime/types.ts +11 -0
- package/src/rust-ffi/batch/build.ts +18 -8
- package/src/rust-ffi/batch/types.ts +15 -0
- package/src/rust-ffi/context.ts +46 -3
- package/src/rust-ffi/options.ts +19 -0
- package/src/rust-ffi/scalar/crypto.ts +8 -0
- package/src/rust-ffi/scalar/factories.ts +53 -20
- package/src/rust-ffi/scalar/http.ts +60 -10
- package/src/rust-ffi/scalar/interface.ts +18 -1
- package/src/rust-ffi/scalar/payload.ts +27 -1
- package/src/rust-ffi/text.ts +5 -4
- package/src/shared/buffer-pool.ts +54 -9
- package/src/shared/codec.ts +26 -0
- package/src/shared/memory.ts +68 -0
- package/src/shared/metrics.ts +86 -3
- package/src/shared/packed/parsers.ts +64 -7
- package/src/shared/runtime.ts +7 -0
- package/src/task/runtime.ts +81 -37
|
@@ -47,17 +47,32 @@ export interface RustBatch {
|
|
|
47
47
|
base64UrlDecode(items: Uint8Array[]): Uint8Array[]
|
|
48
48
|
|
|
49
49
|
// ── Backend-framework features ──
|
|
50
|
+
/**
|
|
51
|
+
* Parallel password hashing. WARNING: the SAME `salt` is applied to every
|
|
52
|
+
* item. Reusing one salt across real user passwords enables a single
|
|
53
|
+
* precomputation to attack them all — generate a unique salt per password in
|
|
54
|
+
* production (this batch form is for precomputed/benchmark workloads).
|
|
55
|
+
*/
|
|
50
56
|
passwordHash(
|
|
51
57
|
passwords: Uint8Array[],
|
|
52
58
|
salt: Uint8Array,
|
|
53
59
|
options?: PasswordHashOptions | null,
|
|
54
60
|
): Uint8Array[]
|
|
61
|
+
/**
|
|
62
|
+
* Parallel AEAD encrypt. `nonce` MUST be exactly 12 bytes and MUST be fresh
|
|
63
|
+
* for every batch call with the same key: a unique per-item nonce is derived
|
|
64
|
+
* by XOR-ing the item index into the low 8 bytes, so reusing a base nonce
|
|
65
|
+
* across calls reuses keystream/tag material for colliding indices
|
|
66
|
+
* (catastrophic for AES-GCM / ChaCha20-Poly1305). A non-12-byte nonce is
|
|
67
|
+
* rejected with an error.
|
|
68
|
+
*/
|
|
55
69
|
aeadEncrypt(
|
|
56
70
|
key: Uint8Array,
|
|
57
71
|
nonce: Uint8Array,
|
|
58
72
|
items: Uint8Array[],
|
|
59
73
|
algorithm?: string | null,
|
|
60
74
|
): Uint8Array[]
|
|
75
|
+
/** Parallel AEAD decrypt. Pass the SAME base nonce and item order as the encrypt call. */
|
|
61
76
|
aeadDecrypt(
|
|
62
77
|
key: Uint8Array,
|
|
63
78
|
nonce: Uint8Array,
|
package/src/rust-ffi/context.ts
CHANGED
|
@@ -21,6 +21,51 @@ import { normalizeExt, resolveRayonThreads } from './options'
|
|
|
21
21
|
// batch.ts uses for its packed entries.
|
|
22
22
|
const nativeFnCache = new Map<string, unknown>()
|
|
23
23
|
|
|
24
|
+
/** Max entries retained per process-wide MIME cache (insertion-order eviction). */
|
|
25
|
+
const MIME_CACHE_MAX = 1024
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Process-wide extension-bytes → MIME-bytes cache shared by every client
|
|
29
|
+
* context: the native addon is a process singleton, so `extension → MIME` is a
|
|
30
|
+
* global pure lookup. Bounded by `MIME_CACHE_MAX`; on overflow the oldest half
|
|
31
|
+
* is evicted via {@link evictOldestHalf}. Exported for the memory-flush path
|
|
32
|
+
* and tests — callers must NOT mutate the returned slices.
|
|
33
|
+
*/
|
|
34
|
+
export const mimeByText = new Map<string, Uint8Array>()
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Process-wide extension-string → MIME-string memo (the `text.mimeFromExtension`
|
|
38
|
+
* fast path). Bounded exactly like {@link mimeByText}.
|
|
39
|
+
*/
|
|
40
|
+
export const mimeStrCache = new Map<string, string>()
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Evict the oldest half of a cache map (insertion order) and return the keys
|
|
44
|
+
* dropped. Generational eviction — halving at the cap amortizes the cost rather
|
|
45
|
+
* than paying a delete per insertion. Returns an empty array below the cap.
|
|
46
|
+
*/
|
|
47
|
+
export function evictOldestHalf<K, V>(map: Map<K, V>): K[] {
|
|
48
|
+
if (map.size < MIME_CACHE_MAX) return []
|
|
49
|
+
const drop = Math.ceil(map.size / 2)
|
|
50
|
+
const dropped: K[] = []
|
|
51
|
+
for (const key of map.keys()) {
|
|
52
|
+
if (dropped.length >= drop) break
|
|
53
|
+
dropped.push(key)
|
|
54
|
+
}
|
|
55
|
+
for (const key of dropped) map.delete(key)
|
|
56
|
+
return dropped
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Clear the process-wide MIME caches (both the bytes and string forms). This is
|
|
61
|
+
* the impure-boundary hook consumed by the public `flushMemory()`; safe to call
|
|
62
|
+
* at any time, including before the addon has ever been loaded.
|
|
63
|
+
*/
|
|
64
|
+
export function clearMimeCaches(): void {
|
|
65
|
+
mimeByText.clear()
|
|
66
|
+
mimeStrCache.clear()
|
|
67
|
+
}
|
|
68
|
+
|
|
24
69
|
/**
|
|
25
70
|
* Resolve a native op on first use and cache it for all subsequent calls —
|
|
26
71
|
* FFI-FIRST via the runtime adapter's transport (`bun:ffi` on Bun, napi
|
|
@@ -91,7 +136,6 @@ export interface RustClientContext {
|
|
|
91
136
|
runtime: NativeRuntimeAdapter
|
|
92
137
|
/** Per-instance toggles (mime/hmac caching). */
|
|
93
138
|
state: { mimeCache: boolean; hmacCache: boolean }
|
|
94
|
-
mimeByText: Map<string, Uint8Array>
|
|
95
139
|
hmacSigners: WeakMap<Uint8Array, HmacSignerInstance>
|
|
96
140
|
|
|
97
141
|
/** Initialize the process-wide rayon pool exactly once (no-op after). */
|
|
@@ -110,7 +154,6 @@ export interface RustClientContext {
|
|
|
110
154
|
|
|
111
155
|
/** Build a per-instance context from client options. */
|
|
112
156
|
export function createContext(options: RustOptions): RustClientContext {
|
|
113
|
-
const mimeByText = new Map<string, Uint8Array>()
|
|
114
157
|
const hmacSigners = new WeakMap<Uint8Array, HmacSignerInstance>()
|
|
115
158
|
const state = {
|
|
116
159
|
mimeCache: options.mimeCache !== false,
|
|
@@ -152,6 +195,7 @@ export function createContext(options: RustOptions): RustClientContext {
|
|
|
152
195
|
|
|
153
196
|
let val = mimeByText.get(key)
|
|
154
197
|
if (!val) {
|
|
198
|
+
evictOldestHalf(mimeByText)
|
|
155
199
|
val = addon.mimeFromExtension(ext)
|
|
156
200
|
mimeByText.set(key, val)
|
|
157
201
|
}
|
|
@@ -181,7 +225,6 @@ export function createContext(options: RustOptions): RustClientContext {
|
|
|
181
225
|
addon,
|
|
182
226
|
runtime: runtimeNative,
|
|
183
227
|
state,
|
|
184
|
-
mimeByText,
|
|
185
228
|
hmacSigners,
|
|
186
229
|
ensurePool,
|
|
187
230
|
isPoolInitialized,
|
package/src/rust-ffi/options.ts
CHANGED
|
@@ -93,6 +93,25 @@ export function asNumber(value: unknown): number {
|
|
|
93
93
|
return 0
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Throw when a signing secret/key is empty.
|
|
98
|
+
*
|
|
99
|
+
* An empty HMAC/JWT/CSRF secret is effectively a public function — anyone can
|
|
100
|
+
* compute the MAC — so a blank/missing env var would silently mint forgeable
|
|
101
|
+
* tokens. The raw `rust.hmacSha256` primitive deliberately stays tolerant of an
|
|
102
|
+
* empty key (see the empty-input matrix); this guards the auth surfaces built
|
|
103
|
+
* on top of it.
|
|
104
|
+
*
|
|
105
|
+
* @param secret - The secret/key bytes about to be used.
|
|
106
|
+
* @param op - Operation name for the error message.
|
|
107
|
+
* @throws Error when `secret` is empty.
|
|
108
|
+
*/
|
|
109
|
+
export function assertNonEmptySecret(secret: Uint8Array, op: string): void {
|
|
110
|
+
if (secret.byteLength === 0) {
|
|
111
|
+
throw new Error(`${op}: secret must not be empty`)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
96
115
|
/** Normalize a file-extension byte slice to a lowercase string w/o a dot. */
|
|
97
116
|
export function normalizeExt(ext: Uint8Array): string {
|
|
98
117
|
let s: string
|
|
@@ -12,6 +12,7 @@ import type { PasswordHashOptions } from '../../native'
|
|
|
12
12
|
import { encoder } from '../../shared/bytes'
|
|
13
13
|
import { memoizeFfi, type RustClientContext, resolveNative } from '../context'
|
|
14
14
|
import { writeInto } from '../into'
|
|
15
|
+
import { assertNonEmptySecret } from '../options'
|
|
15
16
|
|
|
16
17
|
/** Auth / crypto scalar methods (`Pick<RustScalar, ...>`). */
|
|
17
18
|
export function buildCrypto(ctx: RustClientContext) {
|
|
@@ -152,14 +153,17 @@ export function buildCrypto(ctx: RustClientContext) {
|
|
|
152
153
|
return writeInto('verify cookie', output, value)
|
|
153
154
|
},
|
|
154
155
|
csrfToken(secret: Uint8Array): Uint8Array | string {
|
|
156
|
+
assertNonEmptySecret(secret, 'csrfToken')
|
|
155
157
|
return resolveNative(ctx, 'csrfToken')(secret) as Uint8Array | string
|
|
156
158
|
},
|
|
157
159
|
csrfTokenInto(secret: Uint8Array, output: Uint8Array): number {
|
|
160
|
+
assertNonEmptySecret(secret, 'csrfTokenInto')
|
|
158
161
|
const f = ffi()
|
|
159
162
|
if (f) return f.csrfTokenInto(secret, output)
|
|
160
163
|
return writeInto('csrf token', output, addon.csrfToken(secret))
|
|
161
164
|
},
|
|
162
165
|
csrfVerify(token: Uint8Array, secret: Uint8Array): boolean {
|
|
166
|
+
assertNonEmptySecret(secret, 'csrfVerify')
|
|
163
167
|
return resolveNative(ctx, 'csrfVerify')(token, secret) as boolean
|
|
164
168
|
},
|
|
165
169
|
jwtSign(
|
|
@@ -175,6 +179,7 @@ export function buildCrypto(ctx: RustClientContext) {
|
|
|
175
179
|
// preserves JS insertion order while napi's serde_json sorts keys — for
|
|
176
180
|
// multi-key claims the two transports emit different-but-equally-valid
|
|
177
181
|
// tokens (both verify; parity tests cross-verify semantically).
|
|
182
|
+
assertNonEmptySecret(secret, 'jwtSign')
|
|
178
183
|
const f = ffi()
|
|
179
184
|
if (f) {
|
|
180
185
|
const token = f.jwtSignBytes(
|
|
@@ -198,6 +203,7 @@ export function buildCrypto(ctx: RustClientContext) {
|
|
|
198
203
|
ttlSeconds?: number | null,
|
|
199
204
|
nowSeconds?: number,
|
|
200
205
|
): Uint8Array | string {
|
|
206
|
+
assertNonEmptySecret(secret, 'jwtSignBytes')
|
|
201
207
|
const f = ffi()
|
|
202
208
|
if (f) {
|
|
203
209
|
// The C-ABI `ttl <= 0` sentinel matches napi's Option<i64> (inject only
|
|
@@ -223,6 +229,7 @@ export function buildCrypto(ctx: RustClientContext) {
|
|
|
223
229
|
ttlSeconds?: number | null,
|
|
224
230
|
nowSeconds?: number,
|
|
225
231
|
): number {
|
|
232
|
+
assertNonEmptySecret(secret, 'jwtSignBytesInto')
|
|
226
233
|
const f = ffi()
|
|
227
234
|
if (f) {
|
|
228
235
|
return f.jwtSignBytesInto(
|
|
@@ -245,6 +252,7 @@ export function buildCrypto(ctx: RustClientContext) {
|
|
|
245
252
|
)
|
|
246
253
|
},
|
|
247
254
|
jwtVerify(token: Uint8Array, secret: Uint8Array, nowSeconds?: number): unknown {
|
|
255
|
+
assertNonEmptySecret(secret, 'jwtVerify')
|
|
248
256
|
const f = ffi()
|
|
249
257
|
if (f) {
|
|
250
258
|
// Verify via the FFI cstring path (castrum_jwt_verify): the engine
|
|
@@ -27,7 +27,20 @@ import type { BunFFI } from '../../native/ffi'
|
|
|
27
27
|
import { encoder } from '../../shared/bytes'
|
|
28
28
|
import { decodeUtf8, encodeUtf8 } from '../../shared/codec'
|
|
29
29
|
import type { RustClientContext } from '../context'
|
|
30
|
-
import { asNumber } from '../options'
|
|
30
|
+
import { asNumber, assertNonEmptySecret } from '../options'
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Convert a napi `innerPtr()` (u64) into a JS number handle, rejecting values
|
|
34
|
+
* above `Number.MAX_SAFE_INTEGER`: the `Number()` conversion would silently
|
|
35
|
+
* round a high canonical address into a wild pointer.
|
|
36
|
+
*/
|
|
37
|
+
function handlePtr(inner: bigint | null | undefined): number {
|
|
38
|
+
const v = inner ?? 0n
|
|
39
|
+
if (v < 0n || v > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
40
|
+
throw new Error('castrum: native handle exceeds Number.MAX_SAFE_INTEGER')
|
|
41
|
+
}
|
|
42
|
+
return Number(v)
|
|
43
|
+
}
|
|
31
44
|
|
|
32
45
|
// ── FFI-backed instance wrappers ───────────────────────────────────────────
|
|
33
46
|
// On Bun, instances whose methods have a STATELESS C-ABI sibling are backed by
|
|
@@ -339,17 +352,24 @@ function ffiFormParser(ffi: BunFFI): FormParserInstance {
|
|
|
339
352
|
* `values.join('\u001f')` — ZERO TextEncoder work (Bun 1.4 zero-copy text).
|
|
340
353
|
*/
|
|
341
354
|
function ffiMetricsRegistry(ffi: BunFFI): MetricsRegistryInstance {
|
|
342
|
-
|
|
355
|
+
let handle = ffi.metricsCreate()
|
|
356
|
+
// Destroyed state is terminal: every method that would pass the handle to
|
|
357
|
+
// native memory throws instead of dereferencing a freed pointer, and a
|
|
358
|
+
// second destroy is a no-op (not a double-free).
|
|
359
|
+
const live = (): number => {
|
|
360
|
+
if (handle === 0) throw new Error('metrics registry: already destroyed')
|
|
361
|
+
return handle
|
|
362
|
+
}
|
|
343
363
|
return {
|
|
344
364
|
counter(name, labelKeys) {
|
|
345
|
-
return ffi.metricsCounter(
|
|
365
|
+
return ffi.metricsCounter(live(), name, (labelKeys ?? []).join('\u001f'))
|
|
346
366
|
},
|
|
347
367
|
gauge(name, labelKeys) {
|
|
348
|
-
return ffi.metricsGauge(
|
|
368
|
+
return ffi.metricsGauge(live(), name, (labelKeys ?? []).join('\u001f'))
|
|
349
369
|
},
|
|
350
370
|
histogram(name, labelKeys, buckets) {
|
|
351
371
|
return ffi.metricsHistogram(
|
|
352
|
-
|
|
372
|
+
live(),
|
|
353
373
|
name,
|
|
354
374
|
(labelKeys ?? []).join('\u001f'),
|
|
355
375
|
(buckets ?? []).join(','),
|
|
@@ -357,13 +377,13 @@ function ffiMetricsRegistry(ffi: BunFFI): MetricsRegistryInstance {
|
|
|
357
377
|
},
|
|
358
378
|
record(series, values, amount) {
|
|
359
379
|
const joined = (values ?? []).join('\u001f')
|
|
360
|
-
if (!ffi.metricsRecordStr(
|
|
380
|
+
if (!ffi.metricsRecordStr(live(), series, joined, amount ?? 1)) {
|
|
361
381
|
throw new Error('metrics record: unknown series / arity mismatch / invalid amount')
|
|
362
382
|
}
|
|
363
383
|
},
|
|
364
384
|
gaugeSet(series, values, value) {
|
|
365
385
|
const joined = (values ?? []).join('\u001f')
|
|
366
|
-
if (!ffi.metricsGaugeSetStr(
|
|
386
|
+
if (!ffi.metricsGaugeSetStr(live(), series, joined, value)) {
|
|
367
387
|
throw new Error('metrics gauge set: unknown series / arity mismatch')
|
|
368
388
|
}
|
|
369
389
|
},
|
|
@@ -371,24 +391,27 @@ function ffiMetricsRegistry(ffi: BunFFI): MetricsRegistryInstance {
|
|
|
371
391
|
// Probe large enough for typical registries (one native pass), then
|
|
372
392
|
// grow exactly once if ever exceeded (needed-size convention).
|
|
373
393
|
let out = new Uint8Array(8192)
|
|
374
|
-
let w = ffi.metricsRender(
|
|
394
|
+
let w = ffi.metricsRender(live(), out)
|
|
375
395
|
if (w > out.length) {
|
|
376
396
|
out = new Uint8Array(w)
|
|
377
|
-
w = ffi.metricsRender(
|
|
397
|
+
w = ffi.metricsRender(live(), out)
|
|
378
398
|
}
|
|
379
399
|
return decodeUtf8(out.subarray(0, w))
|
|
380
400
|
},
|
|
381
401
|
destroy() {
|
|
382
|
-
|
|
402
|
+
if (handle !== 0) {
|
|
403
|
+
ffi.metricsDestroy(handle)
|
|
404
|
+
handle = 0
|
|
405
|
+
}
|
|
383
406
|
},
|
|
384
407
|
snapshot() {
|
|
385
408
|
// Packed v1 dump — decoded by consumers (the @ignex/native metrics
|
|
386
409
|
// wrapper); probe large, grow exactly once.
|
|
387
410
|
let out = new Uint8Array(4096)
|
|
388
|
-
let w = ffi.metricsSnapshot(
|
|
411
|
+
let w = ffi.metricsSnapshot(live(), out)
|
|
389
412
|
if (w > out.length) {
|
|
390
413
|
out = new Uint8Array(w)
|
|
391
|
-
w = ffi.metricsSnapshot(
|
|
414
|
+
w = ffi.metricsSnapshot(live(), out)
|
|
392
415
|
}
|
|
393
416
|
return out.subarray(0, w)
|
|
394
417
|
},
|
|
@@ -405,11 +428,12 @@ export function buildFactories(ctx: RustClientContext) {
|
|
|
405
428
|
const ffi = transport.ffi
|
|
406
429
|
if (ffi) {
|
|
407
430
|
const napi = new addon.SchemaValidator(schema)
|
|
408
|
-
return ffiSchemaValidator(napi,
|
|
431
|
+
return ffiSchemaValidator(napi, handlePtr(napi.innerPtr?.()), ffi)
|
|
409
432
|
}
|
|
410
433
|
return new addon.SchemaValidator(schema)
|
|
411
434
|
},
|
|
412
435
|
createHmacSigner(key: Uint8Array): HmacSignerInstance {
|
|
436
|
+
assertNonEmptySecret(key, 'createHmacSigner')
|
|
413
437
|
const ffi = transport.ffi
|
|
414
438
|
if (ffi) return ffiHmacSigner(key, ffi)
|
|
415
439
|
return new addon.HmacSigner(key)
|
|
@@ -418,7 +442,7 @@ export function buildFactories(ctx: RustClientContext) {
|
|
|
418
442
|
const ffi = transport.ffi
|
|
419
443
|
if (ffi) {
|
|
420
444
|
const napi = new addon.TemplateRenderer(source)
|
|
421
|
-
return ffiTemplateRenderer(napi,
|
|
445
|
+
return ffiTemplateRenderer(napi, handlePtr(napi.innerPtr?.()), ffi)
|
|
422
446
|
}
|
|
423
447
|
return new addon.TemplateRenderer(source)
|
|
424
448
|
},
|
|
@@ -440,7 +464,7 @@ export function buildFactories(ctx: RustClientContext) {
|
|
|
440
464
|
const ffi = transport.ffi
|
|
441
465
|
if (ffi) {
|
|
442
466
|
const napi = new addon.ConditionalRequest(etagValue, lastModifiedSecs ?? undefined)
|
|
443
|
-
return ffiConditionalRequest(napi,
|
|
467
|
+
return ffiConditionalRequest(napi, handlePtr(napi.innerPtr?.()), ffi)
|
|
444
468
|
}
|
|
445
469
|
return new addon.ConditionalRequest(etagValue, lastModifiedSecs ?? undefined)
|
|
446
470
|
},
|
|
@@ -448,7 +472,7 @@ export function buildFactories(ctx: RustClientContext) {
|
|
|
448
472
|
const ffi = transport.ffi
|
|
449
473
|
if (ffi) {
|
|
450
474
|
const napi = new addon.AcceptNegotiator(supported)
|
|
451
|
-
return ffiAcceptNegotiator(napi,
|
|
475
|
+
return ffiAcceptNegotiator(napi, handlePtr(napi.innerPtr?.()), ffi)
|
|
452
476
|
}
|
|
453
477
|
return new addon.AcceptNegotiator(supported)
|
|
454
478
|
},
|
|
@@ -458,11 +482,13 @@ export function buildFactories(ctx: RustClientContext) {
|
|
|
458
482
|
return new addon.Base64Codec(urlSafe ?? undefined, padding ?? undefined)
|
|
459
483
|
},
|
|
460
484
|
createCookieSigner(secret: Uint8Array): CookieSignerInstance {
|
|
485
|
+
assertNonEmptySecret(secret, 'createCookieSigner')
|
|
461
486
|
const ffi = transport.ffi
|
|
462
487
|
if (ffi) return ffiCookieSigner(secret, ffi)
|
|
463
488
|
return new addon.CookieSigner(secret)
|
|
464
489
|
},
|
|
465
490
|
createCsrfProtector(secret: Uint8Array): CsrfProtectorInstance {
|
|
491
|
+
assertNonEmptySecret(secret, 'createCsrfProtector')
|
|
466
492
|
const ffi = transport.ffi
|
|
467
493
|
if (ffi) return ffiCsrfProtector(secret, ffi)
|
|
468
494
|
return new addon.CsrfProtector(secret)
|
|
@@ -474,15 +500,16 @@ export function buildFactories(ctx: RustClientContext) {
|
|
|
474
500
|
const ffi = transport.ffi
|
|
475
501
|
if (ffi) {
|
|
476
502
|
const napi = new addon.UrlBuilder(base)
|
|
477
|
-
return ffiUrlBuilder(napi,
|
|
503
|
+
return ffiUrlBuilder(napi, handlePtr(napi.innerPtr?.()), ffi)
|
|
478
504
|
}
|
|
479
505
|
return new addon.UrlBuilder(base)
|
|
480
506
|
},
|
|
481
507
|
createJwtSigner(secret: Uint8Array, ttlSeconds?: number): JwtSignerInstance {
|
|
508
|
+
assertNonEmptySecret(secret, 'createJwtSigner')
|
|
482
509
|
const ffi = transport.ffi
|
|
483
510
|
if (ffi) {
|
|
484
511
|
const napi = new addon.JwtSigner(secret, ttlSeconds ?? undefined)
|
|
485
|
-
return ffiJwtSigner(napi,
|
|
512
|
+
return ffiJwtSigner(napi, handlePtr(napi.innerPtr?.()), ffi)
|
|
486
513
|
}
|
|
487
514
|
return new addon.JwtSigner(secret, ttlSeconds ?? undefined)
|
|
488
515
|
},
|
|
@@ -500,7 +527,7 @@ export function buildFactories(ctx: RustClientContext) {
|
|
|
500
527
|
const ffi = transport.ffi
|
|
501
528
|
if (ffi) {
|
|
502
529
|
const napi = new addon.MediaTypeMatcher(expected)
|
|
503
|
-
return ffiMediaTypeMatcher(napi,
|
|
530
|
+
return ffiMediaTypeMatcher(napi, handlePtr(napi.innerPtr?.()), ffi)
|
|
504
531
|
}
|
|
505
532
|
return new addon.MediaTypeMatcher(expected)
|
|
506
533
|
},
|
|
@@ -512,7 +539,7 @@ export function buildFactories(ctx: RustClientContext) {
|
|
|
512
539
|
const ffi = transport.ffi
|
|
513
540
|
if (ffi) {
|
|
514
541
|
const napi = new addon.RateLimiter(limit, windowMs, maxEntries ?? undefined)
|
|
515
|
-
return ffiRateLimiter(napi,
|
|
542
|
+
return ffiRateLimiter(napi, handlePtr(napi.innerPtr?.()), ffi)
|
|
516
543
|
}
|
|
517
544
|
return new addon.RateLimiter(limit, windowMs, maxEntries ?? undefined)
|
|
518
545
|
},
|
|
@@ -532,5 +559,11 @@ export function buildFactories(ctx: RustClientContext) {
|
|
|
532
559
|
rayonNumThreads(): number {
|
|
533
560
|
return asNumber(addon.rayonNumThreads() as unknown)
|
|
534
561
|
},
|
|
562
|
+
clearSchemaCache(): void {
|
|
563
|
+
// Defensive: a stale addon built before this symbol, or a failed bind,
|
|
564
|
+
// must make this a no-op rather than a throw (flushMemory calls it).
|
|
565
|
+
const native = addon as unknown as { clearSchemaCache?: () => void }
|
|
566
|
+
native.clearSchemaCache?.()
|
|
567
|
+
},
|
|
535
568
|
}
|
|
536
569
|
}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import type { EncodingPrefResult, MediaTypeResult } from '../../native'
|
|
13
13
|
import type { BunFFI } from '../../native/ffi'
|
|
14
14
|
import { decoder, hasNul } from '../../shared/bytes'
|
|
15
|
-
import { decodeUtf8Fatal, decodeUtf8Range } from '../../shared/codec'
|
|
15
|
+
import { decodeUtf8Fatal, decodeUtf8Range, decodeUtf8RangeView } from '../../shared/codec'
|
|
16
16
|
import { packPairs } from '../../shared/packed'
|
|
17
17
|
import { memoizeFfi, type RustClientContext, resolveNative } from '../context'
|
|
18
18
|
import { writeInto } from '../into'
|
|
@@ -59,6 +59,19 @@ function u32LE(b: Uint8Array, off: number): number {
|
|
|
59
59
|
)
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/** ASCII byte form of the two known media-type param keys (byte-compare
|
|
63
|
+
* targets — avoids decoding the key string for every packed param). */
|
|
64
|
+
const CHARSET_KEY_BYTES = new Uint8Array([0x63, 0x68, 0x61, 0x72, 0x73, 0x65, 0x74]) // 'charset'
|
|
65
|
+
const BOUNDARY_KEY_BYTES = new Uint8Array([0x62, 0x6f, 0x75, 0x6e, 0x64, 0x61, 0x72, 0x79]) // 'boundary'
|
|
66
|
+
|
|
67
|
+
/** Byte-equality of `b[off..off+len]` with the literal `lit` (no allocation). */
|
|
68
|
+
function keyBytesEqual(b: Uint8Array, off: number, lit: Uint8Array): boolean {
|
|
69
|
+
for (let i = 0; i < lit.length; i++) {
|
|
70
|
+
if ((b[off + i] ?? 0) !== (lit[i] ?? 0)) return false
|
|
71
|
+
}
|
|
72
|
+
return true
|
|
73
|
+
}
|
|
74
|
+
|
|
62
75
|
/**
|
|
63
76
|
* Unpack the `castrum_parse_media_type` verdict into the napi-shaped result.
|
|
64
77
|
* Layout: `[u32 mediaTypeLen][mediaType][u32 charsetLen (0xFFFFFFFF = none)]
|
|
@@ -69,42 +82,75 @@ function u32LE(b: Uint8Array, off: number): number {
|
|
|
69
82
|
function unpackMediaType(packed: Uint8Array): MediaTypeResult {
|
|
70
83
|
// Ranged decode straight off the (pooled) packed buffer — no per-field
|
|
71
84
|
// subarray views. ASCII fields take the latin1 fast path.
|
|
85
|
+
// ONE view per result: `decodeUtf8RangeView` decodes at absolute offsets
|
|
86
|
+
// into a bounded Buffer resolved once here — the per-field WeakMap lookup
|
|
87
|
+
// + offset arithmetic in `decodeUtf8Range` disappear (profile: unpackMediaType
|
|
88
|
+
// called the cached-view path 5x per result).
|
|
89
|
+
const view = Buffer.from(packed.buffer, packed.byteOffset, packed.byteLength)
|
|
72
90
|
let off = 0
|
|
73
91
|
const mtLen = u32LE(packed, off)
|
|
74
92
|
off += 4
|
|
75
|
-
const mediaType =
|
|
93
|
+
const mediaType = decodeUtf8RangeView(view, off, off + mtLen)
|
|
76
94
|
off += mtLen
|
|
77
95
|
let charset: string | null = null
|
|
78
96
|
const csLen = u32LE(packed, off)
|
|
79
97
|
off += 4
|
|
80
98
|
if (csLen !== 0xffffffff) {
|
|
81
|
-
charset =
|
|
99
|
+
charset = decodeUtf8RangeView(view, off, off + csLen)
|
|
82
100
|
off += csLen
|
|
83
101
|
}
|
|
84
102
|
let boundary: string | null = null
|
|
85
103
|
const bLen = u32LE(packed, off)
|
|
86
104
|
off += 4
|
|
87
105
|
if (bLen !== 0xffffffff) {
|
|
88
|
-
boundary =
|
|
106
|
+
boundary = decodeUtf8RangeView(view, off, off + bLen)
|
|
89
107
|
off += bLen
|
|
90
108
|
}
|
|
91
109
|
const count = u32LE(packed, off)
|
|
92
110
|
off += 4
|
|
93
111
|
const params: Record<string, string> = {}
|
|
112
|
+
// The summary slots above hold the FIRST charset/boundary occurrence (the
|
|
113
|
+
// Rust core fills them via `params.iter().find(...)`), while `params` keeps
|
|
114
|
+
// the LAST duplicate (JS assignment order). Reusing the already-decoded
|
|
115
|
+
// summary string for the first matching param skips one redundant ranged
|
|
116
|
+
// decode per named field without changing any observable value.
|
|
117
|
+
let charsetTaken = charset === null
|
|
118
|
+
let boundaryTaken = boundary === null
|
|
94
119
|
for (let i = 0; i < count; i++) {
|
|
95
120
|
const kLen = u32LE(packed, off)
|
|
96
121
|
off += 4
|
|
97
|
-
|
|
122
|
+
// Byte-compare the param KEY against the ASCII literals BEFORE decoding:
|
|
123
|
+
// on a match the key decode is skipped (`params` is keyed by the identical
|
|
124
|
+
// literal) and the summary/decided-value path handles the value. A
|
|
125
|
+
// byte-match to 'charset'/'boundary' implies valid UTF-8 for those bytes,
|
|
126
|
+
// so the skipped decode was exact — observable behavior unchanged
|
|
127
|
+
// (non-matching keys decode normally, casing preserved).
|
|
128
|
+
let key: string
|
|
129
|
+
if (kLen === 7 && keyBytesEqual(packed, off, CHARSET_KEY_BYTES)) {
|
|
130
|
+
key = 'charset'
|
|
131
|
+
} else if (kLen === 8 && keyBytesEqual(packed, off, BOUNDARY_KEY_BYTES)) {
|
|
132
|
+
key = 'boundary'
|
|
133
|
+
} else {
|
|
134
|
+
key = decodeUtf8RangeView(view, off, off + kLen)
|
|
135
|
+
}
|
|
98
136
|
off += kLen
|
|
99
137
|
const vLen = u32LE(packed, off)
|
|
100
138
|
off += 4
|
|
101
|
-
|
|
139
|
+
let val: string
|
|
140
|
+
if (!charsetTaken && key === 'charset' && charset !== null) {
|
|
141
|
+
val = charset
|
|
142
|
+
charsetTaken = true
|
|
143
|
+
} else if (!boundaryTaken && key === 'boundary' && boundary !== null) {
|
|
144
|
+
val = boundary
|
|
145
|
+
boundaryTaken = true
|
|
146
|
+
} else {
|
|
147
|
+
val = decodeUtf8RangeView(view, off, off + vLen)
|
|
148
|
+
}
|
|
102
149
|
off += vLen
|
|
103
150
|
params[key] = val
|
|
104
151
|
}
|
|
105
152
|
return { mediaType, charset, boundary, params }
|
|
106
153
|
}
|
|
107
|
-
|
|
108
154
|
/** Unpack the `castrum_parse_accept_encoding` verdict (f32 q-values) into the
|
|
109
155
|
* napi-shaped array. Layout: `[u32 count]{[u32 encLen][enc][f32 q][u32 order]}`.
|
|
110
156
|
* Ranged decode off the pooled buffer + shared f32 bit-reinterpret views —
|
|
@@ -276,7 +322,11 @@ export function buildHttp(ctx: RustClientContext) {
|
|
|
276
322
|
queryParsePacked(input: Uint8Array): Uint8Array {
|
|
277
323
|
const f = ffi()
|
|
278
324
|
if (f) {
|
|
279
|
-
|
|
325
|
+
// For k nonempty pairs in n bytes, k <= (n + 1) / 2: each
|
|
326
|
+
// pair needs a byte and adjacent pairs need a separator. Packed size
|
|
327
|
+
// <= 4 + 8k + (n - k + 1) <= 5n + 8; decoding never expands bytes.
|
|
328
|
+
// Cookie/form siblings use the same bound and retain fresh ownership.
|
|
329
|
+
const out = new Uint8Array(input.length * 5 + 8)
|
|
280
330
|
const w = f.queryParsePackedInto(input, out)
|
|
281
331
|
return out.subarray(0, w)
|
|
282
332
|
}
|
|
@@ -288,7 +338,7 @@ export function buildHttp(ctx: RustClientContext) {
|
|
|
288
338
|
cookieParsePacked(input: Uint8Array): Uint8Array {
|
|
289
339
|
const f = ffi()
|
|
290
340
|
if (f) {
|
|
291
|
-
const out = new Uint8Array(input.length *
|
|
341
|
+
const out = new Uint8Array(input.length * 5 + 8)
|
|
292
342
|
const w = f.cookieParsePackedInto(input, out)
|
|
293
343
|
return out.subarray(0, w)
|
|
294
344
|
}
|
|
@@ -301,7 +351,7 @@ export function buildHttp(ctx: RustClientContext) {
|
|
|
301
351
|
const f = ffi()
|
|
302
352
|
if (f) {
|
|
303
353
|
// x-www-form-urlencoded uses the SAME packed-pairs core as query parse.
|
|
304
|
-
const out = new Uint8Array(input.length *
|
|
354
|
+
const out = new Uint8Array(input.length * 5 + 8)
|
|
305
355
|
const w = f.formParsePackedInto(input, out)
|
|
306
356
|
return out.subarray(0, w)
|
|
307
357
|
}
|
|
@@ -215,7 +215,10 @@ export interface RustScalar {
|
|
|
215
215
|
createMediaTypeMatcher(expected: Uint8Array): MediaTypeMatcherInstance
|
|
216
216
|
/**
|
|
217
217
|
* Sharded fixed-window per-key rate limiter. `maxEntries` clamps internally
|
|
218
|
-
* (default 1,048,576)
|
|
218
|
+
* (default 1,048,576) and has an effective floor of 16,384
|
|
219
|
+
* (`max_per_shard = max(maxEntries / 256, 64)` × 256 shards) — values below
|
|
220
|
+
* that are silently raised, so don't rely on it to bound a tiny key space.
|
|
221
|
+
* Each instance owns an independent budget.
|
|
219
222
|
* @remarks Scalar per-check cost: ~3-14x SLOWER than the JS Map baseline —
|
|
220
223
|
* prefer the ingress pipeline (one FFI for all stages) for native rate
|
|
221
224
|
* limiting in a request path. [measured 2026-08]
|
|
@@ -234,6 +237,20 @@ export interface RustScalar {
|
|
|
234
237
|
createMetricsRegistry(): MetricsRegistryInstance
|
|
235
238
|
initThreadPool(rayonThreads?: number): void
|
|
236
239
|
rayonNumThreads(): number
|
|
240
|
+
/**
|
|
241
|
+
* Drop the process-wide compiled request-body schema cache. Identical schema
|
|
242
|
+
* bytes are compiled once and shared across `Ingress` / `NativeRoute`
|
|
243
|
+
* instances; this releases those compiled validators (already-constructed
|
|
244
|
+
* instances keep their own shared reference and are unaffected). Idempotent,
|
|
245
|
+
* and a safe no-op when the native addon is absent or predates the symbol
|
|
246
|
+
* (the public `flushMemory()` relies on that).
|
|
247
|
+
*
|
|
248
|
+
* Maintenance-only escape hatch — NOT a benchmarked op, so it is intentionally
|
|
249
|
+
* exempt from `PROVEN_SELECTION` / `selection.json` (there is no native-vs-JS
|
|
250
|
+
* winner to classify). `test/unit/contract/proven.test.ts` only requires
|
|
251
|
+
* registry coverage for selection.json ops + Bun built-ins.
|
|
252
|
+
*/
|
|
253
|
+
clearSchemaCache(): void
|
|
237
254
|
|
|
238
255
|
// ── Backend-framework scalar features ──
|
|
239
256
|
jwtSign(
|
|
@@ -72,6 +72,11 @@ export function buildPayload(ctx: RustClientContext) {
|
|
|
72
72
|
// Lazy-memoized ffi surface: binds on first call, single local read after.
|
|
73
73
|
const ffi = memoizeFfi(transport)
|
|
74
74
|
|
|
75
|
+
// Per-Worker reused scratch for the object-returning multipartParse (the
|
|
76
|
+
// packed bytes are consumed synchronously — data is copied out, so the
|
|
77
|
+
// scratch never escapes; NOT shared with the byte-returning packed paths).
|
|
78
|
+
let SCRATCH: Uint8Array | null = null
|
|
79
|
+
|
|
75
80
|
return {
|
|
76
81
|
gzipCompress(data: Uint8Array, level?: number | null): Uint8Array {
|
|
77
82
|
// Optimal by default under Bun: `Bun.gzipSync` (native zlib) is ~2x
|
|
@@ -140,8 +145,29 @@ export function buildPayload(ctx: RustClientContext) {
|
|
|
140
145
|
multipartParse(body: Uint8Array, boundary: Uint8Array): MultipartPart[] {
|
|
141
146
|
// FFI-first: packed parts (castrum_multipart_parse_packed) → unpack to
|
|
142
147
|
// the object shape. napi keeps its object path.
|
|
148
|
+
//
|
|
149
|
+
// REUSED SCRATCH: the unpack consumes the packed bytes synchronously and
|
|
150
|
+
// each part's `data` is COPIED out (napi parity) — nothing aliases the
|
|
151
|
+
// scratch after return, so the escaping-buffer rule does not apply here.
|
|
152
|
+
// Sizing matches the allocating sibling (body + boundary + 64).
|
|
143
153
|
const f = ffi()
|
|
144
|
-
if (f)
|
|
154
|
+
if (f) {
|
|
155
|
+
const need = body.length + boundary.length + 64
|
|
156
|
+
// Keep retained scratch bounded; the allocating transport handles exact
|
|
157
|
+
// growth and the output cap for large uploads. The public Into wrapper
|
|
158
|
+
// THROWS on insufficient capacity, rather than returning a needed size.
|
|
159
|
+
if (need > 64 * 1024) return unpackMultipart(f.multipartParsePacked(body, boundary))
|
|
160
|
+
if (!SCRATCH || SCRATCH.length < need) SCRATCH = new Uint8Array(need)
|
|
161
|
+
let w: number
|
|
162
|
+
try {
|
|
163
|
+
w = f.multipartParsePackedInto(body, boundary, SCRATCH)
|
|
164
|
+
} catch {
|
|
165
|
+
// An estimate miss (e.g. UTF-8 replacement expansion) uses the same
|
|
166
|
+
// checked allocating path. Malformed input still fails there.
|
|
167
|
+
return unpackMultipart(f.multipartParsePacked(body, boundary))
|
|
168
|
+
}
|
|
169
|
+
return unpackMultipart(SCRATCH.subarray(0, w))
|
|
170
|
+
}
|
|
145
171
|
// Normalize napi `Option<String>` (undefined) → null and expose the
|
|
146
172
|
// camelCase `contentType` key (napi renames `content_type` to camelCase).
|
|
147
173
|
return addon.multipartParse(body, boundary).map((p) => ({
|
package/src/rust-ffi/text.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// napi fallback) — no inline `isBun()` / `getBunFFI()`.
|
|
15
15
|
|
|
16
16
|
import { decoder, encoder, hasNul } from '../shared/bytes'
|
|
17
|
-
import { type RustClientContext, resolveNative } from './context'
|
|
17
|
+
import { evictOldestHalf, mimeStrCache, type RustClientContext, resolveNative } from './context'
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
20
|
* Scratch for the byte-path `Sec-WebSocket-Accept` fallback (28 chars; the
|
|
@@ -67,9 +67,9 @@ export function buildText(ctx: RustClientContext): RustText {
|
|
|
67
67
|
const { builtins, transport } = ctx.runtime
|
|
68
68
|
// String memo: the byte path already caches the native bytes; this avoids the
|
|
69
69
|
// encode → native lookup → slice → decode round-trip for repeated extension
|
|
70
|
-
// strings (the common case) — 0 allocations on a cache hit.
|
|
71
|
-
|
|
72
|
-
|
|
70
|
+
// strings (the common case) — 0 allocations on a cache hit. The map is
|
|
71
|
+
// process-wide and bounded by `evictOldestHalf` (MIME_CACHE_MAX) — see
|
|
72
|
+
// ./context.ts.
|
|
73
73
|
return {
|
|
74
74
|
mimeFromExtension(ext) {
|
|
75
75
|
let mime = mimeStrCache.get(ext)
|
|
@@ -81,6 +81,7 @@ export function buildText(ctx: RustClientContext): RustText {
|
|
|
81
81
|
mime = f
|
|
82
82
|
? (f.mimeFromExtension(ext) ?? 'application/octet-stream')
|
|
83
83
|
: decoder.decode(ctx.cachedMime(encoder.encode(ext)))
|
|
84
|
+
evictOldestHalf(mimeStrCache)
|
|
84
85
|
mimeStrCache.set(ext, mime)
|
|
85
86
|
}
|
|
86
87
|
return mime
|