From b40b85c21ad61960e0db5b541b6f8ff10b7cff59 Mon Sep 17 00:00:00 2001 From: Effet Date: Fri, 24 Apr 2026 21:59:19 +0800 Subject: [PATCH] perf(plugins): use native require for compiled JS before jiti MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every CLI invocation reads the config snapshot, which pulls bundled channel doctor contracts and setup surfaces through `getCachedPluginJitiLoader`. jiti's TS→JS transform pipeline adds several seconds of per-load overhead on slower hosts (NAS profiling shows ~78% of `openclaw config get` wall time spent inside the jiti library), and that overhead is pure waste for the already-compiled `.js` artifacts shipped in dist/. Wrap the loader returned by `getCachedPluginJitiLoader` so that compiled JS targets go through `tryNativeRequireJavaScriptModule` first. Jiti stays on the hot path for: - TS/TSX/MTS/CTS sources - paths the native-require helper declines (Windows by default, or module-resolution fallbacks) This centralises the fast path that already existed — inside `doctor-contract-registry` and `channel-entry-contract` — and extends it to every caller that goes through the jiti loader cache. Benchmark on a modest NAS (Node 22.22, ZFS, telegram + discord configured): | command | before | after | |------------------|-------:|------:| | config get X | 24s | 6s | | status | 45s | 18s | | devices list | 55s | 26s | | nodes status | 55s | 26s | Fixes the slow config/status/devices/nodes read paths reported in openclaw#62842. Remaining time is dominated by non-jiti code paths (config schema validation, eager provider-plugin module eval) that are out of scope for this patch. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plugins/bundled.shape-guard.test.ts | 8 +++ src/plugins/jiti-loader-cache.test.ts | 56 +++++++++++++++++++ src/plugins/jiti-loader-cache.ts | 17 +++++- src/plugins/setup-registry.test.ts | 9 +++ 4 files changed, 89 insertions(+), 1 deletion(-) diff --git a/src/channels/plugins/bundled.shape-guard.test.ts b/src/channels/plugins/bundled.shape-guard.test.ts index fcba5bf999d..9201a476ec1 100644 --- a/src/channels/plugins/bundled.shape-guard.test.ts +++ b/src/channels/plugins/bundled.shape-guard.test.ts @@ -972,6 +972,14 @@ describe("bundled channel entry shape guards", () => { vi.doMock("../../plugins/channel-catalog-registry.js", () => ({ listChannelCatalogEntries: () => [], })); + // jiti-loader-cache prefers native require() for compiled .js before + // falling back to jiti. This test drives plugin loading via the jiti + // mock — disable the native-require fast path so the mocked jiti loader + // is exercised instead of loading the on-disk fixture directly. + vi.doMock("../../plugins/native-module-require.js", () => ({ + isJavaScriptModulePath: () => false, + tryNativeRequireJavaScriptModule: () => ({ ok: false }), + })); let reentered = false; vi.doMock("jiti", () => ({ diff --git a/src/plugins/jiti-loader-cache.test.ts b/src/plugins/jiti-loader-cache.test.ts index 9231571a622..b42e7f2c660 100644 --- a/src/plugins/jiti-loader-cache.test.ts +++ b/src/plugins/jiti-loader-cache.test.ts @@ -202,4 +202,60 @@ describe("getCachedPluginJitiLoader", () => { expect(firstAlias?.beta).toBe("/repo/alpha/sub"); expect((firstAlias as Record)[marker]).toBe(true); }); + + it("serves compiled .js targets from native require without invoking the jiti loader", async () => { + const jitiLoader = vi.fn(); + const createJiti = vi.fn(() => jitiLoader); + vi.doMock("jiti", () => ({ createJiti })); + vi.doMock("./native-module-require.js", () => ({ + isJavaScriptModulePath: (p: string) => + p.endsWith(".js") || p.endsWith(".mjs") || p.endsWith(".cjs"), + tryNativeRequireJavaScriptModule: (target: string) => ({ + ok: true, + moduleExport: { loadedFrom: target }, + }), + })); + const { getCachedPluginJitiLoader } = await importFreshModule< + typeof import("./jiti-loader-cache.js") + >(import.meta.url, "./jiti-loader-cache.js?scope=native-require-fastpath"); + + const cache = new Map(); + const loader = getCachedPluginJitiLoader({ + cache, + modulePath: "/repo/dist/extensions/demo/api.js", + importerUrl: "file:///repo/src/plugins/public-surface-loader.ts", + jitiFilename: "file:///repo/src/plugins/public-surface-loader.ts", + }); + + const result = loader("/repo/dist/extensions/demo/api.js") as { loadedFrom: string }; + expect(result.loadedFrom).toBe("/repo/dist/extensions/demo/api.js"); + // jiti is created eagerly, but its loader must NOT be invoked for .js + // targets that `tryNativeRequireJavaScriptModule` resolves. + expect(jitiLoader).not.toHaveBeenCalled(); + }); + + it("falls back to jiti when the native-require helper declines", async () => { + const jitiLoader = vi.fn(() => ({ fromJiti: true })); + const createJiti = vi.fn(() => jitiLoader); + vi.doMock("jiti", () => ({ createJiti })); + vi.doMock("./native-module-require.js", () => ({ + isJavaScriptModulePath: () => true, + tryNativeRequireJavaScriptModule: () => ({ ok: false }), + })); + const { getCachedPluginJitiLoader } = await importFreshModule< + typeof import("./jiti-loader-cache.js") + >(import.meta.url, "./jiti-loader-cache.js?scope=native-require-fallback"); + + const cache = new Map(); + const loader = getCachedPluginJitiLoader({ + cache, + modulePath: "/repo/dist/extensions/demo/api.js", + importerUrl: "file:///repo/src/plugins/public-surface-loader.ts", + jitiFilename: "file:///repo/src/plugins/public-surface-loader.ts", + }); + + const result = loader("/repo/dist/extensions/demo/api.js") as { fromJiti: boolean }; + expect(result.fromJiti).toBe(true); + expect(jitiLoader).toHaveBeenCalledWith("/repo/dist/extensions/demo/api.js"); + }); }); diff --git a/src/plugins/jiti-loader-cache.ts b/src/plugins/jiti-loader-cache.ts index 9d501a871b4..7e534d406e5 100644 --- a/src/plugins/jiti-loader-cache.ts +++ b/src/plugins/jiti-loader-cache.ts @@ -1,4 +1,5 @@ import { createJiti } from "jiti"; +import { tryNativeRequireJavaScriptModule } from "./native-module-require.js"; import { buildPluginLoaderJitiOptions, createPluginLoaderJitiCacheKey, @@ -74,10 +75,24 @@ export function getCachedPluginJitiLoader(params: { if (cached) { return cached; } - const loader = (params.createLoader ?? createJiti)(jitiFilename, { + const jitiLoader = (params.createLoader ?? createJiti)(jitiFilename, { ...buildPluginLoaderJitiOptions(aliasMap), tryNative, }); + // The returned loader prefers native require() for already-compiled JS + // artifacts (the bundled plugin public surfaces shipped in dist/) because + // jiti's transform pipeline provides no value for output that is already + // plain JS and adds several seconds of per-load overhead on slower hosts. + // Jiti stays on the hot path for TS / TSX and for the small set of + // require(esm)/async-module fallbacks `tryNativeRequireJavaScriptModule` + // declines to handle. + const loader = ((target: string) => { + const native = tryNativeRequireJavaScriptModule(target); + if (native.ok) { + return native.moduleExport; + } + return jitiLoader(target); + }) as PluginJitiLoader; params.cache.set(scopedCacheKey, loader); return loader; } diff --git a/src/plugins/setup-registry.test.ts b/src/plugins/setup-registry.test.ts index fe6a8e89ceb..4f7b4ae1fe0 100644 --- a/src/plugins/setup-registry.test.ts +++ b/src/plugins/setup-registry.test.ts @@ -8,6 +8,15 @@ import { resetRegistryJitiMocks, } from "./test-helpers/registry-jiti-mocks.js"; +// jiti-loader-cache prefers native require() for compiled .js before falling +// back to jiti. These tests scripts plugin-loading behaviour through the +// jiti mock — disable the native-require fast path so the mocked jiti loader +// stays authoritative for the test fixture files on disk. +vi.mock("./native-module-require.js", () => ({ + isJavaScriptModulePath: (_modulePath: string) => false, + tryNativeRequireJavaScriptModule: (_modulePath: string) => ({ ok: false }), +})); + const tempDirs: string[] = []; const mocks = getRegistryJitiMocks();