diff --git a/.changeset/mira-color-like.md b/.changeset/mira-color-like.md new file mode 100644 index 0000000000..f6f82f44d7 --- /dev/null +++ b/.changeset/mira-color-like.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-defaults': patch +--- + +Feature discovery and resolution logic used in `createApp` is now exposed via the `discoverAvailableFeatures` and `resolveAsyncFeatures` functions respectively. diff --git a/packages/frontend-defaults/report.api.md b/packages/frontend-defaults/report.api.md index 5936bcd3b6..ac115566b8 100644 --- a/packages/frontend-defaults/report.api.md +++ b/packages/frontend-defaults/report.api.md @@ -3,6 +3,7 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import { Config } from '@backstage/config'; import { ConfigApi } from '@backstage/frontend-plugin-api'; import { CreateAppRouteBinder } from '@backstage/frontend-app-api'; import { ExtensionFactoryMiddleware } from '@backstage/frontend-plugin-api'; @@ -45,4 +46,17 @@ export interface CreateAppOptions { export function createPublicSignInApp(options?: CreateAppOptions): { createRoot(): React_2.JSX.Element; }; + +// @public (undocumented) +export function discoverAvailableFeatures(config: Config): { + features: FrontendFeature[]; +}; + +// @public (undocumented) +export function resolveAsyncFeatures(options: { + config: Config; + features?: (FrontendFeature | CreateAppFeatureLoader)[]; +}): Promise<{ + features: FrontendFeature[]; +}>; ``` diff --git a/packages/frontend-defaults/src/createApp.tsx b/packages/frontend-defaults/src/createApp.tsx index abf2151434..69278d33ed 100644 --- a/packages/frontend-defaults/src/createApp.tsx +++ b/packages/frontend-defaults/src/createApp.tsx @@ -20,19 +20,19 @@ import { coreExtensionData, ExtensionFactoryMiddleware, } from '@backstage/frontend-plugin-api'; -import { stringifyError } from '@backstage/errors'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { defaultConfigLoaderSync } from '../../core-app-api/src/app/defaultConfigLoader'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { overrideBaseUrlConfigs } from '../../core-app-api/src/app/overrideBaseUrlConfigs'; -import { getAvailableFeatures } from './discovery'; import { ConfigReader } from '@backstage/config'; -import appPlugin from '@backstage/plugin-app'; import { CreateAppRouteBinder, FrontendFeature, createSpecializedApp, } from '@backstage/frontend-app-api'; +import appPlugin from '@backstage/plugin-app'; +import { discoverAvailableFeatures } from './discovery'; +import { resolveAsyncFeatures } from './resolution'; /** * A source of dynamically loaded frontend features. @@ -94,25 +94,11 @@ export function createApp(options?: CreateAppOptions): { overrideBaseUrlConfigs(defaultConfigLoaderSync()), ); - const discoveredFeatures = getAvailableFeatures(config); - - const providedFeatures: FrontendFeature[] = []; - for (const entry of options?.features ?? []) { - if ('load' in entry) { - try { - const result = await entry.load({ config }); - providedFeatures.push(...result.features); - } catch (e) { - throw new Error( - `Failed to read frontend features from loader '${entry.getLoaderName()}', ${stringifyError( - e, - )}`, - ); - } - } else { - providedFeatures.push(entry); - } - } + const { features: discoveredFeatures } = discoverAvailableFeatures(config); + const { features: providedFeatures } = await resolveAsyncFeatures({ + config, + features: options?.features, + }); const app = createSpecializedApp({ config, diff --git a/packages/frontend-defaults/src/discovery.test.ts b/packages/frontend-defaults/src/discovery.test.ts index 8570ca1bc7..80be0b25c5 100644 --- a/packages/frontend-defaults/src/discovery.test.ts +++ b/packages/frontend-defaults/src/discovery.test.ts @@ -15,7 +15,7 @@ */ import { createFrontendPlugin } from '@backstage/frontend-plugin-api'; -import { getAvailableFeatures } from './discovery'; +import { discoverAvailableFeatures } from './discovery'; import { ConfigReader } from '@backstage/config'; const globalSpy = jest.fn(); @@ -27,18 +27,18 @@ const config = new ConfigReader({ app: { experimental: { packages: 'all' } }, }); -describe('getAvailableFeatures', () => { +describe('discoverAvailableFeatures', () => { afterEach(jest.resetAllMocks); it('should discover nothing with undefined global', () => { - expect(getAvailableFeatures(config)).toEqual([]); + expect(discoverAvailableFeatures(config)).toEqual({ features: [] }); }); it('should discover nothing with empty global', () => { globalSpy.mockReturnValue({ modules: [], }); - expect(getAvailableFeatures(config)).toEqual([]); + expect(discoverAvailableFeatures(config)).toEqual({ features: [] }); }); it('should discover a plugin', () => { @@ -46,24 +46,26 @@ describe('getAvailableFeatures', () => { globalSpy.mockReturnValue({ modules: [{ default: testPlugin }], }); - expect(getAvailableFeatures(config)).toEqual([testPlugin]); + expect(discoverAvailableFeatures(config)).toEqual({ + features: [testPlugin], + }); }); it('should ignore garbage', () => { globalSpy.mockReturnValueOnce({ modules: [{ default: null }] }); - expect(getAvailableFeatures(config)).toEqual([]); + expect(discoverAvailableFeatures(config)).toEqual({ features: [] }); globalSpy.mockReturnValueOnce({ modules: [{ default: undefined }] }); - expect(getAvailableFeatures(config)).toEqual([]); + expect(discoverAvailableFeatures(config)).toEqual({ features: [] }); globalSpy.mockReturnValueOnce({ modules: [{ default: Symbol() }] }); - expect(getAvailableFeatures(config)).toEqual([]); + expect(discoverAvailableFeatures(config)).toEqual({ features: [] }); globalSpy.mockReturnValueOnce({ modules: [{ default: () => {} }] }); - expect(getAvailableFeatures(config)).toEqual([]); + expect(discoverAvailableFeatures(config)).toEqual({ features: [] }); globalSpy.mockReturnValueOnce({ modules: [{ default: 0 }] }); - expect(getAvailableFeatures(config)).toEqual([]); + expect(discoverAvailableFeatures(config)).toEqual({ features: [] }); globalSpy.mockReturnValueOnce({ modules: [{ default: false }] }); - expect(getAvailableFeatures(config)).toEqual([]); + expect(discoverAvailableFeatures(config)).toEqual({ features: [] }); globalSpy.mockReturnValueOnce({ modules: [{ default: true }] }); - expect(getAvailableFeatures(config)).toEqual([]); + expect(discoverAvailableFeatures(config)).toEqual({ features: [] }); }); it('should discover multiple plugins', () => { @@ -77,10 +79,8 @@ describe('getAvailableFeatures', () => { { default: test3Plugin }, ], }); - expect(getAvailableFeatures(config)).toEqual([ - test1Plugin, - test2Plugin, - test3Plugin, - ]); + expect(discoverAvailableFeatures(config)).toEqual({ + features: [test1Plugin, test2Plugin, test3Plugin], + }); }); }); diff --git a/packages/frontend-defaults/src/discovery.ts b/packages/frontend-defaults/src/discovery.ts index e04960dc48..e82f2acfda 100644 --- a/packages/frontend-defaults/src/discovery.ts +++ b/packages/frontend-defaults/src/discovery.ts @@ -53,32 +53,35 @@ function readPackageDetectionConfig(config: Config) { } /** - * @internal + * @public */ -export function getAvailableFeatures(config: Config): FrontendFeature[] { +export function discoverAvailableFeatures(config: Config): { + features: FrontendFeature[]; +} { const discovered = ( window as { '__@backstage/discovered__'?: DiscoveryGlobal } )['__@backstage/discovered__']; const detection = readPackageDetectionConfig(config); if (!detection) { - return []; + return { features: [] }; } - return ( - 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) ?? [] - ); + return { + features: + 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) ?? [], + }; } function isBackstageFeature(obj: unknown): obj is FrontendFeature { diff --git a/packages/frontend-defaults/src/index.ts b/packages/frontend-defaults/src/index.ts index 7ee60a2116..573cc9b08c 100644 --- a/packages/frontend-defaults/src/index.ts +++ b/packages/frontend-defaults/src/index.ts @@ -26,3 +26,5 @@ export { type CreateAppFeatureLoader, } from './createApp'; export { createPublicSignInApp } from './createPublicSignInApp'; +export { discoverAvailableFeatures } from './discovery'; +export { resolveAsyncFeatures } from './resolution'; diff --git a/packages/frontend-defaults/src/resolution.test.ts b/packages/frontend-defaults/src/resolution.test.ts new file mode 100644 index 0000000000..d833716666 --- /dev/null +++ b/packages/frontend-defaults/src/resolution.test.ts @@ -0,0 +1,140 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + createFrontendPlugin, + PageBlueprint, +} from '@backstage/frontend-plugin-api'; +import { CreateAppFeatureLoader } from './createApp'; +import { resolveAsyncFeatures } from './resolution'; +import { mockApis } from '@backstage/test-utils'; + +describe('resolveAsyncFeatures', () => { + it('returns empty array when no features are provided', async () => { + const { features } = await resolveAsyncFeatures({ + config: mockApis.config(), + }); + + expect(features).toEqual([]); + }); + + it('returns expected array when features are directly provided', async () => { + const { features } = await resolveAsyncFeatures({ + config: mockApis.config(), + features: [ + createFrontendPlugin({ + id: 'test-feature', + extensions: [ + PageBlueprint.make({ + params: { + defaultPath: '/', + loader: () => new Promise(() => {}), + }, + }), + ], + }), + ], + }); + + expect(features).toMatchObject([ + { + $$type: '@backstage/FrontendPlugin', + id: 'test-feature', + version: 'v1', + extensions: [ + { + $$type: '@backstage/Extension', + id: 'page:test-feature', + version: 'v2', + attachTo: { + id: 'app/routes', + input: 'routes', + }, + }, + ], + }, + ]); + }); + + it('supports feature loaders', async () => { + const loader: CreateAppFeatureLoader = { + getLoaderName() { + return 'test-loader'; + }, + async load() { + return { + features: [ + createFrontendPlugin({ + id: 'test', + extensions: [ + PageBlueprint.make({ + params: { + defaultPath: '/', + loader: () => new Promise(() => {}), + }, + }), + ], + }), + ], + }; + }, + }; + + const { features } = await resolveAsyncFeatures({ + config: mockApis.config(), + features: [loader], + }); + + expect(features).toMatchObject([ + { + $$type: '@backstage/FrontendPlugin', + id: 'test', + version: 'v1', + extensions: [ + { + $$type: '@backstage/Extension', + id: 'page:test', + version: 'v2', + attachTo: { + id: 'app/routes', + input: 'routes', + }, + }, + ], + }, + ]); + }); + + it('should propagate errors thrown by feature loaders', async () => { + const loader: CreateAppFeatureLoader = { + getLoaderName() { + return 'test-loader'; + }, + async load() { + throw new TypeError('boom'); + }, + }; + + await expect(() => + resolveAsyncFeatures({ + config: mockApis.config(), + features: [loader], + }), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to read frontend features from loader 'test-loader', TypeError: boom"`, + ); + }); +}); diff --git a/packages/frontend-defaults/src/resolution.ts b/packages/frontend-defaults/src/resolution.ts new file mode 100644 index 0000000000..e51ecbc2d3 --- /dev/null +++ b/packages/frontend-defaults/src/resolution.ts @@ -0,0 +1,45 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Config } from '@backstage/config'; +import { stringifyError } from '@backstage/errors'; +import { FrontendFeature } from '@backstage/frontend-app-api'; +import { CreateAppFeatureLoader } from './createApp'; + +/** @public */ +export async function resolveAsyncFeatures(options: { + config: Config; + features?: (FrontendFeature | CreateAppFeatureLoader)[]; +}): Promise<{ features: FrontendFeature[] }> { + const features = []; + for (const entry of options.features ?? []) { + if ('load' in entry) { + try { + const result = await entry.load({ config: options.config }); + features.push(...result.features); + } catch (e) { + throw new Error( + `Failed to read frontend features from loader '${entry.getLoaderName()}', ${stringifyError( + e, + )}`, + ); + } + } else { + features.push(entry); + } + } + return { features }; +}