diff --git a/src/extension-host/cutover-inventory.md b/src/extension-host/cutover-inventory.md index d541f6ce053..4ae7d92b4dc 100644 --- a/src/extension-host/cutover-inventory.md +++ b/src/extension-host/cutover-inventory.md @@ -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. | diff --git a/src/extension-host/loader-module-loader.test.ts b/src/extension-host/loader-module-loader.test.ts new file mode 100644 index 00000000000..8ef358cf4c2 --- /dev/null +++ b/src/extension-host/loader-module-loader.test.ts @@ -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" }); + }); +}); diff --git a/src/extension-host/loader-module-loader.ts b/src/extension-host/loader-module-loader.ts new file mode 100644 index 00000000000..1df3523fcc0 --- /dev/null +++ b/src/extension-host/loader-module-loader.ts @@ -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; + +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; + }; +} diff --git a/src/extension-host/loader-orchestrator.ts b/src/extension-host/loader-orchestrator.ts index 5dc966f8643..46f0154e063 100644 --- a/src/extension-host/loader-orchestrator.ts +++ b/src/extension-host/loader-orchestrator.ts @@ -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 | 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, }); } diff --git a/src/extension-host/loader-runtime-proxy.test.ts b/src/extension-host/loader-runtime-proxy.test.ts new file mode 100644 index 00000000000..6724205b16a --- /dev/null +++ b/src/extension-host/loader-runtime-proxy.test.ts @@ -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); + }); +}); diff --git a/src/extension-host/loader-runtime-proxy.ts b/src/extension-host/loader-runtime-proxy.ts new file mode 100644 index 00000000000..05036e4c22f --- /dev/null +++ b/src/extension-host/loader-runtime-proxy.ts @@ -0,0 +1,39 @@ +import type { PluginRuntime } from "../plugins/runtime/types.js"; + +export function createExtensionHostLazyRuntime(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); + }, + }); +}