Plugins: extract loader runtime factories

This commit is contained in:
Gustavo Madeira Santana
2026-03-15 15:05:57 +00:00
parent b57c45ba9c
commit 376acd54ed
6 changed files with 174 additions and 59 deletions

View File

@@ -32,7 +32,9 @@ This is an implementation checklist, not a future-design spec.
| Resolved static registry | flat rows in `src/plugins/manifest-registry.ts` | `src/extension-host/resolved-registry.ts` | `partial` | Manifest records now carry `resolvedExtension`; a host-owned resolved registry view exists for static consumers. |
| Manifest/package metadata loading | `src/plugins/manifest.ts`, `src/plugins/discovery.ts`, `src/plugins/install.ts` | `src/extension-host/schema.ts` and `src/extension-host/manifest-registry.ts` | `partial` | Package metadata parsing is routed through host schema helpers; legacy loader flow still supplies the source manifests. |
| Loader SDK alias compatibility | `src/plugins/loader.ts` | `src/extension-host/loader-compat.ts` | `partial` | Plugin-SDK alias candidate ordering, alias-file resolution, and scoped alias-map construction now live in host-owned loader compatibility helpers. |
| Loader alias-wired module loader creation | `src/plugins/loader.ts` | `src/extension-host/loader-module-loader.ts` | `partial` | Lazy Jiti creation and SDK-alias-wired module loading now delegate through a host-owned loader-module-loader helper. |
| Loader cache key and registry cache control | `src/plugins/loader.ts` | `src/extension-host/loader-cache.ts` | `partial` | Cache-key construction, LRU registry cache reads and writes, and cache clearing now delegate through host-owned loader-cache helpers while preserving the current cache shape and cap. |
| Loader lazy runtime proxy creation | `src/plugins/loader.ts` | `src/extension-host/loader-runtime-proxy.ts` | `partial` | Lazy plugin runtime creation now delegates through a host-owned loader-runtime-proxy helper instead of remaining inline in the orchestrator. |
| Loader provenance and duplicate-order policy | `src/plugins/loader.ts` | `src/extension-host/loader-policy.ts` | `partial` | Plugin-record creation, duplicate precedence, and provenance indexing now live in host-owned loader-policy helpers. |
| Loader discovery policy results | mixed inside `src/extension-host/loader-policy.ts` and `src/extension-host/loader-orchestrator.ts` | `src/extension-host/loader-discovery-policy.ts` | `partial` | Open-allowlist discovery warnings now resolve through explicit host-owned discovery-policy results before the orchestrator logs them. |
| Loader initial candidate planning and record creation | `src/plugins/loader.ts` | `src/extension-host/loader-records.ts` | `partial` | Duplicate detection, initial record creation, manifest metadata attachment, and first-pass enable-state planning now delegate through host-owned loader-records helpers. |

View File

@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { createExtensionHostModuleLoader } from "./loader-module-loader.js";
describe("extension host module loader", () => {
it("creates the jiti loader lazily and reuses it", () => {
let createCount = 0;
const loadedSources: string[] = [];
const loadModule = createExtensionHostModuleLoader({
importMetaUrl: "file:///test-loader.ts",
createJitiLoader: (_url, options) => {
createCount += 1;
expect(options.alias).toEqual({
"openclaw/plugin-sdk": "/sdk/index.ts",
"openclaw/plugin-sdk/telegram": "/sdk/telegram.ts",
});
return ((safeSource: string) => {
loadedSources.push(safeSource);
return { safeSource };
}) as never;
},
resolvePluginSdkAliasFn: () => "/sdk/index.ts",
resolvePluginSdkScopedAliasMapFn: () => ({
"openclaw/plugin-sdk/telegram": "/sdk/telegram.ts",
}),
});
expect(createCount).toBe(0);
expect(loadModule("/plugins/one.ts")).toEqual({ safeSource: "/plugins/one.ts" });
expect(loadModule("/plugins/two.ts")).toEqual({ safeSource: "/plugins/two.ts" });
expect(createCount).toBe(1);
expect(loadedSources).toEqual(["/plugins/one.ts", "/plugins/two.ts"]);
});
it("omits alias config when no aliases resolve", () => {
const loadModule = createExtensionHostModuleLoader({
importMetaUrl: "file:///test-loader.ts",
createJitiLoader: (_url, options) => {
expect(options.alias).toBeUndefined();
return ((safeSource: string) => ({ safeSource })) as never;
},
resolvePluginSdkAliasFn: () => null,
resolvePluginSdkScopedAliasMapFn: () => ({}),
});
expect(loadModule("/plugins/demo.ts")).toEqual({ safeSource: "/plugins/demo.ts" });
});
});

View File

@@ -0,0 +1,44 @@
import { createJiti } from "jiti";
import type { OpenClawPluginModule } from "../plugins/types.js";
import { resolvePluginSdkAlias, resolvePluginSdkScopedAliasMap } from "./loader-compat.js";
type JitiLoaderFactory = typeof createJiti;
type JitiLoader = ReturnType<JitiLoaderFactory>;
export function createExtensionHostModuleLoader(
params: {
createJitiLoader?: JitiLoaderFactory;
importMetaUrl?: string;
resolvePluginSdkAliasFn?: typeof resolvePluginSdkAlias;
resolvePluginSdkScopedAliasMapFn?: typeof resolvePluginSdkScopedAliasMap;
} = {},
): (safeSource: string) => OpenClawPluginModule {
const createJitiLoader = params.createJitiLoader ?? createJiti;
const importMetaUrl = params.importMetaUrl ?? import.meta.url;
const resolvePluginSdkAliasFn = params.resolvePluginSdkAliasFn ?? resolvePluginSdkAlias;
const resolvePluginSdkScopedAliasMapFn =
params.resolvePluginSdkScopedAliasMapFn ?? resolvePluginSdkScopedAliasMap;
let jitiLoader: JitiLoader | null = null;
const getJiti = (): JitiLoader => {
if (jitiLoader) {
return jitiLoader;
}
const pluginSdkAlias = resolvePluginSdkAliasFn();
const aliasMap = {
...(pluginSdkAlias ? { "openclaw/plugin-sdk": pluginSdkAlias } : {}),
...resolvePluginSdkScopedAliasMapFn(),
};
jitiLoader = createJitiLoader(importMetaUrl, {
interopDefault: true,
extensions: [".ts", ".tsx", ".mts", ".cts", ".mtsx", ".ctsx", ".js", ".mjs", ".cjs", ".json"],
...(Object.keys(aliasMap).length > 0 ? { alias: aliasMap } : {}),
});
return jitiLoader;
};
return (safeSource: string): OpenClawPluginModule => {
return getJiti()(safeSource) as OpenClawPluginModule;
};
}

View File

@@ -1,4 +1,3 @@
import { createJiti } from "jiti";
import type { OpenClawConfig } from "../config/config.js";
import { activateExtensionHostRegistry } from "../extension-host/activation.js";
import {
@@ -20,10 +19,10 @@ import { discoverOpenClawPlugins } from "../plugins/discovery.js";
import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js";
import { createPluginRegistry, type PluginRegistry } from "../plugins/registry.js";
import { createPluginRuntime, type CreatePluginRuntimeOptions } from "../plugins/runtime/index.js";
import type { PluginRuntime } from "../plugins/runtime/types.js";
import type { OpenClawPluginModule, PluginLogger } from "../plugins/types.js";
import { resolvePluginSdkAlias, resolvePluginSdkScopedAliasMap } from "./loader-compat.js";
import type { PluginLogger } from "../plugins/types.js";
import { resolveExtensionHostDiscoveryPolicy } from "./loader-discovery-policy.js";
import { createExtensionHostModuleLoader } from "./loader-module-loader.js";
import { createExtensionHostLazyRuntime } from "./loader-runtime-proxy.js";
import {
createExtensionHostLoaderSession,
finalizeExtensionHostLoaderSession,
@@ -78,38 +77,9 @@ export function loadExtensionHostPluginRegistry(
// Clear previously registered plugin commands before reloading.
clearPluginCommands();
// Lazily initialize the runtime so startup paths that discover/skip plugins do
// not eagerly load every channel runtime dependency.
let resolvedRuntime: PluginRuntime | null = null;
const resolveRuntime = (): PluginRuntime => {
resolvedRuntime ??= createPluginRuntime(options.runtimeOptions);
return resolvedRuntime;
};
const runtime = new Proxy({} as PluginRuntime, {
get(_target, prop, receiver) {
return Reflect.get(resolveRuntime(), prop, receiver);
},
set(_target, prop, value, receiver) {
return Reflect.set(resolveRuntime(), prop, value, receiver);
},
has(_target, prop) {
return Reflect.has(resolveRuntime(), prop);
},
ownKeys() {
return Reflect.ownKeys(resolveRuntime() as object);
},
getOwnPropertyDescriptor(_target, prop) {
return Reflect.getOwnPropertyDescriptor(resolveRuntime() as object, prop);
},
defineProperty(_target, prop, attributes) {
return Reflect.defineProperty(resolveRuntime() as object, prop, attributes);
},
deleteProperty(_target, prop) {
return Reflect.deleteProperty(resolveRuntime() as object, prop);
},
getPrototypeOf() {
return Reflect.getPrototypeOf(resolveRuntime() as object);
},
const runtime = createExtensionHostLazyRuntime({
runtimeOptions: options.runtimeOptions,
createRuntime: createPluginRuntime,
});
const { registry, createApi } = createPluginRegistry({
logger,
@@ -152,28 +122,7 @@ export function loadExtensionHostPluginRegistry(
env,
});
// Lazy: avoid creating the Jiti loader when all plugins are disabled (common in unit tests).
let jitiLoader: ReturnType<typeof createJiti> | null = null;
const getJiti = () => {
if (jitiLoader) {
return jitiLoader;
}
const pluginSdkAlias = resolvePluginSdkAlias();
const aliasMap = {
...(pluginSdkAlias ? { "openclaw/plugin-sdk": pluginSdkAlias } : {}),
...resolvePluginSdkScopedAliasMap(),
};
jitiLoader = createJiti(import.meta.url, {
interopDefault: true,
extensions: [".ts", ".tsx", ".mts", ".cts", ".mtsx", ".ctsx", ".js", ".mjs", ".cjs", ".json"],
...(Object.keys(aliasMap).length > 0
? {
alias: aliasMap,
}
: {}),
});
return jitiLoader;
};
const loadModule = createExtensionHostModuleLoader();
const manifestByRoot = new Map(
manifestRegistry.plugins.map((record) => [record.rootDir, record]),
@@ -213,7 +162,7 @@ export function loadExtensionHostPluginRegistry(
rootConfig: cfg,
validateOnly,
createApi,
loadModule: (safeSource) => getJiti()(safeSource) as OpenClawPluginModule,
loadModule,
});
}

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { createExtensionHostLazyRuntime } from "./loader-runtime-proxy.js";
describe("extension host loader runtime proxy", () => {
it("creates the runtime lazily on first access", () => {
let createCount = 0;
const runtime = createExtensionHostLazyRuntime({
createRuntime: () => {
createCount += 1;
return { value: 1 } as never;
},
});
expect(createCount).toBe(0);
expect((runtime as never as { value: number }).value).toBe(1);
expect(createCount).toBe(1);
});
it("reuses the same runtime instance across proxy operations", () => {
let createCount = 0;
const runtime = createExtensionHostLazyRuntime({
createRuntime: () => {
createCount += 1;
return { value: 1 } as never;
},
});
expect("value" in (runtime as object)).toBe(true);
expect(Object.keys(runtime as object)).toEqual(["value"]);
expect((runtime as never as { value: number }).value).toBe(1);
expect(createCount).toBe(1);
});
});

View File

@@ -0,0 +1,39 @@
import type { PluginRuntime } from "../plugins/runtime/types.js";
export function createExtensionHostLazyRuntime<TOptions>(params: {
runtimeOptions?: TOptions;
createRuntime: (runtimeOptions?: TOptions) => PluginRuntime;
}): PluginRuntime {
let resolvedRuntime: PluginRuntime | null = null;
const resolveRuntime = (): PluginRuntime => {
resolvedRuntime ??= params.createRuntime(params.runtimeOptions);
return resolvedRuntime;
};
return new Proxy({} as PluginRuntime, {
get(_target, prop, receiver) {
return Reflect.get(resolveRuntime(), prop, receiver);
},
set(_target, prop, value, receiver) {
return Reflect.set(resolveRuntime(), prop, value, receiver);
},
has(_target, prop) {
return Reflect.has(resolveRuntime(), prop);
},
ownKeys() {
return Reflect.ownKeys(resolveRuntime() as object);
},
getOwnPropertyDescriptor(_target, prop) {
return Reflect.getOwnPropertyDescriptor(resolveRuntime() as object, prop);
},
defineProperty(_target, prop, attributes) {
return Reflect.defineProperty(resolveRuntime() as object, prop, attributes);
},
deleteProperty(_target, prop) {
return Reflect.deleteProperty(resolveRuntime() as object, prop);
},
getPrototypeOf() {
return Reflect.getPrototypeOf(resolveRuntime() as object);
},
});
}