castrum 0.9.7 → 0.9.8
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 +501 -5
- package/dist/index.js +1648 -1325
- package/docs/BENCHMARKS.md +13 -0
- package/docs/CASE_STUDY.md +1 -1
- package/docs/ENVIRONMENT.md +1 -1
- package/docs/FFI_BUN_GUIDE.md +1 -1
- package/docs/INGRESS.md +4 -4
- package/docs/REPO_MAP.md +7 -5
- package/docs/superpowers/plans/2026-09-18-castrum-hardening-cleanup.md +617 -0
- package/index.ts +1 -0
- package/package.json +3 -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 +51 -32
- 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/native_route.rs +21 -3
- package/rust/task/completion.rs +7 -0
- package/rust/task/mod.rs +1 -1
- package/rust/task/tests.rs +30 -0
- 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 +103 -31
- package/src/ingress/headers/memoized-headers.ts +40 -0
- 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 +14 -2
- package/src/ingress/routes/common.ts +26 -10
- package/src/ingress/routes/echo.ts +50 -7
- package/src/ingress/routes/json-write.ts +21 -8
- package/src/ingress/routes/responder.ts +4 -4
- package/src/ingress/server-node.ts +33 -14
- package/src/ingress/server.ts +3 -4
- package/src/ingress/shared.ts +1 -1
- package/src/ingress/types.ts +15 -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.ts +5 -1
- package/src/native/ffi/selftest.ts +3 -3
- package/src/native/ffi/symbols.ts +460 -0
- package/src/native/ffi.ts +2 -434
- package/src/native/ingress-binding.ts +39 -0
- package/src/runtime/codec.ts +24 -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/options.ts +19 -0
- package/src/rust-ffi/scalar/crypto.ts +8 -0
- package/src/rust-ffi/scalar/factories.ts +47 -20
- package/src/rust-ffi/scalar/http.ts +60 -10
- package/src/rust-ffi/scalar/interface.ts +4 -1
- package/src/rust-ffi/scalar/payload.ts +27 -1
- package/src/shared/buffer-pool.ts +44 -9
- package/src/shared/codec.ts +26 -0
- package/src/shared/packed/parsers.ts +64 -7
- package/src/task/runtime.ts +29 -28
|
@@ -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
|
},
|
|
@@ -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]
|
|
@@ -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) => ({
|
|
@@ -39,6 +39,21 @@
|
|
|
39
39
|
* converge on one buffer size instead of re-growing each time. Default false.
|
|
40
40
|
*/
|
|
41
41
|
adaptive?: boolean
|
|
42
|
+
/**
|
|
43
|
+
* Number of buffers (beyond the initial one) to ALLOCATE UP FRONT at
|
|
44
|
+
* construction, so a burst-ramp (e.g. a load-test connection flood) never
|
|
45
|
+
* pays per-request `new Uint8Array` + GC during its first wave. A pure
|
|
46
|
+
* memory-for-latency trade: retained memory becomes `prefill + 1` ×
|
|
47
|
+
* `initialSize` (still bounded by `maxBuffers`). Default 0.
|
|
48
|
+
*/
|
|
49
|
+
prefill?: number
|
|
50
|
+
/**
|
|
51
|
+
* Byte budget for `prefill`: the prefilled count is additionally capped to
|
|
52
|
+
* `floor(prefillBytes / initialSize)`, so a large `initialSize` (e.g. a
|
|
53
|
+
* 64 MiB output buffer) cannot multiply into gigabytes when a router creates
|
|
54
|
+
* one pool per route. Default `Infinity` (only `prefill`/`maxBuffers` apply).
|
|
55
|
+
*/
|
|
56
|
+
prefillBytes?: number
|
|
42
57
|
}
|
|
43
58
|
|
|
44
59
|
/** A borrowed buffer handle handed out by a pool; `release()` returns it. */
|
|
@@ -138,6 +153,22 @@ export class BufferPool {
|
|
|
138
153
|
: null
|
|
139
154
|
this.free.push(new Uint8Array(this.initialSize))
|
|
140
155
|
this.created = 1
|
|
156
|
+
// Pre-fill (memory-for-latency trade, see `prefill` in BufferPoolOptions):
|
|
157
|
+
// spend up-front allocation so a burst ramp never allocates during its
|
|
158
|
+
// first wave. Capped at maxBuffers so the documented retention bound
|
|
159
|
+
// (free list ≤ maxBuffers) still holds, and by `prefillBytes` so a large
|
|
160
|
+
// `initialSize` cannot multiply into an unbounded up-front allocation.
|
|
161
|
+
const prefill = Math.min(
|
|
162
|
+
Math.max(0, Math.floor(options.prefill ?? 0)),
|
|
163
|
+
this.maxBuffers - 1,
|
|
164
|
+
options.prefillBytes !== undefined
|
|
165
|
+
? Math.max(0, Math.floor(options.prefillBytes / this.initialSize))
|
|
166
|
+
: Number.POSITIVE_INFINITY,
|
|
167
|
+
)
|
|
168
|
+
for (let i = 0; i < prefill; i++) {
|
|
169
|
+
this.free.push(new Uint8Array(this.initialSize))
|
|
170
|
+
this.created++
|
|
171
|
+
}
|
|
141
172
|
}
|
|
142
173
|
|
|
143
174
|
/** Number of buffers currently available for immediate reuse. */
|
|
@@ -204,11 +235,14 @@ export class BufferPool {
|
|
|
204
235
|
// Learn the observed demand so future allocations pre-size to it.
|
|
205
236
|
this.estimate?.sample(minSize)
|
|
206
237
|
|
|
207
|
-
// 1) Reuse a free buffer that is already large enough.
|
|
238
|
+
// 1) Reuse a free buffer that is already large enough. Free-list ORDER is
|
|
239
|
+
// irrelevant, so remove by swap-with-last (O(1), no splice compaction/
|
|
240
|
+
// allocation) — this is a per-request hot path.
|
|
208
241
|
for (let i = 0; i < this.free.length; i++) {
|
|
209
242
|
const candidate = this.free[i] as Uint8Array
|
|
210
243
|
if (candidate.byteLength >= minSize) {
|
|
211
|
-
this.free.
|
|
244
|
+
const last = this.free.pop() as Uint8Array
|
|
245
|
+
if (i < this.free.length) this.free[i] = last
|
|
212
246
|
return candidate
|
|
213
247
|
}
|
|
214
248
|
}
|
|
@@ -227,7 +261,8 @@ export class BufferPool {
|
|
|
227
261
|
}
|
|
228
262
|
}
|
|
229
263
|
if (largestBuf !== null) {
|
|
230
|
-
this.free.
|
|
264
|
+
const last = this.free.pop() as Uint8Array
|
|
265
|
+
if (largest < this.free.length) this.free[largest] = last
|
|
231
266
|
this.created++
|
|
232
267
|
return new Uint8Array(target)
|
|
233
268
|
}
|
|
@@ -241,16 +276,16 @@ export class BufferPool {
|
|
|
241
276
|
private releaseBuffer(buffer: Uint8Array): void {
|
|
242
277
|
if (this.free.length >= this.maxBuffers) {
|
|
243
278
|
// Free list is full: drop the smallest retained buffer to stay bounded.
|
|
244
|
-
let smallestBuf: Uint8Array | null = null
|
|
245
279
|
let smallest = 0
|
|
246
|
-
for (let i =
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
280
|
+
for (let i = 1; i < this.free.length; i++) {
|
|
281
|
+
if (
|
|
282
|
+
(this.free[i] as Uint8Array).byteLength < (this.free[smallest] as Uint8Array).byteLength
|
|
283
|
+
) {
|
|
250
284
|
smallest = i
|
|
251
285
|
}
|
|
252
286
|
}
|
|
253
|
-
this.free.
|
|
287
|
+
const last = this.free.pop() as Uint8Array
|
|
288
|
+
if (smallest < this.free.length) this.free[smallest] = last
|
|
254
289
|
}
|
|
255
290
|
this.free.push(buffer)
|
|
256
291
|
}
|
package/src/shared/codec.ts
CHANGED
|
@@ -79,6 +79,32 @@ export const decodeUtf8: (bytes: Uint8Array) => string = runtimeCodec.decodeUtf8
|
|
|
79
79
|
export const decodeUtf8Range: (bytes: Uint8Array, start: number, end: number) => string =
|
|
80
80
|
runtimeCodec.decodeUtf8Range
|
|
81
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Decode a range of a PREPARED bounded Buffer `view` (absolute offsets into
|
|
84
|
+
* the view) to a string — replacement mode on invalid. Companion to
|
|
85
|
+
* `decodeUtf8Range` for packed-wire unpackers that decode MANY fields from
|
|
86
|
+
* ONE packed result: the caller resolves the Buffer view once and every field
|
|
87
|
+
* decode skips the per-field cache lookup + offset arithmetic.
|
|
88
|
+
*
|
|
89
|
+
* Falls back to `decodeUtf8Range` when the runtime codec does not provide the
|
|
90
|
+
* view variant (older adapters / custom codecs).
|
|
91
|
+
*
|
|
92
|
+
* @param view - A bounded Buffer over the packed bytes.
|
|
93
|
+
* @param start - Range start (inclusive, relative to the view).
|
|
94
|
+
* @param end - Range end (exclusive, relative to the view).
|
|
95
|
+
* @returns The decoded string.
|
|
96
|
+
*
|
|
97
|
+
* @example
|
|
98
|
+
* ```ts
|
|
99
|
+
* const view = Buffer.from(packed.buffer, packed.byteOffset, packed.byteLength)
|
|
100
|
+
* decodeUtf8RangeView(view, 4, 24) // → 'multipart/form-data'
|
|
101
|
+
* ```
|
|
102
|
+
*/
|
|
103
|
+
export const decodeUtf8RangeView: (view: Buffer, start: number, end: number) => string =
|
|
104
|
+
runtimeCodec.decodeUtf8RangeView
|
|
105
|
+
? runtimeCodec.decodeUtf8RangeView
|
|
106
|
+
: (view, start, end) => decodeUtf8Range(view, start, end)
|
|
107
|
+
|
|
82
108
|
/**
|
|
83
109
|
* Decode UTF-8 bytes to a string, throwing on invalid UTF-8.
|
|
84
110
|
*
|
|
@@ -4,28 +4,85 @@
|
|
|
4
4
|
// don't have to hand-pack buffers or decode packed pairs:
|
|
5
5
|
// parseQueryString("a=1&b=2") // { a: "1", b: "2" }
|
|
6
6
|
// parseCookieHeader("a=1; b=2") // { a: "1", b: "2" }
|
|
7
|
+
//
|
|
8
|
+
// Transport: bun:ffi PRIMARY — the packed pairs are consumed SYNCHRONOUSLY
|
|
9
|
+
// (readPairsPacked + pairsToObject materialize strings before return), so a
|
|
10
|
+
// reused per-parser scratch is safe (same model as the scalar unpackers);
|
|
11
|
+
// the bytes never escape. Node / `CASTRUM_FFI_MODE=napi` / bind failure fall
|
|
12
|
+
// back to the napi addon (same wire output — pinned by packed-parsers-routing).
|
|
7
13
|
|
|
14
|
+
import { getBunFFI } from '../../native/ffi'
|
|
8
15
|
import { getAddon, lazyAddon } from '../../native'
|
|
9
16
|
import { encoder } from '../bytes'
|
|
10
17
|
import { pairsToObject, readPairsPacked } from './wire'
|
|
11
18
|
|
|
12
|
-
// Lazy: importing this module does not dlopen the addon until first use
|
|
19
|
+
// Lazy: importing this module does not dlopen the addon until first use
|
|
20
|
+
// (getBunFFI binds on first call; the napi proxy loads on first get).
|
|
13
21
|
const addon = lazyAddon(getAddon)
|
|
14
22
|
|
|
23
|
+
// FFI scratch (documented impure module state, per-Worker): grown lazily and
|
|
24
|
+
// reused across calls — the parsed result is a JS object, the packed bytes
|
|
25
|
+
// never escape.
|
|
26
|
+
const ffiCaches: Record<'query' | 'cookie' | 'form', Uint8Array | null> = {
|
|
27
|
+
query: null,
|
|
28
|
+
cookie: null,
|
|
29
|
+
form: null,
|
|
30
|
+
}
|
|
31
|
+
function scratchFor(kind: 'query' | 'cookie' | 'form', input: Uint8Array): Uint8Array {
|
|
32
|
+
// Packed pairs worst case ≈ 9× input (u32 len + bytes per key/value) + header.
|
|
33
|
+
const need = input.length * 9 + 16
|
|
34
|
+
let s = ffiCaches[kind]
|
|
35
|
+
if (s === null || s.length < need) {
|
|
36
|
+
s = new Uint8Array(need)
|
|
37
|
+
ffiCaches[kind] = s
|
|
38
|
+
}
|
|
39
|
+
return s
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parsePackedInto(
|
|
43
|
+
kind: 'query' | 'cookie' | 'form',
|
|
44
|
+
input: Uint8Array,
|
|
45
|
+
): Record<string, string | string[]> | null {
|
|
46
|
+
const f = getBunFFI()
|
|
47
|
+
if (!f) return null
|
|
48
|
+
const out = scratchFor(kind, input)
|
|
49
|
+
const w =
|
|
50
|
+
kind === 'query'
|
|
51
|
+
? f.queryParsePackedInto(input, out)
|
|
52
|
+
: kind === 'cookie'
|
|
53
|
+
? f.cookieParsePackedInto(input, out)
|
|
54
|
+
: f.formParsePackedInto(input, out)
|
|
55
|
+
if (w === 0 || w > out.length) return null
|
|
56
|
+
return pairsToObject(readPairsPacked(out.subarray(0, w)))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Shared FFI-first parse with an napi fallback (one encode, per kind). */
|
|
60
|
+
function parseVia(
|
|
61
|
+
kind: 'query' | 'cookie' | 'form',
|
|
62
|
+
input: Uint8Array,
|
|
63
|
+
): Record<string, string | string[]> {
|
|
64
|
+
const fast = parsePackedInto(kind, input)
|
|
65
|
+
if (fast) return fast
|
|
66
|
+
const packed =
|
|
67
|
+
kind === 'query'
|
|
68
|
+
? addon.queryParsePacked(input)
|
|
69
|
+
: kind === 'cookie'
|
|
70
|
+
? addon.cookieParsePacked(input)
|
|
71
|
+
: addon.formParsePacked(input)
|
|
72
|
+
return pairsToObject(readPairsPacked(packed))
|
|
73
|
+
}
|
|
74
|
+
|
|
15
75
|
/** Parse a query string (`a=1&b=2`) into an object via the native parser. */
|
|
16
76
|
export function parseQueryString(query: string): Record<string, string | string[]> {
|
|
17
|
-
|
|
18
|
-
return pairsToObject(readPairsPacked(packed))
|
|
77
|
+
return parseVia('query', encoder.encode(query))
|
|
19
78
|
}
|
|
20
79
|
|
|
21
80
|
/** Parse a cookie header (`a=1; b=2`) into an object via the native parser. */
|
|
22
81
|
export function parseCookieHeader(header: string): Record<string, string | string[]> {
|
|
23
|
-
|
|
24
|
-
return pairsToObject(readPairsPacked(packed))
|
|
82
|
+
return parseVia('cookie', encoder.encode(header))
|
|
25
83
|
}
|
|
26
84
|
|
|
27
85
|
/** Parse an application/x-www-form-urlencoded body into a key/value object. */
|
|
28
86
|
export function parseFormBody(body: Uint8Array): Record<string, string | string[]> {
|
|
29
|
-
|
|
30
|
-
return pairsToObject(readPairsPacked(packed))
|
|
87
|
+
return parseVia('form', body)
|
|
31
88
|
}
|