castrum 0.9.3 → 0.9.4
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 +2259 -0
- package/Cargo.toml +138 -0
- package/README.md +6 -0
- package/build.rs +5 -0
- package/castrum.linux-x64-gnu.node +0 -0
- package/dist/index.d.ts +1348 -1295
- package/dist/index.js +4307 -3776
- package/docs/API.md +1 -1
- package/docs/ARCHITECTURE.md +3 -2
- package/docs/CASE_STUDY.md +2 -2
- package/docs/ENVIRONMENT.md +16 -0
- package/docs/FFI_BUN_GUIDE.md +89 -6
- package/docs/REPO_MAP.md +2 -2
- package/index.ts +19 -25
- package/package.json +9 -1
- package/rust/crypto/aead.rs +378 -0
- package/rust/crypto/argon2.rs +265 -0
- package/rust/crypto/base64.rs +420 -0
- package/rust/crypto/bcrypt.rs +89 -0
- package/rust/crypto/cookie_sign.rs +169 -0
- package/rust/crypto/csrf.rs +106 -0
- package/rust/crypto/ed25519.rs +215 -0
- package/rust/crypto/hashing.rs +140 -0
- package/rust/crypto/hmac_sha256.rs +88 -0
- package/rust/crypto/jwt/api.rs +286 -0
- package/rust/crypto/jwt/mod.rs +22 -0
- package/rust/crypto/jwt/tests.rs +312 -0
- package/rust/crypto/jwt/token.rs +360 -0
- package/rust/crypto/mod.rs +27 -0
- package/rust/crypto/pbkdf2.rs +96 -0
- package/rust/crypto/random_token.rs +85 -0
- package/rust/crypto/session.rs +222 -0
- package/rust/ffi/crypto.rs +708 -0
- package/rust/ffi/hashing.rs +285 -0
- package/rust/ffi/http.rs +752 -0
- package/rust/ffi/ingress.rs +303 -0
- package/rust/ffi/json.rs +517 -0
- package/rust/ffi/jwt.rs +419 -0
- package/rust/ffi/metrics.rs +373 -0
- package/rust/ffi/mod.rs +68 -0
- package/rust/ffi/payload.rs +275 -0
- package/rust/ffi/probe.rs +60 -0
- package/rust/ffi/rate_limit.rs +77 -0
- package/rust/ffi/route.rs +85 -0
- package/rust/ffi/tests.rs +2333 -0
- package/rust/ffi/util.rs +152 -0
- package/rust/ffi/validators.rs +123 -0
- package/rust/http/accept.rs +606 -0
- package/rust/http/cookie_parser.rs +140 -0
- package/rust/http/etag.rs +289 -0
- package/rust/http/form.rs +132 -0
- package/rust/http/headers.rs +267 -0
- package/rust/http/http_date.rs +286 -0
- package/rust/http/http_parser.rs +252 -0
- package/rust/http/media_type.rs +355 -0
- package/rust/http/method.rs +200 -0
- package/rust/http/mime_lookup.rs +130 -0
- package/rust/http/mod.rs +32 -0
- package/rust/http/multipart.rs +685 -0
- package/rust/http/query_parser.rs +180 -0
- package/rust/http/url_codec.rs +296 -0
- package/rust/http/url_join.rs +324 -0
- package/rust/ingress/api.rs +355 -0
- package/rust/ingress/cors.rs +403 -0
- package/rust/ingress/ingress_constants.rs +209 -0
- package/rust/ingress/ip_trust.rs +312 -0
- package/rust/ingress/mod.rs +48 -0
- package/rust/ingress/native_route.rs +779 -0
- package/rust/ingress/options.rs +176 -0
- package/rust/ingress/output.rs +406 -0
- package/rust/ingress/packed.rs +191 -0
- package/rust/ingress/pipeline.rs +534 -0
- package/rust/ingress/proxy.rs +138 -0
- package/rust/ingress/rate_limit.rs +538 -0
- package/rust/ingress/terminal.rs +114 -0
- package/rust/ingress/tests.rs +566 -0
- package/rust/ingress/time.rs +70 -0
- package/rust/json/fast_schema/capture.rs +336 -0
- package/rust/json/fast_schema/compile.rs +556 -0
- package/rust/json/fast_schema/cursor.rs +368 -0
- package/rust/json/fast_schema/email.rs +282 -0
- package/rust/json/fast_schema/errors.rs +247 -0
- package/rust/json/fast_schema/mod.rs +57 -0
- package/rust/json/fast_schema/tests.rs +1143 -0
- package/rust/json/fast_schema/types.rs +140 -0
- package/rust/json/fast_schema/validate.rs +931 -0
- package/rust/json/json_ops.rs +121 -0
- package/rust/json/json_schema.rs +837 -0
- package/rust/json/json_ser.rs +881 -0
- package/rust/json/mod.rs +19 -0
- package/rust/json/napi_marshal.rs +318 -0
- package/rust/json/patch/api.rs +60 -0
- package/rust/json/patch/engine.rs +153 -0
- package/rust/json/patch/mod.rs +47 -0
- package/rust/json/patch/ops.rs +270 -0
- package/rust/json/patch/pointer.rs +49 -0
- package/rust/json/patch/tests.rs +499 -0
- package/rust/lib.rs +72 -0
- package/rust/metrics/api.rs +148 -0
- package/rust/metrics/mod.rs +21 -0
- package/rust/metrics/registry.rs +961 -0
- package/rust/panic_safety.rs +40 -0
- package/rust/payload/compress.rs +512 -0
- package/rust/payload/mod.rs +13 -0
- package/rust/payload/sse.rs +214 -0
- package/rust/payload/template.rs +253 -0
- package/rust/payload/websocket.rs +69 -0
- package/rust/payload/ws_frames.rs +354 -0
- package/rust/proptest_suite.rs +139 -0
- package/rust/selection.rs +95 -0
- package/rust/src/bin/native-bench.rs +495 -0
- package/rust/test_support.rs +71 -0
- package/rust/util/batch/api.rs +266 -0
- package/rust/util/batch/core.rs +473 -0
- package/rust/util/batch/mod.rs +23 -0
- package/rust/util/batch/tests.rs +445 -0
- package/rust/util/batch_core.rs +184 -0
- package/rust/util/bytes.rs +407 -0
- package/rust/util/mod.rs +34 -0
- package/rust/util/packed.rs +473 -0
- package/rust/util/text.rs +130 -0
- package/rust/util/threadpool.rs +128 -0
- package/rust/util/validation.rs +419 -0
- package/rust-toolchain.toml +17 -0
- package/scripts/postinstall.mjs +199 -0
- package/src/ingress/constants.ts +1 -1
- package/src/ingress/context.ts +2 -2
- package/src/ingress/decode/baked-result.ts +13 -13
- package/src/ingress/decode/fast-result.ts +17 -17
- package/src/ingress/errors.ts +7 -7
- package/src/ingress/headers/baked-templates.ts +1 -1
- package/src/ingress/headers/fast-templates.ts +3 -3
- package/src/ingress/index.ts +1 -1
- package/src/ingress/native-route.ts +9 -5
- package/src/ingress/options.ts +1 -1
- package/src/ingress/packing/gather-raw-headers.ts +1 -1
- package/src/ingress/packing/route-wire.ts +1 -5
- package/src/ingress/response/error-bodies.ts +5 -5
- package/src/ingress/response/terminal.ts +1 -1
- package/src/ingress/routes/echo.ts +4 -4
- package/src/ingress/routes/fallback.ts +2 -2
- package/src/ingress/routes/head.ts +2 -2
- package/src/ingress/routes/native.ts +2 -1
- package/src/ingress/routes/options.ts +1 -1
- package/src/ingress/routes/read.ts +2 -2
- package/src/ingress/server.ts +10 -6
- package/src/ingress/status.ts +7 -7
- package/src/integration/batch.ts +1 -1
- package/src/integration/index.ts +9 -9
- package/src/integration/pipeline.ts +6 -6
- package/src/integration/websocket.ts +2 -2
- package/src/loader/index.ts +1 -5
- package/src/loader/ops.ts +1 -1
- package/src/native/ffi/build/codecs.ts +104 -8
- package/src/native/ffi/build/compress.ts +14 -10
- package/src/native/ffi/build/instances.ts +40 -3
- package/src/native/ffi/build/metrics.ts +224 -0
- package/src/native/ffi/build/parse.ts +59 -23
- package/src/native/ffi/build/util.ts +14 -1
- package/src/native/ffi/build.ts +2 -0
- package/src/native/ffi/selftest.ts +10 -3
- package/src/native/ffi/types.ts +111 -9
- package/src/native/ffi.ts +87 -1
- package/src/native/index.ts +1 -1
- package/src/native/loader.ts +2 -2
- package/src/native/types/instances.ts +34 -0
- package/src/native/types.ts +11 -0
- package/src/runtime/codec.ts +53 -9
- package/src/runtime/types.ts +7 -0
- package/src/rust-ffi/batch/build.ts +16 -9
- package/src/rust-ffi/index.ts +7 -6
- package/src/rust-ffi/options.ts +1 -3
- package/src/rust-ffi/proven.ts +3 -3
- package/src/rust-ffi/scalar/crypto.ts +1 -1
- package/src/rust-ffi/scalar/factories.ts +73 -0
- package/src/rust-ffi/scalar/hashing.ts +1 -1
- package/src/rust-ffi/scalar/http.ts +17 -11
- package/src/rust-ffi/scalar/index.ts +2 -0
- package/src/rust-ffi/scalar/interface.ts +20 -0
- package/src/rust-ffi/scalar/payload.ts +11 -3
- package/src/rust-ffi/scalar/validation.ts +121 -0
- package/src/selection.ts +1 -1
- package/src/shared/bytes.ts +5 -4
- package/src/shared/codec.ts +18 -3
|
@@ -11,12 +11,29 @@ import { decodeUtf8, encodeUtf8 } from '../../../shared/codec'
|
|
|
11
11
|
import { EMPTY_VIEW, SELFTEST_HEX } from '../constants'
|
|
12
12
|
import type { BunFFI, Raw3, Raw4, Raw6, Raw7, Raw10 } from '../types'
|
|
13
13
|
import type { BuildCtx } from './util'
|
|
14
|
-
import { flag, growExact } from './util'
|
|
14
|
+
import { allocOut, flag, growExact } from './util'
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* Build the parser/wire-format methods of the BunFFI surface. `ctx` is
|
|
18
18
|
* destructured so the method bodies read exactly as the original `build()`.
|
|
19
19
|
*/
|
|
20
|
+
|
|
21
|
+
// ── SSE event/id encode memo ────────────────────────────────────────────────
|
|
22
|
+
// SSE streams repeat the same event NAME constantly ('message'/'update') and
|
|
23
|
+
// often repeat ids; re-encoding them per event is pure waste (~60ns each).
|
|
24
|
+
// One memo slot each: a `===` hit returns the cached bytes, a miss re-encodes.
|
|
25
|
+
// Per-Worker module state (single-threaded event loop), same safety model as
|
|
26
|
+
// the pooled scratches in BuildCtx.
|
|
27
|
+
const sseMemoEvent = { str: null as string | null, bytes: EMPTY_VIEW }
|
|
28
|
+
const sseMemoId = { str: null as string | null, bytes: EMPTY_VIEW }
|
|
29
|
+
function sseMemoEncode(slot: { str: string | null; bytes: Uint8Array }, s: string): Uint8Array {
|
|
30
|
+
if (s === slot.str) return slot.bytes
|
|
31
|
+
const bytes = encodeUtf8(s)
|
|
32
|
+
slot.str = s
|
|
33
|
+
slot.bytes = bytes
|
|
34
|
+
return bytes
|
|
35
|
+
}
|
|
36
|
+
|
|
20
37
|
export function buildParse(
|
|
21
38
|
sym: Record<string, (...a: unknown[]) => unknown>,
|
|
22
39
|
ctx: BuildCtx,
|
|
@@ -67,8 +84,9 @@ export function buildParse(
|
|
|
67
84
|
return w
|
|
68
85
|
},
|
|
69
86
|
wsFrameEncode(opcode, payload, mask, fin) {
|
|
70
|
-
// Header (max 10) + payload + mask key (4).
|
|
71
|
-
|
|
87
|
+
// Header (max 10) + payload + mask key (4). allocOut skips the zeroing
|
|
88
|
+
// (the native write fills [0,w) and only that slice escapes).
|
|
89
|
+
const out = allocOut(payload.length + 14)
|
|
72
90
|
const w = Number(
|
|
73
91
|
wsFrameEncode(
|
|
74
92
|
opcode,
|
|
@@ -182,15 +200,24 @@ export function buildParse(
|
|
|
182
200
|
parseMediaType(input) {
|
|
183
201
|
// Packed verdict: [u32 mediaTypeLen][mediaType][u32 charsetLen
|
|
184
202
|
// (0xFFFFFFFF = none)][charset][u32 boundaryLen][boundary]
|
|
185
|
-
// [u32 paramCount]{[u32 keyLen][key][u32 valLen][val]}.
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
)
|
|
203
|
+
// [u32 paramCount]{[u32 keyLen][key][u32 valLen][val]}.
|
|
204
|
+
// POOLED SCRATCH: the only consumer (the scalar `unpackMediaType`)
|
|
205
|
+
// decodes the verdict synchronously into a JS object before the next
|
|
206
|
+
// call, so the packed bytes never need to outlive this call — no
|
|
207
|
+
// growExact fresh-buffer per request. 0 = invalid media type (throw).
|
|
208
|
+
// 2x + 128 covers the worst-case packed expansion (every byte becomes a
|
|
209
|
+
// param key/value length prefix); a still-too-small write falls back to
|
|
210
|
+
// ONE exact-size buffer (needed-size convention).
|
|
211
|
+
let out = scratchFor(input.length * 2 + 128)
|
|
212
|
+
let w = Number(parseMediaTypeSym(input, lenOrView(input), out, lenOrView(out)))
|
|
213
|
+
if (w > out.length) {
|
|
214
|
+
out = new Uint8Array(w)
|
|
215
|
+
w = Number(parseMediaTypeSym(input, lenOrView(input), out, lenOrView(out)))
|
|
216
|
+
}
|
|
217
|
+
if (w === 0 || w > out.length) {
|
|
218
|
+
throw new Error('media type parse: invalid media type or output buffer too small')
|
|
219
|
+
}
|
|
220
|
+
return out.subarray(0, w)
|
|
194
221
|
},
|
|
195
222
|
parseHttpDate(input) {
|
|
196
223
|
// Packed [u8 ok][i64 secs LE] (9 B) / 1 B (ok=0). A too-small buffer is
|
|
@@ -207,13 +234,18 @@ export function buildParse(
|
|
|
207
234
|
},
|
|
208
235
|
parseAcceptEncoding(input) {
|
|
209
236
|
// Packed: [u32 count]{[u32 encLen][enc][f32 q][u32 order]} (empty header →
|
|
210
|
-
// count 0, 4 bytes).
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
237
|
+
// count 0, 4 bytes). POOLED SCRATCH: consumed synchronously by the scalar
|
|
238
|
+
// `unpackAcceptEncoding` — never escapes as raw bytes. 0 = too small.
|
|
239
|
+
let out = scratchFor(input.length * 2 + 128)
|
|
240
|
+
let w = Number(parseAcceptEncodingSym(input, lenOrView(input), out, lenOrView(out)))
|
|
241
|
+
if (w > out.length) {
|
|
242
|
+
out = new Uint8Array(w)
|
|
243
|
+
w = Number(parseAcceptEncodingSym(input, lenOrView(input), out, lenOrView(out)))
|
|
244
|
+
}
|
|
245
|
+
if (w === 0 || w > out.length) {
|
|
246
|
+
throw new Error('accept encoding parse: output buffer too small')
|
|
247
|
+
}
|
|
248
|
+
return out.subarray(0, w)
|
|
217
249
|
},
|
|
218
250
|
urlEncodeQuery(input) {
|
|
219
251
|
// Packed pairs `[u32 count]{[u32 keyLen][key][u32 valLen][val]}` (the JS
|
|
@@ -255,8 +287,12 @@ export function buildParse(
|
|
|
255
287
|
// non-null (flag bits 1/2/4 = present) so a present-but-empty string is
|
|
256
288
|
// distinct from absent (napi Option parity). growExact with the needed-size
|
|
257
289
|
// convention; `data.length + 64` covers the common single-line case.
|
|
258
|
-
|
|
259
|
-
|
|
290
|
+
// NOTE (2026-08-24): cstring-ARG variants were once blamed for JIT
|
|
291
|
+
// crashes here — docs/FFI_BUN_GUIDE.md §14 traced every report to
|
|
292
|
+
// dev-loop ABI desync, not bun:ffi. The memo captures the win without
|
|
293
|
+
// an ABI change anyway.
|
|
294
|
+
const ev = event === null ? EMPTY_VIEW : sseMemoEncode(sseMemoEvent, event)
|
|
295
|
+
const idv = id === null ? EMPTY_VIEW : sseMemoEncode(sseMemoId, id)
|
|
260
296
|
const flags = (event === null ? 0 : 1) | (id === null ? 0 : 2) | (retry === null ? 0 : 4)
|
|
261
297
|
return growExact(
|
|
262
298
|
(out) =>
|
|
@@ -283,8 +319,8 @@ export function buildParse(
|
|
|
283
319
|
// Pooled sibling — caller-owned output buffer (sized ≥ `data.length + 64`
|
|
284
320
|
// for the common single-line case). Needed-size convention: w > output.length
|
|
285
321
|
// = exact required size → throw; w === 0 = real error (invalid UTF-8).
|
|
286
|
-
const ev = event === null ? EMPTY_VIEW :
|
|
287
|
-
const idv = id === null ? EMPTY_VIEW :
|
|
322
|
+
const ev = event === null ? EMPTY_VIEW : sseMemoEncode(sseMemoEvent, event)
|
|
323
|
+
const idv = id === null ? EMPTY_VIEW : sseMemoEncode(sseMemoId, id)
|
|
288
324
|
const flags = (event === null ? 0 : 1) | (id === null ? 0 : 2) | (retry === null ? 0 : 4)
|
|
289
325
|
const w = Number(
|
|
290
326
|
sseEncodeIntoRaw(
|
|
@@ -42,6 +42,19 @@ export interface BuildCtx {
|
|
|
42
42
|
/** Coerce an optional boolean to the C `u8` flag (1/0). */
|
|
43
43
|
export const flag = (v?: boolean): number => (v ? 1 : 0)
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Output-buffer allocation for the FFI wrappers. The native write fills
|
|
47
|
+
* `[0, w)` and only `subarray(0, w)` escapes, so zero-initialization is pure
|
|
48
|
+
* waste: `Buffer.allocUnsafe` skips it (measured ~2x at 4 KB, ~3.4x at 16 KB
|
|
49
|
+
* on Bun 1.4; small sizes stay on the plain constructor where it is faster).
|
|
50
|
+
*/
|
|
51
|
+
export function allocOut(n: number): Uint8Array {
|
|
52
|
+
if (n >= 512 && typeof Buffer !== 'undefined' && typeof Buffer.allocUnsafe === 'function') {
|
|
53
|
+
return Buffer.allocUnsafe(n)
|
|
54
|
+
}
|
|
55
|
+
return new Uint8Array(n)
|
|
56
|
+
}
|
|
57
|
+
|
|
45
58
|
/**
|
|
46
59
|
* Write with the C ABI's "needed" convention. The C side returns the EXACT
|
|
47
60
|
* total bytes the result requires: `0` = real error (throw immediately — no
|
|
@@ -59,7 +72,7 @@ export function growExact(
|
|
|
59
72
|
): Uint8Array {
|
|
60
73
|
let cap = Math.min(Math.max(initial, 16), max)
|
|
61
74
|
for (;;) {
|
|
62
|
-
const out =
|
|
75
|
+
const out = allocOut(cap)
|
|
63
76
|
const w = Number(write(out))
|
|
64
77
|
if (w === 0) throw new Error(error)
|
|
65
78
|
if (w <= out.length) return out.subarray(0, w)
|
package/src/native/ffi/build.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import { buildCodecs } from './build/codecs'
|
|
15
15
|
import { buildCompress } from './build/compress'
|
|
16
16
|
import { buildInstances } from './build/instances'
|
|
17
|
+
import { buildMetrics } from './build/metrics'
|
|
17
18
|
import { buildParse } from './build/parse'
|
|
18
19
|
import type { BuildCtx } from './build/util'
|
|
19
20
|
import type { BunFFI } from './types'
|
|
@@ -90,5 +91,6 @@ export function build(
|
|
|
90
91
|
...buildCompress(sym, ctx),
|
|
91
92
|
...buildParse(sym, ctx),
|
|
92
93
|
...buildInstances(sym, ctx),
|
|
94
|
+
...buildMetrics(sym, ctx),
|
|
93
95
|
} as BunFFI
|
|
94
96
|
}
|
|
@@ -7,16 +7,23 @@
|
|
|
7
7
|
// the safety net. New C-ABI symbols MUST be added to the self-test for their
|
|
8
8
|
// domain: each check lives in the same `ffi/build/` file where the method is
|
|
9
9
|
// bound (`selfTestCodecs` / `selfTestCompress` / `selfTestParse` /
|
|
10
|
-
// `selfTestInstances`), and this entry point ANDs them
|
|
11
|
-
// failure disables the whole layer.
|
|
10
|
+
// `selfTestInstances` / `selfTestMetrics`), and this entry point ANDs them
|
|
11
|
+
// together so any single failure disables the whole layer.
|
|
12
12
|
|
|
13
13
|
import { selfTestCodecs } from './build/codecs'
|
|
14
14
|
import { selfTestCompress } from './build/compress'
|
|
15
15
|
import { selfTestInstances } from './build/instances'
|
|
16
|
+
import { selfTestMetrics } from './build/metrics'
|
|
16
17
|
import { selfTestParse } from './build/parse'
|
|
17
18
|
import type { BunFFI } from './types'
|
|
18
19
|
|
|
19
20
|
/** Verify every bound function against known-good results; false disables ffi. */
|
|
20
21
|
export function selfTest(b: BunFFI): boolean {
|
|
21
|
-
return
|
|
22
|
+
return (
|
|
23
|
+
selfTestCodecs(b) &&
|
|
24
|
+
selfTestCompress(b) &&
|
|
25
|
+
selfTestParse(b) &&
|
|
26
|
+
selfTestInstances(b) &&
|
|
27
|
+
selfTestMetrics(b)
|
|
28
|
+
)
|
|
22
29
|
}
|
package/src/native/ffi/types.ts
CHANGED
|
@@ -29,14 +29,23 @@ export interface BunFFI {
|
|
|
29
29
|
/** RFC 3986 percent-encode into `output`; returns bytes written. */
|
|
30
30
|
urlEncodeInto(input: Uint8Array, output: Uint8Array): number
|
|
31
31
|
|
|
32
|
-
// ── Validators (u8 → boolean)
|
|
33
|
-
//
|
|
32
|
+
// ── Validators (u8 → boolean) ─────────────────────────────────────
|
|
33
|
+
// String forms cross as a `cstring` ARG: the engine transcodes the JS string
|
|
34
34
|
// to a call-scoped NUL-terminated UTF-8 buffer in-engine, so the JS side does
|
|
35
|
-
// ZERO
|
|
35
|
+
// ZERO encode work and Rust borrows via `CStr::from_ptr`. Byte forms are the
|
|
36
|
+
// `(ptr,len)` siblings — zero transcode when the caller already holds bytes.
|
|
36
37
|
validateEmail(input: string): boolean
|
|
37
38
|
validateUuid(input: string): boolean
|
|
38
39
|
validateIpv4(input: string): boolean
|
|
39
40
|
validateIpv6(input: string): boolean
|
|
41
|
+
/** Byte-input sibling of {@link validateEmail} (`(ptr,len)` — no decode). */
|
|
42
|
+
validateEmailBytes(input: Uint8Array): boolean
|
|
43
|
+
/** Byte-input sibling of {@link validateUuid} (`(ptr,len)` — no decode). */
|
|
44
|
+
validateUuidBytes(input: Uint8Array): boolean
|
|
45
|
+
/** Byte-input sibling of {@link validateIpv4} (`(ptr,len)` — no decode). */
|
|
46
|
+
validateIpv4Bytes(input: Uint8Array): boolean
|
|
47
|
+
/** Byte-input sibling of {@link validateIpv6} (`(ptr,len)` — no decode). */
|
|
48
|
+
validateIpv6Bytes(input: Uint8Array): boolean
|
|
40
49
|
/** Sum of `id` fields across a JSON array → bigint (throws on non-array input). */
|
|
41
50
|
jsonSumIds(input: Uint8Array): bigint
|
|
42
51
|
|
|
@@ -114,11 +123,7 @@ export interface BunFFI {
|
|
|
114
123
|
nowSeconds: number,
|
|
115
124
|
): string | null
|
|
116
125
|
/** EdDSA (Ed25519) JWT verify → claims JSON string; `null` = invalid. */
|
|
117
|
-
jwtEdDSAVerify(
|
|
118
|
-
token: Uint8Array,
|
|
119
|
-
publicKey: Uint8Array,
|
|
120
|
-
nowSeconds: number,
|
|
121
|
-
): string | null
|
|
126
|
+
jwtEdDSAVerify(token: Uint8Array, publicKey: Uint8Array, nowSeconds: number): string | null
|
|
122
127
|
/** Compiled `TemplateRenderer` → UTF-8 bytes from a pre-serialized JSON
|
|
123
128
|
* context (opaque `inner` handle); throws on invalid context / render error. */
|
|
124
129
|
templateRender(inner: number, contextJson: Uint8Array): Uint8Array
|
|
@@ -308,7 +313,10 @@ export interface BunFFI {
|
|
|
308
313
|
/**
|
|
309
314
|
* Encode one SSE event → bytes (fresh buffer). event/id null = line omitted
|
|
310
315
|
* (a present-but-empty string emits the line, matching napi `Option`).
|
|
311
|
-
*
|
|
316
|
+
* NOTE: event/id stay `(ptr,len)` byte args — a cstring-ARG conversion was
|
|
317
|
+
* attempted and reverted (2026-08-23); see docs/FFI_BUN_GUIDE.md §14 before
|
|
318
|
+
* touching this signature.
|
|
319
|
+
* FFI sibling of the napi `sse_encode_event`.
|
|
312
320
|
*/
|
|
313
321
|
sseEncodeEvent(
|
|
314
322
|
event: string | null,
|
|
@@ -388,6 +396,98 @@ export interface BunFFI {
|
|
|
388
396
|
routeRun(handle: number, frame: Uint8Array, output: Uint8Array): number
|
|
389
397
|
/** Destroy a compiled route handle (frees the native `Box<NativeRoute>`). */
|
|
390
398
|
routeDestroy(handle: number): void
|
|
399
|
+
|
|
400
|
+
// ── Metrics registry (`castrum_metrics_*`) ─────────────────────────
|
|
401
|
+
/** Create an empty metrics registry → a caller-owned opaque handle. */
|
|
402
|
+
metricsCreate(): number
|
|
403
|
+
/**
|
|
404
|
+
* Declare a counter family → its `u32` series id (`0xFFFFFFFF` = error).
|
|
405
|
+
* `name` and `labelKeys` are `cstring` ARGs; label keys are
|
|
406
|
+
* `\u001f`-separated (empty string = no labels).
|
|
407
|
+
*/
|
|
408
|
+
metricsCounter(handle: number, name: string, labelKeys: string): number
|
|
409
|
+
/** Declare a gauge family → its series id. Same ABI as {@link metricsCounter}. */
|
|
410
|
+
metricsGauge(handle: number, name: string, labelKeys: string): number
|
|
411
|
+
/**
|
|
412
|
+
* Declare a histogram family → its series id. `bucketsCsv` is a
|
|
413
|
+
* comma-separated finite positive list (empty string = default buckets).
|
|
414
|
+
*/
|
|
415
|
+
metricsHistogram(handle: number, name: string, labelKeys: string, bucketsCsv: string): number
|
|
416
|
+
/**
|
|
417
|
+
* Counter/gauge `+= amount`, or histogram observe `amount`. `values` is the
|
|
418
|
+
* packed `\u001f`-separated label values. Returns true on success.
|
|
419
|
+
*/
|
|
420
|
+
metricsRecord(handle: number, series: number, values: Uint8Array, amount: number): boolean
|
|
421
|
+
/** Gauge assignment (`= value`). Returns true on success. */
|
|
422
|
+
metricsGaugeSet(handle: number, series: number, values: Uint8Array, value: number): boolean
|
|
423
|
+
/**
|
|
424
|
+
* Zero-encode sibling of {@link metricsRecord}: `values` is the JOINED
|
|
425
|
+
* `\u001f`-separated JS string — the engine transcodes it in-engine.
|
|
426
|
+
*/
|
|
427
|
+
metricsRecordStr(handle: number, series: number, values: string, amount: number): boolean
|
|
428
|
+
/** Zero-encode sibling of {@link metricsGaugeSet} (joined string values). */
|
|
429
|
+
metricsGaugeSetStr(handle: number, series: number, values: string, value: number): boolean
|
|
430
|
+
/**
|
|
431
|
+
* Render the Prometheus text format into `output`; returns bytes written
|
|
432
|
+
* (`0` = null handle / error; `> output.length` = exact required size).
|
|
433
|
+
*/
|
|
434
|
+
metricsRender(handle: number, output: Uint8Array): number
|
|
435
|
+
/**
|
|
436
|
+
* Dump the packed v1 series snapshot into `output`; returns bytes written
|
|
437
|
+
* (`0` = null handle / error; `> output.length` = exact required size).
|
|
438
|
+
*/
|
|
439
|
+
metricsSnapshot(handle: number, output: Uint8Array): number
|
|
440
|
+
/** Destroy a registry handle (frees the native `Box<MetricsRegistry>`). */
|
|
441
|
+
metricsDestroy(handle: number): void
|
|
442
|
+
/**
|
|
443
|
+
* Record a BATCH of events in one crossing. Packed layout:
|
|
444
|
+
* `[u32 n]{[u32 series][u32 valsLen][vals][f64 amount]}`; returns true when
|
|
445
|
+
* all entries applied.
|
|
446
|
+
*/
|
|
447
|
+
metricsRecordBatch(handle: number, packed: Uint8Array): boolean
|
|
448
|
+
/**
|
|
449
|
+
* Fused wire-level validation: RAW query string → JSON → draft-07 validate
|
|
450
|
+
* against the compiled schema (`inner` from `SchemaValidator.innerPtr()`).
|
|
451
|
+
*/
|
|
452
|
+
queryValidate(inner: number, qs: string): boolean
|
|
453
|
+
/** Fused cookie-header variant of {@link queryValidate}. */
|
|
454
|
+
cookieValidate(inner: number, header: string): boolean
|
|
455
|
+
/**
|
|
456
|
+
* Seal a session envelope natively: builds
|
|
457
|
+
* `{"id":"…","data":<dataJson>,"exp":exp}` and HMAC-signs it →
|
|
458
|
+
* `payload.<hex>` token string (`null` = failure).
|
|
459
|
+
*/
|
|
460
|
+
sessionSeal(id: string, dataJson: string, expSecs: number, secret: string): string | null
|
|
461
|
+
/**
|
|
462
|
+
* Open a sealed session token: verify + extract in one crossing into
|
|
463
|
+
* `output` as `[u8 ok][i64 exp][u32 idLen][id][u32 dataLen][dataJson]`.
|
|
464
|
+
* Returns bytes written (`0` = bad signature / error; `> output.length` =
|
|
465
|
+
* exact required size).
|
|
466
|
+
*/
|
|
467
|
+
sessionOpen(token: string, secret: string, output: Uint8Array): number
|
|
468
|
+
|
|
469
|
+
// ── Batch fixed-width hex validation / RegExp escaping ─────────────
|
|
470
|
+
/**
|
|
471
|
+
* Validate NEWLINE-separated lines as fixed-width hex into `output`
|
|
472
|
+
* (one verdict byte per line); returns bytes written (`0` = bad width /
|
|
473
|
+
* error; `> output.length` = exact required size).
|
|
474
|
+
*/
|
|
475
|
+
hexValidateBatchInto(input: Uint8Array, width: number, output: Uint8Array): number
|
|
476
|
+
/**
|
|
477
|
+
* String-input sibling of {@link hexValidateBatchInto}: the NEWLINE-
|
|
478
|
+
* separated ids cross as a `cstring` ARG (engine-transcoded — no encode).
|
|
479
|
+
*/
|
|
480
|
+
hexValidateBatchStr(ids: string, width: number, output: Uint8Array): number
|
|
481
|
+
/**
|
|
482
|
+
* Escape JS-RegExp metacharacters into `output`; returns bytes written
|
|
483
|
+
* (`0` = error; `> output.length` = exact required size).
|
|
484
|
+
*/
|
|
485
|
+
regexEscapeInto(input: Uint8Array, output: Uint8Array): number
|
|
486
|
+
/**
|
|
487
|
+
* Zero-copy text sibling of {@link regexEscapeInto}: cstring ARG in,
|
|
488
|
+
* cstring return out (engine-cloned — zero JS decode). `null` = failure.
|
|
489
|
+
*/
|
|
490
|
+
regexEscapeStr(input: string): string | null
|
|
391
491
|
}
|
|
392
492
|
|
|
393
493
|
/**
|
|
@@ -411,6 +511,8 @@ export interface RateLimiterVerdict {
|
|
|
411
511
|
resetMs: number
|
|
412
512
|
}
|
|
413
513
|
|
|
514
|
+
/** Raw 0-arg C-ABI symbol signature (`castrum_metrics_create`). */
|
|
515
|
+
export type Raw0 = () => number | bigint
|
|
414
516
|
/** Raw 2-arg C-ABI symbol signature (`(ptr,len)` pairs → scalar result). */
|
|
415
517
|
export type Raw2 = (a: unknown, b: unknown) => number | bigint
|
|
416
518
|
/** Raw 3-arg C-ABI symbol signature. */
|
package/src/native/ffi.ts
CHANGED
|
@@ -206,10 +206,17 @@ function bind(): BunFFI | null {
|
|
|
206
206
|
castrum_url_decode: { args: abi(['ptr', 'usize', 'ptr', 'usize']), returns: U64_FAST },
|
|
207
207
|
// String-input validators: `cstring` ARG (the engine transcodes the JS
|
|
208
208
|
// string in-engine — no JS-side encode; Rust borrows via CStr::from_ptr).
|
|
209
|
+
// The `_bytes` siblings take a `(ptr,len)` pair — zero transcode when
|
|
210
|
+
// the caller already holds bytes (a cstring arg would force a decode +
|
|
211
|
+
// engine re-encode; docs/FFI_BUN_GUIDE.md §3 shape 4).
|
|
209
212
|
castrum_validate_email: { args: ['cstring'], returns: 'u8' },
|
|
210
213
|
castrum_validate_uuid: { args: ['cstring'], returns: 'u8' },
|
|
211
214
|
castrum_validate_ipv4: { args: ['cstring'], returns: 'u8' },
|
|
212
215
|
castrum_validate_ipv6: { args: ['cstring'], returns: 'u8' },
|
|
216
|
+
castrum_validate_email_bytes: { args: inputAbi, returns: 'u8' },
|
|
217
|
+
castrum_validate_uuid_bytes: { args: inputAbi, returns: 'u8' },
|
|
218
|
+
castrum_validate_ipv4_bytes: { args: inputAbi, returns: 'u8' },
|
|
219
|
+
castrum_validate_ipv6_bytes: { args: inputAbi, returns: 'u8' },
|
|
213
220
|
castrum_json_sum_ids: { args: abi(['ptr', 'usize', 'ptr', 'usize']), returns: U64_FAST },
|
|
214
221
|
castrum_hmac_sha256_verify: {
|
|
215
222
|
args: abi(['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize']),
|
|
@@ -357,6 +364,11 @@ function bind(): BunFFI | null {
|
|
|
357
364
|
},
|
|
358
365
|
castrum_http_date_into: { args: ['f64', ...abi(['ptr', 'usize'])], returns: U64_FAST },
|
|
359
366
|
castrum_sse_encode_into: {
|
|
367
|
+
// event/id stay `(ptr,len)` byte args. A cstring-ARG conversion was
|
|
368
|
+
// tried (2026-08-23) and reverted: the win was unproven once the
|
|
369
|
+
// codec encode path got faster, and ABI-desync segfaults during the
|
|
370
|
+
// experiment were misread as an engine bug — see docs/FFI_BUN_GUIDE.md
|
|
371
|
+
// §14 before touching this signature.
|
|
360
372
|
args: abi(['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize', 'u8', 'u32', 'ptr', 'usize']),
|
|
361
373
|
returns: U64_FAST,
|
|
362
374
|
},
|
|
@@ -467,9 +479,83 @@ function bind(): BunFFI | null {
|
|
|
467
479
|
returns: U64_FAST,
|
|
468
480
|
},
|
|
469
481
|
castrum_route_destroy: { args: ['usize'], returns: 'void' },
|
|
482
|
+
// ── Batch fixed-width hex validation (ObjectId-shaped ids, etc.) ──
|
|
483
|
+
// NEWLINE-separated lines in; one verdict byte (1/0) per line out.
|
|
484
|
+
castrum_hex_validate_batch: {
|
|
485
|
+
args: abi(['ptr', 'usize', 'u32', 'ptr', 'usize']),
|
|
486
|
+
returns: U64_FAST,
|
|
487
|
+
},
|
|
488
|
+
// ── JS-RegExp metacharacter escaping (needed-size convention) ──
|
|
489
|
+
castrum_regex_escape: { args: abi(['ptr', 'usize', 'ptr', 'usize']), returns: U64_FAST },
|
|
490
|
+
// ── Metrics registry (`castrum_metrics_*`) ──────────────────────
|
|
491
|
+
// Caller-owned handle (route-stack ownership model): create returns a
|
|
492
|
+
// Box<Registry> as usize; declare fns take name/label-keys as cstring
|
|
493
|
+
// ARGS (label keys `\x1f`-separated) and return the u32 series id
|
|
494
|
+
// (0xFFFFFFFF = error); record/set cross the packed label VALUES as a
|
|
495
|
+
// `(ptr,len)` pair + the amount as f64.
|
|
496
|
+
castrum_metrics_create: { args: [], returns: U64_FAST },
|
|
497
|
+
castrum_metrics_counter: { args: ['usize', 'cstring', 'cstring'], returns: 'u32' },
|
|
498
|
+
castrum_metrics_gauge: { args: ['usize', 'cstring', 'cstring'], returns: 'u32' },
|
|
499
|
+
castrum_metrics_histogram: {
|
|
500
|
+
args: ['usize', 'cstring', 'cstring', 'cstring'],
|
|
501
|
+
returns: 'u32',
|
|
502
|
+
},
|
|
503
|
+
castrum_metrics_record: {
|
|
504
|
+
args: abi(['usize', 'u32', 'ptr', 'usize', 'f64']),
|
|
505
|
+
returns: 'u8',
|
|
506
|
+
},
|
|
507
|
+
castrum_metrics_gauge_set: {
|
|
508
|
+
args: abi(['usize', 'u32', 'ptr', 'usize', 'f64']),
|
|
509
|
+
returns: 'u8',
|
|
510
|
+
},
|
|
511
|
+
// Zero-encode siblings: the joined label values cross as ONE `cstring`
|
|
512
|
+
// ARG — the engine transcodes the JS string in-engine (no TextEncoder).
|
|
513
|
+
castrum_metrics_record_str: {
|
|
514
|
+
args: ['usize', 'u32', 'cstring', 'f64'],
|
|
515
|
+
returns: 'u8',
|
|
516
|
+
},
|
|
517
|
+
castrum_metrics_gauge_set_str: {
|
|
518
|
+
args: ['usize', 'u32', 'cstring', 'f64'],
|
|
519
|
+
returns: 'u8',
|
|
520
|
+
},
|
|
521
|
+
// Zero-copy text escape: cstring ARG in, cstring return out — the JS
|
|
522
|
+
// side does zero encode AND zero decode (engine clones at return).
|
|
523
|
+
castrum_regex_escape_str: { args: ['cstring'], returns: 'cstring' },
|
|
524
|
+
// String-input batch hex validation: `ids.join('\n')` crosses as a
|
|
525
|
+
// cstring ARG.
|
|
526
|
+
castrum_hex_validate_batch_str: {
|
|
527
|
+
args: abi(['cstring', 'u32', 'ptr', 'usize']),
|
|
528
|
+
returns: U64_FAST,
|
|
529
|
+
},
|
|
530
|
+
castrum_metrics_render: { args: abi(['usize', 'ptr', 'usize']), returns: U64_FAST },
|
|
531
|
+
castrum_metrics_snapshot: { args: abi(['usize', 'ptr', 'usize']), returns: U64_FAST },
|
|
532
|
+
castrum_metrics_record_batch: {
|
|
533
|
+
args: abi(['usize', 'ptr', 'usize']),
|
|
534
|
+
returns: 'u8',
|
|
535
|
+
},
|
|
536
|
+
// Fused wire-level validation: RAW query/cookie → JSON → draft-07 gate.
|
|
537
|
+
// `inner` is the SchemaValidator inner handle; strings cross as cstring
|
|
538
|
+
// ARGs (engine-transcoded).
|
|
539
|
+
castrum_query_validate: { args: ['usize', 'cstring'], returns: 'u8' },
|
|
540
|
+
castrum_cookie_validate: { args: ['usize', 'cstring'], returns: 'u8' },
|
|
541
|
+
// Fused session envelope: seal builds+signs; open verifies + extracts.
|
|
542
|
+
castrum_session_seal: {
|
|
543
|
+
args: ['cstring', 'cstring', 'i64', 'cstring'],
|
|
544
|
+
returns: 'cstring',
|
|
545
|
+
},
|
|
546
|
+
castrum_session_open: {
|
|
547
|
+
args: abi(['cstring', 'cstring', 'ptr', 'usize']),
|
|
548
|
+
returns: U64_FAST,
|
|
549
|
+
},
|
|
550
|
+
castrum_metrics_destroy: { args: ['usize'], returns: 'void' },
|
|
470
551
|
})
|
|
471
552
|
|
|
472
|
-
|
|
553
|
+
// (`as unknown as`: the zero-arg `castrum_metrics_create` infers
|
|
554
|
+
// `(...args: never[])`, which can't overlap `(...a: unknown[])` directly.)
|
|
555
|
+
const bindings = build(
|
|
556
|
+
symbols as unknown as Record<string, (...a: unknown[]) => unknown>,
|
|
557
|
+
useBufferLength,
|
|
558
|
+
)
|
|
473
559
|
if (!selfTest(bindings)) {
|
|
474
560
|
if (mode === 'ffi') {
|
|
475
561
|
throw new Error(
|
package/src/native/index.ts
CHANGED
|
@@ -4,5 +4,5 @@
|
|
|
4
4
|
// (./loader.ts). Importing this barrel does NOT dlopen the addon — use
|
|
5
5
|
// `getAddon()` / `lazyAddon` at call time.
|
|
6
6
|
|
|
7
|
+
export { getAddon, getAddonPath, lazyAddon } from './loader'
|
|
7
8
|
export * from './types'
|
|
8
|
-
export { getAddonPath, getAddon, lazyAddon } from './loader'
|
package/src/native/loader.ts
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
// `import castrum` as cheap as possible on Bun.
|
|
6
6
|
|
|
7
7
|
import { existsSync, readFileSync, statSync } from 'node:fs'
|
|
8
|
-
import { fileURLToPath } from 'node:url'
|
|
9
|
-
import { join, dirname } from 'node:path'
|
|
10
8
|
import { createRequire } from 'node:module'
|
|
9
|
+
import { dirname, join } from 'node:path'
|
|
10
|
+
import { fileURLToPath } from 'node:url'
|
|
11
11
|
import { resolveEnvVar } from '../shared/env'
|
|
12
12
|
import type { NativeAddon } from './types'
|
|
13
13
|
|
|
@@ -57,6 +57,40 @@ export interface RouteInstance {
|
|
|
57
57
|
destroy?(): void
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* The native `MetricsRegistry` class instance — sharded counters / gauges /
|
|
62
|
+
* histograms with a Prometheus text-format render. Series are declared once
|
|
63
|
+
* (`counter`/`gauge`/`histogram` return a reusable id); per-event updates
|
|
64
|
+
* take the id + the label values for that event.
|
|
65
|
+
*/
|
|
66
|
+
export interface MetricsRegistryInstance {
|
|
67
|
+
/** Declare a counter family → its series id (idempotent per shape). */
|
|
68
|
+
counter(name: string, labelKeys?: string[]): number
|
|
69
|
+
/** Declare a gauge family → its series id (idempotent per shape). */
|
|
70
|
+
gauge(name: string, labelKeys?: string[]): number
|
|
71
|
+
/**
|
|
72
|
+
* Declare a histogram family → its series id. Omitted/empty `buckets`
|
|
73
|
+
* selects the default latency buckets.
|
|
74
|
+
*/
|
|
75
|
+
histogram(name: string, labelKeys?: string[], buckets?: number[]): number
|
|
76
|
+
/** Counter/gauge `+= amount`, or histogram observe `amount`. */
|
|
77
|
+
record(series: number, values?: string[], amount?: number): void
|
|
78
|
+
/** Gauge assignment (`= value`). */
|
|
79
|
+
gaugeSet(series: number, values: string[] | undefined, value: number): void
|
|
80
|
+
/** Render the Prometheus text exposition format (deterministic order). */
|
|
81
|
+
render(): string
|
|
82
|
+
/**
|
|
83
|
+
* Packed v1 series snapshot bytes (families then series — see the Rust
|
|
84
|
+
* registry docs). bun:ffi-backed wrappers only; napi exposes
|
|
85
|
+
* `MetricsRegistry.snapshot()` directly.
|
|
86
|
+
*/
|
|
87
|
+
snapshot?(): Uint8Array
|
|
88
|
+
/** Number of live series (tests / introspection; napi transport only). */
|
|
89
|
+
seriesCount?(): number
|
|
90
|
+
/** Free the native handle (bun:ffi-backed wrappers only). */
|
|
91
|
+
destroy?(): void
|
|
92
|
+
}
|
|
93
|
+
|
|
60
94
|
/** A single JSON Schema validation error (fast path + DOM fallback). */
|
|
61
95
|
export interface SchemaError {
|
|
62
96
|
/** RFC 6901 JSON pointer to the failing instance value ("" = root). */
|
package/src/native/types.ts
CHANGED
|
@@ -25,6 +25,7 @@ import type {
|
|
|
25
25
|
MediaTypeMatcherInstance,
|
|
26
26
|
MediaTypeParserInstance,
|
|
27
27
|
MediaTypeResult,
|
|
28
|
+
MetricsRegistryInstance,
|
|
28
29
|
MultipartPart,
|
|
29
30
|
PasswordHashOptions,
|
|
30
31
|
RateLimiterInstance,
|
|
@@ -145,6 +146,10 @@ export interface NativeAddon {
|
|
|
145
146
|
validateUuid(input: Uint8Array): boolean
|
|
146
147
|
validateIpv4(input: Uint8Array): boolean
|
|
147
148
|
validateIpv6(input: Uint8Array): boolean
|
|
149
|
+
/** Batch fixed-width hex validation → one verdict byte per newline-separated line. */
|
|
150
|
+
hexValidateBatch(input: Uint8Array, width: number): Uint8Array
|
|
151
|
+
/** Escape JS-RegExp metacharacters so a pattern matches the input literally. */
|
|
152
|
+
regexEscape(input: string): string
|
|
148
153
|
|
|
149
154
|
wsAcceptKey(key: Uint8Array): Uint8Array
|
|
150
155
|
wsAcceptKeyBatchPacked(input: Uint8Array): Uint8Array
|
|
@@ -244,6 +249,12 @@ export interface NativeAddon {
|
|
|
244
249
|
windowMs: number,
|
|
245
250
|
maxEntries?: number | null,
|
|
246
251
|
) => RateLimiterInstance
|
|
252
|
+
/**
|
|
253
|
+
* Sharded counters / gauges / histograms registry with a Prometheus
|
|
254
|
+
* text-format render (the napi fallback transport for the `castrum_metrics_*`
|
|
255
|
+
* C-ABI surface).
|
|
256
|
+
*/
|
|
257
|
+
MetricsRegistry: new () => MetricsRegistryInstance
|
|
247
258
|
|
|
248
259
|
wsFrameEncode(opcode: number, payload: Uint8Array, mask: boolean, fin: boolean): Uint8Array
|
|
249
260
|
wsFrameDecode(data: Uint8Array): WsFrame | null
|