castrum 0.7.0 → 0.9.1
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/LICENSE +21 -0
- package/README.md +154 -368
- package/castrum.linux-x64-gnu.node +0 -0
- package/dist/index.d.ts +2763 -0
- package/dist/index.js +8707 -0
- package/docs/API.md +337 -0
- package/docs/ARCHITECTURE.md +507 -0
- package/docs/BENCHMARKS.md +343 -0
- package/docs/CASE_STUDY.md +240 -0
- package/docs/ENVIRONMENT.md +81 -0
- package/docs/FFI_BUN_GUIDE.md +327 -0
- package/docs/GETTING_STARTED.md +192 -0
- package/docs/INGRESS-ROUTER.md +81 -0
- package/docs/INGRESS.md +746 -0
- package/docs/NATIVE-ROUTE.md +53 -0
- package/docs/REPO_MAP.md +279 -0
- package/docs/adr/0001-two-wire-formats.md +39 -0
- package/docs/adr/0002-zero-dom-fast-schema.md +38 -0
- package/docs/adr/0003-bun-builtins-delegation.md +43 -0
- package/docs/bun-builtins-decision-matrix.md +115 -0
- package/index.ts +44 -8
- package/package.json +98 -33
- package/src/ingress/body.ts +289 -0
- package/src/ingress/constants.ts +220 -48
- package/src/ingress/context.ts +136 -0
- package/src/ingress/decode/baked-result.ts +121 -0
- package/src/ingress/decode/fast-result.ts +160 -0
- package/src/ingress/decode/packed-sections.ts +56 -0
- package/src/ingress/decode/result-base.ts +172 -0
- package/src/ingress/errors.ts +72 -0
- package/src/ingress/fast.ts +152 -1021
- package/src/ingress/handlers.ts +639 -1001
- package/src/ingress/headers/baked-templates.ts +102 -0
- package/src/ingress/headers/cors.ts +44 -0
- package/src/ingress/headers/fast-templates.ts +238 -0
- package/src/ingress/headers/hsts.ts +83 -0
- package/src/ingress/health.ts +68 -0
- package/src/ingress/index.ts +142 -405
- package/src/ingress/metrics.ts +132 -0
- package/src/ingress/options.ts +197 -0
- package/src/ingress/packing/gather-raw-headers.ts +139 -0
- package/src/ingress/packing/header-packing.ts +75 -0
- package/src/ingress/packing/input-packer.ts +164 -0
- package/src/ingress/packing/scratch.ts +50 -0
- package/src/ingress/packing/select-headers.ts +102 -0
- package/src/ingress/response/error-bodies.ts +75 -0
- package/src/ingress/response/terminal.ts +59 -0
- package/src/ingress/router.ts +237 -0
- package/src/ingress/routes/common.ts +79 -0
- package/src/ingress/routes/delete.ts +14 -0
- package/src/ingress/routes/echo.ts +107 -0
- package/src/ingress/routes/fallback.ts +23 -0
- package/src/ingress/routes/head.ts +27 -0
- package/src/ingress/routes/index.ts +11 -0
- package/src/ingress/routes/json-write.ts +129 -0
- package/src/ingress/routes/options.ts +26 -0
- package/src/ingress/routes/read.ts +35 -0
- package/src/ingress/routes/responder.ts +159 -0
- package/src/ingress/server-node.ts +376 -0
- package/src/ingress/server.ts +475 -0
- package/src/ingress/shared.ts +132 -0
- package/src/ingress/status.ts +97 -0
- package/src/ingress/types.ts +191 -0
- package/src/integration/batch.ts +58 -0
- package/src/integration/index.ts +29 -0
- package/src/integration/pipeline.ts +220 -0
- package/src/integration/streaming.ts +78 -0
- package/src/integration/websocket.ts +93 -0
- package/src/loader/batch.ts +137 -0
- package/src/loader/cost.ts +105 -0
- package/src/loader/index.ts +745 -0
- package/src/loader/ops.ts +657 -0
- package/src/loader/state.ts +84 -0
- package/src/loader/types.ts +145 -0
- package/src/native/ffi/constants.ts +58 -0
- package/src/native/ffi/selftest.ts +729 -0
- package/src/native/ffi/types.ts +474 -0
- package/src/native/ffi.ts +1775 -0
- package/src/native/index.ts +8 -207
- package/src/native/loader.ts +240 -0
- package/src/native/types/instances.ts +274 -0
- package/src/native/types.ts +401 -0
- package/src/runtime/builtins.ts +131 -0
- package/src/runtime/codec.ts +147 -0
- package/src/runtime/detect.ts +36 -0
- package/src/runtime/index.ts +28 -0
- package/src/runtime/native.ts +35 -0
- package/src/runtime/server.ts +31 -0
- package/src/runtime/transport.ts +64 -0
- package/src/runtime/types.ts +173 -0
- package/src/runtime/uuid.ts +45 -0
- package/src/runtime/websocket.ts +30 -0
- package/src/rust-ffi/addon.ts +10 -0
- package/src/rust-ffi/batch/build.ts +155 -0
- package/src/rust-ffi/batch/index.ts +8 -0
- package/src/rust-ffi/batch/types.ts +92 -0
- package/src/rust-ffi/client.ts +87 -0
- package/src/rust-ffi/context.ts +193 -0
- package/src/rust-ffi/index.ts +51 -541
- package/src/rust-ffi/into.ts +21 -0
- package/src/rust-ffi/options.ts +113 -0
- package/src/rust-ffi/packed.ts +253 -0
- package/src/rust-ffi/proven.ts +28 -0
- package/src/rust-ffi/scalar/crypto.ts +346 -0
- package/src/rust-ffi/scalar/factories.ts +459 -0
- package/src/rust-ffi/scalar/hashing.ts +74 -0
- package/src/rust-ffi/scalar/http.ts +337 -0
- package/src/rust-ffi/scalar/index.ts +31 -0
- package/src/rust-ffi/scalar/interface.ts +325 -0
- package/src/rust-ffi/scalar/json-packed.ts +175 -0
- package/src/rust-ffi/scalar/json.ts +61 -0
- package/src/rust-ffi/scalar/payload.ts +241 -0
- package/src/rust-ffi/text.ts +121 -0
- package/src/selection.json +194 -0
- package/src/selection.ts +73 -0
- package/src/shared/adaptive.ts +67 -0
- package/src/shared/buffer-pool.ts +264 -0
- package/src/shared/bytes.ts +108 -9
- package/src/shared/codec.ts +78 -0
- package/src/shared/env.ts +20 -0
- package/src/shared/log.ts +75 -0
- package/src/shared/metrics.ts +247 -0
- package/src/shared/packed/index.ts +10 -0
- package/src/shared/packed/parsers.ts +31 -0
- package/src/shared/packed/schema.ts +23 -0
- package/src/shared/packed/wire.ts +504 -0
- package/src/shared/proven.ts +289 -0
- package/src/shared/request-id.ts +67 -0
- package/src/shared/response.ts +104 -0
- package/src/shared/runtime.ts +29 -0
- package/src/shared/trace.ts +81 -0
- package/src/shared/uuid.ts +15 -0
- package/Cargo.toml +0 -73
- package/bench.ts +0 -3
- package/rust/batch.rs +0 -195
- package/rust/cookie_parser.rs +0 -70
- package/rust/cors.rs +0 -263
- package/rust/hashing.rs +0 -164
- package/rust/headers.rs +0 -234
- package/rust/hmac_sha256.rs +0 -113
- package/rust/http_parser.rs +0 -132
- package/rust/ingress.rs +0 -616
- package/rust/ingress_constants.rs +0 -89
- package/rust/ip_trust.rs +0 -185
- package/rust/json_ops.rs +0 -66
- package/rust/json_patch_ops.rs +0 -25
- package/rust/json_schema.rs +0 -151
- package/rust/json_ser.rs +0 -429
- package/rust/lib.rs +0 -48
- package/rust/method.rs +0 -141
- package/rust/mime_lookup.rs +0 -76
- package/rust/output.rs +0 -238
- package/rust/proxy.rs +0 -90
- package/rust/query_parser.rs +0 -141
- package/rust/random_token.rs +0 -52
- package/rust/rate_limit.rs +0 -203
- package/rust/terminal.rs +0 -95
- package/rust/unit_tests.rs +0 -843
- package/rust/url_codec.rs +0 -133
- package/rust/util.rs +0 -537
- package/rust/validation.rs +0 -107
- package/rust/websocket.rs +0 -23
- package/src/baseline/index.ts +0 -12
- package/src/baseline/tasks/cookie.ts +0 -22
- package/src/baseline/tasks/hashing.ts +0 -18
- package/src/baseline/tasks/hmac.ts +0 -38
- package/src/baseline/tasks/http.ts +0 -62
- package/src/baseline/tasks/json-patch.ts +0 -12
- package/src/baseline/tasks/json.ts +0 -50
- package/src/baseline/tasks/mime.ts +0 -5
- package/src/baseline/tasks/query.ts +0 -14
- package/src/baseline/tasks/token.ts +0 -15
- package/src/baseline/tasks/url.ts +0 -11
- package/src/baseline/tasks/validation.ts +0 -19
- package/src/baseline/tasks/websocket.ts +0 -14
- package/src/bench/assert.ts +0 -70
- package/src/bench/checks.ts +0 -227
- package/src/bench/checksum.ts +0 -27
- package/src/bench/comparisons.ts +0 -70
- package/src/bench/concurrent-worker.ts +0 -135
- package/src/bench/fixtures.ts +0 -233
- package/src/bench/index.ts +0 -2
- package/src/bench/measure.ts +0 -177
- package/src/bench/now.ts +0 -3
- package/src/bench/report.ts +0 -79
- package/src/bench/run.ts +0 -57
- package/src/bench/tasks/complex.ts +0 -232
- package/src/bench/tasks/concurrent.ts +0 -213
- package/src/bench/tasks/cookie.ts +0 -44
- package/src/bench/tasks/hashing.ts +0 -33
- package/src/bench/tasks/hmac.ts +0 -36
- package/src/bench/tasks/http.ts +0 -50
- package/src/bench/tasks/index.ts +0 -52
- package/src/bench/tasks/json-patch.ts +0 -21
- package/src/bench/tasks/json.ts +0 -33
- package/src/bench/tasks/mime.ts +0 -21
- package/src/bench/tasks/query.ts +0 -44
- package/src/bench/tasks/stress.ts +0 -142
- package/src/bench/tasks/token.ts +0 -20
- package/src/bench/tasks/url.ts +0 -34
- package/src/bench/tasks/validation.ts +0 -57
- package/src/bench/tasks/websocket.ts +0 -21
- package/src/bench/types.ts +0 -60
- package/src/data/json-rows.ts +0 -33
- package/src/ingress/packed-input.ts +0 -59
- package/src/shared/json.ts +0 -18
- package/src/shared/packed.ts +0 -316
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// src/rust-ffi/scalar/json-packed.ts — packed JSON token-stream decoder.
|
|
2
|
+
//
|
|
3
|
+
// Decodes the output of `castrum_json_parse_packed` (layout documented in
|
|
4
|
+
// `src/native/ffi/types.ts`) into a JS value. The C side parses ONCE with
|
|
5
|
+
// sonic-rs and emits a typed token stream with a DEDUPLICATED string table, so
|
|
6
|
+
// the JS side never re-parses JSON text and decodes each unique string exactly
|
|
7
|
+
// once (no per-occurrence decode — a per-occurrence blob + JS `slice` was ~10x
|
|
8
|
+
// worse due to rope strings). This is what makes `rust.jsonParse` competitive
|
|
9
|
+
// with Bun's own `JSON.parse` (the old FFI cstring path re-serialized the whole
|
|
10
|
+
// doc to text and re-parsed it — measured ~3.92x slower on the 5k-row fixture).
|
|
11
|
+
|
|
12
|
+
import { decodeUtf8 } from '../../shared/codec'
|
|
13
|
+
import { isBun } from '../../shared/runtime'
|
|
14
|
+
|
|
15
|
+
const TAG_NULL = 0
|
|
16
|
+
const TAG_FALSE = 1
|
|
17
|
+
const TAG_TRUE = 2
|
|
18
|
+
const TAG_NUMBER = 3
|
|
19
|
+
const TAG_STRING = 4
|
|
20
|
+
const TAG_ARRAY_START = 5
|
|
21
|
+
const TAG_OBJECT_START = 6
|
|
22
|
+
const TAG_ARRAY_END = 7
|
|
23
|
+
const TAG_OBJECT_END = 8
|
|
24
|
+
const TAG_KEY = 9
|
|
25
|
+
|
|
26
|
+
// Hoisted `bun:ffi` codec. The generic `decodeUtf8` (src/shared/codec.ts) does
|
|
27
|
+
// a `require('bun:ffi')` on EVERY call, which is fine for the few strings the
|
|
28
|
+
// ingress path decodes per request but not for a string table with thousands
|
|
29
|
+
// of entries (the 5k-row fixture decodes ~5k unique strings). Resolve the
|
|
30
|
+
// codec ONCE and reuse it; the Node fallback just delegates to the codec.
|
|
31
|
+
let bunFfiCodec: typeof import('bun:ffi') | null | undefined
|
|
32
|
+
|
|
33
|
+
function fastDecodeUtf8(bytes: Uint8Array): string {
|
|
34
|
+
if (bytes.byteLength === 0) return ''
|
|
35
|
+
if (bunFfiCodec === undefined) {
|
|
36
|
+
bunFfiCodec = null
|
|
37
|
+
if (isBun()) {
|
|
38
|
+
try {
|
|
39
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
40
|
+
bunFfiCodec = require('bun:ffi') as typeof import('bun:ffi')
|
|
41
|
+
} catch {
|
|
42
|
+
bunFfiCodec = null
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (bunFfiCodec) {
|
|
47
|
+
return new bunFfiCodec.CString(bunFfiCodec.ptr(bytes), 0, bytes.byteLength).toString()
|
|
48
|
+
}
|
|
49
|
+
// Node fallback — only reachable if the FFI transport isn't live (this
|
|
50
|
+
// decoder is Bun-only in practice), so reuse the codec's TextDecoder branch.
|
|
51
|
+
return decodeUtf8(bytes)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Decode a packed token stream (from `ffi.jsonParsePacked`) into a JS value.
|
|
56
|
+
*
|
|
57
|
+
* Stream layout (all integers little-endian):
|
|
58
|
+
* `[u32 strCount]{[u32 len][utf8 bytes]}... [u32 treeLen][tree]` — a
|
|
59
|
+
* deduplicated string table followed by a single value encoded as a
|
|
60
|
+
* start/end-marker token stream:
|
|
61
|
+
* `0=null 1=false 2=true 3=number(f64 LE) 4=string(u32 table idx)
|
|
62
|
+
* 5=array start 6=object start 7=array end 8=object end
|
|
63
|
+
* 9=object key(u32 table idx)`.
|
|
64
|
+
* Object body: `6, (9, keyIdx, value)*, 8`.
|
|
65
|
+
*
|
|
66
|
+
* The result is deep-equal to what `JSON.parse` produces — numbers are emitted
|
|
67
|
+
* as f64 (JS number semantics) and duplicate object keys are last-wins.
|
|
68
|
+
*
|
|
69
|
+
* @param bytes - The packed stream from `ffi.jsonParsePacked`.
|
|
70
|
+
* @returns The parsed JS value.
|
|
71
|
+
*
|
|
72
|
+
* @example
|
|
73
|
+
* ```ts
|
|
74
|
+
* decodeJsonPacked(ffi.jsonParsePacked(enc.encode('{"a":1}'))) // { a: 1 }
|
|
75
|
+
* ```
|
|
76
|
+
*/
|
|
77
|
+
export function decodeJsonPacked(bytes: Uint8Array): unknown {
|
|
78
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
79
|
+
let p = 0
|
|
80
|
+
const u32 = (): number => {
|
|
81
|
+
const v = view.getUint32(p, true)
|
|
82
|
+
p += 4
|
|
83
|
+
return v
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Deduplicated string table — each unique string decoded exactly once.
|
|
87
|
+
const strCount = u32()
|
|
88
|
+
const strings = new Array<string>(strCount)
|
|
89
|
+
for (let i = 0; i < strCount; i++) {
|
|
90
|
+
const n = u32()
|
|
91
|
+
strings[i] = fastDecodeUtf8(bytes.subarray(p, p + n))
|
|
92
|
+
p += n
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const treeLen = u32()
|
|
96
|
+
const end = p + treeLen
|
|
97
|
+
|
|
98
|
+
// Iterative decode with an explicit container stack (no recursion).
|
|
99
|
+
let root: unknown
|
|
100
|
+
// frame: { isObj, arr, obj, key } — for arrays values push into `arr`; for
|
|
101
|
+
// objects the pending `key` (set by TAG_KEY) receives the next value.
|
|
102
|
+
interface JsonFrame {
|
|
103
|
+
isObj: boolean
|
|
104
|
+
arr: unknown[]
|
|
105
|
+
obj: Record<string, unknown>
|
|
106
|
+
key: string
|
|
107
|
+
}
|
|
108
|
+
const frames: JsonFrame[] = []
|
|
109
|
+
|
|
110
|
+
const popFrame = (): JsonFrame => {
|
|
111
|
+
const f = frames.pop()
|
|
112
|
+
if (f === undefined) throw new Error('json parse: malformed packed stream')
|
|
113
|
+
return f
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const attach = (v: unknown): void => {
|
|
117
|
+
const f = frames[frames.length - 1]
|
|
118
|
+
if (f === undefined) {
|
|
119
|
+
root = v
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
if (f.isObj) f.obj[f.key] = v
|
|
123
|
+
else f.arr.push(v)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
while (p < end) {
|
|
127
|
+
// Tag byte — guaranteed in-bounds (p < end <= bytes.length).
|
|
128
|
+
const t = view.getUint8(p)
|
|
129
|
+
p += 1
|
|
130
|
+
switch (t) {
|
|
131
|
+
case TAG_NULL:
|
|
132
|
+
attach(null)
|
|
133
|
+
break
|
|
134
|
+
case TAG_FALSE:
|
|
135
|
+
attach(false)
|
|
136
|
+
break
|
|
137
|
+
case TAG_TRUE:
|
|
138
|
+
attach(true)
|
|
139
|
+
break
|
|
140
|
+
case TAG_NUMBER: {
|
|
141
|
+
const v = view.getFloat64(p, true)
|
|
142
|
+
p += 8
|
|
143
|
+
attach(v)
|
|
144
|
+
break
|
|
145
|
+
}
|
|
146
|
+
case TAG_STRING:
|
|
147
|
+
attach(strings[u32()] as string)
|
|
148
|
+
break
|
|
149
|
+
case TAG_ARRAY_START:
|
|
150
|
+
frames.push({ isObj: false, arr: [], obj: {}, key: '' })
|
|
151
|
+
break
|
|
152
|
+
case TAG_OBJECT_START:
|
|
153
|
+
frames.push({ isObj: true, arr: [], obj: {}, key: '' })
|
|
154
|
+
break
|
|
155
|
+
case TAG_ARRAY_END:
|
|
156
|
+
attach(popFrame().arr)
|
|
157
|
+
break
|
|
158
|
+
case TAG_OBJECT_END:
|
|
159
|
+
attach(popFrame().obj)
|
|
160
|
+
break
|
|
161
|
+
case TAG_KEY: {
|
|
162
|
+
const f = frames[frames.length - 1] as JsonFrame
|
|
163
|
+
f.key = strings[u32()] as string
|
|
164
|
+
break
|
|
165
|
+
}
|
|
166
|
+
default:
|
|
167
|
+
throw new Error('json parse: malformed packed token')
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (p !== end || frames.length !== 0) {
|
|
172
|
+
throw new Error('json parse: malformed packed stream')
|
|
173
|
+
}
|
|
174
|
+
return root
|
|
175
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// src/rust-ffi/scalar/json.ts — JSON scalar methods.
|
|
2
|
+
//
|
|
3
|
+
// Mirrors rust/json/*: validate, DOM parse, id-sum and RFC 6902 patch.
|
|
4
|
+
//
|
|
5
|
+
// Runtime dispatch is centralized in the adapter (`ctx.runtime`): the native
|
|
6
|
+
// call comes from `transport.ffi` / `transport.resolve` (bun:ffi first, napi
|
|
7
|
+
// fallback) — no inline `getBunFFI()`.
|
|
8
|
+
|
|
9
|
+
import { type RustClientContext, resolveNative } from '../context'
|
|
10
|
+
import { asBigInt } from '../options'
|
|
11
|
+
import { decodeJsonPacked } from './json-packed'
|
|
12
|
+
|
|
13
|
+
/** JSON scalar methods (`Pick<RustScalar, ...>`). */
|
|
14
|
+
export function buildJson(ctx: RustClientContext) {
|
|
15
|
+
const { addon } = ctx
|
|
16
|
+
const { transport } = ctx.runtime
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
jsonValid(input: Uint8Array): boolean {
|
|
20
|
+
return resolveNative(ctx, 'jsonValid')(input) as boolean
|
|
21
|
+
},
|
|
22
|
+
jsonParse(input: Uint8Array): unknown {
|
|
23
|
+
// FFI-first STRUCTURAL parse: the C side parses once with sonic-rs and
|
|
24
|
+
// emits a packed token stream (all strings in ONE blob, referenced by
|
|
25
|
+
// char offsets); the JS decoder slices the blob with NO second JSON text
|
|
26
|
+
// parse. (The old cstring path re-serialized to text and re-parsed it —
|
|
27
|
+
// measured ~3.92x slower than Bun's JSON.parse on the 5k-row fixture.)
|
|
28
|
+
// Invalid JSON → growExact throws (napi parity).
|
|
29
|
+
//
|
|
30
|
+
// Deliberately NOT delegated to JSON.parse despite the ~2x loss: the
|
|
31
|
+
// native path throws on lone-surrogate escapes (`"\ude80"`) — a pinned,
|
|
32
|
+
// reviewed contract (json-packed-roundtrip.test.ts) — while JSON.parse
|
|
33
|
+
// accepts them. Delegating would diverge Bun from Node on that input.
|
|
34
|
+
const f = transport.ffi
|
|
35
|
+
if (f) {
|
|
36
|
+
return decodeJsonPacked(f.jsonParsePacked(input))
|
|
37
|
+
}
|
|
38
|
+
return addon.jsonParse(input)
|
|
39
|
+
},
|
|
40
|
+
jsonSumIds(input: Uint8Array): bigint {
|
|
41
|
+
// The C ABI returns a packed [u8 ok][i64 sum LE] output, so a legit
|
|
42
|
+
// zero-sum and invalid input are unambiguous — no napi re-dispatch.
|
|
43
|
+
return asBigInt(resolveNative(ctx, 'jsonSumIds')(input) as unknown)
|
|
44
|
+
},
|
|
45
|
+
jsonPatch(doc: Uint8Array, patch: Uint8Array): Uint8Array | string {
|
|
46
|
+
const f = transport.ffi
|
|
47
|
+
if (f) {
|
|
48
|
+
try {
|
|
49
|
+
return f.jsonPatch(doc, patch)
|
|
50
|
+
} catch {
|
|
51
|
+
// The C ABI's 0 is ambiguous (too-small buffer vs a real error) and
|
|
52
|
+
// carries no message. Re-dispatch to napi for exact error semantics:
|
|
53
|
+
// it either succeeds (too-small case) or throws the contextual
|
|
54
|
+
// "invalid document"/"invalid patch"/"apply failed" message.
|
|
55
|
+
return addon.jsonPatch(doc, patch)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return addon.jsonPatch(doc, patch)
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// src/rust-ffi/scalar/payload.ts — Output / streaming scalar methods.
|
|
2
|
+
//
|
|
3
|
+
// Mirrors rust/payload/*: gzip/brotli compress, multipart parse, WebSocket
|
|
4
|
+
// frame codec and SSE event framing.
|
|
5
|
+
//
|
|
6
|
+
// Runtime dispatch is centralized in the adapter (`ctx.runtime`): the Bun
|
|
7
|
+
// gzipCompress delegation comes from `builtins.has(op)` and the native call
|
|
8
|
+
// from `transport.ffi` / `transport.resolve` (bun:ffi first, napi fallback) —
|
|
9
|
+
// no inline `isBun()` / `getBunFFI()`.
|
|
10
|
+
|
|
11
|
+
import type { MultipartPart, WsFrame } from '../../native'
|
|
12
|
+
import { decoder } from '../../shared/bytes'
|
|
13
|
+
import { writeInto } from '../into'
|
|
14
|
+
import { memoizeFfi, type RustClientContext, resolveNative } from '../context'
|
|
15
|
+
|
|
16
|
+
/** Read an unsigned little-endian u32 at `off`. */
|
|
17
|
+
function u32LE(b: Uint8Array, off: number): number {
|
|
18
|
+
return (
|
|
19
|
+
((b[off] ?? 0) |
|
|
20
|
+
((b[off + 1] ?? 0) << 8) |
|
|
21
|
+
((b[off + 2] ?? 0) << 16) |
|
|
22
|
+
((b[off + 3] ?? 0) << 24)) >>>
|
|
23
|
+
0
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Unpack the `parts_to_packed` layout (`[u32 count]{[u32 name_len][name]
|
|
29
|
+
* [u8 has_filename][u32 filename_len][filename][u8 has_ct][u32 ct_len][ct]
|
|
30
|
+
* [u32 data_len][data]}`) into the object shape. Slices `data` by copy so the
|
|
31
|
+
* caller owns it (napi parity).
|
|
32
|
+
*/
|
|
33
|
+
function unpackMultipart(packed: Uint8Array): MultipartPart[] {
|
|
34
|
+
const count = u32LE(packed, 0)
|
|
35
|
+
let off = 4
|
|
36
|
+
const out: MultipartPart[] = []
|
|
37
|
+
for (let i = 0; i < count; i++) {
|
|
38
|
+
const nameLen = u32LE(packed, off)
|
|
39
|
+
off += 4
|
|
40
|
+
const name = decoder.decode(packed.subarray(off, off + nameLen))
|
|
41
|
+
off += nameLen
|
|
42
|
+
const hasFilename = packed[off] ?? 0
|
|
43
|
+
off += 1
|
|
44
|
+
const filenameLen = u32LE(packed, off)
|
|
45
|
+
off += 4
|
|
46
|
+
const filename =
|
|
47
|
+
hasFilename === 1 ? decoder.decode(packed.subarray(off, off + filenameLen)) : null
|
|
48
|
+
off += filenameLen
|
|
49
|
+
const hasCt = packed[off] ?? 0
|
|
50
|
+
off += 1
|
|
51
|
+
const ctLen = u32LE(packed, off)
|
|
52
|
+
off += 4
|
|
53
|
+
const contentType = hasCt === 1 ? decoder.decode(packed.subarray(off, off + ctLen)) : null
|
|
54
|
+
off += ctLen
|
|
55
|
+
const dataLen = u32LE(packed, off)
|
|
56
|
+
off += 4
|
|
57
|
+
const data = packed.subarray(off, off + dataLen).slice()
|
|
58
|
+
off += dataLen
|
|
59
|
+
out.push({ name, filename, contentType, data })
|
|
60
|
+
}
|
|
61
|
+
return out
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Output / streaming scalar methods (`Pick<RustScalar, ...>`). */
|
|
65
|
+
export function buildPayload(ctx: RustClientContext) {
|
|
66
|
+
const { addon } = ctx
|
|
67
|
+
const { builtins, transport } = ctx.runtime
|
|
68
|
+
// Hoist the immutable per-runtime builtin-delegation decision into a
|
|
69
|
+
// bind-time flag (BUILTIN_OPS is a module constant — never mutated) so the
|
|
70
|
+
// hot path reads a property instead of a `Set.has`.
|
|
71
|
+
const HAS = { gzipCompress: builtins.has('gzipCompress') }
|
|
72
|
+
// Lazy-memoized ffi surface: binds on first call, single local read after.
|
|
73
|
+
const ffi = memoizeFfi(transport)
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
gzipCompress(data: Uint8Array, level?: number | null): Uint8Array {
|
|
77
|
+
// Optimal by default under Bun: `Bun.gzipSync` (native zlib) is ~2x
|
|
78
|
+
// faster than the rust+FFI crossing (decision matrix). Emits valid gzip
|
|
79
|
+
// (decompression-parity with the addon — only the header OS byte
|
|
80
|
+
// differs: 0xFF vs 0x03). `gzipCompressInto` (pooled) stays native.
|
|
81
|
+
if (HAS.gzipCompress) return builtins.gzipCompress(data, level)
|
|
82
|
+
const f = ffi()
|
|
83
|
+
if (f) return f.gzipCompress(data, level ?? undefined)
|
|
84
|
+
return addon.gzipCompress(data, level ?? null)
|
|
85
|
+
},
|
|
86
|
+
gzipCompressInto(data: Uint8Array, output: Uint8Array, level?: number | null): number {
|
|
87
|
+
const f = ffi()
|
|
88
|
+
if (f) return f.gzipCompressInto(data, output, level ?? undefined)
|
|
89
|
+
return writeInto('gzip compress', output, addon.gzipCompress(data, level ?? null))
|
|
90
|
+
},
|
|
91
|
+
gzipDecompress(data: Uint8Array, maxDecompressed?: number | null): Uint8Array {
|
|
92
|
+
const f = ffi()
|
|
93
|
+
if (f) return f.gzipDecompress(data, maxDecompressed ?? undefined)
|
|
94
|
+
return addon.gzipDecompress(data, maxDecompressed ?? null)
|
|
95
|
+
},
|
|
96
|
+
gzipDecompressInto(
|
|
97
|
+
data: Uint8Array,
|
|
98
|
+
output: Uint8Array,
|
|
99
|
+
maxDecompressed?: number | null,
|
|
100
|
+
): number {
|
|
101
|
+
// Pooled sibling — caller-owned output buffer (the C ABI streams into
|
|
102
|
+
// it via the `_into` core; keeps the 64 MiB decompression-bomb cap).
|
|
103
|
+
const f = ffi()
|
|
104
|
+
if (f) return f.gzipDecompressInto(data, output, maxDecompressed ?? undefined)
|
|
105
|
+
return writeInto('gzip decompress', output, addon.gzipDecompress(data, maxDecompressed ?? null))
|
|
106
|
+
},
|
|
107
|
+
brotliCompress(data: Uint8Array, quality?: number | null): Uint8Array {
|
|
108
|
+
const f = ffi()
|
|
109
|
+
if (f) return f.brotliCompress(data, quality ?? undefined)
|
|
110
|
+
return addon.brotliCompress(data, quality ?? null)
|
|
111
|
+
},
|
|
112
|
+
brotliCompressInto(data: Uint8Array, output: Uint8Array, quality?: number | null): number {
|
|
113
|
+
const f = ffi()
|
|
114
|
+
if (f) return f.brotliCompressInto(data, output, quality ?? undefined)
|
|
115
|
+
return writeInto('brotli compress', output, addon.brotliCompress(data, quality ?? null))
|
|
116
|
+
},
|
|
117
|
+
brotliDecompress(data: Uint8Array, maxDecompressed?: number | null): Uint8Array {
|
|
118
|
+
const f = ffi()
|
|
119
|
+
if (f) return f.brotliDecompress(data, maxDecompressed ?? undefined)
|
|
120
|
+
return addon.brotliDecompress(data, maxDecompressed ?? null)
|
|
121
|
+
},
|
|
122
|
+
brotliDecompressInto(
|
|
123
|
+
data: Uint8Array,
|
|
124
|
+
output: Uint8Array,
|
|
125
|
+
maxDecompressed?: number | null,
|
|
126
|
+
): number {
|
|
127
|
+
// Pooled sibling — caller-owned output buffer (keeps the 64 MiB cap).
|
|
128
|
+
const f = ffi()
|
|
129
|
+
if (f) return f.brotliDecompressInto(data, output, maxDecompressed ?? undefined)
|
|
130
|
+
return writeInto('brotli decompress', output, addon.brotliDecompress(data, maxDecompressed ?? null))
|
|
131
|
+
},
|
|
132
|
+
multipartParse(body: Uint8Array, boundary: Uint8Array): MultipartPart[] {
|
|
133
|
+
// FFI-first: packed parts (castrum_multipart_parse_packed) → unpack to
|
|
134
|
+
// the object shape. napi keeps its object path.
|
|
135
|
+
const f = ffi()
|
|
136
|
+
if (f) return unpackMultipart(f.multipartParsePacked(body, boundary))
|
|
137
|
+
// Normalize napi `Option<String>` (undefined) → null and expose the
|
|
138
|
+
// camelCase `contentType` key (napi renames `content_type` to camelCase).
|
|
139
|
+
return addon.multipartParse(body, boundary).map((p) => ({
|
|
140
|
+
name: p.name,
|
|
141
|
+
filename: p.filename ?? null,
|
|
142
|
+
contentType: p.contentType ?? null,
|
|
143
|
+
data: p.data,
|
|
144
|
+
}))
|
|
145
|
+
},
|
|
146
|
+
multipartParsePacked(body: Uint8Array, boundary: Uint8Array): Uint8Array {
|
|
147
|
+
// Zero-copy packed sibling — no JS objects per part (see native types).
|
|
148
|
+
return resolveNative(ctx, 'multipartParsePacked')(body, boundary) as Uint8Array
|
|
149
|
+
},
|
|
150
|
+
multipartParsePackedInto(body: Uint8Array, boundary: Uint8Array, output: Uint8Array): number {
|
|
151
|
+
// Pooled sibling — caller-owned output buffer (the napi addon has no
|
|
152
|
+
// packed `Into` for multipart, so copy via the allocating call there).
|
|
153
|
+
const f = ffi()
|
|
154
|
+
if (f) return f.multipartParsePackedInto(body, boundary, output)
|
|
155
|
+
return writeInto('multipart parse', output, addon.multipartParsePacked(body, boundary))
|
|
156
|
+
},
|
|
157
|
+
wsFrameEncode(opcode: number, payload: Uint8Array, mask: boolean, fin: boolean): Uint8Array {
|
|
158
|
+
return resolveNative(ctx, 'wsFrameEncode')(opcode, payload, mask, fin) as Uint8Array
|
|
159
|
+
},
|
|
160
|
+
wsFrameEncodeInto(
|
|
161
|
+
opcode: number,
|
|
162
|
+
payload: Uint8Array,
|
|
163
|
+
mask: boolean,
|
|
164
|
+
fin: boolean,
|
|
165
|
+
output: Uint8Array,
|
|
166
|
+
): number {
|
|
167
|
+
const f = ffi()
|
|
168
|
+
if (f) return f.wsFrameEncodeInto(opcode, payload, mask, fin, output)
|
|
169
|
+
return writeInto('ws frame encode', output, addon.wsFrameEncode(opcode, payload, mask, fin))
|
|
170
|
+
},
|
|
171
|
+
wsFrameDecode(data: Uint8Array): WsFrame | null {
|
|
172
|
+
const f = ffi()
|
|
173
|
+
if (f) {
|
|
174
|
+
// C ABI returns packed [flags][opcode][u32 len][payload]; decode here.
|
|
175
|
+
const packed = f.wsFrameDecodePacked(data)
|
|
176
|
+
if (packed === null) return null
|
|
177
|
+
const payloadLen =
|
|
178
|
+
((packed[2] ?? 0) |
|
|
179
|
+
((packed[3] ?? 0) << 8) |
|
|
180
|
+
((packed[4] ?? 0) << 16) |
|
|
181
|
+
((packed[5] ?? 0) << 24)) >>>
|
|
182
|
+
0
|
|
183
|
+
return {
|
|
184
|
+
fin: (packed[0] ?? 0) === 1,
|
|
185
|
+
opcode: packed[1] ?? 0,
|
|
186
|
+
payload: packed.subarray(6, 6 + payloadLen),
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return addon.wsFrameDecode(data)
|
|
190
|
+
},
|
|
191
|
+
wsFrameDecodePackedInto(data: Uint8Array, output: Uint8Array): number | null {
|
|
192
|
+
// Pooled sibling — caller-owned output buffer sized ≥ data.length + 6
|
|
193
|
+
// (6-byte header + payload). null = malformed frame. The napi addon has
|
|
194
|
+
// no packed decode, so that path re-packs the decoded object instead.
|
|
195
|
+
const f = ffi()
|
|
196
|
+
if (f) return f.wsFrameDecodePackedInto(data, output)
|
|
197
|
+
const frame = addon.wsFrameDecode(data)
|
|
198
|
+
if (frame === null) return null
|
|
199
|
+
const need = 6 + frame.payload.length
|
|
200
|
+
if (output.length < need) throw new Error('ws frame decode: output buffer too small')
|
|
201
|
+
output[0] = frame.fin ? 1 : 0
|
|
202
|
+
output[1] = frame.opcode
|
|
203
|
+
output[2] = frame.payload.length & 0xff
|
|
204
|
+
output[3] = (frame.payload.length >> 8) & 0xff
|
|
205
|
+
output[4] = (frame.payload.length >> 16) & 0xff
|
|
206
|
+
output[5] = (frame.payload.length >> 24) & 0xff
|
|
207
|
+
output.set(frame.payload, 6)
|
|
208
|
+
return need
|
|
209
|
+
},
|
|
210
|
+
sseEncodeEvent(
|
|
211
|
+
event: string | null,
|
|
212
|
+
data: Uint8Array,
|
|
213
|
+
id: string | null,
|
|
214
|
+
retry: number | null,
|
|
215
|
+
): Uint8Array {
|
|
216
|
+
// FFI-first: the C-ABI `castrum_sse_encode_into` encodes directly into a
|
|
217
|
+
// caller buffer (no napi crossing / String args). napi fallback mirrors
|
|
218
|
+
// the Option semantics (null → line omitted).
|
|
219
|
+
const f = ffi()
|
|
220
|
+
if (f) return f.sseEncodeEvent(event, data, id, retry)
|
|
221
|
+
return addon.sseEncodeEvent(event ?? null, data, id ?? null, retry ?? null)
|
|
222
|
+
},
|
|
223
|
+
sseEncodeEventInto(
|
|
224
|
+
event: string | null,
|
|
225
|
+
data: Uint8Array,
|
|
226
|
+
id: string | null,
|
|
227
|
+
retry: number | null,
|
|
228
|
+
output: Uint8Array,
|
|
229
|
+
): number {
|
|
230
|
+
// Pooled sibling — caller-owned output buffer (no per-call alloc). The
|
|
231
|
+
// napi addon has no `_into` variant, so that path re-encodes into the
|
|
232
|
+
// buffer via the allocating call.
|
|
233
|
+
const f = ffi()
|
|
234
|
+
if (f) return f.sseEncodeEventInto(event, data, id, retry, output)
|
|
235
|
+
const bytes = addon.sseEncodeEvent(event ?? null, data, id ?? null, retry ?? null)
|
|
236
|
+
if (output.length < bytes.length) throw new Error('sse encode: output buffer too small')
|
|
237
|
+
output.set(bytes)
|
|
238
|
+
return bytes.length
|
|
239
|
+
},
|
|
240
|
+
}
|
|
241
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// src/rust-ffi/text.ts — String-oriented FFI namespace (text in → text out).
|
|
2
|
+
//
|
|
3
|
+
// `rust.text.*` is the ergonomic string API over the byte-level native
|
|
4
|
+
// functions. Pure wrappers: encode input, call native, decode output.
|
|
5
|
+
//
|
|
6
|
+
// Runtime dispatch is centralized in the adapter (`ctx.runtime`): the Bun
|
|
7
|
+
// built-in delegations (urlEncode/urlDecode) come from `builtins.has(op)` and
|
|
8
|
+
// the native call from `transport.ffi` / `transport.resolve` (bun:ffi first,
|
|
9
|
+
// napi fallback) — no inline `isBun()` / `getBunFFI()`.
|
|
10
|
+
|
|
11
|
+
import { decoder, encoder } from '../shared/bytes'
|
|
12
|
+
import { type RustClientContext, resolveNative } from './context'
|
|
13
|
+
|
|
14
|
+
/** String-oriented FFI namespace. */
|
|
15
|
+
export interface RustText {
|
|
16
|
+
/**
|
|
17
|
+
* Resolve a lowercase file extension to a MIME type string (e.g. `"html"` →
|
|
18
|
+
* `"text/html"`). Unknown extensions return `"application/octet-stream"`.
|
|
19
|
+
* @param ext the file extension WITHOUT a leading dot, case-insensitive.
|
|
20
|
+
* @returns the MIME type for `ext`.
|
|
21
|
+
*/
|
|
22
|
+
mimeFromExtension(ext: string): string
|
|
23
|
+
/**
|
|
24
|
+
* RFC 3986 percent-encode a string (the `encodeURIComponent` unreserved
|
|
25
|
+
* set). Under Bun this delegates to the native `encodeURIComponent` for
|
|
26
|
+
* ~4x speed with byte-for-byte parity.
|
|
27
|
+
* @param input the string to encode.
|
|
28
|
+
* @returns the percent-encoded string.
|
|
29
|
+
*/
|
|
30
|
+
urlEncode(input: string): string
|
|
31
|
+
/**
|
|
32
|
+
* Percent-decode a string. Throws on malformed `%XX` or invalid UTF-8
|
|
33
|
+
* output (parity with `decodeURIComponent`, which is used under Bun).
|
|
34
|
+
* @param input the percent-encoded string to decode.
|
|
35
|
+
* @returns the decoded string.
|
|
36
|
+
*/
|
|
37
|
+
urlDecode(input: string): string
|
|
38
|
+
/**
|
|
39
|
+
* Compute the RFC 6455 `Sec-WebSocket-Accept` value for a client key.
|
|
40
|
+
* @param key the base64 `Sec-WebSocket-Key` header value.
|
|
41
|
+
* @returns the 28-byte base64 accept value.
|
|
42
|
+
*/
|
|
43
|
+
wsAcceptKey(key: string): string
|
|
44
|
+
/** @param input an email address. @returns whether it is RFC-valid. */
|
|
45
|
+
validateEmail(input: string): boolean
|
|
46
|
+
/** @param input a UUID string. @returns whether it is a valid (v1-v8) UUID. */
|
|
47
|
+
validateUuid(input: string): boolean
|
|
48
|
+
/** @param input an IPv4 address. @returns whether it is valid. */
|
|
49
|
+
validateIpv4(input: string): boolean
|
|
50
|
+
/** @param input an IPv6 address. @returns whether it is valid. */
|
|
51
|
+
validateIpv6(input: string): boolean
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Build the `text` namespace for a client context. */
|
|
55
|
+
export function buildText(ctx: RustClientContext): RustText {
|
|
56
|
+
const { builtins, transport } = ctx.runtime
|
|
57
|
+
// String memo: the byte path already caches the native bytes; this avoids the
|
|
58
|
+
// encode → native lookup → slice → decode round-trip for repeated extension
|
|
59
|
+
// strings (the common case) — 0 allocations on a cache hit.
|
|
60
|
+
const mimeStrCache = new Map<string, string>()
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
mimeFromExtension(ext) {
|
|
64
|
+
let mime = mimeStrCache.get(ext)
|
|
65
|
+
if (mime === undefined) {
|
|
66
|
+
// FFI-first: `cstring` ARG + cstring MIME return (native transfer both
|
|
67
|
+
// ways — the engine transcodes the extension in-engine, no JS encode).
|
|
68
|
+
// napi keeps the memoized-bytes path (cachedMime → decode).
|
|
69
|
+
const f = transport.ffi
|
|
70
|
+
mime = f
|
|
71
|
+
? (f.mimeFromExtension(ext) ?? 'application/octet-stream')
|
|
72
|
+
: decoder.decode(ctx.cachedMime(encoder.encode(ext)))
|
|
73
|
+
mimeStrCache.set(ext, mime)
|
|
74
|
+
}
|
|
75
|
+
return mime
|
|
76
|
+
},
|
|
77
|
+
urlEncode(input) {
|
|
78
|
+
// Bun's native encodeURIComponent beats the rust+FFI/NAPI string crossing
|
|
79
|
+
// (~4x, measured) with byte-for-byte parity (RFC 3986 unreserved set) —
|
|
80
|
+
// see docs/bun-builtins-decision-matrix.md.
|
|
81
|
+
if (builtins.has('urlEncode')) return builtins.urlEncodeStr(input)
|
|
82
|
+
return resolveNative(ctx, 'urlEncodeStr')(input) as string
|
|
83
|
+
},
|
|
84
|
+
urlDecode(input) {
|
|
85
|
+
// decodeURIComponent matches the rust decoder on valid input and also
|
|
86
|
+
// throws on malformed/invalid UTF-8 (URIError, an Error subclass).
|
|
87
|
+
if (builtins.has('urlDecode')) return builtins.urlDecodeStr(input)
|
|
88
|
+
return resolveNative(ctx, 'urlDecodeStr')(input) as string
|
|
89
|
+
},
|
|
90
|
+
wsAcceptKey(key) {
|
|
91
|
+
// FFI-first: `cstring` ARG + cstring return (native transfer both ways —
|
|
92
|
+
// the engine transcodes the key in-engine, no JS encode). napi keeps the
|
|
93
|
+
// encode/decode path.
|
|
94
|
+
const f = transport.ffi
|
|
95
|
+
if (f) return f.wsAcceptKey(key)
|
|
96
|
+
return decoder.decode(resolveNative(ctx, 'wsAcceptKey')(encoder.encode(key)) as Uint8Array)
|
|
97
|
+
},
|
|
98
|
+
validateEmail(input) {
|
|
99
|
+
// FFI-first: `cstring` ARG (the engine transcodes the string in-engine —
|
|
100
|
+
// no JS encode). napi fallback keeps the encode path.
|
|
101
|
+
const f = transport.ffi
|
|
102
|
+
if (f) return f.validateEmail(input)
|
|
103
|
+
return resolveNative(ctx, 'validateEmail')(encoder.encode(input)) as boolean
|
|
104
|
+
},
|
|
105
|
+
validateUuid(input) {
|
|
106
|
+
const f = transport.ffi
|
|
107
|
+
if (f) return f.validateUuid(input)
|
|
108
|
+
return resolveNative(ctx, 'validateUuid')(encoder.encode(input)) as boolean
|
|
109
|
+
},
|
|
110
|
+
validateIpv4(input) {
|
|
111
|
+
const f = transport.ffi
|
|
112
|
+
if (f) return f.validateIpv4(input)
|
|
113
|
+
return resolveNative(ctx, 'validateIpv4')(encoder.encode(input)) as boolean
|
|
114
|
+
},
|
|
115
|
+
validateIpv6(input) {
|
|
116
|
+
const f = transport.ffi
|
|
117
|
+
if (f) return f.validateIpv6(input)
|
|
118
|
+
return resolveNative(ctx, 'validateIpv6')(encoder.encode(input)) as boolean
|
|
119
|
+
},
|
|
120
|
+
}
|
|
121
|
+
}
|