refactor: simplify plugin module loading

This commit is contained in:
Peter Steinberger
2026-05-02 01:41:05 +01:00
parent f6f8e6e242
commit 23fd8a90f9
39 changed files with 704 additions and 527 deletions

View File

@@ -66,6 +66,7 @@ vi.mock("../../channels/plugins/config-writes.js", () => ({
}));
vi.mock("../../channels/registry.js", () => ({
normalizeAnyChannelId: vi.fn((value?: string) => value),
normalizeChannelId: vi.fn((value?: string) => value),
}));

View File

@@ -120,7 +120,7 @@ export function listPotentialConfiguredChannelPresenceSignals(
signals.push({ channelId, source });
};
const configuredChannelIds = new Set<string>();
const channelIds = listBundledChannelPluginIds();
const channelIds = listBundledChannelPluginIds(env);
const channelEnvPrefixes = listChannelEnvPrefixes(channelIds);
const channels = isRecord(cfg.channels) ? cfg.channels : null;
if (channels) {
@@ -164,7 +164,7 @@ function hasEnvConfiguredChannel(
env: NodeJS.ProcessEnv,
options: ChannelPresenceOptions = {},
): boolean {
const channelIds = listBundledChannelPluginIds();
const channelIds = listBundledChannelPluginIds(env);
const channelEnvPrefixes = listChannelEnvPrefixes(channelIds);
for (const [key, value] of Object.entries(env)) {
if (!hasNonEmptyString(value)) {

View File

@@ -10,6 +10,6 @@ export function listBundledChannelPluginIdsForRoot(
.toSorted((left, right) => left.localeCompare(right));
}
export function listBundledChannelPluginIds(): string[] {
return listBundledChannelPluginIdsForRoot(resolveBundledChannelRootScope().cacheKey);
export function listBundledChannelPluginIds(env: NodeJS.ProcessEnv = process.env): string[] {
return listBundledChannelPluginIdsForRoot(resolveBundledChannelRootScope(env).cacheKey, env);
}

View File

@@ -14,7 +14,7 @@ import {
listReadOnlyChannelPluginsForConfig,
} from "./read-only.js";
const jitiLoaderParams = vi.hoisted(
const moduleLoaderParams = vi.hoisted(
() =>
[] as Array<{
modulePath: string;
@@ -31,8 +31,9 @@ vi.mock("../../plugins/bundled-dir.js", async (importOriginal) => {
};
});
vi.mock("../../plugins/jiti-loader-cache.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../plugins/jiti-loader-cache.js")>();
vi.mock("../../plugins/plugin-module-loader-cache.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../../plugins/plugin-module-loader-cache.js")>();
const { createRequire } = await import("node:module");
const require = createRequire(import.meta.url);
@@ -105,12 +106,12 @@ vi.mock("../../plugins/jiti-loader-cache.js", async (importOriginal) => {
return {
...actual,
getCachedPluginJitiLoader: ((params) => {
jitiLoaderParams.push({
getCachedPluginModuleLoader: ((params) => {
moduleLoaderParams.push({
modulePath: params.modulePath,
tryNative: params.tryNative,
});
const actualLoader = actual.getCachedPluginJitiLoader(params);
const actualLoader = actual.getCachedPluginModuleLoader(params);
return ((modulePath: string) => {
if (
modulePath.endsWith("/plugins/loader.js") ||
@@ -119,8 +120,8 @@ vi.mock("../../plugins/jiti-loader-cache.js", async (importOriginal) => {
return { loadOpenClawPlugins };
}
return actualLoader(modulePath);
}) as ReturnType<typeof actual.getCachedPluginJitiLoader>;
}) satisfies typeof actual.getCachedPluginJitiLoader,
}) as ReturnType<typeof actual.getCachedPluginModuleLoader>;
}) satisfies typeof actual.getCachedPluginModuleLoader,
};
});
@@ -431,7 +432,7 @@ function expectExternalChatSetupOnlyPluginLoaded(params: {
}
afterEach(() => {
jitiLoaderParams.length = 0;
moduleLoaderParams.length = 0;
resetPluginLoaderTestStateForTest();
});
@@ -498,7 +499,7 @@ describe("listReadOnlyChannelPluginsForConfig", () => {
expectExternalChatSetupOnlyPluginLoaded({ plugins, setupMarker, fullMarker });
expect(
jitiLoaderParams.some(
moduleLoaderParams.some(
(entry) =>
entry.tryNative === true &&
(entry.modulePath.endsWith("/plugins/loader.js") ||

View File

@@ -8,11 +8,11 @@ import {
listConfiguredChannelIdsForReadOnlyScope,
resolveDiscoverableScopedChannelPluginIds,
} from "../../plugins/channel-plugin-ids.js";
import {
getCachedPluginJitiLoader,
type PluginJitiLoaderCache,
} from "../../plugins/jiti-loader-cache.js";
import type { PluginManifestRecord } from "../../plugins/manifest-registry.js";
import {
getCachedPluginModuleLoader,
type PluginModuleLoaderCache,
} from "../../plugins/plugin-module-loader-cache.js";
import { loadPluginManifestRegistryForPluginRegistry } from "../../plugins/plugin-registry.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
import { sanitizeForLog } from "../../terminal/ansi.js";
@@ -34,7 +34,7 @@ const BUILT_PLUGIN_LOADER_MODULE_CANDIDATES = [
"plugins/loader.js",
"plugins/build-smoke-entry.js",
] as const;
const jitiLoaders: PluginJitiLoaderCache = new Map();
const moduleLoaders: PluginModuleLoaderCache = new Map();
type PluginLoaderModule = {
loadOpenClawPlugins: (params: {
@@ -93,15 +93,15 @@ function loadPluginLoaderModule(): PluginLoaderModule {
for (const candidate of listPluginLoaderModuleCandidateUrls()) {
const modulePath = fileURLToPath(candidate);
try {
const jiti = getCachedPluginJitiLoader({
cache: jitiLoaders,
const moduleLoader = getCachedPluginModuleLoader({
cache: moduleLoaders,
modulePath,
importerUrl: import.meta.url,
preferBuiltDist: true,
jitiFilename: import.meta.url,
loaderFilename: import.meta.url,
tryNative: true,
});
pluginLoaderModule = jiti(modulePath) as PluginLoaderModule;
pluginLoaderModule = moduleLoader(modulePath) as PluginLoaderModule;
return pluginLoaderModule;
} catch {
// Try built/runtime source candidates in order.

View File

@@ -35,6 +35,7 @@ vi.mock("../../channels/plugins/catalog.js", () => ({
}));
vi.mock("../../channels/registry.js", () => ({
listChatChannels: () => listChatChannels(),
normalizeAnyChannelId: (channelId?: string) => channelId?.trim().toLowerCase() ?? null,
}));
vi.mock("../../plugins/manifest-registry.js", () => ({
loadPluginManifestRegistry: (...a: unknown[]) => loadPluginManifestRegistry(...a),

View File

@@ -12,8 +12,31 @@ import {
makeRegistry,
resetPluginAutoEnableTestState,
} from "./plugin-auto-enable.test-helpers.js";
import type { OpenClawConfig } from "./types.openclaw.js";
import { validateConfigObject } from "./validation.js";
vi.mock("../channels/plugins/configured-state.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../channels/plugins/configured-state.js")>();
return {
...actual,
hasBundledChannelConfiguredState: (params: {
channelId: string;
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}) => {
if (params.channelId === "irc") {
return Boolean(params.env?.IRC_HOST?.trim() && params.env?.IRC_NICK?.trim());
}
if (params.channelId === "slack") {
return ["SLACK_APP_TOKEN", "SLACK_BOT_TOKEN", "SLACK_USER_TOKEN"].some((key) =>
Boolean(params.env?.[key]?.trim()),
);
}
return actual.hasBundledChannelConfiguredState(params);
},
};
});
const env = makeIsolatedEnv();
afterAll(() => {
@@ -93,6 +116,19 @@ describe("applyPluginAutoEnable core", () => {
expect(result.config.plugins?.allow).toBeUndefined();
});
it("does not auto-enable Slack from unrelated Slack-prefixed env vars", () => {
const result = applyPluginAutoEnable({
config: {},
env: makeIsolatedEnv({
SLACK_WEBHOOK_URL: "https://hooks.slack.com/services/T000/B000/XXX",
}),
});
expect(result.config.channels?.slack).toBeUndefined();
expect(result.config.plugins?.entries?.slack).toBeUndefined();
expect(result.changes).toEqual([]);
});
it("stores auto-enable reasons in a null-prototype dictionary", () => {
const result = applyPluginAutoEnable({
config: {

View File

@@ -2,7 +2,7 @@ import { collectConfiguredAgentHarnessRuntimes } from "../agents/harness-runtime
import { normalizeProviderId } from "../agents/provider-id.js";
import {
hasPotentialConfiguredChannels,
listPotentialConfiguredChannelIds,
listPotentialConfiguredChannelPresenceSignals,
} from "../channels/config-presence.js";
import { getChatChannelMeta, normalizeChatChannelId } from "../channels/registry.js";
import {
@@ -294,10 +294,12 @@ function collectPluginIdsForConfiguredChannel(
return [builtInId ?? claims[0]?.plugin.id ?? normalizedChannelId];
}
function collectCandidateChannelIds(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): string[] {
return listPotentialConfiguredChannelIds(cfg, env, { includePersistedAuthState: false }).map(
(channelId) => normalizeChatChannelId(channelId) ?? channelId,
);
function collectConfiguredChannelIds(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): string[] {
return listPotentialConfiguredChannelPresenceSignals(cfg, env, {
includePersistedAuthState: false,
})
.map((signal) => normalizeChatChannelId(signal.channelId) ?? signal.channelId)
.filter((channelId) => isChannelConfigured(cfg, channelId, env));
}
function hasConfiguredWebSearchPluginEntry(cfg: OpenClawConfig): boolean {
@@ -574,11 +576,9 @@ export function resolveConfiguredPluginAutoEnableCandidates(params: {
registry: PluginManifestRegistry;
}): PluginAutoEnableCandidate[] {
const changes: PluginAutoEnableCandidate[] = [];
for (const channelId of collectCandidateChannelIds(params.config, params.env)) {
if (isChannelConfigured(params.config, channelId, params.env)) {
for (const pluginId of collectPluginIdsForConfiguredChannel(channelId, params.registry)) {
changes.push({ pluginId, kind: "channel-configured", channelId });
}
for (const channelId of collectConfiguredChannelIds(params.config, params.env)) {
for (const pluginId of collectPluginIdsForConfiguredChannel(channelId, params.registry)) {
changes.push({ pluginId, kind: "channel-configured", channelId });
}
}

View File

@@ -19,6 +19,7 @@ export function makeIsolatedEnv(overrides: NodeJS.ProcessEnv = {}): NodeJS.Proce
return {
OPENCLAW_STATE_DIR: path.join(rootDir, "state"),
OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(process.cwd(), "extensions"),
OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR: "1",
VITEST: "true",
...overrides,
};

View File

@@ -45,6 +45,7 @@ vi.mock("../channels/registry.js", () => ({
meta: Parameters<FormatChannelSelectionLine>[0],
docsLink: Parameters<FormatChannelSelectionLine>[1],
) => formatChannelSelectionLine(meta, docsLink),
normalizeAnyChannelId: (channelId?: string) => channelId?.trim().toLowerCase() ?? null,
}));
vi.mock("../commands/channel-setup/discovery.js", () => ({

View File

@@ -134,6 +134,8 @@ vi.mock("../channels/plugins/setup-registry.js", () => ({
vi.mock("../channels/registry.js", () => ({
getChatChannelMeta: (channelId: string) => ({ id: channelId, label: channelId }),
listChatChannels: () => [],
normalizeAnyChannelId: (channelId?: unknown) =>
typeof channelId === "string" ? channelId.trim().toLowerCase() || null : null,
normalizeChatChannelId: (channelId?: unknown) =>
typeof channelId === "string" ? channelId.trim().toLowerCase() || null : null,
}));

View File

@@ -213,7 +213,7 @@ async function expectBuiltArtifactNodeRequireFastPath(
const importerPath = path.join(pluginRoot, "index.js");
const sidecarPath = path.join(pluginRoot, "fast-path-sidecar.cjs");
fs.writeFileSync(importerPath, "export default {};\n", "utf8");
// CommonJS so `nodeRequire` succeeds without falling back to jiti, even
// CommonJS so `nodeRequire` succeeds without falling back to the source loader, even
// inside built plugin artifacts with a `type: "module"` package boundary.
fs.writeFileSync(sidecarPath, "module.exports = { sentinel: 7 };\n", "utf8");
@@ -228,10 +228,10 @@ async function expectBuiltArtifactNodeRequireFastPath(
.map((args) => String(args[0] ?? ""))
.find((line) => line.startsWith("[plugin-load-profile] phase=bundled-entry-module-load"));
expect(profileLine, "expected a bundled-entry-module-load profile line").toBeDefined();
expect(profileLine).toMatch(/getJitiMs=\d/u);
expect(profileLine).toMatch(/jitiCallMs=\d/u);
expect(profileLine).not.toMatch(/getJitiMs=-/);
expect(profileLine).not.toMatch(/jitiCallMs=-/);
expect(profileLine).toMatch(/sourceLoaderCreateMs=\d/u);
expect(profileLine).toMatch(/sourceLoaderCallMs=\d/u);
expect(profileLine).not.toMatch(/sourceLoaderCreateMs=-/);
expect(profileLine).not.toMatch(/sourceLoaderCallMs=-/);
} finally {
errorSpy.mockRestore();
}
@@ -267,7 +267,7 @@ describe("loadBundledEntryExportSync", () => {
expect(message).toContain("ENOENT");
});
it("keeps Windows dist sidecar loads off Jiti native import", async () => {
it("keeps Windows dist sidecar loads off source-transform loading", async () => {
const createJiti = vi.fn(() => vi.fn(() => ({ load: 42 })));
vi.doMock("jiti", () => ({
createJiti,
@@ -306,7 +306,7 @@ describe("loadBundledEntryExportSync", () => {
}
});
it("normalizes Windows absolute sidecar paths before Jiti loads them", async () => {
it("normalizes Windows absolute sidecar paths before module loads them", async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-channel-entry-contract-"));
tempDirs.push(tempRoot);
const openedFdPath = path.join(tempRoot, "opened");
@@ -396,10 +396,10 @@ describe("loadBundledEntryExportSync", () => {
});
});
it("emits non-negative jiti sub-step timings on the built-artifact load path", async () => {
it("emits non-negative source-loader sub-step timings on the built-artifact load path", async () => {
// Built artifacts prefer `nodeRequire`, but Node can still reject a sidecar
// and fall back through jiti. The profile line must never report negative
// or missing jiti sub-step timings either way.
// or missing source-loader sub-step timings either way.
await expectBuiltArtifactNodeRequireFastPath("built-artifact-profile-fast-path");
});

View File

@@ -8,15 +8,15 @@ 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 { openBoundaryFileSync } from "../infra/boundary-file-read.js";
import {
getCachedPluginJitiLoader,
type PluginJitiLoaderCache,
} from "../plugins/jiti-loader-cache.js";
import {
createProfiler,
formatPluginLoadProfileLine,
shouldProfilePluginLoader,
} from "../plugins/plugin-load-profile.js";
import {
getCachedPluginSourceModuleLoader,
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 type { AnyAgentTool, OpenClawPluginApi, PluginCommandContext } from "../plugins/types.js";
@@ -125,7 +125,7 @@ export type BundledChannelSetupEntryContract<TPlugin = ChannelPlugin> = {
export type BundledEntryModuleLoadOptions = Record<string, never>;
const nodeRequire = createRequire(import.meta.url);
const jitiLoaders: PluginJitiLoaderCache = new Map();
const moduleLoaders: PluginModuleLoaderCache = new Map();
const loadedModuleExports = new Map<string, unknown>();
const disableBundledEntrySourceFallbackEnv = "OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK";
@@ -319,13 +319,13 @@ function resolveBundledEntryModulePath(importMetaUrl: string, specifier: string)
);
}
function getJiti(modulePath: string) {
return getCachedPluginJitiLoader({
cache: jitiLoaders,
function getSourceModuleLoader(modulePath: string) {
return getCachedPluginSourceModuleLoader({
cache: moduleLoaders,
modulePath,
importerUrl: import.meta.url,
preferBuiltDist: true,
jitiFilename: import.meta.url,
loaderFilename: import.meta.url,
});
}
@@ -352,25 +352,25 @@ function loadBundledEntryModuleSync(
let loaded: unknown;
const profile = shouldProfilePluginLoader();
const loadStartMs = profile ? performance.now() : 0;
let getJitiEndMs = 0;
let sourceLoaderReadyMs = 0;
if (canTryNodeRequireBuiltModule(modulePath)) {
try {
loaded = nodeRequire(modulePath);
} catch {
const jiti = getJiti(modulePath);
getJitiEndMs = profile ? performance.now() : 0;
loaded = jiti(toSafeImportPath(modulePath));
const moduleLoader = getSourceModuleLoader(modulePath);
sourceLoaderReadyMs = profile ? performance.now() : 0;
loaded = moduleLoader(toSafeImportPath(modulePath));
}
} else {
const jiti = getJiti(modulePath);
getJitiEndMs = profile ? performance.now() : 0;
loaded = jiti(toSafeImportPath(modulePath));
const moduleLoader = getSourceModuleLoader(modulePath);
sourceLoaderReadyMs = profile ? performance.now() : 0;
loaded = moduleLoader(toSafeImportPath(modulePath));
}
if (profile) {
const endMs = performance.now();
// Use shared formatter — but split timing fields ourselves so we can
// attribute time spent in `getJiti(...)` factory creation vs the actual
// graph-walking `__j(modulePath)` call. Both are emitted as extras
// attribute time spent in source-loader creation vs the actual graph load.
// Both are emitted as extras
// alongside the canonical `elapsedMs=<total>` field.
console.error(
formatPluginLoadProfileLine({
@@ -378,15 +378,12 @@ function loadBundledEntryModuleSync(
pluginId: "(bundled-entry)",
source: modulePath,
elapsedMs: endMs - loadStartMs,
// When the built-artifact fast-path resolves the module via `nodeRequire`,
// `getJitiEndMs` stays `0` because the `catch` block (the only place
// it gets stamped) never runs. Reporting `getJitiMs` /
// `jitiCallMs` as `0` for that path keeps the breakdown honest:
// `elapsedMs=` already captures the nodeRequire time, and we don't
// want to mis-attribute it to jiti sub-steps.
// When the built-artifact fast path resolves via `nodeRequire`, the
// source-loader timestamp stays `0`; keep its breakdown at zero so
// `elapsedMs=` owns the native load time.
extras: [
["getJitiMs", getJitiEndMs ? getJitiEndMs - loadStartMs : 0],
["jitiCallMs", getJitiEndMs ? endMs - getJitiEndMs : 0],
["sourceLoaderCreateMs", sourceLoaderReadyMs ? sourceLoaderReadyMs - loadStartMs : 0],
["sourceLoaderCallMs", sourceLoaderReadyMs ? endMs - sourceLoaderReadyMs : 0],
],
}),
);

View File

@@ -6,7 +6,7 @@ import {
listImportedBundledPluginFacadeIds,
loadBundledPluginPublicSurfaceModuleSync,
resetFacadeLoaderStateForTest,
setFacadeLoaderJitiFactoryForTest,
setFacadeLoaderSourceTransformFactoryForTest,
} from "./facade-loader.js";
import { listImportedBundledPluginFacadeIds as listImportedFacadeRuntimeIds } from "./facade-runtime.js";
import { createPluginSdkTestHarness } from "./test-helpers.js";
@@ -15,7 +15,9 @@ const { createTempDirSync } = createPluginSdkTestHarness();
const originalBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
const originalDisableBundledPlugins = process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS;
const FACADE_LOADER_GLOBAL = "__openclawTestLoadBundledPluginPublicSurfaceModuleSync";
type FacadeLoaderJitiFactory = NonNullable<Parameters<typeof setFacadeLoaderJitiFactoryForTest>[0]>;
type FacadeLoaderSourceTransformFactory = NonNullable<
Parameters<typeof setFacadeLoaderSourceTransformFactoryForTest>[0]
>;
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const trustedBundledPluginFixtureRoots: string[] = [];
let trustedPluginIdCounter = 0;
@@ -159,7 +161,7 @@ function writeJsonFile(filePath: string, value: unknown): void {
afterEach(() => {
vi.restoreAllMocks();
resetFacadeLoaderStateForTest();
setFacadeLoaderJitiFactoryForTest(undefined);
setFacadeLoaderSourceTransformFactoryForTest(undefined);
for (const dir of trustedBundledPluginFixtureRoots.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
@@ -265,13 +267,13 @@ describe("plugin-sdk facade loader", () => {
});
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = fixture.bundledPluginsDir;
const createJitiCalls: Parameters<FacadeLoaderJitiFactory>[] = [];
setFacadeLoaderJitiFactoryForTest(((...args) => {
const createJitiCalls: Parameters<FacadeLoaderSourceTransformFactory>[] = [];
setFacadeLoaderSourceTransformFactoryForTest(((...args) => {
createJitiCalls.push(args);
return vi.fn(() => ({
marker: "jiti-fallback",
})) as unknown as ReturnType<FacadeLoaderJitiFactory>;
}) as FacadeLoaderJitiFactory);
})) as unknown as ReturnType<FacadeLoaderSourceTransformFactory>;
}) as FacadeLoaderSourceTransformFactory);
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
const restoreVersions = forceNodeRuntimeVersionsForTest();

View File

@@ -5,29 +5,29 @@ import { fileURLToPath, pathToFileURL } from "node:url";
import { openBoundaryFileSync } from "../infra/boundary-file-read.js";
import { resolveBundledPluginsDir } from "../plugins/bundled-dir.js";
import {
getCachedPluginJitiLoader,
type PluginJitiLoaderCache,
type PluginJitiLoaderFactory,
} from "../plugins/jiti-loader-cache.js";
getCachedPluginModuleLoader,
type PluginModuleLoaderCache,
type PluginModuleLoaderFactory,
} from "../plugins/plugin-module-loader-cache.js";
import { resolveLoaderPackageRoot } from "../plugins/sdk-alias.js";
import { resolveBundledFacadeModuleLocation } from "./facade-resolution-shared.js";
const CURRENT_MODULE_PATH = fileURLToPath(import.meta.url);
const nodeRequire = createRequire(import.meta.url);
const jitiLoaders: PluginJitiLoaderCache = new Map();
const moduleLoaders: PluginModuleLoaderCache = new Map();
const loadedFacadeModules = new Map<string, unknown>();
const loadedFacadePluginIds = new Set<string>();
let facadeLoaderJitiFactory: PluginJitiLoaderFactory | undefined;
let facadeLoaderSourceTransformFactory: PluginModuleLoaderFactory | undefined;
let cachedOpenClawPackageRoot: string | undefined;
function getJitiFactory() {
if (facadeLoaderJitiFactory) {
return facadeLoaderJitiFactory;
function getSourceTransformFactory() {
if (facadeLoaderSourceTransformFactory) {
return facadeLoaderSourceTransformFactory;
}
const { createJiti } = nodeRequire("jiti") as typeof import("jiti");
facadeLoaderJitiFactory = createJiti;
return facadeLoaderJitiFactory;
facadeLoaderSourceTransformFactory = createJiti;
return facadeLoaderSourceTransformFactory;
}
function getOpenClawPackageRoot() {
@@ -56,14 +56,14 @@ function resolveFacadeModuleLocation(params: {
});
}
function getJiti(modulePath: string) {
return getCachedPluginJitiLoader({
cache: jitiLoaders,
function getModuleLoader(modulePath: string) {
return getCachedPluginModuleLoader({
cache: moduleLoaders,
modulePath,
importerUrl: import.meta.url,
preferBuiltDist: true,
jitiFilename: import.meta.url,
createLoader: getJitiFactory(),
loaderFilename: import.meta.url,
createLoader: getSourceTransformFactory(),
});
}
@@ -173,7 +173,7 @@ export function loadFacadeModuleAtLocationSync<T extends object>(params: {
try {
loaded =
params.loadModule?.(location.modulePath) ??
(getJiti(location.modulePath)(location.modulePath) as T);
(getModuleLoader(location.modulePath)(location.modulePath) as T);
Object.assign(sentinel, loaded);
loadedFacadePluginIds.add(
typeof params.trackedPluginId === "function"
@@ -264,13 +264,13 @@ export function listImportedBundledPluginFacadeIds(): string[] {
export function resetFacadeLoaderStateForTest(): void {
loadedFacadeModules.clear();
loadedFacadePluginIds.clear();
jitiLoaders.clear();
facadeLoaderJitiFactory = undefined;
moduleLoaders.clear();
facadeLoaderSourceTransformFactory = undefined;
cachedOpenClawPackageRoot = undefined;
}
export function setFacadeLoaderJitiFactoryForTest(
factory: PluginJitiLoaderFactory | undefined,
export function setFacadeLoaderSourceTransformFactoryForTest(
factory: PluginModuleLoaderFactory | undefined,
): void {
facadeLoaderJitiFactory = factory;
facadeLoaderSourceTransformFactory = factory;
}

View File

@@ -3,9 +3,9 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { resolveBundledPluginsDir } from "../plugins/bundled-dir.js";
import {
getCachedPluginJitiLoader,
type PluginJitiLoaderCache,
} from "../plugins/jiti-loader-cache.js";
getCachedPluginSourceModuleLoader,
type PluginModuleLoaderCache,
} from "../plugins/plugin-module-loader-cache.js";
import { resolveLoaderPackageRoot } from "../plugins/sdk-alias.js";
import {
loadBundledPluginPublicSurfaceModuleSync as loadBundledPluginPublicSurfaceModuleSyncLight,
@@ -110,16 +110,15 @@ const FACADE_ACTIVATION_CHECK_RUNTIME_CANDIDATES = [
] as const;
let facadeActivationCheckRuntimeModule: FacadeActivationCheckRuntimeModule | undefined;
const facadeActivationCheckRuntimeJitiLoaders: PluginJitiLoaderCache = new Map();
const facadeActivationCheckRuntimeLoaders: PluginModuleLoaderCache = new Map();
function getFacadeActivationCheckRuntimeJiti(modulePath: string) {
return getCachedPluginJitiLoader({
cache: facadeActivationCheckRuntimeJitiLoaders,
function getFacadeActivationCheckRuntimeSourceLoader(modulePath: string) {
return getCachedPluginSourceModuleLoader({
cache: facadeActivationCheckRuntimeLoaders,
modulePath,
importerUrl: import.meta.url,
jitiFilename: import.meta.url,
loaderFilename: import.meta.url,
aliasMap: {},
tryNative: false,
});
}
@@ -149,7 +148,7 @@ function loadFacadeActivationCheckRuntime(): FacadeActivationCheckRuntimeModule
return facadeActivationCheckRuntimeModule;
}
facadeActivationCheckRuntimeModule = loadFacadeActivationCheckRuntimeFromCandidates((candidate) =>
getFacadeActivationCheckRuntimeJiti(candidate)(candidate),
getFacadeActivationCheckRuntimeSourceLoader(candidate)(candidate),
);
if (facadeActivationCheckRuntimeModule) {
return facadeActivationCheckRuntimeModule;
@@ -246,7 +245,7 @@ export function tryLoadActivatedBundledPluginPublicSurfaceModuleSync<T extends o
export function resetFacadeRuntimeStateForTest(): void {
resetFacadeLoaderStateForTest();
facadeActivationCheckRuntimeModule = undefined;
facadeActivationCheckRuntimeJitiLoaders.clear();
facadeActivationCheckRuntimeLoaders.clear();
}
export const __testing = {

View File

@@ -5,7 +5,7 @@ const fs = require("node:fs");
let monolithicSdk = null;
let diagnosticEventsModule = null;
const jitiLoaders = new Map();
const moduleLoaders = new Map();
const pluginSdkSubpathsCache = new Map();
const pluginSdkPackageNames = ["openclaw/plugin-sdk", "@openclaw/plugin-sdk"];
const pluginSdkSourceExtensions = [".ts", ".mts", ".js", ".mjs", ".cts", ".cjs"];
@@ -194,15 +194,15 @@ function buildPluginSdkAliasMap(useDist) {
return aliasMap;
}
function getJiti(tryNative) {
function getModuleLoader(tryNative) {
const effectiveTryNative = process.platform === "win32" ? false : tryNative;
if (jitiLoaders.has(effectiveTryNative)) {
return jitiLoaders.get(effectiveTryNative);
if (moduleLoaders.has(effectiveTryNative)) {
return moduleLoaders.get(effectiveTryNative);
}
const { createJiti } = require("jiti");
const jitiLoader = createJiti(__filename, {
const moduleLoader = createJiti(__filename, {
alias: buildPluginSdkAliasMap(effectiveTryNative),
interopDefault: true,
// Prefer Node's native sync ESM loader for built dist/plugin-sdk/*.js files
@@ -210,8 +210,8 @@ function getJiti(tryNative) {
tryNative: effectiveTryNative,
extensions: [".ts", ".tsx", ".mts", ".cts", ".mtsx", ".ctsx", ".js", ".mjs", ".cjs", ".json"],
});
jitiLoaders.set(effectiveTryNative, jitiLoader);
return jitiLoader;
moduleLoaders.set(effectiveTryNative, moduleLoader);
return moduleLoader;
}
function loadMonolithicSdk() {
@@ -222,14 +222,16 @@ function loadMonolithicSdk() {
const distCandidate = path.resolve(__dirname, "..", "..", "dist", "plugin-sdk", "compat.js");
if (!shouldPreferSourceGraph && fs.existsSync(distCandidate)) {
try {
monolithicSdk = getJiti(true)(distCandidate);
monolithicSdk = getModuleLoader(true)(distCandidate);
return monolithicSdk;
} catch {
// Fall through to source alias if dist is unavailable or stale.
}
}
monolithicSdk = getJiti(false)(path.join(getPackageRoot(), "src", "plugin-sdk", "compat.ts"));
monolithicSdk = getModuleLoader(false)(
path.join(getPackageRoot(), "src", "plugin-sdk", "compat.ts"),
);
return monolithicSdk;
}
@@ -252,7 +254,9 @@ function loadDiagnosticEventsModule() {
findDistChunkByPrefix("diagnostic-events");
if (distCandidate) {
try {
diagnosticEventsModule = normalizeDiagnosticEventsModule(getJiti(true)(distCandidate));
diagnosticEventsModule = normalizeDiagnosticEventsModule(
getModuleLoader(true)(distCandidate),
);
return diagnosticEventsModule;
} catch {
// Fall through to source path if dist is unavailable or stale.
@@ -261,7 +265,7 @@ function loadDiagnosticEventsModule() {
}
diagnosticEventsModule = normalizeDiagnosticEventsModule(
getJiti(false)(path.join(getPackageRoot(), "src", "infra", "diagnostic-events.ts")),
getModuleLoader(false)(path.join(getPackageRoot(), "src", "infra", "diagnostic-events.ts")),
);
return diagnosticEventsModule;
}

View File

@@ -9,15 +9,18 @@ import {
import { resolveBundledPluginRepoEntryPath } from "./bundled-plugin-metadata.js";
import { createCapturedPluginRegistration } from "./captured-registration.js";
import { discoverOpenClawPlugins } from "./discovery.js";
import { getCachedPluginJitiLoader, type PluginJitiLoaderCache } from "./jiti-loader-cache.js";
import type { PluginLoadOptions } from "./loader.js";
import { loadPluginManifestRegistry } from "./manifest-registry.js";
import { unwrapDefaultModuleExport } from "./module-export.js";
import {
getCachedPluginModuleLoader,
type PluginModuleLoaderCache,
} from "./plugin-module-loader-cache.js";
import { createEmptyPluginRegistry } from "./registry-empty.js";
import type { PluginRecord, PluginRegistry } from "./registry.js";
import {
buildPluginLoaderAliasMap,
shouldPreferNativeJiti,
shouldPreferNativeModuleLoad,
type PluginSdkResolutionPreference,
} from "./sdk-alias.js";
import type { OpenClawPluginDefinition, OpenClawPluginModule } from "./types.js";
@@ -193,11 +196,12 @@ export function loadBundledCapabilityRuntimeRegistry(params: {
const env = params.env ?? process.env;
const pluginIds = new Set(params.pluginIds);
const registry = createEmptyPluginRegistry();
const jitiLoaders: PluginJitiLoaderCache = new Map();
const moduleLoaders: PluginModuleLoaderCache = new Map();
const getJiti = (modulePath: string) => {
const getModuleLoader = (modulePath: string) => {
const tryNative =
shouldPreferNativeJiti(modulePath) && !(env?.VITEST && params.pluginSdkResolution === "dist");
shouldPreferNativeModuleLoad(modulePath) &&
!(env?.VITEST && params.pluginSdkResolution === "dist");
const aliasMap = shouldApplyVitestCapabilityAliasOverrides({
pluginSdkResolution: params.pluginSdkResolution,
env,
@@ -213,11 +217,11 @@ export function loadBundledCapabilityRuntimeRegistry(params: {
env,
})
: undefined;
return getCachedPluginJitiLoader({
cache: jitiLoaders,
return getCachedPluginModuleLoader({
cache: moduleLoaders,
modulePath,
importerUrl: import.meta.url,
jitiFilename: import.meta.url,
loaderFilename: import.meta.url,
...(aliasMap ? { aliasMap } : {}),
pluginSdkResolution: params.pluginSdkResolution,
tryNative,
@@ -289,7 +293,7 @@ export function loadBundledCapabilityRuntimeRegistry(params: {
let mod: OpenClawPluginModule | null = null;
try {
mod = getJiti(safeSource)(safeSource) as OpenClawPluginModule;
mod = getModuleLoader(safeSource)(safeSource) as OpenClawPluginModule;
} catch (error) {
recordCapabilityLoadError(registry, record, String(error));
continue;

View File

@@ -7,13 +7,16 @@ import {
normalizeBundledPluginStringList,
trimBundledPluginString,
} from "./bundled-plugin-scan.js";
import { getCachedPluginJitiLoader, type PluginJitiLoaderCache } from "./jiti-loader-cache.js";
import type { PluginConfigUiHint } from "./manifest-types.js";
import type {
OpenClawPackageManifest,
PluginManifest,
PluginManifestChannelConfig,
} from "./manifest.js";
import {
getCachedPluginModuleLoader,
type PluginModuleLoaderCache,
} from "./plugin-module-loader-cache.js";
import { PUBLIC_SURFACE_SOURCE_EXTENSIONS } from "./public-surface-runtime.js";
const SOURCE_CONFIG_SCHEMA_CANDIDATES = [
@@ -32,7 +35,7 @@ type ChannelConfigSurface = {
runtime?: ChannelConfigRuntimeSchema;
};
const jitiLoaders: PluginJitiLoaderCache = new Map();
const moduleLoaders: PluginModuleLoaderCache = new Map();
function isBuiltChannelConfigSchema(value: unknown): value is ChannelConfigSurface {
if (!value || typeof value !== "object") {
@@ -70,13 +73,13 @@ function resolveConfigSchemaExport(imported: Record<string, unknown>): ChannelCo
return null;
}
function getJiti(modulePath: string) {
return getCachedPluginJitiLoader({
cache: jitiLoaders,
function getModuleLoader(modulePath: string) {
return getCachedPluginModuleLoader({
cache: moduleLoaders,
modulePath,
importerUrl: import.meta.url,
preferBuiltDist: true,
jitiFilename: import.meta.url,
loaderFilename: import.meta.url,
});
}
@@ -100,7 +103,7 @@ function resolveChannelConfigSchemaModulePath(pluginDir: string): string | undef
function loadChannelConfigSurfaceModuleSync(modulePath: string): ChannelConfigSurface | null {
try {
const imported = getJiti(modulePath)(modulePath) as Record<string, unknown>;
const imported = getModuleLoader(modulePath)(modulePath) as Record<string, unknown>;
return resolveConfigSchemaExport(imported);
} catch {
return null;

View File

@@ -23,7 +23,7 @@ afterEach(() => {
cleanupTrackedTempDirs(tempDirs);
});
describe("doctor-contract-registry getJiti", () => {
describe("doctor-contract-registry module loader", () => {
beforeEach(async () => {
resetRegistryJitiMocks();
vi.resetModules();
@@ -67,7 +67,7 @@ describe("doctor-contract-registry getJiti", () => {
expect(mocks.createJiti).not.toHaveBeenCalled();
});
it("falls back to the Jiti boundary on Windows for TypeScript contract-api modules", () => {
it("falls back to the source-transform boundary on Windows for TypeScript contract-api modules", () => {
const pluginRoot = makeTempDir();
const contractApiPath = path.join(pluginRoot, "contract-api.ts");
fs.writeFileSync(

View File

@@ -4,8 +4,11 @@ import { fileURLToPath } from "node:url";
import type { LegacyConfigRule } from "../config/legacy.shared.js";
import type { OpenClawConfig } from "../config/types.js";
import { asNullableRecord } from "../shared/record-coerce.js";
import { getCachedPluginJitiLoader, type PluginJitiLoaderCache } from "./jiti-loader-cache.js";
import type { PluginManifestRegistry } from "./manifest-registry.js";
import {
getCachedPluginModuleLoader,
type PluginModuleLoaderCache,
} from "./plugin-module-loader-cache.js";
import { loadPluginManifestRegistryForPluginRegistry } from "./plugin-registry.js";
const CONTRACT_API_EXTENSIONS = [".js", ".mjs", ".cjs", ".ts", ".mts", ".cts"] as const;
@@ -36,11 +39,11 @@ type PluginDoctorContractEntry = {
type PluginManifestRegistryRecord = PluginManifestRegistry["plugins"][number];
const jitiLoaders: PluginJitiLoaderCache = new Map();
const moduleLoaders: PluginModuleLoaderCache = new Map();
function loadPluginDoctorContractModule(modulePath: string): PluginDoctorContractModule {
return getCachedPluginJitiLoader({
cache: jitiLoaders,
return getCachedPluginModuleLoader({
cache: moduleLoaders,
modulePath,
importerUrl: import.meta.url,
})(modulePath) as PluginDoctorContractModule;
@@ -228,7 +231,7 @@ function resolvePluginDoctorContracts(params?: {
}
export function clearPluginDoctorContractRegistryCache(): void {
jitiLoaders.clear();
moduleLoaders.clear();
}
export function listPluginDoctorLegacyConfigRules(params?: {

View File

@@ -1872,7 +1872,14 @@ describe("installPluginFromArchive", () => {
});
it("does not flag the real qa-matrix plugin as dangerous install code", async () => {
const pluginDir = path.resolve(process.cwd(), "extensions", "qa-matrix");
const sourcePluginDir = path.resolve(process.cwd(), "extensions", "qa-matrix");
const pluginDir = path.join(suiteTempRootTracker.makeTempDir(), "qa-matrix");
fs.cpSync(sourcePluginDir, pluginDir, {
recursive: true,
filter: (entryPath) =>
!path.relative(sourcePluginDir, entryPath).split(path.sep).includes("node_modules"),
});
vi.mocked(resolveOpenClawPackageRootSync).mockReturnValue(process.cwd());
const scanResult = await installSecurityScan.scanPackageInstallSource({
extensions: ["./index.ts"],

View File

@@ -12,7 +12,6 @@ import {
isPrereleaseResolutionAllowed,
parseRegistryNpmSpec,
} from "../infra/npm-registry-spec.js";
import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js";
import {
createSafeNpmInstallArgs,
createSafeNpmInstallEnv,
@@ -35,6 +34,7 @@ import {
type PackageManifest as PluginPackageManifest,
} from "./manifest.js";
import { validatePackageExtensionEntriesForInstall } from "./package-entry-resolution.js";
import { linkOpenClawPeerDependencies } from "./plugin-peer-link.js";
export { resolvePluginInstallDir } from "./install-paths.js";
@@ -552,50 +552,6 @@ async function detectNativePackageInstallSource(packageDir: string): Promise<boo
}
}
/**
* After the installed package tree has been scanned, symlink the host openclaw
* package for plugins that declare it as a peer dependency.
*/
async function linkOpenClawPeerDependencies(params: {
installedDir: string;
peerDependencies: Record<string, string>;
logger: PluginInstallLogger;
}): Promise<void> {
const peers = Object.keys(params.peerDependencies).filter((name) => name === "openclaw");
if (peers.length === 0) {
return;
}
const hostRoot = resolveOpenClawPackageRootSync({
argv1: process.argv[1],
moduleUrl: import.meta.url,
cwd: process.cwd(),
});
if (!hostRoot) {
params.logger.warn?.(
"Could not locate openclaw package root to symlink peerDependencies; plugin may fail to resolve openclaw at runtime.",
);
return;
}
const nodeModulesDir = path.join(params.installedDir, "node_modules");
await fs.mkdir(nodeModulesDir, { recursive: true });
for (const peerName of peers) {
const linkPath = path.join(nodeModulesDir, peerName);
try {
// Remove any existing entry (broken link or stale directory) before
// creating the new symlink so re-installs are idempotent.
await fs.rm(linkPath, { recursive: true, force: true });
await fs.symlink(hostRoot, linkPath, "junction");
params.logger.info?.(`Linked peerDependency "${peerName}" -> ${hostRoot}`);
} catch (err) {
params.logger.warn?.(`Failed to symlink peerDependency "${peerName}": ${String(err)}`);
}
}
}
type ValidatedPackagePlugin = {
manifest: PackageManifest;
pluginId: string;

View File

@@ -27,8 +27,8 @@ describe("plugin loader git path regression", () => {
const copiedPluginSdkDir = path.join(copiedExtensionRoot, "plugin-sdk");
mkdirSafe(copiedSourceDir);
mkdirSafe(copiedPluginSdkDir);
const jitiBaseFile = path.join(copiedSourceDir, "__jiti-base__.mjs");
fs.writeFileSync(jitiBaseFile, "export {};\n", "utf-8");
const sourceLoaderBaseFile = path.join(copiedSourceDir, "__jiti-base__.mjs");
fs.writeFileSync(sourceLoaderBaseFile, "export {};\n", "utf-8");
fs.writeFileSync(
path.join(copiedSourceDir, "channel.runtime.ts"),
`import { resolveOutboundSendDep } from "openclaw/plugin-sdk/outbound-send-deps";
@@ -59,7 +59,7 @@ export const copiedRuntimeMarker = {
const copiedChannelRuntime = path.join(copiedExtensionRoot, "src", "channel.runtime.ts");
const script = `
import { createJiti } from "jiti";
const withoutAlias = createJiti(${JSON.stringify(jitiBaseFile)}, {
const withoutAlias = createJiti(${JSON.stringify(sourceLoaderBaseFile)}, {
interopDefault: true,
tryNative: false,
extensions: [".ts", ".tsx", ".mts", ".cts", ".mtsx", ".ctsx", ".js", ".mjs", ".cjs", ".json"],
@@ -70,7 +70,7 @@ export const copiedRuntimeMarker = {
} catch {
withoutAliasThrew = true;
}
const withAlias = createJiti(${JSON.stringify(jitiBaseFile)}, {
const withAlias = createJiti(${JSON.stringify(sourceLoaderBaseFile)}, {
interopDefault: true,
tryNative: false,
extensions: [".ts", ".tsx", ".mts", ".cts", ".mtsx", ".ctsx", ".js", ".mjs", ".cjs", ".json"],

View File

@@ -1,97 +0,0 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { importFreshModule } from "openclaw/plugin-sdk/test-fixtures";
import { afterEach, describe, expect, it, vi } from "vitest";
const tempDirs: string[] = [];
function makeTempDir() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-loader-"));
tempDirs.push(dir);
return dir;
}
function writeBundledPluginFixture(id: string) {
const pluginRoot = makeTempDir();
fs.writeFileSync(
path.join(pluginRoot, "openclaw.plugin.json"),
JSON.stringify(
{
id,
configSchema: {
type: "object",
additionalProperties: false,
properties: {},
},
},
null,
2,
),
"utf-8",
);
fs.writeFileSync(
path.join(pluginRoot, "index.cjs"),
`module.exports = { id: ${JSON.stringify(id)}, register() {} };`,
"utf-8",
);
return pluginRoot;
}
afterEach(() => {
vi.resetModules();
vi.doUnmock("./jiti-loader-cache.js");
delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
describe("createPluginModuleLoader", () => {
it("loads bundled JavaScript without creating a jiti loader", async () => {
const jitiLoaderCalls: Array<{ modulePath: string; jitiFilename?: string }> = [];
vi.doMock("./jiti-loader-cache.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./jiti-loader-cache.js")>();
return {
...actual,
getCachedPluginJitiLoader: vi.fn((params) => {
jitiLoaderCalls.push({
modulePath: params.modulePath,
jitiFilename: params.jitiFilename,
});
return vi.fn(() => ({
default: {
id: "demo",
register() {},
},
}));
}),
};
});
const { loadOpenClawPlugins } = await importFreshModule<typeof import("./loader.js")>(
import.meta.url,
"./loader.js?scope=jiti-filename",
);
const pluginRoot = writeBundledPluginFixture("demo");
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = pluginRoot;
loadOpenClawPlugins({
cache: false,
workspaceDir: pluginRoot,
onlyPluginIds: ["demo"],
config: {
plugins: {
entries: {
demo: {
enabled: true,
},
},
},
},
});
expect(jitiLoaderCalls).toEqual([]);
});
});

View File

@@ -0,0 +1,176 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { importFreshModule } from "openclaw/plugin-sdk/test-fixtures";
import { afterEach, describe, expect, it, vi } from "vitest";
const tempDirs: string[] = [];
function makeTempDir() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-loader-"));
tempDirs.push(dir);
return dir;
}
function writeBundledPluginFixture(id: string) {
const pluginRoot = makeTempDir();
fs.writeFileSync(
path.join(pluginRoot, "openclaw.plugin.json"),
JSON.stringify(
{
id,
configSchema: {
type: "object",
additionalProperties: false,
properties: {},
},
},
null,
2,
),
"utf-8",
);
fs.writeFileSync(
path.join(pluginRoot, "index.cjs"),
`module.exports = { id: ${JSON.stringify(id)}, register() {} };`,
"utf-8",
);
return pluginRoot;
}
function writePackagedPluginFixture(id: string) {
const pluginRoot = makeTempDir();
fs.writeFileSync(
path.join(pluginRoot, "package.json"),
JSON.stringify(
{
name: id,
type: "commonjs",
openclaw: {
extensions: ["./index.cjs"],
},
},
null,
2,
),
"utf-8",
);
fs.writeFileSync(
path.join(pluginRoot, "openclaw.plugin.json"),
JSON.stringify(
{
id,
configSchema: {
type: "object",
additionalProperties: false,
properties: {},
},
},
null,
2,
),
"utf-8",
);
fs.writeFileSync(
path.join(pluginRoot, "index.cjs"),
`module.exports = { id: ${JSON.stringify(id)}, register() {} };`,
"utf-8",
);
return pluginRoot;
}
afterEach(() => {
vi.resetModules();
vi.doUnmock("./plugin-module-loader-cache.js");
delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
function mockSourceLoaderCalls() {
const sourceLoaderCalls: Array<{ modulePath: string; loaderFilename?: string }> = [];
vi.doMock("./plugin-module-loader-cache.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./plugin-module-loader-cache.js")>();
return {
...actual,
getCachedPluginSourceModuleLoader: vi.fn((params) => {
sourceLoaderCalls.push({
modulePath: params.modulePath,
loaderFilename: params.loaderFilename,
});
return vi.fn(() => ({
default: {
id: "source-fallback",
register() {},
},
}));
}),
};
});
return sourceLoaderCalls;
}
describe("createPluginModuleLoader", () => {
it("loads bundled JavaScript without creating a module loader", async () => {
const sourceLoaderCalls = mockSourceLoaderCalls();
const { loadOpenClawPlugins } = await importFreshModule<typeof import("./loader.js")>(
import.meta.url,
"./loader.js?scope=native-module-loader",
);
const pluginRoot = writeBundledPluginFixture("demo");
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = pluginRoot;
loadOpenClawPlugins({
cache: false,
workspaceDir: pluginRoot,
onlyPluginIds: ["demo"],
config: {
plugins: {
entries: {
demo: {
enabled: true,
},
},
},
},
});
expect(sourceLoaderCalls).toEqual([]);
});
it("loads packaged JavaScript without creating a module loader", async () => {
const sourceLoaderCalls = mockSourceLoaderCalls();
const { loadOpenClawPlugins } = await importFreshModule<typeof import("./loader.js")>(
import.meta.url,
"./loader.js?scope=packaged-native-module-loader",
);
const pluginRoot = writePackagedPluginFixture("npm-demo");
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = makeTempDir();
const registry = loadOpenClawPlugins({
cache: false,
config: {
plugins: {
enabled: true,
load: {
paths: [pluginRoot],
},
allow: ["npm-demo"],
entries: {
"npm-demo": {
enabled: true,
},
},
},
},
});
expect(registry.plugins.find((plugin) => plugin.id === "npm-demo")?.status).toBe("loaded");
expect(sourceLoaderCalls).toEqual([]);
});
});

View File

@@ -56,7 +56,6 @@ import {
listPluginInteractiveHandlers,
restorePluginInteractiveHandlers,
} from "./interactive-registry.js";
import { getCachedPluginJitiLoader, type PluginJitiLoaderCache } from "./jiti-loader-cache.js";
import { PluginLoaderCacheState } from "./loader-cache-state.js";
import {
channelPluginIdBelongsToManifest,
@@ -104,6 +103,10 @@ import {
import { unwrapDefaultModuleExport } from "./module-export.js";
import { tryNativeRequireJavaScriptModule } from "./native-module-require.js";
import { withProfile } from "./plugin-load-profile.js";
import {
getCachedPluginSourceModuleLoader,
type PluginModuleLoaderCache,
} from "./plugin-module-loader-cache.js";
import {
createPluginIdScopeSet,
hasExplicitPluginIdScope,
@@ -135,7 +138,7 @@ import {
resolvePluginSdkAliasFile,
resolvePluginRuntimeModulePath,
resolvePluginSdkScopedAliasMap,
shouldPreferNativeJiti,
shouldPreferNativeModuleLoad,
} from "./sdk-alias.js";
import { hasKind, kindsEqual } from "./slots.js";
import type {
@@ -457,13 +460,13 @@ function runPluginRegisterSync(
}
function createPluginModuleLoader(options: Pick<PluginLoadOptions, "pluginSdkResolution">) {
const jitiLoaders: PluginJitiLoaderCache = new Map();
const loadWithJiti = (modulePath: string) => {
return getCachedPluginJitiLoader({
cache: jitiLoaders,
const moduleLoaders: PluginModuleLoaderCache = new Map();
const loadSourceModule = (modulePath: string) => {
return getCachedPluginSourceModuleLoader({
cache: moduleLoaders,
modulePath,
importerUrl: import.meta.url,
jitiFilename: modulePath,
loaderFilename: modulePath,
aliasMap: buildPluginLoaderAliasMap(
modulePath,
process.argv[1],
@@ -471,11 +474,10 @@ function createPluginModuleLoader(options: Pick<PluginLoadOptions, "pluginSdkRes
options.pluginSdkResolution,
),
pluginSdkResolution: options.pluginSdkResolution,
tryNative: false,
});
};
return (modulePath: string): unknown => {
if (shouldPreferNativeJiti(modulePath)) {
if (shouldPreferNativeModuleLoad(modulePath)) {
const native = tryNativeRequireJavaScriptModule(modulePath, { allowWindows: true });
if (native.ok) {
return native.moduleExport;
@@ -484,7 +486,7 @@ function createPluginModuleLoader(options: Pick<PluginLoadOptions, "pluginSdkRes
// Source .ts runtime shims import sibling ".js" specifiers that only exist
// after build. Jiti remains the dev/source fallback because it rewrites those
// imports against the source graph and applies SDK aliases.
return loadWithJiti(modulePath)(toSafeImportPath(modulePath));
return loadSourceModule(modulePath)(toSafeImportPath(modulePath));
};
}
@@ -510,7 +512,7 @@ export const __testing = {
resolvePluginRuntimeModulePath,
ensureOpenClawPluginSdkAlias,
shouldLoadChannelPluginInSetupRuntime,
shouldPreferNativeJiti,
shouldPreferNativeModuleLoad,
toSafeImportPath,
getCompatibleActivePluginRegistry,
resolvePluginLoadCacheContext,

View File

@@ -7,7 +7,7 @@ afterEach(() => {
vi.doUnmock("jiti");
});
async function loadCachedPluginJitiLoader(scope: string) {
async function loadCachedPluginModuleLoader(scope: string) {
const createJiti = vi.fn((filename: string, options?: Record<string, unknown>) =>
Object.assign(vi.fn(), {
filename,
@@ -18,17 +18,17 @@ async function loadCachedPluginJitiLoader(scope: string) {
createJiti,
}));
const { getCachedPluginJitiLoader } = await importFreshModule<
typeof import("./jiti-loader-cache.js")
>(import.meta.url, `./jiti-loader-cache.js?scope=${scope}`);
const { getCachedPluginModuleLoader } = await importFreshModule<
typeof import("./plugin-module-loader-cache.js")
>(import.meta.url, `./plugin-module-loader-cache.js?scope=${scope}`);
return { createJiti, getCachedPluginJitiLoader };
return { createJiti, getCachedPluginModuleLoader };
}
describe("getCachedPluginJitiLoader", () => {
describe("getCachedPluginModuleLoader", () => {
it("reuses cached loaders for the same module config and filename", async () => {
const { createJiti, getCachedPluginJitiLoader } =
await loadCachedPluginJitiLoader("cached-loader");
const { createJiti, getCachedPluginModuleLoader } =
await loadCachedPluginModuleLoader("cached-loader");
const cache = new Map();
const params = {
@@ -36,11 +36,11 @@ describe("getCachedPluginJitiLoader", () => {
modulePath: "/repo/extensions/demo/index.ts",
importerUrl: "file:///repo/src/plugins/setup-registry.ts",
argvEntry: "/repo/openclaw.mjs",
jitiFilename: "file:///repo/src/plugins/source-loader.ts",
loaderFilename: "file:///repo/src/plugins/source-loader.ts",
} as const;
const first = getCachedPluginJitiLoader(params);
const second = getCachedPluginJitiLoader(params);
const first = getCachedPluginModuleLoader(params);
const second = getCachedPluginModuleLoader(params);
expect(second).toBe(first);
first("/repo/extensions/demo/index.ts");
@@ -48,26 +48,26 @@ describe("getCachedPluginJitiLoader", () => {
expect(cache.size).toBe(1);
});
it("keeps loader caches scoped by jiti filename and dist preference", async () => {
const { createJiti, getCachedPluginJitiLoader } =
await loadCachedPluginJitiLoader("filename-scope");
it("keeps loader caches scoped by loader filename and dist preference", async () => {
const { createJiti, getCachedPluginModuleLoader } =
await loadCachedPluginModuleLoader("filename-scope");
const cache = new Map();
const first = getCachedPluginJitiLoader({
const first = getCachedPluginModuleLoader({
cache,
modulePath: "/repo/dist/extensions/demo/api.ts",
importerUrl: "file:///repo/src/plugins/public-surface-loader.ts",
argvEntry: "/repo/openclaw.mjs",
preferBuiltDist: true,
jitiFilename: "file:///repo/src/plugins/public-surface-loader.ts",
loaderFilename: "file:///repo/src/plugins/public-surface-loader.ts",
});
const second = getCachedPluginJitiLoader({
const second = getCachedPluginModuleLoader({
cache,
modulePath: "/repo/dist/extensions/demo/api.ts",
importerUrl: "file:///repo/src/plugins/public-surface-loader.ts",
argvEntry: "/repo/openclaw.mjs",
preferBuiltDist: true,
jitiFilename: "file:///repo/src/plugins/bundled-channel-config-metadata.ts",
loaderFilename: "file:///repo/src/plugins/bundled-channel-config-metadata.ts",
});
expect(second).not.toBe(first);
@@ -95,25 +95,26 @@ describe("getCachedPluginJitiLoader", () => {
});
it("lets callers override alias maps and tryNative while keeping cache keys stable", async () => {
const { createJiti, getCachedPluginJitiLoader } = await loadCachedPluginJitiLoader("overrides");
const { createJiti, getCachedPluginModuleLoader } =
await loadCachedPluginModuleLoader("overrides");
const cache = new Map();
const first = getCachedPluginJitiLoader({
const first = getCachedPluginModuleLoader({
cache,
modulePath: "/repo/extensions/demo/index.ts",
importerUrl: "file:///repo/src/plugins/loader.ts",
jitiFilename: "file:///repo/src/plugins/loader.ts",
loaderFilename: "file:///repo/src/plugins/loader.ts",
aliasMap: {
alpha: "/repo/alpha.js",
zeta: "/repo/zeta.js",
},
tryNative: false,
});
const second = getCachedPluginJitiLoader({
const second = getCachedPluginModuleLoader({
cache,
modulePath: "/repo/extensions/demo/index.ts",
importerUrl: "file:///repo/src/plugins/loader.ts",
jitiFilename: "file:///repo/src/plugins/loader.ts",
loaderFilename: "file:///repo/src/plugins/loader.ts",
aliasMap: {
zeta: "/repo/zeta.js",
alpha: "/repo/alpha.js",
@@ -137,26 +138,26 @@ describe("getCachedPluginJitiLoader", () => {
});
it("lets callers intentionally share loaders behind a custom cache scope key", async () => {
const { createJiti, getCachedPluginJitiLoader } =
await loadCachedPluginJitiLoader("cache-scope-key");
const { createJiti, getCachedPluginModuleLoader } =
await loadCachedPluginModuleLoader("cache-scope-key");
const cache = new Map();
const first = getCachedPluginJitiLoader({
const first = getCachedPluginModuleLoader({
cache,
modulePath: "/repo/dist/extensions/demo-a/api.js",
importerUrl: "file:///repo/src/plugins/public-surface-loader.ts",
jitiFilename: "file:///repo/src/plugins/public-surface-loader.ts",
loaderFilename: "file:///repo/src/plugins/public-surface-loader.ts",
aliasMap: {
demo: "/repo/demo-a.js",
},
tryNative: true,
cacheScopeKey: "bundled:native",
});
const second = getCachedPluginJitiLoader({
const second = getCachedPluginModuleLoader({
cache,
modulePath: "/repo/dist/extensions/demo-b/api.js",
importerUrl: "file:///repo/src/plugins/public-surface-loader.ts",
jitiFilename: "file:///repo/src/plugins/public-surface-loader.ts",
loaderFilename: "file:///repo/src/plugins/public-surface-loader.ts",
aliasMap: {
demo: "/repo/demo-b.js",
},
@@ -171,26 +172,26 @@ describe("getCachedPluginJitiLoader", () => {
});
it("reuses pre-normalized alias options across module-scoped loader filenames", async () => {
const { createJiti, getCachedPluginJitiLoader } =
await loadCachedPluginJitiLoader("module-filename-aliases");
const { createJiti, getCachedPluginModuleLoader } =
await loadCachedPluginModuleLoader("module-filename-aliases");
const cache = new Map();
getCachedPluginJitiLoader({
getCachedPluginModuleLoader({
cache,
modulePath: "/repo/extensions/demo-a/index.ts",
importerUrl: "file:///repo/src/plugins/loader.ts",
jitiFilename: "/repo/extensions/demo-a/index.ts",
loaderFilename: "/repo/extensions/demo-a/index.ts",
aliasMap: {
alpha: "/repo/alpha",
beta: "alpha/sub",
},
tryNative: false,
});
getCachedPluginJitiLoader({
getCachedPluginModuleLoader({
cache,
modulePath: "/repo/extensions/demo-b/index.ts",
importerUrl: "file:///repo/src/plugins/loader.ts",
jitiFilename: "/repo/extensions/demo-b/index.ts",
loaderFilename: "/repo/extensions/demo-b/index.ts",
aliasMap: {
beta: "alpha/sub",
alpha: "/repo/alpha",
@@ -198,22 +199,22 @@ describe("getCachedPluginJitiLoader", () => {
tryNative: false,
});
getCachedPluginJitiLoader({
getCachedPluginModuleLoader({
cache,
modulePath: "/repo/extensions/demo-a/index.ts",
importerUrl: "file:///repo/src/plugins/loader.ts",
jitiFilename: "/repo/extensions/demo-a/index.ts",
loaderFilename: "/repo/extensions/demo-a/index.ts",
aliasMap: {
alpha: "/repo/alpha",
beta: "alpha/sub",
},
tryNative: false,
})("/repo/extensions/demo-a/index.ts");
getCachedPluginJitiLoader({
getCachedPluginModuleLoader({
cache,
modulePath: "/repo/extensions/demo-b/index.ts",
importerUrl: "file:///repo/src/plugins/loader.ts",
jitiFilename: "/repo/extensions/demo-b/index.ts",
loaderFilename: "/repo/extensions/demo-b/index.ts",
aliasMap: {
beta: "alpha/sub",
alpha: "/repo/alpha",
@@ -232,9 +233,9 @@ describe("getCachedPluginJitiLoader", () => {
expect((firstAlias as Record<symbol, unknown>)[marker]).toBe(true);
});
it("serves compiled .js targets from native require without invoking the jiti loader", async () => {
const jitiLoader = vi.fn();
const createJiti = vi.fn(() => jitiLoader);
it("serves compiled .js targets from native require without invoking the module loader", async () => {
const fromSourceTransformer = vi.fn();
const createJiti = vi.fn(() => fromSourceTransformer);
vi.doMock("jiti", () => ({ createJiti }));
const nativeStub = vi.fn((target: string) => ({
ok: true as const,
@@ -245,16 +246,16 @@ describe("getCachedPluginJitiLoader", () => {
p.endsWith(".js") || p.endsWith(".mjs") || p.endsWith(".cjs"),
tryNativeRequireJavaScriptModule: nativeStub,
}));
const { getCachedPluginJitiLoader } = await importFreshModule<
typeof import("./jiti-loader-cache.js")
>(import.meta.url, "./jiti-loader-cache.js?scope=native-require-fastpath");
const { getCachedPluginModuleLoader } = await importFreshModule<
typeof import("./plugin-module-loader-cache.js")
>(import.meta.url, "./plugin-module-loader-cache.js?scope=native-require-fastpath");
const cache = new Map();
const loader = getCachedPluginJitiLoader({
const loader = getCachedPluginModuleLoader({
cache,
modulePath: "/repo/dist/extensions/demo/api.js",
importerUrl: "file:///repo/src/plugins/public-surface-loader.ts",
jitiFilename: "file:///repo/src/plugins/public-surface-loader.ts",
loaderFilename: "file:///repo/src/plugins/public-surface-loader.ts",
});
const result = loader("/repo/dist/extensions/demo/api.js") as { loadedFrom: string };
@@ -262,57 +263,57 @@ describe("getCachedPluginJitiLoader", () => {
// Jiti should not be constructed or invoked for .js targets that
// `tryNativeRequireJavaScriptModule` resolves.
expect(createJiti).not.toHaveBeenCalled();
expect(jitiLoader).not.toHaveBeenCalled();
expect(fromSourceTransformer).not.toHaveBeenCalled();
// allowWindows must be passed so the native fast path works on Windows too.
expect(nativeStub).toHaveBeenCalledWith("/repo/dist/extensions/demo/api.js", {
allowWindows: true,
});
});
it("falls back to jiti when the native-require helper declines", async () => {
const jitiLoader = vi.fn(() => ({ fromJiti: true }));
const createJiti = vi.fn(() => jitiLoader);
it("falls back to source transform when the native-require helper declines", async () => {
const fromSourceTransformer = vi.fn(() => ({ fromSourceTransform: true }));
const createJiti = vi.fn(() => fromSourceTransformer);
vi.doMock("jiti", () => ({ createJiti }));
vi.doMock("./native-module-require.js", () => ({
isJavaScriptModulePath: () => true,
tryNativeRequireJavaScriptModule: () => ({ ok: false }),
}));
const { getCachedPluginJitiLoader } = await importFreshModule<
typeof import("./jiti-loader-cache.js")
>(import.meta.url, "./jiti-loader-cache.js?scope=native-require-fallback");
const { getCachedPluginModuleLoader } = await importFreshModule<
typeof import("./plugin-module-loader-cache.js")
>(import.meta.url, "./plugin-module-loader-cache.js?scope=native-require-fallback");
const cache = new Map();
const loader = getCachedPluginJitiLoader({
const loader = getCachedPluginModuleLoader({
cache,
modulePath: "/repo/dist/extensions/demo/api.js",
importerUrl: "file:///repo/src/plugins/public-surface-loader.ts",
jitiFilename: "file:///repo/src/plugins/public-surface-loader.ts",
loaderFilename: "file:///repo/src/plugins/public-surface-loader.ts",
});
const result = loader("/repo/dist/extensions/demo/api.js") as { fromJiti: boolean };
expect(result.fromJiti).toBe(true);
expect(jitiLoader).toHaveBeenCalledWith("/repo/dist/extensions/demo/api.js");
const result = loader("/repo/dist/extensions/demo/api.js") as { fromSourceTransform: boolean };
expect(result.fromSourceTransform).toBe(true);
expect(fromSourceTransformer).toHaveBeenCalledWith("/repo/dist/extensions/demo/api.js");
});
it("normalizes Windows absolute paths before creating and calling jiti", async () => {
it("normalizes Windows absolute paths before creating and calling the source transformer", async () => {
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
const jitiLoader = vi.fn(() => ({ fromJiti: true }));
const createJiti = vi.fn(() => jitiLoader);
const fromSourceTransformer = vi.fn(() => ({ fromSourceTransform: true }));
const createJiti = vi.fn(() => fromSourceTransformer);
vi.doMock("jiti", () => ({ createJiti }));
vi.doMock("./native-module-require.js", () => ({
isJavaScriptModulePath: () => true,
tryNativeRequireJavaScriptModule: () => ({ ok: false }),
}));
const { getCachedPluginJitiLoader } = await importFreshModule<
typeof import("./jiti-loader-cache.js")
>(import.meta.url, "./jiti-loader-cache.js?scope=windows-jiti-paths");
const { getCachedPluginModuleLoader } = await importFreshModule<
typeof import("./plugin-module-loader-cache.js")
>(import.meta.url, "./plugin-module-loader-cache.js?scope=windows-jiti-paths");
const cache = new Map();
const loader = getCachedPluginJitiLoader({
const loader = getCachedPluginModuleLoader({
cache,
modulePath: "C:\\Users\\alice\\openclaw\\dist\\extensions\\feishu\\api.js",
importerUrl: "file:///C:/Users/alice/openclaw/dist/src/plugins/public-surface-loader.js",
jitiFilename: "C:\\Users\\alice\\openclaw\\dist\\extensions\\feishu\\api.js",
loaderFilename: "C:\\Users\\alice\\openclaw\\dist\\extensions\\feishu\\api.js",
tryNative: true,
});
@@ -322,62 +323,62 @@ describe("getCachedPluginJitiLoader", () => {
"file:///C:/Users/alice/openclaw/dist/extensions/feishu/api.js",
expect.objectContaining({ tryNative: true }),
);
expect(jitiLoader).toHaveBeenCalledWith(
expect(fromSourceTransformer).toHaveBeenCalledWith(
"file:///C:/Users/alice/openclaw/dist/extensions/feishu/api.js",
);
});
it("skips the native-require fast path when tryNative is explicitly false", async () => {
const jitiLoader = vi.fn(() => ({ fromJiti: true }));
const createJiti = vi.fn(() => jitiLoader);
const fromSourceTransformer = vi.fn(() => ({ fromSourceTransform: true }));
const createJiti = vi.fn(() => fromSourceTransformer);
vi.doMock("jiti", () => ({ createJiti }));
const nativeStub = vi.fn(() => ({ ok: true, moduleExport: { fromNative: true } }));
vi.doMock("./native-module-require.js", () => ({
isJavaScriptModulePath: () => true,
tryNativeRequireJavaScriptModule: nativeStub,
}));
const { getCachedPluginJitiLoader } = await importFreshModule<
typeof import("./jiti-loader-cache.js")
>(import.meta.url, "./jiti-loader-cache.js?scope=native-require-opt-out");
const { getCachedPluginModuleLoader } = await importFreshModule<
typeof import("./plugin-module-loader-cache.js")
>(import.meta.url, "./plugin-module-loader-cache.js?scope=native-require-opt-out");
const cache = new Map();
const loader = getCachedPluginJitiLoader({
const loader = getCachedPluginModuleLoader({
cache,
modulePath: "/repo/dist/extensions/demo/api.js",
importerUrl: "file:///repo/src/plugins/bundled-capability-runtime.ts",
jitiFilename: "file:///repo/src/plugins/bundled-capability-runtime.ts",
loaderFilename: "file:///repo/src/plugins/bundled-capability-runtime.ts",
aliasMap: { "openclaw/plugin-sdk": "/repo/shim.js" },
tryNative: false,
});
const result = loader("/repo/dist/extensions/demo/api.js") as { fromJiti: boolean };
expect(result.fromJiti).toBe(true);
// With tryNative: false the wrapper must route every target through jiti
const result = loader("/repo/dist/extensions/demo/api.js") as { fromSourceTransform: boolean };
expect(result.fromSourceTransform).toBe(true);
// With tryNative: false the wrapper must route every target through the source transformer
// so its alias rewrites still apply; native require must not be consulted.
expect(nativeStub).not.toHaveBeenCalled();
expect(jitiLoader).toHaveBeenCalledWith("/repo/dist/extensions/demo/api.js");
expect(fromSourceTransformer).toHaveBeenCalledWith("/repo/dist/extensions/demo/api.js");
});
it("normalizes Windows absolute paths when native loading is disabled", async () => {
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
const jitiLoader = vi.fn(() => ({ fromJiti: true }));
const createJiti = vi.fn(() => jitiLoader);
const fromSourceTransformer = vi.fn(() => ({ fromSourceTransform: true }));
const createJiti = vi.fn(() => fromSourceTransformer);
vi.doMock("jiti", () => ({ createJiti }));
const nativeStub = vi.fn(() => ({ ok: true, moduleExport: { fromNative: true } }));
vi.doMock("./native-module-require.js", () => ({
isJavaScriptModulePath: () => true,
tryNativeRequireJavaScriptModule: nativeStub,
}));
const { getCachedPluginJitiLoader } = await importFreshModule<
typeof import("./jiti-loader-cache.js")
>(import.meta.url, "./jiti-loader-cache.js?scope=windows-jiti-no-native");
const { getCachedPluginModuleLoader } = await importFreshModule<
typeof import("./plugin-module-loader-cache.js")
>(import.meta.url, "./plugin-module-loader-cache.js?scope=windows-jiti-no-native");
const cache = new Map();
const loader = getCachedPluginJitiLoader({
const loader = getCachedPluginModuleLoader({
cache,
modulePath: "C:\\Users\\alice\\openclaw\\extensions\\feishu\\api.ts",
importerUrl: "file:///C:/Users/alice/openclaw/src/plugins/loader.ts",
jitiFilename: "C:\\Users\\alice\\openclaw\\extensions\\feishu\\api.ts",
loaderFilename: "C:\\Users\\alice\\openclaw\\extensions\\feishu\\api.ts",
tryNative: false,
});
@@ -388,33 +389,37 @@ describe("getCachedPluginJitiLoader", () => {
"file:///C:/Users/alice/openclaw/extensions/feishu/api.ts",
expect.objectContaining({ tryNative: false }),
);
expect(jitiLoader).toHaveBeenCalledWith(
expect(fromSourceTransformer).toHaveBeenCalledWith(
"file:///C:/Users/alice/openclaw/extensions/feishu/api.ts",
);
});
it("forwards extra loader arguments through to the jiti fallback", async () => {
const jitiLoader = vi.fn(() => ({ fromJiti: true }));
const createJiti = vi.fn(() => jitiLoader);
it("forwards extra loader arguments through to the source-transform fallback", async () => {
const fromSourceTransformer = vi.fn(() => ({ fromSourceTransform: true }));
const createJiti = vi.fn(() => fromSourceTransformer);
vi.doMock("jiti", () => ({ createJiti }));
vi.doMock("./native-module-require.js", () => ({
isJavaScriptModulePath: () => true,
tryNativeRequireJavaScriptModule: () => ({ ok: false }),
}));
const { getCachedPluginJitiLoader } = await importFreshModule<
typeof import("./jiti-loader-cache.js")
>(import.meta.url, "./jiti-loader-cache.js?scope=native-require-rest-args");
const { getCachedPluginModuleLoader } = await importFreshModule<
typeof import("./plugin-module-loader-cache.js")
>(import.meta.url, "./plugin-module-loader-cache.js?scope=native-require-rest-args");
const cache = new Map();
const loader = getCachedPluginJitiLoader({
const loader = getCachedPluginModuleLoader({
cache,
modulePath: "/repo/dist/extensions/demo/api.js",
importerUrl: "file:///repo/src/plugins/public-surface-loader.ts",
jitiFilename: "file:///repo/src/plugins/public-surface-loader.ts",
loaderFilename: "file:///repo/src/plugins/public-surface-loader.ts",
});
const loose = loader as unknown as (t: string, ...a: unknown[]) => unknown;
loose("/repo/dist/extensions/demo/api.js", { hint: "x" }, 42);
expect(jitiLoader).toHaveBeenCalledWith("/repo/dist/extensions/demo/api.js", { hint: "x" }, 42);
expect(fromSourceTransformer).toHaveBeenCalledWith(
"/repo/dist/extensions/demo/api.js",
{ hint: "x" },
42,
);
});
});

View File

@@ -3,31 +3,31 @@ import { toSafeImportPath } from "../shared/import-specifier.js";
import { tryNativeRequireJavaScriptModule } from "./native-module-require.js";
import {
buildPluginLoaderJitiOptions,
createPluginLoaderJitiCacheKey,
resolvePluginLoaderJitiConfig,
createPluginLoaderModuleCacheKey,
resolvePluginLoaderModuleConfig,
type PluginSdkResolutionPreference,
} from "./sdk-alias.js";
export type PluginJitiLoader = ReturnType<typeof createJiti>;
export type PluginJitiLoaderFactory = typeof createJiti;
export type PluginJitiLoaderCache = Map<string, PluginJitiLoader>;
export type PluginModuleLoader = ReturnType<typeof createJiti>;
export type PluginModuleLoaderFactory = typeof createJiti;
export type PluginModuleLoaderCache = Map<string, PluginModuleLoader>;
export function getCachedPluginJitiLoader(params: {
cache: PluginJitiLoaderCache;
export function getCachedPluginModuleLoader(params: {
cache: PluginModuleLoaderCache;
modulePath: string;
importerUrl: string;
argvEntry?: string;
preferBuiltDist?: boolean;
jitiFilename?: string;
createLoader?: PluginJitiLoaderFactory;
loaderFilename?: string;
createLoader?: PluginModuleLoaderFactory;
aliasMap?: Record<string, string>;
tryNative?: boolean;
pluginSdkResolution?: PluginSdkResolutionPreference;
cacheScopeKey?: string;
}): PluginJitiLoader {
const jitiFilename = toSafeImportPath(params.jitiFilename ?? params.modulePath);
}): PluginModuleLoader {
const loaderFilename = toSafeImportPath(params.loaderFilename ?? params.modulePath);
if (params.cacheScopeKey) {
const scopedCacheKey = `${jitiFilename}::${params.cacheScopeKey}`;
const scopedCacheKey = `${loaderFilename}::${params.cacheScopeKey}`;
const cached = params.cache.get(scopedCacheKey);
if (cached) {
return cached;
@@ -37,7 +37,7 @@ export function getCachedPluginJitiLoader(params: {
const hasTryNativeOverride = typeof params.tryNative === "boolean";
const defaultConfig =
hasAliasOverride || hasTryNativeOverride
? resolvePluginLoaderJitiConfig({
? resolvePluginLoaderModuleConfig({
modulePath: params.modulePath,
argv1: params.argvEntry ?? process.argv[1],
moduleUrl: params.importerUrl,
@@ -57,7 +57,7 @@ export function getCachedPluginJitiLoader(params: {
aliasMap: params.aliasMap ?? defaultConfig.aliasMap,
cacheKey: canReuseDefaultCacheKey ? defaultConfig.cacheKey : undefined,
}
: resolvePluginLoaderJitiConfig({
: resolvePluginLoaderModuleConfig({
modulePath: params.modulePath,
argv1: params.argvEntry ?? process.argv[1],
moduleUrl: params.importerUrl,
@@ -67,25 +67,25 @@ export function getCachedPluginJitiLoader(params: {
const { tryNative, aliasMap } = resolved;
const cacheKey =
resolved.cacheKey ??
createPluginLoaderJitiCacheKey({
createPluginLoaderModuleCacheKey({
tryNative,
aliasMap,
});
const scopedCacheKey = `${jitiFilename}::${params.cacheScopeKey ?? cacheKey}`;
const scopedCacheKey = `${loaderFilename}::${params.cacheScopeKey ?? cacheKey}`;
const cached = params.cache.get(scopedCacheKey);
if (cached) {
return cached;
}
let loadWithJiti: PluginJitiLoader | undefined;
const getLoadWithJiti = (): PluginJitiLoader => {
if (loadWithJiti) {
return loadWithJiti;
let loadWithSourceTransform: PluginModuleLoader | undefined;
const getLoadWithSourceTransform = (): PluginModuleLoader => {
if (loadWithSourceTransform) {
return loadWithSourceTransform;
}
const jitiLoader = (params.createLoader ?? createJiti)(jitiFilename, {
const jitiLoader = (params.createLoader ?? createJiti)(loaderFilename, {
...buildPluginLoaderJitiOptions(aliasMap),
tryNative,
});
loadWithJiti = new Proxy(jitiLoader, {
loadWithSourceTransform = new Proxy(jitiLoader, {
apply(target, thisArg, argArray) {
const [first, ...rest] = argArray as [unknown, ...unknown[]];
if (typeof first === "string") {
@@ -97,7 +97,7 @@ export function getCachedPluginJitiLoader(params: {
return Reflect.apply(target, thisArg, argArray as never) as never;
},
});
return loadWithJiti;
return loadWithSourceTransform;
};
// When the caller has explicitly opted out of native loading (for example
// `bundled-capability-runtime` in Vitest+dist mode, which depends on
@@ -105,10 +105,10 @@ export function getCachedPluginJitiLoader(params: {
// target through jiti so those alias rewrites still apply.
if (!tryNative) {
const loader = ((target: string, ...rest: unknown[]) =>
(getLoadWithJiti() as (t: string, ...a: unknown[]) => unknown)(
(getLoadWithSourceTransform() as (t: string, ...a: unknown[]) => unknown)(
target,
...rest,
)) as PluginJitiLoader;
)) as PluginModuleLoader;
params.cache.set(scopedCacheKey, loader);
return loader;
}
@@ -124,8 +124,20 @@ export function getCachedPluginJitiLoader(params: {
if (native.ok) {
return native.moduleExport;
}
return (getLoadWithJiti() as (t: string, ...a: unknown[]) => unknown)(target, ...rest);
}) as PluginJitiLoader;
return (getLoadWithSourceTransform() as (t: string, ...a: unknown[]) => unknown)(
target,
...rest,
);
}) as PluginModuleLoader;
params.cache.set(scopedCacheKey, loader);
return loader;
}
export function getCachedPluginSourceModuleLoader(
params: Omit<Parameters<typeof getCachedPluginModuleLoader>[0], "tryNative">,
): PluginModuleLoader {
return getCachedPluginModuleLoader({
...params,
tryNative: false,
});
}

View File

@@ -0,0 +1,51 @@
import fs from "node:fs/promises";
import path from "node:path";
import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js";
type PluginPeerLinkLogger = {
info?: (message: string) => void;
warn?: (message: string) => void;
};
/**
* Symlink the host openclaw package for plugins that declare it as a peer.
* Plugin package managers still own third-party dependencies; this only wires
* the host SDK package into the plugin-local Node graph.
*/
export async function linkOpenClawPeerDependencies(params: {
installedDir: string;
peerDependencies: Record<string, string>;
logger: PluginPeerLinkLogger;
}): Promise<void> {
const peers = Object.keys(params.peerDependencies).filter((name) => name === "openclaw");
if (peers.length === 0) {
return;
}
const hostRoot = resolveOpenClawPackageRootSync({
argv1: process.argv[1],
moduleUrl: import.meta.url,
cwd: process.cwd(),
});
if (!hostRoot) {
params.logger.warn?.(
"Could not locate openclaw package root to symlink peerDependencies; plugin may fail to resolve openclaw at runtime.",
);
return;
}
const nodeModulesDir = path.join(params.installedDir, "node_modules");
await fs.mkdir(nodeModulesDir, { recursive: true });
for (const peerName of peers) {
const linkPath = path.join(nodeModulesDir, peerName);
try {
await fs.rm(linkPath, { recursive: true, force: true });
await fs.symlink(hostRoot, linkPath, "junction");
params.logger.info?.(`Linked peerDependency "${peerName}" -> ${hostRoot}`);
} catch (err) {
params.logger.warn?.(`Failed to symlink peerDependency "${peerName}": ${String(err)}`);
}
}
}

View File

@@ -5,9 +5,12 @@ import { fileURLToPath } from "node:url";
import { openBoundaryFileSync } from "../infra/boundary-file-read.js";
import { sameFileIdentity } from "../infra/file-identity.js";
import { resolveBundledPluginsDir } from "./bundled-dir.js";
import { getCachedPluginJitiLoader, type PluginJitiLoaderCache } from "./jiti-loader-cache.js";
import {
getCachedPluginModuleLoader,
type PluginModuleLoaderCache,
} from "./plugin-module-loader-cache.js";
import { resolveBundledPluginPublicSurfacePath } from "./public-surface-runtime.js";
import { resolvePluginLoaderJitiTryNative, resolveLoaderPackageRoot } from "./sdk-alias.js";
import { resolvePluginLoaderTryNative, resolveLoaderPackageRoot } from "./sdk-alias.js";
const OPENCLAW_PACKAGE_ROOT =
resolveLoaderPackageRoot({
@@ -23,7 +26,7 @@ const publicSurfaceLocations = new Map<
boundaryRoot: string;
} | null
>();
const jitiLoaders: PluginJitiLoaderCache = new Map();
const moduleLoaders: PluginModuleLoaderCache = new Map();
function isSourceArtifactPath(modulePath: string): boolean {
switch (path.extname(modulePath).toLowerCase()) {
@@ -88,22 +91,22 @@ function resolvePublicSurfaceLocation(params: {
return resolved;
}
function getJiti(modulePath: string) {
return getCachedPluginJitiLoader({
cache: jitiLoaders,
function getModuleLoader(modulePath: string) {
return getCachedPluginModuleLoader({
cache: moduleLoaders,
modulePath,
importerUrl: import.meta.url,
preferBuiltDist: true,
jitiFilename: import.meta.url,
loaderFilename: import.meta.url,
});
}
function loadPublicSurfaceModule(modulePath: string): unknown {
const tryNative = resolvePluginLoaderJitiTryNative(modulePath, { preferBuiltDist: true });
const tryNative = resolvePluginLoaderTryNative(modulePath, { preferBuiltDist: true });
if (canUseSourceArtifactRequire({ modulePath, tryNative })) {
return sourceArtifactRequire(modulePath);
}
return getJiti(modulePath)(modulePath);
return getModuleLoader(modulePath)(modulePath);
}
// oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- Dynamic public artifact loaders use caller-supplied module surface types.
@@ -170,5 +173,5 @@ export function resolveBundledPluginPublicArtifactPath(params: {
export function resetBundledPluginPublicArtifactLoaderForTest(): void {
loadedPublicSurfaceModules.clear();
publicSurfaceLocations.clear();
jitiLoaders.clear();
moduleLoaders.clear();
}

View File

@@ -3,7 +3,7 @@ import path from "node:path";
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 type { PluginModuleLoaderCache } from "./plugin-module-loader-cache.js";
import { loadPluginBoundaryModule } from "./runtime/runtime-plugin-boundary.js";
import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fixtures.js";
@@ -122,7 +122,7 @@ function createExternalTypeScriptRuntimePackageFixture() {
}
function loadWhatsAppBoundaryModules(runtimePluginDir: string) {
const loaders: PluginJitiLoaderCache = new Map();
const loaders: PluginModuleLoaderCache = new Map();
return {
light: loadPluginBoundaryModule<LightModule>(
path.join(runtimePluginDir, "light-runtime-api.js"),
@@ -165,7 +165,7 @@ describe("runtime plugin boundary whatsapp seam", () => {
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();
const loaders: PluginModuleLoaderCache = new Map();
expect(() =>
loadPluginBoundaryModule<{ ok: boolean }>(modulePath, loaders, { origin: "bundled" }),
@@ -173,9 +173,9 @@ describe("runtime plugin boundary whatsapp seam", () => {
expect(loaders.size).toBe(0);
});
it("keeps the Jiti TypeScript package fallback available for non-bundled plugins", () => {
it("keeps the TypeScript source package fallback available for non-bundled plugins", () => {
const modulePath = createExternalTypeScriptRuntimePackageFixture();
const loaders: PluginJitiLoaderCache = new Map();
const loaders: PluginModuleLoaderCache = new Map();
expect(
loadPluginBoundaryModule<{ ok: boolean; loadedVia: string }>(modulePath, loaders, {

View File

@@ -1,12 +1,15 @@
import fs from "node:fs";
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 {
isJavaScriptModulePath,
tryNativeRequireJavaScriptModule,
} from "../native-module-require.js";
import {
getCachedPluginSourceModuleLoader,
type PluginModuleLoaderCache,
} from "../plugin-module-loader-cache.js";
import type { PluginOrigin } from "../plugin-origin.types.js";
type PluginRuntimeRecord = {
@@ -109,20 +112,19 @@ export function resolvePluginRuntimeModulePath(
return null;
}
function getPluginBoundarySourceLoader(modulePath: string, loaders: PluginJitiLoaderCache) {
return getCachedPluginJitiLoader({
function getPluginBoundarySourceLoader(modulePath: string, loaders: PluginModuleLoaderCache) {
return getCachedPluginSourceModuleLoader({
cache: loaders,
modulePath,
importerUrl: import.meta.url,
jitiFilename: import.meta.url,
tryNative: false,
loaderFilename: import.meta.url,
});
}
// oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- Dynamic plugin boundary loaders use caller-supplied module types.
export function loadPluginBoundaryModule<TModule>(
modulePath: string,
loaders: PluginJitiLoaderCache,
loaders: PluginModuleLoaderCache,
options: { origin?: PluginOrigin } = {},
): TModule {
if (isJavaScriptModulePath(modulePath)) {

View File

@@ -8,7 +8,7 @@ import {
optimizeImageToJpeg as optimizeImageToJpegImpl,
} from "../../media/web-media.js";
import type { PollInput } from "../../polls.js";
import type { PluginJitiLoaderCache } from "../jiti-loader-cache.js";
import type { PluginModuleLoaderCache } from "../plugin-module-loader-cache.js";
import type { PluginOrigin } from "../plugin-origin.types.js";
import {
loadPluginBoundaryModule,
@@ -109,7 +109,7 @@ let cachedHeavyModule: WebChannelHeavyRuntimeModule | null = null;
let cachedLightModulePath: string | null = null;
let cachedLightModule: WebChannelLightRuntimeModule | null = null;
const jitiLoaders: PluginJitiLoaderCache = new Map();
const moduleLoaders: PluginModuleLoaderCache = new Map();
function resolveWebChannelPluginRecord(): WebChannelPluginRecord {
return resolvePluginRuntimeRecordByEntryBaseNames(["light-runtime-api", "runtime-api"], () => {
@@ -135,7 +135,7 @@ function resolveWebChannelRuntimeModulePath(
function loadCurrentHeavyModuleSync(): WebChannelHeavyRuntimeModule {
const record = resolveWebChannelPluginRecord();
const modulePath = resolveWebChannelRuntimeModulePath(record, "runtime-api");
return loadPluginBoundaryModule<WebChannelHeavyRuntimeModule>(modulePath, jitiLoaders, {
return loadPluginBoundaryModule<WebChannelHeavyRuntimeModule>(modulePath, moduleLoaders, {
origin: record.origin,
});
}
@@ -146,7 +146,7 @@ function loadWebChannelLightModule(): WebChannelLightRuntimeModule {
if (cachedLightModule && cachedLightModulePath === modulePath) {
return cachedLightModule;
}
const loaded = loadPluginBoundaryModule<WebChannelLightRuntimeModule>(modulePath, jitiLoaders, {
const loaded = loadPluginBoundaryModule<WebChannelLightRuntimeModule>(modulePath, moduleLoaders, {
origin: record.origin,
});
cachedLightModulePath = modulePath;
@@ -160,7 +160,7 @@ async function loadWebChannelHeavyModule(): Promise<WebChannelHeavyRuntimeModule
if (cachedHeavyModule && cachedHeavyModulePath === modulePath) {
return cachedHeavyModule;
}
const loaded = loadPluginBoundaryModule<WebChannelHeavyRuntimeModule>(modulePath, jitiLoaders, {
const loaded = loadPluginBoundaryModule<WebChannelHeavyRuntimeModule>(modulePath, moduleLoaders, {
origin: record.origin,
});
cachedHeavyModulePath = modulePath;

View File

@@ -10,18 +10,18 @@ import { afterAll, describe, expect, it, vi } from "vitest";
import { withEnv } from "../test-utils/env.js";
import {
buildPluginLoaderAliasMap,
createPluginLoaderJitiCacheKey,
createPluginLoaderModuleCacheKey,
buildPluginLoaderJitiOptions,
isBundledPluginExtensionPath,
listPluginSdkAliasCandidates,
listPluginSdkExportedSubpaths,
normalizeJitiAliasTargetPath,
resolvePluginLoaderJitiConfig,
resolvePluginLoaderJitiTryNative,
resolvePluginLoaderModuleConfig,
resolvePluginLoaderTryNative,
resolveExtensionApiAlias,
resolvePluginRuntimeModulePath,
resolvePluginSdkAliasFile,
shouldPreferNativeJiti,
shouldPreferNativeModuleLoad,
} from "./sdk-alias.js";
import {
cleanupTrackedTempDirs,
@@ -912,7 +912,7 @@ describe("plugin sdk alias helpers", () => {
});
});
it("configures the plugin loader jiti boundary to prefer native dist modules", () => {
it("configures the plugin loader native-first boundary to prefer native dist modules", () => {
const options = buildPluginLoaderJitiOptions({});
expect(options.tryNative).toBe(true);
@@ -922,14 +922,16 @@ describe("plugin sdk alias helpers", () => {
expect("alias" in options).toBe(false);
});
it("uses transpiled Jiti loads for source TypeScript plugin entries", () => {
expect(shouldPreferNativeJiti("/repo/dist/plugins/runtime/index.js")).toBe(true);
it("uses transpiled module loads for source TypeScript plugin entries", () => {
expect(shouldPreferNativeModuleLoad("/repo/dist/plugins/runtime/index.js")).toBe(true);
expect(
shouldPreferNativeJiti(`/repo/${bundledPluginFile("discord", "src/channel.runtime.ts")}`),
shouldPreferNativeModuleLoad(
`/repo/${bundledPluginFile("discord", "src/channel.runtime.ts")}`,
),
).toBe(false);
});
it("disables native Jiti loads under Bun even for built JavaScript entries", () => {
it("disables native module loads under Bun even for built JavaScript entries", () => {
const originalVersions = process.versions;
Object.defineProperty(process, "versions", {
configurable: true,
@@ -940,10 +942,10 @@ describe("plugin sdk alias helpers", () => {
});
try {
expect(shouldPreferNativeJiti("/repo/dist/plugins/runtime/index.js")).toBe(false);
expect(shouldPreferNativeJiti(`/repo/${bundledDistPluginFile("browser", "index.js")}`)).toBe(
false,
);
expect(shouldPreferNativeModuleLoad("/repo/dist/plugins/runtime/index.js")).toBe(false);
expect(
shouldPreferNativeModuleLoad(`/repo/${bundledDistPluginFile("browser", "index.js")}`),
).toBe(false);
} finally {
Object.defineProperty(process, "versions", {
configurable: true,
@@ -952,7 +954,7 @@ describe("plugin sdk alias helpers", () => {
}
});
it("enables native Jiti loads on Windows for built JavaScript entries", () => {
it("enables native module loads on Windows for built JavaScript entries", () => {
const originalPlatform = process.platform;
Object.defineProperty(process, "platform", {
configurable: true,
@@ -960,10 +962,10 @@ describe("plugin sdk alias helpers", () => {
});
try {
expect(shouldPreferNativeJiti("/repo/dist/plugins/runtime/index.js")).toBe(true);
expect(shouldPreferNativeJiti(`/repo/${bundledDistPluginFile("browser", "index.js")}`)).toBe(
true,
);
expect(shouldPreferNativeModuleLoad("/repo/dist/plugins/runtime/index.js")).toBe(true);
expect(
shouldPreferNativeModuleLoad(`/repo/${bundledDistPluginFile("browser", "index.js")}`),
).toBe(true);
} finally {
Object.defineProperty(process, "platform", {
configurable: true,
@@ -972,7 +974,7 @@ describe("plugin sdk alias helpers", () => {
}
});
it("keeps plugin loader dist shortcuts on native Jiti on Windows for JS entries", () => {
it("keeps plugin loader dist shortcuts on native module loading on Windows for JS entries", () => {
const originalPlatform = process.platform;
Object.defineProperty(process, "platform", {
configurable: true,
@@ -981,12 +983,12 @@ describe("plugin sdk alias helpers", () => {
try {
expect(
resolvePluginLoaderJitiTryNative(`/repo/${bundledDistPluginFile("browser", "index.js")}`, {
resolvePluginLoaderTryNative(`/repo/${bundledDistPluginFile("browser", "index.js")}`, {
preferBuiltDist: true,
}),
).toBe(true);
expect(
resolvePluginLoaderJitiTryNative(`/repo/${bundledDistPluginFile("browser", "helper.ts")}`, {
resolvePluginLoaderTryNative(`/repo/${bundledDistPluginFile("browser", "helper.ts")}`, {
preferBuiltDist: true,
}),
).toBe(false);
@@ -998,31 +1000,31 @@ describe("plugin sdk alias helpers", () => {
}
});
it("prefers native jiti for bundled plugin dist .js modules, keeps .ts on aliased path", () => {
it("prefers native module loading 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 Node for
// to shouldPreferNativeModuleLoad() — which returns true on Node for
// compiled artifacts, avoiding the slow jiti transform path.
expect(
resolvePluginLoaderJitiTryNative(`/repo/${bundledDistPluginFile("browser", "index.js")}`, {
resolvePluginLoaderTryNative(`/repo/${bundledDistPluginFile("browser", "index.js")}`, {
preferBuiltDist: true,
}),
).toBe(true);
// TypeScript source files still need jiti's transform pipeline.
expect(
resolvePluginLoaderJitiTryNative(`/repo/${bundledDistPluginFile("browser", "helper.ts")}`, {
resolvePluginLoaderTryNative(`/repo/${bundledDistPluginFile("browser", "helper.ts")}`, {
preferBuiltDist: true,
}),
).toBe(false);
expect(
resolvePluginLoaderJitiTryNative("/repo/dist/plugins/runtime/index.js", {
resolvePluginLoaderTryNative("/repo/dist/plugins/runtime/index.js", {
preferBuiltDist: true,
}),
).toBe(true);
});
it("keeps plugin loader Jiti cache keys stable across alias insertion order", () => {
it("keeps plugin loader module cache keys stable across alias insertion order", () => {
expect(
createPluginLoaderJitiCacheKey({
createPluginLoaderModuleCacheKey({
tryNative: true,
aliasMap: {
zeta: "/repo/zeta.js",
@@ -1030,7 +1032,7 @@ describe("plugin sdk alias helpers", () => {
},
}),
).toBe(
createPluginLoaderJitiCacheKey({
createPluginLoaderModuleCacheKey({
tryNative: true,
aliasMap: {
alpha: "/repo/alpha.js",
@@ -1040,14 +1042,14 @@ describe("plugin sdk alias helpers", () => {
);
});
it("returns plugin loader Jiti config with stable cache keys", () => {
const first = resolvePluginLoaderJitiConfig({
it("returns plugin loader module config with stable cache keys", () => {
const first = resolvePluginLoaderModuleConfig({
modulePath: `/repo/${bundledDistPluginFile("browser", "index.js")}`,
argv1: "/repo/openclaw.mjs",
moduleUrl: "file:///repo/src/plugins/public-surface-loader.ts",
preferBuiltDist: true,
});
const second = resolvePluginLoaderJitiConfig({
const second = resolvePluginLoaderModuleConfig({
modulePath: `/repo/${bundledDistPluginFile("browser", "index.js")}`,
argv1: "/repo/openclaw.mjs",
moduleUrl: "file:///repo/src/plugins/public-surface-loader.ts",
@@ -1057,7 +1059,7 @@ describe("plugin sdk alias helpers", () => {
expect(second).toBe(first);
});
it("scopes plugin loader Jiti config by plugin-sdk resolution", () => {
it("scopes plugin loader module config by plugin-sdk resolution", () => {
const { fixture, sourceRootAlias, distRootAlias } = createPluginSdkAliasTargetFixture();
const sourcePluginEntry = writePluginEntry(
fixture.root,
@@ -1065,19 +1067,19 @@ describe("plugin sdk alias helpers", () => {
);
const { auto, dist, distAgain } = withEnv({ NODE_ENV: undefined }, () => ({
auto: resolvePluginLoaderJitiConfig({
auto: resolvePluginLoaderModuleConfig({
modulePath: sourcePluginEntry,
argv1: path.join(fixture.root, "openclaw.mjs"),
moduleUrl: pathToFileURL(path.join(fixture.root, "src/plugins/loader.ts")).href,
pluginSdkResolution: "auto",
}),
dist: resolvePluginLoaderJitiConfig({
dist: resolvePluginLoaderModuleConfig({
modulePath: sourcePluginEntry,
argv1: path.join(fixture.root, "openclaw.mjs"),
moduleUrl: pathToFileURL(path.join(fixture.root, "src/plugins/loader.ts")).href,
pluginSdkResolution: "dist",
}),
distAgain: resolvePluginLoaderJitiConfig({
distAgain: resolvePluginLoaderModuleConfig({
modulePath: sourcePluginEntry,
argv1: path.join(fixture.root, "openclaw.mjs"),
moduleUrl: pathToFileURL(path.join(fixture.root, "src/plugins/loader.ts")).href,
@@ -1116,7 +1118,7 @@ describe("plugin sdk alias helpers", () => {
).toBe(false);
});
it("normalizes Windows alias targets before handing them to Jiti", () => {
it("normalizes Windows alias targets before handing them to the source transformer", () => {
const originalPlatform = process.platform;
Object.defineProperty(process, "platform", {
configurable: true,
@@ -1135,14 +1137,14 @@ describe("plugin sdk alias helpers", () => {
}
});
it("loads source runtime shims through the non-native Jiti boundary", async () => {
it("loads source runtime shims through the non-native module loading boundary", async () => {
const copiedExtensionRoot = path.join(makeTempDir(), bundledPluginRoot("discord"));
const copiedSourceDir = path.join(copiedExtensionRoot, "src");
const copiedPluginSdkDir = path.join(copiedExtensionRoot, "plugin-sdk");
mkdirSafeDir(copiedSourceDir);
mkdirSafeDir(copiedPluginSdkDir);
const jitiBaseFile = path.join(copiedSourceDir, "__jiti-base__.mjs");
fs.writeFileSync(jitiBaseFile, "export {};\n", "utf-8");
const sourceLoaderBaseFile = path.join(copiedSourceDir, "__jiti-base__.mjs");
fs.writeFileSync(sourceLoaderBaseFile, "export {};\n", "utf-8");
fs.writeFileSync(
path.join(copiedSourceDir, "channel.runtime.ts"),
`import { resolveOutboundSendDep } from "@openclaw/plugin-sdk/outbound-send-deps";
@@ -1163,16 +1165,16 @@ export const syntheticRuntimeMarker = {
"utf-8",
);
const copiedChannelRuntime = path.join(copiedExtensionRoot, "src", "channel.runtime.ts");
const jitiBaseUrl = pathToFileURL(jitiBaseFile).href;
const sourceLoaderBaseUrl = pathToFileURL(sourceLoaderBaseFile).href;
const createJiti = await getCreateJiti();
const withoutAlias = createJiti(jitiBaseUrl, {
const withoutAlias = createJiti(sourceLoaderBaseUrl, {
...buildPluginLoaderJitiOptions({}),
tryNative: false,
});
expect(() => withoutAlias(copiedChannelRuntime)).toThrow();
const withAlias = createJiti(jitiBaseUrl, {
const withAlias = createJiti(sourceLoaderBaseUrl, {
...buildPluginLoaderJitiOptions({
"openclaw/plugin-sdk/outbound-send-deps": copiedChannelRuntimeShim,
"@openclaw/plugin-sdk/outbound-send-deps": copiedChannelRuntimeShim,
@@ -1351,7 +1353,7 @@ describe("buildPluginLoaderAliasMap memoization", () => {
});
describe("buildPluginLoaderJitiOptions", () => {
it("pre-normalizes and marks alias maps for Jiti", () => {
it("pre-normalizes and marks alias maps for source transforms", () => {
const marker = Symbol.for("pathe:normalizedAlias");
const aliasMap = {
"openclaw/plugin-sdk/core": "/repo/src/plugin-sdk/core.ts",
@@ -1367,7 +1369,7 @@ describe("buildPluginLoaderJitiOptions", () => {
expect(Object.prototype.propertyIsEnumerable.call(first, marker)).toBe(false);
});
it("applies Jiti alias-target normalization before caching", () => {
it("applies source-transform alias-target normalization before caching", () => {
const aliasMap = {
alpha: "/repo/alpha",
beta: "alpha/sub",

View File

@@ -490,7 +490,7 @@ const JITI_ALIAS_ROOT_SENTINELS = new Set<string | undefined>(["/", "\\", undefi
// surfaces depend on them.
const aliasMapCache = new Map<string, Record<string, string>>();
const normalizedJitiAliasMapCache = new Map<string, Record<string, string>>();
const pluginLoaderJitiConfigCache = new Map<
const pluginLoaderModuleConfigCache = new Map<
string,
{
tryNative: boolean;
@@ -578,7 +578,7 @@ function buildPluginLoaderAliasMapCacheKey(params: {
].join("\0");
}
function buildPluginLoaderJitiConfigCacheKey(params: {
function buildPluginLoaderModuleConfigCacheKey(params: {
modulePath: string;
argv1?: string;
moduleUrl: string;
@@ -693,7 +693,7 @@ export function buildPluginLoaderJitiOptions(aliasMap: Record<string, string>) {
};
}
function supportsNativeJitiRuntime(): boolean {
function supportsNativeModuleRuntime(): boolean {
const versions = process.versions as { bun?: string };
return typeof versions.bun !== "string";
}
@@ -702,8 +702,8 @@ function isBundledPluginDistModulePath(modulePath: string): boolean {
return modulePath.replace(/\\/g, "/").includes("/dist/extensions/");
}
export function shouldPreferNativeJiti(modulePath: string): boolean {
if (!supportsNativeJitiRuntime()) {
export function shouldPreferNativeModuleLoad(modulePath: string): boolean {
if (!supportsNativeModuleRuntime()) {
return false;
}
switch (normalizeLowercaseStringOrEmpty(path.extname(modulePath))) {
@@ -717,24 +717,24 @@ export function shouldPreferNativeJiti(modulePath: string): boolean {
}
}
export function resolvePluginLoaderJitiTryNative(
export function resolvePluginLoaderTryNative(
modulePath: string,
options?: {
preferBuiltDist?: boolean;
},
): boolean {
if (isBundledPluginDistModulePath(modulePath)) {
return shouldPreferNativeJiti(modulePath);
return shouldPreferNativeModuleLoad(modulePath);
}
return (
shouldPreferNativeJiti(modulePath) ||
(supportsNativeJitiRuntime() &&
shouldPreferNativeModuleLoad(modulePath) ||
(supportsNativeModuleRuntime() &&
options?.preferBuiltDist === true &&
modulePath.includes(`${path.sep}dist${path.sep}`))
);
}
export function createPluginLoaderJitiCacheKey(params: {
export function createPluginLoaderModuleCacheKey(params: {
tryNative: boolean;
aliasMap: Record<string, string>;
}): string {
@@ -746,7 +746,7 @@ export function createPluginLoaderJitiCacheKey(params: {
});
}
export function resolvePluginLoaderJitiConfig(params: {
export function resolvePluginLoaderModuleConfig(params: {
modulePath: string;
argv1?: string;
moduleUrl: string;
@@ -757,13 +757,13 @@ export function resolvePluginLoaderJitiConfig(params: {
aliasMap: Record<string, string>;
cacheKey: string;
} {
const configCacheKey = buildPluginLoaderJitiConfigCacheKey(params);
const cached = pluginLoaderJitiConfigCache.get(configCacheKey);
const configCacheKey = buildPluginLoaderModuleConfigCacheKey(params);
const cached = pluginLoaderModuleConfigCache.get(configCacheKey);
if (cached) {
return cached;
}
const tryNative = resolvePluginLoaderJitiTryNative(
const tryNative = resolvePluginLoaderTryNative(
params.modulePath,
params.preferBuiltDist ? { preferBuiltDist: true } : {},
);
@@ -776,12 +776,12 @@ export function resolvePluginLoaderJitiConfig(params: {
const result = {
tryNative,
aliasMap,
cacheKey: createPluginLoaderJitiCacheKey({
cacheKey: createPluginLoaderModuleCacheKey({
tryNative,
aliasMap,
}),
};
setBoundedCacheValue(pluginLoaderJitiConfigCache, configCacheKey, result);
setBoundedCacheValue(pluginLoaderModuleConfigCache, configCacheKey, result);
return result;
}

View File

@@ -9,9 +9,9 @@ import {
resetRegistryJitiMocks,
} from "./test-helpers/registry-jiti-mocks.js";
// jiti-loader-cache prefers native require() for compiled .js before falling
// plugin-module-loader-cache prefers native require() for compiled .js before falling
// back to jiti. These tests scripts plugin-loading behaviour through the
// jiti mock — disable the native-require fast path so the mocked jiti loader
// source-transform mock — disable the native-require fast path so the mocked source transformer
// stays authoritative for the test fixture files on disk.
vi.mock("./native-module-require.js", () => ({
isJavaScriptModulePath: (_modulePath: string) => false,
@@ -171,7 +171,7 @@ afterEach(() => {
cleanupTrackedTempDirs(tempDirs);
});
describe("setup-registry getJiti", () => {
describe("setup-registry module loader", () => {
beforeEach(async () => {
resetRegistryJitiMocks();
vi.resetModules();
@@ -185,7 +185,7 @@ describe("setup-registry getJiti", () => {
clearPluginSetupRegistryCache();
});
it("uses the runtime-supported Jiti boundary on Windows for setup-api modules", () => {
it("uses the runtime-supported source-transform boundary 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({

View File

@@ -5,8 +5,11 @@ import { normalizeProviderId } from "../agents/provider-id.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { buildPluginApi } from "./api-builder.js";
import { collectPluginConfigContractMatches } from "./config-contracts.js";
import { getCachedPluginJitiLoader, type PluginJitiLoaderCache } from "./jiti-loader-cache.js";
import type { PluginManifestRecord, PluginManifestRegistry } from "./manifest-registry.js";
import {
getCachedPluginModuleLoader,
type PluginModuleLoaderCache,
} from "./plugin-module-loader-cache.js";
import { loadPluginManifestRegistryForPluginRegistry } from "./plugin-registry.js";
import type { PluginRuntime } from "./runtime/types.js";
import { listSetupCliBackendIds, listSetupProviderIds } from "./setup-descriptors.js";
@@ -82,15 +85,15 @@ const NOOP_LOGGER: PluginLogger = {
error() {},
};
const jitiLoaders: PluginJitiLoaderCache = new Map();
const moduleLoaders: PluginModuleLoaderCache = new Map();
export function clearPluginSetupRegistryCache(): void {
jitiLoaders.clear();
moduleLoaders.clear();
}
function getJiti(modulePath: string) {
return getCachedPluginJitiLoader({
cache: jitiLoaders,
function getModuleLoader(modulePath: string) {
return getCachedPluginModuleLoader({
cache: moduleLoaders,
modulePath,
importerUrl: import.meta.url,
});
@@ -224,7 +227,7 @@ function resolveSetupRegistration(record: PluginManifestRecord): {
let mod: OpenClawPluginModule;
try {
mod = getJiti(setupSource)(setupSource) as OpenClawPluginModule;
mod = getModuleLoader(setupSource)(setupSource) as OpenClawPluginModule;
} catch {
return null;
}

View File

@@ -1,23 +1,23 @@
import type { PluginJitiLoaderCache } from "./jiti-loader-cache.js";
import { getCachedPluginJitiLoader } from "./jiti-loader-cache.js";
import { withProfile } from "./plugin-load-profile.js";
import type { PluginModuleLoaderCache } from "./plugin-module-loader-cache.js";
import { getCachedPluginSourceModuleLoader } from "./plugin-module-loader-cache.js";
export type PluginSourceLoader = (modulePath: string) => unknown;
export function createPluginSourceLoader(): PluginSourceLoader {
const loaders: PluginJitiLoaderCache = new Map();
const loaders: PluginModuleLoaderCache = new Map();
return (modulePath) => {
const jiti = getCachedPluginJitiLoader({
const sourceLoader = getCachedPluginSourceModuleLoader({
cache: loaders,
modulePath,
importerUrl: import.meta.url,
jitiFilename: import.meta.url,
loaderFilename: import.meta.url,
});
// Direct source loads are not associated with a specific plugin id —
// preserve the existing `plugin=(direct)` field used by tooling that
// scrapes [plugin-load-profile] lines.
return withProfile({ pluginId: "(direct)", source: modulePath }, "source-loader", () =>
jiti(modulePath),
sourceLoader(modulePath),
);
};
}