perf: resolve native esm plugin sdk imports

This commit is contained in:
Peter Steinberger
2026-05-29 23:37:57 +01:00
parent d7354d61b2
commit 41a92ae445
6 changed files with 282 additions and 9 deletions

View File

@@ -1,3 +1,4 @@
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
@@ -41,6 +42,11 @@ function stubPluginModuleLoaderJitiFactory(createJiti: PluginModuleLoaderFactory
)[pluginModuleLoaderJitiFactoryOverrideKey] = createJiti;
}
function writeJson(targetPath: string, value: unknown): void {
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
fs.writeFileSync(targetPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
function createApi(registrationMode: PluginRegistrationMode): OpenClawPluginApi {
return {
registrationMode,
@@ -496,6 +502,72 @@ describe("loadBundledEntryExportSync", () => {
await expectBuiltArtifactNodeRequireFastPath("dist-runtime-profile-fast-path", "dist-runtime");
});
it("keeps compiled ESM sidecars with SDK imports on the nodeRequire fast-path", async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-channel-entry-contract-"));
tempDirs.push(tempRoot);
const probePath = path.join(tempRoot, "probe.mjs");
const channelEntryContractModuleUrl = pathToFileURL(
path.join(process.cwd(), "src", "plugin-sdk", "channel-entry-contract.ts"),
).href;
writeJson(path.join(tempRoot, "package.json"), {
name: "openclaw",
type: "module",
bin: { openclaw: "./openclaw.mjs" },
exports: {
"./plugin-sdk": "./dist/plugin-sdk/root-alias.cjs",
"./plugin-sdk/channel-outbound": "./dist/plugin-sdk/channel-outbound.js",
},
});
fs.writeFileSync(path.join(tempRoot, "openclaw.mjs"), "#!/usr/bin/env node\n", "utf8");
fs.mkdirSync(path.join(tempRoot, "dist", "plugin-sdk"), { recursive: true });
fs.writeFileSync(
path.join(tempRoot, "dist", "plugin-sdk", "root-alias.cjs"),
"module.exports = {};\n",
"utf8",
);
fs.writeFileSync(
path.join(tempRoot, "dist", "plugin-sdk", "channel-outbound.js"),
'export const defineChannelMessageAdapter = () => "adapter";\n',
"utf8",
);
const pluginRoot = path.join(tempRoot, "dist", "extensions", "slack");
fs.mkdirSync(pluginRoot, { recursive: true });
const importerPath = path.join(pluginRoot, "index.js");
fs.writeFileSync(importerPath, "export default {};\n", "utf8");
fs.writeFileSync(
path.join(pluginRoot, "sidecar.js"),
'import { defineChannelMessageAdapter } from "openclaw/plugin-sdk/channel-outbound";\nexport const sentinel = defineChannelMessageAdapter();\n',
"utf8",
);
fs.writeFileSync(
probePath,
[
`import { loadBundledEntryExportSync } from ${JSON.stringify(channelEntryContractModuleUrl)};`,
`const value = loadBundledEntryExportSync(${JSON.stringify(pathToFileURL(importerPath).href)}, {`,
' specifier: "./sidecar.js",',
' exportName: "sentinel",',
"});",
"console.log(value);",
"",
].join("\n"),
"utf8",
);
const result = spawnSync(process.execPath, ["--import", "tsx", probePath], {
cwd: process.cwd(),
encoding: "utf8",
env: { ...process.env, OPENCLAW_PLUGIN_LOAD_PROFILE: "1" },
});
expect(result.status).toBe(0);
expect(result.stdout.trim()).toBe("adapter");
expect(result.stderr).toMatch(/sourceLoaderCreateMs=0(?:\.0+)?(?:\s|$)/u);
expect(result.stderr).toMatch(/sourceLoaderCallMs=0(?:\.0+)?(?:\s|$)/u);
});
it("can disable source-tree fallback for dist bundled entry checks", () => {
stubPluginModuleLoaderJitiFactory(
vi.fn(() => vi.fn(() => ({ sentinel: 42 }))) as unknown as PluginModuleLoaderFactory,

View File

@@ -1,5 +1,4 @@
import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { emptyChannelConfigSchema } from "../channels/plugins/config-schema.js";
@@ -9,6 +8,7 @@ import type { ChannelLegacyStateMigrationPlan } from "../channels/plugins/types.
import type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { openRootFileSync } from "../infra/boundary-file-read.js";
import { tryNativeRequireJavaScriptModule } from "../plugins/native-module-require.js";
import {
createProfiler,
formatPluginLoadProfileLine,
@@ -20,7 +20,7 @@ import {
type PluginModuleLoaderCache,
} from "../plugins/plugin-module-loader-cache.js";
import type { PluginRuntime } from "../plugins/runtime/types.js";
import { resolveLoaderPackageRoot } from "../plugins/sdk-alias.js";
import { buildPluginLoaderAliasMap, resolveLoaderPackageRoot } from "../plugins/sdk-alias.js";
import type {
AnyAgentTool,
OpenClawPluginApi,
@@ -144,7 +144,6 @@ export type BundledEntryModuleLoadOptions = {
createLoaderForTest?: PluginModuleLoaderFactory;
};
const nodeRequire = createRequire(import.meta.url);
const moduleLoaders: PluginModuleLoaderCache = new Map();
const entryBoundaryInfoCache = new Map<string, BundledEntryBoundaryInfo>();
const resolvedModulePaths = new Map<string, string>();
@@ -413,9 +412,15 @@ function loadBundledEntryModuleSync(
const loadStartMs = profile ? performance.now() : 0;
let sourceLoaderReadyMs = 0;
if (canTryNodeRequireBuiltModule(modulePath)) {
try {
loaded = nodeRequire(modulePath);
} catch {
const native = tryNativeRequireJavaScriptModule(modulePath, {
allowWindows: true,
aliasMap: buildPluginLoaderAliasMap(modulePath, process.argv[1], import.meta.url, "dist"),
fallbackOnMissingDependency: true,
fallbackOnNativeError: true,
});
if (native.ok) {
loaded = native.moduleExport;
} else {
const moduleLoader = getSourceModuleLoader(modulePath, options);
sourceLoaderReadyMs = profile ? performance.now() : 0;
loaded = moduleLoader(toSafeImportPath(modulePath));
@@ -437,7 +442,7 @@ function loadBundledEntryModuleSync(
pluginId: "(bundled-entry)",
source: modulePath,
elapsedMs: endMs - loadStartMs,
// When the built-artifact fast path resolves via `nodeRequire`, the
// When the built-artifact fast path resolves natively, the
// source-loader timestamp stays `0`; keep its breakdown at zero so
// `elapsedMs=` owns the native load time.
extras: [

View File

@@ -1,6 +1,8 @@
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import {
clearNativeRequireJavaScriptModuleCache,
@@ -78,6 +80,51 @@ describe("tryNativeRequireJavaScriptModule", () => {
).toEqual({ ok: false });
});
it("loads native ESM graphs with temporary SDK aliases", () => {
const dir = makeTempDir();
const sdkPath = path.join(dir, "sdk.js");
const modulePath = path.join(dir, "plugin.mjs");
const probePath = path.join(dir, "probe.mjs");
const nativeRequireModuleUrl = pathToFileURL(
path.join(process.cwd(), "src", "plugins", "native-module-require.ts"),
).href;
fs.writeFileSync(
sdkPath,
'export const defineChannelMessageAdapter = () => "adapter";\n',
"utf8",
);
fs.writeFileSync(
modulePath,
'import { defineChannelMessageAdapter } from "openclaw/plugin-sdk/channel-outbound";\nexport const marker = defineChannelMessageAdapter();\n',
"utf8",
);
fs.writeFileSync(
probePath,
[
`import { tryNativeRequireJavaScriptModule } from ${JSON.stringify(nativeRequireModuleUrl)};`,
`const result = tryNativeRequireJavaScriptModule(${JSON.stringify(modulePath)}, {`,
" allowWindows: true,",
` aliasMap: { "openclaw/plugin-sdk/channel-outbound": ${JSON.stringify(sdkPath)} },`,
"});",
"if (!result.ok) {",
' throw new Error("native require declined ESM graph");',
"}",
"console.log(result.moduleExport.marker);",
"",
].join("\n"),
"utf8",
);
const result = spawnSync(process.execPath, ["--import", "tsx", probePath], {
cwd: process.cwd(),
encoding: "utf8",
});
expect(result.stderr).toBe("");
expect(result.status).toBe(0);
expect(result.stdout.trim()).toBe("adapter");
});
it("declines missing dependency errors when the caller can use source transform fallback", () => {
const dir = makeTempDir();
const modulePath = path.join(dir, "plugin.cjs");

View File

@@ -2,6 +2,7 @@ import fs from "node:fs";
import { createRequire } from "node:module";
import Module from "node:module";
import path from "node:path";
import { pathToFileURL } from "node:url";
const nodeRequire = createRequire(import.meta.url);
type ResolveFilename = (
@@ -12,6 +13,18 @@ type ResolveFilename = (
) => string;
const moduleWithResolver = Module as typeof Module & {
_resolveFilename?: ResolveFilename;
registerHooks?: (options: {
resolve?: (
specifier: string,
context: { parentURL?: string | undefined },
nextResolve: (
specifier: string,
context?: { parentURL?: string | undefined },
) => {
url: string;
},
) => { shortCircuit?: boolean; url: string };
}) => { deregister: () => void };
};
export function isJavaScriptModulePath(modulePath: string): boolean {
@@ -141,6 +154,18 @@ export function withNativeRequireAliases<T>(
return run();
}
const originalResolveFilename = moduleWithResolver["_resolveFilename"];
const esmHooks = moduleWithResolver.registerHooks?.({
resolve(specifier, context, nextResolve) {
const aliasTarget = aliasMap[specifier];
if (aliasTarget) {
return {
shortCircuit: true,
url: pathToFileURL(aliasTarget).href,
};
}
return nextResolve(specifier, context);
},
});
moduleWithResolver["_resolveFilename"] = ((request, parent, isMain, options) => {
const aliasTarget = aliasMap[request];
if (aliasTarget) {
@@ -152,5 +177,6 @@ export function withNativeRequireAliases<T>(
return run();
} finally {
moduleWithResolver["_resolveFilename"] = originalResolveFilename;
esmHooks?.deregister();
}
}

View File

@@ -1,7 +1,9 @@
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import {
installOpenClawPluginSdkNativeResolver,
@@ -122,6 +124,80 @@ describe("installOpenClawPluginSdkNativeResolver", () => {
}
});
it("keeps SDK aliases available for native ESM lazy imports", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sdk-native-esm-resolver-"));
const probePath = path.join(root, "probe.mjs");
const resolverModuleUrl = pathToFileURL(
path.join(process.cwd(), "src", "plugins", "plugin-sdk-native-resolver.ts"),
).href;
fs.writeFileSync(
probePath,
[
'import fs from "node:fs";',
'import path from "node:path";',
'import { pathToFileURL } from "node:url";',
`import { installOpenClawPluginSdkNativeResolver, resetOpenClawPluginSdkNativeResolverForTest } from ${JSON.stringify(resolverModuleUrl)};`,
`const root = ${JSON.stringify(root)};`,
"const writeJson = (targetPath, value) => {",
" fs.mkdirSync(path.dirname(targetPath), { recursive: true });",
' fs.writeFileSync(targetPath, `${JSON.stringify(value, null, 2)}\\n`, "utf8");',
"};",
'writeJson(path.join(root, "package.json"), {',
' name: "openclaw",',
' type: "module",',
' bin: { openclaw: "./openclaw.mjs" },',
" exports: {",
' "./plugin-sdk": "./dist/plugin-sdk/root-alias.cjs",',
' "./plugin-sdk/channel-outbound": "./dist/plugin-sdk/channel-outbound.js",',
" },",
"});",
'fs.writeFileSync(path.join(root, "openclaw.mjs"), "#!/usr/bin/env node\\n", "utf8");',
'fs.mkdirSync(path.join(root, "dist", "plugin-sdk"), { recursive: true });',
'fs.writeFileSync(path.join(root, "dist", "plugin-sdk", "root-alias.cjs"), "module.exports = {};\\n", "utf8");',
'fs.writeFileSync(path.join(root, "dist", "plugin-sdk", "channel-outbound.js"), "export const defineChannelMessageAdapter = () => \\"adapter\\";\\n", "utf8");',
'const loaderModulePath = path.join(root, "dist", "plugins", "loader.js");',
"fs.mkdirSync(path.dirname(loaderModulePath), { recursive: true });",
'fs.writeFileSync(loaderModulePath, "export default {};\\n", "utf8");',
'const pluginRoot = path.join(root, "external-plugin");',
'writeJson(path.join(pluginRoot, "package.json"), { name: "external-plugin", type: "module" });',
'const entryPath = path.join(pluginRoot, "dist", "runtime-api.js");',
'const lazyPath = path.join(pluginRoot, "dist", "lazy.js");',
"fs.mkdirSync(path.dirname(entryPath), { recursive: true });",
"fs.writeFileSync(",
" entryPath,",
' "import { defineChannelMessageAdapter } from \\"openclaw/plugin-sdk/channel-outbound\\"; export const eager = defineChannelMessageAdapter(); export const loadLazy = () => import(\\"./lazy.js\\");\\n",',
' "utf8",',
");",
"fs.writeFileSync(",
" lazyPath,",
' "import { defineChannelMessageAdapter } from \\"openclaw/plugin-sdk/channel-outbound\\"; export const lazy = defineChannelMessageAdapter();\\n",',
' "utf8",',
");",
"installOpenClawPluginSdkNativeResolver({",
" modulePath: loaderModulePath,",
" pluginModulePath: entryPath,",
' pluginSdkResolution: "dist",',
"});",
"const module = await import(pathToFileURL(entryPath).href);",
"const lazy = await module.loadLazy();",
"resetOpenClawPluginSdkNativeResolverForTest();",
"console.log(`${module.eager}:${lazy.lazy}`);",
"",
].join("\n"),
"utf8",
);
const result = spawnSync(process.execPath, ["--import", "tsx", probePath], {
cwd: process.cwd(),
encoding: "utf8",
});
fs.rmSync(root, { recursive: true, force: true });
expect(result.stderr).toBe("");
expect(result.status).toBe(0);
expect(result.stdout.trim()).toBe("adapter:adapter");
});
it("does not resolve SDK aliases for parents outside registered plugin roots", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sdk-native-guard-"));
const { loaderModulePath } = writeFakeOpenClawPackage(root);

View File

@@ -1,7 +1,7 @@
import fs from "node:fs";
import Module from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { fileURLToPath, pathToFileURL } from "node:url";
import { buildPluginLoaderAliasMap, type PluginSdkResolutionPreference } from "./sdk-alias.js";
type ResolveFilename = (
@@ -13,6 +13,18 @@ type ResolveFilename = (
type ModuleWithResolver = typeof Module & {
_resolveFilename?: ResolveFilename;
registerHooks?: (options: {
resolve?: (
specifier: string,
context: { parentURL?: string | undefined },
nextResolve: (
specifier: string,
context?: { parentURL?: string | undefined },
) => {
url: string;
},
) => { shortCircuit?: boolean; url: string };
}) => { deregister: () => void };
};
type NativeAliasEntry = {
@@ -35,6 +47,7 @@ const PLUGIN_SDK_PACKAGE_PREFIXES = ["openclaw/plugin-sdk", "@openclaw/plugin-sd
const pluginSdkNativeAliases = new Map<string, NativeAliasEntry[]>();
let installed = false;
let previousResolveFilename: ResolveFilename | undefined;
let esmHooks: { deregister: () => void } | undefined;
function resolveLoaderModulePath(options: InstallOpenClawPluginSdkNativeResolverOptions): string {
return options.modulePath ?? fileURLToPath(options.moduleUrl ?? import.meta.url);
@@ -153,9 +166,29 @@ function isWithinRoot(candidate: string, root: string): boolean {
function resolveAliasTargetForParent(
request: string,
parent: NodeJS.Module | undefined,
): string | undefined {
return resolveAliasTargetForParentPath(request, parent?.filename);
}
function resolveAliasTargetForParentUrl(
request: string,
parentUrl: string | undefined,
): string | undefined {
if (!isPluginSdkAliasSpecifier(request) || !parentUrl?.startsWith("file:")) {
return undefined;
}
try {
return resolveAliasTargetForParentPath(request, fileURLToPath(parentUrl));
} catch {
return undefined;
}
}
function resolveAliasTargetForParentPath(
request: string,
parentFilename: string | undefined,
): string | undefined {
const entries = pluginSdkNativeAliases.get(request);
const parentFilename = parent?.filename;
if (!entries || !parentFilename) {
return undefined;
}
@@ -201,6 +234,18 @@ function installResolver(): void {
}
return previousResolveFilename?.(request, parent, isMain, options) ?? request;
}) satisfies ResolveFilename;
esmHooks = moduleWithResolver.registerHooks?.({
resolve(specifier, context, nextResolve) {
const aliasTarget = resolveAliasTargetForParentUrl(specifier, context.parentURL);
if (aliasTarget) {
return {
shortCircuit: true,
url: pathToFileURL(aliasTarget).href,
};
}
return nextResolve(specifier, context);
},
});
installed = true;
}
@@ -236,6 +281,8 @@ export function installOpenClawPluginSdkNativeResolver(
export function resetOpenClawPluginSdkNativeResolverForTest(): void {
pluginSdkNativeAliases.clear();
esmHooks?.deregister();
esmHooks = undefined;
if (installed && previousResolveFilename) {
moduleWithResolver[nodeResolveFilenameProperty] = previousResolveFilename;
}