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
package/src/shared/bytes.ts
CHANGED
|
@@ -1,16 +1,115 @@
|
|
|
1
|
+
// src/shared/bytes.ts — shared UTF-8 / byte helpers (encoder, decoder, toBytes, toText).
|
|
2
|
+
|
|
3
|
+
import { decodeUtf8, encodeUtf8, encodeUtf8Into } from './codec'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Shared UTF-8 encoder (encode / encodeInto), backed by the runtime-native
|
|
7
|
+
* codec (`src/shared/codec.ts`): on Bun the work is done by Bun's native
|
|
8
|
+
* transfer machinery (`Bun.ArrayBufferSink` / a zero-copy `Buffer` view
|
|
9
|
+
* write); under Node it falls back to `TextEncoder`. Reusing a single object
|
|
10
|
+
* avoids allocation overhead in hot paths.
|
|
11
|
+
*
|
|
12
|
+
* `encode` accepts the string-or-bytes union the text-returning `rust.*` ops
|
|
13
|
+
* surface (Bun returns strings, Node returns bytes) and normalizes to bytes —
|
|
14
|
+
* a string is UTF-8 encoded, bytes pass through unchanged.
|
|
15
|
+
*/
|
|
16
|
+
export const encoder: {
|
|
17
|
+
encode(s: Uint8Array | string): Uint8Array
|
|
18
|
+
encodeInto(s: string, dest: Uint8Array): { read: number; written: number }
|
|
19
|
+
} = {
|
|
20
|
+
encode(s) {
|
|
21
|
+
return typeof s === 'string' ? encodeUtf8(s) : s
|
|
22
|
+
},
|
|
23
|
+
encodeInto(s, dest) {
|
|
24
|
+
const written = encodeUtf8Into(s, dest)
|
|
25
|
+
// `read` mirrors `TextEncoder.encodeInto`'s shape; callers here only use
|
|
26
|
+
// `written` (a truncated write consumes the whole source in our call
|
|
27
|
+
// sites), so this is a documented approximation.
|
|
28
|
+
return { read: s.length, written }
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
|
|
1
32
|
/**
|
|
2
|
-
* Shared
|
|
33
|
+
* Shared UTF-8 decoder, backed by the runtime-native codec
|
|
34
|
+
* (`src/shared/codec.ts`): on Bun it uses `new CString(ptr, 0, len)` from
|
|
35
|
+
* `bun:ffi` (JSC-native, exact-length clone); under Node it falls back to
|
|
36
|
+
* `TextDecoder`. Reusing a single object avoids allocation overhead in hot
|
|
37
|
+
* paths.
|
|
3
38
|
*
|
|
4
|
-
*
|
|
39
|
+
* `decode` accepts the string-or-bytes union the text-returning `rust.*` ops
|
|
40
|
+
* surface and normalizes to a string — bytes are UTF-8 decoded, strings pass
|
|
41
|
+
* through unchanged.
|
|
5
42
|
*/
|
|
6
|
-
export const
|
|
43
|
+
export const decoder: {
|
|
44
|
+
decode(bytes: Uint8Array | string): string
|
|
45
|
+
} = {
|
|
46
|
+
decode(bytes) {
|
|
47
|
+
return typeof bytes === 'string' ? bytes : decodeUtf8(bytes)
|
|
48
|
+
},
|
|
49
|
+
}
|
|
7
50
|
|
|
8
51
|
/**
|
|
9
|
-
*
|
|
52
|
+
* Normalize the string-or-bytes union (the text-returning `rust.*` ops return
|
|
53
|
+
* STRINGS on the Bun path — native transfer — and bytes on the napi path) to
|
|
54
|
+
* bytes. Codec-backed — no `TextEncoder` runs on the Bun path.
|
|
10
55
|
*
|
|
11
|
-
*
|
|
56
|
+
* @param v - A string or UTF-8 byte buffer.
|
|
57
|
+
* @returns The UTF-8 bytes of `v` (identity when `v` is already bytes).
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* ```ts
|
|
61
|
+
* toBytes('aGVsbG8=') // Uint8Array(8)
|
|
62
|
+
* ```
|
|
12
63
|
*/
|
|
13
|
-
export
|
|
64
|
+
export function toBytes(v: Uint8Array | string): Uint8Array {
|
|
65
|
+
return typeof v === 'string' ? encodeUtf8(v) : v
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Normalize the string-or-bytes union to a string. Codec-backed — no
|
|
70
|
+
* `TextDecoder` runs on the Bun path.
|
|
71
|
+
*
|
|
72
|
+
* @param v - A string or UTF-8 byte buffer.
|
|
73
|
+
* @returns The decoded string (identity when `v` is already a string).
|
|
74
|
+
*
|
|
75
|
+
* @example
|
|
76
|
+
* ```ts
|
|
77
|
+
* toText(new Uint8Array([104, 105])) // 'hi'
|
|
78
|
+
* ```
|
|
79
|
+
*/
|
|
80
|
+
export function toText(v: Uint8Array | string): string {
|
|
81
|
+
return typeof v === 'string' ? v : decodeUtf8(v)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Cache of `DataView`s keyed by their backing buffer + creation offset.
|
|
86
|
+
*/
|
|
87
|
+
const dataViewCache = new WeakMap<ArrayBufferLike, { offset: number; view: DataView }>()
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Returns a `DataView` over `buffer` starting at `byteOffset`, creating and
|
|
91
|
+
* caching it once per (backing buffer, byteOffset) pair.
|
|
92
|
+
*
|
|
93
|
+
* Hot ingress paths decode the native packed output through a `DataView` on
|
|
94
|
+
* every request. The pooled/fixed output buffers are long-lived `Uint8Array`s
|
|
95
|
+
* over stable `ArrayBuffer`s at `byteOffset` 0, so a single view per buffer is
|
|
96
|
+
* reused across requests — eliminating one allocation per request. A
|
|
97
|
+
* nonzero-offset subarray (e.g. a written-prefix view over a shared backing
|
|
98
|
+
* buffer) gets its own view so reads stay aligned to the caller's start.
|
|
99
|
+
*
|
|
100
|
+
* @param buffer - The backing buffer to create (or reuse) a view for.
|
|
101
|
+
* @param byteOffset - The start of the view within `buffer`. Default 0.
|
|
102
|
+
* @returns A cached `DataView` over `buffer` starting at `byteOffset`.
|
|
103
|
+
*/
|
|
104
|
+
export function viewForArrayBuffer(buffer: ArrayBufferLike, byteOffset = 0): DataView {
|
|
105
|
+
const cached = dataViewCache.get(buffer)
|
|
106
|
+
if (cached !== undefined && cached.offset === byteOffset) {
|
|
107
|
+
return cached.view
|
|
108
|
+
}
|
|
109
|
+
const view = new DataView(buffer, byteOffset)
|
|
110
|
+
dataViewCache.set(buffer, { offset: byteOffset, view })
|
|
111
|
+
return view
|
|
112
|
+
}
|
|
14
113
|
|
|
15
114
|
/**
|
|
16
115
|
* Creates an independent copy of a Uint8Array.
|
|
@@ -30,7 +129,7 @@ export const decoder = new TextDecoder();
|
|
|
30
129
|
* ```
|
|
31
130
|
*/
|
|
32
131
|
export function toPlainBuffer(bytes: Uint8Array): Uint8Array {
|
|
33
|
-
const copy = new Uint8Array(bytes.byteLength)
|
|
34
|
-
copy.set(bytes)
|
|
35
|
-
return copy
|
|
132
|
+
const copy = new Uint8Array(bytes.byteLength)
|
|
133
|
+
copy.set(bytes)
|
|
134
|
+
return copy
|
|
36
135
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// src/shared/codec.ts — runtime-native UTF-8 codec (facade).
|
|
2
|
+
//
|
|
3
|
+
// The runtime-native UTF-8 codec (Bun transfer machinery vs
|
|
4
|
+
// `TextEncoder`/`TextDecoder`) is implemented in `src/runtime/codec.ts` and
|
|
5
|
+
// selected ONCE at module load by the runtime adapter. This module re-exports
|
|
6
|
+
// it so existing callers are unchanged. The Bun/Node branches live in
|
|
7
|
+
// `src/runtime/codec.ts` — do NOT add `TextEncoder`/`TextDecoder` or `bun:ffi`
|
|
8
|
+
// calls here.
|
|
9
|
+
|
|
10
|
+
import { runtimeCodec } from '../runtime/codec'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Encode a string to UTF-8 bytes.
|
|
14
|
+
*
|
|
15
|
+
* On Bun this uses `Bun.ArrayBufferSink` (native UTF-8 writing — no
|
|
16
|
+
* `TextEncoder`). Under Node it falls back to the standard `TextEncoder`.
|
|
17
|
+
* The implementation is selected once at load via the runtime adapter.
|
|
18
|
+
*
|
|
19
|
+
* @param s - The string to encode to UTF-8 bytes.
|
|
20
|
+
* @returns A fresh `Uint8Array` of the UTF-8 encoding of `s`.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```ts
|
|
24
|
+
* encodeUtf8('hello') // Uint8Array [104, 101, 108, 108, 111]
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
export const encodeUtf8: (s: string) => Uint8Array = runtimeCodec.encodeUtf8
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Encode a string into `dest` starting at `offset`, returning bytes written.
|
|
31
|
+
*
|
|
32
|
+
* On Bun this writes through a zero-copy `Buffer` view over `dest`'s backing
|
|
33
|
+
* store (native `.write` — never splits a multibyte character); under Node it
|
|
34
|
+
* uses `TextEncoder.prototype.encodeInto`.
|
|
35
|
+
*
|
|
36
|
+
* @param s - The string to encode.
|
|
37
|
+
* @param dest - The destination buffer.
|
|
38
|
+
* @param offset - Byte offset into `dest` at which to start writing. Default 0.
|
|
39
|
+
* @returns The number of UTF-8 bytes written.
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* ```ts
|
|
43
|
+
* const out = new Uint8Array(8)
|
|
44
|
+
* encodeUtf8Into('hi', out) // 2; out[0..1] = 104, 105
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
export const encodeUtf8Into: (s: string, dest: Uint8Array, offset?: number) => number =
|
|
48
|
+
runtimeCodec.encodeUtf8Into
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Decode UTF-8 bytes to a string.
|
|
52
|
+
*
|
|
53
|
+
* On Bun this uses `new CString(ptr(bytes), 0, len)` from `bun:ffi` (JSC-native
|
|
54
|
+
* exact-length clone); under Node it falls back to the standard `TextDecoder`.
|
|
55
|
+
*
|
|
56
|
+
* @param bytes - The UTF-8 bytes to decode.
|
|
57
|
+
* @returns The decoded string (invalid sequences decode to U+FFFD replacement
|
|
58
|
+
* characters, matching `TextDecoder` replacement mode).
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* ```ts
|
|
62
|
+
* decodeUtf8(new Uint8Array([104, 105])) // 'hi'
|
|
63
|
+
* ```
|
|
64
|
+
*/
|
|
65
|
+
export const decodeUtf8: (bytes: Uint8Array) => string = runtimeCodec.decodeUtf8
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Decode UTF-8 bytes to a string, throwing on invalid UTF-8.
|
|
69
|
+
*
|
|
70
|
+
* Under Node this uses a fatal `TextDecoder`. On Bun (whose `CString` is
|
|
71
|
+
* replacement-mode) strictness is normally enforced by the native
|
|
72
|
+
* `castrum_utf8_valid` C-ABI probe (callers check it first via the transport's
|
|
73
|
+
* ffi surface); this fallback uses the replacement-mode decode.
|
|
74
|
+
*
|
|
75
|
+
* @param bytes - The UTF-8 bytes to decode.
|
|
76
|
+
* @returns The decoded string (throws on invalid UTF-8 under Node).
|
|
77
|
+
*/
|
|
78
|
+
export const decodeUtf8Fatal: (bytes: Uint8Array) => string = runtimeCodec.decodeUtf8Fatal
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// src/shared/env.ts — Centralized environment-variable resolution.
|
|
2
|
+
//
|
|
3
|
+
// Runtime env vars use the `CASTRUM_*` prefix (plus the napi-rs standard
|
|
4
|
+
// `NAPI_RS_NATIVE_LIBRARY_PATH`). All reads MUST go through `resolveEnvVar` so
|
|
5
|
+
// they can never drift apart (they previously lived inline at several call
|
|
6
|
+
// sites with different shapes).
|
|
7
|
+
|
|
8
|
+
/** First set value among `preferred` and its legacy aliases, or `undefined`. */
|
|
9
|
+
export function resolveEnvVar(
|
|
10
|
+
preferred: string,
|
|
11
|
+
legacy: readonly string[] = [],
|
|
12
|
+
): string | undefined {
|
|
13
|
+
const direct = process.env[preferred]
|
|
14
|
+
if (direct !== undefined) return direct
|
|
15
|
+
for (const alias of legacy) {
|
|
16
|
+
const aliasValue = process.env[alias]
|
|
17
|
+
if (aliasValue !== undefined) return aliasValue
|
|
18
|
+
}
|
|
19
|
+
return undefined
|
|
20
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// src/shared/log.ts — lightweight structured logging for the ingress layer.
|
|
2
|
+
//
|
|
3
|
+
// No external dependencies. Emits one JSON object per line to a writable
|
|
4
|
+
// stream (default stderr), gated by the `CASTRUM_LOG_LEVEL` env var. This
|
|
5
|
+
// gives operators a zero-dependency way to correlate requests by request-id
|
|
6
|
+
// without wiring OpenTelemetry. For full control, use the `onRequest` /
|
|
7
|
+
// `onResponse` / `onError` runtime hooks instead.
|
|
8
|
+
|
|
9
|
+
/** Structured-logging severity levels (least to most severe). */
|
|
10
|
+
export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'silent'
|
|
11
|
+
|
|
12
|
+
const LEVEL_ORDER: Record<LogLevel, number> = {
|
|
13
|
+
debug: 10,
|
|
14
|
+
info: 20,
|
|
15
|
+
warn: 30,
|
|
16
|
+
error: 40,
|
|
17
|
+
silent: 99,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function resolveLevel(envValue: string | undefined): LogLevel {
|
|
21
|
+
const v = envValue?.trim().toLowerCase()
|
|
22
|
+
if (v === 'debug') return 'debug'
|
|
23
|
+
if (v === 'warn' || v === 'warning') return 'warn'
|
|
24
|
+
if (v === 'error') return 'error'
|
|
25
|
+
if (v === 'silent' || v === 'off' || v === 'none') return 'silent'
|
|
26
|
+
return 'info'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A minimal structured logger. Emits `{ts, level, ...fields}` JSON lines to
|
|
31
|
+
* `stream` when `level` is enabled by the ambient `CASTRUM_LOG_LEVEL`.
|
|
32
|
+
*/
|
|
33
|
+
export function createStructuredLogger(
|
|
34
|
+
stream: Pick<NodeJS.WritableStream, 'write'> = process.stderr,
|
|
35
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
36
|
+
) {
|
|
37
|
+
const threshold = LEVEL_ORDER[resolveLevel(env.CASTRUM_LOG_LEVEL)] ?? LEVEL_ORDER.info
|
|
38
|
+
|
|
39
|
+
function emit(level: LogLevel, fields: Record<string, unknown>): void {
|
|
40
|
+
if (LEVEL_ORDER[level] < threshold) return
|
|
41
|
+
try {
|
|
42
|
+
const line = JSON.stringify({
|
|
43
|
+
ts: Date.now(),
|
|
44
|
+
level,
|
|
45
|
+
...fields,
|
|
46
|
+
})
|
|
47
|
+
stream.write(`${line}\n`)
|
|
48
|
+
} catch {
|
|
49
|
+
// never throw from logging
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
/** Record one completed request. */
|
|
55
|
+
request(info: {
|
|
56
|
+
requestId: string
|
|
57
|
+
method: string
|
|
58
|
+
path: string
|
|
59
|
+
status: number
|
|
60
|
+
durationMs: number
|
|
61
|
+
ip?: string
|
|
62
|
+
}): void {
|
|
63
|
+
emit('info', { event: 'request', ...info })
|
|
64
|
+
},
|
|
65
|
+
error(info: {
|
|
66
|
+
requestId?: string
|
|
67
|
+
method?: string
|
|
68
|
+
path?: string
|
|
69
|
+
code?: string
|
|
70
|
+
message: string
|
|
71
|
+
}): void {
|
|
72
|
+
emit('error', { event: 'error', ...info })
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// src/shared/metrics.ts — Zero-dependency metrics registry (counters, gauges,
|
|
2
|
+
// histograms) with Prometheus text-exposition rendering.
|
|
3
|
+
//
|
|
4
|
+
// This is the "enterprise observability" primitive: `createMetrics()` gives
|
|
5
|
+
// operators counters/histograms they can expose at a `/metrics` endpoint
|
|
6
|
+
// (see src/ingress/metrics.ts for the ingress wiring + route factory). No
|
|
7
|
+
// external deps — matches the codebase's zero-runtime-dependency ethos; an
|
|
8
|
+
// OpenTelemetry exporter can wrap the same registry later.
|
|
9
|
+
|
|
10
|
+
/** Default histogram buckets (Prometheus-style, seconds). */
|
|
11
|
+
export const DEFAULT_BUCKETS: readonly number[] = [
|
|
12
|
+
0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10,
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
type LabelValues = readonly string[]
|
|
16
|
+
type Labels = Readonly<Record<string, string>>
|
|
17
|
+
|
|
18
|
+
/** Stable key for a label set (sorted for deterministic exposition). */
|
|
19
|
+
function labelKey(names: readonly string[], values: LabelValues): string {
|
|
20
|
+
if (names.length === 0) return ''
|
|
21
|
+
const parts: string[] = []
|
|
22
|
+
for (let i = 0; i < names.length; i++) {
|
|
23
|
+
parts.push(`${names[i]}="${escapeLabel(values[i] ?? '')}"`)
|
|
24
|
+
}
|
|
25
|
+
return parts.join(',')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function escapeLabel(v: string): string {
|
|
29
|
+
return v.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A single labelled counter value. */
|
|
33
|
+
export interface Counter {
|
|
34
|
+
/** Increment by 1 (or `by`). `labels` must match the label names exactly. */
|
|
35
|
+
inc(labels?: Labels, by?: number): void
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A single labelled gauge value (can go up and down). */
|
|
39
|
+
export interface Gauge {
|
|
40
|
+
inc(labels?: Labels, by?: number): void
|
|
41
|
+
dec(labels?: Labels, by?: number): void
|
|
42
|
+
set(labels: Labels | undefined, value: number): void
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A single labelled histogram. */
|
|
46
|
+
export interface Histogram {
|
|
47
|
+
/** Record one observation. `labels` must match the label names exactly. */
|
|
48
|
+
observe(value: number, labels?: Labels): void
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The metrics registry factory. */
|
|
52
|
+
export interface MetricsRegistry {
|
|
53
|
+
/** Create (or fetch) a counter. `help` is only used on first creation. */
|
|
54
|
+
counter(name: string, help: string, labelNames?: readonly string[]): Counter
|
|
55
|
+
/** Create (or fetch) a gauge. */
|
|
56
|
+
gauge(name: string, help: string, labelNames?: readonly string[]): Gauge
|
|
57
|
+
/**
|
|
58
|
+
* Create (or fetch) a histogram. `buckets`/`help` only apply on first
|
|
59
|
+
* creation; later calls with the same name return the existing histogram.
|
|
60
|
+
*/
|
|
61
|
+
histogram(
|
|
62
|
+
name: string,
|
|
63
|
+
help: string,
|
|
64
|
+
buckets?: readonly number[],
|
|
65
|
+
labelNames?: readonly string[],
|
|
66
|
+
): Histogram
|
|
67
|
+
/** Render the Prometheus text exposition format. */
|
|
68
|
+
render(): string
|
|
69
|
+
/** Reset all metrics (for tests / interval rotation). */
|
|
70
|
+
reset(): void
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface CounterEntry {
|
|
74
|
+
name: string
|
|
75
|
+
help: string
|
|
76
|
+
labelNames: string[]
|
|
77
|
+
values: Map<string, number>
|
|
78
|
+
}
|
|
79
|
+
interface GaugeEntry {
|
|
80
|
+
name: string
|
|
81
|
+
help: string
|
|
82
|
+
labelNames: string[]
|
|
83
|
+
values: Map<string, number>
|
|
84
|
+
}
|
|
85
|
+
interface HistogramEntry {
|
|
86
|
+
name: string
|
|
87
|
+
help: string
|
|
88
|
+
labelNames: string[]
|
|
89
|
+
buckets: number[]
|
|
90
|
+
/** le bucket index -> cumulative count per label set. */
|
|
91
|
+
counts: Map<string, number[]>
|
|
92
|
+
sums: Map<string, number>
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Create a fresh metrics registry. */
|
|
96
|
+
export function createMetrics(): MetricsRegistry {
|
|
97
|
+
const counters = new Map<string, CounterEntry>()
|
|
98
|
+
const gauges = new Map<string, GaugeEntry>()
|
|
99
|
+
const histograms = new Map<string, HistogramEntry>()
|
|
100
|
+
|
|
101
|
+
const bucketUpper = (buckets: number[]): number[] => [
|
|
102
|
+
...buckets.map((b) => b),
|
|
103
|
+
Number.POSITIVE_INFINITY,
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
const renderCounter = (e: CounterEntry): string[] => {
|
|
107
|
+
const lines = [`# HELP ${e.name} ${e.help}`, `# TYPE ${e.name} counter`]
|
|
108
|
+
for (const [key, value] of e.values) {
|
|
109
|
+
lines.push(key ? `${e.name}{${key}} ${value}` : `${e.name} ${value}`)
|
|
110
|
+
}
|
|
111
|
+
return lines
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const renderGauge = (e: GaugeEntry): string[] => {
|
|
115
|
+
const lines = [`# HELP ${e.name} ${e.help}`, `# TYPE ${e.name} gauge`]
|
|
116
|
+
for (const [key, value] of e.values) {
|
|
117
|
+
lines.push(key ? `${e.name}{${key}} ${value}` : `${e.name} ${value}`)
|
|
118
|
+
}
|
|
119
|
+
return lines
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const renderHistogram = (e: HistogramEntry): string[] => {
|
|
123
|
+
const lines = [`# HELP ${e.name} ${e.help}`, `# TYPE ${e.name} histogram`]
|
|
124
|
+
const upper = bucketUpper(e.buckets)
|
|
125
|
+
for (const [key, counts] of e.counts) {
|
|
126
|
+
const sum = e.sums.get(key) ?? 0
|
|
127
|
+
const baseSuffix = key ? `{${key}}` : ''
|
|
128
|
+
for (let i = 0; i < upper.length; i++) {
|
|
129
|
+
const le = upper[i]
|
|
130
|
+
const leLabel = le === Number.POSITIVE_INFINITY ? '+Inf' : String(le)
|
|
131
|
+
const v = counts[i] ?? 0
|
|
132
|
+
// `le` merges INTO the label set (Prometheus requires one brace group).
|
|
133
|
+
const full = key ? `${key},le="${leLabel}"` : `le="${leLabel}"`
|
|
134
|
+
lines.push(`${e.name}_bucket{${full}} ${v}`)
|
|
135
|
+
}
|
|
136
|
+
lines.push(`${e.name}_sum${baseSuffix} ${sum}`)
|
|
137
|
+
lines.push(`${e.name}_count${baseSuffix} ${counts[counts.length - 1] ?? 0}`)
|
|
138
|
+
}
|
|
139
|
+
return lines
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const render = (): string => {
|
|
143
|
+
const out: string[] = []
|
|
144
|
+
for (const e of counters.values()) out.push(...renderCounter(e))
|
|
145
|
+
for (const e of gauges.values()) out.push(...renderGauge(e))
|
|
146
|
+
for (const e of histograms.values()) out.push(...renderHistogram(e))
|
|
147
|
+
return `${out.join('\n')}\n`
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const reset = (): void => {
|
|
151
|
+
counters.clear()
|
|
152
|
+
gauges.clear()
|
|
153
|
+
histograms.clear()
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
counter(name, help, labelNames = []) {
|
|
158
|
+
// Capture a guaranteed non-null entry (first creation wins; later calls
|
|
159
|
+
// with the same name return the existing counter).
|
|
160
|
+
const existing = counters.get(name)
|
|
161
|
+
const entry: CounterEntry = existing ?? {
|
|
162
|
+
name,
|
|
163
|
+
help,
|
|
164
|
+
labelNames: [...labelNames],
|
|
165
|
+
values: new Map(),
|
|
166
|
+
}
|
|
167
|
+
if (!existing) counters.set(name, entry)
|
|
168
|
+
const names = entry.labelNames
|
|
169
|
+
return {
|
|
170
|
+
inc(labels, by = 1) {
|
|
171
|
+
const key = labelKey(
|
|
172
|
+
names,
|
|
173
|
+
names.map((n) => labels?.[n] ?? ''),
|
|
174
|
+
)
|
|
175
|
+
entry.values.set(key, (entry.values.get(key) ?? 0) + by)
|
|
176
|
+
},
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
gauge(name, help, labelNames = []) {
|
|
181
|
+
const existing = gauges.get(name)
|
|
182
|
+
const entry: GaugeEntry = existing ?? {
|
|
183
|
+
name,
|
|
184
|
+
help,
|
|
185
|
+
labelNames: [...labelNames],
|
|
186
|
+
values: new Map(),
|
|
187
|
+
}
|
|
188
|
+
if (!existing) gauges.set(name, entry)
|
|
189
|
+
const names = entry.labelNames
|
|
190
|
+
const keyOf = (labels?: Labels): string =>
|
|
191
|
+
labelKey(
|
|
192
|
+
names,
|
|
193
|
+
names.map((n) => labels?.[n] ?? ''),
|
|
194
|
+
)
|
|
195
|
+
return {
|
|
196
|
+
inc(labels, by = 1) {
|
|
197
|
+
entry.values.set(keyOf(labels), (entry.values.get(keyOf(labels)) ?? 0) + by)
|
|
198
|
+
},
|
|
199
|
+
dec(labels, by = 1) {
|
|
200
|
+
entry.values.set(keyOf(labels), (entry.values.get(keyOf(labels)) ?? 0) - by)
|
|
201
|
+
},
|
|
202
|
+
set(labels, value) {
|
|
203
|
+
entry.values.set(keyOf(labels), value)
|
|
204
|
+
},
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
|
|
208
|
+
histogram(name, help, buckets = DEFAULT_BUCKETS, labelNames = []) {
|
|
209
|
+
const existing = histograms.get(name)
|
|
210
|
+
const entry: HistogramEntry = existing ?? {
|
|
211
|
+
name,
|
|
212
|
+
help,
|
|
213
|
+
labelNames: [...labelNames],
|
|
214
|
+
buckets: [...buckets].sort((a, b) => a - b),
|
|
215
|
+
counts: new Map(),
|
|
216
|
+
sums: new Map(),
|
|
217
|
+
}
|
|
218
|
+
if (!existing) histograms.set(name, entry)
|
|
219
|
+
const names = entry.labelNames
|
|
220
|
+
return {
|
|
221
|
+
observe(value, labels) {
|
|
222
|
+
const key = labelKey(
|
|
223
|
+
names,
|
|
224
|
+
names.map((n) => labels?.[n] ?? ''),
|
|
225
|
+
)
|
|
226
|
+
let counts = entry.counts.get(key)
|
|
227
|
+
if (!counts) {
|
|
228
|
+
// One slot per bucket PLUS the implicit +Inf slot.
|
|
229
|
+
counts = new Array<number>(entry.buckets.length + 1).fill(0)
|
|
230
|
+
entry.counts.set(key, counts)
|
|
231
|
+
}
|
|
232
|
+
// Cumulative count per upper bound, including the implicit +Inf
|
|
233
|
+
// bucket (every value falls in it).
|
|
234
|
+
for (let i = 0; i < counts.length; i++) {
|
|
235
|
+
const bound =
|
|
236
|
+
i < entry.buckets.length ? (entry.buckets[i] as number) : Number.POSITIVE_INFINITY
|
|
237
|
+
if (value <= bound) counts[i] = (counts[i] ?? 0) + 1
|
|
238
|
+
}
|
|
239
|
+
entry.sums.set(key, (entry.sums.get(key) ?? 0) + value)
|
|
240
|
+
},
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
|
+
|
|
244
|
+
render,
|
|
245
|
+
reset,
|
|
246
|
+
}
|
|
247
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// src/shared/packed/index.ts — Packed wire-format helpers (barrel).
|
|
2
|
+
//
|
|
3
|
+
// Re-exports the pure wire encode/decode helpers (./wire.ts), the native
|
|
4
|
+
// JSON-Schema validator + batch helpers (./schema.ts), and the ergonomic
|
|
5
|
+
// string parsers (./parsers.ts). Importing this barrel does NOT dlopen the
|
|
6
|
+
// addon — only calling a parser in ./parsers.ts does (lazy).
|
|
7
|
+
|
|
8
|
+
export * from './parsers'
|
|
9
|
+
export * from './schema'
|
|
10
|
+
export * from './wire'
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// src/shared/packed/parsers.ts — High-level string parsers (convenience).
|
|
2
|
+
//
|
|
3
|
+
// Wrap the native packed parsers + decoders for ergonomic use, so consumers
|
|
4
|
+
// don't have to hand-pack buffers or decode packed pairs:
|
|
5
|
+
// parseQueryString("a=1&b=2") // { a: "1", b: "2" }
|
|
6
|
+
// parseCookieHeader("a=1; b=2") // { a: "1", b: "2" }
|
|
7
|
+
|
|
8
|
+
import { getAddon, lazyAddon } from '../../native'
|
|
9
|
+
import { encoder } from '../bytes'
|
|
10
|
+
import { pairsToObject, readPairsPacked } from './wire'
|
|
11
|
+
|
|
12
|
+
// Lazy: importing this module does not dlopen the addon until first use.
|
|
13
|
+
const addon = lazyAddon(getAddon)
|
|
14
|
+
|
|
15
|
+
/** Parse a query string (`a=1&b=2`) into an object via the native parser. */
|
|
16
|
+
export function parseQueryString(query: string): Record<string, string | string[]> {
|
|
17
|
+
const packed = addon.queryParsePacked(encoder.encode(query))
|
|
18
|
+
return pairsToObject(readPairsPacked(packed))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Parse a cookie header (`a=1; b=2`) into an object via the native parser. */
|
|
22
|
+
export function parseCookieHeader(header: string): Record<string, string | string[]> {
|
|
23
|
+
const packed = addon.cookieParsePacked(encoder.encode(header))
|
|
24
|
+
return pairsToObject(readPairsPacked(packed))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Parse an application/x-www-form-urlencoded body into a key/value object. */
|
|
28
|
+
export function parseFormBody(body: Uint8Array): Record<string, string | string[]> {
|
|
29
|
+
const packed = addon.formParsePacked(body)
|
|
30
|
+
return pairsToObject(readPairsPacked(packed))
|
|
31
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// src/shared/packed/schema.ts — Native JSON-Schema validator + batch helpers.
|
|
2
|
+
//
|
|
3
|
+
// Thin wrapper over the native `SchemaValidator` instance: single-item handle
|
|
4
|
+
// alias plus packed-batch validate helpers (bitset / count). The batch entry
|
|
5
|
+
// points reuse the pure pack-scratch pool from ./wire.ts.
|
|
6
|
+
|
|
7
|
+
import type { SchemaValidatorInstance } from '../../native'
|
|
8
|
+
import { unpackBitset, withPackScratch } from './wire'
|
|
9
|
+
|
|
10
|
+
/** Native JSON-Schema validator handle (see `createSchemaValidator`). */
|
|
11
|
+
export type SchemaValidator = SchemaValidatorInstance
|
|
12
|
+
|
|
13
|
+
/** Validate each item against `validator`; returns a packed validity bitset. */
|
|
14
|
+
export function schemaValidateBatch(validator: SchemaValidator, items: Uint8Array[]): Uint8Array {
|
|
15
|
+
return withPackScratch(items, (packed) =>
|
|
16
|
+
unpackBitset(validator.validateBatchPackedBitset(packed)),
|
|
17
|
+
)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Validate each item against `validator`; returns the count of valid items. */
|
|
21
|
+
export function schemaValidateBatchCount(validator: SchemaValidator, items: Uint8Array[]): number {
|
|
22
|
+
return withPackScratch(items, (packed) => validator.validateBatchPackedCount(packed))
|
|
23
|
+
}
|