diff --git a/src/channels/plugins/module-loader.test.ts b/src/channels/plugins/module-loader.test.ts index 3447b95f3cd..4ee33f12d26 100644 --- a/src/channels/plugins/module-loader.test.ts +++ b/src/channels/plugins/module-loader.test.ts @@ -92,7 +92,7 @@ describe("channel plugin module loader helpers", () => { expect(createJiti).not.toHaveBeenCalled(); }); - it("keeps Windows dist loads off Jiti native import", async () => { + it("uses native Jiti import for Windows dist loads", async () => { const createJiti = vi.fn(() => vi.fn(() => ({ ok: true }))); vi.doMock("jiti", () => ({ createJiti, @@ -119,7 +119,7 @@ describe("channel plugin module loader helpers", () => { expect(createJiti).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ - tryNative: false, + tryNative: true, }), ); } finally { diff --git a/src/plugin-sdk/facade-loader.test.ts b/src/plugin-sdk/facade-loader.test.ts index ffc4b21bd3a..cba63c0a96d 100644 --- a/src/plugin-sdk/facade-loader.test.ts +++ b/src/plugin-sdk/facade-loader.test.ts @@ -108,7 +108,7 @@ describe("plugin-sdk facade loader", () => { expect(listImportedFacadeRuntimeIds()).toEqual(["demo"]); }); - it("keeps Windows dist facade loads off Jiti native import", () => { + it("uses native Jiti import for Windows dist facade loads", () => { const dir = createTempDirSync("openclaw-facade-loader-windows-dist-"); const bundledPluginsDir = path.join(dir, "dist"); fs.mkdirSync(path.join(bundledPluginsDir, "demo"), { recursive: true }); @@ -139,7 +139,7 @@ describe("plugin-sdk facade loader", () => { expect(createJitiCalls[0]?.[0]).toEqual(expect.any(String)); expect(createJitiCalls[0]?.[1]).toEqual( expect.objectContaining({ - tryNative: false, + tryNative: true, }), ); } finally { diff --git a/src/plugins/bundled-runtime-root.ts b/src/plugins/bundled-runtime-root.ts index f1f1cc7260d..60918cf0521 100644 --- a/src/plugins/bundled-runtime-root.ts +++ b/src/plugins/bundled-runtime-root.ts @@ -122,6 +122,7 @@ function prepareBundledPluginRuntimeDistMirror(params: { const mirrorDistRoot = path.join(params.installRoot, "dist"); const mirrorExtensionsRoot = path.join(mirrorDistRoot, "extensions"); fs.mkdirSync(mirrorExtensionsRoot, { recursive: true, mode: 0o755 }); + ensureBundledRuntimeDistPackageJson(mirrorDistRoot); for (const entry of fs.readdirSync(sourceDistRoot, { withFileTypes: true })) { if (entry.name === "extensions") { continue; @@ -145,6 +146,14 @@ function prepareBundledPluginRuntimeDistMirror(params: { return mirrorExtensionsRoot; } +function ensureBundledRuntimeDistPackageJson(mirrorDistRoot: string): void { + const packageJsonPath = path.join(mirrorDistRoot, "package.json"); + if (fs.existsSync(packageJsonPath)) { + return; + } + writeRuntimeJsonFile(packageJsonPath, { type: "module" }); +} + function copyBundledPluginRuntimeRoot(sourceRoot: string, targetRoot: string): void { fs.mkdirSync(targetRoot, { recursive: true, mode: 0o755 }); for (const entry of fs.readdirSync(sourceRoot, { withFileTypes: true })) { diff --git a/src/plugins/doctor-contract-registry.test.ts b/src/plugins/doctor-contract-registry.test.ts index a7c0ec4eade..44d0a3470f1 100644 --- a/src/plugins/doctor-contract-registry.test.ts +++ b/src/plugins/doctor-contract-registry.test.ts @@ -34,7 +34,7 @@ describe("doctor-contract-registry getJiti", () => { clearPluginDoctorContractRegistryCache(); }); - it("disables native jiti loading on Windows for contract-api modules", () => { + it("uses native jiti loading on Windows for contract-api modules", () => { const pluginRoot = makeTempDir(); fs.writeFileSync(path.join(pluginRoot, "contract-api.js"), "export default {};\n", "utf-8"); mocks.loadPluginManifestRegistry.mockReturnValue({ @@ -56,7 +56,7 @@ describe("doctor-contract-registry getJiti", () => { expect(mocks.createJiti.mock.calls[0]?.[0]).toBe(path.join(pluginRoot, "contract-api.js")); expect(mocks.createJiti.mock.calls[0]?.[1]).toEqual( expect.objectContaining({ - tryNative: false, + tryNative: true, }), ); }); diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 0358bb118e8..153d73bbfd6 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -1582,7 +1582,11 @@ module.exports = { name: "root-support", version: "1.0.0", type: "module", - exports: "./index.js", + exports: { + ".": { + import: "./index.js", + }, + }, }), "utf-8", ); @@ -1602,10 +1606,11 @@ module.exports = { path.join(pluginRoot, "index.js"), [ `import { marker } from "../../manifest-support.js";`, + `import externalRuntime from "external-runtime";`, `export default {`, ` id: "alpha",`, ` register(api) {`, - ` api.registerCommand({ name: "root-support", handler: () => marker });`, + ` api.registerCommand({ name: "root-support", handler: () => [marker, externalRuntime.marker].join(":") });`, ` },`, `};`, "", @@ -1619,6 +1624,9 @@ module.exports = { name: "@openclaw/alpha", version: "1.0.0", type: "module", + dependencies: { + "external-runtime": "1.0.0", + }, openclaw: { extensions: ["./index.js"] }, }, null, @@ -1650,6 +1658,29 @@ module.exports = { registry = loadOpenClawPlugins({ cache: false, config: { plugins: { enabled: true } }, + bundledRuntimeDepsInstaller: ({ installRoot }) => { + const depRoot = path.join(installRoot, "node_modules", "external-runtime"); + fs.mkdirSync(depRoot, { recursive: true }); + fs.writeFileSync( + path.join(depRoot, "package.json"), + JSON.stringify({ + name: "external-runtime", + version: "1.0.0", + type: "module", + exports: { + ".": { + import: "./index.js", + }, + }, + }), + "utf-8", + ); + fs.writeFileSync( + path.join(depRoot, "index.js"), + "export default { marker: 'external-ok' };\n", + "utf-8", + ); + }, }); } finally { symlinkSync.mockRestore(); diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 70772972258..13f5d8d5983 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -120,6 +120,7 @@ import { resolvePluginSdkAliasFile, resolvePluginRuntimeModulePath, resolvePluginSdkScopedAliasMap, + normalizeJitiAliasTargetPath, shouldPreferNativeJiti, } from "./sdk-alias.js"; import { hasKind, kindsEqual } from "./slots.js"; @@ -257,7 +258,7 @@ export function clearPluginLoaderCache(): void { inFlightPluginRegistryLoads.clear(); openAllowlistWarningCache.clear(); clearBundledRuntimeDependencyNodePaths(); - registeredBundledRuntimeDepMirrorRoots.clear(); + bundledRuntimeDependencyJitiAliases.clear(); clearAgentHarnesses(); clearPluginCommands(); clearCompactionProviders(); @@ -457,16 +458,133 @@ function toSafeImportPath(specifier: string): string { return specifier; } +type RuntimeDependencyPackageJson = { + dependencies?: Record; + optionalDependencies?: Record; + peerDependencies?: Record; + exports?: unknown; + module?: string; + main?: string; +}; + +const bundledRuntimeDependencyJitiAliases = new Map(); + +function readRuntimeDependencyPackageJson( + packageJsonPath: string, +): RuntimeDependencyPackageJson | null { + try { + return JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) as RuntimeDependencyPackageJson; + } catch { + return null; + } +} + +function collectRuntimeDependencyNames(pkg: RuntimeDependencyPackageJson): string[] { + return [ + ...Object.keys(pkg.dependencies ?? {}), + ...Object.keys(pkg.optionalDependencies ?? {}), + ...Object.keys(pkg.peerDependencies ?? {}), + ].toSorted((left, right) => left.localeCompare(right)); +} + +function resolveRuntimePackageImportTarget(exportsField: unknown): string | null { + if (typeof exportsField === "string") { + return exportsField; + } + if (Array.isArray(exportsField)) { + for (const entry of exportsField) { + const resolved = resolveRuntimePackageImportTarget(entry); + if (resolved) { + return resolved; + } + } + return null; + } + if (!exportsField || typeof exportsField !== "object" || Array.isArray(exportsField)) { + return null; + } + const record = exportsField as Record; + if (Object.prototype.hasOwnProperty.call(record, ".")) { + return resolveRuntimePackageImportTarget(record["."]); + } + for (const condition of ["import", "node", "default"] as const) { + const resolved = resolveRuntimePackageImportTarget(record[condition]); + if (resolved) { + return resolved; + } + } + return null; +} + +function registerBundledRuntimeDependencyJitiAliases(rootDir: string): void { + const rootPackageJson = readRuntimeDependencyPackageJson(path.join(rootDir, "package.json")); + if (!rootPackageJson) { + return; + } + for (const dependencyName of collectRuntimeDependencyNames(rootPackageJson)) { + const dependencyPackageJsonPath = path.join( + rootDir, + "node_modules", + ...dependencyName.split("/"), + "package.json", + ); + const dependencyPackageJson = readRuntimeDependencyPackageJson(dependencyPackageJsonPath); + if (!dependencyPackageJson) { + continue; + } + const entry = + resolveRuntimePackageImportTarget(dependencyPackageJson.exports) ?? + dependencyPackageJson.module ?? + dependencyPackageJson.main; + if (!entry || entry.startsWith("#")) { + continue; + } + const dependencyRoot = path.dirname(dependencyPackageJsonPath); + const targetPath = path.resolve(dependencyRoot, entry); + if (!isPathInside(dependencyRoot, targetPath) || !fs.existsSync(targetPath)) { + continue; + } + bundledRuntimeDependencyJitiAliases.set( + dependencyName, + normalizeJitiAliasTargetPath(targetPath), + ); + } +} + +function resolveBundledRuntimeDependencyJitiAliasMap(): Record | undefined { + if (bundledRuntimeDependencyJitiAliases.size === 0) { + return undefined; + } + return Object.fromEntries( + [...bundledRuntimeDependencyJitiAliases.entries()].toSorted(([left], [right]) => + left.localeCompare(right), + ), + ); +} + function createPluginJitiLoader(options: Pick) { const jitiLoaders: PluginJitiLoaderCache = new Map(); return (modulePath: string) => { - const tryNative = - shouldPreferNativeJiti(modulePath) && !isBundledRuntimeDependencyMirrorPath(modulePath); + const tryNative = shouldPreferNativeJiti(modulePath); + const runtimeAliasMap = resolveBundledRuntimeDependencyJitiAliasMap(); return getCachedPluginJitiLoader({ cache: jitiLoaders, modulePath, importerUrl: import.meta.url, jitiFilename: modulePath, + ...(runtimeAliasMap + ? { + aliasMap: { + ...buildPluginLoaderAliasMap( + modulePath, + process.argv[1], + import.meta.url, + options.pluginSdkResolution, + ), + ...runtimeAliasMap, + }, + } + : {}), pluginSdkResolution: options.pluginSdkResolution, // Source .ts runtime shims import sibling ".js" specifiers that only exist // after build. Disable native loading for source entries so Jiti rewrites @@ -487,25 +605,6 @@ function resolveCanonicalDistRuntimeSource(source: string): string { return fs.existsSync(candidate) ? candidate : source; } -const registeredBundledRuntimeDepMirrorRoots = new Set(); - -function isBundledRuntimeDependencyMirrorPath(modulePath: string): boolean { - const resolvedModulePath = path.resolve(modulePath); - for (const installRoot of registeredBundledRuntimeDepMirrorRoots) { - if ( - resolvedModulePath === installRoot || - resolvedModulePath.startsWith(`${installRoot}${path.sep}`) - ) { - return true; - } - } - return false; -} - -function registerBundledRuntimeDependencyMirrorRoot(installRoot: string): void { - registeredBundledRuntimeDepMirrorRoots.add(path.resolve(installRoot)); -} - function mirrorBundledPluginRuntimeRoot(params: { pluginId: string; pluginRoot: string; @@ -551,6 +650,7 @@ function prepareBundledPluginRuntimeDistMirror(params: { const mirrorDistRoot = path.join(params.installRoot, sourceDistRootName); const mirrorExtensionsRoot = path.join(mirrorDistRoot, "extensions"); fs.mkdirSync(mirrorExtensionsRoot, { recursive: true, mode: 0o755 }); + ensureBundledRuntimeDistPackageJson(mirrorDistRoot); for (const entry of fs.readdirSync(sourceDistRoot, { withFileTypes: true })) { if (entry.name === "extensions") { continue; @@ -592,6 +692,14 @@ function prepareBundledPluginRuntimeDistMirror(params: { return mirrorExtensionsRoot; } +function ensureBundledRuntimeDistPackageJson(mirrorDistRoot: string): void { + const packageJsonPath = path.join(mirrorDistRoot, "package.json"); + if (fs.existsSync(packageJsonPath)) { + return; + } + writeRuntimeJsonFile(packageJsonPath, { type: "module" }); +} + function copyBundledPluginRuntimeRoot(sourceRoot: string, targetRoot: string): void { fs.mkdirSync(targetRoot, { recursive: true, mode: 0o755 }); for (const entry of fs.readdirSync(sourceRoot, { withFileTypes: true })) { @@ -2358,9 +2466,10 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi const packageRoot = resolveBundledRuntimeDependencyPackageRoot(pluginRoot); if (packageRoot) { registerBundledRuntimeDependencyNodePath(packageRoot); + registerBundledRuntimeDependencyJitiAliases(packageRoot); } registerBundledRuntimeDependencyNodePath(installRoot); - registerBundledRuntimeDependencyMirrorRoot(installRoot); + registerBundledRuntimeDependencyJitiAliases(installRoot); runtimePluginRoot = mirrorBundledPluginRuntimeRoot({ pluginId: record.id, pluginRoot, diff --git a/src/plugins/public-surface-loader.test.ts b/src/plugins/public-surface-loader.test.ts index f1d04c4f87d..fe3587eb3e4 100644 --- a/src/plugins/public-surface-loader.test.ts +++ b/src/plugins/public-surface-loader.test.ts @@ -28,7 +28,7 @@ afterEach(() => { }); describe("bundled plugin public surface loader", () => { - it("keeps Windows dist public artifact loads off Jiti native import", async () => { + it("uses native Jiti import for Windows dist public artifact loads", async () => { const createJiti = vi.fn(() => vi.fn(() => ({ marker: "windows-dist-ok" }))); vi.doMock("jiti", () => ({ createJiti, @@ -56,7 +56,7 @@ describe("bundled plugin public surface loader", () => { expect(createJiti).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ - tryNative: false, + tryNative: true, }), ); } finally { diff --git a/src/plugins/sdk-alias.test.ts b/src/plugins/sdk-alias.test.ts index 474969f9825..3f5ad2ee0cc 100644 --- a/src/plugins/sdk-alias.test.ts +++ b/src/plugins/sdk-alias.test.ts @@ -870,7 +870,7 @@ describe("plugin sdk alias helpers", () => { } }); - it("disables native Jiti loads on Windows even for built JavaScript entries", () => { + it("prefers native Jiti loads on Windows for built JavaScript entries", () => { const originalPlatform = process.platform; Object.defineProperty(process, "platform", { configurable: true, @@ -878,9 +878,9 @@ describe("plugin sdk alias helpers", () => { }); try { - expect(shouldPreferNativeJiti("/repo/dist/plugins/runtime/index.js")).toBe(false); + expect(shouldPreferNativeJiti("/repo/dist/plugins/runtime/index.js")).toBe(true); expect(shouldPreferNativeJiti(`/repo/${bundledDistPluginFile("browser", "index.js")}`)).toBe( - false, + true, ); } finally { Object.defineProperty(process, "platform", { @@ -890,7 +890,7 @@ describe("plugin sdk alias helpers", () => { } }); - it("keeps plugin loader dist shortcuts off on Windows", () => { + it("keeps plugin loader dist shortcuts native on Windows", () => { const originalPlatform = process.platform; Object.defineProperty(process, "platform", { configurable: true, @@ -902,7 +902,7 @@ describe("plugin sdk alias helpers", () => { resolvePluginLoaderJitiTryNative(`/repo/${bundledDistPluginFile("browser", "index.js")}`, { preferBuiltDist: true, }), - ).toBe(false); + ).toBe(true); expect( resolvePluginLoaderJitiTryNative(`/repo/${bundledDistPluginFile("browser", "helper.ts")}`, { preferBuiltDist: true, @@ -918,7 +918,7 @@ describe("plugin sdk alias helpers", () => { it("prefers native jiti for bundled plugin dist .js modules, keeps .ts on aliased path", () => { // Built .js/.mjs/.cjs files under dist/extensions/ should now delegate - // to shouldPreferNativeJiti() — which returns true on Linux/macOS for + // to shouldPreferNativeJiti() — which returns true on Node for // compiled artifacts, avoiding the slow jiti transform path. expect( resolvePluginLoaderJitiTryNative(`/repo/${bundledDistPluginFile("browser", "index.js")}`, { diff --git a/src/plugins/sdk-alias.ts b/src/plugins/sdk-alias.ts index 212537d55f1..26b5aba0d06 100644 --- a/src/plugins/sdk-alias.ts +++ b/src/plugins/sdk-alias.ts @@ -695,7 +695,7 @@ export function buildPluginLoaderJitiOptions(aliasMap: Record) { function supportsNativeJitiRuntime(): boolean { const versions = process.versions as { bun?: string }; - return typeof versions.bun !== "string" && process.platform !== "win32"; + return typeof versions.bun !== "string"; } function isBundledPluginDistModulePath(modulePath: string): boolean { diff --git a/src/plugins/setup-registry.test.ts b/src/plugins/setup-registry.test.ts index 815fa5e2fac..a953e7a9b18 100644 --- a/src/plugins/setup-registry.test.ts +++ b/src/plugins/setup-registry.test.ts @@ -158,7 +158,7 @@ describe("setup-registry getJiti", () => { clearPluginSetupRegistryCache(); }); - it("disables native jiti loading on Windows for setup-api modules", () => { + it("uses native jiti loading on Windows for setup-api modules", () => { const pluginRoot = makeTempDir(); fs.writeFileSync(path.join(pluginRoot, "setup-api.js"), "export default {};\n", "utf-8"); mocks.loadPluginManifestRegistry.mockReturnValue({ @@ -180,7 +180,7 @@ describe("setup-registry getJiti", () => { expect(mocks.createJiti.mock.calls[0]?.[0]).toBe(path.join(pluginRoot, "setup-api.js")); expect(mocks.createJiti.mock.calls[0]?.[1]).toEqual( expect.objectContaining({ - tryNative: false, + tryNative: true, }), ); });