Author SHA1 Message Date
jochen 5d395c5473 One SDK per runtime: a bundle's SDK import resolves to the runtime's copy (hq issue 209)
A bundle carries its dependencies, the SDK among them; imported in-process that copy was a
second SDK with its own tool registry, so a bundle registered its tools into a list the
runtime never read and served nothing, silently. A resolve hook (module.registerHooks, in
thread; module.register is deprecated from Node 26) now sends every import of
@novox/mesh-sdk, from whichever bundle, to the runtime's own copy: one registry, one broker.
The test loads a bundle from a directory holding its own SDK copy and sees its tool served.
2026-10-03 12:49:41 +02:00
2 changed files with 72 additions and 0 deletions
+32
View File
@@ -11,6 +11,7 @@
import { pathToFileURL } from "node:url"; import { pathToFileURL } from "node:url";
import { resolve } from "node:path"; import { resolve } from "node:path";
import { registerHooks } from "node:module";
import { useBroker } from "@novox/mesh-sdk/messaging"; import { useBroker } from "@novox/mesh-sdk/messaging";
import { collectTools, toolKey, type ToolDefinition } from "@novox/mesh-sdk/tools"; import { collectTools, toolKey, type ToolDefinition } from "@novox/mesh-sdk/tools";
import type { Broker } from "@novox/mesh-sdk/messaging"; import type { Broker } from "@novox/mesh-sdk/messaging";
@@ -109,6 +110,9 @@ export async function runTools(opts: RuntimeOptions): Promise<() => void> {
// to the module whose bundle made it. // to the module whose bundle made it.
// A bundle that is not plain JavaScript — or is marked executable — is launched as a process // A bundle that is not plain JavaScript — or is marked executable — is launched as a process
// and spoken to over MCP on stdio instead (ADR 0188); what it lists is registered the same way. // and spoken to over MCP on stdio instead (ADR 0188); what it lists is registered the same way.
// Every bundle's import of the SDK resolves to this runtime's copy (04-ISSUES/209): one registry
// of tools, one broker. Installed before the first bundle is imported.
oneSdk();
const failed = new Map<string, string>(); const failed = new Map<string, string>();
const owner: string[] = []; // registration index → the module whose bundle registered it const owner: string[] = []; // registration index → the module whose bundle registered it
const launched: { module: string; owner: string; tools: ToolDefinition[] }[] = []; const launched: { module: string; owner: string; tools: ToolDefinition[] }[] = [];
@@ -313,3 +317,31 @@ async function serveClaimedSeats(
if (typeof broker.onMembership === "function") broker.onMembership(() => void serve()); if (typeof broker.onMembership === "function") broker.onMembership(() => void serve());
return () => stops.forEach((s) => s()); return () => stops.forEach((s) => s());
} }
let sdkHooked = false;
const SDK = "@novox/mesh-sdk";
/**
* The one SDK in a node's runtime (novox/hq ADR 0175, 04-ISSUES/209).
*
* A bundle carries its own dependencies — the toolchain copies them in so a bundle starts anywhere
* (to-be 38 WP3) — and among them is a copy of the SDK. Imported in this process, that copy would be
* a second SDK: its own registry of tools, its own broker handle. A bundle calling registerModuleTools
* through it registers into a list this runtime never reads, and its tools are silently not served.
* So every import of the SDK, from whichever bundle, is resolved as if this runtime had written it:
* one registry, one broker — the runtime's. Everything else a bundle carries resolves from the
* bundle's own tree, as before. A launched bundle (ADR 0188) is another process and is untouched.
*
* Installed once, in-thread, before the first bundle is imported; the hook sees every import after.
*/
function oneSdk(): void {
if (sdkHooked) return;
sdkHooked = true;
registerHooks({
resolve(specifier, context, next) {
if (specifier === SDK || specifier.startsWith(SDK + "/")) {
return next(specifier, { ...context, parentURL: import.meta.url });
}
return next(specifier, context);
},
});
}
+40
View File
@@ -11,6 +11,9 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { test } from "node:test"; import { test } from "node:test";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { cpSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { connect, StringCodec } from "nats"; import { connect, StringCodec } from "nats";
import { resetTools } from "@novox/mesh-sdk/tools"; import { resetTools } from "@novox/mesh-sdk/tools";
@@ -192,3 +195,40 @@ test("the node's runtime serves five modules' bundles on one credential — two
resetTools(); resetTools();
} }
}); });
test("a bundle carrying its own copy of the SDK registers into the runtime's registry, and its tools are served (issue 209)", async (t) => {
if (!url) return t.skip("MESH_TEST_NATS unset");
resetTools();
// A bundle as the toolchain packs one: its compiled entrypoint, a package.json saying ES modules,
// and its dependencies copied in — the SDK among them, a second copy beside the runtime's own.
const dir = mkdtempSync(join(tmpdir(), "mesh-bundle-"));
const sdk = realpathSync(fileURLToPath(new URL("../node_modules/@novox/mesh-sdk/", import.meta.url)));
cpSync(sdk, join(dir, "node_modules", "@novox", "mesh-sdk"), { recursive: true });
writeFileSync(join(dir, "package.json"), '{"type":"module","private":true}\n');
writeFileSync(join(dir, "index.js"),
'import { registerModuleTools } from "@novox/mesh-sdk/tools";\n' +
'registerModuleTools("zeta", () => [{ name: "probe", description: "answers", input: {}, run: async () => ({ zeta: true }) }]);\n');
const mesh = await aMesh();
await mesh.issue(membershipOf("zeta", "anchor"));
const credential = { url, node: "anchor", module: "node-tools" };
const nodeTools = await connectNats(credential);
const asker = await connectNats({ url, module: "console", node: "workstation" });
const said: string[] = [];
const log = console.log;
console.log = (...a: unknown[]) => said.push(a.join(" "));
let stop = () => {};
try {
stop = await runTools({ broker: nodeTools, credential, serves: [{ module: "zeta", entrypoints: [join(dir, "index.js")] }] });
console.log = log;
assert.ok(said.some((s) => /serving 1 tool\(s\) for 1 module\(s\): zeta\.probe/.test(s)), said.join("\n"));
assert.deepEqual((await callTool(asker, "zeta.probe@anchor", {})).result, { zeta: true });
} finally {
console.log = log;
stop();
await asker.close();
await nodeTools.close();
await mesh.close();
rmSync(dir, { recursive: true, force: true });
resetTools();
}
});