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.
Files changed (176) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +322 -59
  3. package/castrum.linux-x64-gnu.node +0 -0
  4. package/dist/index.d.ts +3161 -0
  5. package/dist/index.js +7306 -0
  6. package/docs/ARCHITECTURE.md +415 -0
  7. package/docs/BENCHMARKS.md +307 -0
  8. package/docs/ENVIRONMENT.md +79 -0
  9. package/docs/GETTING_STARTED.md +209 -0
  10. package/docs/INGRESS.md +745 -0
  11. package/docs/REPO_MAP.md +259 -0
  12. package/docs/adr/0001-two-wire-formats.md +39 -0
  13. package/docs/adr/0002-zero-dom-fast-schema.md +38 -0
  14. package/docs/adr/0003-bun-builtins-delegation.md +42 -0
  15. package/docs/bun-builtins-decision-matrix.md +108 -0
  16. package/index.ts +51 -8
  17. package/package.json +73 -20
  18. package/src/data/json-rows.ts +13 -13
  19. package/src/ingress/body.ts +149 -0
  20. package/src/ingress/constants.ts +220 -48
  21. package/src/ingress/context.ts +136 -0
  22. package/src/ingress/decode/baked-result.ts +121 -0
  23. package/src/ingress/decode/fast-result.ts +160 -0
  24. package/src/ingress/decode/packed-sections.ts +56 -0
  25. package/src/ingress/decode/result-base.ts +172 -0
  26. package/src/ingress/errors.ts +72 -0
  27. package/src/ingress/fast.ts +152 -1021
  28. package/src/ingress/handlers.ts +563 -1001
  29. package/src/ingress/headers/baked-templates.ts +102 -0
  30. package/src/ingress/headers/cors.ts +44 -0
  31. package/src/ingress/headers/fast-templates.ts +238 -0
  32. package/src/ingress/headers/hsts.ts +83 -0
  33. package/src/ingress/health.ts +67 -0
  34. package/src/ingress/index.ts +130 -405
  35. package/src/ingress/metrics.ts +132 -0
  36. package/src/ingress/options.ts +197 -0
  37. package/src/ingress/packing/gather-raw-headers.ts +75 -0
  38. package/src/ingress/packing/header-packing.ts +75 -0
  39. package/src/ingress/packing/input-packer.ts +150 -0
  40. package/src/ingress/packing/scratch.ts +50 -0
  41. package/src/ingress/packing/select-headers.ts +96 -0
  42. package/src/ingress/response/error-bodies.ts +75 -0
  43. package/src/ingress/response/terminal.ts +59 -0
  44. package/src/ingress/routes/common.ts +38 -0
  45. package/src/ingress/routes/delete.ts +14 -0
  46. package/src/ingress/routes/echo.ts +107 -0
  47. package/src/ingress/routes/fallback.ts +23 -0
  48. package/src/ingress/routes/head.ts +37 -0
  49. package/src/ingress/routes/index.ts +10 -0
  50. package/src/ingress/routes/json-write.ts +153 -0
  51. package/src/ingress/routes/options.ts +36 -0
  52. package/src/ingress/routes/read.ts +45 -0
  53. package/src/ingress/server-node.ts +376 -0
  54. package/src/ingress/server.ts +443 -0
  55. package/src/ingress/shared.ts +132 -0
  56. package/src/ingress/status.ts +97 -0
  57. package/src/ingress/types.ts +157 -0
  58. package/src/integration/batch.ts +58 -0
  59. package/src/integration/index.ts +29 -0
  60. package/src/integration/pipeline.ts +217 -0
  61. package/src/integration/streaming.ts +79 -0
  62. package/src/integration/websocket.ts +92 -0
  63. package/src/loader/batch.ts +133 -0
  64. package/src/loader/cost.ts +104 -0
  65. package/src/loader/index.ts +930 -0
  66. package/src/loader/ops.ts +657 -0
  67. package/src/native/ffi.ts +1541 -0
  68. package/src/native/index.ts +8 -207
  69. package/src/native/loader.ts +194 -0
  70. package/src/native/types.ts +598 -0
  71. package/src/rust-ffi/addon.ts +10 -0
  72. package/src/rust-ffi/batch.ts +431 -0
  73. package/src/rust-ffi/client.ts +88 -0
  74. package/src/rust-ffi/context.ts +164 -0
  75. package/src/rust-ffi/index.ts +40 -541
  76. package/src/rust-ffi/options.ts +113 -0
  77. package/src/rust-ffi/packed.ts +253 -0
  78. package/src/rust-ffi/proven.ts +42 -0
  79. package/src/rust-ffi/scalar/crypto.ts +265 -0
  80. package/src/rust-ffi/scalar/factories.ts +233 -0
  81. package/src/rust-ffi/scalar/hashing.ts +72 -0
  82. package/src/rust-ffi/scalar/http.ts +206 -0
  83. package/src/rust-ffi/scalar/index.ts +31 -0
  84. package/src/rust-ffi/scalar/interface.ts +450 -0
  85. package/src/rust-ffi/scalar/json.ts +45 -0
  86. package/src/rust-ffi/scalar/payload.ts +124 -0
  87. package/src/rust-ffi/text.ts +95 -0
  88. package/src/selection.json +194 -0
  89. package/src/selection.ts +77 -0
  90. package/src/shared/adaptive.ts +68 -0
  91. package/src/shared/bench-classify.ts +209 -0
  92. package/src/shared/buffer-pool.ts +209 -0
  93. package/src/shared/bytes.ts +35 -5
  94. package/src/shared/env.ts +20 -0
  95. package/src/shared/log.ts +74 -0
  96. package/src/shared/metrics.ts +247 -0
  97. package/src/shared/packed.ts +350 -154
  98. package/src/shared/proven.ts +596 -0
  99. package/src/shared/request-id.ts +67 -0
  100. package/src/shared/response.ts +104 -0
  101. package/src/shared/runtime.ts +34 -0
  102. package/src/shared/trace.ts +81 -0
  103. package/src/shared/uuid.ts +31 -0
  104. package/Cargo.toml +0 -73
  105. package/bench.ts +0 -3
  106. package/rust/batch.rs +0 -195
  107. package/rust/cookie_parser.rs +0 -70
  108. package/rust/cors.rs +0 -263
  109. package/rust/hashing.rs +0 -164
  110. package/rust/headers.rs +0 -234
  111. package/rust/hmac_sha256.rs +0 -113
  112. package/rust/http_parser.rs +0 -132
  113. package/rust/ingress.rs +0 -616
  114. package/rust/ingress_constants.rs +0 -89
  115. package/rust/ip_trust.rs +0 -185
  116. package/rust/json_ops.rs +0 -66
  117. package/rust/json_patch_ops.rs +0 -25
  118. package/rust/json_schema.rs +0 -151
  119. package/rust/json_ser.rs +0 -429
  120. package/rust/lib.rs +0 -48
  121. package/rust/method.rs +0 -141
  122. package/rust/mime_lookup.rs +0 -76
  123. package/rust/output.rs +0 -238
  124. package/rust/proxy.rs +0 -90
  125. package/rust/query_parser.rs +0 -141
  126. package/rust/random_token.rs +0 -52
  127. package/rust/rate_limit.rs +0 -203
  128. package/rust/terminal.rs +0 -95
  129. package/rust/unit_tests.rs +0 -843
  130. package/rust/url_codec.rs +0 -133
  131. package/rust/util.rs +0 -537
  132. package/rust/validation.rs +0 -107
  133. package/rust/websocket.rs +0 -23
  134. package/src/baseline/index.ts +0 -12
  135. package/src/baseline/tasks/cookie.ts +0 -22
  136. package/src/baseline/tasks/hashing.ts +0 -18
  137. package/src/baseline/tasks/hmac.ts +0 -38
  138. package/src/baseline/tasks/http.ts +0 -62
  139. package/src/baseline/tasks/json-patch.ts +0 -12
  140. package/src/baseline/tasks/json.ts +0 -50
  141. package/src/baseline/tasks/mime.ts +0 -5
  142. package/src/baseline/tasks/query.ts +0 -14
  143. package/src/baseline/tasks/token.ts +0 -15
  144. package/src/baseline/tasks/url.ts +0 -11
  145. package/src/baseline/tasks/validation.ts +0 -19
  146. package/src/baseline/tasks/websocket.ts +0 -14
  147. package/src/bench/assert.ts +0 -70
  148. package/src/bench/checks.ts +0 -227
  149. package/src/bench/checksum.ts +0 -27
  150. package/src/bench/comparisons.ts +0 -70
  151. package/src/bench/concurrent-worker.ts +0 -135
  152. package/src/bench/fixtures.ts +0 -233
  153. package/src/bench/index.ts +0 -2
  154. package/src/bench/measure.ts +0 -177
  155. package/src/bench/now.ts +0 -3
  156. package/src/bench/report.ts +0 -79
  157. package/src/bench/run.ts +0 -57
  158. package/src/bench/tasks/complex.ts +0 -232
  159. package/src/bench/tasks/concurrent.ts +0 -213
  160. package/src/bench/tasks/cookie.ts +0 -44
  161. package/src/bench/tasks/hashing.ts +0 -33
  162. package/src/bench/tasks/hmac.ts +0 -36
  163. package/src/bench/tasks/http.ts +0 -50
  164. package/src/bench/tasks/index.ts +0 -52
  165. package/src/bench/tasks/json-patch.ts +0 -21
  166. package/src/bench/tasks/json.ts +0 -33
  167. package/src/bench/tasks/mime.ts +0 -21
  168. package/src/bench/tasks/query.ts +0 -44
  169. package/src/bench/tasks/stress.ts +0 -142
  170. package/src/bench/tasks/token.ts +0 -20
  171. package/src/bench/tasks/url.ts +0 -34
  172. package/src/bench/tasks/validation.ts +0 -57
  173. package/src/bench/tasks/websocket.ts +0 -21
  174. package/src/bench/types.ts +0 -60
  175. package/src/ingress/packed-input.ts +0 -59
  176. 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
+ }
@@ -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
+ }