mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-03 18:34:03 +00:00
perf: resolve native esm plugin sdk imports
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user