diff --git a/packages/frontend-app-api/report.api.md b/packages/frontend-app-api/report.api.md index bdc9caaa0b..f24d662df4 100644 --- a/packages/frontend-app-api/report.api.md +++ b/packages/frontend-app-api/report.api.md @@ -8,8 +8,7 @@ import { AppTree } from '@backstage/frontend-plugin-api'; import { ConfigApi } from '@backstage/core-plugin-api'; import { ExtensionFactoryMiddleware } from '@backstage/frontend-plugin-api'; import { ExternalRouteRef } from '@backstage/frontend-plugin-api'; -import { FrontendModule } from '@backstage/frontend-plugin-api'; -import { FrontendPlugin } from '@backstage/frontend-plugin-api'; +import { FrontendFeature as FrontendFeature_2 } from '@backstage/frontend-plugin-api'; import { RouteRef } from '@backstage/frontend-plugin-api'; import { SubRouteRef } from '@backstage/frontend-plugin-api'; @@ -40,6 +39,6 @@ export function createSpecializedApp(options?: { tree: AppTree; }; -// @public (undocumented) -export type FrontendFeature = FrontendPlugin | FrontendModule; +// @public @deprecated (undocumented) +export type FrontendFeature = FrontendFeature_2; ``` diff --git a/packages/frontend-app-api/src/wiring/types.ts b/packages/frontend-app-api/src/wiring/types.ts index 6699f1c272..2bf08f327f 100644 --- a/packages/frontend-app-api/src/wiring/types.ts +++ b/packages/frontend-app-api/src/wiring/types.ts @@ -14,11 +14,13 @@ * limitations under the License. */ import { RouteRef } from '@backstage/frontend-plugin-api'; -import { FrontendModule, FrontendPlugin } from '@backstage/frontend-plugin-api'; +import { FrontendFeature as PluginApiFrontendFeature } from '@backstage/frontend-plugin-api'; import { BackstageRouteObject } from '../routing/types'; -/** @public */ -export type FrontendFeature = FrontendPlugin | FrontendModule; +/** @public + * @deprecated Use {@link @backstage/frontend-plugin-api#FrontendFeature} instead. + */ +export type FrontendFeature = PluginApiFrontendFeature; /** @internal */ export type RouteInfo = { diff --git a/packages/frontend-defaults/report.api.md b/packages/frontend-defaults/report.api.md index ac115566b8..abee0b0ef0 100644 --- a/packages/frontend-defaults/report.api.md +++ b/packages/frontend-defaults/report.api.md @@ -7,7 +7,8 @@ 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'; -import { FrontendFeature } from '@backstage/frontend-app-api'; +import { FrontendFeature } from '@backstage/frontend-plugin-api'; +import { FrontendFeatureLoader } from '@backstage/frontend-plugin-api'; import { JSX as JSX_2 } from 'react'; import { default as React_2 } from 'react'; import { ReactNode } from 'react'; @@ -17,7 +18,7 @@ export function createApp(options?: CreateAppOptions): { createRoot(): JSX_2.Element; }; -// @public +// @public @deprecated export interface CreateAppFeatureLoader { getLoaderName(): string; load(options: { config: ConfigApi }): Promise<{ @@ -38,7 +39,11 @@ export interface CreateAppOptions { | ExtensionFactoryMiddleware | ExtensionFactoryMiddleware[]; // (undocumented) - features?: (FrontendFeature | CreateAppFeatureLoader)[]; + features?: ( + | FrontendFeature + | FrontendFeatureLoader + | CreateAppFeatureLoader + )[]; loadingComponent?: ReactNode; } @@ -50,12 +55,17 @@ export function createPublicSignInApp(options?: CreateAppOptions): { // @public (undocumented) export function discoverAvailableFeatures(config: Config): { features: FrontendFeature[]; + featureLoaders?: FrontendFeatureLoader[]; }; // @public (undocumented) export function resolveAsyncFeatures(options: { config: Config; - features?: (FrontendFeature | CreateAppFeatureLoader)[]; + features?: ( + | FrontendFeature + | FrontendFeatureLoader + | CreateAppFeatureLoader + )[]; }): Promise<{ features: FrontendFeature[]; }>; diff --git a/packages/frontend-defaults/src/createApp.tsx b/packages/frontend-defaults/src/createApp.tsx index 69278d33ed..7dd5a6eb15 100644 --- a/packages/frontend-defaults/src/createApp.tsx +++ b/packages/frontend-defaults/src/createApp.tsx @@ -19,6 +19,8 @@ import { ConfigApi, coreExtensionData, ExtensionFactoryMiddleware, + FrontendFeature, + FrontendFeatureLoader, } from '@backstage/frontend-plugin-api'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { defaultConfigLoaderSync } from '../../core-app-api/src/app/defaultConfigLoader'; @@ -27,7 +29,6 @@ import { overrideBaseUrlConfigs } from '../../core-app-api/src/app/overrideBaseU import { ConfigReader } from '@backstage/config'; import { CreateAppRouteBinder, - FrontendFeature, createSpecializedApp, } from '@backstage/frontend-app-api'; import appPlugin from '@backstage/plugin-app'; @@ -38,6 +39,7 @@ import { resolveAsyncFeatures } from './resolution'; * A source of dynamically loaded frontend features. * * @public + * @deprecated Use the {@link @backstage/frontend-plugin-api#createFrontendFeatureLoader} function instead. */ export interface CreateAppFeatureLoader { /** @@ -59,7 +61,11 @@ export interface CreateAppFeatureLoader { * @public */ export interface CreateAppOptions { - features?: (FrontendFeature | CreateAppFeatureLoader)[]; + features?: ( + | FrontendFeature + | FrontendFeatureLoader + | CreateAppFeatureLoader + )[]; configLoader?: () => Promise<{ config: ConfigApi }>; bindRoutes?(context: { bind: CreateAppRouteBinder }): void; /** @@ -94,15 +100,22 @@ export function createApp(options?: CreateAppOptions): { overrideBaseUrlConfigs(defaultConfigLoaderSync()), ); - const { features: discoveredFeatures } = discoverAvailableFeatures(config); - const { features: providedFeatures } = await resolveAsyncFeatures({ + const { + features: discoveredFeatures, + featureLoaders: discoveredFeatureLoaders, + } = discoverAvailableFeatures(config); + const { features: loadedFeatures } = await resolveAsyncFeatures({ config, - features: options?.features, + features: [ + ...discoveredFeatures, + ...(discoveredFeatureLoaders ?? []), + ...(options?.features ?? []), + ], }); const app = createSpecializedApp({ config, - features: [appPlugin, ...discoveredFeatures, ...providedFeatures], + features: [appPlugin, ...loadedFeatures], bindRoutes: options?.bindRoutes, extensionFactoryMiddleware: options?.extensionFactoryMiddleware, }); diff --git a/packages/frontend-defaults/src/discovery.test.ts b/packages/frontend-defaults/src/discovery.test.ts index 80be0b25c5..f939495da2 100644 --- a/packages/frontend-defaults/src/discovery.test.ts +++ b/packages/frontend-defaults/src/discovery.test.ts @@ -14,7 +14,10 @@ * limitations under the License. */ -import { createFrontendPlugin } from '@backstage/frontend-plugin-api'; +import { + createFrontendFeatureLoader, + createFrontendPlugin, +} from '@backstage/frontend-plugin-api'; import { discoverAvailableFeatures } from './discovery'; import { ConfigReader } from '@backstage/config'; @@ -51,6 +54,21 @@ describe('discoverAvailableFeatures', () => { }); }); + it('should discover a frontend feature loader', () => { + const testLoader = createFrontendFeatureLoader({ + loader() { + return []; + }, + }); + globalSpy.mockReturnValue({ + modules: [{ default: testLoader }], + }); + expect(discoverAvailableFeatures(config)).toEqual({ + features: [], + featureLoaders: [testLoader], + }); + }); + it('should ignore garbage', () => { globalSpy.mockReturnValueOnce({ modules: [{ default: null }] }); expect(discoverAvailableFeatures(config)).toEqual({ features: [] }); diff --git a/packages/frontend-defaults/src/discovery.ts b/packages/frontend-defaults/src/discovery.ts index 398d8a204f..473315d3bf 100644 --- a/packages/frontend-defaults/src/discovery.ts +++ b/packages/frontend-defaults/src/discovery.ts @@ -15,7 +15,10 @@ */ import { Config, ConfigReader } from '@backstage/config'; -import { FrontendFeature } from '@backstage/frontend-app-api'; +import { + FrontendFeature, + FrontendFeatureLoader, +} from '@backstage/frontend-plugin-api'; interface DiscoveryGlobal { modules: Array<{ name: string; export?: string; default: unknown }>; @@ -57,6 +60,7 @@ function readPackageDetectionConfig(config: Config) { */ export function discoverAvailableFeatures(config: Config): { features: FrontendFeature[]; + featureLoaders?: FrontendFeatureLoader[]; } { const discovered = ( window as { '__@backstage/discovered__'?: DiscoveryGlobal } @@ -67,20 +71,34 @@ export function discoverAvailableFeatures(config: Config): { return { features: [] }; } + const detectedExports = 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); + + if (detectedExports === undefined) { + return { + features: [], + }; + } + + const features = detectedExports.filter(isBackstageFeature); + const detectedFeatureLoaders = detectedExports.filter( + isBackstageFeatureLoader, + ); + const featureLoaders = + detectedFeatureLoaders.length > 0 ? detectedFeatureLoaders : undefined; + 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) ?? [], + features, + featureLoaders, }; } @@ -93,3 +111,14 @@ function isBackstageFeature(obj: unknown): obj is FrontendFeature { } return false; } + +export function isBackstageFeatureLoader( + obj: unknown, +): obj is FrontendFeatureLoader { + return ( + obj !== null && + typeof obj === 'object' && + '$$type' in obj && + obj.$$type === '@backstage/FrontendFeatureLoader' + ); +} diff --git a/packages/frontend-defaults/src/resolution.test.ts b/packages/frontend-defaults/src/resolution.test.ts index d833716666..609b975a33 100644 --- a/packages/frontend-defaults/src/resolution.test.ts +++ b/packages/frontend-defaults/src/resolution.test.ts @@ -15,7 +15,9 @@ */ import { + createFrontendFeatureLoader, createFrontendPlugin, + FrontendFeatureLoader, PageBlueprint, } from '@backstage/frontend-plugin-api'; import { CreateAppFeatureLoader } from './createApp'; @@ -69,7 +71,7 @@ describe('resolveAsyncFeatures', () => { ]); }); - it('supports feature loaders', async () => { + it('supports deprecated feature loaders', async () => { const loader: CreateAppFeatureLoader = { getLoaderName() { return 'test-loader'; @@ -118,7 +120,7 @@ describe('resolveAsyncFeatures', () => { ]); }); - it('should propagate errors thrown by feature loaders', async () => { + it('should propagate errors thrown by deprecated feature loaders', async () => { const loader: CreateAppFeatureLoader = { getLoaderName() { return 'test-loader'; @@ -137,4 +139,65 @@ describe('resolveAsyncFeatures', () => { `"Failed to read frontend features from loader 'test-loader', TypeError: boom"`, ); }); + + it('supports feature loaders', async () => { + const loader: FrontendFeatureLoader = createFrontendFeatureLoader({ + async loader({ config: _ }) { + return [ + 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: FrontendFeatureLoader = createFrontendFeatureLoader({ + async loader({ config: _ }) { + throw new TypeError('boom'); + }, + }); + + await expect(() => + resolveAsyncFeatures({ + config: mockApis.config(), + features: [loader], + }), + ).rejects.toThrow( + /^Failed to read frontend features from loader created at .*: TypeError: boom$/, + ); + }); }); diff --git a/packages/frontend-defaults/src/resolution.ts b/packages/frontend-defaults/src/resolution.ts index e51ecbc2d3..147aa06bc2 100644 --- a/packages/frontend-defaults/src/resolution.ts +++ b/packages/frontend-defaults/src/resolution.ts @@ -16,30 +16,93 @@ import { Config } from '@backstage/config'; import { stringifyError } from '@backstage/errors'; -import { FrontendFeature } from '@backstage/frontend-app-api'; +import { + FrontendFeature, + FrontendFeatureLoader, +} from '@backstage/frontend-plugin-api'; import { CreateAppFeatureLoader } from './createApp'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { isInternalFrontendFeatureLoader } from '../../frontend-plugin-api/src/wiring/createFrontendFeatureLoader'; +import { isBackstageFeatureLoader } from './discovery'; /** @public */ export async function resolveAsyncFeatures(options: { config: Config; - features?: (FrontendFeature | CreateAppFeatureLoader)[]; + features?: ( + | FrontendFeature + | FrontendFeatureLoader + | CreateAppFeatureLoader + )[]; }): Promise<{ features: FrontendFeature[] }> { - const features = []; - for (const entry of options.features ?? []) { - if ('load' in entry) { + const featuresOrLoaders: (FrontendFeature | FrontendFeatureLoader)[] = []; + + // Separate deprecated CreateAppFeatureLoader elements from the frontend features, + // and manage the deprecated elements first. + for (const item of options?.features ?? []) { + if ('load' in item) { try { - const result = await entry.load({ config: options.config }); - features.push(...result.features); + const result = await item.load({ config: options.config }); + featuresOrLoaders.push(...result.features); } catch (e) { throw new Error( - `Failed to read frontend features from loader '${entry.getLoaderName()}', ${stringifyError( + `Failed to read frontend features from loader '${item.getLoaderName()}', ${stringifyError( e, )}`, ); } } else { - features.push(entry); + featuresOrLoaders.push(item); } } - return { features }; + + const loadedFeatures: FrontendFeature[] = []; + const alreadyMetFeatureLoaders: FrontendFeatureLoader[] = []; + const maxRecursionDepth = 5; + + async function applyFeatureLoaders( + toLoad: (FrontendFeature | FrontendFeatureLoader)[], + recursionDepth: number, + ) { + if (featuresOrLoaders.length === 0) { + return; + } + + const featureLoaders: FrontendFeatureLoader[] = []; + for (const item of toLoad) { + if (isBackstageFeatureLoader(item)) { + featureLoaders.push(item); + } else { + loadedFeatures.push(item); + } + } + + for (const featureLoader of featureLoaders) { + if (alreadyMetFeatureLoaders.some(l => l === featureLoader)) { + continue; + } + if (isInternalFrontendFeatureLoader(featureLoader)) { + if (recursionDepth > maxRecursionDepth) { + throw new Error( + `Maximum feature loading recursion depth (${maxRecursionDepth}) reached for the feature loader ${featureLoader.description}`, + ); + } + alreadyMetFeatureLoaders.push(featureLoader); + let result: (FrontendFeature | FrontendFeatureLoader)[]; + try { + result = await featureLoader.loader({ config: options.config }); + } catch (e) { + throw new Error( + `Failed to read frontend features from loader ${ + featureLoader.description + }: ${stringifyError(e)}`, + ); + } + await applyFeatureLoaders(result, recursionDepth + 1); + } + } + } + + await applyFeatureLoaders(featuresOrLoaders, 1); + + return { features: loadedFeatures }; } diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index 3ae6451627..a8b5937af1 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -707,6 +707,40 @@ export function createExternalRouteRef< } >; +// @public (undocumented) +export function createFrontendFeatureLoader( + options: CreateFrontendFeatureLoaderOptions, +): FrontendFeatureLoader; + +// @public (undocumented) +export interface CreateFrontendFeatureLoaderOptions { + // (undocumented) + loader(deps: { config: ConfigApi }): + | Iterable< + | FrontendFeature + | FrontendFeatureLoader + | Promise<{ + default: FrontendFeature | FrontendFeatureLoader; + }> + > + | Promise< + Iterable< + | FrontendFeature + | FrontendFeatureLoader + | Promise<{ + default: FrontendFeature | FrontendFeatureLoader; + }> + > + > + | AsyncIterable< + | FrontendFeature + | FrontendFeatureLoader + | { + default: FrontendFeature | FrontendFeatureLoader; + } + >; +} + // @public (undocumented) export function createFrontendModule< TId extends string, @@ -1236,6 +1270,15 @@ export { FetchApi }; export { fetchApiRef }; +// @public (undocumented) +export type FrontendFeature = FrontendPlugin | FrontendModule; + +// @public (undocumented) +export interface FrontendFeatureLoader { + // (undocumented) + readonly $$type: '@backstage/FrontendFeatureLoader'; +} + // @public (undocumented) export interface FrontendModule { // (undocumented) diff --git a/packages/frontend-plugin-api/src/wiring/createFrontendFeatureLoader.test.ts b/packages/frontend-plugin-api/src/wiring/createFrontendFeatureLoader.test.ts new file mode 100644 index 0000000000..310623d541 --- /dev/null +++ b/packages/frontend-plugin-api/src/wiring/createFrontendFeatureLoader.test.ts @@ -0,0 +1,468 @@ +/* + * 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 React from 'react'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { createApp } from '../../../frontend-defaults/src/createApp'; +import { screen } from '@testing-library/react'; +import { createFrontendPlugin } from './createFrontendPlugin'; +import { JsonObject } from '@backstage/types'; +import { createExtension } from './createExtension'; +import { createExtensionDataRef } from './createExtensionDataRef'; +import { coreExtensionData } from './coreExtensionData'; +import { mockApis, renderWithEffects } from '@backstage/test-utils'; +import { createExtensionInput } from './createExtensionInput'; +import { + CreateFrontendFeatureLoaderOptions, + InternalFrontendFeatureLoader, + createFrontendFeatureLoader, + FrontendFeatureLoader, +} from './createFrontendFeatureLoader'; +import { FrontendFeature } from './types'; + +const nameExtensionDataRef = createExtensionDataRef().with({ + id: 'name', +}); + +function createTestAppRoot({ + features, + config = {}, +}: { + features: (FrontendFeature | FrontendFeatureLoader)[]; + config: JsonObject; +}) { + return createApp({ + features: [...features], + configLoader: async () => ({ config: mockApis.config({ data: config }) }), + }).createRoot(); +} + +describe('createFrontendFeatureLoader', () => { + it('should create several plugins with only one feature loader', async () => { + const featureLoader: FrontendFeatureLoader = createFrontendFeatureLoader({ + loader({ config }) { + const pluginIdPrefix = config.getOptionalString('pluginIdPrefix'); + const extensionNamePrefix = config.getOptionalString( + 'extensionNamePrefix', + ); + return [ + createFrontendPlugin({ + id: `${pluginIdPrefix}-1`, + extensions: [ + createExtension({ + name: '1', + attachTo: { + id: `${pluginIdPrefix}-output/output`, + input: 'names', + }, + output: [nameExtensionDataRef], + factory() { + return [nameExtensionDataRef(`${extensionNamePrefix}-1`)]; + }, + }), + ], + }) as FrontendFeature | FrontendFeatureLoader, + createFrontendPlugin({ + id: `${pluginIdPrefix}-2`, + extensions: [ + createExtension({ + name: '2', + attachTo: { + id: `${pluginIdPrefix}-output/output`, + input: 'names', + }, + output: [nameExtensionDataRef], + factory() { + return [nameExtensionDataRef(`${extensionNamePrefix}-2`)]; + }, + }), + ], + }) as FrontendFeature | FrontendFeatureLoader, + createFrontendPlugin({ + id: `${pluginIdPrefix}-output`, + extensions: [ + createExtension({ + name: 'output', + attachTo: { id: 'app', input: 'root' }, + inputs: { + names: createExtensionInput([nameExtensionDataRef]), + }, + output: [coreExtensionData.reactElement], + factory({ inputs }) { + return [ + coreExtensionData.reactElement( + React.createElement('span', {}, [ + `Names: ${inputs.names + .map(n => n.get(nameExtensionDataRef)) + .join(', ')}`, + ]), + ), + ]; + }, + }), + ], + }) as FrontendFeature | FrontendFeatureLoader, + ]; + }, + } as CreateFrontendFeatureLoaderOptions); + + expect(featureLoader).toBeDefined(); + expect(String(featureLoader)).toMatch( + /^FeatureLoader{description=created at '.*\/packages\/frontend-plugin-api\/src\/wiring\/createFrontendFeatureLoader\.test\.ts:.*'}$/, + ); + + await renderWithEffects( + createTestAppRoot({ + features: [featureLoader], + config: { + app: { extensions: [{ 'app/root': false }] }, + extensionNamePrefix: 'extension', + pluginIdPrefix: 'plugin', + }, + }), + ); + + await expect( + screen.findByText('Names: extension-1, extension-2'), + ).resolves.toBeInTheDocument(); + }); + + it('should propagate errors thrown by feature loaders', async () => { + const featureLoader: FrontendFeature | FrontendFeatureLoader = + createFrontendFeatureLoader({ + async loader(_) { + throw new TypeError('boom'); + }, + }); + + await expect( + renderWithEffects( + createTestAppRoot({ + features: [featureLoader], + config: {}, + }), + ), + ).rejects.toThrow( + /^Failed to read frontend features from loader created at '.*\/packages\/frontend-plugin-api\/src\/wiring\/createFrontendFeatureLoader\.test\.ts:.*': TypeError: boom$/, + ); + }); + + it('should support loading feature loaders', async () => { + const featureLoader: FrontendFeature | FrontendFeatureLoader = + createFrontendFeatureLoader({ + async loader(_) { + return [ + createFrontendFeatureLoader({ + async *loader(__) { + yield createFrontendPlugin({ + id: 'plugin-1', + extensions: [ + createExtension({ + name: '1', + attachTo: { + id: 'plugin-output/output', + input: 'names', + }, + output: [nameExtensionDataRef], + factory() { + return [nameExtensionDataRef('extension-1')]; + }, + }), + ], + }); + yield createFrontendFeatureLoader({ + loader: async ___ => [ + createFrontendPlugin({ + id: 'plugin-2', + extensions: [ + createExtension({ + name: '2', + attachTo: { + id: 'plugin-output/output', + input: 'names', + }, + output: [nameExtensionDataRef], + factory() { + return [nameExtensionDataRef('extension-2')]; + }, + }), + ], + }), + ], + }); + }, + }), + createFrontendPlugin({ + id: 'plugin-output', + extensions: [ + createExtension({ + name: 'output', + attachTo: { id: 'app', input: 'root' }, + inputs: { + names: createExtensionInput([nameExtensionDataRef]), + }, + output: [coreExtensionData.reactElement], + factory({ inputs }) { + return [ + coreExtensionData.reactElement( + React.createElement('span', {}, [ + `Names: ${inputs.names + .map(n => n.get(nameExtensionDataRef)) + .join(', ')}`, + ]), + ), + ]; + }, + }), + ], + }), + ]; + }, + }); + + expect(featureLoader).toBeDefined(); + expect(String(featureLoader)).toMatch( + /^FeatureLoader{description=created at '.*\/packages\/frontend-plugin-api\/src\/wiring\/createFrontendFeatureLoader\.test\.ts:.*'}$/, + ); + + await renderWithEffects( + createTestAppRoot({ + features: [featureLoader], + config: { + app: { extensions: [{ 'app/root': false }] }, + }, + }), + ); + + await expect( + screen.findByText('Names: extension-1, extension-2'), + ).resolves.toBeInTheDocument(); + }); + + it('should guard against infinite recursion of nested feature loaders', async () => { + const nestedFeatureLoaderHolder: { + loader?: FrontendFeature | FrontendFeatureLoader; + } = {}; + const featureLoader: FrontendFeature | FrontendFeatureLoader = + createFrontendFeatureLoader({ + loader: () => + [ + nestedFeatureLoaderHolder.loader, + createFrontendPlugin({ + id: 'plugin', + extensions: [ + createExtension({ + name: 'output', + attachTo: { id: 'app', input: 'root' }, + inputs: {}, + output: [coreExtensionData.reactElement], + factory() { + return [ + coreExtensionData.reactElement( + React.createElement('span', {}, [`My Content`]), + ), + ]; + }, + }), + ], + }), + ].filter( + (f): f is FrontendFeature | FrontendFeatureLoader => + f !== undefined, + ), + }); + nestedFeatureLoaderHolder.loader = featureLoader; + + expect(featureLoader).toBeDefined(); + expect(String(featureLoader)).toMatch( + /^FeatureLoader{description=created at '.*\/packages\/frontend-plugin-api\/src\/wiring\/createFrontendFeatureLoader\.test\.ts:.*'}$/, + ); + + await renderWithEffects( + createTestAppRoot({ + features: [featureLoader], + config: { + app: { extensions: [{ 'app/root': false }] }, + }, + }), + ); + }); + + it('should support multiple output formats', async () => { + const feature = createFrontendPlugin({ + id: 'test', + }); + const dynamicFeature = Promise.resolve({ default: feature }); + + async function extractResult(f: FrontendFeature | FrontendFeatureLoader) { + const internal = f as InternalFrontendFeatureLoader; + return internal.loader({ config: mockApis.config() }); + } + + await expect( + extractResult( + createFrontendFeatureLoader({ + loader() { + return [feature]; + }, + }), + ), + ).resolves.toEqual([feature]); + + await expect( + extractResult( + createFrontendFeatureLoader({ + async loader() { + return [feature]; + }, + }), + ), + ).resolves.toEqual([feature]); + + await expect( + extractResult( + createFrontendFeatureLoader({ + *loader() { + yield feature; + }, + }), + ), + ).resolves.toEqual([feature]); + + await expect( + extractResult( + createFrontendFeatureLoader({ + async *loader() { + yield feature; + }, + }), + ), + ).resolves.toEqual([feature]); + + await expect( + extractResult( + createFrontendFeatureLoader({ + loader() { + return [dynamicFeature]; + }, + }), + ), + ).resolves.toEqual([feature]); + + await expect( + extractResult( + createFrontendFeatureLoader({ + async loader() { + return [dynamicFeature]; + }, + }), + ), + ).resolves.toEqual([feature]); + + await expect( + extractResult( + createFrontendFeatureLoader({ + *loader() { + yield dynamicFeature; + }, + }), + ), + ).resolves.toEqual([feature]); + + await expect( + extractResult( + createFrontendFeatureLoader({ + async *loader() { + yield dynamicFeature; + }, + }), + ), + ).resolves.toEqual([feature]); + }); + + it('should limit feature loading recursion', async () => { + const plugin = createFrontendPlugin({ + id: 'plugin', + extensions: [ + createExtension({ + name: 'output', + attachTo: { id: 'app', input: 'root' }, + inputs: {}, + output: [coreExtensionData.reactElement], + factory() { + return [ + coreExtensionData.reactElement( + React.createElement('span', {}, [`My Content`]), + ), + ]; + }, + }), + ], + }); + + const featureLoader: FrontendFeature | FrontendFeatureLoader = + createFrontendFeatureLoader({ + loader: () => [ + createFrontendFeatureLoader({ + loader: () => [ + createFrontendFeatureLoader({ + loader: () => [ + createFrontendFeatureLoader({ + loader: () => [ + createFrontendFeatureLoader({ + loader: () => [ + createFrontendFeatureLoader({ + loader: () => [plugin], + }), + ], + }), + ], + }), + ], + }), + ], + }), + ], + }); + + await expect( + renderWithEffects( + createTestAppRoot({ + features: [featureLoader], + config: { + app: { extensions: [{ 'app/root': false }] }, + }, + }), + ), + ).rejects.toThrow( + /^Maximum feature loading recursion depth \(5\) reached for the feature loader created at '.*\/packages\/frontend-plugin-api\/src\/wiring\/createFrontendFeatureLoader\.test\.ts:.*'$/, + ); + + const nestedLoaders = await ( + featureLoader as InternalFrontendFeatureLoader + ).loader({ config: mockApis.config() }); + await renderWithEffects( + createTestAppRoot({ + features: [nestedLoaders[0]], + config: { + app: { extensions: [{ 'app/root': false }] }, + }, + }), + ); + + await expect(screen.findByText('My Content')).resolves.toBeInTheDocument(); + }); +}); diff --git a/packages/frontend-plugin-api/src/wiring/createFrontendFeatureLoader.ts b/packages/frontend-plugin-api/src/wiring/createFrontendFeatureLoader.ts new file mode 100644 index 0000000000..ef71a79db8 --- /dev/null +++ b/packages/frontend-plugin-api/src/wiring/createFrontendFeatureLoader.ts @@ -0,0 +1,129 @@ +/* + * 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 { ConfigApi } from '../apis/definitions'; +import { describeParentCallSite } from '../routing/describeParentCallSite'; +import { FrontendFeature } from './types'; + +/** @public */ +export interface CreateFrontendFeatureLoaderOptions { + loader(deps: { + config: ConfigApi; + }): + | Iterable< + | FrontendFeature + | FrontendFeatureLoader + | Promise<{ default: FrontendFeature | FrontendFeatureLoader }> + > + | Promise< + Iterable< + | FrontendFeature + | FrontendFeatureLoader + | Promise<{ default: FrontendFeature | FrontendFeatureLoader }> + > + > + | AsyncIterable< + | FrontendFeature + | FrontendFeatureLoader + | { default: FrontendFeature | FrontendFeatureLoader } + >; +} + +/** @public */ +export interface FrontendFeatureLoader { + readonly $$type: '@backstage/FrontendFeatureLoader'; +} + +/** @internal */ +export interface InternalFrontendFeatureLoader extends FrontendFeatureLoader { + readonly version: 'v1'; + readonly description: string; + readonly loader: (deps: { + config: ConfigApi; + }) => Promise<(FrontendFeature | FrontendFeatureLoader)[]>; +} + +/** @public */ +export function createFrontendFeatureLoader( + options: CreateFrontendFeatureLoaderOptions, +): FrontendFeatureLoader { + const description = `created at '${describeParentCallSite()}'`; + return { + $$type: '@backstage/FrontendFeatureLoader', + version: 'v1', + description, + toString() { + return `FeatureLoader{description=${description}}`; + }, + async loader(deps: { + config: ConfigApi; + }): Promise<(FrontendFeature | FrontendFeatureLoader)[]> { + const it = await options.loader(deps); + const result = new Array(); + for await (const item of it) { + if (isFeatureOrLoader(item)) { + result.push(item); + } else if ('default' in item) { + result.push(item.default); + } else { + throw new Error(`Invalid item "${item}"`); + } + } + return result; + }, + } as InternalFrontendFeatureLoader; +} + +/** @internal */ +export function isInternalFrontendFeatureLoader(opaque: { + $$type: string; +}): opaque is InternalFrontendFeatureLoader { + if (opaque.$$type === '@backstage/FrontendFeatureLoader') { + // Make sure we throw if invalid + toInternalFrontendFeatureLoader(opaque as FrontendFeatureLoader); + return true; + } + return false; +} + +/** @internal */ +export function toInternalFrontendFeatureLoader( + plugin: FrontendFeatureLoader, +): InternalFrontendFeatureLoader { + const internal = plugin as InternalFrontendFeatureLoader; + if (internal.$$type !== '@backstage/FrontendFeatureLoader') { + throw new Error(`Invalid plugin instance, bad type '${internal.$$type}'`); + } + if (internal.version !== 'v1') { + throw new Error( + `Invalid plugin instance, bad version '${internal.version}'`, + ); + } + return internal; +} + +function isFeatureOrLoader( + obj: unknown, +): obj is FrontendFeature | FrontendFeatureLoader { + if (obj !== null && typeof obj === 'object' && '$$type' in obj) { + return ( + obj.$$type === '@backstage/FrontendPlugin' || + obj.$$type === '@backstage/FrontendModule' || + obj.$$type === '@backstage/FrontendFeatureLoader' + ); + } + return false; +} diff --git a/packages/frontend-plugin-api/src/wiring/index.ts b/packages/frontend-plugin-api/src/wiring/index.ts index b5cefc75fb..b584fc9769 100644 --- a/packages/frontend-plugin-api/src/wiring/index.ts +++ b/packages/frontend-plugin-api/src/wiring/index.ts @@ -46,6 +46,11 @@ export { type FrontendModule, type CreateFrontendModuleOptions, } from './createFrontendModule'; +export { + createFrontendFeatureLoader, + type FrontendFeatureLoader, + type CreateFrontendFeatureLoaderOptions, +} from './createFrontendFeatureLoader'; export { type Extension } from './resolveExtensionDefinition'; export { type AnyRoutes, @@ -53,6 +58,7 @@ export { type ExtensionDataContainer, type FeatureFlagConfig, type ExtensionFactoryMiddleware, + type FrontendFeature, } from './types'; export { type CreateExtensionBlueprintOptions, diff --git a/packages/frontend-plugin-api/src/wiring/types.ts b/packages/frontend-plugin-api/src/wiring/types.ts index 6d3e7a4abd..22bcbdadb7 100644 --- a/packages/frontend-plugin-api/src/wiring/types.ts +++ b/packages/frontend-plugin-api/src/wiring/types.ts @@ -23,6 +23,8 @@ import { ExtensionDataValue, } from './createExtensionDataRef'; import { ApiHolder, AppNode } from '../apis'; +import { FrontendModule } from './createFrontendModule'; +import { FrontendPlugin } from './createFrontendPlugin'; /** * Feature flag configuration. @@ -80,3 +82,6 @@ export type ExtensionFactoryMiddleware = ( config?: JsonObject; }, ) => Iterable>; + +/** @public */ +export type FrontendFeature = FrontendPlugin | FrontendModule;