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
@@ -1,207 +1,8 @@
1
- import { existsSync } from "node:fs";
2
- import { fileURLToPath } from "node:url";
3
- import { join, dirname } from "node:path";
4
- import { createRequire } from "node:module";
5
-
6
- const require = createRequire(import.meta.url);
7
-
8
- export interface IngressInstance {
9
- handleRequestPacked(
10
- input: Uint8Array,
11
- body: Uint8Array | null,
12
- output: Uint8Array,
13
- ): number;
14
-
15
- handleRequestFullSync(
16
- methodKind: number,
17
- url: string,
18
- ip: string,
19
- requestId: string,
20
- headers: Array<[string, string]>,
21
- body: Uint8Array | null,
22
- outputBufferSize?: number,
23
- ): Uint8Array;
24
- }
25
-
26
- function resolveAddonPath(): string {
27
- const __dirname = dirname(fileURLToPath(import.meta.url));
28
- const platform = process.platform;
29
- const arch = process.arch;
30
- const base = join(__dirname, "..", "..");
31
-
32
- // napi-rs artifact naming: castrum.<platform>-<arch>[-<libc>].node
33
- // e.g. linux-x64-gnu, linux-x64-musl, win32-x64-msvc, darwin-arm64.
34
- const libcVariants =
35
- platform === "win32"
36
- ? ["msvc", "gnu"]
37
- : platform === "linux"
38
- ? ["gnu", "musl"]
39
- : [""];
40
-
41
- const names = libcVariants.map(
42
- (libc) => `castrum.${platform}-${arch}${libc ? `-${libc}` : ""}.node`,
43
- );
44
- names.push("castrum.node");
45
-
46
- const roots = [base, join(base, "target", "release")];
47
-
48
- const candidates: string[] = [];
49
- for (const root of roots) {
50
- for (const name of names) {
51
- candidates.push(join(root, name));
52
- }
53
- }
54
-
55
- for (const candidate of candidates) {
56
- if (existsSync(candidate)) {
57
- return candidate;
58
- }
59
- }
60
-
61
- throw new Error(
62
- `Could not find castrum native addon.\n` +
63
- `Run: bun run build\n` +
64
- `Looked in:\n${candidates.map((c) => ` - ${c}`).join("\n")}`,
65
- );
66
- }
67
-
68
- interface NativeAddon {
69
- HmacSigner: new (key: Uint8Array) => HmacSignerInstance;
70
- Ingress: new (options: Record<string, unknown>) => IngressInstance;
71
-
72
- // ── Ingress binary-layout constants (single source of truth: rust/ingress_constants.rs) ──
73
- INGRESS_OUT_VERDICT: number;
74
- INGRESS_OUT_ERROR_CODE: number;
75
- INGRESS_OUT_STATUS: number;
76
- INGRESS_OUT_FLAGS: number;
77
- INGRESS_OUT_RATE_LIMIT: number;
78
- INGRESS_OUT_RATE_REMAINING: number;
79
- INGRESS_OUT_RATE_RESET: number;
80
- INGRESS_OUT_RETRY_AFTER: number;
81
- INGRESS_OUT_COOKIES_JSON_LEN: number;
82
- INGRESS_OUT_QUERY_JSON_LEN: number;
83
- INGRESS_OUT_HEADER_VARIANT: number;
84
- INGRESS_OUT_BODY_JSON_LEN: number;
85
- INGRESS_OUT_DATA_START: number;
86
- INGRESS_FLAG_HAS_COOKIES: number;
87
- INGRESS_FLAG_HAS_QUERY: number;
88
- INGRESS_FLAG_BODY_VALID_JSON: number;
89
- INGRESS_FLAG_SCHEMA_VALID: number;
90
- INGRESS_FLAG_CORS_ALLOWED: number;
91
- INGRESS_FLAG_IS_PREFLIGHT: number;
92
- INGRESS_FLAG_RATE_LIMITED: number;
93
- INGRESS_FLAG_HTTPS: number;
94
- INGRESS_FLAG_TRUSTED_PROXY: number;
95
- INGRESS_FLAG_BODY_TRUNCATED: number;
96
- INGRESS_HV_JSON: number;
97
- INGRESS_HV_CORS_SIMPLE: number;
98
- INGRESS_HV_CORS_PREFLIGHT: number;
99
- INGRESS_HV_RATE_ACTIVE: number;
100
- INGRESS_HV_RATE_LIMITED: number;
101
- INGRESS_HV_COUNT: number;
102
- INGRESS_ERR_NONE: number;
103
- INGRESS_ERR_CORS_PREFLIGHT: number;
104
- INGRESS_ERR_RATE_LIMITED: number;
105
- INGRESS_ERR_BODY_TOO_LARGE: number;
106
- INGRESS_ERR_INVALID_JSON: number;
107
- INGRESS_ERR_SCHEMA_VALIDATION: number;
108
- INGRESS_ERR_BAD_REQUEST: number;
109
- INGRESS_ERR_REQUEST_TOO_LARGE: number;
110
- INGRESS_ERR_INTERNAL: number;
111
-
112
- crc32(input: Uint8Array): number;
113
- fnv1a64(input: Uint8Array): bigint;
114
-
115
- hmacSha256(key: Uint8Array, data: Uint8Array): Uint8Array;
116
- hmacSha256Verify(key: Uint8Array, data: Uint8Array, sig: Uint8Array): boolean;
117
-
118
- jsonValid(input: Uint8Array): boolean;
119
- jsonSumIds(input: Uint8Array): bigint;
120
-
121
- jsonPatch(doc: Uint8Array, patch: Uint8Array): Uint8Array;
122
-
123
- mimeFromExtension(ext: Uint8Array): Uint8Array;
124
- randomToken(byteLen: number): Uint8Array;
125
-
126
- urlEncode(input: Uint8Array): Uint8Array;
127
- urlDecode(input: Uint8Array): Uint8Array;
128
- urlDecodeBytes(input: Uint8Array): Uint8Array;
129
-
130
- validateEmail(input: Uint8Array): boolean;
131
- validateUuid(input: Uint8Array): boolean;
132
- validateIpv4(input: Uint8Array): boolean;
133
- validateIpv6(input: Uint8Array): boolean;
134
-
135
- wsAcceptKey(key: Uint8Array): Uint8Array;
136
-
137
- SchemaValidator: new (schema: Uint8Array) => SchemaValidatorInstance;
138
-
139
- initThreadPool(rayonThreads?: number): void;
140
- rayonNumThreads(): number;
141
-
142
- jsonValidBatchPacked(input: Uint8Array): Uint8Array;
143
- validateEmailBatchPacked(input: Uint8Array): Uint8Array;
144
- validateUuidBatchPacked(input: Uint8Array): Uint8Array;
145
- validateIpv4BatchPacked(input: Uint8Array): Uint8Array;
146
- validateIpv6BatchPacked(input: Uint8Array): Uint8Array;
147
-
148
- jsonSumBatchPacked(input: Uint8Array): Uint8Array;
149
- queryParseBatchPacked(input: Uint8Array): Uint8Array;
150
- cookieParseBatchPacked(input: Uint8Array): Uint8Array;
151
- httpParseRequestBatchPacked(input: Uint8Array): Uint8Array;
152
-
153
- httpParseRequestPacked(input: Uint8Array): Uint8Array;
154
-
155
- queryParsePacked(input: Uint8Array): Uint8Array;
156
-
157
- cookieParsePacked(input: Uint8Array): Uint8Array;
158
-
159
- crc32BatchPacked(input: Uint8Array): Uint8Array;
160
-
161
- // ── Batch metadata / counts ──
162
- jsonValidBatchCountPacked(input: Uint8Array): number;
163
- validateEmailBatchCountPacked(input: Uint8Array): number;
164
- validateUuidBatchCountPacked(input: Uint8Array): number;
165
- validateIpv4BatchCountPacked(input: Uint8Array): number;
166
- validateIpv6BatchCountPacked(input: Uint8Array): number;
167
- jsonSumBatchTotalPacked(input: Uint8Array): number;
168
- queryParseBatchTotalLenPacked(input: Uint8Array): number;
169
- cookieParseBatchTotalLenPacked(input: Uint8Array): number;
170
- httpParseRequestBatchTotalLenPacked(input: Uint8Array): number;
171
- }
172
-
173
- const addonPath = resolveAddonPath();
174
-
175
- let addon: NativeAddon;
176
- try {
177
- addon = require(addonPath);
178
- } catch (err) {
179
- const cause = err instanceof Error ? `\n Underlying cause: ${err.message}` : "";
180
- throw new Error(
181
- `Failed to load castrum native addon from:\n ${addonPath}\n` +
182
- `The addon exists but could not be loaded (ABI mismatch, missing system ` +
183
- `libraries, or a corrupt/partial artifact).\n` +
184
- `Run: bun run build\n` +
185
- `If the problem persists, verify the binary was built for this platform/CPU.` +
186
- cause,
187
- );
188
- }
189
-
190
- if (process.env.CASTRUM_DEBUG || process.env.RUST_BENCH_DEBUG) {
191
- console.log("Native addon loaded from:", addonPath);
192
- console.log("Exported keys:", Object.keys(addon).sort());
193
- }
194
-
195
- export interface SchemaValidatorInstance {
196
- validateBatchPackedCount(packed: Uint8Array): number;
197
- validateBatchPackedBitset(packed: Uint8Array): Uint8Array;
198
- validateBatchStreaming(batchBytes: Uint8Array): number;
199
- }
200
-
201
- export interface HmacSignerInstance {
202
- sign(data: Uint8Array): Uint8Array;
203
- verify(data: Uint8Array, sig: Uint8Array): boolean;
204
- }
205
-
206
- export default addon;
207
- export { addonPath };
1
+ // src/native/index.ts — Native addon layer (barrel).
2
+ //
3
+ // Re-exports the addon type surface (./types.ts) and the lazy loader
4
+ // (./loader.ts). Importing this barrel does NOT dlopen the addon — use
5
+ // `getAddon()` / `lazyAddon` at call time.
6
+
7
+ export * from './types'
8
+ export { getAddonPath, getAddon, lazyAddon } from './loader'
@@ -0,0 +1,194 @@
1
+ // src/native/loader.ts — Native addon path resolution + lazy loading.
2
+ //
3
+ // Importing this module does NOT dlopen the addon; `getAddon()` /
4
+ // `lazyAddon()` trigger loading exactly once on first use. This keeps
5
+ // `import castrum` as cheap as possible on Bun.
6
+
7
+ import { existsSync, statSync } from 'node:fs'
8
+ import { fileURLToPath } from 'node:url'
9
+ import { join, dirname } from 'node:path'
10
+ import { createRequire } from 'node:module'
11
+ import { resolveEnvVar } from '../shared/env'
12
+ import type { NativeAddon } from './types'
13
+
14
+ const require = createRequire(import.meta.url)
15
+
16
+ /**
17
+ * Resolve the addon path given EXPLICIT inputs (pure — unit-testable).
18
+ *
19
+ * `resolveAddonPath()` is a thin wrapper that reads `process.env` and the
20
+ * module location; this function contains all the resolution logic so tests
21
+ * can exercise env-override / multi-root / missing-addon behavior without
22
+ * mutating the process-global cache.
23
+ *
24
+ * @param envOverride an explicit `CASTRUM_NATIVE_LIBRARY_PATH`-style path, or
25
+ * `undefined` for none (the real call passes the env-var value)
26
+ * @param baseDir the directory of the current module (`dist/` or `src/native`)
27
+ * @param platform `process.platform`
28
+ * @param arch `process.arch`
29
+ * @returns the first existing candidate path (throws if none exists)
30
+ */
31
+ export function resolveAddonPathFrom(
32
+ envOverride: string | undefined,
33
+ baseDir: string,
34
+ platform: string,
35
+ arch: string,
36
+ ): string {
37
+ // napi-rs artifact naming: castrum.<platform>-<arch>[-<libc>].node
38
+ // e.g. linux-x64-gnu, linux-x64-musl, win32-x64-msvc, darwin-arm64.
39
+ const libcVariants =
40
+ platform === 'win32' ? ['msvc', 'gnu'] : platform === 'linux' ? ['gnu', 'musl'] : ['']
41
+
42
+ const names = libcVariants.map(
43
+ (libc) => `castrum.${platform}-${arch}${libc ? `-${libc}` : ''}.node`,
44
+ )
45
+ names.push('castrum.node')
46
+
47
+ const candidates: string[] = []
48
+
49
+ // Explicit override (napi-rs convention + castrum/legacy aliases). May be a
50
+ // direct file path or a directory that contains the .node artifact.
51
+ if (envOverride) {
52
+ // A DIRECT file override must resolve to a FILE (a .node/loadable path).
53
+ // If the override is a DIRECTORY, only the joined `dir/<name>.node`
54
+ // candidates are valid — pushing the directory itself used to make
55
+ // `require(<dir>)` fail with MODULE_NOT_FOUND (no package.json/index inside).
56
+ const isFile = (p: string): boolean => {
57
+ try {
58
+ return statSync(p).isFile()
59
+ } catch {
60
+ return false
61
+ }
62
+ }
63
+ const overrideCandidates: string[] = []
64
+ if (existsSync(envOverride) && isFile(envOverride)) {
65
+ candidates.push(envOverride)
66
+ overrideCandidates.push(envOverride)
67
+ }
68
+ for (const name of names) {
69
+ const candidate = join(envOverride, name)
70
+ candidates.push(candidate)
71
+ overrideCandidates.push(candidate)
72
+ }
73
+ // A SET-but-misconfigured override must not silently fall through to the
74
+ // package roots — that would mask a typo'd path (a stale/root addon could
75
+ // load while the user believes their override is active). Throw a clear
76
+ // error instead, pointing at the expected override locations.
77
+ if (!overrideCandidates.some((c) => existsSync(c))) {
78
+ throw new Error(
79
+ `Could not find castrum native addon.\n` +
80
+ `CASTRUM_NATIVE_LIBRARY_PATH (or NAPI_RS_NATIVE_LIBRARY_PATH) is set to ` +
81
+ `"${envOverride}" but no loadable addon was found there.\n` +
82
+ `Expected one of:\n${overrideCandidates.map((c) => ` - ${c}`).join('\n')}\n` +
83
+ `Fix the path, or unset the variable to fall back to the package layout.`,
84
+ )
85
+ }
86
+ }
87
+
88
+ // Candidate roots cover BOTH layouts:
89
+ // - source layout (Bun, raw TS): <pkg>/src/native → <pkg> via ".."/"../.."
90
+ // - bundled layout (Node, dist/): <pkg>/dist → <pkg> via ".."
91
+ const roots = [
92
+ baseDir, // <pkg>/dist (bundled) or <pkg>/src/native
93
+ join(baseDir, '..'), // <pkg> when bundled into <pkg>/dist
94
+ join(baseDir, '..', '..'), // <pkg> when running from <pkg>/src/native
95
+ ]
96
+
97
+ for (const root of roots) {
98
+ for (const name of names) {
99
+ candidates.push(join(root, name))
100
+ }
101
+ }
102
+
103
+ // Local dev fallback: <pkg>/target/release/<name>.node (cargo-built artifact
104
+ // not yet copied to the package root). Note the artifact NAME must be
105
+ // appended — a bare directory is not loadable (regression guarded in tests).
106
+ for (const name of names) {
107
+ candidates.push(join(baseDir, '..', '..', 'target', 'release', name))
108
+ }
109
+
110
+ for (const candidate of candidates) {
111
+ if (existsSync(candidate)) {
112
+ return candidate
113
+ }
114
+ }
115
+
116
+ throw new Error(
117
+ `Could not find castrum native addon.\n` +
118
+ `Run: bun run build\n` +
119
+ `Looked in:\n${candidates.map((c) => ` - ${c}`).join('\n')}`,
120
+ )
121
+ }
122
+
123
+ function resolveAddonPath(): string {
124
+ return resolveAddonPathFrom(
125
+ resolveEnvVar('CASTRUM_NATIVE_LIBRARY_PATH', [
126
+ 'NAPI_RS_NATIVE_LIBRARY_PATH',
127
+ 'RUST_BENCH_NATIVE_LIBRARY_PATH',
128
+ ]),
129
+ dirname(fileURLToPath(import.meta.url)),
130
+ process.platform,
131
+ process.arch,
132
+ )
133
+ }
134
+
135
+ let cachedPath: string | undefined
136
+
137
+ /** Resolve the addon file path exactly once (lazy). */
138
+ export function getAddonPath(): string {
139
+ cachedPath ??= resolveAddonPath()
140
+ return cachedPath
141
+ }
142
+
143
+ let cachedAddon: NativeAddon | undefined
144
+
145
+ /**
146
+ * Load the native addon exactly once, lazily.
147
+ *
148
+ * The first call triggers loading (see {@link getAddonPath} for resolution).
149
+ */
150
+ export function getAddon(): NativeAddon {
151
+ if (cachedAddon === undefined) {
152
+ const addonPath = getAddonPath()
153
+ let loaded: NativeAddon
154
+ try {
155
+ loaded = require(addonPath)
156
+ } catch (err) {
157
+ const cause = err instanceof Error ? `\n Underlying cause: ${err.message}` : ''
158
+ throw new Error(
159
+ `Failed to load castrum native addon from:\n ${addonPath}\n` +
160
+ `The addon exists but could not be loaded (ABI mismatch, missing system ` +
161
+ `libraries, or a corrupt/partial artifact).\n` +
162
+ `Run: bun run build\n` +
163
+ `If the problem persists, verify the binary was built for this platform/CPU.` +
164
+ cause,
165
+ )
166
+ }
167
+
168
+ if (resolveEnvVar('CASTRUM_DEBUG', ['RUST_BENCH_DEBUG']) !== undefined) {
169
+ console.log('Native addon loaded from:', addonPath)
170
+ console.log('Exported keys:', Object.keys(loaded).sort())
171
+ }
172
+
173
+ cachedAddon = loaded
174
+ }
175
+ return cachedAddon
176
+ }
177
+
178
+ /**
179
+ * A lazily-loading proxy over the native addon.
180
+ *
181
+ * Use this in modules that only touch the addon at call time (e.g. the FFI
182
+ * wrappers) so that importing them does not dlopen the addon until the first
183
+ * real native call. All existing `addon.X` / `new addon.Y(...)` call sites
184
+ * keep working unchanged.
185
+ */
186
+ export function lazyAddon<T extends object>(loader: () => T): T {
187
+ let loaded: T | undefined
188
+ return new Proxy({} as T, {
189
+ get(_target, prop: string | symbol) {
190
+ loaded ??= loader()
191
+ return Reflect.get(loaded, prop)
192
+ },
193
+ })
194
+ }