mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-13 12:36:04 +00:00
139 lines
4.5 KiB
TypeScript
139 lines
4.5 KiB
TypeScript
/**
|
|
* Channel plugin module loader.
|
|
*
|
|
* Loads JavaScript or source plugin modules through native require or cached TS loaders.
|
|
*/
|
|
import fs from "node:fs";
|
|
import { createRequire } from "node:module";
|
|
import path from "node:path";
|
|
import { openRootFileSync } from "../../infra/boundary-file-read.js";
|
|
import { isJavaScriptModulePath } from "../../plugins/native-module-require.js";
|
|
import {
|
|
getCachedPluginModuleLoader,
|
|
type PluginModuleLoaderCache,
|
|
type PluginModuleLoaderFactory,
|
|
} from "../../plugins/plugin-module-loader-cache.js";
|
|
|
|
const nodeRequire = createRequire(import.meta.url);
|
|
const SOURCE_MODULE_EXTENSIONS = new Set([".ts", ".tsx", ".mts", ".cts"]);
|
|
const jitiLoaders: PluginModuleLoaderCache = new Map();
|
|
let channelPluginModuleLoaderFactoryForTest: PluginModuleLoaderFactory | undefined;
|
|
|
|
/**
|
|
* Installs a test-only module loader factory for source channel plugin modules.
|
|
*/
|
|
export function setChannelPluginModuleLoaderFactoryForTest(
|
|
factory?: PluginModuleLoaderFactory,
|
|
): void {
|
|
channelPluginModuleLoaderFactoryForTest = factory;
|
|
jitiLoaders.clear();
|
|
}
|
|
|
|
function hasNativeSourceRequireHook(modulePath: string): boolean {
|
|
const extension = path.extname(modulePath).toLowerCase();
|
|
return (
|
|
SOURCE_MODULE_EXTENSIONS.has(extension) &&
|
|
typeof nodeRequire.extensions?.[extension] === "function"
|
|
);
|
|
}
|
|
|
|
function isSourceModulePath(modulePath: string): boolean {
|
|
return SOURCE_MODULE_EXTENSIONS.has(path.extname(modulePath).toLowerCase());
|
|
}
|
|
|
|
function loadModuleWithJiti(modulePath: string): unknown {
|
|
const loadWithJiti = getCachedPluginModuleLoader({
|
|
cache: jitiLoaders,
|
|
modulePath,
|
|
importerUrl: import.meta.url,
|
|
loaderFilename: import.meta.url,
|
|
tryNative: false,
|
|
cacheScopeKey: "channel-plugin-module-loader",
|
|
...(channelPluginModuleLoaderFactoryForTest
|
|
? { createLoader: channelPluginModuleLoaderFactoryForTest }
|
|
: {}),
|
|
});
|
|
return loadWithJiti(modulePath);
|
|
}
|
|
|
|
function loadModule(modulePath: string): unknown {
|
|
if (!isJavaScriptModulePath(modulePath) && !hasNativeSourceRequireHook(modulePath)) {
|
|
if (isSourceModulePath(modulePath)) {
|
|
// Local source plugins need the TS loader unless the current runtime has
|
|
// installed a native source require hook for that extension.
|
|
return loadModuleWithJiti(modulePath);
|
|
}
|
|
throw new Error(`channel plugin module must be built JavaScript: ${modulePath}`);
|
|
}
|
|
try {
|
|
return nodeRequire(modulePath);
|
|
} catch (error) {
|
|
if (isSourceModulePath(modulePath)) {
|
|
// Native source hooks can still fail on ESM/TS edge cases; fall back to
|
|
// the cached loader before surfacing the error.
|
|
return loadModuleWithJiti(modulePath);
|
|
}
|
|
throw new Error(`failed to load channel plugin module with native require: ${modulePath}`, {
|
|
cause: error,
|
|
});
|
|
}
|
|
}
|
|
|
|
function resolvePluginModuleCandidates(rootDir: string, specifier: string): string[] {
|
|
const normalizedSpecifier = specifier.replace(/\\/g, "/");
|
|
const resolvedPath = path.resolve(rootDir, normalizedSpecifier);
|
|
const ext = path.extname(resolvedPath);
|
|
if (ext) {
|
|
return [resolvedPath];
|
|
}
|
|
return [
|
|
resolvedPath,
|
|
`${resolvedPath}.ts`,
|
|
`${resolvedPath}.mts`,
|
|
`${resolvedPath}.js`,
|
|
`${resolvedPath}.mjs`,
|
|
`${resolvedPath}.cts`,
|
|
`${resolvedPath}.cjs`,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Resolves a plugin-relative module specifier to an existing candidate path.
|
|
*/
|
|
export function resolveExistingPluginModulePath(rootDir: string, specifier: string): string {
|
|
for (const candidate of resolvePluginModuleCandidates(rootDir, specifier)) {
|
|
if (fs.existsSync(candidate)) {
|
|
return candidate;
|
|
}
|
|
}
|
|
return path.resolve(rootDir, specifier);
|
|
}
|
|
|
|
/**
|
|
* Loads a channel plugin module after enforcing plugin-root file boundaries.
|
|
*/
|
|
export function loadChannelPluginModule(params: {
|
|
modulePath: string;
|
|
rootDir: string;
|
|
boundaryRootDir?: string;
|
|
boundaryLabel?: string;
|
|
}): unknown {
|
|
const opened = openRootFileSync({
|
|
absolutePath: params.modulePath,
|
|
rootPath: params.boundaryRootDir ?? params.rootDir,
|
|
boundaryLabel: params.boundaryLabel ?? "plugin root",
|
|
rejectHardlinks: false,
|
|
skipLexicalRootCheck: true,
|
|
});
|
|
if (!opened.ok) {
|
|
throw new Error(
|
|
`${params.boundaryLabel ?? "plugin"} module path escapes plugin root or fails alias checks`,
|
|
);
|
|
}
|
|
const safePath = opened.path;
|
|
// The boundary check opens the file to verify the path; close before loading
|
|
// through require/jiti so module evaluation owns its own descriptor lifecycle.
|
|
fs.closeSync(opened.fd);
|
|
return loadModule(safePath);
|
|
}
|