diff --git a/src/plugins/sdk-alias.test.ts b/src/plugins/sdk-alias.test.ts index 77fbaaeccce..34e30633b82 100644 --- a/src/plugins/sdk-alias.test.ts +++ b/src/plugins/sdk-alias.test.ts @@ -1056,3 +1056,95 @@ export const syntheticRuntimeMarker = { expect(resolved).toBe(expected === "dist" ? fixture.distFile : fixture.srcFile); }); }); + +describe("buildPluginLoaderAliasMap memoization", () => { + it("returns the same object reference for identical inputs (jiti sentinel safety)", () => { + const fixture = createPluginSdkAliasFixture(); + const sourceRootAlias = path.join(fixture.root, "src", "plugin-sdk", "root-alias.cjs"); + fs.writeFileSync(sourceRootAlias, "module.exports = {};\n", "utf-8"); + const sourcePluginEntry = writePluginEntry( + fixture.root, + bundledPluginFile("memo-demo", "src/index.ts"), + ); + + const first = buildPluginLoaderAliasMap(sourcePluginEntry); + const second = buildPluginLoaderAliasMap(sourcePluginEntry); + + // Reference identity is critical: jiti's normalizeAliases uses a + // reference-identity sentinel to skip O(N²) re-processing. + expect(second).toBe(first); + }); + + it("returns different references for different modulePath inputs", () => { + const fixtureA = createPluginSdkAliasFixture(); + const fixtureB = createPluginSdkAliasFixture(); + fs.writeFileSync( + path.join(fixtureA.root, "src", "plugin-sdk", "root-alias.cjs"), + "module.exports = {};\n", + "utf-8", + ); + fs.writeFileSync( + path.join(fixtureB.root, "src", "plugin-sdk", "root-alias.cjs"), + "module.exports = {};\n", + "utf-8", + ); + const entryA = writePluginEntry(fixtureA.root, bundledPluginFile("a", "src/index.ts")); + const entryB = writePluginEntry(fixtureB.root, bundledPluginFile("b", "src/index.ts")); + + const aliasA = buildPluginLoaderAliasMap(entryA); + const aliasB = buildPluginLoaderAliasMap(entryB); + + expect(aliasA).not.toBe(aliasB); + expect(aliasA["openclaw/plugin-sdk"]).not.toBe(aliasB["openclaw/plugin-sdk"]); + }); + + it("returns different references when pluginSdkResolution differs", () => { + const fixture = createPluginSdkAliasFixture(); + fs.writeFileSync( + path.join(fixture.root, "src", "plugin-sdk", "root-alias.cjs"), + "module.exports = {};\n", + "utf-8", + ); + const entry = writePluginEntry(fixture.root, bundledPluginFile("res", "src/index.ts")); + + const auto = buildPluginLoaderAliasMap(entry, undefined, undefined, "auto"); + const dist = buildPluginLoaderAliasMap(entry, undefined, undefined, "dist"); + + expect(auto).not.toBe(dist); + }); + + it("returns different references when argv1 differs", () => { + const fixture = createPluginSdkAliasFixture(); + fs.writeFileSync( + path.join(fixture.root, "src", "plugin-sdk", "root-alias.cjs"), + "module.exports = {};\n", + "utf-8", + ); + const entry = writePluginEntry(fixture.root, bundledPluginFile("argv", "src/index.ts")); + + const a = buildPluginLoaderAliasMap(entry, "/path/to/cli-a.mjs"); + const b = buildPluginLoaderAliasMap(entry, "/path/to/cli-b.mjs"); + + expect(a).not.toBe(b); + }); + + it("memoized result has identical content to a freshly computed map", () => { + const fixture = createPluginSdkAliasFixture(); + fs.writeFileSync( + path.join(fixture.root, "src", "plugin-sdk", "root-alias.cjs"), + "module.exports = {};\n", + "utf-8", + ); + const entry = writePluginEntry(fixture.root, bundledPluginFile("eq", "src/index.ts")); + + const first = buildPluginLoaderAliasMap(entry); + const second = buildPluginLoaderAliasMap(entry); + + // Same reference (cache hit) + expect(second).toBe(first); + // Same content + expect(second).toEqual(first); + // Same key set + expect(Object.keys(second).toSorted()).toEqual(Object.keys(first).toSorted()); + }); +}); diff --git a/src/plugins/sdk-alias.ts b/src/plugins/sdk-alias.ts index 0bf1b02ef37..18bd47d7155 100644 --- a/src/plugins/sdk-alias.ts +++ b/src/plugins/sdk-alias.ts @@ -430,12 +430,25 @@ export function resolveExtensionApiAlias(params: LoaderModuleResolveParams = {}) return null; } +// Memoize alias maps by inputs so the same object reference is returned for +// identical parameters. jiti's `normalizeAliases` uses a reference-identity +// sentinel (`if (e[pt]) return e`) to skip its O(N²) normalization — returning +// the same object lets the sentinel fire on the 2nd+ call instead of repeating +// the full cycle every time. See #68983. +const aliasMapCache = new Map>(); + export function buildPluginLoaderAliasMap( modulePath: string, argv1: string | undefined = STARTUP_ARGV1, moduleUrl?: string, pluginSdkResolution: PluginSdkResolutionPreference = "auto", ): Record { + const cacheKey = `${modulePath}\0${argv1 ?? ""}\0${moduleUrl ?? ""}\0${pluginSdkResolution}`; + const cached = aliasMapCache.get(cacheKey); + if (cached) { + return cached; + } + const pluginSdkAlias = resolvePluginSdkAliasFile({ srcFile: "root-alias.cjs", distFile: "root-alias.cjs", @@ -445,7 +458,7 @@ export function buildPluginLoaderAliasMap( pluginSdkResolution, }); const extensionApiAlias = resolveExtensionApiAlias({ modulePath, pluginSdkResolution }); - return { + const result: Record = { ...(extensionApiAlias ? { "openclaw/extension-api": normalizeJitiAliasTargetPath(extensionApiAlias) } : {}), @@ -463,6 +476,8 @@ export function buildPluginLoaderAliasMap( ).map(([key, value]) => [key, normalizeJitiAliasTargetPath(value)]), ), }; + aliasMapCache.set(cacheKey, result); + return result; } export function resolvePluginRuntimeModulePath(