@telorun/kernel 0.4.1 → 0.6.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 (89) hide show
  1. package/dist/controller-loader.d.ts +19 -20
  2. package/dist/controller-loader.d.ts.map +1 -1
  3. package/dist/controller-loader.js +67 -247
  4. package/dist/controller-loader.js.map +1 -1
  5. package/dist/controller-loaders/napi-loader.d.ts +27 -0
  6. package/dist/controller-loaders/napi-loader.d.ts.map +1 -0
  7. package/dist/controller-loaders/napi-loader.js +158 -0
  8. package/dist/controller-loaders/napi-loader.js.map +1 -0
  9. package/dist/controller-loaders/npm-loader.d.ts +20 -0
  10. package/dist/controller-loaders/npm-loader.d.ts.map +1 -0
  11. package/dist/controller-loaders/npm-loader.js +256 -0
  12. package/dist/controller-loaders/npm-loader.js.map +1 -0
  13. package/dist/controller-registry.d.ts +30 -20
  14. package/dist/controller-registry.d.ts.map +1 -1
  15. package/dist/controller-registry.js +50 -99
  16. package/dist/controller-registry.js.map +1 -1
  17. package/dist/controllers/module/import-controller.d.ts +11 -0
  18. package/dist/controllers/module/import-controller.d.ts.map +1 -1
  19. package/dist/controllers/module/import-controller.js +30 -3
  20. package/dist/controllers/module/import-controller.js.map +1 -1
  21. package/dist/controllers/resource-definition/abstract-controller.d.ts +35 -0
  22. package/dist/controllers/resource-definition/abstract-controller.d.ts.map +1 -0
  23. package/dist/controllers/resource-definition/abstract-controller.js +34 -0
  24. package/dist/controllers/resource-definition/abstract-controller.js.map +1 -0
  25. package/dist/controllers/resource-definition/resource-definition-controller.d.ts.map +1 -1
  26. package/dist/controllers/resource-definition/resource-definition-controller.js +1 -1
  27. package/dist/controllers/resource-definition/resource-definition-controller.js.map +1 -1
  28. package/dist/evaluation-context.js +1 -1
  29. package/dist/evaluation-context.js.map +1 -1
  30. package/dist/index.d.ts +2 -1
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +2 -1
  33. package/dist/index.js.map +1 -1
  34. package/dist/kernel.d.ts +14 -16
  35. package/dist/kernel.d.ts.map +1 -1
  36. package/dist/kernel.js +58 -49
  37. package/dist/kernel.js.map +1 -1
  38. package/dist/manifest-schemas.d.ts +50 -0
  39. package/dist/manifest-schemas.d.ts.map +1 -1
  40. package/dist/manifest-schemas.js +31 -0
  41. package/dist/manifest-schemas.js.map +1 -1
  42. package/dist/{manifest-adapters/local-file-adapter.d.ts → manifest-sources/local-file-source.d.ts} +3 -3
  43. package/dist/manifest-sources/local-file-source.d.ts.map +1 -0
  44. package/dist/{manifest-adapters/local-file-adapter.js → manifest-sources/local-file-source.js} +2 -2
  45. package/dist/manifest-sources/local-file-source.js.map +1 -0
  46. package/dist/manifest-sources/memory-source.d.ts +23 -0
  47. package/dist/manifest-sources/memory-source.d.ts.map +1 -0
  48. package/dist/manifest-sources/memory-source.js +83 -0
  49. package/dist/manifest-sources/memory-source.js.map +1 -0
  50. package/dist/module-context.d.ts +17 -3
  51. package/dist/module-context.d.ts.map +1 -1
  52. package/dist/module-context.js +19 -1
  53. package/dist/module-context.js.map +1 -1
  54. package/dist/registry.d.ts.map +1 -1
  55. package/dist/registry.js +0 -1
  56. package/dist/registry.js.map +1 -1
  57. package/dist/resource-context.d.ts +2 -7
  58. package/dist/resource-context.d.ts.map +1 -1
  59. package/dist/resource-context.js +8 -28
  60. package/dist/resource-context.js.map +1 -1
  61. package/dist/runtime-registry.d.ts +50 -0
  62. package/dist/runtime-registry.d.ts.map +1 -0
  63. package/dist/runtime-registry.js +140 -0
  64. package/dist/runtime-registry.js.map +1 -0
  65. package/package.json +16 -5
  66. package/src/controller-loader.ts +77 -273
  67. package/src/controller-loaders/napi-loader.ts +191 -0
  68. package/src/controller-loaders/npm-loader.ts +285 -0
  69. package/src/controller-registry.ts +66 -129
  70. package/src/controllers/module/import-controller.ts +32 -3
  71. package/src/controllers/resource-definition/abstract-controller.ts +56 -0
  72. package/src/controllers/resource-definition/resource-definition-controller.ts +1 -0
  73. package/src/evaluation-context.ts +1 -1
  74. package/src/index.ts +2 -1
  75. package/src/kernel.ts +86 -67
  76. package/src/manifest-schemas.ts +33 -0
  77. package/src/{manifest-adapters/local-file-adapter.ts → manifest-sources/local-file-source.ts} +2 -2
  78. package/src/manifest-sources/memory-source.ts +104 -0
  79. package/src/module-context.ts +36 -3
  80. package/src/registry.ts +0 -1
  81. package/src/resource-context.ts +11 -36
  82. package/src/runtime-registry.ts +170 -0
  83. package/dist/manifest-adapters/local-file-adapter.d.ts.map +0 -1
  84. package/dist/manifest-adapters/local-file-adapter.js.map +0 -1
  85. package/dist/manifest-adapters/manifest-adapter.d.ts +0 -35
  86. package/dist/manifest-adapters/manifest-adapter.d.ts.map +0 -1
  87. package/dist/manifest-adapters/manifest-adapter.js +0 -2
  88. package/dist/manifest-adapters/manifest-adapter.js.map +0 -1
  89. package/src/manifest-adapters/manifest-adapter.ts +0 -35
@@ -1,301 +1,105 @@
1
1
  import { ControllerInstance, RuntimeError } from "@telorun/sdk";
2
- import { execFile } from "child_process";
3
- import { createHash } from "crypto";
4
- import * as fs from "fs/promises";
5
- import * as os from "os";
6
- import { PackageURL } from "packageurl-js";
7
- import * as path from "path";
8
- import { promisify } from "util";
9
-
10
- const homedir = os.homedir();
11
- const cacheRoot = process.env.TELO_CACHE_DIR
12
- ? path.resolve(process.env.TELO_CACHE_DIR)
13
- : path.join(homedir, ".cache", "telo");
14
- const npmCacheRoot = path.join(cacheRoot, "npm");
15
- const isBun = typeof (globalThis as any).Bun !== "undefined";
16
-
2
+ import { ControllerEnvMissingError, NapiControllerLoader } from "./controller-loaders/napi-loader.js";
3
+ import { NpmControllerLoader } from "./controller-loaders/npm-loader.js";
4
+ import { ControllerPolicy, DEFAULT_POLICY, POLICY_WILDCARD } from "./runtime-registry.js";
5
+
6
+ export type { ControllerPolicy } from "./runtime-registry.js";
7
+
8
+ /**
9
+ * Top-level controller-loader dispatcher. Picks a per-scheme sub-loader by
10
+ * PURL type and applies the resolved selection policy:
11
+ *
12
+ * ControllerLoader.load(candidates, baseUri, policy)
13
+ * └─ orderCandidates(candidates, policy)
14
+ * ├─ pkg:npm → NpmControllerLoader
15
+ * └─ pkg:cargo NapiControllerLoader
16
+ *
17
+ * Recovery: env-missing failures (`ControllerEnvMissingError`) advance to the
18
+ * next candidate. User-code failures (`RuntimeError("ERR_CONTROLLER_BUILD_FAILED" | "ERR_CONTROLLER_INVALID")`)
19
+ * fail hard regardless of remaining candidates.
20
+ */
17
21
  export class ControllerLoader {
18
- /**
19
- * Load controller instance from URI in format:
20
- *
21
- * <runtime>:<registry>:<path>@<version-spec>
22
- */
23
- async load(purlCandidates: string[], baseUri: string): Promise<ControllerInstance> {
22
+ private npmLoader = new NpmControllerLoader();
23
+ private napiLoader = new NapiControllerLoader();
24
+
25
+ async load(
26
+ purlCandidates: string[],
27
+ baseUri: string,
28
+ policy?: ControllerPolicy,
29
+ ): Promise<ControllerInstance> {
24
30
  if (!purlCandidates || purlCandidates.length === 0) {
25
31
  throw new RuntimeError("ERR_CONTROLLER_NOT_FOUND", "Missing controller PURL candidates");
26
32
  }
27
- const purl = purlCandidates.find((p) => p.startsWith("pkg:npm"));
28
- if (!purl) {
33
+ const effectivePolicy = policy ?? DEFAULT_POLICY;
34
+ const ordered = orderCandidates(purlCandidates, effectivePolicy);
35
+ if (ordered.length === 0) {
29
36
  throw new RuntimeError(
30
37
  "ERR_CONTROLLER_NOT_FOUND",
31
- "Controller PURL candidates not applicable",
38
+ `No controllers match runtime selection [${effectivePolicy.load.join(", ")}]; declared: ${purlCandidates.join(", ")}`,
32
39
  );
33
40
  }
34
- const [type, namespace, name, versionSpec, qualifiers, entry] = PackageURL.parseString(purl);
35
41
 
36
- const localPath = (qualifiers as any)?.get("local_path");
37
- const cacheKey = createHash("sha256").update(purlCandidates[0]).digest("hex").slice(0, 12);
38
- const installDir = path.join(npmCacheRoot, cacheKey);
39
-
40
- let packageRoot: string;
41
- const isLocalManifest =
42
- baseUri && !baseUri.startsWith("http://") && !baseUri.startsWith("https://");
43
- if (localPath && isLocalManifest) {
44
- const baseUriPath = baseUri.startsWith("file://") ? baseUri.slice("file://".length) : baseUri;
45
- const manifestDir = path.dirname(baseUriPath);
46
- const resolvedLocalPath = path.resolve(manifestDir, localPath);
47
- if (await this.pathExists(resolvedLocalPath)) {
48
- packageRoot = resolvedLocalPath;
49
- } else {
50
- const nodeModulesPath = await this.findInNodeModules(`${namespace}/${name}`);
51
- if (nodeModulesPath) {
52
- packageRoot = nodeModulesPath;
53
- } else {
54
- await this.ensureNpmPackageInstalled(installDir, `${namespace}/${name}@${versionSpec}`);
55
- packageRoot = this.getInstalledPackageRoot(installDir, `${namespace}/${name}`);
42
+ const errors: string[] = [];
43
+ for (const purl of ordered) {
44
+ try {
45
+ return await this.dispatchOne(purl, baseUri);
46
+ } catch (err) {
47
+ if (err instanceof ControllerEnvMissingError) {
48
+ errors.push(`${purl}: ${err.message}`);
49
+ continue;
56
50
  }
51
+ throw err;
57
52
  }
58
- } else {
59
- const nodeModulesPath = await this.findInNodeModules(`${namespace}/${name}`);
60
- if (nodeModulesPath) {
61
- packageRoot = nodeModulesPath;
62
- } else {
63
- await this.ensureNpmPackageInstalled(installDir, `${namespace}/${name}@${versionSpec}`);
64
- packageRoot = this.getInstalledPackageRoot(installDir, `${namespace}/${name}`);
65
- }
66
- }
67
-
68
- const entryFile = await this.resolvePackageEntry(packageRoot, entry ? `./${entry}` : ".");
69
- const instance = await import(entryFile);
70
- if (!instance || (!instance.create && !instance.register)) {
71
- throw new Error(
72
- `Invalid controller loaded from "${purlCandidates[0]}": missing create or register function`,
73
- );
74
53
  }
75
- return instance;
76
- }
77
-
78
- private async ensureNpmPackageInstalled(installDir: string, packageSpec: string): Promise<void> {
79
- const packageName = this.getPackageName(
80
- packageSpec.startsWith(".") || path.isAbsolute(packageSpec)
81
- ? await this.getLocalPackageName(packageSpec)
82
- : packageSpec,
54
+ throw new RuntimeError(
55
+ "ERR_CONTROLLER_NOT_FOUND",
56
+ `No controller resolved. Tried ${ordered.length} candidate(s):\n${errors.join("\n")}`,
83
57
  );
84
- const packageRoot = this.getInstalledPackageRoot(installDir, packageName);
85
- const packageJsonPath = path.join(packageRoot, "package.json");
86
- if (await this.pathExists(packageJsonPath)) {
87
- return;
88
- }
89
-
90
- await fs.mkdir(installDir, { recursive: true });
91
- const rootPackageJson = path.join(installDir, "package.json");
92
- if (!(await this.pathExists(rootPackageJson))) {
93
- await fs.writeFile(
94
- rootPackageJson,
95
- JSON.stringify({ name: "telo-cache", private: true }, null, 2),
96
- );
97
- }
98
-
99
- const execFileAsync = promisify(execFile);
100
- const args = [
101
- "install",
102
- "--no-audit",
103
- "--no-fund",
104
- "--silent",
105
- "--prefix",
106
- installDir,
107
- packageSpec,
108
- ];
109
-
110
- await execFileAsync("npm", args);
111
- }
112
-
113
- private getPackageName(packageSpec: string): string {
114
- if (packageSpec.startsWith("@")) {
115
- const lastAt = packageSpec.lastIndexOf("@");
116
- return lastAt > 0 ? packageSpec.slice(0, lastAt) : packageSpec;
117
- }
118
- const [name] = packageSpec.split("@");
119
- return name;
120
- }
121
-
122
- private getInstalledPackageRoot(installDir: string, packageName: string): string {
123
- const nameParts = packageName.split("/");
124
- return path.join(installDir, "node_modules", ...nameParts);
125
- }
126
-
127
- private async getLocalPackageName(packagePath: string): Promise<string> {
128
- const packageJsonPath = path.join(packagePath, "package.json");
129
- if (!(await this.pathExists(packageJsonPath))) {
130
- throw new Error(`Local package missing package.json: ${packagePath}`);
131
- }
132
- const content = await fs.readFile(packageJsonPath, "utf8");
133
- const parsed = JSON.parse(content);
134
- if (!parsed?.name) {
135
- throw new Error(`Local package missing name in package.json: ${packagePath}`);
136
- }
137
- return parsed.name;
138
58
  }
139
59
 
140
- private async resolvePackageEntry(
141
- packageRoot: string,
142
- entry: string,
143
- packageName?: string,
144
- ): Promise<string> {
145
- const packageJsonPath = path.join(packageRoot, "package.json");
146
- let resolvedPackageName = packageName;
147
- let packageJson: any = null;
148
- if (!resolvedPackageName && (await this.pathExists(packageJsonPath))) {
149
- const content = await fs.readFile(packageJsonPath, "utf8");
150
- try {
151
- packageJson = JSON.parse(content);
152
- resolvedPackageName = packageJson?.name;
153
- } catch {
154
- resolvedPackageName = packageName;
155
- }
156
- } else if (await this.pathExists(packageJsonPath)) {
157
- try {
158
- packageJson = JSON.parse(await fs.readFile(packageJsonPath, "utf8"));
159
- } catch {
160
- packageJson = null;
161
- }
162
- }
163
-
164
- const entryValue = entry.trim();
165
- const exportTarget = this.resolvePackageExportTarget(packageJson?.exports, entryValue);
166
- if (exportTarget) {
167
- const resolved = path.resolve(packageRoot, exportTarget);
168
- if (await this.pathExists(resolved)) {
169
- return this.resolveForRuntime(resolved, packageRoot);
170
- }
171
- if (!path.extname(resolved)) {
172
- const withJs = `${resolved}.js`;
173
- if (await this.pathExists(withJs)) {
174
- return withJs;
175
- }
176
- }
177
- }
178
- if ((entryValue === "." || entryValue === "./") && packageJson) {
179
- const mainFields = ["module", "main"];
180
- for (const field of mainFields) {
181
- const target = packageJson[field];
182
- if (typeof target === "string") {
183
- const resolved = path.resolve(packageRoot, target);
184
- if (await this.pathExists(resolved)) {
185
- return this.resolveForRuntime(resolved, packageRoot);
186
- }
187
- if (!path.extname(resolved)) {
188
- const withJs = `${resolved}.js`;
189
- if (await this.pathExists(withJs)) {
190
- return withJs;
191
- }
192
- }
193
- }
194
- }
195
- }
196
-
197
- const directPath = path.resolve(packageRoot, entryValue);
198
- if (await this.pathExists(directPath)) {
199
- return this.resolveForRuntime(directPath, packageRoot);
60
+ private async dispatchOne(purl: string, baseUri: string): Promise<ControllerInstance> {
61
+ if (purl.startsWith("pkg:npm")) {
62
+ return this.npmLoader.load(purl, baseUri);
200
63
  }
201
- if (!path.extname(directPath)) {
202
- const withJs = `${directPath}.js`;
203
- if (await this.pathExists(withJs)) {
204
- return withJs;
205
- }
64
+ if (purl.startsWith("pkg:cargo")) {
65
+ return this.napiLoader.load(purl, baseUri);
206
66
  }
207
-
208
- throw new Error(`Controller entry "${entryValue}" could not be resolved in ${packageRoot}`);
67
+ throw new ControllerEnvMissingError(`Unsupported PURL scheme: ${purl}`);
209
68
  }
69
+ }
210
70
 
211
- private resolvePackageExportTarget(exportsField: any, entry: string): string | null {
212
- if (!exportsField) {
213
- return null;
214
- }
215
-
216
- const key = entry === "." || entry === "./" ? "." : entry;
217
- const target = exportsField[key];
218
- return this.resolveExportTargetValue(target);
219
- }
71
+ function getPurlType(purl: string): string {
72
+ const slashIdx = purl.indexOf("/", purl.indexOf(":") + 1);
73
+ return slashIdx === -1 ? purl : purl.slice(0, slashIdx);
74
+ }
220
75
 
221
- private resolveExportTargetValue(target: any): string | null {
222
- if (!target) {
223
- return null;
224
- }
225
- if (typeof target === "string") {
226
- return target;
227
- }
228
- if (Array.isArray(target)) {
229
- for (const item of target) {
230
- const resolved = this.resolveExportTargetValue(item);
231
- if (resolved) {
232
- return resolved;
76
+ function orderCandidates(
77
+ candidates: ReadonlyArray<string>,
78
+ policy: ControllerPolicy,
79
+ ): string[] {
80
+ const result: string[] = [];
81
+ const seen = new Set<string>();
82
+ const explicitTypes = new Set(policy.load.filter((t) => t !== POLICY_WILDCARD));
83
+
84
+ for (const entry of policy.load) {
85
+ if (entry === POLICY_WILDCARD) {
86
+ for (const candidate of candidates) {
87
+ if (seen.has(candidate)) continue;
88
+ const type = getPurlType(candidate);
89
+ if (!explicitTypes.has(type)) {
90
+ result.push(candidate);
91
+ seen.add(candidate);
233
92
  }
234
93
  }
235
- return null;
236
- }
237
- if (typeof target === "object") {
238
- const preferredKeys = isBun
239
- ? ["bun", "import", "default", "require"]
240
- : ["import", "default", "require"];
241
- for (const key of preferredKeys) {
242
- if (target[key]) {
243
- const resolved = this.resolveExportTargetValue(target[key]);
244
- if (resolved) {
245
- return resolved;
246
- }
94
+ } else {
95
+ for (const candidate of candidates) {
96
+ if (seen.has(candidate)) continue;
97
+ if (getPurlType(candidate) === entry) {
98
+ result.push(candidate);
99
+ seen.add(candidate);
247
100
  }
248
101
  }
249
102
  }
250
- return null;
251
- }
252
-
253
- /**
254
- * For Node.js, resolve .ts paths to their compiled .js equivalents in dist/.
255
- * Bun can load .ts directly, so it returns the path unchanged.
256
- */
257
- private async resolveForRuntime(resolvedPath: string, packageRoot: string): Promise<string> {
258
- if (isBun || !resolvedPath.endsWith(".ts")) {
259
- return resolvedPath;
260
- }
261
- // Try dist/ equivalent: src/foo.ts -> dist/foo.js
262
- const relative = path.relative(packageRoot, resolvedPath);
263
- const distEquivalent = path.resolve(
264
- packageRoot,
265
- relative.replace(/^src\//, "dist/").replace(/\.ts$/, ".js"),
266
- );
267
- if (await this.pathExists(distEquivalent)) {
268
- return distEquivalent;
269
- }
270
- // Fallback: same location but .js
271
- const jsPath = resolvedPath.replace(/\.ts$/, ".js");
272
- if (await this.pathExists(jsPath)) {
273
- return jsPath;
274
- }
275
- return resolvedPath;
276
- }
277
-
278
- private async findInNodeModules(packageName: string): Promise<string | null> {
279
- const nameParts = packageName.split("/");
280
- const candidates = [
281
- path.join(process.cwd(), "node_modules", ...nameParts),
282
- path.join(process.cwd(), "node_modules", ".pnpm", "node_modules", ...nameParts),
283
- ];
284
- for (const candidate of candidates) {
285
- const packageJsonPath = path.join(candidate, "package.json");
286
- if (await this.pathExists(packageJsonPath)) {
287
- return candidate;
288
- }
289
- }
290
- return null;
291
- }
292
-
293
- private async pathExists(filePath: string): Promise<boolean> {
294
- try {
295
- await fs.access(filePath);
296
- return true;
297
- } catch {
298
- return false;
299
- }
300
103
  }
104
+ return result;
301
105
  }
@@ -0,0 +1,191 @@
1
+ import { ControllerInstance, RuntimeError } from "@telorun/sdk";
2
+ import { execFile } from "child_process";
3
+ import * as fs from "fs/promises";
4
+ import { createRequire } from "module";
5
+ import { PackageURL } from "packageurl-js";
6
+ import * as path from "path";
7
+ import { promisify } from "util";
8
+
9
+ const execFileAsync = promisify(execFile);
10
+ const requireFromHere = createRequire(import.meta.url);
11
+
12
+ /**
13
+ * Recoverable resolution failure — the loader could not find the artifact in the
14
+ * environment (missing rustc, missing local_path, missing prebuilt dylib in dist
15
+ * mode). The dispatcher may try the next candidate when wildcard fallback is
16
+ * enabled. Distinguished from `RuntimeError("ERR_CONTROLLER_BUILD_FAILED" | …)`
17
+ * which is non-recoverable: those mean user code is broken and must surface.
18
+ */
19
+ export class ControllerEnvMissingError extends Error {
20
+ readonly _envMissing = true as const;
21
+ constructor(message: string) {
22
+ super(message);
23
+ this.name = "ControllerEnvMissingError";
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Process-lifetime cache of resolved native modules keyed by the realpath of
29
+ * the crate directory. The first call for a given crate does the cargo build,
30
+ * dylib rename, and `require()` work; subsequent calls (e.g. a second kernel
31
+ * registering the same controller, or two manifests reaching the crate via
32
+ * different — possibly symlinked — paths) skip straight to the cached module.
33
+ *
34
+ * Beyond the obvious cost saving, this is load-bearing for Node compatibility:
35
+ * `fs.copyFile` overwriting a `.node` file that Node has already mmapped, plus
36
+ * the secondary `cargo metadata` / build invocations, can leave Node's native
37
+ * addon machinery in a state where finalize callbacks for class instances
38
+ * created in a previous kernel race and segfault. Keying on the canonical
39
+ * crate path (not on the caller's baseUri) ensures two distinct paths that
40
+ * point at the same crate share one cache entry.
41
+ */
42
+ const _napiModuleCache = new Map<string, ControllerInstance>();
43
+
44
+ export class NapiControllerLoader {
45
+ /**
46
+ * Resolve a `pkg:cargo/...` PURL to a controller module instance by building
47
+ * the crate and loading the resulting native addon.
48
+ *
49
+ * Dev mode (local_path qualifier present): probe rustc, run `cargo build
50
+ * --release`, locate the dylib via `cargo metadata`, copy to
51
+ * `<libname>.node`, load via createRequire. Cached on success.
52
+ *
53
+ * Distribution mode (no local_path): out of scope for the PoC; the hook is
54
+ * left in place so the dispatcher reports env-missing and falls through.
55
+ */
56
+ async load(purl: string, baseUri: string): Promise<ControllerInstance> {
57
+ const [, , name, , qualifiers] = PackageURL.parseString(purl);
58
+ const localPath = (qualifiers as any)?.get("local_path");
59
+
60
+ const isLocalManifest =
61
+ baseUri && !baseUri.startsWith("http://") && !baseUri.startsWith("https://");
62
+ if (!localPath || !isLocalManifest) {
63
+ throw new ControllerEnvMissingError(
64
+ `pkg:cargo distribution-mode resolution is not implemented for the PoC; supply ?local_path=...`,
65
+ );
66
+ }
67
+
68
+ const baseUriPath = baseUri.startsWith("file://") ? baseUri.slice("file://".length) : baseUri;
69
+ const manifestDir = path.dirname(baseUriPath);
70
+ const cratePath = path.resolve(manifestDir, localPath);
71
+
72
+ if (!(await pathExists(cratePath))) {
73
+ throw new ControllerEnvMissingError(`pkg:cargo local_path does not exist: ${cratePath}`);
74
+ }
75
+
76
+ // Key the cache on the canonical crate location (realpath) — two manifests
77
+ // that reach the same crate via different baseUris (e.g. through symlinked
78
+ // workspace paths) must share one cache entry. A miss here would re-run
79
+ // cargo build and `fs.copyFile` over an already-mmapped `.node`, which
80
+ // can leave napi finalize callbacks racing and crash Node.
81
+ const canonicalCratePath = await fs.realpath(cratePath);
82
+ const cacheKey = canonicalCratePath;
83
+ const cached = _napiModuleCache.get(cacheKey);
84
+ if (cached) {
85
+ return cached;
86
+ }
87
+
88
+ try {
89
+ await execFileAsync("rustc", ["--version"]);
90
+ } catch {
91
+ throw new ControllerEnvMissingError("rustc not found on PATH");
92
+ }
93
+
94
+ try {
95
+ // Plain `cargo build --release` — no `--features` flag. The SDK's
96
+ // `default = ["napi"]` selects the napi backend transitively, so the
97
+ // controller crate's Cargo.toml stays free of any `[features]` block
98
+ // or napi-rs deps. A future Rust kernel passes
99
+ // `--no-default-features --features native` here instead.
100
+ await execFileAsync("cargo", ["build", "--release"], {
101
+ cwd: cratePath,
102
+ maxBuffer: 32 * 1024 * 1024,
103
+ });
104
+ } catch (err: any) {
105
+ const stderr = err?.stderr ? `\n${err.stderr}` : "";
106
+ throw new RuntimeError(
107
+ "ERR_CONTROLLER_BUILD_FAILED",
108
+ `cargo build failed for ${cratePath}:${stderr}`,
109
+ );
110
+ }
111
+
112
+ const { targetDir, libName } = await resolveCrateMetadata(cratePath, name ?? "");
113
+
114
+ const dylibPath = await findDylib(targetDir, libName);
115
+ if (!dylibPath) {
116
+ throw new RuntimeError(
117
+ "ERR_CONTROLLER_BUILD_FAILED",
118
+ `cargo build succeeded but no cdylib found for ${libName} under ${path.join(targetDir, "release")}/`,
119
+ );
120
+ }
121
+
122
+ const nodePath = path.join(path.dirname(dylibPath), `${libName}.node`);
123
+ await fs.copyFile(dylibPath, nodePath);
124
+
125
+ let module: any;
126
+ try {
127
+ module = requireFromHere(nodePath);
128
+ } catch (err: any) {
129
+ throw new RuntimeError(
130
+ "ERR_CONTROLLER_INVALID",
131
+ `Failed to load native addon ${nodePath}: ${err.message}`,
132
+ );
133
+ }
134
+
135
+ if (!module || (!module.create && !module.register)) {
136
+ throw new RuntimeError(
137
+ "ERR_CONTROLLER_INVALID",
138
+ `pkg:cargo controller at ${nodePath} exports neither create nor register`,
139
+ );
140
+ }
141
+ _napiModuleCache.set(cacheKey, module);
142
+ return module;
143
+ }
144
+ }
145
+
146
+ async function resolveCrateMetadata(
147
+ cratePath: string,
148
+ fallbackName: string,
149
+ ): Promise<{ targetDir: string; libName: string }> {
150
+ const result = await execFileAsync("cargo", [
151
+ "metadata",
152
+ "--format-version",
153
+ "1",
154
+ "--manifest-path",
155
+ path.join(cratePath, "Cargo.toml"),
156
+ "--no-deps",
157
+ ], { maxBuffer: 32 * 1024 * 1024 });
158
+ const metadata = JSON.parse(result.stdout);
159
+ const cratePackage = metadata.packages?.find(
160
+ (p: any) => p.manifest_path === path.join(cratePath, "Cargo.toml"),
161
+ );
162
+ const packageName = cratePackage?.name ?? fallbackName;
163
+ return {
164
+ targetDir: metadata.target_directory,
165
+ libName: packageName.replace(/-/g, "_"),
166
+ };
167
+ }
168
+
169
+ async function findDylib(targetDir: string, libName: string): Promise<string | null> {
170
+ const releaseDir = path.join(targetDir, "release");
171
+ const candidates = [
172
+ path.join(releaseDir, `lib${libName}.so`),
173
+ path.join(releaseDir, `lib${libName}.dylib`),
174
+ path.join(releaseDir, `${libName}.dll`),
175
+ ];
176
+ for (const candidate of candidates) {
177
+ if (await pathExists(candidate)) {
178
+ return candidate;
179
+ }
180
+ }
181
+ return null;
182
+ }
183
+
184
+ async function pathExists(filePath: string): Promise<boolean> {
185
+ try {
186
+ await fs.access(filePath);
187
+ return true;
188
+ } catch {
189
+ return false;
190
+ }
191
+ }