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.
Files changed (207) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +154 -368
  3. package/castrum.linux-x64-gnu.node +0 -0
  4. package/dist/index.d.ts +2763 -0
  5. package/dist/index.js +8707 -0
  6. package/docs/API.md +337 -0
  7. package/docs/ARCHITECTURE.md +507 -0
  8. package/docs/BENCHMARKS.md +343 -0
  9. package/docs/CASE_STUDY.md +240 -0
  10. package/docs/ENVIRONMENT.md +81 -0
  11. package/docs/FFI_BUN_GUIDE.md +327 -0
  12. package/docs/GETTING_STARTED.md +192 -0
  13. package/docs/INGRESS-ROUTER.md +81 -0
  14. package/docs/INGRESS.md +746 -0
  15. package/docs/NATIVE-ROUTE.md +53 -0
  16. package/docs/REPO_MAP.md +279 -0
  17. package/docs/adr/0001-two-wire-formats.md +39 -0
  18. package/docs/adr/0002-zero-dom-fast-schema.md +38 -0
  19. package/docs/adr/0003-bun-builtins-delegation.md +43 -0
  20. package/docs/bun-builtins-decision-matrix.md +115 -0
  21. package/index.ts +44 -8
  22. package/package.json +98 -33
  23. package/src/ingress/body.ts +289 -0
  24. package/src/ingress/constants.ts +220 -48
  25. package/src/ingress/context.ts +136 -0
  26. package/src/ingress/decode/baked-result.ts +121 -0
  27. package/src/ingress/decode/fast-result.ts +160 -0
  28. package/src/ingress/decode/packed-sections.ts +56 -0
  29. package/src/ingress/decode/result-base.ts +172 -0
  30. package/src/ingress/errors.ts +72 -0
  31. package/src/ingress/fast.ts +152 -1021
  32. package/src/ingress/handlers.ts +639 -1001
  33. package/src/ingress/headers/baked-templates.ts +102 -0
  34. package/src/ingress/headers/cors.ts +44 -0
  35. package/src/ingress/headers/fast-templates.ts +238 -0
  36. package/src/ingress/headers/hsts.ts +83 -0
  37. package/src/ingress/health.ts +68 -0
  38. package/src/ingress/index.ts +142 -405
  39. package/src/ingress/metrics.ts +132 -0
  40. package/src/ingress/options.ts +197 -0
  41. package/src/ingress/packing/gather-raw-headers.ts +139 -0
  42. package/src/ingress/packing/header-packing.ts +75 -0
  43. package/src/ingress/packing/input-packer.ts +164 -0
  44. package/src/ingress/packing/scratch.ts +50 -0
  45. package/src/ingress/packing/select-headers.ts +102 -0
  46. package/src/ingress/response/error-bodies.ts +75 -0
  47. package/src/ingress/response/terminal.ts +59 -0
  48. package/src/ingress/router.ts +237 -0
  49. package/src/ingress/routes/common.ts +79 -0
  50. package/src/ingress/routes/delete.ts +14 -0
  51. package/src/ingress/routes/echo.ts +107 -0
  52. package/src/ingress/routes/fallback.ts +23 -0
  53. package/src/ingress/routes/head.ts +27 -0
  54. package/src/ingress/routes/index.ts +11 -0
  55. package/src/ingress/routes/json-write.ts +129 -0
  56. package/src/ingress/routes/options.ts +26 -0
  57. package/src/ingress/routes/read.ts +35 -0
  58. package/src/ingress/routes/responder.ts +159 -0
  59. package/src/ingress/server-node.ts +376 -0
  60. package/src/ingress/server.ts +475 -0
  61. package/src/ingress/shared.ts +132 -0
  62. package/src/ingress/status.ts +97 -0
  63. package/src/ingress/types.ts +191 -0
  64. package/src/integration/batch.ts +58 -0
  65. package/src/integration/index.ts +29 -0
  66. package/src/integration/pipeline.ts +220 -0
  67. package/src/integration/streaming.ts +78 -0
  68. package/src/integration/websocket.ts +93 -0
  69. package/src/loader/batch.ts +137 -0
  70. package/src/loader/cost.ts +105 -0
  71. package/src/loader/index.ts +745 -0
  72. package/src/loader/ops.ts +657 -0
  73. package/src/loader/state.ts +84 -0
  74. package/src/loader/types.ts +145 -0
  75. package/src/native/ffi/constants.ts +58 -0
  76. package/src/native/ffi/selftest.ts +729 -0
  77. package/src/native/ffi/types.ts +474 -0
  78. package/src/native/ffi.ts +1775 -0
  79. package/src/native/index.ts +8 -207
  80. package/src/native/loader.ts +240 -0
  81. package/src/native/types/instances.ts +274 -0
  82. package/src/native/types.ts +401 -0
  83. package/src/runtime/builtins.ts +131 -0
  84. package/src/runtime/codec.ts +147 -0
  85. package/src/runtime/detect.ts +36 -0
  86. package/src/runtime/index.ts +28 -0
  87. package/src/runtime/native.ts +35 -0
  88. package/src/runtime/server.ts +31 -0
  89. package/src/runtime/transport.ts +64 -0
  90. package/src/runtime/types.ts +173 -0
  91. package/src/runtime/uuid.ts +45 -0
  92. package/src/runtime/websocket.ts +30 -0
  93. package/src/rust-ffi/addon.ts +10 -0
  94. package/src/rust-ffi/batch/build.ts +155 -0
  95. package/src/rust-ffi/batch/index.ts +8 -0
  96. package/src/rust-ffi/batch/types.ts +92 -0
  97. package/src/rust-ffi/client.ts +87 -0
  98. package/src/rust-ffi/context.ts +193 -0
  99. package/src/rust-ffi/index.ts +51 -541
  100. package/src/rust-ffi/into.ts +21 -0
  101. package/src/rust-ffi/options.ts +113 -0
  102. package/src/rust-ffi/packed.ts +253 -0
  103. package/src/rust-ffi/proven.ts +28 -0
  104. package/src/rust-ffi/scalar/crypto.ts +346 -0
  105. package/src/rust-ffi/scalar/factories.ts +459 -0
  106. package/src/rust-ffi/scalar/hashing.ts +74 -0
  107. package/src/rust-ffi/scalar/http.ts +337 -0
  108. package/src/rust-ffi/scalar/index.ts +31 -0
  109. package/src/rust-ffi/scalar/interface.ts +325 -0
  110. package/src/rust-ffi/scalar/json-packed.ts +175 -0
  111. package/src/rust-ffi/scalar/json.ts +61 -0
  112. package/src/rust-ffi/scalar/payload.ts +241 -0
  113. package/src/rust-ffi/text.ts +121 -0
  114. package/src/selection.json +194 -0
  115. package/src/selection.ts +73 -0
  116. package/src/shared/adaptive.ts +67 -0
  117. package/src/shared/buffer-pool.ts +264 -0
  118. package/src/shared/bytes.ts +108 -9
  119. package/src/shared/codec.ts +78 -0
  120. package/src/shared/env.ts +20 -0
  121. package/src/shared/log.ts +75 -0
  122. package/src/shared/metrics.ts +247 -0
  123. package/src/shared/packed/index.ts +10 -0
  124. package/src/shared/packed/parsers.ts +31 -0
  125. package/src/shared/packed/schema.ts +23 -0
  126. package/src/shared/packed/wire.ts +504 -0
  127. package/src/shared/proven.ts +289 -0
  128. package/src/shared/request-id.ts +67 -0
  129. package/src/shared/response.ts +104 -0
  130. package/src/shared/runtime.ts +29 -0
  131. package/src/shared/trace.ts +81 -0
  132. package/src/shared/uuid.ts +15 -0
  133. package/Cargo.toml +0 -73
  134. package/bench.ts +0 -3
  135. package/rust/batch.rs +0 -195
  136. package/rust/cookie_parser.rs +0 -70
  137. package/rust/cors.rs +0 -263
  138. package/rust/hashing.rs +0 -164
  139. package/rust/headers.rs +0 -234
  140. package/rust/hmac_sha256.rs +0 -113
  141. package/rust/http_parser.rs +0 -132
  142. package/rust/ingress.rs +0 -616
  143. package/rust/ingress_constants.rs +0 -89
  144. package/rust/ip_trust.rs +0 -185
  145. package/rust/json_ops.rs +0 -66
  146. package/rust/json_patch_ops.rs +0 -25
  147. package/rust/json_schema.rs +0 -151
  148. package/rust/json_ser.rs +0 -429
  149. package/rust/lib.rs +0 -48
  150. package/rust/method.rs +0 -141
  151. package/rust/mime_lookup.rs +0 -76
  152. package/rust/output.rs +0 -238
  153. package/rust/proxy.rs +0 -90
  154. package/rust/query_parser.rs +0 -141
  155. package/rust/random_token.rs +0 -52
  156. package/rust/rate_limit.rs +0 -203
  157. package/rust/terminal.rs +0 -95
  158. package/rust/unit_tests.rs +0 -843
  159. package/rust/url_codec.rs +0 -133
  160. package/rust/util.rs +0 -537
  161. package/rust/validation.rs +0 -107
  162. package/rust/websocket.rs +0 -23
  163. package/src/baseline/index.ts +0 -12
  164. package/src/baseline/tasks/cookie.ts +0 -22
  165. package/src/baseline/tasks/hashing.ts +0 -18
  166. package/src/baseline/tasks/hmac.ts +0 -38
  167. package/src/baseline/tasks/http.ts +0 -62
  168. package/src/baseline/tasks/json-patch.ts +0 -12
  169. package/src/baseline/tasks/json.ts +0 -50
  170. package/src/baseline/tasks/mime.ts +0 -5
  171. package/src/baseline/tasks/query.ts +0 -14
  172. package/src/baseline/tasks/token.ts +0 -15
  173. package/src/baseline/tasks/url.ts +0 -11
  174. package/src/baseline/tasks/validation.ts +0 -19
  175. package/src/baseline/tasks/websocket.ts +0 -14
  176. package/src/bench/assert.ts +0 -70
  177. package/src/bench/checks.ts +0 -227
  178. package/src/bench/checksum.ts +0 -27
  179. package/src/bench/comparisons.ts +0 -70
  180. package/src/bench/concurrent-worker.ts +0 -135
  181. package/src/bench/fixtures.ts +0 -233
  182. package/src/bench/index.ts +0 -2
  183. package/src/bench/measure.ts +0 -177
  184. package/src/bench/now.ts +0 -3
  185. package/src/bench/report.ts +0 -79
  186. package/src/bench/run.ts +0 -57
  187. package/src/bench/tasks/complex.ts +0 -232
  188. package/src/bench/tasks/concurrent.ts +0 -213
  189. package/src/bench/tasks/cookie.ts +0 -44
  190. package/src/bench/tasks/hashing.ts +0 -33
  191. package/src/bench/tasks/hmac.ts +0 -36
  192. package/src/bench/tasks/http.ts +0 -50
  193. package/src/bench/tasks/index.ts +0 -52
  194. package/src/bench/tasks/json-patch.ts +0 -21
  195. package/src/bench/tasks/json.ts +0 -33
  196. package/src/bench/tasks/mime.ts +0 -21
  197. package/src/bench/tasks/query.ts +0 -44
  198. package/src/bench/tasks/stress.ts +0 -142
  199. package/src/bench/tasks/token.ts +0 -20
  200. package/src/bench/tasks/url.ts +0 -34
  201. package/src/bench/tasks/validation.ts +0 -57
  202. package/src/bench/tasks/websocket.ts +0 -21
  203. package/src/bench/types.ts +0 -60
  204. package/src/data/json-rows.ts +0 -33
  205. package/src/ingress/packed-input.ts +0 -59
  206. package/src/shared/json.ts +0 -18
  207. package/src/shared/packed.ts +0 -316
@@ -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 TextEncoder instance for encoding strings to UTF-8 bytes.
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
- * Reusing a single encoder avoids allocation overhead in hot paths.
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 encoder = new TextEncoder();
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
- * Shared TextDecoder instance for decoding UTF-8 bytes to strings.
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
- * Reusing a single decoder avoids allocation overhead in hot paths.
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 const decoder = new TextDecoder();
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
+ }