From 6e36c02adedd944538c5309cdff2dbd19eca0b08 Mon Sep 17 00:00:00 2001 From: Danielv123 Date: Sun, 11 Oct 2026 13:22:14 +0200 Subject: [PATCH] Load plugins installed from npm by package name Plugins installed from npm are listed by package name, but the plugin loader joined that name onto the main and entrypoint paths and imported the result as a file, which resolves relative to the working directory. Every npm installed plugin then failed to load. Resolve these paths from the directory of the plugin's package.json instead. --- packages/lib/src/plugin_loader.ts | 5 ++-- test/lib/plugin_loader.js | 44 ++++++++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/packages/lib/src/plugin_loader.ts b/packages/lib/src/plugin_loader.ts index 818efff1..d0b8c400 100644 --- a/packages/lib/src/plugin_loader.ts +++ b/packages/lib/src/plugin_loader.ts @@ -51,8 +51,9 @@ export async function loadPluginInfos(pluginList: Map) { try { pluginPackage = (await import(pathToFileURL(packagePath).href, { with: { type: "json" }})).default; + // pluginPath is a package name for plugins installed from npm, so resolve from the package itself pluginInfo = (await import( - pathToFileURL(path.posix.join(pluginPath, pluginPackage.main ?? "index.js")).href + pathToFileURL(path.join(path.dirname(packagePath), pluginPackage.main ?? "index.js")).href )).plugin; } catch (err: any) { if (err.code === "InstallationError") { @@ -106,7 +107,7 @@ export async function loadPlugin< return; } - const module = await import(pathToFileURL(path.posix.join(pluginInfo.requirePath, requirePath)).href); + const module = await import(pathToFileURL(path.join(path.dirname(pluginInfo.packagePath), requirePath)).href); const pluginContext: libPlugin.PluginLoadContext = { ...context, plugin: pluginInfo, diff --git a/test/lib/plugin_loader.js b/test/lib/plugin_loader.js index 412638ff..ec08f515 100644 --- a/test/lib/plugin_loader.js +++ b/test/lib/plugin_loader.js @@ -66,6 +66,43 @@ describe("lib/plugin_loader", function() { { message: `PluginError: ${brokenMessage}` } ); }); + describe("plugin installed from npm", function() { + // Plugins from npm are listed by package name, so they must be resolved like a dependency + const packageDir = path.resolve("node_modules", "@clusterio-test", "npm_plugin"); + before(async function() { + await fs.mkdir(path.join(packageDir, "dist"), { recursive: true }); + await fs.writeFile(path.join(packageDir, "package.json"), JSON.stringify({ + name: "@clusterio-test/npm_plugin", version: "0.0.2", main: "dist/index.js", type: "module", + })); + await fs.writeFile( + path.join(packageDir, "dist", "index.js"), + 'export const plugin = { name: "npm_plugin", controllerEntrypoint: "dist/controller.js" };', + ); + await fs.writeFile( + path.join(packageDir, "dist", "controller.js"), + "export default async function(context) { context.controller.loadedNpmPlugin = true; }", + ); + }); + after(async function() { + await fs.rm(path.dirname(packageDir), { recursive: true, force: true }); + }); + + it("should load the plugin and its entrypoints", async function() { + const [pluginInfo] = await lib.loadPluginInfos( + new Map([["npm_plugin", "@clusterio-test/npm_plugin"]]) + ); + assert.equal(pluginInfo.name, "npm_plugin"); + assert.equal(pluginInfo.npmPackage, "@clusterio-test/npm_plugin"); + assert.equal(pluginInfo.packagePath, path.join(packageDir, "package.json")); + + const controller = new mock.MockController(); + await lib.loadPlugin( + pluginInfo, "controller", { controller, metrics: {}, logger: new mock.MockLogger() }, + "ControllerPlugin", BaseControllerPlugin, + ); + assert.equal(controller.loadedNpmPlugin, true); + }); + }); it("should reject on invalid plugin", async function() { await assert.rejects( lib.loadPluginInfos(new Map([["invalid", path.resolve(invalidPlugin)]])), @@ -122,7 +159,12 @@ describe("lib/plugin_loader", function() { }); function info(requirePath, entrypoint = "controller.js") { - return { name: "test", requirePath, controllerEntrypoint: entrypoint }; + return { + name: "test", + requirePath, + packagePath: path.join(requirePath, "package.json"), + controllerEntrypoint: entrypoint, + }; } it("should do nothing when the entrypoint is not set", async function() {