From 4074a227c25c6d859c2b9dc387f79d9c55c1438a Mon Sep 17 00:00:00 2001 From: David Festal Date: Tue, 3 Mar 2026 22:23:14 +0100 Subject: [PATCH] fix(backend-dynamic-feature-service): improve package resolution for bundled dynamic plugins Updated `resolvePackagePath` to correctly resolve `package.json` for dynamic plugins that bundle their own copy of `@backstage/backend-plugin-api`. This change ensures that the resolution works for both the host application and bundled dependencies, enhancing compatibility with plugins created using the `backstage-cli package bundle` command. Signed-off-by: David Festal Assisted-by: Cursor --- .changeset/brave-pens-argue.md | 5 +++ docs/tooling/cli/03-commands.md | 6 ++- .../dist/index.cjs.js | 28 +++++++++++++ .../@backstage/backend-plugin-api/index.js | 22 +++++++++++ .../backend-plugin-api/package.json | 6 +++ .../test-backend-bundled-dynamic/package.json | 31 +++++++++++++++ .../src/features/features.test.ts | 39 +++++++++++++++++++ .../src/loader/CommonJSModuleLoader.ts | 20 ++++++---- packages/cli-module-build/cli-report.md | 17 -------- packages/cli-module-build/src/index.ts | 3 +- packages/cli/cli-report.md | 17 -------- 11 files changed, 151 insertions(+), 43 deletions(-) create mode 100644 .changeset/brave-pens-argue.md create mode 100644 packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root-for-bundled/test-backend-bundled-dynamic/dist/index.cjs.js create mode 100644 packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root-for-bundled/test-backend-bundled-dynamic/node_modules/@backstage/backend-plugin-api/index.js create mode 100644 packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root-for-bundled/test-backend-bundled-dynamic/node_modules/@backstage/backend-plugin-api/package.json create mode 100644 packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root-for-bundled/test-backend-bundled-dynamic/package.json diff --git a/.changeset/brave-pens-argue.md b/.changeset/brave-pens-argue.md new file mode 100644 index 0000000000..2ae66c29cf --- /dev/null +++ b/.changeset/brave-pens-argue.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-dynamic-feature-service': patch +--- + +Fixed `resolvePackagePath` resolution for bundled dynamic plugins. When a plugin bundles its own copy of `@backstage/backend-plugin-api` inside `node_modules`, the `CommonJSModuleLoader` fallback now correctly resolves the plugin's `package.json` by name. Previously the fallback only applied when the resolution originated from the host application; it now also applies when originating from a bundled dependency, which is the case for plugins produced by the `backstage-cli package bundle` command. diff --git a/docs/tooling/cli/03-commands.md b/docs/tooling/cli/03-commands.md index 6feb031277..38bb16b3c4 100644 --- a/docs/tooling/cli/03-commands.md +++ b/docs/tooling/cli/03-commands.md @@ -38,7 +38,6 @@ The `package` command category, `yarn backstage-cli package --help`: ```text start [options] Start a package for local development build [options] Build a package for production deployment or publishing -bundle [options] Bundle a plugin for dynamic loading (backend or frontend) lint [options] [directories...] Lint a package test Run tests, forwarding args to Jest, defaulting to watch mode clean Delete cache directories @@ -207,6 +206,11 @@ Options: ## package bundle +:::caution Experimental +This command is experimental and may receive breaking changes in future releases +without a deprecation period. It is hidden from the main `--help` output. +::: + Bundle a plugin for dynamic loading. This creates a self-contained plugin package that can be deployed independently and loaded dynamically by a Backstage application. Supports both backend and frontend plugins. diff --git a/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root-for-bundled/test-backend-bundled-dynamic/dist/index.cjs.js b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root-for-bundled/test-backend-bundled-dynamic/dist/index.cjs.js new file mode 100644 index 0000000000..f3671bc0a7 --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root-for-bundled/test-backend-bundled-dynamic/dist/index.cjs.js @@ -0,0 +1,28 @@ +'use strict'; + +Object.defineProperty(exports, '__esModule', { value: true }); + +// This require resolves to the bundled proxy copy inside this plugin's +// own node_modules/@backstage/backend-plugin-api, NOT the host's copy. +var backendPluginApi = require('@backstage/backend-plugin-api'); + +// Triggers the _resolveFilename fallback: require.resolve runs from +// the bundled @backstage/backend-plugin-api whose mod.path is inside +// this plugin's node_modules. +var pkgDir = backendPluginApi.resolvePackagePath('plugin-test-backend-bundled'); + +const testBundledPlugin = backendPluginApi.createBackendPlugin({ + pluginId: "test-bundled", + register(env) { + env.registerInit({ + deps: { + logger: backendPluginApi.coreServices.rootLogger, + }, + async init({ logger }) { + logger.info("Bundled backend plugin loaded successfully"); + } + }); + } +}); + +exports.default = testBundledPlugin; diff --git a/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root-for-bundled/test-backend-bundled-dynamic/node_modules/@backstage/backend-plugin-api/index.js b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root-for-bundled/test-backend-bundled-dynamic/node_modules/@backstage/backend-plugin-api/index.js new file mode 100644 index 0000000000..70e57950be --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root-for-bundled/test-backend-bundled-dynamic/node_modules/@backstage/backend-plugin-api/index.js @@ -0,0 +1,22 @@ +'use strict'; + +// Proxy that simulates a bundled copy of @backstage/backend-plugin-api. +// Re-exports everything from the real workspace package, but provides a +// local resolvePackagePath so that require.resolve() runs from THIS +// file's context (mod.path inside node_modules/@backstage/backend-plugin-api). + +const nodePath = require('node:path'); +const real = require('../../../../../../../../../backend-plugin-api/src'); + +function resolvePackagePath(name) { + const args = Array.prototype.slice.call(arguments, 1); + const pkgJson = require.resolve(name + '/package.json'); + return nodePath.resolve.apply( + nodePath, + [nodePath.dirname(pkgJson)].concat(args), + ); +} + +module.exports = Object.assign({}, real, { + resolvePackagePath: resolvePackagePath, +}); diff --git a/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root-for-bundled/test-backend-bundled-dynamic/node_modules/@backstage/backend-plugin-api/package.json b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root-for-bundled/test-backend-bundled-dynamic/node_modules/@backstage/backend-plugin-api/package.json new file mode 100644 index 0000000000..f487169afe --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root-for-bundled/test-backend-bundled-dynamic/node_modules/@backstage/backend-plugin-api/package.json @@ -0,0 +1,6 @@ +{ + "name": "@backstage/backend-plugin-api", + "version": "0.0.0", + "description": "Proxy that re-exports the real @backstage/backend-plugin-api with a local resolvePackagePath, simulating a bundled copy.", + "main": "index.js" +} diff --git a/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root-for-bundled/test-backend-bundled-dynamic/package.json b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root-for-bundled/test-backend-bundled-dynamic/package.json new file mode 100644 index 0000000000..bb2dbf15a6 --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root-for-bundled/test-backend-bundled-dynamic/package.json @@ -0,0 +1,31 @@ +{ + "name": "plugin-test-backend-bundled-dynamic", + "version": "0.0.0", + "description": "A test dynamic backend plugin that bundles its own @backstage/backend-plugin-api.", + "backstage": { + "role": "backend-plugin", + "pluginId": "test-bundled", + "pluginPackages": [ + "plugin-test-backend-bundled" + ] + }, + "keywords": [ + "backstage", + "dynamic" + ], + "exports": { + ".": { + "require": "./dist/index.cjs.js", + "default": "./dist/index.cjs.js" + }, + "./package.json": "./package.json" + }, + "main": "./dist/index.cjs.js", + "files": [ + "dist" + ], + "dependencies": { + "@backstage/backend-plugin-api": "0.0.0" + }, + "bundleDependencies": true +} diff --git a/packages/backend-dynamic-feature-service/src/features/features.test.ts b/packages/backend-dynamic-feature-service/src/features/features.test.ts index ce15964c77..ac6c6ea510 100644 --- a/packages/backend-dynamic-feature-service/src/features/features.test.ts +++ b/packages/backend-dynamic-feature-service/src/features/features.test.ts @@ -465,6 +465,45 @@ Require stack: }); }); + it('should load a backend plugin that bundles its own @backstage/backend-plugin-api', async () => { + const dynamicPluginsLister = new DynamicPluginLister(); + const dynamicPluginsRootForBundled = resolvePath( + __dirname, + '__fixtures__/dynamic-plugins-root-for-bundled', + ); + await startTestBackend({ + features: [ + mockServices.rootConfig.factory({ + data: { + dynamicPlugins: { + rootDirectory: dynamicPluginsRootForBundled, + }, + backend: { + baseUrl: `http://localhost:0`, + }, + }, + }), + dynamicPluginsFeatureLoader({ + moduleLoader: logger => + jestFreeTypescriptAwareModuleLoader({ logger }), + }), + dynamicPluginsLister.feature(), + ], + }); + + expect(dynamicPluginsLister.loadedPlugins).toMatchObject([ + { + installer: { + kind: 'new', + }, + name: 'plugin-test-backend-bundled-dynamic', + platform: 'node', + role: 'backend-plugin', + version: '0.0.0', + }, + ]); + }); + describe('module federation support', () => { const createRemoteProviderPlugin = ( provider: FrontendRemoteResolverProvider, diff --git a/packages/backend-dynamic-feature-service/src/loader/CommonJSModuleLoader.ts b/packages/backend-dynamic-feature-service/src/loader/CommonJSModuleLoader.ts index 3cc640086f..8902decdfa 100644 --- a/packages/backend-dynamic-feature-service/src/loader/CommonJSModuleLoader.ts +++ b/packages/backend-dynamic-feature-service/src/loader/CommonJSModuleLoader.ts @@ -99,15 +99,21 @@ export class CommonJSModuleLoader implements ModuleLoader { ); } - // Are we trying to resolve a `package.json` from an originating module of the core backstage application - // (this is mostly done by calling `@backstage/backend-plugin-api/resolvePackagePath`). - const resolvingPackageJsonFromBackstageApplication = + // Is this a `resolvePackagePath` call from `@backstage/backend-plugin-api`? + // This covers both the host application's copy and a bundled copy living + // inside a dynamic plugin's own node_modules. + // The regex matches mod.path against the various ways the package can be resolved on disk + // (with optional subdirectory such as /src or /dist after the package name): + // - .../node_modules/@backstage/backend-plugin-api[/...] (npm-installed) + // - ...//node_modules/@backstage/backend-plugin-api[/...] (bundled) + // - .../packages/backend-plugin-api[/...] (symlinked workspace in monorepo) + const resolvingPackageJsonViaResolvePackagePath = request?.endsWith('/package.json') && - mod?.path && - !dynamicPluginsPaths.some(p => mod.path.startsWith(p)); + /[/\\](?:@backstage|packages)[/\\]backend-plugin-api(?:[/\\]|$)/.test( + mod?.path ?? '', + ); - // If not, we don't need the dedicated specific case below. - if (!resolvingPackageJsonFromBackstageApplication) { + if (!resolvingPackageJsonViaResolvePackagePath) { throw errorToThrow; } diff --git a/packages/cli-module-build/cli-report.md b/packages/cli-module-build/cli-report.md index 961cd69c28..1c11022dc8 100644 --- a/packages/cli-module-build/cli-report.md +++ b/packages/cli-module-build/cli-report.md @@ -38,7 +38,6 @@ Options: Commands: build - bundle clean help [command] postpack @@ -61,22 +60,6 @@ Options: -h, --help ``` -### `backstage-cli-module-build package bundle` - -``` -Usage: @backstage/cli-module-build package bundle - -Options: - --clean - --no-build - --no-install - --output-destination - --output-name - --pre-packed-dir - --verbose - -h, --help -``` - ### `backstage-cli-module-build package clean` ``` diff --git a/packages/cli-module-build/src/index.ts b/packages/cli-module-build/src/index.ts index 4b6a0b31c7..cf892db53c 100644 --- a/packages/cli-module-build/src/index.ts +++ b/packages/cli-module-build/src/index.ts @@ -38,7 +38,8 @@ export default createCliModule({ description: 'Bundle a plugin for dynamic loading. Creates a self-contained plugin ' + 'package that can be deployed and loaded dynamically by a Backstage application. ' + - 'Supports both backend and frontend plugins.', + 'Supports both backend and frontend plugins. Experimental.', + experimental: true, execute: { loader: () => import('./commands/package/bundle') }, }); diff --git a/packages/cli/cli-report.md b/packages/cli/cli-report.md index 15917c0280..bb22326786 100644 --- a/packages/cli/cli-report.md +++ b/packages/cli/cli-report.md @@ -317,7 +317,6 @@ Options: Commands: build - bundle clean help [command] lint @@ -342,22 +341,6 @@ Options: -h, --help ``` -### `backstage-cli package bundle` - -``` -Usage: backstage-cli package bundle - -Options: - --clean - --no-build - --no-install - --output-destination - --output-name - --pre-packed-dir - --verbose - -h, --help -``` - ### `backstage-cli package clean` ```