Introduce a createFrontendFeatureLoader
Signed-off-by: David Festal <dfestal@redhat.com>
This commit is contained in:
@@ -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;
|
||||
```
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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[];
|
||||
}>;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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: [] });
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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$/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<string>().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<FrontendFeature | FrontendFeatureLoader>(
|
||||
(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();
|
||||
});
|
||||
});
|
||||
@@ -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<FrontendFeature | FrontendFeatureLoader>();
|
||||
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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<ExtensionDataValue<any, any>>;
|
||||
|
||||
/** @public */
|
||||
export type FrontendFeature = FrontendPlugin | FrontendModule;
|
||||
|
||||
Reference in New Issue
Block a user