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 <dfestal@redhat.com>
Assisted-by: Cursor
This commit is contained in:
David Festal
2026-03-03 22:23:14 +01:00
parent 551d729723
commit 4074a227c2
11 changed files with 151 additions and 43 deletions
+5
View File
@@ -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.
+5 -1
View File
@@ -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.
@@ -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;
@@ -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,
});
@@ -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"
}
@@ -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
}
@@ -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,
@@ -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)
// - .../<plugin>/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;
}
-17
View File
@@ -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 <string>
--output-name <string>
--pre-packed-dir <string>
--verbose
-h, --help
```
### `backstage-cli-module-build package clean`
```
+2 -1
View File
@@ -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') },
});
-17
View File
@@ -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 <string>
--output-name <string>
--pre-packed-dir <string>
--verbose
-h, --help
```
### `backstage-cli package clean`
```