refactor(plugins): keep bundled runtime boundaries native

This commit is contained in:
Peter Steinberger
2026-05-02 00:09:47 +01:00
parent 543b248c5a
commit c8451947e0
3 changed files with 66 additions and 28 deletions

View File

@@ -4,7 +4,7 @@ import { bundledDistPluginFile } from "openclaw/plugin-sdk/test-fixtures";
import { afterEach, describe, expect, it } from "vitest";
import { stageBundledPluginRuntime } from "../../scripts/stage-bundled-plugin-runtime.mjs";
import type { PluginJitiLoaderCache } from "./jiti-loader-cache.js";
import { loadPluginBoundaryModuleWithJiti } from "./runtime/runtime-plugin-boundary.js";
import { loadPluginBoundaryModule } from "./runtime/runtime-plugin-boundary.js";
import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fixtures.js";
type LightModule = {
@@ -92,13 +92,15 @@ function createBundledWhatsAppRuntimeFixture() {
function loadWhatsAppBoundaryModules(runtimePluginDir: string) {
const loaders: PluginJitiLoaderCache = new Map();
return {
light: loadPluginBoundaryModuleWithJiti<LightModule>(
light: loadPluginBoundaryModule<LightModule>(
path.join(runtimePluginDir, "light-runtime-api.js"),
loaders,
{ origin: "bundled" },
),
heavy: loadPluginBoundaryModuleWithJiti<HeavyModule>(
heavy: loadPluginBoundaryModule<HeavyModule>(
path.join(runtimePluginDir, "runtime-api.js"),
loaders,
{ origin: "bundled" },
),
};
}
@@ -126,4 +128,28 @@ describe("runtime plugin boundary whatsapp seam", () => {
it("shares listener state between staged light and heavy runtime modules", () => {
expectSharedWhatsAppListenerState(createBundledWhatsAppRuntimeFixture(), "work");
});
it("rejects bundled TypeScript runtime modules instead of using the source loader", () => {
const rootDir = makeTrackedTempDir("openclaw-bundled-boundary-ts", tempDirs);
const modulePath = path.join(rootDir, "runtime-api.ts");
writeRuntimeFixtureText(rootDir, "runtime-api.ts", "export const ok = true;\n");
const loaders: PluginJitiLoaderCache = new Map();
expect(() =>
loadPluginBoundaryModule<{ ok: boolean }>(modulePath, loaders, { origin: "bundled" }),
).toThrow(/must be built JavaScript/u);
expect(loaders.size).toBe(0);
});
it("keeps TypeScript source fallback available for non-bundled plugins", () => {
const rootDir = makeTrackedTempDir("openclaw-external-boundary-ts", tempDirs);
const modulePath = path.join(rootDir, "runtime-api.ts");
writeRuntimeFixtureText(rootDir, "runtime-api.ts", "export const ok = true;\n");
const loaders: PluginJitiLoaderCache = new Map();
expect(
loadPluginBoundaryModule<{ ok: boolean }>(modulePath, loaders, { origin: "workspace" }),
).toMatchObject({ ok: true });
expect(loaders.size).toBe(1);
});
});

View File

@@ -3,10 +3,14 @@ import path from "node:path";
import { getRuntimeConfig } from "../../config/config.js";
import { getCachedPluginJitiLoader, type PluginJitiLoaderCache } from "../jiti-loader-cache.js";
import { loadPluginManifestRegistry } from "../manifest-registry.js";
import { shouldPreferNativeJiti } from "../sdk-alias.js";
import {
isJavaScriptModulePath,
tryNativeRequireJavaScriptModule,
} from "../native-module-require.js";
import type { PluginOrigin } from "../plugin-origin.types.js";
type PluginRuntimeRecord = {
origin?: string;
origin?: PluginOrigin;
rootDir?: string;
source: string;
};
@@ -105,21 +109,33 @@ export function resolvePluginRuntimeModulePath(
return null;
}
export function getPluginBoundaryJiti(modulePath: string, loaders: PluginJitiLoaderCache) {
const tryNative = shouldPreferNativeJiti(modulePath);
function getPluginBoundarySourceLoader(modulePath: string, loaders: PluginJitiLoaderCache) {
return getCachedPluginJitiLoader({
cache: loaders,
modulePath,
importerUrl: import.meta.url,
jitiFilename: import.meta.url,
tryNative,
tryNative: false,
});
}
// oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- Dynamic plugin boundary loaders use caller-supplied module types.
export function loadPluginBoundaryModuleWithJiti<TModule>(
export function loadPluginBoundaryModule<TModule>(
modulePath: string,
loaders: PluginJitiLoaderCache,
options: { origin?: PluginOrigin } = {},
): TModule {
return getPluginBoundaryJiti(modulePath, loaders)(modulePath) as TModule;
if (isJavaScriptModulePath(modulePath)) {
const native = tryNativeRequireJavaScriptModule(modulePath, { allowWindows: true });
if (native.ok) {
return native.moduleExport as TModule;
}
if (options.origin === "bundled") {
throw new Error(`bundled plugin runtime module must load natively: ${modulePath}`);
}
} else if (options.origin === "bundled") {
throw new Error(`bundled plugin runtime module must be built JavaScript: ${modulePath}`);
}
return getPluginBoundarySourceLoader(modulePath, loaders)(modulePath) as TModule;
}

View File

@@ -10,7 +10,7 @@ import {
import type { PollInput } from "../../polls.js";
import type { PluginJitiLoaderCache } from "../jiti-loader-cache.js";
import {
loadPluginBoundaryModuleWithJiti,
loadPluginBoundaryModule,
resolvePluginRuntimeRecordByEntryBaseNames,
resolvePluginRuntimeModulePath,
} from "./runtime-plugin-boundary.js";
@@ -132,25 +132,22 @@ function resolveWebChannelRuntimeModulePath(
}
function loadCurrentHeavyModuleSync(): WebChannelHeavyRuntimeModule {
const modulePath = resolveWebChannelRuntimeModulePath(
resolveWebChannelPluginRecord(),
"runtime-api",
);
return loadPluginBoundaryModuleWithJiti<WebChannelHeavyRuntimeModule>(modulePath, jitiLoaders);
const record = resolveWebChannelPluginRecord();
const modulePath = resolveWebChannelRuntimeModulePath(record, "runtime-api");
return loadPluginBoundaryModule<WebChannelHeavyRuntimeModule>(modulePath, jitiLoaders, {
origin: record.origin,
});
}
function loadWebChannelLightModule(): WebChannelLightRuntimeModule {
const modulePath = resolveWebChannelRuntimeModulePath(
resolveWebChannelPluginRecord(),
"light-runtime-api",
);
const record = resolveWebChannelPluginRecord();
const modulePath = resolveWebChannelRuntimeModulePath(record, "light-runtime-api");
if (cachedLightModule && cachedLightModulePath === modulePath) {
return cachedLightModule;
}
const loaded = loadPluginBoundaryModuleWithJiti<WebChannelLightRuntimeModule>(
modulePath,
jitiLoaders,
);
const loaded = loadPluginBoundaryModule<WebChannelLightRuntimeModule>(modulePath, jitiLoaders, {
origin: record.origin,
});
cachedLightModulePath = modulePath;
cachedLightModule = loaded;
return loaded;
@@ -162,10 +159,9 @@ async function loadWebChannelHeavyModule(): Promise<WebChannelHeavyRuntimeModule
if (cachedHeavyModule && cachedHeavyModulePath === modulePath) {
return cachedHeavyModule;
}
const loaded = loadPluginBoundaryModuleWithJiti<WebChannelHeavyRuntimeModule>(
modulePath,
jitiLoaders,
);
const loaded = loadPluginBoundaryModule<WebChannelHeavyRuntimeModule>(modulePath, jitiLoaders, {
origin: record.origin,
});
cachedHeavyModulePath = modulePath;
cachedHeavyModule = loaded;
return loaded;