diff --git a/.changeset/chilled-sloths-rest.md b/.changeset/chilled-sloths-rest.md new file mode 100644 index 0000000000..1cad0be99f --- /dev/null +++ b/.changeset/chilled-sloths-rest.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-app-api': patch +--- + +Filters for discovered packages are now also applied at runtime. This makes it possible to disable packages through the `app.experimental.packages` config at runtime. diff --git a/.changeset/twelve-mirrors-brush.md b/.changeset/twelve-mirrors-brush.md new file mode 100644 index 0000000000..90725b5ec4 --- /dev/null +++ b/.changeset/twelve-mirrors-brush.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +The experimental package discovery will now always use the package name for include and exclude filtering, rather than the full module id. Entries pointing to a subpath export will now instead have an `export` field specifying the subpath that the import is from. diff --git a/packages/cli/src/lib/bundler/packageDetection.ts b/packages/cli/src/lib/bundler/packageDetection.ts index 10686f7c86..2095fe23e7 100644 --- a/packages/cli/src/lib/bundler/packageDetection.ts +++ b/packages/cli/src/lib/bundler/packageDetection.ts @@ -15,7 +15,7 @@ */ import { BackstagePackageJson } from '@backstage/cli-node'; -import { Config } from '@backstage/config'; +import { Config, ConfigReader } from '@backstage/config'; import chokidar from 'chokidar'; import fs from 'fs-extra'; import { join as joinPath, resolve as resolvePath } from 'path'; @@ -45,9 +45,19 @@ function readPackageDetectionConfig( return {}; } + if (typeof packages !== 'object' || Array.isArray(packages)) { + throw new Error( + "Invalid config at 'app.experimental.packages', expected object", + ); + } + const packagesConfig = new ConfigReader( + packages, + 'app.experimental.packages', + ); + return { - include: config.getOptionalStringArray('app.experimental.packages.include'), - exclude: config.getOptionalStringArray('app.experimental.packages.exclude'), + include: packagesConfig.getOptionalStringArray('include'), + exclude: packagesConfig.getOptionalStringArray('exclude'), }; } @@ -59,40 +69,47 @@ async function detectPackages( resolvePath(targetPath, 'package.json'), ); - return Object.keys(pkg.dependencies ?? {}) - .flatMap(depName => { - const depPackageJson: BackstagePackageJson = require(require.resolve( - `${depName}/package.json`, - { paths: [targetPath] }, - )); - if ( - ['frontend-plugin', 'frontend-plugin-module'].includes( - depPackageJson.backstage?.role ?? '', - ) - ) { - // Include alpha entry point if available. If there's no default export it will be ignored - const exp = depPackageJson.exports; - if (exp && typeof exp === 'object' && './alpha' in exp) { - return [depName, `${depName}/alpha`]; - } - return [depName]; - } + return Object.keys(pkg.dependencies ?? {}).flatMap(depName => { + if (exclude?.includes(depName)) { return []; - }) - .filter(name => { - if (exclude?.includes(name)) { - return false; + } + if (include && !include.includes(depName)) { + return []; + } + + const depPackageJson: BackstagePackageJson = require(require.resolve( + `${depName}/package.json`, + { paths: [targetPath] }, + )); + if ( + ['frontend-plugin', 'frontend-plugin-module'].includes( + depPackageJson.backstage?.role ?? '', + ) + ) { + // Include alpha entry point if available. If there's no default export it will be ignored + const exp = depPackageJson.exports; + if (exp && typeof exp === 'object' && './alpha' in exp) { + return [ + { name: depName, import: depName }, + { name: depName, export: './alpha', import: `${depName}/alpha` }, + ]; } - if (include && !include.includes(name)) { - return false; - } - return true; - }); + return [{ name: depName, import: depName }]; + } + return []; + }); } -async function writeDetectedPackagesModule(packageNames: string[]) { - const requirePackageScript = packageNames - ?.map(pkg => `{ name: '${pkg}', default: require('${pkg}').default }`) +async function writeDetectedPackagesModule( + pkgs: { name: string; export?: string; import: string }[], +) { + const requirePackageScript = pkgs + ?.map( + pkg => + `{ name: ${JSON.stringify(pkg.name)}, export: ${JSON.stringify( + pkg.export, + )}, default: require('${pkg.import}').default }`, + ) .join(','); await fs.writeFile( diff --git a/packages/frontend-app-api/config.d.ts b/packages/frontend-app-api/config.d.ts index 81c24d1185..5f5f877d3e 100644 --- a/packages/frontend-app-api/config.d.ts +++ b/packages/frontend-app-api/config.d.ts @@ -16,6 +16,14 @@ export interface Config { app?: { + experimental?: { + /** + * @visibility frontend + * @deepVisibility frontend + */ + packages?: 'all' | { include?: string[]; exclude?: string[] }; + }; + /** * @deepVisibility frontend */ diff --git a/packages/frontend-app-api/src/wiring/createApp.tsx b/packages/frontend-app-api/src/wiring/createApp.tsx index f262ecde72..4b7e4985f3 100644 --- a/packages/frontend-app-api/src/wiring/createApp.tsx +++ b/packages/frontend-app-api/src/wiring/createApp.tsx @@ -116,7 +116,7 @@ export interface ExtensionTree { export function createExtensionTree(options: { config: Config; }): ExtensionTree { - const features = getAvailableFeatures(); + const features = getAvailableFeatures(options.config); const { instances } = createInstances({ features, config: options.config, @@ -286,7 +286,7 @@ export function createApp(options: { overrideBaseUrlConfigs(defaultConfigLoaderSync()), ); - const discoveredFeatures = getAvailableFeatures(); + const discoveredFeatures = getAvailableFeatures(config); const loadedFeatures = (await options.featureLoader?.({ config })) ?? []; const allFeatures = Array.from( new Set([ diff --git a/packages/frontend-app-api/src/wiring/discovery.test.ts b/packages/frontend-app-api/src/wiring/discovery.test.ts index 54294bbd45..6008ce6746 100644 --- a/packages/frontend-app-api/src/wiring/discovery.test.ts +++ b/packages/frontend-app-api/src/wiring/discovery.test.ts @@ -16,24 +16,29 @@ import { createPlugin } from '@backstage/frontend-plugin-api'; import { getAvailableFeatures } from './discovery'; +import { ConfigReader } from '@backstage/config'; const globalSpy = jest.fn(); Object.defineProperty(global, '__@backstage/discovered__', { get: globalSpy, }); +const config = new ConfigReader({ + app: { experimental: { packages: 'all' } }, +}); + describe('getAvailableFeatures', () => { afterEach(jest.resetAllMocks); it('should discover nothing with undefined global', () => { - expect(getAvailableFeatures()).toEqual([]); + expect(getAvailableFeatures(config)).toEqual([]); }); it('should discover nothing with empty global', () => { globalSpy.mockReturnValue({ modules: [], }); - expect(getAvailableFeatures()).toEqual([]); + expect(getAvailableFeatures(config)).toEqual([]); }); it('should discover a plugin', () => { @@ -41,24 +46,24 @@ describe('getAvailableFeatures', () => { globalSpy.mockReturnValue({ modules: [{ default: testPlugin }], }); - expect(getAvailableFeatures()).toEqual([testPlugin]); + expect(getAvailableFeatures(config)).toEqual([testPlugin]); }); it('should ignore garbage', () => { globalSpy.mockReturnValueOnce({ modules: [{ default: null }] }); - expect(getAvailableFeatures()).toEqual([]); + expect(getAvailableFeatures(config)).toEqual([]); globalSpy.mockReturnValueOnce({ modules: [{ default: undefined }] }); - expect(getAvailableFeatures()).toEqual([]); + expect(getAvailableFeatures(config)).toEqual([]); globalSpy.mockReturnValueOnce({ modules: [{ default: Symbol() }] }); - expect(getAvailableFeatures()).toEqual([]); + expect(getAvailableFeatures(config)).toEqual([]); globalSpy.mockReturnValueOnce({ modules: [{ default: () => {} }] }); - expect(getAvailableFeatures()).toEqual([]); + expect(getAvailableFeatures(config)).toEqual([]); globalSpy.mockReturnValueOnce({ modules: [{ default: 0 }] }); - expect(getAvailableFeatures()).toEqual([]); + expect(getAvailableFeatures(config)).toEqual([]); globalSpy.mockReturnValueOnce({ modules: [{ default: false }] }); - expect(getAvailableFeatures()).toEqual([]); + expect(getAvailableFeatures(config)).toEqual([]); globalSpy.mockReturnValueOnce({ modules: [{ default: true }] }); - expect(getAvailableFeatures()).toEqual([]); + expect(getAvailableFeatures(config)).toEqual([]); }); it('should discover multiple plugins', () => { @@ -72,7 +77,7 @@ describe('getAvailableFeatures', () => { { default: test3Plugin }, ], }); - expect(getAvailableFeatures()).toEqual([ + expect(getAvailableFeatures(config)).toEqual([ test1Plugin, test2Plugin, test3Plugin, diff --git a/packages/frontend-app-api/src/wiring/discovery.ts b/packages/frontend-app-api/src/wiring/discovery.ts index 14786c93d9..1181992d53 100644 --- a/packages/frontend-app-api/src/wiring/discovery.ts +++ b/packages/frontend-app-api/src/wiring/discovery.ts @@ -14,28 +14,75 @@ * limitations under the License. */ +import { Config, ConfigReader } from '@backstage/config'; import { BackstagePlugin, ExtensionOverrides, } from '@backstage/frontend-plugin-api'; interface DiscoveryGlobal { - modules: Array<{ name: string; default: unknown }>; + modules: Array<{ name: string; export?: string; default: unknown }>; +} + +function readPackageDetectionConfig(config: Config) { + const packages = config.getOptional('app.experimental.packages'); + if (packages === undefined || packages === null) { + return undefined; + } + + if (typeof packages === 'string') { + if (packages !== 'all') { + throw new Error( + `Invalid app.experimental.packages mode, got '${packages}', expected 'all'`, + ); + } + return {}; + } + + if (typeof packages !== 'object' || Array.isArray(packages)) { + throw new Error( + "Invalid config at 'app.experimental.packages', expected object", + ); + } + const packagesConfig = new ConfigReader( + packages, + 'app.experimental.packages', + ); + + return { + include: packagesConfig.getOptionalStringArray('include'), + exclude: packagesConfig.getOptionalStringArray('exclude'), + }; } /** * @public */ -export function getAvailableFeatures(): ( - | BackstagePlugin - | ExtensionOverrides -)[] { +export function getAvailableFeatures( + config: Config, +): (BackstagePlugin | ExtensionOverrides)[] { const discovered = ( window as { '__@backstage/discovered__'?: DiscoveryGlobal } )['__@backstage/discovered__']; + const detection = readPackageDetectionConfig(config); + if (!detection) { + return []; + } + return ( - discovered?.modules.map(m => m.default).filter(isBackstageFeature) ?? [] + discovered?.modules + .filter(({ name }) => { + if (detection.exclude?.includes(name)) { + return false; + } + if (detection.include && !detection.include.includes(name)) { + return false; + } + return true; + }) + .map(m => m.default) + .filter(isBackstageFeature) ?? [] ); }