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
@@ -0,0 +1,131 @@
1
+ // src/runtime/builtins.ts — Bun built-in delegation registry (the BUN_WINS set).
2
+ //
3
+ // Single source of truth for "under Bun this op is faster as a built-in than
4
+ // via the rust addon" (measured in docs/bun-builtins-decision-matrix.md). The
5
+ // scalar/text builders prefer `builtins.has(op)` over calling the native
6
+ // transport; `src/selection.ts` derives its `opImpl` decision from the same
7
+ // registry, so the hardcoded `BUN_WINS` set + `isBun()` branch disappear.
8
+ //
9
+ // NOTE: `gzipDecompress` is deliberately NOT delegated — the rust surface
10
+ // keeps the native path (with its 64 MiB decompression-bomb cap) even under
11
+ // Bun, because `Bun.gunzipSync` has no output-size bound.
12
+ //
13
+ // On Node the registry is EMPTY (`has()` returns false; methods throw if
14
+ // called) so every op falls through to the native transport.
15
+
16
+ import { runtimeCodec } from './codec'
17
+ import { detectedRuntime } from './detect'
18
+ import type { BuiltinsAdapter, RuntimeName } from './types'
19
+
20
+ /** The delegated op names (matches the historical `BUN_WINS` set). */
21
+ export const BUILTIN_OPS = [
22
+ 'gzipCompress',
23
+ 'crc32',
24
+ 'randomToken',
25
+ 'hmacSha256',
26
+ 'xxh3',
27
+ 'urlEncode',
28
+ 'urlDecode',
29
+ 'base64Encode',
30
+ 'base64UrlEncode',
31
+ 'hexEncode',
32
+ 'httpDate',
33
+ ] as const
34
+
35
+ const MAX_TOKEN_BYTES = 16 * 1024 * 1024 // 16 MiB guard (matches the native path)
36
+
37
+ function createBunBuiltins(): BuiltinsAdapter {
38
+ const ops = new Set<string>(BUILTIN_OPS)
39
+ return {
40
+ ops: BUILTIN_OPS,
41
+ has(op: string): boolean {
42
+ return ops.has(op)
43
+ },
44
+ crc32(input: Uint8Array): number {
45
+ return Bun.hash.crc32(input) >>> 0 // preserves the unsigned-32 contract
46
+ },
47
+ xxh3(input: Uint8Array): bigint {
48
+ return Bun.hash.xxHash3(input)
49
+ },
50
+ hmacSha256(key: Uint8Array, data: Uint8Array): Uint8Array | string {
51
+ const hasher = new Bun.CryptoHasher('sha256', key)
52
+ hasher.update(data)
53
+ return Buffer.from(hasher.digest()).toString('hex')
54
+ },
55
+ gzipCompress(data: Uint8Array, level?: number | null): Uint8Array {
56
+ const opts = level != null ? ({ level } as Parameters<typeof Bun.gzipSync>[1]) : undefined
57
+ return Bun.gzipSync(data as unknown as Uint8Array<ArrayBuffer>, opts)
58
+ },
59
+ randomToken(byteLen: number): Uint8Array | string {
60
+ if (byteLen > MAX_TOKEN_BYTES) {
61
+ throw new RangeError('randomToken: byteLen exceeds 16 MiB limit')
62
+ }
63
+ return Buffer.from(crypto.getRandomValues(new Uint8Array(byteLen))).toString('hex')
64
+ },
65
+ base64Encode(
66
+ input: Uint8Array,
67
+ urlSafe?: boolean,
68
+ padding?: boolean,
69
+ ): Uint8Array | string | undefined {
70
+ // Only the standard padded, non-url-safe case delegates (SIMD Buffer);
71
+ // url-safe/unpadded falls through to the native transport.
72
+ if (urlSafe || padding === false) return undefined
73
+ return Buffer.from(input).toString('base64')
74
+ },
75
+ base64UrlEncode(input: Uint8Array): Uint8Array | string {
76
+ return Buffer.from(input).toString('base64url')
77
+ },
78
+ hexEncode(input: Uint8Array): Uint8Array | string {
79
+ return Buffer.from(input).toString('hex')
80
+ },
81
+ urlEncode(input: Uint8Array): string {
82
+ return encodeURIComponent(runtimeCodec.decodeUtf8(input))
83
+ },
84
+ urlDecode(input: Uint8Array): Uint8Array {
85
+ return runtimeCodec.encodeUtf8(decodeURIComponent(runtimeCodec.decodeUtf8(input)))
86
+ },
87
+ urlEncodeStr(input: string): string {
88
+ return encodeURIComponent(input)
89
+ },
90
+ urlDecodeStr(input: string): string {
91
+ return decodeURIComponent(input)
92
+ },
93
+ httpDate(secs?: number): string {
94
+ const t = secs ?? Math.floor(Date.now() / 1000)
95
+ return new Date(t * 1000).toUTCString()
96
+ },
97
+ }
98
+ }
99
+
100
+ function createNodeBuiltins(): BuiltinsAdapter {
101
+ const unavailable = (op: string): never => {
102
+ throw new Error(`builtin "${op}" is a Bun-only delegation — not available on Node`)
103
+ }
104
+ return {
105
+ ops: [],
106
+ has(): boolean {
107
+ return false
108
+ },
109
+ crc32: () => unavailable('crc32'),
110
+ xxh3: () => unavailable('xxh3'),
111
+ hmacSha256: () => unavailable('hmacSha256'),
112
+ gzipCompress: () => unavailable('gzipCompress'),
113
+ randomToken: () => unavailable('randomToken'),
114
+ base64Encode: () => unavailable('base64Encode'),
115
+ base64UrlEncode: () => unavailable('base64UrlEncode'),
116
+ hexEncode: () => unavailable('hexEncode'),
117
+ urlEncode: () => unavailable('urlEncode'),
118
+ urlDecode: () => unavailable('urlDecode'),
119
+ urlEncodeStr: () => unavailable('urlEncodeStr'),
120
+ urlDecodeStr: () => unavailable('urlDecodeStr'),
121
+ httpDate: () => unavailable('httpDate'),
122
+ }
123
+ }
124
+
125
+ /** Build the builtins registry for a runtime. */
126
+ export function createBuiltins(name: RuntimeName): BuiltinsAdapter {
127
+ return name === 'bun' ? createBunBuiltins() : createNodeBuiltins()
128
+ }
129
+
130
+ /** The shared builtins registry for the detected runtime (selected once at load). */
131
+ export const runtimeBuiltins: BuiltinsAdapter = createBuiltins(detectedRuntime)
@@ -0,0 +1,147 @@
1
+ // src/runtime/codec.ts — runtime-native UTF-8 codec (Bun transfer, Node fallback).
2
+ //
3
+ // The UTF-8 codec branches (formerly in `src/shared/codec.ts`) are centralized
4
+ // here and selected ONCE at module load:
5
+ // - encode: Bun `Bun.ArrayBufferSink` vs `TextEncoder`.
6
+ // - encodeInto: zero-copy `Buffer` view `.write` vs `TextEncoder.encodeInto`.
7
+ // - decode: `new CString(ptr, 0, len)` from `bun:ffi` vs `TextDecoder`.
8
+ // - decodeFatal: replacement-mode (Bun) vs fatal `TextDecoder` (Node).
9
+ //
10
+ // `src/shared/codec.ts` re-exports these so existing callers are unchanged.
11
+ // This module must NOT import `src/native/ffi.ts` (it would create a cycle
12
+ // through the bind-time self-test) — the Bun decode lazily `require`s
13
+ // `bun:ffi` itself, and depends only on the detection leaf.
14
+
15
+ import { detectedRuntime } from './detect'
16
+ import type { RuntimeName, Utf8Codec } from './types'
17
+
18
+ // ── Lazy Node singletons ──────────────────────────────────────────
19
+ let nodeEncoder: TextEncoder | null = null
20
+ let nodeDecoder: TextDecoder | null = null
21
+ let nodeFatalDecoder: TextDecoder | null = null
22
+
23
+ function getNodeEncoder(): TextEncoder {
24
+ if (nodeEncoder === null) nodeEncoder = new TextEncoder()
25
+ return nodeEncoder
26
+ }
27
+
28
+ function getNodeDecoder(): TextDecoder {
29
+ if (nodeDecoder === null) nodeDecoder = new TextDecoder()
30
+ return nodeDecoder
31
+ }
32
+
33
+ function getNodeFatalDecoder(): TextDecoder {
34
+ if (nodeFatalDecoder === null) nodeFatalDecoder = new TextDecoder('utf-8', { fatal: true })
35
+ return nodeFatalDecoder
36
+ }
37
+
38
+ // ── Bun codec ─────────────────────────────────────────────────────
39
+ let bunSink: Bun.ArrayBufferSink | null = null
40
+ let bunFfiCodec: typeof import('bun:ffi') | null | undefined
41
+
42
+ function getBunFfiCodec(): typeof import('bun:ffi') | null {
43
+ if (bunFfiCodec === undefined) {
44
+ try {
45
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
46
+ bunFfiCodec = require('bun:ffi') as typeof import('bun:ffi')
47
+ } catch {
48
+ bunFfiCodec = null
49
+ }
50
+ }
51
+ return bunFfiCodec
52
+ }
53
+
54
+ function createBunCodec(): Utf8Codec {
55
+ const decodeUtf8Bun = (bytes: Uint8Array): string => {
56
+ if (bytes.byteLength === 0) return ''
57
+ const ffi = getBunFfiCodec()
58
+ if (ffi) {
59
+ return new ffi.CString(ffi.ptr(bytes), 0, bytes.byteLength).toString()
60
+ }
61
+ return getNodeDecoder().decode(bytes)
62
+ }
63
+ return {
64
+ encodeUtf8(s: string): Uint8Array {
65
+ if (bunSink === null) bunSink = new Bun.ArrayBufferSink()
66
+ const sink = bunSink
67
+ // `stream` resets the buffer on flush (reusable singleton); `flush()`
68
+ // returns the written data as a `Uint8Array`.
69
+ sink.start({ asUint8Array: true, stream: true })
70
+ sink.write(s)
71
+ return sink.flush() as Uint8Array
72
+ },
73
+ encodeUtf8Into(s: string, dest: Uint8Array, offset = 0): number {
74
+ return bunEncodeUtf8Into(s, dest, offset)
75
+ },
76
+ decodeUtf8: decodeUtf8Bun,
77
+ // Bun's CString is replacement-mode; strictness is enforced by the native
78
+ // `castrum_utf8_valid` probe (callers check it first via the transport's
79
+ // ffi surface). This fallback matches the historical behavior.
80
+ decodeUtf8Fatal: decodeUtf8Bun,
81
+ }
82
+ }
83
+
84
+ // Cached zero-copy `Buffer` views keyed by the backing `ArrayBuffer`.
85
+ //
86
+ // `Buffer.write` requires a `Buffer` view; the historical implementation
87
+ // created one with `Buffer.from(backing, start, len)` on EVERY call — an
88
+ // object allocation on the hot path (the ingress packers encode the URL/IP
89
+ // twice per request through this function). Caching the full-backing view per
90
+ // `ArrayBuffer` (they are immutable-size in this codebase — the packers
91
+ // replace the whole buffer on growth, giving a fresh `ArrayBuffer` key) turns
92
+ // the per-call `Buffer.from` into a WeakMap hit.
93
+ const bunEncodeViewCache = new WeakMap<ArrayBufferLike, Buffer>()
94
+
95
+ /**
96
+ * Write `s` (UTF-8) into `dest` starting at `offset` and return bytes written.
97
+ *
98
+ * Fast path (the hot callers — the ingress input/header packers — pass `dest`
99
+ * as a subarray that extends to the END of its backing buffer): reuse the
100
+ * cached full-backing `Buffer` view and write at the absolute position — the
101
+ * write bound is then identical to the historical `Buffer.from(backing, start,
102
+ * len)` behaviour, so no bytes outside `dest` can be touched.
103
+ *
104
+ * When `dest` does NOT extend to the end of its backing (a mid-buffer slice),
105
+ * fall back to a bounded `subarray` so the truncation semantics match the
106
+ * original `Buffer.from(backing, start, len)` exactly.
107
+ */
108
+ function bunEncodeUtf8Into(s: string, dest: Uint8Array, offset = 0): number {
109
+ const backing = dest.buffer
110
+ let view = bunEncodeViewCache.get(backing)
111
+ if (view === undefined || view.byteLength !== backing.byteLength) {
112
+ view = Buffer.from(backing)
113
+ bunEncodeViewCache.set(backing, view)
114
+ }
115
+ const start = dest.byteOffset + offset
116
+ if (dest.byteOffset + dest.byteLength !== backing.byteLength) {
117
+ // Non-suffix dest: bound the write to `dest` exactly (historical semantics).
118
+ return view.subarray(start, start + dest.byteLength - offset).write(s, 0, 'utf8')
119
+ }
120
+ return view.write(s, start, 'utf8')
121
+ }
122
+
123
+ // ── Node codec ────────────────────────────────────────────────────
124
+ function createNodeCodec(): Utf8Codec {
125
+ return {
126
+ encodeUtf8(s: string): Uint8Array {
127
+ return getNodeEncoder().encode(s)
128
+ },
129
+ encodeUtf8Into(s: string, dest: Uint8Array, offset = 0): number {
130
+ return getNodeEncoder().encodeInto(s, dest.subarray(offset)).written
131
+ },
132
+ decodeUtf8(bytes: Uint8Array): string {
133
+ return getNodeDecoder().decode(bytes)
134
+ },
135
+ decodeUtf8Fatal(bytes: Uint8Array): string {
136
+ return getNodeFatalDecoder().decode(bytes)
137
+ },
138
+ }
139
+ }
140
+
141
+ /** Build the codec for a runtime (used by `createRuntimeAdapter` / tests). */
142
+ export function createCodec(name: RuntimeName): Utf8Codec {
143
+ return name === 'bun' ? createBunCodec() : createNodeCodec()
144
+ }
145
+
146
+ /** The shared codec for the detected runtime (selected once at load). */
147
+ export const runtimeCodec: Utf8Codec = createCodec(detectedRuntime)
@@ -0,0 +1,36 @@
1
+ // src/runtime/detect.ts — one-time runtime detection (cached).
2
+ //
3
+ // The ONLY place `typeof Bun` is checked. The result is resolved ONCE at
4
+ // module load into a constant — hot paths never re-evaluate the detection,
5
+ // and `src/shared/runtime.ts` is a thin facade over this module so existing
6
+ // `isBun()` / `isNode()` call sites keep working unchanged.
7
+
8
+ import type { RuntimeName } from './types'
9
+
10
+ function detectRuntimeName(): RuntimeName {
11
+ // Bun defines the global `Bun` object; Node does not.
12
+ if (typeof (globalThis as { Bun?: unknown }).Bun !== 'undefined') return 'bun'
13
+ if (typeof process !== 'undefined' && process.versions?.node != null) return 'node'
14
+ return 'unknown'
15
+ }
16
+
17
+ /** The host runtime, resolved once at module load. */
18
+ export const detectedRuntime: RuntimeName = detectRuntimeName()
19
+
20
+ /** Whether the host runtime is Bun (equivalent to `detectedRuntime === "bun"`). */
21
+ export function isBunRuntime(): boolean {
22
+ return detectedRuntime === 'bun'
23
+ }
24
+
25
+ /** Whether the host runtime is Node.js. */
26
+ export function isNodeRuntime(): boolean {
27
+ return detectedRuntime === 'node'
28
+ }
29
+
30
+ /** The Node.js major version when running under Node, otherwise `null`. */
31
+ export function nodeMajorVersion(): number | null {
32
+ if (detectedRuntime !== 'node') return null
33
+ const v = process.versions.node
34
+ const major = Number(v?.split('.')[0])
35
+ return Number.isFinite(major) ? major : null
36
+ }
@@ -0,0 +1,28 @@
1
+ // src/runtime/index.ts — runtime adapter barrel (FULL adapter).
2
+ //
3
+ // Composes the FFI-facing runtime seam (`src/runtime/native.ts`) with the
4
+ // platform server / websocket adapters (`src/runtime/server.ts` +
5
+ // `websocket.ts`). The FFI layer imports the seam from `./native` (it never
6
+ // pulls the ingress graph); the public API imports the full `runtime` from
7
+ // here.
8
+
9
+ import { runtimeNative } from './native'
10
+ import { runtimeServer } from './server'
11
+ import type { RuntimeAdapter } from './types'
12
+ import { runtimeWebsocket } from './websocket'
13
+
14
+ /** The full runtime adapter for the detected host runtime (native + server + websocket). */
15
+ export const runtime: RuntimeAdapter = {
16
+ ...runtimeNative,
17
+ server: runtimeServer,
18
+ websocket: runtimeWebsocket,
19
+ }
20
+
21
+ // Runtime detection (cached) — the single place `typeof Bun` is checked.
22
+ export { detectedRuntime, isBunRuntime, isNodeRuntime, nodeMajorVersion } from './detect'
23
+ export { runtimeNative } from './native'
24
+ // Runtime-driven server factory (Bun.serve on Bun, node:http on Node) — the
25
+ // public `createIngressServer` (src/ingress/index.ts) re-exports this.
26
+ export { createIngressServer } from './server'
27
+
28
+ export type { RuntimeName } from './types'
@@ -0,0 +1,35 @@
1
+ // src/runtime/native.ts — the FFI-facing runtime seam.
2
+ //
3
+ // What the `rust-ffi` layer / selection / shared helpers consume: runtime
4
+ // detection, UTF-8 codec, uuid, env resolution, the Bun built-in delegation
5
+ // registry, and the native transport (bun:ffi vs napi). Importing this module
6
+ // does NOT eagerly bind the ffi transport (lazy) and never pulls the ingress
7
+ // graph, so `rust-ffi/*` can import it without a cycle.
8
+ //
9
+ // The FULL adapter (adding `server` + `websocket`) is composed in
10
+ // `src/runtime/index.ts` / `src/runtime/server.ts` for the public API.
11
+
12
+ import { resolveEnvVar } from '../shared/env'
13
+ import { runtimeBuiltins } from './builtins'
14
+ import { runtimeCodec } from './codec'
15
+ import { detectedRuntime } from './detect'
16
+ import { createTransport } from './transport'
17
+ import type { NativeRuntimeAdapter } from './types'
18
+ import { runtimeUuid } from './uuid'
19
+
20
+ /**
21
+ * The shared runtime adapter (native seam) for the detected host runtime —
22
+ * codec / uuid / env / builtins / transport. The FULL adapter (adding
23
+ * `server` + `websocket`) is composed in `src/runtime/index.ts` for the
24
+ * public API; the FFI layer imports THIS seam so it never pulls the ingress
25
+ * graph.
26
+ */
27
+ export const runtimeNative: NativeRuntimeAdapter = {
28
+ name: detectedRuntime,
29
+ isWebStandard: detectedRuntime === 'bun',
30
+ codec: runtimeCodec,
31
+ uuid: runtimeUuid,
32
+ env: { resolveVar: resolveEnvVar },
33
+ builtins: runtimeBuiltins,
34
+ transport: createTransport(),
35
+ }
@@ -0,0 +1,31 @@
1
+ // src/runtime/server.ts — platform HTTP server adapter (Bun.serve / node:http).
2
+ //
3
+ // The SAME pre-baked ingress route handlers (`buildRouteHandlers`) are served
4
+ // by `Bun.serve` on Bun (src/ingress/server.ts) and the `node:http` adapter on
5
+ // Node (src/ingress/server-node.ts). Historically consumers had to pick
6
+ // `createIngressServer` (Bun-only) vs `createIngressServerNode` explicitly;
7
+ // this adapter lets the public `createIngressServer` pick the backend once,
8
+ // at module load. Selected ONCE — the branch never runs in the hot path.
9
+
10
+ import type { BakedServer, CreateIngressServerOptions } from '../ingress/server'
11
+ import { createIngressServer as createIngressServerBun } from '../ingress/server'
12
+ import { createIngressServerNode } from '../ingress/server-node'
13
+ import { detectedRuntime } from './detect'
14
+ import type { ServerAdapter } from './types'
15
+
16
+ /** The shared server adapter for the detected host runtime. */
17
+ export const runtimeServer: ServerAdapter = {
18
+ runtime: detectedRuntime === 'bun' ? 'bun' : 'node',
19
+ createIngressServer,
20
+ }
21
+
22
+ /**
23
+ * Create the runtime-appropriate ingress server: `Bun.serve` on Bun, the
24
+ * `node:http` adapter on Node. Selected ONCE at module load — the branch
25
+ * never runs in the hot path.
26
+ */
27
+ export function createIngressServer(options: CreateIngressServerOptions): BakedServer {
28
+ return detectedRuntime === 'bun'
29
+ ? createIngressServerBun(options)
30
+ : createIngressServerNode(options)
31
+ }
@@ -0,0 +1,64 @@
1
+ // src/runtime/transport.ts — native transport adapter (bun:ffi primary, napi fallback).
2
+ //
3
+ // The SAME cdylib is reached two ways: the bun:ffi C-ABI transport
4
+ // (`getBunFFI()`, PRIMARY under Bun — ~10-20ns crossing) and the napi addon
5
+ // (`addon`, Node / forced `CASTRUM_FFI_MODE=napi` / failed ffi self-test).
6
+ // Historically every scalar builder repeated `const ffi = getBunFFI();
7
+ // if (ffi) return ffi.X(...); return addon.X(...)`. This adapter centralizes
8
+ // that selection: `resolve(op)` returns the bound op (ffi-first, cached,
9
+ // napi fallback), and the raw `ffi` / `napi` surfaces are exposed for the few
10
+ // ops with transport-specific JS handling (jsonParse packed path, urlDecode
11
+ // UTF-8 probe, packed unboxers).
12
+ //
13
+ // LAZY: the one-time ffi bind is deferred until the first `resolve` /
14
+ // introspection, so importing the adapter does not eagerly dlopen.
15
+
16
+ import type { BunFFI } from '../native/ffi'
17
+ import { getBunFFI } from '../native/ffi'
18
+ import { addon } from '../rust-ffi/addon'
19
+ import type { NativeCallable, TransportAdapter } from './types'
20
+
21
+ /**
22
+ * Build a native transport adapter: resolves native ops ffi-first (bun:ffi,
23
+ * cached) with the napi addon as the fallback. The one-time ffi bind is
24
+ * deferred until the first resolve / introspection — importing the adapter
25
+ * does not eagerly dlopen.
26
+ */
27
+ export function createTransport(): TransportAdapter {
28
+ let cachedFfi: BunFFI | null | undefined // undefined = not yet attempted
29
+ const getFfi = (): BunFFI | null => {
30
+ if (cachedFfi === undefined) cachedFfi = getBunFFI()
31
+ return cachedFfi
32
+ }
33
+ const cache = new Map<string, NativeCallable>()
34
+
35
+ const resolve = (op: string): NativeCallable | undefined => {
36
+ let f = cache.get(op)
37
+ if (f === undefined) {
38
+ const ffiSurface = getFfi() as unknown as Record<string, unknown> | null
39
+ const candidate =
40
+ ffiSurface && typeof ffiSurface[op] === 'function'
41
+ ? (ffiSurface[op] as NativeCallable)
42
+ : (addon as unknown as Record<string, unknown>)[op]
43
+ if (typeof candidate !== 'function') return undefined
44
+ f = candidate as NativeCallable
45
+ cache.set(op, f)
46
+ }
47
+ return f
48
+ }
49
+
50
+ return {
51
+ get name(): 'ffi' | 'napi' {
52
+ return getFfi() !== null ? 'ffi' : 'napi'
53
+ },
54
+ get returnType(): 'string' | 'bytes' {
55
+ return getFfi() !== null ? 'string' : 'bytes'
56
+ },
57
+ get ffi(): BunFFI | null {
58
+ return getFfi()
59
+ },
60
+ napi: addon,
61
+ ffiActive: () => getFfi() !== null,
62
+ resolve,
63
+ }
64
+ }
@@ -0,0 +1,173 @@
1
+ // src/runtime/types.ts — Runtime adapter contracts (Bun / Node).
2
+ //
3
+ // The single seam that centralizes every Bun-vs-Node and native-transport
4
+ // decision: detection (detect.ts), UTF-8 codec, uuid, env, Bun built-in
5
+ // delegation (builtins.ts), and the bun:ffi-vs-napi transport (transport.ts).
6
+ // Modeled on Elysia's adapter (`ElysiaAdapterOptions` / `createAdapter`) but
7
+ // internal-only, typed with concrete sub-objects (no string-union escape
8
+ // hatch), and selected ONCE at module load — hot paths never re-check
9
+ // `typeof Bun`.
10
+ //
11
+ // `src/runtime/native.ts` exposes the FFI-facing seam ({ name, codec, uuid,
12
+ // env, builtins, transport }); `src/runtime/index.ts` composes the FULL
13
+ // adapter (adding server + websocket in src/runtime/server.ts). Pure modules
14
+ // must not import this boundary — take the functions they need via DI.
15
+
16
+ import type { BakedServer, CreateIngressServerOptions } from '../ingress/server'
17
+ import type { WebSocketUpgradeOptions, WebSocketUpgradeResult } from '../integration/websocket'
18
+ import type { NativeAddon } from '../native'
19
+ import type { BunFFI } from '../native/ffi'
20
+
21
+ /** Host runtime identifier. */
22
+ export type RuntimeName = 'bun' | 'node' | 'unknown'
23
+
24
+ /** Runtime-native UTF-8 codec (Bun transfer machinery vs TextEncoder/TextDecoder). */
25
+ export interface Utf8Codec {
26
+ /** Encode a string to UTF-8 bytes (fresh buffer). */
27
+ encodeUtf8(s: string): Uint8Array
28
+ /** Encode into `dest` starting at `offset`; returns bytes written (no split chars). */
29
+ encodeUtf8Into(s: string, dest: Uint8Array, offset?: number): number
30
+ /** Decode UTF-8 bytes to a string (replacement mode on invalid). */
31
+ decodeUtf8(bytes: Uint8Array): string
32
+ /** Decode UTF-8 bytes to a string, throwing on invalid UTF-8. */
33
+ decodeUtf8Fatal(bytes: Uint8Array): string
34
+ }
35
+
36
+ /** UUIDv7 generation (Bun built-in vs crypto.randomUUID on Node). */
37
+ export interface UuidAdapter {
38
+ uuidv7(): string
39
+ }
40
+
41
+ /** Env-var resolution (CASTRUM_* preferred, napi-rs `NAPI_RS_*` aliases). */
42
+ export interface EnvAdapter {
43
+ resolveVar(preferred: string, legacy: readonly string[]): string | undefined
44
+ }
45
+
46
+ /** A bound native callable (bun:ffi C-ABI or napi addon). */
47
+ export type NativeCallable = (...args: unknown[]) => unknown
48
+
49
+ /**
50
+ * Native transport: the SAME cdylib accessed via bun:ffi (C-ABI, PRIMARY on
51
+ * Bun) or napi (Node / fallback). `resolve(op)` returns the bound op
52
+ * (ffi-first, cached, napi fallback) so hot call sites have no per-call
53
+ * `getBunFFI()` branch.
54
+ */
55
+ export interface TransportAdapter {
56
+ /** Active transport: `"ffi"` (bun:ffi live) or `"napi"`. */
57
+ readonly name: 'ffi' | 'napi'
58
+ /** Native text-return contract: `"string"` on the ffi path, `"bytes"` on napi. */
59
+ readonly returnType: 'string' | 'bytes'
60
+ /** The raw bun:ffi surface when live, else `null` (for ops with ffi-specific JS handling). */
61
+ readonly ffi: BunFFI | null
62
+ /** The napi addon proxy (lazy — first native call triggers the dlopen). */
63
+ readonly napi: NativeAddon
64
+ /** Whether the bun:ffi transport is live (equivalent to `name === "ffi"`). */
65
+ ffiActive(): boolean
66
+ /** Bound op callable (ffi-first, napi fallback), or `undefined` for unknown ops. */
67
+ resolve(op: string): NativeCallable | undefined
68
+ }
69
+
70
+ /**
71
+ * Bun built-in delegation registry (the `BUN_WINS` set). Single source of
72
+ * truth for "under Bun this op is faster as a built-in than via the addon".
73
+ * On Node the registry is empty (`has()` is false) so builders fall through
74
+ * to the native transport.
75
+ *
76
+ * `ops` is the exported op-name list; `selection.ts` derives its BUN_WINS
77
+ * decision from `has(op)` so the hardcoded set + `isBun()` branch disappear.
78
+ */
79
+ export interface BuiltinsAdapter {
80
+ /** Op names this runtime delegates to built-ins (empty on Node). */
81
+ readonly ops: readonly string[]
82
+ /** Whether `op` has a built-in delegation on this runtime. */
83
+ has(op: string): boolean
84
+
85
+ // ── The delegated ops (bytes-in scalar surface, Bun contracts) ──
86
+ crc32(input: Uint8Array): number
87
+ xxh3(input: Uint8Array): bigint
88
+ /** HMAC-SHA256 → lowercase-hex STRING on Bun. */
89
+ hmacSha256(key: Uint8Array, data: Uint8Array): Uint8Array | string
90
+ gzipCompress(data: Uint8Array, level?: number | null): Uint8Array
91
+ /** Random hex token (2n chars) with the 16 MiB guard. */
92
+ randomToken(byteLen: number): Uint8Array | string
93
+ /** Standard padded base64 only; `undefined` when url-safe/unpadded (falls to native). */
94
+ base64Encode(
95
+ input: Uint8Array,
96
+ urlSafe?: boolean,
97
+ padding?: boolean,
98
+ ): Uint8Array | string | undefined
99
+ base64UrlEncode(input: Uint8Array): Uint8Array | string
100
+ hexEncode(input: Uint8Array): Uint8Array | string
101
+ /** Percent-encode bytes → percent-encoded STRING. */
102
+ urlEncode(input: Uint8Array): string
103
+ /** Percent-decode bytes → bytes. */
104
+ urlDecode(input: Uint8Array): Uint8Array
105
+ /** String-in percent-encode (`encodeURIComponent`). */
106
+ urlEncodeStr(input: string): string
107
+ /** String-in percent-decode (`decodeURIComponent`). */
108
+ urlDecodeStr(input: string): string
109
+ /** RFC 1123 IMF-fixdate STRING for `secs` (default now). */
110
+ httpDate(secs?: number): string
111
+ }
112
+
113
+ /**
114
+ * Platform HTTP server adapter. `createIngressServer` builds a `Bun.serve`
115
+ * server on Bun and the `node:http` adapter on Node — the SAME pre-baked
116
+ * route handlers (shared `buildRouteHandlers`) either way. The explicit
117
+ * `createIngressServerNode` stays exported for consumers who want the Node
118
+ * backend pinned.
119
+ */
120
+ export interface ServerAdapter {
121
+ /** The backend this adapter targets. */
122
+ readonly runtime: 'bun' | 'node'
123
+ createIngressServer(options: CreateIngressServerOptions): BakedServer
124
+ }
125
+
126
+ /**
127
+ * Platform WebSocket adapter. `createWebSocketUpgrade` returns the RFC 6455
128
+ * 101 handshake on Bun (whose `Response` can carry status 101) and throws a
129
+ * clear Bun-only error on Node (which must use the server-node `upgrade`
130
+ * option instead).
131
+ */
132
+ export interface WebsocketAdapter {
133
+ createWebSocketUpgrade(
134
+ req: Request,
135
+ opts?: WebSocketUpgradeOptions,
136
+ ): WebSocketUpgradeResult | null
137
+ }
138
+
139
+ /**
140
+ * The runtime adapter — the single seam centralizing every Bun-vs-Node and
141
+ * native-transport decision. Selected ONCE at module load (`src/runtime`).
142
+ * `src/runtime/native.ts` composes the FFI-facing subset
143
+ * ({@link NativeRuntimeAdapter}); the FULL adapter additionally carries
144
+ * `server` + `websocket` (composed in `src/runtime/index.ts` for the public
145
+ * API).
146
+ */
147
+ export interface RuntimeAdapter {
148
+ /** Host runtime (cached at load — never re-checked in hot paths). */
149
+ readonly name: RuntimeName
150
+ /** Whether the runtime speaks the web-standard Request/Response surface. */
151
+ readonly isWebStandard: boolean
152
+ /** Runtime-native UTF-8 codec. */
153
+ readonly codec: Utf8Codec
154
+ /** UUIDv7 generation. */
155
+ readonly uuid: UuidAdapter
156
+ /** Env-var alias resolution. */
157
+ readonly env: EnvAdapter
158
+ /** Bun built-in delegation registry (empty on Node). */
159
+ readonly builtins: BuiltinsAdapter
160
+ /** Native transport (bun:ffi primary, napi fallback). */
161
+ readonly transport: TransportAdapter
162
+ /** Platform HTTP server (Bun.serve on Bun, node:http on Node). */
163
+ readonly server: ServerAdapter
164
+ /** Platform WebSocket 101 handshake (Bun-only). */
165
+ readonly websocket: WebsocketAdapter
166
+ }
167
+
168
+ /**
169
+ * The FFI-facing runtime subset (no server/websocket) — what the `rust-ffi`
170
+ * layer / selection / shared helpers import, so the native seam never pulls
171
+ * the ingress graph.
172
+ */
173
+ export type NativeRuntimeAdapter = Omit<RuntimeAdapter, 'server' | 'websocket'>