diff --git a/src/plugin-sdk/channel-entry-contract.test.ts b/src/plugin-sdk/channel-entry-contract.test.ts index 0f432de9548..a525af4fe78 100644 --- a/src/plugin-sdk/channel-entry-contract.test.ts +++ b/src/plugin-sdk/channel-entry-contract.test.ts @@ -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, diff --git a/src/plugin-sdk/channel-entry-contract.ts b/src/plugin-sdk/channel-entry-contract.ts index 3c6a7f7dc1b..74b8aa48930 100644 --- a/src/plugin-sdk/channel-entry-contract.ts +++ b/src/plugin-sdk/channel-entry-contract.ts @@ -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(); const resolvedModulePaths = new Map(); @@ -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: [ diff --git a/src/plugins/native-module-require.test.ts b/src/plugins/native-module-require.test.ts index b37c02c4c84..b2177912665 100644 --- a/src/plugins/native-module-require.test.ts +++ b/src/plugins/native-module-require.test.ts @@ -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"); diff --git a/src/plugins/native-module-require.ts b/src/plugins/native-module-require.ts index b6c1315512d..96790e5405c 100644 --- a/src/plugins/native-module-require.ts +++ b/src/plugins/native-module-require.ts @@ -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( 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( return run(); } finally { moduleWithResolver["_resolveFilename"] = originalResolveFilename; + esmHooks?.deregister(); } } diff --git a/src/plugins/plugin-sdk-native-resolver.test.ts b/src/plugins/plugin-sdk-native-resolver.test.ts index ebdd330526d..d7c32ba9f7f 100644 --- a/src/plugins/plugin-sdk-native-resolver.test.ts +++ b/src/plugins/plugin-sdk-native-resolver.test.ts @@ -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); diff --git a/src/plugins/plugin-sdk-native-resolver.ts b/src/plugins/plugin-sdk-native-resolver.ts index 48b94d42333..8467692c74a 100644 --- a/src/plugins/plugin-sdk-native-resolver.ts +++ b/src/plugins/plugin-sdk-native-resolver.ts @@ -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(); 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; }