castrum 0.7.0 → 0.9.0
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 +322 -59
- package/castrum.linux-x64-gnu.node +0 -0
- package/dist/index.d.ts +3161 -0
- package/dist/index.js +7306 -0
- package/docs/ARCHITECTURE.md +415 -0
- package/docs/BENCHMARKS.md +307 -0
- package/docs/ENVIRONMENT.md +79 -0
- package/docs/GETTING_STARTED.md +209 -0
- package/docs/INGRESS.md +745 -0
- package/docs/REPO_MAP.md +259 -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 +42 -0
- package/docs/bun-builtins-decision-matrix.md +108 -0
- package/index.ts +51 -8
- package/package.json +73 -20
- package/src/data/json-rows.ts +13 -13
- package/src/ingress/body.ts +149 -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 +563 -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 +67 -0
- package/src/ingress/index.ts +130 -405
- package/src/ingress/metrics.ts +132 -0
- package/src/ingress/options.ts +197 -0
- package/src/ingress/packing/gather-raw-headers.ts +75 -0
- package/src/ingress/packing/header-packing.ts +75 -0
- package/src/ingress/packing/input-packer.ts +150 -0
- package/src/ingress/packing/scratch.ts +50 -0
- package/src/ingress/packing/select-headers.ts +96 -0
- package/src/ingress/response/error-bodies.ts +75 -0
- package/src/ingress/response/terminal.ts +59 -0
- package/src/ingress/routes/common.ts +38 -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 +37 -0
- package/src/ingress/routes/index.ts +10 -0
- package/src/ingress/routes/json-write.ts +153 -0
- package/src/ingress/routes/options.ts +36 -0
- package/src/ingress/routes/read.ts +45 -0
- package/src/ingress/server-node.ts +376 -0
- package/src/ingress/server.ts +443 -0
- package/src/ingress/shared.ts +132 -0
- package/src/ingress/status.ts +97 -0
- package/src/ingress/types.ts +157 -0
- package/src/integration/batch.ts +58 -0
- package/src/integration/index.ts +29 -0
- package/src/integration/pipeline.ts +217 -0
- package/src/integration/streaming.ts +79 -0
- package/src/integration/websocket.ts +92 -0
- package/src/loader/batch.ts +133 -0
- package/src/loader/cost.ts +104 -0
- package/src/loader/index.ts +930 -0
- package/src/loader/ops.ts +657 -0
- package/src/native/ffi.ts +1541 -0
- package/src/native/index.ts +8 -207
- package/src/native/loader.ts +194 -0
- package/src/native/types.ts +598 -0
- package/src/rust-ffi/addon.ts +10 -0
- package/src/rust-ffi/batch.ts +431 -0
- package/src/rust-ffi/client.ts +88 -0
- package/src/rust-ffi/context.ts +164 -0
- package/src/rust-ffi/index.ts +40 -541
- package/src/rust-ffi/options.ts +113 -0
- package/src/rust-ffi/packed.ts +253 -0
- package/src/rust-ffi/proven.ts +42 -0
- package/src/rust-ffi/scalar/crypto.ts +265 -0
- package/src/rust-ffi/scalar/factories.ts +233 -0
- package/src/rust-ffi/scalar/hashing.ts +72 -0
- package/src/rust-ffi/scalar/http.ts +206 -0
- package/src/rust-ffi/scalar/index.ts +31 -0
- package/src/rust-ffi/scalar/interface.ts +450 -0
- package/src/rust-ffi/scalar/json.ts +45 -0
- package/src/rust-ffi/scalar/payload.ts +124 -0
- package/src/rust-ffi/text.ts +95 -0
- package/src/selection.json +194 -0
- package/src/selection.ts +77 -0
- package/src/shared/adaptive.ts +68 -0
- package/src/shared/bench-classify.ts +209 -0
- package/src/shared/buffer-pool.ts +209 -0
- package/src/shared/bytes.ts +35 -5
- package/src/shared/env.ts +20 -0
- package/src/shared/log.ts +74 -0
- package/src/shared/metrics.ts +247 -0
- package/src/shared/packed.ts +350 -154
- package/src/shared/proven.ts +596 -0
- package/src/shared/request-id.ts +67 -0
- package/src/shared/response.ts +104 -0
- package/src/shared/runtime.ts +34 -0
- package/src/shared/trace.ts +81 -0
- package/src/shared/uuid.ts +31 -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/ingress/packed-input.ts +0 -59
- package/src/shared/json.ts +0 -18
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// src/shared/buffer-pool.ts — generic reusable byte-buffer pool for hot-path FFI calls.
|
|
2
|
+
//
|
|
3
|
+
// Hot native-FFI paths (e.g. the ingress pipeline) write into large output
|
|
4
|
+
// buffers. Allocating a fresh buffer per call is a measurable cost at high
|
|
5
|
+
// request rates. This pool hands out buffers that are returned to the pool
|
|
6
|
+
// once the consumer is done with them, eliminating steady-state allocation.
|
|
7
|
+
//
|
|
8
|
+
// It is deliberately generic: it works with any `Uint8Array` consumer, is safe
|
|
9
|
+
// for a single-threaded event loop, and supports both eager release (callers
|
|
10
|
+
// that copy out immediately) and in-flight borrowing (zero-copy callers that
|
|
11
|
+
// hold the buffer past the call). Memory is bounded by `maxBuffers` retained
|
|
12
|
+
// buffers; under heavy concurrency, extra temporary buffers are allocated and
|
|
13
|
+
// discarded on release.
|
|
14
|
+
|
|
15
|
+
export interface BufferPoolOptions {
|
|
16
|
+
/**
|
|
17
|
+
* Initial buffer size in bytes. Default: 131072.
|
|
18
|
+
*/
|
|
19
|
+
initialSize?: number
|
|
20
|
+
/**
|
|
21
|
+
* Upper bound on buffers retained by the pool. When this is reached and no
|
|
22
|
+
* free buffer is large enough, the largest free buffer is grown in place
|
|
23
|
+
* (replaced) so memory stays bounded. If the pool is fully in-flight, a
|
|
24
|
+
* temporary buffer is allocated and discarded on release. Default: 16.
|
|
25
|
+
*/
|
|
26
|
+
maxBuffers?: number
|
|
27
|
+
/**
|
|
28
|
+
* Hard cap on the number of buffers simultaneously in flight (acquired but
|
|
29
|
+
* not yet released). When exceeded, `acquire()` throws a `RangeError` — a
|
|
30
|
+
* backpressure signal for zero-copy responses consumed too slowly. 0
|
|
31
|
+
* (default) is unlimited: when the retained set is exhausted, temporary
|
|
32
|
+
* buffers are allocated and discarded on release (unbounded under a slow
|
|
33
|
+
* zero-copy consumer — set this to bound it).
|
|
34
|
+
*/
|
|
35
|
+
maxInFlight?: number
|
|
36
|
+
/**
|
|
37
|
+
* When true, learn the observed request size (EWMA over `minSize` at each
|
|
38
|
+
* take) and pre-size new buffers to at least the learned estimate (bounded
|
|
39
|
+
* by `maxBuffers` retention). Helps workloads with varying request sizes
|
|
40
|
+
* converge on one buffer size instead of re-growing each time. Default false.
|
|
41
|
+
*/
|
|
42
|
+
adaptive?: boolean
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface PooledBuffer {
|
|
46
|
+
/**
|
|
47
|
+
* The underlying buffer. Its byteLength is guaranteed to be at least the
|
|
48
|
+
* `minSize` requested at acquire time.
|
|
49
|
+
*/
|
|
50
|
+
readonly buffer: Uint8Array
|
|
51
|
+
/**
|
|
52
|
+
* Whether this handle has already been returned to the pool.
|
|
53
|
+
*/
|
|
54
|
+
readonly released: boolean
|
|
55
|
+
/**
|
|
56
|
+
* Return the buffer to the pool for reuse. Safe to call more than once;
|
|
57
|
+
* subsequent calls are no-ops.
|
|
58
|
+
*/
|
|
59
|
+
release(): void
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
import { AdaptiveEstimate } from './adaptive'
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A pool of reusable `Uint8Array` buffers.
|
|
66
|
+
*
|
|
67
|
+
* @example
|
|
68
|
+
* ```ts
|
|
69
|
+
* const pool = new BufferPool({ initialSize: 131072 });
|
|
70
|
+
*
|
|
71
|
+
* const handle = pool.acquire(131072);
|
|
72
|
+
* try {
|
|
73
|
+
* // write into handle.buffer ...
|
|
74
|
+
* useBytes(handle.buffer.subarray(0, written));
|
|
75
|
+
* } finally {
|
|
76
|
+
* handle.release();
|
|
77
|
+
* }
|
|
78
|
+
* ```
|
|
79
|
+
*/
|
|
80
|
+
export class BufferPool {
|
|
81
|
+
private readonly free: Uint8Array[] = []
|
|
82
|
+
private readonly initialSize: number
|
|
83
|
+
private readonly maxBuffers: number
|
|
84
|
+
private readonly maxInFlight: number
|
|
85
|
+
private readonly estimate: AdaptiveEstimate | null
|
|
86
|
+
private created = 0
|
|
87
|
+
private inFlight = 0
|
|
88
|
+
|
|
89
|
+
constructor(options: BufferPoolOptions = {}) {
|
|
90
|
+
this.initialSize = Math.max(1, Math.floor(options.initialSize ?? 131_072))
|
|
91
|
+
this.maxBuffers = Math.max(1, Math.floor(options.maxBuffers ?? 16))
|
|
92
|
+
this.maxInFlight = Math.max(0, Math.floor(options.maxInFlight ?? 0))
|
|
93
|
+
this.estimate =
|
|
94
|
+
options.adaptive === true
|
|
95
|
+
? new AdaptiveEstimate({ alpha: 0.25, min: this.initialSize })
|
|
96
|
+
: null
|
|
97
|
+
this.free.push(new Uint8Array(this.initialSize))
|
|
98
|
+
this.created = 1
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Number of buffers currently available for immediate reuse. */
|
|
102
|
+
get freeCount(): number {
|
|
103
|
+
return this.free.length
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Total number of buffers this pool has allocated (monotonic). */
|
|
107
|
+
get createdCount(): number {
|
|
108
|
+
return this.created
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Acquire a buffer of at least `minSize` bytes. The returned handle must be
|
|
113
|
+
* released (directly, or via a zero-copy Response) once the caller is done.
|
|
114
|
+
*
|
|
115
|
+
* @param minSize - Minimum buffer size in bytes. Default: 0 (any free buffer).
|
|
116
|
+
* @throws RangeError when `maxInFlight` is set and that many buffers are
|
|
117
|
+
* already borrowed (never released).
|
|
118
|
+
*/
|
|
119
|
+
acquire(minSize = 0): PooledBuffer {
|
|
120
|
+
if (this.maxInFlight > 0 && this.inFlight >= this.maxInFlight) {
|
|
121
|
+
throw new RangeError(
|
|
122
|
+
`BufferPool: maxInFlight (${this.maxInFlight}) exceeded — too many ` +
|
|
123
|
+
'buffers borrowed at once (unreleased zero-copy responses?). ' +
|
|
124
|
+
'Raise maxInFlight or consume/release responses faster.',
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
const buffer = this.take(Math.max(0, Math.floor(minSize)))
|
|
128
|
+
this.inFlight++
|
|
129
|
+
let released = false
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
buffer,
|
|
133
|
+
get released(): boolean {
|
|
134
|
+
return released
|
|
135
|
+
},
|
|
136
|
+
release: () => {
|
|
137
|
+
if (released) {
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
released = true
|
|
141
|
+
this.inFlight--
|
|
142
|
+
this.releaseBuffer(buffer)
|
|
143
|
+
},
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Take a buffer of at least `minSize` bytes out of circulation. */
|
|
148
|
+
private take(minSize: number): Uint8Array {
|
|
149
|
+
// Learn the observed demand so future allocations pre-size to it.
|
|
150
|
+
this.estimate?.sample(minSize)
|
|
151
|
+
|
|
152
|
+
// 1) Reuse a free buffer that is already large enough.
|
|
153
|
+
for (let i = 0; i < this.free.length; i++) {
|
|
154
|
+
const candidate = this.free[i] as Uint8Array
|
|
155
|
+
if (candidate.byteLength >= minSize) {
|
|
156
|
+
this.free.splice(i, 1)
|
|
157
|
+
return candidate
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const target = this.nextSize(minSize)
|
|
162
|
+
|
|
163
|
+
// 2) No free buffer is large enough: grow the largest free buffer in place
|
|
164
|
+
// to keep the number of retained buffers bounded.
|
|
165
|
+
let largestBuf: Uint8Array | null = null
|
|
166
|
+
let largest = -1
|
|
167
|
+
for (let i = 0; i < this.free.length; i++) {
|
|
168
|
+
const candidate = this.free[i] as Uint8Array
|
|
169
|
+
if (largestBuf === null || candidate.byteLength > largestBuf.byteLength) {
|
|
170
|
+
largestBuf = candidate
|
|
171
|
+
largest = i
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (largestBuf !== null) {
|
|
175
|
+
this.free.splice(largest, 1)
|
|
176
|
+
this.created++
|
|
177
|
+
return new Uint8Array(target)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// 3) Pool exhausted (all buffers in flight): allocate a fresh buffer.
|
|
181
|
+
this.created++
|
|
182
|
+
return new Uint8Array(target)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Return a buffer to the free list, bounding total retained memory. */
|
|
186
|
+
private releaseBuffer(buffer: Uint8Array): void {
|
|
187
|
+
if (this.free.length >= this.maxBuffers) {
|
|
188
|
+
// Free list is full: drop the smallest retained buffer to stay bounded.
|
|
189
|
+
let smallestBuf: Uint8Array | null = null
|
|
190
|
+
let smallest = 0
|
|
191
|
+
for (let i = 0; i < this.free.length; i++) {
|
|
192
|
+
const candidate = this.free[i] as Uint8Array
|
|
193
|
+
if (smallestBuf === null || candidate.byteLength < smallestBuf.byteLength) {
|
|
194
|
+
smallestBuf = candidate
|
|
195
|
+
smallest = i
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
this.free.splice(smallest, 1)
|
|
199
|
+
}
|
|
200
|
+
this.free.push(buffer)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private nextSize(minSize: number): number {
|
|
204
|
+
const floor = this.estimate
|
|
205
|
+
? Math.max(this.initialSize, Math.ceil(this.estimate.estimate))
|
|
206
|
+
: this.initialSize
|
|
207
|
+
return Math.max(floor, minSize)
|
|
208
|
+
}
|
|
209
|
+
}
|
package/src/shared/bytes.ts
CHANGED
|
@@ -3,14 +3,44 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Reusing a single encoder avoids allocation overhead in hot paths.
|
|
5
5
|
*/
|
|
6
|
-
export const encoder = new TextEncoder()
|
|
6
|
+
export const encoder = new TextEncoder()
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Shared TextDecoder instance for decoding UTF-8 bytes to strings.
|
|
10
10
|
*
|
|
11
11
|
* Reusing a single decoder avoids allocation overhead in hot paths.
|
|
12
12
|
*/
|
|
13
|
-
export const decoder = new TextDecoder()
|
|
13
|
+
export const decoder = new TextDecoder()
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Cache of `DataView`s keyed by their backing buffer + creation offset.
|
|
17
|
+
*/
|
|
18
|
+
const dataViewCache = new WeakMap<ArrayBufferLike, { offset: number; view: DataView }>()
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Returns a `DataView` over `buffer` starting at `byteOffset`, creating and
|
|
22
|
+
* caching it once per (backing buffer, byteOffset) pair.
|
|
23
|
+
*
|
|
24
|
+
* Hot ingress paths decode the native packed output through a `DataView` on
|
|
25
|
+
* every request. The pooled/fixed output buffers are long-lived `Uint8Array`s
|
|
26
|
+
* over stable `ArrayBuffer`s at `byteOffset` 0, so a single view per buffer is
|
|
27
|
+
* reused across requests — eliminating one allocation per request. A
|
|
28
|
+
* nonzero-offset subarray (e.g. a written-prefix view over a shared backing
|
|
29
|
+
* buffer) gets its own view so reads stay aligned to the caller's start.
|
|
30
|
+
*
|
|
31
|
+
* @param buffer - The backing buffer to create (or reuse) a view for.
|
|
32
|
+
* @param byteOffset - The start of the view within `buffer`. Default 0.
|
|
33
|
+
* @returns A cached `DataView` over `buffer` starting at `byteOffset`.
|
|
34
|
+
*/
|
|
35
|
+
export function viewForArrayBuffer(buffer: ArrayBufferLike, byteOffset = 0): DataView {
|
|
36
|
+
const cached = dataViewCache.get(buffer)
|
|
37
|
+
if (cached !== undefined && cached.offset === byteOffset) {
|
|
38
|
+
return cached.view
|
|
39
|
+
}
|
|
40
|
+
const view = new DataView(buffer, byteOffset)
|
|
41
|
+
dataViewCache.set(buffer, { offset: byteOffset, view })
|
|
42
|
+
return view
|
|
43
|
+
}
|
|
14
44
|
|
|
15
45
|
/**
|
|
16
46
|
* Creates an independent copy of a Uint8Array.
|
|
@@ -30,7 +60,7 @@ export const decoder = new TextDecoder();
|
|
|
30
60
|
* ```
|
|
31
61
|
*/
|
|
32
62
|
export function toPlainBuffer(bytes: Uint8Array): Uint8Array {
|
|
33
|
-
const copy = new Uint8Array(bytes.byteLength)
|
|
34
|
-
copy.set(bytes)
|
|
35
|
-
return copy
|
|
63
|
+
const copy = new Uint8Array(bytes.byteLength)
|
|
64
|
+
copy.set(bytes)
|
|
65
|
+
return copy
|
|
36
66
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// src/shared/env.ts — Centralized environment-variable resolution.
|
|
2
|
+
//
|
|
3
|
+
// Runtime env vars use the `CASTRUM_*` prefix; legacy `RUST_BENCH_*` / `RUST_*`
|
|
4
|
+
// names are still read as aliases (see docs/ENVIRONMENT.md). All alias-chain
|
|
5
|
+
// reads MUST go through `resolveEnvVar` so the chains can never drift apart
|
|
6
|
+
// (they previously lived inline at several call 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,74 @@
|
|
|
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
|
+
export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'silent'
|
|
10
|
+
|
|
11
|
+
const LEVEL_ORDER: Record<LogLevel, number> = {
|
|
12
|
+
debug: 10,
|
|
13
|
+
info: 20,
|
|
14
|
+
warn: 30,
|
|
15
|
+
error: 40,
|
|
16
|
+
silent: 99,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function resolveLevel(envValue: string | undefined): LogLevel {
|
|
20
|
+
const v = envValue?.trim().toLowerCase()
|
|
21
|
+
if (v === 'debug') return 'debug'
|
|
22
|
+
if (v === 'warn' || v === 'warning') return 'warn'
|
|
23
|
+
if (v === 'error') return 'error'
|
|
24
|
+
if (v === 'silent' || v === 'off' || v === 'none') return 'silent'
|
|
25
|
+
return 'info'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A minimal structured logger. Emits `{ts, level, ...fields}` JSON lines to
|
|
30
|
+
* `stream` when `level` is enabled by the ambient `CASTRUM_LOG_LEVEL`.
|
|
31
|
+
*/
|
|
32
|
+
export function createStructuredLogger(
|
|
33
|
+
stream: Pick<NodeJS.WritableStream, 'write'> = process.stderr,
|
|
34
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
35
|
+
) {
|
|
36
|
+
const threshold = LEVEL_ORDER[resolveLevel(env.CASTRUM_LOG_LEVEL)] ?? LEVEL_ORDER.info
|
|
37
|
+
|
|
38
|
+
function emit(level: LogLevel, fields: Record<string, unknown>): void {
|
|
39
|
+
if (LEVEL_ORDER[level] < threshold) return
|
|
40
|
+
try {
|
|
41
|
+
const line = JSON.stringify({
|
|
42
|
+
ts: Date.now(),
|
|
43
|
+
level,
|
|
44
|
+
...fields,
|
|
45
|
+
})
|
|
46
|
+
stream.write(`${line}\n`)
|
|
47
|
+
} catch {
|
|
48
|
+
// never throw from logging
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
/** Record one completed request. */
|
|
54
|
+
request(info: {
|
|
55
|
+
requestId: string
|
|
56
|
+
method: string
|
|
57
|
+
path: string
|
|
58
|
+
status: number
|
|
59
|
+
durationMs: number
|
|
60
|
+
ip?: string
|
|
61
|
+
}): void {
|
|
62
|
+
emit('info', { event: 'request', ...info })
|
|
63
|
+
},
|
|
64
|
+
error(info: {
|
|
65
|
+
requestId?: string
|
|
66
|
+
method?: string
|
|
67
|
+
path?: string
|
|
68
|
+
code?: string
|
|
69
|
+
message: string
|
|
70
|
+
}): void {
|
|
71
|
+
emit('error', { event: 'error', ...info })
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -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
|
+
}
|