diff --git a/src/plugin-sdk/channel-entry-contract.test.ts b/src/plugin-sdk/channel-entry-contract.test.ts index 1fc18d0003ed..f01646b4e26d 100644 --- a/src/plugin-sdk/channel-entry-contract.test.ts +++ b/src/plugin-sdk/channel-entry-contract.test.ts @@ -404,6 +404,54 @@ describe("loadBundledEntryExportSync", () => { }); }); + it("reuses resolved bundled sidecar paths before cached module exports", async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-channel-entry-contract-")); + tempDirs.push(tempRoot); + + const pluginRoot = path.join(tempRoot, "dist", "extensions", "telegram"); + fs.mkdirSync(pluginRoot, { recursive: true }); + + const importerPath = path.join(pluginRoot, "index.js"); + const helperPath = path.join(pluginRoot, "helper.cjs"); + fs.writeFileSync(importerPath, "export default {};\n", "utf8"); + fs.writeFileSync(helperPath, "module.exports = { sentinel: 42 };\n", "utf8"); + + const openRootFileSync = vi.fn(() => ({ + ok: true, + path: helperPath, + fd: fs.openSync(helperPath, "r"), + })); + vi.doMock("../infra/boundary-file-read.js", () => ({ + openRootFileSync, + })); + + try { + const channelEntryContract = await importFreshModule< + typeof import("./channel-entry-contract.js") + >(import.meta.url, "./channel-entry-contract.js?scope=resolved-sidecar-cache"); + + const ref = { + specifier: "./helper.cjs", + exportName: "sentinel", + }; + expect( + channelEntryContract.loadBundledEntryExportSync( + pathToFileURL(importerPath).href, + ref, + ), + ).toBe(42); + expect( + channelEntryContract.loadBundledEntryExportSync( + pathToFileURL(importerPath).href, + ref, + ), + ).toBe(42); + expect(openRootFileSync).toHaveBeenCalledTimes(1); + } finally { + vi.doUnmock("../infra/boundary-file-read.js"); + } + }); + 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 diff --git a/src/plugin-sdk/channel-entry-contract.ts b/src/plugin-sdk/channel-entry-contract.ts index c762bacd1114..882379b28189 100644 --- a/src/plugin-sdk/channel-entry-contract.ts +++ b/src/plugin-sdk/channel-entry-contract.ts @@ -144,6 +144,8 @@ export type BundledEntryModuleLoadOptions = { const nodeRequire = createRequire(import.meta.url); const moduleLoaders: PluginModuleLoaderCache = new Map(); +const entryBoundaryInfoCache = new Map(); +const resolvedModulePaths = new Map(); const loadedModuleExports = new Map(); const disableBundledEntrySourceFallbackEnv = "OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK"; @@ -174,6 +176,38 @@ type BundledEntryModuleCandidate = { boundaryRoot: string; }; +type BundledEntryBoundaryInfo = { + importerPath: string; + importerDir: string; + boundaryRoot: string; + packageRoot: string | null; +}; + +function resolveBundledEntryBoundaryInfo(importMetaUrl: string): BundledEntryBoundaryInfo { + const cacheKey = `${process.argv[1] ?? ""}\0${importMetaUrl}`; + const cached = entryBoundaryInfoCache.get(cacheKey); + if (cached) { + return cached; + } + const importerPath = fileURLToPath(importMetaUrl); + const importerDir = path.dirname(importerPath); + const boundaryRoot = path.dirname(importerPath); + const info = { + importerPath, + importerDir, + boundaryRoot, + packageRoot: + resolveLoaderPackageRoot({ + modulePath: importerPath, + moduleUrl: importMetaUrl, + cwd: importerDir, + argv1: process.argv[1], + }) ?? null, + }; + entryBoundaryInfoCache.set(cacheKey, info); + return info; +} + function addBundledEntryCandidates( candidates: BundledEntryModuleCandidate[], basePath: string, @@ -193,9 +227,8 @@ function resolveBundledEntryModuleCandidates( importMetaUrl: string, specifier: string, ): BundledEntryModuleCandidate[] { - const importerPath = fileURLToPath(importMetaUrl); - const importerDir = path.dirname(importerPath); - const boundaryRoot = resolveEntryBoundaryRoot(importMetaUrl); + const { importerPath, importerDir, boundaryRoot, packageRoot } = + resolveBundledEntryBoundaryInfo(importMetaUrl); const candidates: BundledEntryModuleCandidate[] = []; const primaryResolved = path.resolve(importerDir, specifier); addBundledEntryCandidates(candidates, primaryResolved, boundaryRoot); @@ -209,12 +242,6 @@ function resolveBundledEntryModuleCandidates( ); } - const packageRoot = resolveLoaderPackageRoot({ - modulePath: importerPath, - moduleUrl: importMetaUrl, - cwd: importerDir, - argv1: process.argv[1], - }); if (!packageRoot) { return candidates; } @@ -282,7 +309,17 @@ function formatBundledEntryModuleOpenFailure(params: { ].join(" "); } +function createBundledEntryModulePathCacheKey(importMetaUrl: string, specifier: string): string { + const sourceFallbackDisabled = isTruthyEnvFlag(process.env[disableBundledEntrySourceFallbackEnv]); + return `${sourceFallbackDisabled ? "1" : "0"}\0${importMetaUrl}\0${specifier}`; +} + function resolveBundledEntryModulePath(importMetaUrl: string, specifier: string): string { + const cacheKey = createBundledEntryModulePathCacheKey(importMetaUrl, specifier); + const cached = resolvedModulePaths.get(cacheKey); + if (cached) { + return cached; + } const candidates = resolveBundledEntryModuleCandidates(importMetaUrl, specifier); const fallbackCandidate = candidates[0] ?? { path: path.resolve(path.dirname(fileURLToPath(importMetaUrl)), specifier), @@ -304,6 +341,7 @@ function resolveBundledEntryModulePath(importMetaUrl: string, specifier: string) }); if (opened.ok) { fs.closeSync(opened.fd); + resolvedModulePaths.set(cacheKey, opened.path); return opened.path; } firstFailure ??= { candidate, failure: opened };