@telorun/kernel 0.4.0 → 0.5.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.
- package/dist/controller-loader.d.ts +19 -20
- package/dist/controller-loader.d.ts.map +1 -1
- package/dist/controller-loader.js +67 -247
- package/dist/controller-loader.js.map +1 -1
- package/dist/controller-loaders/napi-loader.d.ts +27 -0
- package/dist/controller-loaders/napi-loader.d.ts.map +1 -0
- package/dist/controller-loaders/napi-loader.js +158 -0
- package/dist/controller-loaders/napi-loader.js.map +1 -0
- package/dist/controller-loaders/npm-loader.d.ts +20 -0
- package/dist/controller-loaders/npm-loader.d.ts.map +1 -0
- package/dist/controller-loaders/npm-loader.js +256 -0
- package/dist/controller-loaders/npm-loader.js.map +1 -0
- package/dist/controller-registry.d.ts +30 -20
- package/dist/controller-registry.d.ts.map +1 -1
- package/dist/controller-registry.js +50 -99
- package/dist/controller-registry.js.map +1 -1
- package/dist/controllers/module/import-controller.d.ts +11 -0
- package/dist/controllers/module/import-controller.d.ts.map +1 -1
- package/dist/controllers/module/import-controller.js +28 -1
- package/dist/controllers/module/import-controller.js.map +1 -1
- package/dist/controllers/resource-definition/abstract-controller.d.ts +35 -0
- package/dist/controllers/resource-definition/abstract-controller.d.ts.map +1 -0
- package/dist/controllers/resource-definition/abstract-controller.js +34 -0
- package/dist/controllers/resource-definition/abstract-controller.js.map +1 -0
- package/dist/controllers/resource-definition/resource-definition-controller.d.ts.map +1 -1
- package/dist/controllers/resource-definition/resource-definition-controller.js +1 -1
- package/dist/controllers/resource-definition/resource-definition-controller.js.map +1 -1
- package/dist/kernel.d.ts +1 -1
- package/dist/kernel.d.ts.map +1 -1
- package/dist/kernel.js +35 -14
- package/dist/kernel.js.map +1 -1
- package/dist/manifest-schemas.d.ts +50 -0
- package/dist/manifest-schemas.d.ts.map +1 -1
- package/dist/manifest-schemas.js +31 -0
- package/dist/manifest-schemas.js.map +1 -1
- package/dist/module-context.d.ts +11 -1
- package/dist/module-context.d.ts.map +1 -1
- package/dist/module-context.js +6 -0
- package/dist/module-context.js.map +1 -1
- package/dist/resource-context.d.ts +2 -1
- package/dist/resource-context.d.ts.map +1 -1
- package/dist/resource-context.js +6 -1
- package/dist/resource-context.js.map +1 -1
- package/dist/runtime-registry.d.ts +50 -0
- package/dist/runtime-registry.d.ts.map +1 -0
- package/dist/runtime-registry.js +140 -0
- package/dist/runtime-registry.js.map +1 -0
- package/package.json +4 -4
- package/src/controller-loader.ts +77 -273
- package/src/controller-loaders/napi-loader.ts +191 -0
- package/src/controller-loaders/npm-loader.ts +285 -0
- package/src/controller-registry.ts +66 -129
- package/src/controllers/module/import-controller.ts +30 -1
- package/src/controllers/resource-definition/abstract-controller.ts +56 -0
- package/src/controllers/resource-definition/resource-definition-controller.ts +1 -0
- package/src/kernel.ts +43 -13
- package/src/manifest-schemas.ts +33 -0
- package/src/module-context.ts +22 -1
- package/src/resource-context.ts +8 -1
- package/src/runtime-registry.ts +170 -0
package/src/controller-loader.ts
CHANGED
|
@@ -1,301 +1,105 @@
|
|
|
1
1
|
import { ControllerInstance, RuntimeError } from "@telorun/sdk";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
28
|
-
|
|
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
|
-
"
|
|
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
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
|
141
|
-
|
|
142
|
-
|
|
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 (
|
|
202
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
+
}
|