diff --git a/docs/install/bun-compatibility.md b/docs/install/bun-compatibility.md index b00549d948f3..4e5328ce9ea9 100644 --- a/docs/install/bun-compatibility.md +++ b/docs/install/bun-compatibility.md @@ -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). diff --git a/docs/plugins/architecture.md b/docs/plugins/architecture.md index 1ad28a0e9bbe..4b6fe5247436 100644 --- a/docs/plugins/architecture.md +++ b/docs/plugins/architecture.md @@ -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. diff --git a/src/plugins/native-module-require.ts b/src/plugins/native-module-require.ts index 506e5247199f..4344b6e89a11 100644 --- a/src/plugins/native-module-require.ts +++ b/src/plugins/native-module-require.ts @@ -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(); -type ResolveFilename = ( +export type ResolveFilename = ( request: string, parent: NodeJS.Module | undefined, isMain: boolean, @@ -408,21 +413,19 @@ function withNativeRequireAliases( 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) { diff --git a/src/plugins/plugin-instance-module-loader.ts b/src/plugins/plugin-instance-module-loader.ts index f79239d1fe44..b8a390c35d4a 100644 --- a/src/plugins/plugin-instance-module-loader.ts +++ b/src/plugins/plugin-instance-module-loader.ts @@ -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>(); 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 = diff --git a/src/plugins/plugin-module-generation.test.ts b/src/plugins/plugin-module-generation.test.ts index e019848bcbcf..54bdfe5c4e08 100644 --- a/src/plugins/plugin-module-generation.test.ts +++ b/src/plugins/plugin-module-generation.test.ts @@ -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); diff --git a/src/plugins/plugin-module-loader-cache.ts b/src/plugins/plugin-module-loader-cache.ts index c763ddcaae25..e28f6e70d039 100644 --- a/src/plugins/plugin-module-loader-cache.ts +++ b/src/plugins/plugin-module-loader-cache.ts @@ -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, diff --git a/src/plugins/plugin-sdk-native-resolver.ts b/src/plugins/plugin-sdk-native-resolver.ts index 7d64f8e2057b..2e18419dd475 100644 --- a/src/plugins/plugin-sdk-native-resolver.ts +++ b/src/plugins/plugin-sdk-native-resolver.ts @@ -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; } diff --git a/src/plugins/plugin-source-build.ts b/src/plugins/plugin-source-build.ts index bd9654b9d972..4c5974ce6357 100644 --- a/src/plugins/plugin-source-build.ts +++ b/src/plugins/plugin-source-build.ts @@ -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(); const formats = new Map();