mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
fix: keep Bun plugins on their native loader when module hooks exist (#163539)
Keep Bun plugin resolution on its native/Jiti and Bun.plugin path even when Module.registerHooks exists. Share one runtime predicate across all five loader decisions; Node keeps its hooks path. Preserve Bun's independently evolving require.resolve.paths capability through the existing legacy Jiti control, and document runtime ownership. The real throwing-hook regression fails before and passes after. All 282 plugin files match old Bun exactly, historical interop passes 68/68 on all three runtimes, and candidate plugin hooks fall from 177 to zero. Old-Bun median load time is unchanged; the residual 2.84% candidate-binary cost is assigned to the fork's registerHooks landing by the maintainer. Production LOC is neutral. P2 review is scoped-clean and exact-head CI37022559011 passes. The inherited local environment-count check remains477/476 on main; no variable or budget change is included.
This commit is contained in:
parent
1b8d4594fe
commit
eb50c3a61b
8 changed files with 95 additions and 65 deletions
|
|
@ -8,6 +8,8 @@ read_when:
|
|||
|
||||
Bun is an explicit opt-in runtime for OpenClaw's CLI, Gateway, and managed node host. Node remains the primary and recommended runtime for those installations. The macOS app uses the OpenClaw Bun fork for its bundled private runtime, described below. This reference covers Bun requirements and compatibility; see [Bun](/install/bun) for installation and opt-in steps, or [Node.js compatibility](/install/node-compatibility) for Node requirements.
|
||||
|
||||
Plugin resolution stays with Bun's native/Jiti loader and `Bun.plugin` on Bun, even when `Module.registerHooks` is available; Node uses `Module.registerHooks`.
|
||||
|
||||
## Requirements
|
||||
|
||||
OpenClaw requires **Bun 1.4.0+**, an available **`node:sqlite`** API, and the same [WAL-safe SQLite floor as Node](/install/node-compatibility#why-the-floors-exist).
|
||||
|
|
|
|||
|
|
@ -529,7 +529,9 @@ source so relative asset reads stay within that generation. Node executes compil
|
|||
JavaScript from a separate directory; its module URLs and CommonJS cache keys can
|
||||
differ from the source filenames.
|
||||
|
||||
Bun 1.4.2 uses its native/Jiti loader with a separate captured source artifact for
|
||||
Node owns plugin resolution through `Module.registerHooks`; Bun keeps its native/Jiti loader and `Bun.plugin` resolver even when `Module.registerHooks` exists.
|
||||
|
||||
Bun uses its native/Jiti loader with a separate captured source artifact for
|
||||
each managed instance. Reload prepares fresh TypeScript entries and helpers while
|
||||
existing consumers retain their old instance. Disposal removes that instance's
|
||||
captured cache records and files without evicting its replacement or the host SDK.
|
||||
|
|
|
|||
|
|
@ -6,6 +6,11 @@ import { isPathInside } from "../infra/path-guards.js";
|
|||
import { resolveGlobalSingleton } from "../shared/global-singleton.js";
|
||||
import { PLUGIN_SOURCE_CAPTURE_PREFIX } from "./plugin-source-capture-path.js";
|
||||
|
||||
/** Bun's native plugin resolver remains the owner even when Node hooks are available. */
|
||||
export function useNodeModuleHooks(): boolean {
|
||||
return !process.versions.bun && typeof Module.registerHooks === "function";
|
||||
}
|
||||
|
||||
// Resolution and Jiti must accept the same source family, including typed JSX variants.
|
||||
export const PLUGIN_SOURCE_MODULE_EXTENSIONS: readonly string[] = [
|
||||
".ts",
|
||||
|
|
@ -23,7 +28,7 @@ export function isPluginSourceModulePath(modulePath: string): boolean {
|
|||
// Failed ESM jobs survive require-cache eviction. Preserve an observed terminal error
|
||||
// if a retry hits that job, rather than transforming its rejected graph through Jiti.
|
||||
const nativeModuleLoadFailures = new Map<string, unknown>();
|
||||
type ResolveFilename = (
|
||||
export type ResolveFilename = (
|
||||
request: string,
|
||||
parent: NodeJS.Module | undefined,
|
||||
isMain: boolean,
|
||||
|
|
@ -408,21 +413,19 @@ function withNativeRequireAliases<T>(
|
|||
const resolveAlias =
|
||||
typeof aliasMap === "function" ? aliasMap : (specifier: string) => aliasMap[specifier];
|
||||
const originalResolveFilename = moduleWithResolver["_resolveFilename"];
|
||||
const esmHooks = moduleWithResolver.registerHooks?.({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
const parent = context.parentURL?.startsWith("file:")
|
||||
? fileURLToPath(context.parentURL)
|
||||
: undefined;
|
||||
const aliasTarget = resolveAlias(specifier, parent);
|
||||
if (aliasTarget) {
|
||||
return {
|
||||
shortCircuit: true,
|
||||
url: pathToFileURL(aliasTarget).href,
|
||||
};
|
||||
}
|
||||
return nextResolve(specifier, context);
|
||||
},
|
||||
});
|
||||
const esmHooks = useNodeModuleHooks()
|
||||
? Module.registerHooks({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
const parent = context.parentURL?.startsWith("file:")
|
||||
? fileURLToPath(context.parentURL)
|
||||
: undefined;
|
||||
const aliasTarget = resolveAlias(specifier, parent);
|
||||
return aliasTarget
|
||||
? { shortCircuit: true, url: pathToFileURL(aliasTarget).href }
|
||||
: nextResolve(specifier, context);
|
||||
},
|
||||
})
|
||||
: undefined;
|
||||
moduleWithResolver["_resolveFilename"] = ((request, parent, isMain, options) => {
|
||||
const aliasTarget = resolveAlias(request, parent?.filename);
|
||||
if (aliasTarget) {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
resolvePluginLoaderTryNative,
|
||||
isPluginSourceModulePath,
|
||||
supportsBunRuntimeOnResolveTargets,
|
||||
useNodeModuleHooks,
|
||||
} from "./native-module-require.js";
|
||||
import type { PluginModuleLoader } from "./plugin-cache-artifacts.js";
|
||||
import { bindPluginCacheRoot, getPluginCache, withPluginCache } from "./plugin-cache.js";
|
||||
|
|
@ -65,7 +66,6 @@ export function bindPluginInstanceModuleLoader(params: PluginInstanceModuleLoade
|
|||
});
|
||||
return;
|
||||
}
|
||||
const nativeHooks = typeof Module.registerHooks === "function";
|
||||
const sourceBuilds = new Map<string, ReturnType<typeof buildPluginTypeScriptSource>>();
|
||||
const sourceForOutput = (filename: string): PluginSourceFile => {
|
||||
for (const build of sourceBuilds.values()) {
|
||||
|
|
@ -120,7 +120,7 @@ export function bindPluginInstanceModuleLoader(params: PluginInstanceModuleLoade
|
|||
allowedParentRoots: [artifact.boundaryRoot],
|
||||
pluginSdkResolution: params.pluginSdkResolution,
|
||||
});
|
||||
if (!nativeHooks) {
|
||||
if (!useNodeModuleHooks()) {
|
||||
const capturedSource = artifact.resolve(params.source);
|
||||
artifact.prepareModule(capturedSource);
|
||||
const bunSourceFacts =
|
||||
|
|
|
|||
|
|
@ -35,6 +35,32 @@ function load(rootDir: string, entry: string, standalone = false) {
|
|||
}
|
||||
|
||||
describe("plugin module generations", () => {
|
||||
it.runIf(Boolean(process.versions.bun))(
|
||||
"keeps Bun-native plugin generations when Node module hooks are available",
|
||||
() => {
|
||||
const root = temp.make("plugin-bun-native-owner-");
|
||||
fs.writeFileSync(path.join(root, "index.ts"), "export const value: number = 42;");
|
||||
fs.writeFileSync(path.join(root, "index.cjs"), "exports.value = 42;");
|
||||
const previous = Object.getOwnPropertyDescriptor(Module, "registerHooks");
|
||||
Object.defineProperty(Module, "registerHooks", {
|
||||
configurable: true,
|
||||
value: () => {
|
||||
throw new Error("Bun plugin loading must not install Node module hooks");
|
||||
},
|
||||
});
|
||||
try {
|
||||
expect(load(root, "index.ts").value).toMatchObject({ value: 42 });
|
||||
expect(load(root, "index.cjs").value).toMatchObject({ value: 42 });
|
||||
} finally {
|
||||
if (previous) {
|
||||
Object.defineProperty(Module, "registerHooks", previous);
|
||||
} else {
|
||||
Reflect.deleteProperty(Module, "registerHooks");
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
...["ts", "mts", "mtsx"].flatMap((extension) =>
|
||||
["commonjs", undefined].map((type) => ({ extension, type, importOnly: false })),
|
||||
|
|
@ -118,9 +144,9 @@ describe("plugin module generations", () => {
|
|||
const first = load(root, entry).value as StartupPlugin;
|
||||
expect(first).toMatchObject(expected);
|
||||
expect(first.resolveThenRequire()).toBe(value);
|
||||
expect(first.requireProperties()).toEqual(
|
||||
process.versions.bun ? [true, true, true, false, true] : [true, true, true, true, true],
|
||||
);
|
||||
// Bun's lookup-path support follows its Jiti require, including native API improvements.
|
||||
const lookupPaths = process.versions.bun ? legacy.requireProperties()[3] : true;
|
||||
expect(first.requireProperties()).toEqual([true, true, true, lookupPaths, true]);
|
||||
expect(first.resolve()).toMatch(importOnly ? /import\.mjs$/ : /require\.cjs$/);
|
||||
if (importOnly) {
|
||||
expect(legacy.alias()).toBe(value);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
/** Caches plugin module loaders and native-load stats for runtime/source module imports. */
|
||||
import fs from "node:fs";
|
||||
import Module from "node:module";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import type { NodePath } from "@babel/traverse";
|
||||
|
|
@ -13,6 +12,7 @@ import {
|
|||
resolvePluginLoaderTryNative,
|
||||
tryNativeRequireJavaScriptModule,
|
||||
tryNativeRequireModule,
|
||||
useNodeModuleHooks,
|
||||
} from "./native-module-require.js";
|
||||
import { isPathInside, openPluginRootFileSync } from "./path-safety.js";
|
||||
import type { PluginModuleLoader } from "./plugin-cache-artifacts.js";
|
||||
|
|
@ -204,7 +204,7 @@ function resolvePluginModuleLoaderCacheEntry(params: ResolvePluginModuleLoaderCa
|
|||
pluginSdkResolution: params.pluginSdkResolution,
|
||||
});
|
||||
const moduleConfigCacheKey = `${tryNative ? "native" : "transform"}\0${aliases.cacheKey}`;
|
||||
const lazyNativeAliasFallback = tryNative && typeof Module.registerHooks !== "function";
|
||||
const lazyNativeAliasFallback = tryNative && !useNodeModuleHooks();
|
||||
const scopedCacheKey = `${loaderFilename}::${params.cacheScopeKey ? `${params.cacheScopeKey}::` : ""}${moduleConfigCacheKey}`;
|
||||
return {
|
||||
loaderFilename,
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@ import { escapeRegExp } from "../shared/regexp.js";
|
|||
import {
|
||||
isPluginSourceModulePath,
|
||||
supportsNativeModuleAliasHooks,
|
||||
useNodeModuleHooks,
|
||||
type BunPluginRuntime,
|
||||
type ResolveFilename,
|
||||
} from "./native-module-require.js";
|
||||
import { pluginCacheExistsSync, pluginCacheRealpathSync } from "./plugin-cache-files.js";
|
||||
import { getPluginSdkHostFacts } from "./plugin-cache-sdk.js";
|
||||
|
|
@ -21,13 +23,6 @@ import {
|
|||
type PluginSdkResolutionPreference,
|
||||
} from "./sdk-alias.js";
|
||||
|
||||
type ResolveFilename = (
|
||||
request: string,
|
||||
parent: NodeJS.Module | undefined,
|
||||
isMain: boolean,
|
||||
options?: { paths?: string[] },
|
||||
) => string;
|
||||
|
||||
type ModuleWithResolver = typeof Module & {
|
||||
_resolveFilename?: ResolveFilename;
|
||||
};
|
||||
|
|
@ -124,14 +119,10 @@ function resolveLoaderModulePath(options: InstallOpenClawPluginSdkNativeResolver
|
|||
}
|
||||
|
||||
function isNativeLoadableSdkTarget(targetPath: string): boolean {
|
||||
switch (path.extname(targetPath)) {
|
||||
case ".cjs":
|
||||
case ".js":
|
||||
case ".mjs":
|
||||
return true;
|
||||
default:
|
||||
return isPluginSourceModulePath(targetPath);
|
||||
}
|
||||
return (
|
||||
[".cjs", ".js", ".mjs"].includes(path.extname(targetPath)) ||
|
||||
isPluginSourceModulePath(targetPath)
|
||||
);
|
||||
}
|
||||
|
||||
const normalizePathForBoundary = (targetPath: string) =>
|
||||
|
|
@ -383,33 +374,35 @@ function installResolver(): void {
|
|||
moduleWithResolver[nodeResolveFilenameProperty] = ((request, parent, isMain, options) =>
|
||||
resolvePluginNativeAliasForParent(request, parent?.filename) ??
|
||||
previousResolveFilename(request, parent, isMain, options)) satisfies ResolveFilename;
|
||||
moduleWithResolver.registerHooks?.({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
const aliasTarget = resolveAliasTargetForParentUrl(specifier, context.parentURL);
|
||||
const resolved = aliasTarget
|
||||
? { shortCircuit: true, url: pathToFileURL(aliasTarget).href }
|
||||
: nextResolve(specifier, context);
|
||||
if (context.conditions.includes("import") && resolved.url.startsWith("file:")) {
|
||||
const filename = fileURLToPath(resolved.url);
|
||||
const sdkTarget = isPluginSdkAliasSpecifier(specifier)
|
||||
? aliasTarget
|
||||
: Array.from(getPluginCache().sdk.contexts.values()).some(({ sdkRoots }) =>
|
||||
sdkRoots.includes(path.dirname(filename)),
|
||||
)
|
||||
? resolveAliasTargetForParentUrl(
|
||||
`openclaw/plugin-sdk/${path.basename(filename, path.extname(filename))}`,
|
||||
context.parentURL,
|
||||
)
|
||||
: undefined;
|
||||
// Built plugins use relative SDK URLs. Match the authorized host alias before
|
||||
// evaluation so later synchronous loads never inherit an uninstantiated job.
|
||||
if (sdkTarget && pathToFileURL(sdkTarget).href === resolved.url) {
|
||||
Module.createRequire(import.meta.url)(sdkTarget);
|
||||
if (useNodeModuleHooks()) {
|
||||
Module.registerHooks({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
const aliasTarget = resolveAliasTargetForParentUrl(specifier, context.parentURL);
|
||||
const resolved = aliasTarget
|
||||
? { shortCircuit: true, url: pathToFileURL(aliasTarget).href }
|
||||
: nextResolve(specifier, context);
|
||||
if (context.conditions.includes("import") && resolved.url.startsWith("file:")) {
|
||||
const filename = fileURLToPath(resolved.url);
|
||||
const sdkTarget = isPluginSdkAliasSpecifier(specifier)
|
||||
? aliasTarget
|
||||
: Array.from(getPluginCache().sdk.contexts.values()).some(({ sdkRoots }) =>
|
||||
sdkRoots.includes(path.dirname(filename)),
|
||||
)
|
||||
? resolveAliasTargetForParentUrl(
|
||||
`openclaw/plugin-sdk/${path.basename(filename, path.extname(filename))}`,
|
||||
context.parentURL,
|
||||
)
|
||||
: undefined;
|
||||
// Built plugins use relative SDK URLs. Match the authorized host alias before
|
||||
// evaluation so later synchronous loads never inherit an uninstantiated job.
|
||||
if (sdkTarget && pathToFileURL(sdkTarget).href === resolved.url) {
|
||||
Module.createRequire(import.meta.url)(sdkTarget);
|
||||
}
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
},
|
||||
});
|
||||
return resolved;
|
||||
},
|
||||
});
|
||||
}
|
||||
installed = true;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type { Identifier } from "@babel/types";
|
|||
import { stringifyNonErrorCause } from "@openclaw/normalization-core/error-coercion";
|
||||
import { isPathInside } from "../infra/path-guards.js";
|
||||
import { createJiti } from "./jiti-factory.js";
|
||||
import { useNodeModuleHooks } from "./native-module-require.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
|
|
@ -23,6 +24,9 @@ export type PluginSourceFile = {
|
|||
|
||||
/** Compile captured source into a private namespace; native files stay with their capture owner. */
|
||||
export function buildPluginTypeScriptSource(root: string) {
|
||||
if (!useNodeModuleHooks()) {
|
||||
throw new Error("Plugin source builds require Node module hooks");
|
||||
}
|
||||
const directory = fs.mkdtempSync(path.join(path.dirname(root), ".source-"));
|
||||
const outputs = new Map<string, string>();
|
||||
const formats = new Map<string, "module" | "commonjs">();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue