Initial implementation of an extension factory middleware

Signed-off-by: Eric Peterson <ericpeterson@spotify.com>
This commit is contained in:
Eric Peterson
2025-03-04 17:01:03 +01:00
parent 7a25af54b1
commit 4d18b55ecc
7 changed files with 85 additions and 17 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/frontend-app-api': patch
'@backstage/frontend-defaults': patch
---
It's now possible to provide a middleware that wraps all extension factories by passing an `extensionFactoryMiddleware` to either `createApp()` or `createSpecializedApp()`.
+12
View File
@@ -3,7 +3,10 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { ApiHolder } from '@backstage/core-plugin-api';
import { AppTree } from '@backstage/frontend-plugin-api';
import { ConfigApi } from '@backstage/core-plugin-api';
import { ExtensionDefinition } 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';
@@ -29,10 +32,19 @@ export function createSpecializedApp(options?: {
features?: FrontendFeature[];
config?: ConfigApi;
bindRoutes?(context: { bind: CreateAppRouteBinder }): void;
apis?: ApiHolder;
extensionFactoryMiddleware?: ExtensionFactoryMiddleware;
}): {
apis: ApiHolder;
createRoot(): JSX_2.Element;
tree: AppTree;
};
// @public (undocumented)
export type ExtensionFactoryMiddleware = Parameters<
ExtensionDefinition['override']
>[0]['factory'];
// @public (undocumented)
export type FrontendFeature =
| FrontendPlugin
@@ -26,6 +26,8 @@ import mapValues from 'lodash/mapValues';
import { AppNode, AppNodeInstance } from '@backstage/frontend-plugin-api';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { toInternalExtension } from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition';
import { ExtensionFactoryMiddleware } from '../wiring';
import { createExtensionDataContainer } from '@internal/frontend';
type Mutable<T> = {
-readonly [P in keyof T]: T[P];
@@ -242,6 +244,7 @@ function resolveV2Inputs(
/** @internal */
export function createAppNodeInstance(options: {
extensionFactoryMiddleware?: ExtensionFactoryMiddleware;
node: AppNode;
apis: ApiHolder;
attachments: ReadonlyMap<string, AppNode[]>;
@@ -251,9 +254,11 @@ export function createAppNodeInstance(options: {
const extensionData = new Map<string, unknown>();
const extensionDataRefs = new Set<ExtensionDataRef<unknown>>();
let parsedConfig: unknown;
let parsedConfig: { [x: string]: any };
try {
parsedConfig = extension.configSchema?.parse(config ?? {});
parsedConfig = extension.configSchema?.parse(config ?? {}) as {
[x: string]: any;
};
} catch (e) {
throw new Error(
`Invalid configuration for extension '${id}'; caused by ${e}`,
@@ -289,12 +294,30 @@ export function createAppNodeInstance(options: {
extensionDataRefs.add(ref);
}
} else if (internalExtension.version === 'v2') {
const outputDataValues = internalExtension.factory({
const context = {
node,
apis,
config: parsedConfig,
inputs: resolveV2Inputs(internalExtension.inputs, attachments),
});
};
const outputDataValues = options.extensionFactoryMiddleware
? createExtensionDataContainer(
options.extensionFactoryMiddleware(
({ config: configOverride } = {}) =>
createExtensionDataContainer(
internalExtension.factory(
configOverride
? {
...context,
config: configOverride,
}
: context,
),
),
context,
),
)
: internalExtension.factory(context);
const outputDataMap = new Map<string, unknown>();
for (const value of outputDataValues) {
@@ -356,6 +379,7 @@ export function createAppNodeInstance(options: {
export function instantiateAppNodeTree(
rootNode: AppNode,
apis: ApiHolder,
extensionFactoryMiddleware?: ExtensionFactoryMiddleware,
): void {
function createInstance(node: AppNode): AppNodeInstance | undefined {
if (node.instance) {
@@ -381,6 +405,7 @@ export function instantiateAppNodeTree(
}
(node as Mutable<AppNode>).instance = createAppNodeInstance({
extensionFactoryMiddleware,
node,
apis,
attachments: instantiatedAttachments,
@@ -69,7 +69,11 @@ import { ApiRegistry } from '../../../core-app-api/src/apis/system/ApiRegistry';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { AppIdentityProxy } from '../../../core-app-api/src/apis/implementations/IdentityApi/AppIdentityProxy';
import { BackstageRouteObject } from '../routing/types';
import { FrontendFeature, RouteInfo } from './types';
import {
ExtensionFactoryMiddleware,
FrontendFeature,
RouteInfo,
} from './types';
import { matchRoutes } from 'react-router-dom';
function deduplicateFeatures(
@@ -198,7 +202,9 @@ export function createSpecializedApp(options?: {
features?: FrontendFeature[];
config?: ConfigApi;
bindRoutes?(context: { bind: CreateAppRouteBinder }): void;
}): { createRoot(): JSX.Element } {
apis?: ApiHolder;
extensionFactoryMiddleware?: ExtensionFactoryMiddleware;
}): { apis: ApiHolder; createRoot(): JSX.Element; tree: AppTree } {
const config = options?.config ?? new ConfigReader({}, 'empty-config');
const features = deduplicateFeatures(options?.features ?? []);
@@ -227,15 +233,17 @@ export function createSpecializedApp(options?: {
);
const appIdentityProxy = new AppIdentityProxy();
const apiHolder = createApiHolder({
factories,
staticFactories: [
createApiFactory(appTreeApiRef, appTreeApi),
createApiFactory(configApiRef, config),
createApiFactory(routeResolutionApiRef, routeResolutionApi),
createApiFactory(identityApiRef, appIdentityProxy),
],
});
const apiHolder =
options?.apis ??
createApiHolder({
factories,
staticFactories: [
createApiFactory(appTreeApiRef, appTreeApi),
createApiFactory(configApiRef, config),
createApiFactory(routeResolutionApiRef, routeResolutionApi),
createApiFactory(identityApiRef, appIdentityProxy),
],
});
const featureFlagApi = apiHolder.get(featureFlagsApiRef);
if (featureFlagApi) {
@@ -260,7 +268,11 @@ export function createSpecializedApp(options?: {
}
// Now instantiate the entire tree, which will skip anything that's already been instantiated
instantiateAppNodeTree(tree.root, apiHolder);
instantiateAppNodeTree(
tree.root,
apiHolder,
options?.extensionFactoryMiddleware,
);
const routeInfo = extractRouteInfoFromAppNode(tree.root);
@@ -272,6 +284,8 @@ export function createSpecializedApp(options?: {
const AppComponent = () => rootEl;
return {
apis: apiHolder,
tree,
createRoot() {
return <AppComponent />;
},
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { RouteRef } from '@backstage/frontend-plugin-api';
import { ExtensionDefinition, RouteRef } from '@backstage/frontend-plugin-api';
import { FrontendModule, FrontendPlugin } from '@backstage/frontend-plugin-api';
import { BackstageRouteObject } from '../routing/types';
@@ -31,3 +31,8 @@ export type RouteInfo = {
routeParents: Map<RouteRef, RouteRef | undefined>;
routeObjects: BackstageRouteObject[];
};
/** @public */
export type ExtensionFactoryMiddleware = Parameters<
ExtensionDefinition['override']
>[0]['factory'];
+3
View File
@@ -5,6 +5,7 @@
```ts
import { ConfigApi } from '@backstage/frontend-plugin-api';
import { CreateAppRouteBinder } from '@backstage/frontend-app-api';
import { ExtensionFactoryMiddleware } from '@backstage/frontend-app-api';
import { FrontendFeature } from '@backstage/frontend-app-api';
import { JSX as JSX_2 } from 'react';
import { default as React_2 } from 'react';
@@ -32,6 +33,8 @@ export interface CreateAppOptions {
config: ConfigApi;
}>;
// (undocumented)
extensionFactoryMiddleware?: ExtensionFactoryMiddleware;
// (undocumented)
features?: (FrontendFeature | CreateAppFeatureLoader)[];
loadingComponent?: ReactNode;
}
@@ -26,6 +26,7 @@ import { ConfigReader } from '@backstage/config';
import appPlugin from '@backstage/plugin-app';
import {
CreateAppRouteBinder,
ExtensionFactoryMiddleware,
FrontendFeature,
createSpecializedApp,
} from '@backstage/frontend-app-api';
@@ -65,6 +66,7 @@ export interface CreateAppOptions {
* If set to "null" then no loading fallback component is rendered. *
*/
loadingComponent?: ReactNode;
extensionFactoryMiddleware?: ExtensionFactoryMiddleware;
}
/**
@@ -111,6 +113,7 @@ export function createApp(options?: CreateAppOptions): {
config,
features: [appPlugin, ...discoveredFeatures, ...providedFeatures],
bindRoutes: options?.bindRoutes,
extensionFactoryMiddleware: options?.extensionFactoryMiddleware,
}).createRoot();
return { default: () => app };