Merge pull request #33356 from backstage/conditional-extensions

frontend-app-api: add phased app preparation
This commit is contained in:
Patrik Oldsberg
2026-03-17 14:44:00 +01:00
committed by GitHub
90 changed files with 6028 additions and 893 deletions
+336 -2
View File
@@ -23,6 +23,9 @@ import {
PageBlueprint,
FrontendPluginInfo,
useAppNode,
createExtensionBlueprint,
createExtensionInput,
coreExtensionData,
} from '@backstage/frontend-plugin-api';
import { useEffect, useState } from 'react';
import { Route, Routes } from 'react-router-dom';
@@ -65,7 +68,8 @@ const IndexPage = PageBlueprint.make({
const page1Link = useRouteRef(page1RouteRef);
return (
<div>
op
<h1>Example Pages Plugin</h1>
<h2>Navigation</h2>
{page1Link && (
<div>
<Link to={page1Link()}>Page 1</Link>
@@ -83,6 +87,54 @@ const IndexPage = PageBlueprint.make({
<div>
<Link to="/settings">Settings</Link>
</div>
<h2>Permission Enablement Examples</h2>
<p>
The following pages demonstrate conditional extension enablement
via the <code>if</code> predicate using permissions. They will
only appear when the user has the required permissions.
</p>
<ul>
<li>
<Link to="/permission-gated-example">
Permission Gated Example
</Link>{' '}
requires <code>catalog.entity.create</code>
</li>
<li>
<Link to="/permission-card-example">
Permission Card Example
</Link>{' '}
a page that is always visible, but individual cards on it are
toggled by permissions
</li>
</ul>
<h2>Feature Flag Enablement Examples</h2>
<p>
The following pages demonstrate conditional extension enablement
via the <code>if</code> predicate. They will only appear in the
router tree when their conditions are satisfied. Toggle the
relevant feature flags in <Link to="/settings">Settings</Link>,
then refresh the app to see the pages appear.
</p>
<ul>
<li>
<Link to="/feature-flag-example">Feature Flag Example</Link>
requires the <code>experimental-features</code> flag
</li>
<li>
<Link to="/all-flags-example">All Flags Example</Link>
requires <em>both</em> <code>experimental-features</code> and{' '}
<code>advanced-features</code> (<code>$all</code>)
</li>
<li>
<Link to="/any-flag-example">Any Flag Example</Link> requires{' '}
<em>either</em> <code>experimental-features</code> or{' '}
<code>beta-access</code> (<code>$any</code>)
</li>
</ul>
<PluginInfo />
</div>
);
@@ -150,6 +202,270 @@ const ExternalPage = PageBlueprint.make({
},
});
// Example: Page enabled only when a single feature flag is active.
//
// The `if` predicate is evaluated once at app startup (before the router
// tree is built), so this page simply won't exist in the app until the flag is
// toggled and the page is refreshed.
//
// To test: enable the 'experimental-features' flag in Settings, then refresh.
const FeatureFlagPage = PageBlueprint.make({
name: 'featureFlagExample',
params: {
path: '/feature-flag-example',
loader: async () => {
const Component = () => {
const indexLink = useRouteRef(indexRouteRef);
return (
<div>
<h1>Feature Flag Enabled Page</h1>
<p>
This page is only present in the app when the{' '}
<code>experimental-features</code> feature flag is active.
</p>
<p>
It uses a simple{' '}
<code>
{'{ featureFlags: { $contains: "experimental-features" } }'}
</code>{' '}
predicate.
</p>
{indexLink && <Link to={indexLink()}>Go back</Link>}
</div>
);
};
return <Component />;
},
},
if: { featureFlags: { $contains: 'experimental-features' } },
});
// Example: Page enabled only when ALL of several feature flags are active.
//
// The $all operator requires every nested predicate to be satisfied. This page
// won't appear unless both 'experimental-features' and 'advanced-features' are
// enabled at the same time.
//
// To test: enable BOTH flags in Settings, then refresh.
const AllFlagsPage = PageBlueprint.make({
name: 'allFlagsExample',
params: {
path: '/all-flags-example',
loader: async () => {
const Component = () => {
const indexLink = useRouteRef(indexRouteRef);
return (
<div>
<h1>All Flags Required Page</h1>
<p>
This page requires <em>both</em>{' '}
<code>experimental-features</code> and{' '}
<code>advanced-features</code> to be active simultaneously.
</p>
<p>
It uses a <code>$all</code> predicate to AND the two conditions
together.
</p>
{indexLink && <Link to={indexLink()}>Go back</Link>}
</div>
);
};
return <Component />;
},
},
if: {
$all: [
{ featureFlags: { $contains: 'experimental-features' } },
{ featureFlags: { $contains: 'advanced-features' } },
],
},
});
// Example: Page enabled when ANY one of several feature flags is active.
//
// The $any operator is satisfied as soon as at least one nested predicate
// matches. Enabling either 'experimental-features' or 'beta-access' will make
// this page appear.
//
// To test: enable at least one of the two flags in Settings, then refresh.
const AnyFlagPage = PageBlueprint.make({
name: 'anyFlagExample',
params: {
path: '/any-flag-example',
loader: async () => {
const Component = () => {
const indexLink = useRouteRef(indexRouteRef);
return (
<div>
<h1>Any Flag Sufficient Page</h1>
<p>
This page appears when <em>either</em>{' '}
<code>experimental-features</code> or <code>beta-access</code> is
active.
</p>
<p>
It uses a <code>$any</code> predicate to OR the two conditions
together.
</p>
{indexLink && <Link to={indexLink()}>Go back</Link>}
</div>
);
};
return <Component />;
},
},
if: {
$any: [
{ featureFlags: { $contains: 'experimental-features' } },
{ featureFlags: { $contains: 'beta-access' } },
],
},
});
// Blueprint for cards that attach to the PermissionCardPage below.
//
// Each card receives a title and description and renders a simple bordered card.
// Individual card instances can be selectively enabled via the `if`
// predicate, so only the cards the user is allowed to see will be instantiated.
const PermissionExampleCardBlueprint = createExtensionBlueprint({
kind: 'permission-example-card',
attachTo: { id: 'page:pages/permissionCardExample', input: 'cards' },
output: [coreExtensionData.reactElement],
*factory(params: { title: string; description: string }) {
yield coreExtensionData.reactElement(
<div
style={{
border: '1px solid #ccc',
borderRadius: '4px',
padding: '1rem',
}}
>
<h3 style={{ marginTop: 0 }}>{params.title}</h3>
<p style={{ marginBottom: 0 }}>{params.description}</p>
</div>,
);
},
});
// Example: Page with cards that are individually toggled by permissions.
//
// The page itself is always present. What changes is which cards are
// instantiated inside it — each card declares its own `enabled` predicate
// and is only wired into the page if that predicate is satisfied at startup.
//
// To test: make sure you do NOT have the catalog.entity.create permission and
// refresh the page — the "Restricted Card" below should disappear.
const PermissionCardPage = PageBlueprint.makeWithOverrides({
name: 'permissionCardExample',
inputs: {
cards: createExtensionInput([coreExtensionData.reactElement]),
},
factory(originalFactory, { inputs }) {
return originalFactory({
path: '/permission-card-example',
loader: async () => {
const Component = () => {
const indexLink = useRouteRef(indexRouteRef);
const cards = inputs.cards.map(card =>
card.get(coreExtensionData.reactElement),
);
return (
<div>
<h1>Permission-Gated Card Example</h1>
<p>
This page is always visible. The cards below are individually
gated each one declares its own{' '}
<code>{'if: { permissions: { $contains: "..." } }'}</code>{' '}
predicate. Cards whose predicate fails are never instantiated,
so they simply won't appear here.
</p>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
gap: '1rem',
}}
>
{cards.length > 0 ? (
cards
) : (
<p>
No cards are visible — you may lack the required
permissions.
</p>
)}
</div>
{indexLink && <Link to={indexLink()}>Go back</Link>}
</div>
);
};
return <Component />;
},
});
},
});
// Always-visible card — no predicate, every user sees this.
const PublicCard = PermissionExampleCardBlueprint.make({
name: 'public',
params: {
title: 'Public Card',
description: 'This card is visible to everyone regardless of permissions.',
},
});
// Permission-gated card — only instantiated when the user has
// the catalog.entity.create permission.
const RestrictedCard = PermissionExampleCardBlueprint.make({
name: 'restricted',
params: {
title: 'Restricted Card',
description:
'This card is only visible to users who have the catalog.entity.create permission.',
},
if: { permissions: { $contains: 'catalog.entity.create' } },
});
// Feature flag-gated card — only instantiated when the user has
// the experimental-card FF enabled.
const FeatureFlagCard = PermissionExampleCardBlueprint.make({
name: 'feature-flag',
params: {
title: 'Feature Flagged Card',
description: 'Visible only with the experimental-card FF active.',
},
if: { featureFlags: { $contains: 'experimental-card' } },
});
// Example: Page enabled only when the user is allowed to create catalog entities.
//
// The `if` predicate is evaluated once at app startup (after sign-in),
// so this page simply won't exist in the router tree if the user lacks the
// required permission.
const PermissionGatedPage = PageBlueprint.make({
name: 'permissionGatedExample',
params: {
path: '/permission-gated-example',
loader: async () => {
const Component = () => {
const indexLink = useRouteRef(indexRouteRef);
return (
<div>
<h1>Permission Gated Page</h1>
<p>
This page is only present when the user has the{' '}
<code>catalog.entity.create</code> permission.
</p>
{indexLink && <Link to={indexLink()}>Go back</Link>}
</div>
);
};
return <Component />;
},
},
if: { permissions: { $contains: 'catalog.entity.create' } },
});
export const pagesPlugin = createFrontendPlugin({
pluginId: 'pages',
// routes: {
@@ -170,5 +486,23 @@ export const pagesPlugin = createFrontendPlugin({
externalRoutes: {
pageX: externalPageXRouteRef,
},
extensions: [IndexPage, Page1, ExternalPage],
featureFlags: [
{ name: 'experimental-features' },
{ name: 'advanced-features' },
{ name: 'beta-access' },
{ name: 'experimental-card' },
],
extensions: [
IndexPage,
Page1,
ExternalPage,
FeatureFlagPage,
AllFlagsPage,
AnyFlagPage,
PermissionCardPage,
PublicCard,
RestrictedCard,
PermissionGatedPage,
FeatureFlagCard,
],
});
+1
View File
@@ -33,6 +33,7 @@
"dependencies": {
"@backstage/core-plugin-api": "workspace:^",
"@backstage/errors": "workspace:^",
"@backstage/filter-predicates": "workspace:^",
"@backstage/frontend-plugin-api": "workspace:^",
"@backstage/plugin-app-react": "workspace:^",
"@backstage/plugin-catalog-react": "workspace:^",
@@ -42,6 +42,7 @@ describe('convertLegacyPlugin', () => {
"getExtension": [Function],
"icon": undefined,
"id": "test",
"if": undefined,
"info": [Function],
"infoOptions": undefined,
"pluginId": "test",
@@ -15,6 +15,7 @@
*/
import { renderInTestApp } from '@backstage/test-utils';
import { screen } from '@testing-library/react';
import { Progress } from './Progress';
@@ -23,4 +24,11 @@ describe('<Progress />', () => {
const { queryByTestId } = await renderInTestApp(<Progress />);
expect(queryByTestId('progress')).toBeInTheDocument();
});
it('provides an accessible name for the progress bar', async () => {
await renderInTestApp(<Progress />);
expect(
await screen.findByRole('progressbar', { name: 'Loading' }),
).toBeInTheDocument();
});
});
@@ -22,6 +22,7 @@ import { useTheme } from '@material-ui/core/styles';
import { PropsWithChildren, useEffect, useState } from 'react';
export function Progress(props: PropsWithChildren<LinearProgressProps>) {
const { 'aria-label': ariaLabel, ...progressProps } = props;
const theme = useTheme();
const [isVisible, setIsVisible] = useState(false);
@@ -34,7 +35,11 @@ export function Progress(props: PropsWithChildren<LinearProgressProps>) {
}, [theme.transitions.duration.short]);
return isVisible ? (
<LinearProgress {...props} data-testid="progress" />
<LinearProgress
{...progressProps}
aria-label={ariaLabel ?? 'Loading'}
data-testid="progress"
/>
) : (
<Box display="none" data-testid="progress" />
);
+2
View File
@@ -36,6 +36,7 @@
"@backstage/core-app-api": "workspace:^",
"@backstage/core-plugin-api": "workspace:^",
"@backstage/errors": "workspace:^",
"@backstage/filter-predicates": "workspace:^",
"@backstage/frontend-defaults": "workspace:^",
"@backstage/frontend-plugin-api": "workspace:^",
"@backstage/types": "workspace:^",
@@ -47,6 +48,7 @@
"@backstage/cli": "workspace:^",
"@backstage/frontend-test-utils": "workspace:^",
"@backstage/plugin-app": "workspace:^",
"@backstage/plugin-permission-common": "workspace:^",
"@backstage/test-utils": "workspace:^",
"@testing-library/jest-dom": "^6.0.0",
"@testing-library/react": "^16.0.0",
+73 -9
View File
@@ -10,6 +10,7 @@ import { ConfigApi } from '@backstage/frontend-plugin-api';
import { ExtensionDataContainer } from '@backstage/frontend-plugin-api';
import { ExtensionDataRef } from '@backstage/frontend-plugin-api';
import { ExtensionDataValue } from '@backstage/frontend-plugin-api';
import { ExtensionFactoryMiddleware as ExtensionFactoryMiddleware_2 } from '@backstage/frontend-plugin-api';
import { ExternalRouteRef } from '@backstage/frontend-plugin-api';
import { FrontendFeature } from '@backstage/frontend-plugin-api';
import { FrontendPlugin } from '@backstage/frontend-plugin-api';
@@ -127,6 +128,26 @@ export type AppErrorTypes = {
existingPluginId: string;
};
};
EXTENSION_BOOTSTRAP_PREDICATE_IGNORED: {
context: {
node: AppNode;
};
};
EXTENSION_BOOTSTRAP_API_UNAVAILABLE: {
context: {
node: AppNode;
apiRefId: string;
};
};
EXTENSION_BOOTSTRAP_API_OVERRIDE_IGNORED: {
context: {
node: AppNode;
apiRefId: string;
bootstrapNode: AppNode;
pluginId: string;
bootstrapPluginId: string;
};
};
ROUTE_DUPLICATE: {
context: {
routeId: string;
@@ -144,6 +165,12 @@ export type AppErrorTypes = {
};
};
// @public
export type BootstrapSpecializedApp = {
element: JSX.Element;
tree: AppTree;
};
// @public
export type CreateAppRouteBinder = <
TExternalRoutes extends {
@@ -157,14 +184,12 @@ export type CreateAppRouteBinder = <
>,
) => void;
// @public
export function createSpecializedApp(options?: CreateSpecializedAppOptions): {
apis: ApiHolder;
tree: AppTree;
errors?: AppError[];
};
// @public @deprecated
export function createSpecializedApp(
options?: CreateSpecializedAppOptions,
): FinalizedSpecializedApp;
// @public
// @public @deprecated
export type CreateSpecializedAppOptions = {
features?: FrontendFeature[];
config?: ConfigApi;
@@ -172,8 +197,8 @@ export type CreateSpecializedAppOptions = {
advanced?: {
apis?: ApiHolder;
extensionFactoryMiddleware?:
| ExtensionFactoryMiddleware
| ExtensionFactoryMiddleware[];
| ExtensionFactoryMiddleware_2
| ExtensionFactoryMiddleware_2[];
pluginInfoResolver?: FrontendPluginInfoResolver;
};
};
@@ -190,6 +215,14 @@ export type ExtensionFactoryMiddleware = (
},
) => Iterable<ExtensionDataValue<any, any>>;
// @public
export type FinalizedSpecializedApp = {
element: JSX.Element;
sessionState: SpecializedAppSessionState;
tree: AppTree;
errors?: AppError[];
};
// @public
export type FrontendPluginInfoResolver = (ctx: {
packageJson(): Promise<JsonObject | undefined>;
@@ -203,4 +236,35 @@ export type FrontendPluginInfoResolver = (ctx: {
}) => Promise<{
info: FrontendPluginInfo;
}>;
// @public
export type PreparedSpecializedApp = {
getBootstrapApp(): BootstrapSpecializedApp;
onFinalized(callback: (app: FinalizedSpecializedApp) => void): () => void;
finalize(): FinalizedSpecializedApp;
};
// @public
export function prepareSpecializedApp(
options?: PrepareSpecializedAppOptions,
): PreparedSpecializedApp;
// @public
export type PrepareSpecializedAppOptions = {
features?: FrontendFeature[];
config?: ConfigApi;
bindRoutes?(context: { bind: CreateAppRouteBinder }): void;
advanced?: {
sessionState?: SpecializedAppSessionState;
extensionFactoryMiddleware?:
| ExtensionFactoryMiddleware_2
| ExtensionFactoryMiddleware_2[];
pluginInfoResolver?: FrontendPluginInfoResolver;
};
};
// @public
export type SpecializedAppSessionState = {
$$type: '@backstage/SpecializedAppSessionState';
};
```
@@ -1782,4 +1782,96 @@ describe('instantiateAppNodeTree', () => {
});
});
});
describe('if predicate', () => {
function makeNodeWithEnabled(
enabled: AppNodeSpec['if'],
disabled = false,
): AppNode {
const ext = resolveExtensionDefinition(
createExtension({
attachTo: { id: 'ignored', input: 'ignored' },
output: [testDataRef],
factory: () => [testDataRef('value')],
}),
{ namespace: 'test-ext' },
);
return {
spec: {
id: ext.id,
attachTo: ext.attachTo,
disabled,
if: enabled,
extension: ext as Extension<unknown, unknown>,
plugin: createFrontendPlugin({ pluginId: 'app' }),
},
edges: { attachments: new Map() },
};
}
it('should skip a node when the predicate is not satisfied', () => {
const node = makeNodeWithEnabled({
featureFlags: { $contains: 'the-flag' },
});
const tree = resolveAppTree('test-ext', [node.spec], collector);
instantiateAppNodeTree(tree.root, testApis, collector, undefined, {
featureFlags: [],
});
expect(tree.root.instance).toBeUndefined();
});
it('should instantiate a node when the predicate is satisfied', () => {
const node = makeNodeWithEnabled({
featureFlags: { $contains: 'the-flag' },
});
const tree = resolveAppTree('test-ext', [node.spec], collector);
instantiateAppNodeTree(tree.root, testApis, collector, undefined, {
featureFlags: ['the-flag'],
});
expect(tree.root.instance).toBeDefined();
expect(tree.root.instance?.getData(testDataRef)).toBe('value');
});
it('should support $all operator across multiple flags', () => {
const node = makeNodeWithEnabled({
$all: [
{ featureFlags: { $contains: 'flag-a' } },
{ featureFlags: { $contains: 'flag-b' } },
],
});
const tree = resolveAppTree('test-ext', [node.spec], collector);
// Only one flag active — should not instantiate
instantiateAppNodeTree(tree.root, testApis, collector, undefined, {
featureFlags: ['flag-a'],
});
expect(tree.root.instance).toBeUndefined();
// Both flags active — should instantiate
const tree2 = resolveAppTree('test-ext', [node.spec], collector);
instantiateAppNodeTree(tree2.root, testApis, collector, undefined, {
featureFlags: ['flag-a', 'flag-b'],
});
expect(tree2.root.instance).toBeDefined();
});
it('should instantiate nodes without an enabled field regardless of predicateContext', () => {
const node = makeNodeWithEnabled(undefined);
const tree = resolveAppTree('test-ext', [node.spec], collector);
instantiateAppNodeTree(tree.root, testApis, collector, undefined, {
featureFlags: [],
});
expect(tree.root.instance).toBeDefined();
});
it('should instantiate nodes with enabled predicate when predicateContext is not provided', () => {
const node = makeNodeWithEnabled({
featureFlags: { $contains: 'the-flag' },
});
const tree = resolveAppTree('test-ext', [node.spec], collector);
// No predicateContext passed — predicate evaluation is skipped
instantiateAppNodeTree(tree.root, testApis, collector);
expect(tree.root.instance).toBeDefined();
});
});
});
@@ -29,6 +29,7 @@ import { AppNode, AppNodeInstance } from '@backstage/frontend-plugin-api';
import { toInternalExtension } from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition';
import { createExtensionDataContainer } from '@internal/frontend';
import { ErrorCollector } from '../wiring/createErrorCollector';
import { evaluateFilterPredicate } from '@backstage/filter-predicates';
const INSTANTIATION_FAILED = new Error('Instantiation failed');
@@ -64,6 +65,19 @@ type Mutable<T> = {
-readonly [P in keyof T]: T[P];
};
type InstantiateAppNodeSubtreeOptions = {
rootNode: AppNode;
apis: ApiHolder;
collector: ErrorCollector;
extensionFactoryMiddleware?: ExtensionFactoryMiddleware;
stopAtAttachment?(ctx: { node: AppNode; input: string }): boolean;
skipChild?(ctx: { node: AppNode; input: string; child: AppNode }): boolean;
onMissingApi?(ctx: { node: AppNode; apiRefId: string }): void;
predicateContext?: Record<string, unknown>;
reuseExistingInstances?: boolean;
writeNodeInstances?: boolean;
};
function resolveV1InputDataMap(
dataMap: {
[name in string]: ExtensionDataRef;
@@ -337,12 +351,28 @@ export function createAppNodeInstance(options: {
apis: ApiHolder;
attachments: ReadonlyMap<string, AppNode[]>;
collector: ErrorCollector;
onMissingApi?(ctx: { node: AppNode; apiRefId: string }): void;
}): AppNodeInstance | undefined {
const { node, apis, attachments } = options;
const collector = options.collector.child({ node });
const { id, extension, config } = node.spec;
const extensionData = new Map<string, unknown>();
const extensionDataRefs = new Set<ExtensionDataRef<unknown>>();
const scopedApis: ApiHolder =
options.onMissingApi === undefined
? apis
: {
get(apiRef) {
const api = apis.get(apiRef);
if (api === undefined) {
options.onMissingApi?.({
node,
apiRefId: apiRef.id,
});
}
return api;
},
};
let parsedConfig: { [x: string]: any };
try {
@@ -367,7 +397,7 @@ export function createAppNodeInstance(options: {
if (internalExtension.version === 'v1') {
const namedOutputs = internalExtension.factory({
node,
apis,
apis: scopedApis,
config: parsedConfig,
inputs: resolveV1Inputs(internalExtension.inputs, attachments),
});
@@ -388,7 +418,7 @@ export function createAppNodeInstance(options: {
} else if (internalExtension.version === 'v2') {
const context = {
node,
apis,
apis: scopedApis,
config: parsedConfig,
inputs: resolveV2Inputs(
internalExtension.inputs,
@@ -500,6 +530,87 @@ export function createAppNodeInstance(options: {
};
}
/**
* Starting at the provided node, instantiate a subtree without necessarily
* mutating the original app tree.
*
* @internal
*/
export function instantiateAppNodeSubtree(
options: InstantiateAppNodeSubtreeOptions,
): AppNode | undefined {
const instantiatedNodes = new WeakMap<AppNode, AppNode | null>();
function createInstance(node: AppNode): AppNode | undefined {
if (instantiatedNodes.has(node)) {
return instantiatedNodes.get(node) ?? undefined;
}
if (options.reuseExistingInstances !== false && node.instance) {
instantiatedNodes.set(node, node);
return node;
}
if (node.spec.disabled) {
instantiatedNodes.set(node, null);
return undefined;
}
if (
options.predicateContext !== undefined &&
node.spec.if !== undefined &&
!evaluateFilterPredicate(node.spec.if, options.predicateContext)
) {
instantiatedNodes.set(node, null);
return undefined;
}
const instantiatedAttachments = new Map<string, AppNode[]>();
for (const [input, children] of node.edges.attachments) {
if (options.stopAtAttachment?.({ node, input })) {
continue;
}
const instantiatedChildren = children.flatMap(child => {
if (options.skipChild?.({ node, input, child })) {
return [];
}
const childNode = createInstance(child);
return childNode ? [childNode] : [];
});
if (instantiatedChildren.length > 0) {
instantiatedAttachments.set(input, instantiatedChildren);
}
}
const instance = createAppNodeInstance({
extensionFactoryMiddleware: options.extensionFactoryMiddleware,
node,
apis: options.apis,
attachments: instantiatedAttachments,
collector: options.collector,
onMissingApi: options.onMissingApi,
});
if (!instance) {
instantiatedNodes.set(node, null);
return undefined;
}
if (options.writeNodeInstances === false) {
const detachedNode: AppNode = {
spec: node.spec,
edges: node.edges,
instance,
};
instantiatedNodes.set(node, detachedNode);
return detachedNode;
}
(node as Mutable<AppNode>).instance = instance;
instantiatedNodes.set(node, node);
return node;
}
return createInstance(options.rootNode);
}
/**
* Starting at the provided node, instantiate all reachable nodes in the tree that have not been disabled.
* @internal
@@ -509,40 +620,45 @@ export function instantiateAppNodeTree(
apis: ApiHolder,
collector: ErrorCollector,
extensionFactoryMiddleware?: ExtensionFactoryMiddleware,
): boolean {
function createInstance(node: AppNode): AppNodeInstance | undefined {
if (node.instance) {
return node.instance;
}
if (node.spec.disabled) {
return undefined;
}
const instantiatedAttachments = new Map<string, AppNode[]>();
for (const [input, children] of node.edges.attachments) {
const instantiatedChildren = children.flatMap(child => {
const childInstance = createInstance(child);
if (!childInstance) {
return [];
}
return [child];
});
if (instantiatedChildren.length > 0) {
instantiatedAttachments.set(input, instantiatedChildren);
optionsOrPredicateContext?:
| {
stopAtAttachment?(ctx: { node: AppNode; input: string }): boolean;
skipChild?(ctx: {
node: AppNode;
input: string;
child: AppNode;
}): boolean;
onMissingApi?(ctx: { node: AppNode; apiRefId: string }): void;
predicateContext?: Record<string, unknown>;
}
}
| Record<string, unknown>,
): boolean {
const options: {
stopAtAttachment?(ctx: { node: AppNode; input: string }): boolean;
skipChild?(ctx: { node: AppNode; input: string; child: AppNode }): boolean;
onMissingApi?(ctx: { node: AppNode; apiRefId: string }): void;
predicateContext?: Record<string, unknown>;
} =
optionsOrPredicateContext &&
('stopAtAttachment' in optionsOrPredicateContext ||
'skipChild' in optionsOrPredicateContext ||
'onMissingApi' in optionsOrPredicateContext ||
'predicateContext' in optionsOrPredicateContext)
? optionsOrPredicateContext
: {
predicateContext: optionsOrPredicateContext,
};
(node as Mutable<AppNode>).instance = createAppNodeInstance({
extensionFactoryMiddleware,
node,
return (
instantiateAppNodeSubtree({
rootNode,
apis,
attachments: instantiatedAttachments,
collector,
});
return node.instance;
}
return createInstance(rootNode) !== undefined;
extensionFactoryMiddleware,
stopAtAttachment: options.stopAtAttachment,
skipChild: options.skipChild,
onMissingApi: options.onMissingApi,
predicateContext: options.predicateContext,
}) !== undefined
);
}
@@ -15,6 +15,8 @@
*/
import {
createExtension,
createExtensionDataRef,
createFrontendModule,
createFrontendPlugin,
Extension,
@@ -506,4 +508,204 @@ describe('resolveAppNodeSpecs', () => {
},
]);
});
it('should carry if predicate through to AppNodeSpec', () => {
const dataRef = createExtensionDataRef<string>().with({ id: 'test.data' });
const ifPredicate = { featureFlags: { $contains: 'my-flag' } };
const plugin = createFrontendPlugin({
pluginId: 'test-plugin',
extensions: [
createExtension({
attachTo: { id: 'app', input: 'root' },
if: ifPredicate,
output: [dataRef],
factory: () => [dataRef('value')],
}),
],
});
const specs = resolveAppNodeSpecs({
features: [plugin],
builtinExtensions: [],
parameters: [],
collector,
});
expect(specs).toHaveLength(1);
expect(specs[0].if).toEqual(ifPredicate);
});
it('should apply plugin if predicates to all plugin extensions', () => {
const dataRef = createExtensionDataRef<string>().with({ id: 'test.data' });
const pluginIf = { featureFlags: { $contains: 'plugin-flag' } };
const plugin = createFrontendPlugin({
pluginId: 'test-plugin',
if: pluginIf,
extensions: [
createExtension({
name: 'one',
attachTo: { id: 'app', input: 'root' },
output: [dataRef],
factory: () => [dataRef('one')],
}),
createExtension({
name: 'two',
attachTo: { id: 'app', input: 'root' },
output: [dataRef],
factory: () => [dataRef('two')],
}),
],
});
const specs = resolveAppNodeSpecs({
features: [plugin],
builtinExtensions: [],
parameters: [],
collector,
});
expect(specs).toHaveLength(2);
expect(specs[0].if).toEqual(pluginIf);
expect(specs[1].if).toEqual(pluginIf);
});
it('should allow plugin overrides to replace or remove plugin if predicates', () => {
const dataRef = createExtensionDataRef<string>().with({ id: 'test.data' });
const pluginIf = { featureFlags: { $contains: 'plugin-flag' } };
const overrideIf = { permissions: { $contains: 'override.permission' } };
const plugin = createFrontendPlugin({
pluginId: 'test-plugin',
if: pluginIf,
extensions: [
createExtension({
name: 'one',
attachTo: { id: 'app', input: 'root' },
output: [dataRef],
factory: () => [dataRef('one')],
}),
],
});
const overriddenSpecs = resolveAppNodeSpecs({
features: [plugin.withOverrides({ if: overrideIf })],
builtinExtensions: [],
parameters: [],
collector,
});
const clearedSpecs = resolveAppNodeSpecs({
features: [plugin.withOverrides({ if: undefined })],
builtinExtensions: [],
parameters: [],
collector,
});
expect(overriddenSpecs).toHaveLength(1);
expect(overriddenSpecs[0].if).toEqual(overrideIf);
expect(clearedSpecs).toHaveLength(1);
expect(clearedSpecs[0].if).toBeUndefined();
});
it('should merge plugin and module if predicates with extension predicates', () => {
const dataRef = createExtensionDataRef<string>().with({ id: 'test.data' });
const pluginIf = { featureFlags: { $contains: 'plugin-flag' } };
const moduleIf = { permissions: { $contains: 'module.permission' } };
const extensionIf = { featureFlags: { $contains: 'extension-flag' } };
const moduleExtensionIf = { featureFlags: { $contains: 'module-flag' } };
const plugin = createFrontendPlugin({
pluginId: 'test-plugin',
if: pluginIf,
extensions: [
createExtension({
name: 'plugin-extension',
attachTo: { id: 'app', input: 'root' },
if: extensionIf,
output: [dataRef],
factory: () => [dataRef('plugin')],
}),
createExtension({
name: 'module-extension',
attachTo: { id: 'app', input: 'root' },
output: [dataRef],
factory: () => [dataRef('base')],
}),
],
});
const module = createFrontendModule({
pluginId: 'test-plugin',
if: moduleIf,
extensions: [
plugin.getExtension('test-plugin/module-extension').override({
if: moduleExtensionIf,
factory: () => [dataRef('module')],
}),
],
});
const specs = resolveAppNodeSpecs({
features: [plugin, module],
builtinExtensions: [],
parameters: [],
collector,
});
expect(specs).toHaveLength(2);
expect(specs[0].id).toBe('test-plugin/plugin-extension');
expect(specs[0].if).toEqual({ $all: [pluginIf, extensionIf] });
expect(specs[1].id).toBe('test-plugin/module-extension');
expect(specs[1].if).toEqual({ $all: [moduleIf, moduleExtensionIf] });
});
it('should allow module extension overrides to replace or remove extension if predicates', () => {
const dataRef = createExtensionDataRef<string>().with({ id: 'test.data' });
const extensionIf = { featureFlags: { $contains: 'extension-flag' } };
const overrideIf = { permissions: { $contains: 'override.permission' } };
const plugin = createFrontendPlugin({
pluginId: 'test-plugin',
extensions: [
createExtension({
name: 'extension',
attachTo: { id: 'app', input: 'root' },
if: extensionIf,
output: [dataRef],
factory: () => [dataRef('base')],
}),
],
});
const overriddenSpecs = resolveAppNodeSpecs({
features: [
plugin,
createFrontendModule({
pluginId: 'test-plugin',
extensions: [
plugin.getExtension('test-plugin/extension').override({
if: overrideIf,
}),
],
}),
],
builtinExtensions: [],
parameters: [],
collector,
});
const clearedSpecs = resolveAppNodeSpecs({
features: [
plugin,
createFrontendModule({
pluginId: 'test-plugin',
extensions: [
plugin.getExtension('test-plugin/extension').override({
if: undefined,
}),
],
}),
],
builtinExtensions: [],
parameters: [],
collector,
});
expect(overriddenSpecs).toHaveLength(1);
expect(overriddenSpecs[0].if).toEqual(overrideIf);
expect(clearedSpecs).toHaveLength(1);
expect(clearedSpecs[0].if).toBeUndefined();
});
});
@@ -20,6 +20,7 @@ import {
FrontendFeature,
FrontendPlugin,
} from '@backstage/frontend-plugin-api';
import { FilterPredicate } from '@backstage/filter-predicates';
import { ExtensionParameters } from './readAppExtensionsConfig';
import { AppNodeSpec } from '@backstage/frontend-plugin-api';
import { OpaqueFrontendPlugin } from '@internal/frontend';
@@ -40,6 +41,29 @@ function normalizePlugin(plugin: FrontendPlugin): FrontendPlugin {
return plugin;
}
function combinePredicates(
left: FilterPredicate | undefined,
right: FilterPredicate | undefined,
) {
if (!left) {
return right;
}
if (!right) {
return left;
}
return { $all: [left, right] };
}
function getExtensionPredicate(options: {
internalExtension: ReturnType<typeof toInternalExtension>;
}) {
if (options.internalExtension.version === 'v2') {
return options.internalExtension.if;
}
return undefined;
}
/** @internal */
export function resolveAppNodeSpecs(options: {
features?: FrontendFeature[];
@@ -79,26 +103,50 @@ export function resolveAppNodeSpecs(options: {
};
const pluginExtensions = plugins.flatMap(plugin => {
return OpaqueFrontendPlugin.toInternal(plugin)
.extensions.map(extension => ({
...extension,
plugin,
}))
const internalPlugin = OpaqueFrontendPlugin.toInternal(plugin);
return internalPlugin.extensions
.map(extension => {
const internalExtension = toInternalExtension(extension);
return {
...internalExtension,
plugin,
if: combinePredicates(
internalPlugin.if,
internalExtension.version === 'v2'
? internalExtension.if
: undefined,
),
};
})
.filter(filterForbidden);
});
const moduleExtensions = modules.flatMap(mod =>
toInternalFrontendModule(mod)
.extensions.flatMap(extension => {
const moduleExtensions = modules.flatMap(mod => {
const internalModule = toInternalFrontendModule(mod);
return internalModule.extensions
.flatMap(extension => {
const internalExtension = toInternalExtension(extension);
// Modules for plugins that are not installed are ignored
const plugin = plugins.find(p => p.pluginId === mod.pluginId);
if (!plugin) {
return [];
}
return [{ ...extension, plugin }];
return [
{
...internalExtension,
plugin,
if: combinePredicates(
internalModule.if,
internalExtension.version === 'v2'
? internalExtension.if
: undefined,
),
},
];
})
.filter(filterForbidden),
);
.filter(filterForbidden);
});
const appPlugin =
plugins.find(plugin => plugin.pluginId === 'app') ??
@@ -116,6 +164,7 @@ export function resolveAppNodeSpecs(options: {
source: plugin,
attachTo: internalExtension.attachTo,
disabled: internalExtension.disabled,
if: getExtensionPredicate({ internalExtension }),
config: undefined as unknown,
},
};
@@ -129,6 +178,7 @@ export function resolveAppNodeSpecs(options: {
plugin: appPlugin,
attachTo: internalExtension.attachTo,
disabled: internalExtension.disabled,
if: getExtensionPredicate({ internalExtension }),
config: undefined as unknown,
},
};
@@ -148,6 +198,9 @@ export function resolveAppNodeSpecs(options: {
configuredExtensions[index].extension = internalExtension;
configuredExtensions[index].params.attachTo = internalExtension.attachTo;
configuredExtensions[index].params.disabled = internalExtension.disabled;
configuredExtensions[index].params.if = getExtensionPredicate({
internalExtension,
});
} else {
// Add the extension as a new one when not overriding an existing one
configuredExtensions.push({
@@ -157,6 +210,7 @@ export function resolveAppNodeSpecs(options: {
source: extension.plugin,
attachTo: internalExtension.attachTo,
disabled: internalExtension.disabled,
if: getExtensionPredicate({ internalExtension }),
config: undefined,
},
});
@@ -235,6 +289,7 @@ export function resolveAppNodeSpecs(options: {
attachTo: param.params.attachTo,
extension: param.extension,
disabled: param.params.disabled,
if: param.params.if,
plugin: param.params.plugin,
source: param.params.source,
config: param.params.config,
@@ -0,0 +1,72 @@
/*
* Copyright 2026 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 {
type AnyApiFactory,
createApiRef,
} from '@backstage/frontend-plugin-api';
import {
FrontendApiRegistry,
FrontendApiResolver,
} from './FrontendApiRegistry';
describe('FrontendApiResolver', () => {
it('should cache falsy API values', () => {
const falseApiRef = createApiRef<boolean>({ id: 'test.false' });
const falseFactoryFn = jest.fn(() => false);
const registry = new FrontendApiRegistry();
registry.register({
api: falseApiRef,
deps: {},
factory: falseFactoryFn,
} as AnyApiFactory);
const resolver = new FrontendApiResolver({ primaryRegistry: registry });
expect(resolver.get(falseApiRef)).toBe(false);
expect(resolver.get(falseApiRef)).toBe(false);
expect(falseFactoryFn).toHaveBeenCalledTimes(1);
});
it('should resolve falsy dependencies', () => {
const falseApiRef = createApiRef<boolean>({ id: 'test.false' });
const dependentApiRef = createApiRef<string>({ id: 'test.dependent' });
const falseFactoryFn = jest.fn(() => false);
const dependentFactoryFn = jest.fn((deps: { falseDependency: boolean }) =>
deps.falseDependency === false ? 'resolved' : 'unexpected',
);
const registry = new FrontendApiRegistry();
registry.register({
api: falseApiRef,
deps: {},
factory: falseFactoryFn,
} as AnyApiFactory);
registry.register({
api: dependentApiRef,
deps: { falseDependency: falseApiRef },
factory: dependentFactoryFn,
} as AnyApiFactory);
const resolver = new FrontendApiResolver({ primaryRegistry: registry });
expect(resolver.get(dependentApiRef)).toBe('resolved');
expect(dependentFactoryFn).toHaveBeenCalledWith({
falseDependency: false,
});
});
});
@@ -0,0 +1,139 @@
/*
* Copyright 2026 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 {
AnyApiFactory,
AnyApiRef,
ApiFactory,
ApiHolder,
ApiRef,
} from '@backstage/frontend-plugin-api';
export class FrontendApiRegistry {
private readonly factories = new Map<string, AnyApiFactory>();
register(factory: AnyApiFactory) {
if (this.factories.has(factory.api.id)) {
return false;
}
this.factories.set(factory.api.id, factory);
return true;
}
registerAll(factories: AnyApiFactory[]) {
for (const factory of factories) {
this.register(factory);
}
}
set(factory: AnyApiFactory) {
this.factories.set(factory.api.id, factory);
}
setAll(factories: Iterable<AnyApiFactory>) {
for (const factory of factories) {
this.set(factory);
}
}
get<T>(
api: ApiRef<T>,
): ApiFactory<T, T, { [name: string]: unknown }> | undefined {
const factory = this.factories.get(api.id);
if (!factory) {
return undefined;
}
return factory as ApiFactory<T, T, { [name: string]: unknown }>;
}
getAllApis() {
const refs = new Set<AnyApiRef>();
for (const factory of this.factories.values()) {
refs.add(factory.api);
}
return refs;
}
}
export class FrontendApiResolver implements ApiHolder {
private readonly apis = new Map<string, unknown>();
private readonly primaryRegistry?: FrontendApiRegistry;
private readonly secondaryRegistry?: FrontendApiRegistry;
private readonly fallbackApis?: ApiHolder;
constructor(options: {
primaryRegistry?: FrontendApiRegistry;
secondaryRegistry?: FrontendApiRegistry;
fallbackApis?: ApiHolder;
}) {
this.primaryRegistry = options.primaryRegistry;
this.secondaryRegistry = options.secondaryRegistry;
this.fallbackApis = options.fallbackApis;
}
get<T>(ref: ApiRef<T>): T | undefined {
return this.load(ref);
}
isMaterialized(apiRefId: string) {
return this.apis.has(apiRefId);
}
invalidate(apiRefIds?: Iterable<string>) {
if (apiRefIds === undefined) {
this.apis.clear();
return;
}
for (const apiRefId of apiRefIds) {
this.apis.delete(apiRefId);
}
}
private load<T>(ref: ApiRef<T>, loading: AnyApiRef[] = []): T | undefined {
const existing = this.apis.get(ref.id);
if (this.apis.has(ref.id)) {
return existing as T;
}
const factory =
this.primaryRegistry?.get(ref) ?? this.secondaryRegistry?.get(ref);
if (!factory) {
return this.fallbackApis?.get(ref);
}
if (loading.includes(factory.api)) {
throw new Error(`Circular dependency of api factory for ${factory.api}`);
}
const deps = {} as { [name: string]: unknown };
for (const [key, depRef] of Object.entries(factory.deps)) {
const dep = this.load(depRef, [...loading, factory.api]);
if (dep === undefined) {
throw new Error(
`No API factory available for dependency ${depRef} of dependent ${factory.api}`,
);
}
deps[key] = dep;
}
const api = factory.factory(deps);
this.apis.set(ref.id, api);
return api as T;
}
}
@@ -0,0 +1,304 @@
/*
* Copyright 2023 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 {
ApiBlueprint,
AnyApiFactory,
ApiHolder,
AppNode,
FrontendFeature,
featureFlagsApiRef,
} from '@backstage/frontend-plugin-api';
import { OpaqueFrontendPlugin } from '@internal/frontend';
import { instantiateAppNodeSubtree } from '../tree/instantiateAppNodeTree';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import {
isInternalFrontendModule,
toInternalFrontendModule,
} from '../../../frontend-plugin-api/src/wiring/createFrontendModule';
import { ErrorCollector } from './createErrorCollector';
import {
FrontendApiRegistry,
FrontendApiResolver,
} from './FrontendApiRegistry';
import { type ExtensionPredicateContext } from './predicates';
export type ApiFactoryEntry = {
node: AppNode;
pluginId: string;
factory: AnyApiFactory;
};
/**
* Registers feature flag declarations on an already prepared API holder.
*
* This is primarily used when bootstrap reuses APIs from a provided session
* state rather than building a fresh registry from bootstrap-visible factories.
*/
export function registerFeatureFlagDeclarationsInHolder(
apis: ApiHolder,
features: FrontendFeature[],
) {
const featureFlagApi = apis.get(featureFlagsApiRef);
if (featureFlagApi) {
registerFeatureFlagDeclarations(featureFlagApi, features);
}
}
/**
* Decorates the feature flags API factory so plugin and module declarations are
* registered whenever that API is instantiated.
*/
export function wrapFeatureFlagApiFactory(
factory: AnyApiFactory,
features: FrontendFeature[],
) {
if (factory.api.id !== featureFlagsApiRef.id) {
return factory;
}
return {
...factory,
factory(deps) {
const featureFlagApi = factory.factory(
deps,
) as typeof featureFlagsApiRef.T;
registerFeatureFlagDeclarations(featureFlagApi, features);
return featureFlagApi;
},
} as AnyApiFactory;
}
/**
* Reconciles deferred API factories into the finalized API registry.
*
* It preserves bootstrap-frozen APIs, allows safe deferred additions, and
* reports cases where bootstrap-visible extensions relied on APIs that only
* became available during finalization.
*/
export function syncFinalApiFactories(options: {
deferredApiNodes: Iterable<AppNode>;
appApiRegistry: FrontendApiRegistry;
apiResolver: FrontendApiResolver;
collector: ErrorCollector;
features: FrontendFeature[];
bootstrapApiFactoryEntries: ReadonlyMap<string, ApiFactoryEntry>;
bootstrapMissingApiAccesses: Map<string, { node: AppNode; apiRefId: string }>;
predicateContext: ExtensionPredicateContext;
}) {
const finalApiEntries = collectApiFactoryEntries({
apiNodes: options.deferredApiNodes,
collector: options.collector,
predicateContext: options.predicateContext,
entries: new Map(options.bootstrapApiFactoryEntries),
});
// Only newly introduced or still-safe overrides are registered here. Any
// bootstrap-materialized API remains frozen for the lifetime of the app.
const changedEntries = Array.from(finalApiEntries.values()).filter(entry => {
const bootstrapEntry = options.bootstrapApiFactoryEntries.get(
entry.factory.api.id,
);
if (!bootstrapEntry) {
return true;
}
if (bootstrapEntry.factory === entry.factory) {
return false;
}
if (options.apiResolver.isMaterialized(entry.factory.api.id)) {
options.collector.report({
code: 'EXTENSION_BOOTSTRAP_API_OVERRIDE_IGNORED',
message:
`Extension '${entry.node.spec.id}' tried to override API ` +
`'${entry.factory.api.id}' after it had already been materialized during bootstrap. ` +
'The bootstrap implementation was kept and the deferred override was ignored.',
context: {
node: entry.node,
apiRefId: entry.factory.api.id,
bootstrapNode: bootstrapEntry.node,
pluginId: entry.pluginId,
bootstrapPluginId: bootstrapEntry.pluginId,
},
});
return false;
}
return true;
});
const changedFactories = changedEntries.map(entry =>
wrapFeatureFlagApiFactory(entry.factory, options.features),
);
options.appApiRegistry.setAll(changedFactories);
options.apiResolver.invalidate(
changedFactories.map(factory => factory.api.id),
);
for (const bootstrapAccess of options.bootstrapMissingApiAccesses.values()) {
if (
options.bootstrapApiFactoryEntries.has(bootstrapAccess.apiRefId) ||
!finalApiEntries.has(bootstrapAccess.apiRefId)
) {
continue;
}
options.collector.report({
code: 'EXTENSION_BOOTSTRAP_API_UNAVAILABLE',
message:
`Extension '${bootstrapAccess.node.spec.id}' tried to access API ` +
`'${bootstrapAccess.apiRefId}' during bootstrap before it was available. ` +
'That API became available during finalization, so bootstrap-visible extensions must not depend on deferred APIs.',
context: {
node: bootstrapAccess.node,
apiRefId: bootstrapAccess.apiRefId,
},
});
}
}
const EMPTY_API_HOLDER: ApiHolder = {
get() {
return undefined;
},
};
function registerFeatureFlagDeclarations(
featureFlagApi: typeof featureFlagsApiRef.T,
features: FrontendFeature[],
) {
for (const feature of features) {
if (OpaqueFrontendPlugin.isType(feature)) {
OpaqueFrontendPlugin.toInternal(feature).featureFlags.forEach(flag =>
featureFlagApi.registerFlag({
name: flag.name,
description: flag.description,
pluginId: feature.id,
}),
);
}
if (isInternalFrontendModule(feature)) {
toInternalFrontendModule(feature).featureFlags.forEach(flag =>
featureFlagApi.registerFlag({
name: flag.name,
description: flag.description,
pluginId: feature.pluginId,
}),
);
}
}
}
/**
* Instantiates API extension subtrees in isolation and extracts the factories
* they provide without mutating the live app tree.
*
* The collected entries are later used both for bootstrap registration and for
* the finalization-time reconciliation of deferred API roots.
*/
export function collectApiFactoryEntries(options: {
apiNodes: Iterable<AppNode>;
collector: ErrorCollector;
predicateContext?: ExtensionPredicateContext;
entries?: Map<string, ApiFactoryEntry>;
}): Map<string, ApiFactoryEntry> {
const factoriesById = options.entries ?? new Map<string, ApiFactoryEntry>();
for (const apiNode of options.apiNodes) {
// API extensions are instantiated in isolation so we can inspect the
// produced factories without mutating the live app tree.
const detachedApiNode = instantiateAppNodeSubtree({
rootNode: apiNode,
apis: EMPTY_API_HOLDER,
collector: options.collector,
predicateContext: options.predicateContext,
writeNodeInstances: false,
reuseExistingInstances: false,
});
if (!detachedApiNode) {
continue;
}
const apiFactory = detachedApiNode.instance?.getData(
ApiBlueprint.dataRefs.factory,
);
if (apiFactory) {
const apiRefId = apiFactory.api.id;
const ownerId = getApiOwnerId(apiRefId);
const pluginId = apiNode.spec.plugin.pluginId ?? 'app';
const existingFactory = factoriesById.get(apiRefId);
// This allows modules to override factories provided by the plugin, but
// it rejects API overrides from other plugins. In the event of a
// conflict, the owning plugin is attempted to be inferred from the API
// reference ID.
if (existingFactory && existingFactory.pluginId !== pluginId) {
const shouldReplace =
ownerId === pluginId && existingFactory.pluginId !== ownerId;
const acceptedPluginId = shouldReplace
? pluginId
: existingFactory.pluginId;
const rejectedPluginId = shouldReplace
? existingFactory.pluginId
: pluginId;
options.collector.report({
code: 'API_FACTORY_CONFLICT',
message: `API '${apiRefId}' is already provided by plugin '${acceptedPluginId}', cannot also be provided by '${rejectedPluginId}'.`,
context: {
node: apiNode,
apiRefId,
pluginId: rejectedPluginId,
existingPluginId: acceptedPluginId,
},
});
if (shouldReplace) {
factoriesById.set(apiRefId, {
pluginId,
node: apiNode,
factory: apiFactory,
});
}
continue;
}
factoriesById.set(apiRefId, {
pluginId,
node: apiNode,
factory: apiFactory,
});
} else {
options.collector.report({
code: 'API_EXTENSION_INVALID',
message: `API extension '${apiNode.spec.id}' did not output an API factory`,
context: {
node: apiNode,
},
});
}
}
return factoriesById;
}
// TODO(Rugvip): It would be good if this was more explicit, but I think that
// might need to wait for some future update for API factories.
function getApiOwnerId(apiRefId: string): string {
const [prefix, ...rest] = apiRefId.split('.');
if (!prefix) {
return apiRefId;
}
if (prefix === 'core') {
return 'app';
}
if (prefix === 'plugin' && rest[0]) {
return rest[0];
}
return prefix;
}
@@ -82,6 +82,21 @@ export type AppErrorTypes = {
existingPluginId: string;
};
};
EXTENSION_BOOTSTRAP_PREDICATE_IGNORED: {
context: { node: AppNode };
};
EXTENSION_BOOTSTRAP_API_UNAVAILABLE: {
context: { node: AppNode; apiRefId: string };
};
EXTENSION_BOOTSTRAP_API_OVERRIDE_IGNORED: {
context: {
node: AppNode;
apiRefId: string;
bootstrapNode: AppNode;
pluginId: string;
bootstrapPluginId: string;
};
};
// routing
ROUTE_DUPLICATE: {
context: { routeId: string };
File diff suppressed because it is too large Load Diff
@@ -14,209 +14,28 @@
* limitations under the License.
*/
import { ConfigReader } from '@backstage/config';
import {
AnyApiFactory,
ApiBlueprint,
ApiHolder,
AppNode,
AppTree,
AppTreeApi,
appTreeApiRef,
AnyRouteRefParams,
ConfigApi,
configApiRef,
createApiFactory,
ExternalRouteRef,
featureFlagsApiRef,
ExtensionFactoryMiddleware,
FrontendFeature,
identityApiRef,
RouteFunc,
RouteRef,
RouteResolutionApi,
routeResolutionApiRef,
SubRouteRef,
} from '@backstage/frontend-plugin-api';
import { ExtensionFactoryMiddleware } from './types';
import { ApiFactoryRegistry, ApiResolver } from '@backstage/core-app-api';
import {
createExtensionDataContainer,
OpaqueApiRef,
OpaqueFrontendPlugin,
} from '@internal/frontend';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import {
resolveExtensionDefinition,
toInternalExtension,
} from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition';
import {
extractRouteInfoFromAppNode,
RouteInfo,
} from '../routing/extractRouteInfoFromAppNode';
import { CreateAppRouteBinder } from '../routing';
import { RouteResolver } from '../routing/RouteResolver';
import { resolveRouteBindings } from '../routing/resolveRouteBindings';
import { collectRouteIds } from '../routing/collectRouteIds';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { FrontendPluginInfoResolver } from './createPluginInfoAttacher';
import {
toInternalFrontendModule,
isInternalFrontendModule,
} from '../../../frontend-plugin-api/src/wiring/createFrontendModule';
import { getBasePath } from '../routing/getBasePath';
import { Root } from '../extensions/Root';
import { resolveAppTree } from '../tree/resolveAppTree';
import { resolveAppNodeSpecs } from '../tree/resolveAppNodeSpecs';
import { readAppExtensionsConfig } from '../tree/readAppExtensionsConfig';
import { instantiateAppNodeTree } from '../tree/instantiateAppNodeTree';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
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 { matchRoutes } from 'react-router-dom';
import {
createPluginInfoAttacher,
FrontendPluginInfoResolver,
} from './createPluginInfoAttacher';
import { createRouteAliasResolver } from '../routing/RouteAliasResolver';
import {
AppError,
createErrorCollector,
ErrorCollector,
} from './createErrorCollector';
createSessionStateFromApis,
CreateSpecializedAppInternalOptions,
FinalizedSpecializedApp,
prepareSpecializedApp,
} from './prepareSpecializedApp';
function deduplicateFeatures(
allFeatures: FrontendFeature[],
): FrontendFeature[] {
// Start by removing duplicates by reference
const features = Array.from(new Set(allFeatures));
// Plugins are deduplicated by ID, last one wins
const seenIds = new Set<string>();
return features
.reverse()
.filter(feature => {
if (!OpaqueFrontendPlugin.isType(feature)) {
return true;
}
if (seenIds.has(feature.id)) {
return false;
}
seenIds.add(feature.id);
return true;
})
.reverse();
}
// Helps delay callers from reaching out to the API before the app tree has been materialized
class AppTreeApiProxy implements AppTreeApi {
#routeInfo?: RouteInfo;
private readonly tree: AppTree;
private readonly appBasePath: string;
constructor(tree: AppTree, appBasePath: string) {
this.tree = tree;
this.appBasePath = appBasePath;
}
private checkIfInitialized() {
if (!this.#routeInfo) {
throw new Error(
`You can't access the AppTreeApi during initialization of the app tree. Please move occurrences of this out of the initialization of the factory`,
);
}
}
getTree() {
this.checkIfInitialized();
return { tree: this.tree };
}
getNodesByRoutePath(routePath: string): { nodes: AppNode[] } {
this.checkIfInitialized();
let path = routePath;
if (path.startsWith(this.appBasePath)) {
path = path.slice(this.appBasePath.length);
}
const matchedRoutes = matchRoutes(this.#routeInfo!.routeObjects, path);
const matchedAppNodes =
matchedRoutes
?.filter(routeObj => !!routeObj.route.appNode)
.map(routeObj => routeObj.route.appNode!) || [];
return { nodes: matchedAppNodes };
}
initialize(routeInfo: RouteInfo) {
this.#routeInfo = routeInfo;
}
}
// Helps delay callers from reaching out to the API before the app tree has been materialized
class RouteResolutionApiProxy implements RouteResolutionApi {
#delegate: RouteResolutionApi | undefined;
#routeObjects: BackstageRouteObject[] | undefined;
private readonly routeBindings: Map<ExternalRouteRef, RouteRef | SubRouteRef>;
private readonly appBasePath: string;
constructor(
routeBindings: Map<ExternalRouteRef, RouteRef | SubRouteRef>,
appBasePath: string,
) {
this.routeBindings = routeBindings;
this.appBasePath = appBasePath;
}
resolve<TParams extends AnyRouteRefParams>(
anyRouteRef:
| RouteRef<TParams>
| SubRouteRef<TParams>
| ExternalRouteRef<TParams>,
options?: { sourcePath?: string },
): RouteFunc<TParams> | undefined {
if (!this.#delegate) {
throw new Error(
`You can't access the RouteResolver during initialization of the app tree. Please move occurrences of this out of the initialization of the factory`,
);
}
return this.#delegate.resolve(anyRouteRef, options);
}
initialize(
routeInfo: RouteInfo,
routeRefsById: Map<string, RouteRef | SubRouteRef>,
) {
this.#delegate = new RouteResolver(
routeInfo.routePaths,
routeInfo.routeParents,
routeInfo.routeObjects,
this.routeBindings,
this.appBasePath,
routeInfo.routeAliasResolver,
routeRefsById,
);
this.#routeObjects = routeInfo.routeObjects;
return routeInfo;
}
getRouteObjects() {
return this.#routeObjects;
}
}
export type { CreateSpecializedAppInternalOptions };
/**
* Options for {@link createSpecializedApp}.
*
* @deprecated Use `PrepareSpecializedAppOptions` with `prepareSpecializedApp` instead.
*
* @public
*/
export type CreateSpecializedAppOptions = {
@@ -245,12 +64,7 @@ export type CreateSpecializedAppOptions = {
*/
advanced?: {
/**
* A replacement API holder implementation to use.
*
* By default, a new API holder will be constructed automatically based on
* the other inputs. If you pass in a custom one here, none of that
* automation will take place - so you will have to take care to supply all
* those APIs yourself.
* APIs to expose to the app during startup.
*/
apis?: ApiHolder;
@@ -272,257 +86,29 @@ export type CreateSpecializedAppOptions = {
};
};
// Internal options type, not exported in the public API
export interface CreateSpecializedAppInternalOptions
extends CreateSpecializedAppOptions {
__internal?: {
apiFactoryOverrides?: AnyApiFactory[];
};
}
/**
* Creates an empty app without any default features. This is a low-level API is
* intended for use in tests or specialized setups. Typically you want to use
* `createApp` from `@backstage/frontend-defaults` instead.
*
* @deprecated Use `prepareSpecializedApp` instead.
*
* @public
*/
export function createSpecializedApp(options?: CreateSpecializedAppOptions): {
apis: ApiHolder;
tree: AppTree;
errors?: AppError[];
} {
const internalOptions = options as CreateSpecializedAppInternalOptions;
const config = options?.config ?? new ConfigReader({}, 'empty-config');
const features = deduplicateFeatures(options?.features ?? []).map(
createPluginInfoAttacher(config, options?.advanced?.pluginInfoResolver),
);
export function createSpecializedApp(
options?: CreateSpecializedAppOptions,
): FinalizedSpecializedApp {
const sessionState = options?.advanced?.apis
? createSessionStateFromApis(options.advanced.apis)
: undefined;
const collector = createErrorCollector();
const tree = resolveAppTree(
'root',
resolveAppNodeSpecs({
features,
builtinExtensions: [
resolveExtensionDefinition(Root, { namespace: 'root' }),
],
parameters: readAppExtensionsConfig(config),
forbidden: new Set(['root']),
collector,
}),
collector,
);
const factories = createApiFactories({ tree, collector });
const appBasePath = getBasePath(config);
const appTreeApi = new AppTreeApiProxy(tree, appBasePath);
const routeRefsById = collectRouteIds(features, collector);
const routeResolutionApi = new RouteResolutionApiProxy(
resolveRouteBindings(options?.bindRoutes, config, routeRefsById, collector),
appBasePath,
);
const appIdentityProxy = new AppIdentityProxy();
const apis =
options?.advanced?.apis ??
createApiHolder({
factories,
staticFactories: [
createApiFactory(appTreeApiRef, appTreeApi),
createApiFactory(configApiRef, config),
createApiFactory(routeResolutionApiRef, routeResolutionApi),
createApiFactory(identityApiRef, appIdentityProxy),
...(internalOptions?.__internal?.apiFactoryOverrides ?? []),
],
});
const featureFlagApi = apis.get(featureFlagsApiRef);
if (featureFlagApi) {
for (const feature of features) {
if (OpaqueFrontendPlugin.isType(feature)) {
OpaqueFrontendPlugin.toInternal(feature).featureFlags.forEach(flag =>
featureFlagApi.registerFlag({
name: flag.name,
description: flag.description,
pluginId: feature.id,
}),
);
}
if (isInternalFrontendModule(feature)) {
toInternalFrontendModule(feature).featureFlags.forEach(flag =>
featureFlagApi.registerFlag({
name: flag.name,
description: flag.description,
pluginId: feature.pluginId,
}),
);
}
}
}
// Now instantiate the entire tree, which will skip anything that's already been instantiated
instantiateAppNodeTree(
tree.root,
apis,
collector,
mergeExtensionFactoryMiddleware(
options?.advanced?.extensionFactoryMiddleware,
),
);
const routeInfo = extractRouteInfoFromAppNode(
tree.root,
createRouteAliasResolver(routeRefsById),
);
routeResolutionApi.initialize(routeInfo, routeRefsById.routes);
appTreeApi.initialize(routeInfo);
return { apis, tree, errors: collector.collectErrors() };
}
function createApiFactories(options: {
tree: AppTree;
collector: ErrorCollector;
}): AnyApiFactory[] {
const emptyApiHolder = ApiRegistry.from([]);
const factoriesById = new Map<
string,
{ pluginId: string; factory: AnyApiFactory }
>();
for (const apiNode of options.tree.root.edges.attachments.get('apis') ?? []) {
if (!instantiateAppNodeTree(apiNode, emptyApiHolder, options.collector)) {
continue;
}
const apiFactory = apiNode.instance?.getData(ApiBlueprint.dataRefs.factory);
if (apiFactory) {
const apiRefId = apiFactory.api.id;
const ownerId = getApiOwnerId(apiFactory.api);
const pluginId = apiNode.spec.plugin.pluginId ?? 'app';
const existingFactory = factoriesById.get(apiRefId);
// This allows modules to override factories provided by the plugin, but
// it rejects API overrides from other plugins. In the event of a
// conflict, the owning plugin is inferred from the explicit pluginId or
// legacy plugin-prefixed API reference ID.
if (existingFactory && existingFactory.pluginId !== pluginId) {
const shouldReplace =
ownerId === pluginId && existingFactory.pluginId !== ownerId;
const acceptedPluginId = shouldReplace
? pluginId
: existingFactory.pluginId;
const rejectedPluginId = shouldReplace
? existingFactory.pluginId
: pluginId;
options.collector.report({
code: 'API_FACTORY_CONFLICT',
message: `API '${apiRefId}' is already provided by plugin '${acceptedPluginId}', cannot also be provided by '${rejectedPluginId}'.`,
context: {
node: apiNode,
apiRefId,
pluginId: rejectedPluginId,
existingPluginId: acceptedPluginId,
},
});
if (shouldReplace) {
factoriesById.set(apiRefId, {
pluginId,
factory: apiFactory,
});
}
continue;
}
factoriesById.set(apiRefId, { pluginId, factory: apiFactory });
} else {
options.collector.report({
code: 'API_EXTENSION_INVALID',
message: `API extension '${apiNode.spec.id}' did not output an API factory`,
context: {
node: apiNode,
},
});
}
}
return Array.from(factoriesById.values(), entry => entry.factory);
}
// TODO(Rugvip): It would be good if this was more explicit, but I think that
// might need to wait for some future update for API factories.
function getApiOwnerId(apiRef: { id: string }): string {
if (OpaqueApiRef.isType(apiRef)) {
const { pluginId } = OpaqueApiRef.toInternal(apiRef);
if (pluginId) {
return pluginId;
}
}
const apiRefId = apiRef.id;
const [prefix, ...rest] = apiRefId.split('.');
if (!prefix) {
return apiRefId;
}
if (prefix === 'plugin' && rest[0]) {
return rest[0];
}
return prefix;
}
function createApiHolder(options: {
factories: AnyApiFactory[];
staticFactories: AnyApiFactory[];
}): ApiHolder {
const factoryRegistry = new ApiFactoryRegistry();
for (const factory of options.factories.slice().reverse()) {
factoryRegistry.register('default', factory);
}
for (const factory of options.staticFactories) {
factoryRegistry.register('static', factory);
}
ApiResolver.validateFactories(factoryRegistry, factoryRegistry.getAllApis());
return new ApiResolver(factoryRegistry);
}
function mergeExtensionFactoryMiddleware(
middlewares?: ExtensionFactoryMiddleware | ExtensionFactoryMiddleware[],
): ExtensionFactoryMiddleware | undefined {
if (!middlewares) {
return undefined;
}
if (!Array.isArray(middlewares)) {
return middlewares;
}
if (middlewares.length <= 1) {
return middlewares[0];
}
return middlewares.reduce((prev, next) => {
if (!prev || !next) {
return prev ?? next;
}
return (orig, ctx) => {
const internalExt = toInternalExtension(ctx.node.spec.extension);
if (internalExt.version !== 'v2') {
return orig();
}
return next(ctxOverrides => {
return createExtensionDataContainer(
prev(orig, {
node: ctx.node,
apis: ctx.apis,
config: ctxOverrides?.config ?? ctx.config,
}),
'extension factory middleware',
);
}, ctx);
};
});
return prepareSpecializedApp({
features: options?.features,
config: options?.config,
bindRoutes: options?.bindRoutes,
advanced: {
...options?.advanced,
sessionState,
},
}).finalize();
}
@@ -14,6 +14,14 @@
* limitations under the License.
*/
export {
type BootstrapSpecializedApp,
type FinalizedSpecializedApp,
prepareSpecializedApp,
type PrepareSpecializedAppOptions,
type PreparedSpecializedApp,
type SpecializedAppSessionState,
} from './prepareSpecializedApp';
export {
createSpecializedApp,
type CreateSpecializedAppOptions,
@@ -0,0 +1,289 @@
/*
* Copyright 2023 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 {
AnyApiFactory,
ApiHolder,
AppTree,
AppTreeApi,
appTreeApiRef,
ConfigApi,
configApiRef,
createApiFactory,
ExternalRouteRef,
identityApiRef,
RouteFunc,
RouteRef,
RouteResolutionApi,
routeResolutionApiRef,
SubRouteRef,
type AnyRouteRefParams,
type AppNode,
type ExtensionFactoryMiddleware,
type IdentityApi,
} from '@backstage/frontend-plugin-api';
import { matchRoutes } from 'react-router-dom';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { AppIdentityProxy } from '../../../core-app-api/src/apis/implementations/IdentityApi/AppIdentityProxy';
import { createRouteAliasResolver } from '../routing/RouteAliasResolver';
import { RouteResolver } from '../routing/RouteResolver';
import { collectRouteIds } from '../routing/collectRouteIds';
import {
extractRouteInfoFromAppNode,
type RouteInfo,
} from '../routing/extractRouteInfoFromAppNode';
import { type BackstageRouteObject } from '../routing/types';
import { instantiateAppNodeTree } from '../tree/instantiateAppNodeTree';
import {
FrontendApiRegistry,
FrontendApiResolver,
} from './FrontendApiRegistry';
import { type ExtensionPredicateContext } from './predicates';
import { type ErrorCollector } from './createErrorCollector';
// Helps delay callers from reaching out to the API before the app tree has been materialized
export class AppTreeApiProxy implements AppTreeApi {
#routeInfo?: RouteInfo;
private readonly tree: AppTree;
private readonly appBasePath: string;
constructor(tree: AppTree, appBasePath: string) {
this.tree = tree;
this.appBasePath = appBasePath;
}
private checkIfInitialized() {
if (!this.#routeInfo) {
throw new Error(
`You can't access the AppTreeApi during initialization of the app tree. Please move occurrences of this out of the initialization of the factory`,
);
}
}
getTree() {
this.checkIfInitialized();
return { tree: this.tree };
}
getNodesByRoutePath(routePath: string): { nodes: AppNode[] } {
this.checkIfInitialized();
const routeInfo = this.#routeInfo;
if (!routeInfo) {
throw new Error(
`You can't access the AppTreeApi during initialization of the app tree. Please move occurrences of this out of the initialization of the factory`,
);
}
let path = routePath;
if (path.startsWith(this.appBasePath)) {
path = path.slice(this.appBasePath.length);
}
const matchedRoutes = matchRoutes(routeInfo.routeObjects, path);
const matchedAppNodes =
matchedRoutes?.flatMap(routeObj => {
const appNode = routeObj.route.appNode;
return appNode ? [appNode] : [];
}) || [];
return { nodes: matchedAppNodes };
}
initialize(routeInfo: RouteInfo) {
this.#routeInfo = routeInfo;
}
}
// Helps delay callers from reaching out to the API before the app tree has been materialized
export class RouteResolutionApiProxy implements RouteResolutionApi {
#delegate: RouteResolutionApi | undefined;
#routeObjects: BackstageRouteObject[] | undefined;
private readonly routeBindings: Map<ExternalRouteRef, RouteRef | SubRouteRef>;
private readonly appBasePath: string;
constructor(
routeBindings: Map<ExternalRouteRef, RouteRef | SubRouteRef>,
appBasePath: string,
) {
this.routeBindings = routeBindings;
this.appBasePath = appBasePath;
}
resolve<TParams extends AnyRouteRefParams>(
anyRouteRef:
| RouteRef<TParams>
| SubRouteRef<TParams>
| ExternalRouteRef<TParams>,
options?: { sourcePath?: string },
): RouteFunc<TParams> | undefined {
if (!this.#delegate) {
throw new Error(
`You can't access the RouteResolver during initialization of the app tree. Please move occurrences of this out of the initialization of the factory`,
);
}
return this.#delegate.resolve(anyRouteRef, options);
}
initialize(
routeInfo: RouteInfo,
routeRefsById: Map<string, RouteRef | SubRouteRef>,
) {
this.#delegate = new RouteResolver(
routeInfo.routePaths,
routeInfo.routeParents,
routeInfo.routeObjects,
this.routeBindings,
this.appBasePath,
routeInfo.routeAliasResolver,
routeRefsById,
);
this.#routeObjects = routeInfo.routeObjects;
return routeInfo;
}
getRouteObjects() {
return this.#routeObjects;
}
}
export class PreparedAppIdentityProxy extends AppIdentityProxy {
#onTargetSet?:
| ((identityApi: Parameters<AppIdentityProxy['setTarget']>[0]) => void)
| undefined;
setTargetHandlers(options: {
onTargetSet?(
identityApi: Parameters<AppIdentityProxy['setTarget']>[0],
): void;
}) {
this.#onTargetSet = options.onTargetSet;
}
clearTargetHandlers() {
this.#onTargetSet = undefined;
}
override setTarget(
identityApi: Parameters<AppIdentityProxy['setTarget']>[0],
targetOptions: Parameters<AppIdentityProxy['setTarget']>[1],
) {
super.setTarget(identityApi, targetOptions);
const onTargetSet = this.#onTargetSet;
if (!onTargetSet) {
return;
}
this.clearTargetHandlers();
onTargetSet(identityApi);
}
}
export function createPhaseApis(options: {
tree: AppTree;
config: ConfigApi;
appApiRegistry: FrontendApiRegistry;
fallbackApis?: ApiHolder;
includeConfigApi: boolean;
appBasePath: string;
routeBindings: Map<ExternalRouteRef, RouteRef | SubRouteRef>;
staticFactories: AnyApiFactory[];
}) {
const appTreeApi = new AppTreeApiProxy(options.tree, options.appBasePath);
const routeResolutionApi = new RouteResolutionApiProxy(
options.routeBindings,
options.appBasePath,
);
const identityProxy = new PreparedAppIdentityProxy();
const phaseApiRegistry = new FrontendApiRegistry();
phaseApiRegistry.registerAll([
createApiFactory(appTreeApiRef, appTreeApi),
...(options.includeConfigApi
? [createApiFactory(configApiRef, options.config)]
: []),
createApiFactory(routeResolutionApiRef, routeResolutionApi),
createApiFactory(identityApiRef, identityProxy),
...options.staticFactories,
]);
const apis = new FrontendApiResolver({
primaryRegistry: phaseApiRegistry,
secondaryRegistry: options.appApiRegistry,
fallbackApis: options.fallbackApis,
});
return {
apis,
routeResolutionApi,
appTreeApi,
identityApiProxy: identityProxy,
};
}
export function instantiateAndInitializePhaseTree(options: {
tree: AppTree;
apis: ApiHolder;
collector: ErrorCollector;
extensionFactoryMiddleware?: ExtensionFactoryMiddleware;
routeResolutionApi: RouteResolutionApiProxy;
appTreeApi: AppTreeApiProxy;
routeRefsById: ReturnType<typeof collectRouteIds>;
skipChild?(ctx: { node: AppNode; input: string; child: AppNode }): boolean;
onMissingApi?(ctx: { node: AppNode; apiRefId: string }): void;
predicateContext?: ExtensionPredicateContext;
stopAtAttachment?(ctx: { node: AppNode; input: string }): boolean;
}) {
instantiateAppNodeTree(
options.tree.root,
options.apis,
options.collector,
options.extensionFactoryMiddleware,
{
...(options.stopAtAttachment
? { stopAtAttachment: options.stopAtAttachment }
: {}),
skipChild: options.skipChild,
onMissingApi: options.onMissingApi,
predicateContext: options.predicateContext,
},
);
const routeInfo = extractRouteInfoFromAppNode(
options.tree.root,
createRouteAliasResolver(options.routeRefsById),
);
options.routeResolutionApi.initialize(
routeInfo,
options.routeRefsById.routes,
);
options.appTreeApi.initialize(routeInfo);
}
export function setIdentityApiTarget(options: {
identityApiProxy: AppIdentityProxy;
identityApi: IdentityApi;
signOutTargetUrl: string;
}) {
options.identityApiProxy.setTarget(options.identityApi, {
signOutTargetUrl: options.signOutTargetUrl,
});
}
@@ -0,0 +1,185 @@
/*
* Copyright 2023 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 {
ApiHolder,
createApiRef,
featureFlagsApiRef,
} from '@backstage/frontend-plugin-api';
import { FilterPredicate } from '@backstage/filter-predicates';
import type {
EvaluatePermissionRequest,
EvaluatePermissionResponse,
} from '@backstage/plugin-permission-common';
export type ExtensionPredicateContext = {
featureFlags: string[];
permissions: string[];
};
export const EMPTY_PREDICATE_CONTEXT: ExtensionPredicateContext = {
featureFlags: [],
permissions: [],
};
// Minimal local permission API interface to avoid a dependency on @backstage/plugin-permission-react
type MinimalPermissionApi = {
authorize(
request: EvaluatePermissionRequest,
): Promise<EvaluatePermissionResponse>;
};
export const localPermissionApiRef = createApiRef<MinimalPermissionApi>({
id: 'plugin.permission.api',
});
export function createPredicateContextLoader(options: {
apis: ApiHolder;
predicateReferences: ExtensionPredicateContext;
}) {
function getActiveFeatureFlags() {
const featureFlagsApi = options.apis.get(featureFlagsApiRef);
if (!featureFlagsApi) {
return [];
}
return options.predicateReferences.featureFlags.filter(name =>
featureFlagsApi.isActive(name),
);
}
function getImmediate(): ExtensionPredicateContext | undefined {
if (options.predicateReferences.permissions.length > 0) {
const permissionApi = options.apis.get(localPermissionApiRef);
if (permissionApi) {
return undefined;
}
}
return {
featureFlags: getActiveFeatureFlags(),
permissions: [],
};
}
async function load() {
const immediatePredicateContext = getImmediate();
if (immediatePredicateContext) {
return immediatePredicateContext;
}
let allowedPermissions: string[] = [];
const permissionApi = options.apis.get(localPermissionApiRef);
if (permissionApi) {
const permissionNames = options.predicateReferences.permissions;
const responses = await Promise.all(
permissionNames.map(name =>
permissionApi.authorize({
permission: { name, type: 'basic', attributes: {} },
}),
),
);
allowedPermissions = permissionNames.filter(
(_, i) => responses[i].result === 'ALLOW',
);
}
return {
featureFlags: getActiveFeatureFlags(),
permissions: allowedPermissions,
};
}
return {
getImmediate,
load,
};
}
export function collectPredicateReferences(
nodes: Iterable<{ spec: { if?: FilterPredicate } }>,
): ExtensionPredicateContext {
const featureFlags = new Set<string>();
const permissions = new Set<string>();
for (const node of nodes) {
if (node.spec.if === undefined) {
continue;
}
for (const name of extractFeatureFlagNames(node.spec.if)) {
featureFlags.add(name);
}
for (const name of extractPermissionNames(node.spec.if)) {
permissions.add(name);
}
}
return {
featureFlags: Array.from(featureFlags),
permissions: Array.from(permissions),
};
}
/**
* Recursively walks a FilterPredicate and returns all string values referenced
* by `featureFlags: { $contains: '...' }` expressions. This lets us call
* `isActive()` only for the flags that are actually used in predicates rather
* than fetching the full registered-flag list.
*/
function extractFeatureFlagNames(predicate: FilterPredicate): string[] {
return extractPredicateKeyNames(predicate, 'featureFlags');
}
/**
* Recursively walks a FilterPredicate and returns all string values referenced
* by `permissions: { $contains: '...' }` expressions. This lets us issue a
* single batched authorize call for only the permissions actually referenced.
*/
function extractPermissionNames(predicate: FilterPredicate): string[] {
return extractPredicateKeyNames(predicate, 'permissions');
}
function extractPredicateKeyNames(
predicate: FilterPredicate,
key: string,
): string[] {
if (typeof predicate !== 'object' || predicate === null) {
return [];
}
const obj = predicate as Record<string, unknown>;
if (Array.isArray(obj.$all)) {
return (obj.$all as FilterPredicate[]).flatMap(p =>
extractPredicateKeyNames(p, key),
);
}
if (Array.isArray(obj.$any)) {
return (obj.$any as FilterPredicate[]).flatMap(p =>
extractPredicateKeyNames(p, key),
);
}
if (obj.$not !== undefined) {
return extractPredicateKeyNames(obj.$not as FilterPredicate, key);
}
const value = obj[key];
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
const contains = (value as Record<string, unknown>).$contains;
if (typeof contains === 'string') {
return [contains];
}
}
return [];
}
@@ -0,0 +1,926 @@
/*
* Copyright 2023 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 { ConfigReader } from '@backstage/config';
import { isError } from '@backstage/errors';
import {
AnyApiFactory,
ApiHolder,
AppTree,
ConfigApi,
coreExtensionData,
AppNode,
ExtensionFactoryMiddleware,
FrontendFeature,
IdentityApi,
identityApiRef,
createExtensionDataRef,
} from '@backstage/frontend-plugin-api';
import {
createExtensionDataContainer,
OpaqueFrontendPlugin,
} from '@internal/frontend';
import { OpaqueType } from '@internal/opaque';
import { ComponentType, ReactNode } from 'react';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import {
resolveExtensionDefinition,
toInternalExtension,
} from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition';
import { CreateAppRouteBinder } from '../routing';
import { resolveRouteBindings } from '../routing/resolveRouteBindings';
import { collectRouteIds } from '../routing/collectRouteIds';
import { getBasePath } from '../routing/getBasePath';
import { Root } from '../extensions/Root';
import { resolveAppTree } from '../tree/resolveAppTree';
import { resolveAppNodeSpecs } from '../tree/resolveAppNodeSpecs';
import { readAppExtensionsConfig } from '../tree/readAppExtensionsConfig';
import {
createPluginInfoAttacher,
FrontendPluginInfoResolver,
} from './createPluginInfoAttacher';
import {
AppError,
createErrorCollector,
ErrorCollector,
} from './createErrorCollector';
import {
createPhaseApis,
instantiateAndInitializePhaseTree,
setIdentityApiTarget,
} from './phaseApis';
import {
collectPredicateReferences,
createPredicateContextLoader,
EMPTY_PREDICATE_CONTEXT,
type ExtensionPredicateContext,
} from './predicates';
import { FrontendApiRegistry } from './FrontendApiRegistry';
import {
ApiFactoryEntry,
collectApiFactoryEntries,
registerFeatureFlagDeclarationsInHolder,
syncFinalApiFactories,
wrapFeatureFlagApiFactory,
} from './apiFactories';
import {
attachThrowingFinalizationChild,
BootstrapClassification,
classifyBootstrapTree,
clearFinalizationBoundaryInstances,
createBootstrapApp,
prepareFinalizedTree,
} from './treeLifecycle';
function deduplicateFeatures(
allFeatures: FrontendFeature[],
): FrontendFeature[] {
// Start by removing duplicates by reference
const features = Array.from(new Set(allFeatures));
// Plugins are deduplicated by ID, last one wins
const seenIds = new Set<string>();
return features
.reverse()
.filter(feature => {
if (!OpaqueFrontendPlugin.isType(feature)) {
return true;
}
if (seenIds.has(feature.id)) {
return false;
}
seenIds.add(feature.id);
return true;
})
.reverse();
}
type SignInPageProps = {
onSignInSuccess(identityApi: IdentityApi): void;
children?: ReactNode;
};
/**
* Result of bootstrapping a prepared specialized app.
*
* @public
*/
export type BootstrapSpecializedApp = {
element: JSX.Element;
tree: AppTree;
};
/**
* Result of finalizing a prepared specialized app.
*
* @public
*/
export type FinalizedSpecializedApp = {
element: JSX.Element;
sessionState: SpecializedAppSessionState;
tree: AppTree;
errors?: AppError[];
};
type SignInRuntime = {
readyIdentityApi?: IdentityApi;
requiresSignIn: boolean;
};
type FinalizationState = {
started: boolean;
promise: Promise<FinalizedSpecializedApp>;
resolve(app: FinalizedSpecializedApp): void;
reject(error: unknown): void;
};
type FinalizationMode = 'onFinalized' | 'finalize';
type InternalSpecializedAppSessionState = {
apis: ApiHolder;
identityApi?: IdentityApi;
predicateContext: ExtensionPredicateContext;
};
/**
* Opaque reusable session state for specialized apps.
*
* @public
*/
export type SpecializedAppSessionState = {
$$type: '@backstage/SpecializedAppSessionState';
};
const OpaqueSpecializedAppSessionState = OpaqueType.create<{
public: SpecializedAppSessionState;
versions: InternalSpecializedAppSessionState & {
version: 'v1';
};
}>({
type: '@backstage/SpecializedAppSessionState',
versions: ['v1'],
});
const signInPageComponentDataRef = createExtensionDataRef<
ComponentType<SignInPageProps>
>().with({ id: 'core.sign-in-page.component' });
/**
* Options for {@link prepareSpecializedApp}.
*
* @public
*/
export type PrepareSpecializedAppOptions = {
/**
* The list of features to load.
*/
features?: FrontendFeature[];
/**
* The config API implementation to use. For most normal apps, this should be
* specified.
*
* If none is given, a new _empty_ config will be used during startup. In
* later stages of the app lifecycle, the config API in the API holder will be
* used.
*/
config?: ConfigApi;
/**
* Allows for the binding of plugins' external route refs within the app.
*/
bindRoutes?(context: { bind: CreateAppRouteBinder }): void;
/**
* Advanced, more rarely used options.
*/
advanced?: {
/**
* A reusable specialized app session state to use.
*
* This can be obtained from either the app passed to
* {@link PreparedSpecializedApp.onFinalized} or from
* {@link PreparedSpecializedApp.finalize}, and reused in a future app
* instance to skip sign-in and session preparation.
*/
sessionState?: SpecializedAppSessionState;
/**
* Applies one or more middleware on every extension, as they are added to
* the application.
*
* This is an advanced use case for modifying extension data on the fly as
* it gets emitted by extensions being instantiated.
*/
extensionFactoryMiddleware?:
| ExtensionFactoryMiddleware
| ExtensionFactoryMiddleware[];
/**
* Allows for customizing how plugin info is retrieved.
*/
pluginInfoResolver?: FrontendPluginInfoResolver;
};
};
/**
* Result of {@link prepareSpecializedApp}.
*
* @public
*/
export type PreparedSpecializedApp = {
getBootstrapApp(): BootstrapSpecializedApp;
onFinalized(callback: (app: FinalizedSpecializedApp) => void): () => void;
finalize(): FinalizedSpecializedApp;
};
// Internal options type, not exported in the public API
export interface CreateSpecializedAppInternalOptions
extends PrepareSpecializedAppOptions {
__internal?: {
apiFactoryOverrides?: AnyApiFactory[];
};
}
export function createSessionStateFromApis(
apis: ApiHolder,
): SpecializedAppSessionState {
return OpaqueSpecializedAppSessionState.createInstance('v1', {
apis,
identityApi: apis.get(identityApiRef),
predicateContext: EMPTY_PREDICATE_CONTEXT,
});
}
/**
* Prepares an app without instantiating the full extension tree.
*
* @remarks
*
* This is useful for split sign-in flows where the sign-in page should be
* rendered first, and the full app finalized once an identity has been
* captured.
*
* @public
*/
export function prepareSpecializedApp(
options?: PrepareSpecializedAppOptions,
): PreparedSpecializedApp {
const internalOptions = options as CreateSpecializedAppInternalOptions;
const config = options?.config ?? new ConfigReader({}, 'empty-config');
const features = deduplicateFeatures(options?.features ?? []).map(
createPluginInfoAttacher(config, options?.advanced?.pluginInfoResolver),
);
const collector = createErrorCollector();
const tree = resolveAppTree(
'root',
resolveAppNodeSpecs({
features,
builtinExtensions: [
resolveExtensionDefinition(Root, { namespace: 'root' }),
],
parameters: readAppExtensionsConfig(config),
forbidden: new Set(['root']),
collector,
}),
collector,
);
const appBasePath = getBasePath(config);
const routeRefsById = collectRouteIds(features, collector);
const routeBindings = resolveRouteBindings(
options?.bindRoutes,
config,
routeRefsById,
collector,
);
const mergedExtensionFactoryMiddleware = mergeExtensionFactoryMiddleware(
options?.advanced?.extensionFactoryMiddleware,
);
const providedSessionState = options?.advanced?.sessionState;
const providedSessionData = providedSessionState
? OpaqueSpecializedAppSessionState.toInternal(providedSessionState)
: undefined;
const providedApis = providedSessionData?.apis;
// Bootstrap only renders the parts of the tree that are known to be safe
// before predicate context and sign-in have been resolved.
const bootstrapClassification = classifyBootstrapTree({
tree,
collector,
});
const predicateReferences = collectPredicateReferences(tree.nodes.values());
const appApiRegistry = new FrontendApiRegistry();
const internalStaticFactories =
internalOptions?.__internal?.apiFactoryOverrides ?? [];
const phaseStaticFactories = [...internalStaticFactories];
const bootstrapApiFactoryEntries = new Map<string, ApiFactoryEntry>();
const bootstrapMissingApiAccesses = new Map<
string,
{ node: AppNode; apiRefId: string }
>();
if (providedApis) {
// Reused session state already carries a fully prepared API holder, so the
// bootstrap path only needs to register feature flag declarations on top.
registerFeatureFlagDeclarationsInHolder(providedApis, features);
} else {
// Bootstrap materializes only the immediately visible API factories. Any
// predicate-gated API roots are revisited during finalization.
collectApiFactoryEntries({
apiNodes: (tree.root.edges.attachments.get('apis') ?? []).filter(
apiNode => !bootstrapClassification.deferredApiRoots.has(apiNode),
),
collector,
entries: bootstrapApiFactoryEntries,
});
const apiFactories = Array.from(
bootstrapApiFactoryEntries.values(),
entry => wrapFeatureFlagApiFactory(entry.factory, features),
);
appApiRegistry.registerAll(apiFactories);
}
const phase = createPhaseApis({
tree,
config,
appApiRegistry,
fallbackApis: providedApis,
includeConfigApi: !providedApis,
appBasePath,
routeBindings,
staticFactories: phaseStaticFactories,
});
const predicateContextLoader = createPredicateContextLoader({
apis: phase.apis,
predicateReferences,
});
let signInRuntime: SignInRuntime | undefined;
let finalized: FinalizedSpecializedApp | undefined;
let bootstrapApp: BootstrapSpecializedApp | undefined;
function updateIdentityApiTarget(identityApi?: IdentityApi) {
if (!identityApi) {
return;
}
setIdentityApiTarget({
identityApiProxy: phase.identityApiProxy,
identityApi,
signOutTargetUrl: appBasePath || '/',
});
}
function createSessionState(predicateContext: ExtensionPredicateContext) {
const identityApi =
signInRuntime?.readyIdentityApi ?? providedSessionData?.identityApi;
// As soon as a real identity is available we swap the phase proxy over so
// the finalized tree observes the same API instance.
updateIdentityApiTarget(identityApi);
const sessionState = OpaqueSpecializedAppSessionState.createInstance('v1', {
apis: phase.apis,
identityApi,
predicateContext,
});
return sessionState;
}
function getSynchronousSessionState() {
if (providedSessionState) {
return providedSessionState;
}
// The direct finalize() path is intentionally synchronous. If sign-in is
// still pending we refuse to guess and force the caller to wait.
if (signInRuntime?.requiresSignIn) {
return undefined;
}
const predicateContext = predicateContextLoader.getImmediate();
if (!predicateContext) {
return undefined;
}
return createSessionState(predicateContext);
}
function loadAsyncSessionState() {
if (providedSessionState) {
return Promise.resolve(providedSessionState);
}
if (signInRuntime?.requiresSignIn && !signInRuntime.readyIdentityApi) {
return Promise.reject(
new Error(
'prepareSpecializedApp requires waiting for the bootstrap app to be ready before calling finalize()',
),
);
}
// For apps without sign-in we can sometimes finalize immediately from the
// already available predicate context, skipping the async loader.
if (!signInRuntime?.requiresSignIn) {
const immediateSessionState = getSynchronousSessionState();
if (immediateSessionState) {
return Promise.resolve(immediateSessionState);
}
}
return predicateContextLoader.load().then(createSessionState);
}
function finalizeWithSessionState(
finalizedSessionState: SpecializedAppSessionState,
) {
return finalizeFromSessionState({
finalized,
finalizedSessionState,
tree,
collector,
phase,
extensionFactoryMiddleware: mergedExtensionFactoryMiddleware,
routeRefsById,
appBasePath,
providedApis,
features,
appApiRegistry,
bootstrapClassification,
bootstrapApiFactoryEntries,
bootstrapMissingApiAccesses,
});
}
function finalizeWithBootstrapError(
error: Error,
finalizedSessionState?: SpecializedAppSessionState,
) {
return finalizeFromBootstrapError({
finalized,
error,
finalizedSessionState,
tree,
collector,
phase,
extensionFactoryMiddleware: mergedExtensionFactoryMiddleware,
routeRefsById,
signInRuntime,
providedSessionData,
});
}
const finalization = createFinalizationController({
getFinalized() {
return finalized;
},
setFinalized(finalizedApp) {
finalized = finalizedApp;
},
finalizeFromSessionState: finalizeWithSessionState,
finalizeFromBootstrapError: finalizeWithBootstrapError,
});
function getBootstrapApp() {
if (bootstrapApp) {
return bootstrapApp;
}
const runtime: SignInRuntime = {
requiresSignIn: false,
};
if (!providedSessionState) {
phase.identityApiProxy.setTargetHandlers({
onTargetSet(identityApi) {
runtime.readyIdentityApi = identityApi;
// Sign-in completion only auto-starts finalization for onFinalized().
// The direct finalize() path stays explicit and synchronous.
if (finalization.getMode() === 'onFinalized') {
finalization.start(loadAsyncSessionState);
}
},
});
}
const result = createBootstrapApp({
tree,
apis: phase.apis,
collector,
routeRefsById,
routeResolutionApi: phase.routeResolutionApi,
appTreeApi: phase.appTreeApi,
extensionFactoryMiddleware: mergedExtensionFactoryMiddleware,
disableSignIn: Boolean(providedSessionState),
skipBootstrapChild({ child }) {
return bootstrapClassification.deferredRoots.has(child);
},
onMissingApi({ node, apiRefId }) {
bootstrapMissingApiAccesses.set(`${node.spec.id}:${apiRefId}`, {
node,
apiRefId,
});
},
hasSignInPage(signInPageNode) {
return Boolean(
signInPageNode?.instance?.getData(signInPageComponentDataRef),
);
},
});
if (!result.requiresSignIn) {
phase.identityApiProxy.clearTargetHandlers();
}
runtime.requiresSignIn = result.requiresSignIn;
signInRuntime = runtime;
bootstrapApp = result.bootstrapApp;
return bootstrapApp;
}
return {
getBootstrapApp,
onFinalized(callback) {
finalization.selectMode('onFinalized');
// Subscribing to finalization also ensures the bootstrap tree exists,
// because sign-in may need to capture identity before finalization starts.
getBootstrapApp();
let subscribed = true;
if (finalized) {
const finalizedApp = finalized;
Promise.resolve().then(() => {
if (subscribed) {
callback(finalizedApp);
}
});
return () => {
subscribed = false;
};
}
// If sign-in is still in progress we wait for the shared promise created
// by the sign-in callback. Otherwise we can start finalization right away.
const finalizedAppPromise =
signInRuntime?.requiresSignIn && !signInRuntime.readyIdentityApi
? finalization.getPromise()
: finalization.start(loadAsyncSessionState);
finalizedAppPromise
.then(finalizedApp => {
if (subscribed) {
callback(finalizedApp);
}
})
.catch(() => {});
return () => {
subscribed = false;
};
},
finalize() {
finalization.selectMode('finalize');
if (finalized) {
return finalized;
}
if (!providedSessionState) {
// finalize() still depends on bootstrap classification and sign-in
// discovery unless a reusable session was supplied up front, so we make
// sure the bootstrap tree has been prepared first.
getBootstrapApp();
}
// Direct finalization never waits for async session preparation. Callers
// must either provide sessionState during prepareSpecializedApp() or
// invoke finalize() only when the predicate context is already available
// synchronously.
const finalizedSessionState = signInRuntime?.requiresSignIn
? undefined
: getSynchronousSessionState();
if (!finalizedSessionState) {
if (signInRuntime?.requiresSignIn) {
throw new Error(
'prepareSpecializedApp requires waiting for the bootstrap app to be ready before calling finalize()',
);
}
throw new Error(
'prepareSpecializedApp requires waiting for asynchronous finalization before calling finalize()',
);
}
finalized = finalizeWithSessionState(finalizedSessionState);
return finalized;
},
};
}
/**
* Materializes the fully finalized app tree from a prepared session state.
*
* This is responsible for switching the identity proxy to the resolved target,
* synchronizing any deferred API factories, and re-instantiating the parts of
* the tree that are only valid once predicate context is available.
*/
function finalizeFromSessionState(options: {
finalized?: FinalizedSpecializedApp;
finalizedSessionState: SpecializedAppSessionState;
tree: AppTree;
collector: ErrorCollector;
phase: ReturnType<typeof createPhaseApis>;
extensionFactoryMiddleware?: ExtensionFactoryMiddleware;
routeRefsById: ReturnType<typeof collectRouteIds>;
appBasePath: string;
providedApis?: ApiHolder;
features: FrontendFeature[];
appApiRegistry: FrontendApiRegistry;
bootstrapClassification: BootstrapClassification;
bootstrapApiFactoryEntries: Map<string, ApiFactoryEntry>;
bootstrapMissingApiAccesses: Map<string, { node: AppNode; apiRefId: string }>;
}): FinalizedSpecializedApp {
if (options.finalized) {
return options.finalized;
}
const sessionStateData = OpaqueSpecializedAppSessionState.toInternal(
options.finalizedSessionState,
);
if (sessionStateData.identityApi) {
// Finalization retargets the identity proxy before any additional nodes are
// instantiated so the full tree observes the captured identity immediately.
setIdentityApiTarget({
identityApiProxy: options.phase.identityApiProxy,
identityApi: sessionStateData.identityApi,
signOutTargetUrl: options.appBasePath || '/',
});
}
if (!options.providedApis) {
// Deferred API roots are synchronized at finalization time, but bootstrap-
// materialized APIs stay frozen if they were already observed earlier.
syncFinalApiFactories({
deferredApiNodes: options.bootstrapClassification.deferredApiRoots,
appApiRegistry: options.appApiRegistry,
apiResolver: options.phase.apis,
collector: options.collector,
features: options.features,
bootstrapApiFactoryEntries: options.bootstrapApiFactoryEntries,
bootstrapMissingApiAccesses: options.bootstrapMissingApiAccesses,
predicateContext: sessionStateData.predicateContext,
});
}
prepareFinalizedTree({
tree: options.tree,
});
// Finalization re-instantiates the boundary subtree so predicate-gated app
// content can be re-evaluated without disturbing preserved bootstrap nodes.
clearFinalizationBoundaryInstances(options.tree);
instantiateAndInitializePhaseTree({
tree: options.tree,
apis: options.phase.apis,
collector: options.collector,
extensionFactoryMiddleware: options.extensionFactoryMiddleware,
routeResolutionApi: options.phase.routeResolutionApi,
appTreeApi: options.phase.appTreeApi,
routeRefsById: options.routeRefsById,
predicateContext: sessionStateData.predicateContext,
});
const element = options.tree.root.instance?.getData(
coreExtensionData.reactElement,
);
if (!element) {
throw new Error('Expected finalized app tree to expose a root element');
}
return {
element,
sessionState: options.finalizedSessionState,
tree: options.tree,
errors: options.collector.collectErrors(),
};
}
/**
* Builds a finalized app that rethrows a bootstrap-time failure through the
* normal app root boundary.
*
* This keeps the error handling path aligned with normal finalization while
* preserving any session state that was already resolved before the failure.
*/
function finalizeFromBootstrapError(options: {
finalized?: FinalizedSpecializedApp;
error: Error;
finalizedSessionState?: SpecializedAppSessionState;
tree: AppTree;
collector: ErrorCollector;
phase: ReturnType<typeof createPhaseApis>;
extensionFactoryMiddleware?: ExtensionFactoryMiddleware;
routeRefsById: ReturnType<typeof collectRouteIds>;
signInRuntime?: SignInRuntime;
providedSessionData?: InternalSpecializedAppSessionState;
}): FinalizedSpecializedApp {
if (options.finalized) {
return options.finalized;
}
// If finalization fails after session state was already prepared, keep using
// it so the error app reflects the same identity and API view.
const finalizedSessionState =
options.finalizedSessionState ??
OpaqueSpecializedAppSessionState.createInstance('v1', {
apis: options.phase.apis,
identityApi:
options.signInRuntime?.readyIdentityApi ??
options.providedSessionData?.identityApi,
predicateContext: EMPTY_PREDICATE_CONTEXT,
});
prepareFinalizedTree({
tree: options.tree,
});
clearFinalizationBoundaryInstances(options.tree);
// The final app reports bootstrap failures through app/root.children so the
// normal app root boundary renders the error state for us.
attachThrowingFinalizationChild(options.tree, options.error);
instantiateAndInitializePhaseTree({
tree: options.tree,
apis: options.phase.apis,
collector: options.collector,
extensionFactoryMiddleware: options.extensionFactoryMiddleware,
routeResolutionApi: options.phase.routeResolutionApi,
appTreeApi: options.phase.appTreeApi,
routeRefsById: options.routeRefsById,
});
const element = options.tree.root.instance?.getData(
coreExtensionData.reactElement,
);
if (!element) {
throw new Error('Expected finalized app tree to expose a root element');
}
return {
element,
sessionState: finalizedSessionState,
tree: options.tree,
};
}
/**
* Owns the callback-driven finalization lifecycle for a prepared app.
*
* The controller enforces the selected finalization mode, memoizes the shared
* async finalization promise for `onFinalized()` subscribers, and funnels both
* successful and failing async finalization through the same resolution path.
*/
function createFinalizationController(options: {
getFinalized(): FinalizedSpecializedApp | undefined;
setFinalized(finalizedApp: FinalizedSpecializedApp): void;
finalizeFromSessionState(
finalizedSessionState: SpecializedAppSessionState,
): FinalizedSpecializedApp;
finalizeFromBootstrapError(
error: Error,
finalizedSessionState?: SpecializedAppSessionState,
): FinalizedSpecializedApp;
}) {
let finalizationState: FinalizationState | undefined;
let finalizationMode: FinalizationMode | undefined;
function getState(): FinalizationState {
if (finalizationState) {
return finalizationState;
}
// onFinalized() subscribers all fan into the same promise so that the full
// finalization flow only ever runs once.
let resolve: ((app: FinalizedSpecializedApp) => void) | undefined;
let reject: ((error: unknown) => void) | undefined;
const promise = new Promise<FinalizedSpecializedApp>((res, rej) => {
resolve = res;
reject = rej;
});
if (!resolve || !reject) {
throw new Error('Failed to create finalization state');
}
finalizationState = {
started: false,
promise,
resolve,
reject,
};
return finalizationState;
}
return {
getMode() {
return finalizationMode;
},
getPromise() {
return getState().promise;
},
selectMode(mode: FinalizationMode) {
if (finalizationMode && finalizationMode !== mode) {
throw new Error(
`prepareSpecializedApp only supports using either onFinalized() or finalize(), not both`,
);
}
// A prepared app now has one owner: either the callback-driven path or
// the direct finalize() path, never both.
finalizationMode = mode;
},
start(loader: () => Promise<SpecializedAppSessionState>) {
const finalized = options.getFinalized();
if (finalized) {
return Promise.resolve(finalized);
}
const state = getState();
if (state.started) {
return state.promise;
}
state.started = true;
// If loading finishes but final tree materialization fails, we still
// want to preserve the resolved session state when building the error app.
let finalizedSessionState: SpecializedAppSessionState | undefined;
loader()
.then(sessionState => {
finalizedSessionState = sessionState;
const finalizedApp = options.finalizeFromSessionState(sessionState);
options.setFinalized(finalizedApp);
state.resolve(finalizedApp);
})
.catch(error => {
try {
const bootstrapFailure = isError(error)
? error
: new Error(String(error));
const finalizedApp = options.finalizeFromBootstrapError(
bootstrapFailure,
finalizedSessionState,
);
options.setFinalized(finalizedApp);
state.resolve(finalizedApp);
} catch (finalizationError) {
finalizationState = undefined;
state.reject(finalizationError);
}
});
return state.promise;
},
};
}
/**
* Combines one or more extension factory middlewares into a single middleware
* invocation chain that preserves Backstage's extension data container shape.
*/
function mergeExtensionFactoryMiddleware(
middlewares?: ExtensionFactoryMiddleware | ExtensionFactoryMiddleware[],
): ExtensionFactoryMiddleware | undefined {
if (!middlewares) {
return undefined;
}
if (!Array.isArray(middlewares)) {
return middlewares;
}
if (middlewares.length <= 1) {
return middlewares[0];
}
return middlewares.reduce((prev, next) => {
if (!prev || !next) {
return prev ?? next;
}
return (orig, ctx) => {
const internalExt = toInternalExtension(ctx.node.spec.extension);
if (internalExt.version !== 'v2') {
return orig();
}
return next(ctxOverrides => {
return createExtensionDataContainer(
prev(orig, {
node: ctx.node,
apis: ctx.apis,
config: ctxOverrides?.config ?? ctx.config,
}),
'extension factory middleware',
);
}, ctx);
};
});
}
@@ -0,0 +1,318 @@
/*
* Copyright 2023 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 {
ApiHolder,
AppNode,
AppNodeInstance,
AppTree,
coreExtensionData,
ExtensionDataRef,
ExtensionFactoryMiddleware,
} from '@backstage/frontend-plugin-api';
import { FilterPredicate } from '@backstage/filter-predicates';
import { collectRouteIds } from '../routing/collectRouteIds';
import { ErrorCollector } from './createErrorCollector';
import {
AppTreeApiProxy,
instantiateAndInitializePhaseTree,
RouteResolutionApiProxy,
} from './phaseApis';
export type BootstrapClassification = {
deferredApiRoots: Set<AppNode>;
deferredElementRoots: Set<AppNode>;
deferredRoots: Set<AppNode>;
};
/**
* Instantiates the bootstrap-visible portion of the app tree and returns the
* element that should be rendered while the prepared app is still incomplete.
*
* The bootstrap tree deliberately stops at the session boundary so sign-in and
* other deferred content can be handled separately during finalization.
*/
export function createBootstrapApp(options: {
tree: AppTree;
apis: ApiHolder;
collector: ErrorCollector;
routeRefsById: ReturnType<typeof collectRouteIds>;
routeResolutionApi: RouteResolutionApiProxy;
appTreeApi: AppTreeApiProxy;
extensionFactoryMiddleware?: ExtensionFactoryMiddleware;
disableSignIn?: boolean;
skipBootstrapChild?(ctx: {
node: AppNode;
input: string;
child: AppNode;
}): boolean;
onMissingApi?(ctx: { node: AppNode; apiRefId: string }): void;
hasSignInPage(node?: AppNode): boolean;
}): {
bootstrapApp: { element: JSX.Element; tree: AppTree };
requiresSignIn: boolean;
} {
const signInPageNode = getAppRootNode(options.tree)?.edges.attachments.get(
'signInPage',
)?.[0];
instantiateAndInitializePhaseTree({
tree: options.tree,
apis: options.apis,
collector: options.collector,
extensionFactoryMiddleware: options.extensionFactoryMiddleware,
routeResolutionApi: options.routeResolutionApi,
appTreeApi: options.appTreeApi,
routeRefsById: options.routeRefsById,
stopAtAttachment: ({ node, input }) =>
isSessionBoundaryAttachment(node, input),
skipChild: options.skipBootstrapChild,
onMissingApi: options.onMissingApi,
});
const element = options.tree.root.instance?.getData(
coreExtensionData.reactElement,
);
if (!element) {
throw new Error('Expected bootstrap tree to expose a root element');
}
return {
bootstrapApp: {
element,
tree: options.tree,
},
requiresSignIn:
!options.disableSignIn && options.hasSignInPage(signInPageNode),
};
}
/**
* Splits the app tree into bootstrap-visible and deferred regions.
*
* Predicate-gated roots are deferred to finalization, while any predicate that
* still leaks into the bootstrap-visible region is reported and ignored.
*/
export function classifyBootstrapTree(options: {
tree: AppTree;
collector: ErrorCollector;
}): BootstrapClassification {
const apiNodes = options.tree.root.edges.attachments.get('apis') ?? [];
const deferredApiRoots = new Set(
apiNodes.filter(apiNode => subtreeContainsPredicate(apiNode)),
);
const appRootElementNodes =
getAppRootNode(options.tree)?.edges.attachments.get('elements') ?? [];
const deferredElementRoots = new Set(
appRootElementNodes.filter(elementNode =>
subtreeContainsPredicate(elementNode),
),
);
const deferredRoots = new Set<AppNode>([
...deferredApiRoots,
...deferredElementRoots,
]);
const bootstrapNodes = collectBootstrapVisibleNodes(options.tree, {
deferredRoots,
});
for (const node of bootstrapNodes) {
if (node.spec.if === undefined) {
continue;
}
options.collector.report({
code: 'EXTENSION_BOOTSTRAP_PREDICATE_IGNORED',
message:
`Extension '${node.spec.id}' uses 'if' during bootstrap, so the predicate was ignored. ` +
"Move it behind 'app/root.children', onto a deferred 'app/root.elements' subtree, or into an API subtree.",
context: {
node,
},
});
(node.spec as typeof node.spec & { if?: FilterPredicate }).if = undefined;
}
return {
deferredApiRoots,
deferredElementRoots,
deferredRoots,
};
}
/**
* Prepares the app tree for finalization by removing the bootstrap-only
* sign-in attachment from the app root boundary.
*/
export function prepareFinalizedTree(options: { tree: AppTree }) {
for (const appRootNode of getFinalizationBoundaryNodes(options.tree)) {
const attachments = appRootNode.edges.attachments as Map<string, AppNode[]>;
attachments.delete('signInPage');
}
}
/**
* Clears instances inside the finalization boundary so those nodes can be
* re-instantiated with finalized predicate context and API availability.
*/
export function clearFinalizationBoundaryInstances(tree: AppTree) {
clearNodeInstance(tree.root);
const visited = new Set<AppNode>();
function visit(node: AppNode) {
if (visited.has(node)) {
return;
}
visited.add(node);
clearNodeInstance(node);
for (const [input, children] of node.edges.attachments) {
// app/root.elements is allowed to keep its bootstrap instances so we only
// re-run the parts of the boundary that actually change at finalization.
if (node.spec.id === 'app/root' && input === 'elements') {
continue;
}
for (const child of children) {
visit(child);
}
}
}
for (const appRootNode of getFinalizationBoundaryNodes(tree)) {
visit(appRootNode);
}
}
/**
* Identifies the attachment that separates bootstrap rendering from the
* children that are deferred until finalization.
*/
export function isSessionBoundaryAttachment(node: AppNode, input: string) {
return node.spec.id === 'app/root' && input === 'children';
}
/**
* Injects a synthetic finalization child that throws the captured bootstrap
* error when rendered.
*
* This lets the finalized tree reuse the normal app root error boundary rather
* than introducing a separate error rendering path.
*/
export function attachThrowingFinalizationChild(tree: AppTree, error: Error) {
const bootstrapChildNode =
getAppRootNode(tree)?.edges.attachments.get('children')?.[0];
if (!bootstrapChildNode) {
throw error;
}
function ThrowBootstrapError(): never {
throw error;
}
// This synthetic child gives the finalized tree a stable place to rethrow
// bootstrap failures through the normal extension boundary stack.
(bootstrapChildNode as AppNode & { instance?: AppNodeInstance }).instance = {
getDataRefs() {
return [coreExtensionData.reactElement];
},
getData<TValue>(dataRef: ExtensionDataRef<TValue>) {
if (dataRef.id === coreExtensionData.reactElement.id) {
return (<ThrowBootstrapError />) as TValue;
}
return undefined;
},
};
}
function getAppRootNode(tree: AppTree) {
return tree.nodes.get('app/root');
}
function getFinalizationBoundaryNodes(tree: AppTree): AppNode[] {
const nodes = new Set<AppNode>();
const appRootNode = getAppRootNode(tree);
if (appRootNode) {
nodes.add(appRootNode);
}
const attachedAppRootNode = tree.root.edges.attachments.get('app')?.[0];
if (attachedAppRootNode) {
nodes.add(attachedAppRootNode);
}
return Array.from(nodes);
}
function clearNodeInstance(node: AppNode) {
(node as AppNode & { instance?: AppNodeInstance }).instance = undefined;
}
function collectBootstrapVisibleNodes(
tree: AppTree,
options?: { deferredRoots?: Set<AppNode> },
) {
const visibleNodes = new Set<AppNode>();
function visit(node: AppNode) {
if (visibleNodes.has(node)) {
return;
}
visibleNodes.add(node);
for (const [input, children] of node.edges.attachments) {
if (isSessionBoundaryAttachment(node, input)) {
continue;
}
for (const child of children) {
if (options?.deferredRoots?.has(child)) {
continue;
}
visit(child);
}
}
}
visit(tree.root);
return visibleNodes;
}
function subtreeContainsPredicate(root: AppNode) {
const visited = new Set<AppNode>();
function visit(node: AppNode): boolean {
if (visited.has(node)) {
return false;
}
visited.add(node);
if (node.spec.if !== undefined) {
return true;
}
for (const children of node.edges.attachments.values()) {
for (const child of children) {
if (visit(child)) {
return true;
}
}
}
return false;
}
return visit(root);
}
+2
View File
@@ -43,6 +43,8 @@
"@backstage/cli": "workspace:^",
"@backstage/core-plugin-api": "workspace:^",
"@backstage/plugin-app-react": "workspace:^",
"@backstage/plugin-permission-common": "workspace:^",
"@backstage/plugin-permission-react": "workspace:^",
"@backstage/test-utils": "workspace:^",
"@testing-library/jest-dom": "^6.0.0",
"@testing-library/react": "^16.0.0",
+1 -1
View File
@@ -8,7 +8,7 @@ import { AppErrorTypes } from '@backstage/frontend-app-api';
import { Config } from '@backstage/config';
import { ConfigApi } from '@backstage/frontend-plugin-api';
import { CreateAppRouteBinder } from '@backstage/frontend-app-api';
import { ExtensionFactoryMiddleware } from '@backstage/frontend-app-api';
import { ExtensionFactoryMiddleware } from '@backstage/frontend-plugin-api';
import { FrontendFeature } from '@backstage/frontend-plugin-api';
import { FrontendFeatureLoader } from '@backstage/frontend-plugin-api';
import { FrontendPluginInfoResolver } from '@backstage/frontend-app-api';
+687 -28
View File
@@ -16,9 +16,14 @@
import {
AppTreeApi,
ApiBlueprint,
appTreeApiRef,
coreExtensionData,
createApiRef,
createExtensionDataRef,
createExtension,
createExtensionBlueprint,
createExtensionInput,
PageBlueprint,
createFrontendPlugin,
createFrontendFeatureLoader,
@@ -27,12 +32,22 @@ import {
FrontendPluginInfo,
} from '@backstage/frontend-plugin-api';
import { ThemeBlueprint } from '@backstage/plugin-app-react';
import { screen, waitFor } from '@testing-library/react';
import { act, screen, waitFor } from '@testing-library/react';
import { createApp } from './createApp';
import { mockApis, renderWithEffects } from '@backstage/test-utils';
import { featureFlagsApiRef, useApi } from '@backstage/core-plugin-api';
import {
featureFlagsApiRef,
IdentityApi,
useApi,
} from '@backstage/core-plugin-api';
import { default as appPluginOriginal } from '@backstage/plugin-app';
import { useState, useEffect } from 'react';
import { ComponentType, useState, useEffect } from 'react';
import { permissionApiRef } from '@backstage/plugin-permission-react';
import { AuthorizeResult } from '@backstage/plugin-permission-common';
const signInPageComponentDataRef = createExtensionDataRef<
ComponentType<{ onSignInSuccess(identity: IdentityApi): void }>
>().with({ id: 'core.sign-in-page.component' });
describe('createApp', () => {
const appPlugin = appPluginOriginal.withOverrides({
@@ -43,6 +58,25 @@ describe('createApp', () => {
],
});
function createFeatureFlagsApi(activeFlags: string[]) {
return {
isActive: jest.fn((name: string) => activeFlags.includes(name)),
registerFlag: jest.fn(),
getRegisteredFlags: () => [],
save: jest.fn(),
} as unknown as typeof featureFlagsApiRef.T;
}
function createPermissionApi(allowedPermissions: string[]) {
return {
authorize: jest.fn(async request => ({
result: allowedPermissions.includes(request.permission.name)
? AuthorizeResult.ALLOW
: AuthorizeResult.DENY,
})),
} as typeof permissionApiRef.T;
}
it('should allow themes to be installed', async () => {
const app = createApp({
advanced: {
@@ -84,6 +118,184 @@ describe('createApp', () => {
await expect(screen.findByText('Derp')).resolves.toBeInTheDocument();
});
it('should provide app APIs to sign-in pages before finalization', async () => {
const signInApiRef = createApiRef<{ value: string }>({
id: 'test.sign-in-api',
});
const app = createApp({
advanced: {
configLoader: async () => ({ config: mockApis.config() }),
},
features: [
appPluginOriginal,
createFrontendPlugin({
pluginId: 'test',
extensions: [
ApiBlueprint.make({
params: defineParams =>
defineParams({
api: signInApiRef,
deps: {},
factory: () => ({ value: 'ok' }),
}),
}),
],
}),
createFrontendModule({
pluginId: 'app',
extensions: [
appPluginOriginal.getExtension('sign-in-page:app').override({
factory: () => {
const SignInPage = () => {
const api = useApi(signInApiRef);
return <div>Sign In API: {api.value}</div>;
};
return [signInPageComponentDataRef(SignInPage)];
},
}),
],
}),
],
});
await renderWithEffects(app.createRoot());
await expect(
screen.findByText('Sign In API: ok'),
).resolves.toBeInTheDocument();
});
it('should provide feature flags to sign-in pages before finalization', async () => {
const app = createApp({
advanced: {
configLoader: async () => ({ config: mockApis.config() }),
},
features: [
appPluginOriginal,
createFrontendPlugin({
pluginId: 'test',
featureFlags: [{ name: 'test-flag' }],
extensions: [],
}),
createFrontendModule({
pluginId: 'app',
extensions: [
appPluginOriginal.getExtension('sign-in-page:app').override({
factory: () => {
const SignInPage = () => {
const flagsApi = useApi(featureFlagsApiRef);
return (
<div>
Flags:{' '}
{flagsApi
.getRegisteredFlags()
.map(flag => flag.name)
.join(', ')}
</div>
);
};
return [signInPageComponentDataRef(SignInPage)];
},
}),
],
}),
],
});
await renderWithEffects(app.createRoot());
await expect(
screen.findByText('Flags: test-flag'),
).resolves.toBeInTheDocument();
});
it('should surface sign-in bootstrap errors through the app root boundary', async () => {
const identityApi = {
getProfileInfo: async () => ({ displayName: 'Test User' }),
getBackstageIdentity: async () => ({
type: 'user' as const,
userEntityRef: 'user:default/test-user',
ownershipEntityRefs: ['user:default/test-user'],
}),
getCredentials: async () => ({ token: 'token' }),
signOut: async () => {},
};
const featureFlagsApi = {
isActive: jest.fn(() => {
throw new Error('sign-in bootstrap failed');
}),
registerFlag: jest.fn(),
getRegisteredFlags: () => [],
save: jest.fn(),
} as unknown as typeof featureFlagsApiRef.T;
let onSignInSuccess: ((identity: IdentityApi) => void) | undefined;
const app = createApp({
advanced: {
configLoader: async () => ({ config: mockApis.config() }),
},
features: [
appPluginOriginal,
createFrontendModule({
pluginId: 'app',
extensions: [
ApiBlueprint.make({
params: defineParams =>
defineParams({
api: featureFlagsApiRef,
deps: {},
factory: () => featureFlagsApi,
}),
}),
appPluginOriginal.getExtension('sign-in-page:app').override({
factory: () => {
function SignInPage(props: {
onSignInSuccess(identity: IdentityApi): void;
}) {
onSignInSuccess = props.onSignInSuccess;
return <div>Custom Sign In</div>;
}
return [signInPageComponentDataRef(SignInPage)];
},
}),
],
}),
createFrontendPlugin({
pluginId: 'test',
featureFlags: [{ name: 'test-flag' }],
extensions: [
PageBlueprint.make({
if: { featureFlags: { $contains: 'test-flag' } },
params: {
path: '/',
loader: async () => <div>Flagged Page</div>,
},
}),
],
}),
],
});
await renderWithEffects(app.createRoot());
await expect(
screen.findByText('Custom Sign In'),
).resolves.toBeInTheDocument();
if (!onSignInSuccess) {
throw new Error('Expected sign-in success callback to be captured');
}
const triggerSignInSuccess = onSignInSuccess;
act(() => {
triggerSignInSuccess(identityApi);
});
await expect(
screen.findByText('sign-in bootstrap failed'),
).resolves.toBeInTheDocument();
});
it('should deduplicate features keeping the last received one', async () => {
const duplicatedFeatureId = 'test';
const app = createApp({
@@ -283,47 +495,497 @@ describe('createApp', () => {
).resolves.toBeInTheDocument();
});
it('should warn about unknown extension config', async () => {
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
it('should evaluate extension if predicates before rendering apps without sign-in', async () => {
const featureFlagsApi = {
isActive: jest.fn((name: string) => name === 'test-flag'),
registerFlag: jest.fn(),
getRegisteredFlags: () => [],
save: jest.fn(),
} as unknown as typeof featureFlagsApiRef.T;
const app = createApp({
advanced: {
configLoader: async () => ({ config: mockApis.config() }),
},
features: [
appPlugin,
createFrontendModule({
pluginId: 'app',
extensions: [
ApiBlueprint.make({
params: defineParams =>
defineParams({
api: featureFlagsApiRef,
deps: {},
factory: () => featureFlagsApi,
}),
}),
],
}),
createFrontendPlugin({
pluginId: 'test',
featureFlags: [{ name: 'test-flag' }],
extensions: [
PageBlueprint.make({
if: { featureFlags: { $contains: 'test-flag' } },
params: {
path: '/',
loader: async () => <div>Derp</div>,
loader: async () => <div>Flagged Page</div>,
},
}),
],
}),
],
advanced: {
configLoader: async () => ({
config: mockApis.config({
data: {
app: {
extensions: [{ 'unknown:lols/wut': false }],
},
},
}),
}),
},
});
await renderWithEffects(app.createRoot());
await expect(screen.findByText('Derp')).resolves.toBeInTheDocument();
expect(warnSpy).toHaveBeenCalledWith('App startup encountered warnings:');
expect(warnSpy).toHaveBeenCalledWith(
'INVALID_EXTENSION_CONFIG_KEY: Extension unknown:lols/wut does not exist',
);
warnSpy.mockRestore();
await expect(
screen.findByText('Flagged Page'),
).resolves.toBeInTheDocument();
expect(featureFlagsApi.isActive).toHaveBeenCalledWith('test-flag');
});
it('should support $all feature flag predicates on pages', async () => {
const partialFlagsApi = createFeatureFlagsApi(['experimental-features']);
const partialFlagsApp = createApp({
advanced: {
configLoader: async () => ({ config: mockApis.config() }),
},
features: [
appPlugin,
createFrontendModule({
pluginId: 'app',
extensions: [
ApiBlueprint.make({
params: defineParams =>
defineParams({
api: featureFlagsApiRef,
deps: {},
factory: () => partialFlagsApi,
}),
}),
],
}),
createFrontendPlugin({
pluginId: 'test',
featureFlags: [
{ name: 'experimental-features' },
{ name: 'advanced-features' },
],
extensions: [
PageBlueprint.make({
if: {
$all: [
{ featureFlags: { $contains: 'experimental-features' } },
{ featureFlags: { $contains: 'advanced-features' } },
],
},
params: {
path: '/',
loader: async () => <div>All Flags Page</div>,
},
}),
],
}),
],
});
const partialRender = await renderWithEffects(partialFlagsApp.createRoot());
await waitFor(() =>
expect(screen.queryByText('All Flags Page')).not.toBeInTheDocument(),
);
partialRender.unmount();
const allFlagsApi = createFeatureFlagsApi([
'experimental-features',
'advanced-features',
]);
const allFlagsApp = createApp({
advanced: {
configLoader: async () => ({ config: mockApis.config() }),
},
features: [
appPlugin,
createFrontendModule({
pluginId: 'app',
extensions: [
ApiBlueprint.make({
params: defineParams =>
defineParams({
api: featureFlagsApiRef,
deps: {},
factory: () => allFlagsApi,
}),
}),
],
}),
createFrontendPlugin({
pluginId: 'test',
featureFlags: [
{ name: 'experimental-features' },
{ name: 'advanced-features' },
],
extensions: [
PageBlueprint.make({
if: {
$all: [
{ featureFlags: { $contains: 'experimental-features' } },
{ featureFlags: { $contains: 'advanced-features' } },
],
},
params: {
path: '/',
loader: async () => <div>All Flags Page</div>,
},
}),
],
}),
],
});
await renderWithEffects(allFlagsApp.createRoot());
await expect(
screen.findByText('All Flags Page'),
).resolves.toBeInTheDocument();
expect(allFlagsApi.isActive).toHaveBeenCalledWith('experimental-features');
expect(allFlagsApi.isActive).toHaveBeenCalledWith('advanced-features');
});
it('should support $any feature flag predicates on pages', async () => {
const noFlagsApi = createFeatureFlagsApi([]);
const noFlagsApp = createApp({
advanced: {
configLoader: async () => ({ config: mockApis.config() }),
},
features: [
appPlugin,
createFrontendModule({
pluginId: 'app',
extensions: [
ApiBlueprint.make({
params: defineParams =>
defineParams({
api: featureFlagsApiRef,
deps: {},
factory: () => noFlagsApi,
}),
}),
],
}),
createFrontendPlugin({
pluginId: 'test',
featureFlags: [
{ name: 'experimental-features' },
{ name: 'beta-access' },
],
extensions: [
PageBlueprint.make({
if: {
$any: [
{ featureFlags: { $contains: 'experimental-features' } },
{ featureFlags: { $contains: 'beta-access' } },
],
},
params: {
path: '/',
loader: async () => <div>Any Flag Page</div>,
},
}),
],
}),
],
});
const noFlagsRender = await renderWithEffects(noFlagsApp.createRoot());
await waitFor(() =>
expect(screen.queryByText('Any Flag Page')).not.toBeInTheDocument(),
);
noFlagsRender.unmount();
const oneFlagApi = createFeatureFlagsApi(['beta-access']);
const oneFlagApp = createApp({
advanced: {
configLoader: async () => ({ config: mockApis.config() }),
},
features: [
appPlugin,
createFrontendModule({
pluginId: 'app',
extensions: [
ApiBlueprint.make({
params: defineParams =>
defineParams({
api: featureFlagsApiRef,
deps: {},
factory: () => oneFlagApi,
}),
}),
],
}),
createFrontendPlugin({
pluginId: 'test',
featureFlags: [
{ name: 'experimental-features' },
{ name: 'beta-access' },
],
extensions: [
PageBlueprint.make({
if: {
$any: [
{ featureFlags: { $contains: 'experimental-features' } },
{ featureFlags: { $contains: 'beta-access' } },
],
},
params: {
path: '/',
loader: async () => <div>Any Flag Page</div>,
},
}),
],
}),
],
});
await renderWithEffects(oneFlagApp.createRoot());
await expect(
screen.findByText('Any Flag Page'),
).resolves.toBeInTheDocument();
expect(oneFlagApi.isActive).toHaveBeenCalledWith('experimental-features');
expect(oneFlagApi.isActive).toHaveBeenCalledWith('beta-access');
});
it('should support permission predicates on pages', async () => {
const deniedPermissionApi = createPermissionApi([]);
const deniedApp = createApp({
advanced: {
configLoader: async () => ({ config: mockApis.config() }),
},
features: [
appPlugin,
createFrontendModule({
pluginId: 'app',
extensions: [
ApiBlueprint.make({
params: defineParams =>
defineParams({
api: permissionApiRef,
deps: {},
factory: () => deniedPermissionApi,
}),
}),
],
}),
createFrontendPlugin({
pluginId: 'test',
extensions: [
PageBlueprint.make({
if: { permissions: { $contains: 'catalog.entity.create' } },
params: {
path: '/',
loader: async () => <div>Permission Page</div>,
},
}),
],
}),
],
});
const deniedRender = await renderWithEffects(deniedApp.createRoot());
await waitFor(() =>
expect(screen.queryByText('Permission Page')).not.toBeInTheDocument(),
);
deniedRender.unmount();
const allowedPermissionApi = createPermissionApi(['catalog.entity.create']);
const allowedApp = createApp({
advanced: {
configLoader: async () => ({ config: mockApis.config() }),
},
features: [
appPlugin,
createFrontendModule({
pluginId: 'app',
extensions: [
ApiBlueprint.make({
params: defineParams =>
defineParams({
api: permissionApiRef,
deps: {},
factory: () => allowedPermissionApi,
}),
}),
],
}),
createFrontendPlugin({
pluginId: 'test',
extensions: [
PageBlueprint.make({
if: { permissions: { $contains: 'catalog.entity.create' } },
params: {
path: '/',
loader: async () => <div>Permission Page</div>,
},
}),
],
}),
],
});
await renderWithEffects(allowedApp.createRoot());
await expect(
screen.findByText('Permission Page'),
).resolves.toBeInTheDocument();
expect(allowedPermissionApi.authorize).toHaveBeenCalledWith({
permission: {
name: 'catalog.entity.create',
type: 'basic',
attributes: {},
},
});
});
it('should support conditional child extensions attached to pages', async () => {
const CardBlueprint = createExtensionBlueprint({
kind: 'card',
attachTo: { id: 'page:test/card-page', input: 'cards' },
output: [coreExtensionData.reactElement],
*factory(params: { title: string }) {
yield coreExtensionData.reactElement(<div>{params.title}</div>);
},
});
const page = PageBlueprint.makeWithOverrides({
name: 'card-page',
inputs: {
cards: createExtensionInput([coreExtensionData.reactElement], {
optional: false,
singleton: false,
}),
},
factory(originalFactory, { inputs }) {
return originalFactory({
path: '/',
loader: async () => (
<div>
{inputs.cards.map(card =>
card.get(coreExtensionData.reactElement),
)}
</div>
),
});
},
});
const publicCard = CardBlueprint.make({
name: 'public',
params: { title: 'Public Card' },
});
const permissionCard = CardBlueprint.make({
name: 'permission',
params: { title: 'Permission Card' },
if: { permissions: { $contains: 'catalog.entity.create' } },
});
const featureFlagCard = CardBlueprint.make({
name: 'feature-flag',
params: { title: 'Feature Flag Card' },
if: { featureFlags: { $contains: 'experimental-card' } },
});
const hiddenCardsApp = createApp({
advanced: {
configLoader: async () => ({ config: mockApis.config() }),
},
features: [
appPlugin,
createFrontendModule({
pluginId: 'app',
extensions: [
ApiBlueprint.make({
name: 'permission-api',
params: defineParams =>
defineParams({
api: permissionApiRef,
deps: {},
factory: () => createPermissionApi([]),
}),
}),
ApiBlueprint.make({
name: 'feature-flags-api',
params: defineParams =>
defineParams({
api: featureFlagsApiRef,
deps: {},
factory: () => createFeatureFlagsApi([]),
}),
}),
],
}),
createFrontendPlugin({
pluginId: 'test',
featureFlags: [{ name: 'experimental-card' }],
extensions: [page, publicCard, permissionCard, featureFlagCard],
}),
],
});
const hiddenCardsRender = await renderWithEffects(
hiddenCardsApp.createRoot(),
);
await expect(screen.findByText('Public Card')).resolves.toBeInTheDocument();
await waitFor(() =>
expect(screen.queryByText('Permission Card')).not.toBeInTheDocument(),
);
await waitFor(() =>
expect(screen.queryByText('Feature Flag Card')).not.toBeInTheDocument(),
);
hiddenCardsRender.unmount();
const visibleCardsApp = createApp({
advanced: {
configLoader: async () => ({ config: mockApis.config() }),
},
features: [
appPlugin,
createFrontendModule({
pluginId: 'app',
extensions: [
ApiBlueprint.make({
name: 'permission-api',
params: defineParams =>
defineParams({
api: permissionApiRef,
deps: {},
factory: () => createPermissionApi(['catalog.entity.create']),
}),
}),
ApiBlueprint.make({
name: 'feature-flags-api',
params: defineParams =>
defineParams({
api: featureFlagsApiRef,
deps: {},
factory: () => createFeatureFlagsApi(['experimental-card']),
}),
}),
],
}),
createFrontendPlugin({
pluginId: 'test',
featureFlags: [{ name: 'experimental-card' }],
extensions: [page, publicCard, permissionCard, featureFlagCard],
}),
],
});
await renderWithEffects(visibleCardsApp.createRoot());
await expect(
screen.findByText('Permission Card'),
).resolves.toBeInTheDocument();
await expect(
screen.findByText('Feature Flag Card'),
).resolves.toBeInTheDocument();
});
it('should make the app structure available through the AppTreeApi', async () => {
let appTreeApi: AppTreeApi | undefined = undefined;
@@ -428,9 +1090,6 @@ describe('createApp', () => {
<app-root-element:app/alert-display out=[core.reactElement] />
<app-root-element:app/dialog-display out=[core.reactElement] />
]
signInPage [
<sign-in-page:app />
]
</app/root>
]
</app>
+34 -15
View File
@@ -14,10 +14,10 @@
* limitations under the License.
*/
import { JSX, lazy, ReactNode, Suspense } from 'react';
import { JSX, lazy, ReactNode, Suspense, useEffect, useState } from 'react';
import {
ConfigApi,
coreExtensionData,
ExtensionFactoryMiddleware,
FrontendFeature,
FrontendFeatureLoader,
} from '@backstage/frontend-plugin-api';
@@ -29,8 +29,9 @@ import { overrideBaseUrlConfigs } from '../../core-app-api/src/app/overrideBaseU
import { ConfigReader } from '@backstage/config';
import {
CreateAppRouteBinder,
createSpecializedApp,
ExtensionFactoryMiddleware,
FinalizedSpecializedApp,
prepareSpecializedApp,
PreparedSpecializedApp,
FrontendPluginInfoResolver,
} from '@backstage/frontend-app-api';
import appPlugin from '@backstage/plugin-app';
@@ -119,23 +120,16 @@ export function createApp(options?: CreateAppOptions): {
features: [...discoveredFeaturesAndLoaders, ...(options?.features ?? [])],
});
const app = createSpecializedApp({
const preparedApp = prepareSpecializedApp({
features: [appPlugin, ...loadedFeatures],
config,
bindRoutes: options?.bindRoutes,
advanced: options?.advanced,
});
const errorPage = maybeCreateErrorPage(app);
if (errorPage) {
return { default: () => errorPage };
}
const rootEl = app.tree.root.instance!.getData(
coreExtensionData.reactElement,
);
return { default: () => rootEl };
return {
default: () => <PreparedAppRoot preparedApp={preparedApp} />,
};
}
const LazyApp = lazy(appLoader);
@@ -150,3 +144,28 @@ export function createApp(options?: CreateAppOptions): {
},
};
}
function PreparedAppRoot(props: {
preparedApp: PreparedSpecializedApp;
}): JSX.Element {
const bootstrapApp = props.preparedApp.getBootstrapApp();
const [finalizedApp, setFinalizedApp] = useState<
FinalizedSpecializedApp | undefined
>();
useEffect(
() => props.preparedApp.onFinalized(setFinalizedApp),
[props.preparedApp],
);
if (!finalizedApp) {
return bootstrapApp.element;
}
const errorPage = maybeCreateErrorPage(finalizedApp);
if (errorPage) {
return errorPage;
}
return finalizedApp.element;
}
@@ -24,6 +24,8 @@ const DEFAULT_WARNING_CODES: Array<keyof AppErrorTypes> = [
'EXTENSION_INPUT_DATA_IGNORED',
'EXTENSION_INPUT_INTERNAL_IGNORED',
'EXTENSION_OUTPUT_IGNORED',
'EXTENSION_BOOTSTRAP_PREDICATE_IGNORED',
'EXTENSION_BOOTSTRAP_API_UNAVAILABLE',
];
function AppErrorItem(props: { error: AppError }): JSX.Element {
+1
View File
@@ -23,6 +23,7 @@
"test": "backstage-cli package test"
},
"dependencies": {
"@backstage/filter-predicates": "workspace:^",
"@backstage/frontend-plugin-api": "workspace:^",
"@backstage/types": "workspace:^",
"@backstage/version-bridge": "workspace:^"
@@ -28,6 +28,7 @@ import {
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { ResolvedExtensionInputs } from '../../../frontend-plugin-api/src/wiring/createExtension';
import { OpaqueType } from '@internal/opaque';
import { FilterPredicate } from '@backstage/filter-predicates';
export const OpaqueExtensionDefinition = OpaqueType.create<{
public: OverridableExtensionDefinition<ExtensionDefinitionParameters>;
@@ -70,6 +71,7 @@ export const OpaqueExtensionDefinition = OpaqueType.create<{
readonly name?: string;
readonly attachTo: ExtensionDefinitionAttachTo;
readonly disabled: boolean;
readonly if?: FilterPredicate;
readonly configSchema?: PortableSchema<any, any>;
readonly inputs: { [inputName in string]: ExtensionInput };
readonly output: Array<ExtensionDataRef>;
@@ -20,6 +20,7 @@ import {
IconElement,
OverridableFrontendPlugin,
} from '@backstage/frontend-plugin-api';
import { FilterPredicate } from '@backstage/filter-predicates';
import { JsonObject } from '@backstage/types';
import { OpaqueType } from '@internal/opaque';
@@ -31,6 +32,7 @@ export const OpaqueFrontendPlugin = OpaqueType.create<{
readonly icon?: IconElement;
readonly extensions: Extension<unknown>[];
readonly featureFlags: FeatureFlagConfig[];
readonly if?: FilterPredicate;
readonly infoOptions?: {
packageJson?: () => Promise<JsonObject>;
manifest?: () => Promise<JsonObject>;
@@ -45,6 +45,7 @@
},
"dependencies": {
"@backstage/errors": "workspace:^",
"@backstage/filter-predicates": "workspace:^",
"@backstage/types": "workspace:^",
"@backstage/version-bridge": "workspace:^",
"zod": "^3.25.76",
+601 -10
View File
@@ -3,13 +3,576 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { ApiRef } from '@backstage/frontend-plugin-api';
import { ApiRef as ApiRef_2 } from '@backstage/frontend-plugin-api';
import { ComponentType } from 'react';
import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api';
import { ExtensionBlueprint } from '@backstage/frontend-plugin-api';
import { ExtensionBlueprintParams } from '@backstage/frontend-plugin-api';
import { ExtensionDataRef } from '@backstage/frontend-plugin-api';
import { ConfigurableExtensionDataRef as ConfigurableExtensionDataRef_2 } from '@backstage/frontend-plugin-api';
import { Expand } from '@backstage/types';
import { ExtensionBlueprint as ExtensionBlueprint_2 } from '@backstage/frontend-plugin-api';
import { ExtensionBlueprintParams as ExtensionBlueprintParams_2 } from '@backstage/frontend-plugin-api';
import { ExtensionDataRef as ExtensionDataRef_2 } from '@backstage/frontend-plugin-api';
import { FilterPredicate } from '@backstage/filter-predicates';
import { JsonObject } from '@backstage/types';
import { JSX as JSX_2 } from 'react';
import { ReactNode } from 'react';
import type { z } from 'zod';
// @public
export type AnyRouteRefParams =
| {
[param in string]: string;
}
| undefined;
// @public
export type ApiHolder = {
get<T>(api: ApiRef<T>): T | undefined;
};
// @public
export type ApiRef<T, TId extends string = string> = {
readonly $$type?: '@backstage/ApiRef';
readonly id: TId;
readonly T: T;
};
// @public
export interface AppNode {
readonly edges: AppNodeEdges;
readonly instance?: AppNodeInstance;
readonly spec: AppNodeSpec;
}
// @public
export interface AppNodeEdges {
// (undocumented)
readonly attachedTo?: {
node: AppNode;
input: string;
};
// (undocumented)
readonly attachments: ReadonlyMap<string, AppNode[]>;
}
// @public
export interface AppNodeInstance {
getData<T>(ref: ExtensionDataRef<T>): T | undefined;
getDataRefs(): Iterable<ExtensionDataRef<unknown>>;
}
// @public
export interface AppNodeSpec {
// (undocumented)
readonly attachTo: ExtensionAttachTo;
// (undocumented)
readonly config?: unknown;
// (undocumented)
readonly disabled: boolean;
// (undocumented)
readonly extension: Extension<unknown, unknown>;
// (undocumented)
readonly id: string;
// (undocumented)
readonly if?: FilterPredicate;
// (undocumented)
readonly plugin: FrontendPlugin;
}
// @public
export interface AppTree {
readonly nodes: ReadonlyMap<string, AppNode>;
readonly orphans: Iterable<AppNode>;
readonly root: AppNode;
}
// @public (undocumented)
export interface ConfigurableExtensionDataRef<
TData,
TId extends string,
TConfig extends {
optional?: true;
} = {},
> extends ExtensionDataRef<TData, TId, TConfig> {
// (undocumented)
(t: TData): ExtensionDataValue<TData, TId>;
// (undocumented)
optional(): ConfigurableExtensionDataRef<
TData,
TId,
TConfig & {
optional: true;
}
>;
}
// @public
export function createExtensionBlueprintParams<T extends object = object>(
params: T,
): ExtensionBlueprintParams<T>;
// @public (undocumented)
export interface Extension<TConfig, TConfigInput = TConfig> {
// (undocumented)
$$type: '@backstage/Extension';
// (undocumented)
readonly attachTo: ExtensionAttachTo;
// (undocumented)
readonly configSchema?: PortableSchema<TConfig, TConfigInput>;
// (undocumented)
readonly disabled: boolean;
// (undocumented)
readonly id: string;
}
// @public (undocumented)
export type ExtensionAttachTo = {
id: string;
input: string;
};
// @public (undocumented)
export interface ExtensionBlueprint<
T extends ExtensionBlueprintParameters = ExtensionBlueprintParameters,
> {
// (undocumented)
dataRefs: T['dataRefs'];
// (undocumented)
make<
TName extends string | undefined,
TParamsInput extends AnyParamsInput<NonNullable<T['params']>>,
UParentInputs extends ExtensionDataRef,
>(args: {
name?: TName;
attachTo?: ExtensionDefinitionAttachTo<UParentInputs> &
VerifyExtensionAttachTo<NonNullable<T['output']>, UParentInputs>;
disabled?: boolean;
if?: FilterPredicate;
params: TParamsInput extends ExtensionBlueprintDefineParams
? TParamsInput
: T['params'] extends ExtensionBlueprintDefineParams
? 'Error: This blueprint uses advanced parameter types and requires you to pass parameters as using the following callback syntax: `<blueprint>.make({ params: defineParams => defineParams(<params>) })`'
: T['params'];
}): OverridableExtensionDefinition<{
kind: T['kind'];
name: string | undefined extends TName ? undefined : TName;
config: T['config'];
configInput: T['configInput'];
output: T['output'];
inputs: T['inputs'];
params: T['params'];
}>;
makeWithOverrides<
TName extends string | undefined,
TExtensionConfigSchema extends {
[key in string]: (zImpl: typeof z) => z.ZodType;
},
UFactoryOutput extends ExtensionDataValue<any, any>,
UNewOutput extends ExtensionDataRef,
UParentInputs extends ExtensionDataRef,
TExtraInputs extends {
[inputName in string]: ExtensionInput;
} = {},
>(args: {
name?: TName;
attachTo?: ExtensionDefinitionAttachTo<UParentInputs> &
VerifyExtensionAttachTo<
ExtensionDataRef extends UNewOutput
? NonNullable<T['output']>
: UNewOutput,
UParentInputs
>;
disabled?: boolean;
if?: FilterPredicate;
inputs?: TExtraInputs & {
[KName in keyof T['inputs']]?: `Error: Input '${KName &
string}' is already defined in parent definition`;
};
output?: Array<UNewOutput>;
config?: {
schema: TExtensionConfigSchema & {
[KName in keyof T['config']]?: `Error: Config key '${KName &
string}' is already defined in parent schema`;
};
};
factory(
originalFactory: <
TParamsInput extends AnyParamsInput<NonNullable<T['params']>>,
>(
params: TParamsInput extends ExtensionBlueprintDefineParams
? TParamsInput
: T['params'] extends ExtensionBlueprintDefineParams
? 'Error: This blueprint uses advanced parameter types and requires you to pass parameters as using the following callback syntax: `originalFactory(defineParams => defineParams(<params>))`'
: T['params'],
context?: {
config?: T['config'];
inputs?: ResolvedInputValueOverrides<NonNullable<T['inputs']>>;
},
) => ExtensionDataContainer<NonNullable<T['output']>>,
context: {
node: AppNode;
apis: ApiHolder;
config: T['config'] & {
[key in keyof TExtensionConfigSchema]: z.infer<
ReturnType<TExtensionConfigSchema[key]>
>;
};
inputs: Expand<ResolvedExtensionInputs<T['inputs'] & TExtraInputs>>;
},
): Iterable<UFactoryOutput> &
VerifyExtensionFactoryOutput<
ExtensionDataRef extends UNewOutput
? NonNullable<T['output']>
: UNewOutput,
UFactoryOutput
>;
}): OverridableExtensionDefinition<{
config: Expand<
(string extends keyof TExtensionConfigSchema
? {}
: {
[key in keyof TExtensionConfigSchema]: z.infer<
ReturnType<TExtensionConfigSchema[key]>
>;
}) &
T['config']
>;
configInput: Expand<
(string extends keyof TExtensionConfigSchema
? {}
: z.input<
z.ZodObject<{
[key in keyof TExtensionConfigSchema]: ReturnType<
TExtensionConfigSchema[key]
>;
}>
>) &
T['configInput']
>;
output: ExtensionDataRef extends UNewOutput ? T['output'] : UNewOutput;
inputs: Expand<T['inputs'] & TExtraInputs>;
kind: T['kind'];
name: string | undefined extends TName ? undefined : TName;
params: T['params'];
}>;
}
// @public
export type ExtensionBlueprintDefineParams<
TParams extends object = object,
TInput = any,
> = (params: TInput) => ExtensionBlueprintParams<TParams>;
// @public (undocumented)
export type ExtensionBlueprintParameters = {
kind: string;
params?: object | ExtensionBlueprintDefineParams;
configInput?: {
[K in string]: any;
};
config?: {
[K in string]: any;
};
output?: ExtensionDataRef;
inputs?: {
[KName in string]: ExtensionInput;
};
dataRefs?: {
[name in string]: ExtensionDataRef;
};
};
// @public
export type ExtensionBlueprintParams<T extends object = object> = {
$$type: '@backstage/BlueprintParams';
T: T;
};
// @public (undocumented)
export type ExtensionDataContainer<UExtensionData extends ExtensionDataRef> =
Iterable<
UExtensionData extends ExtensionDataRef<
infer IData,
infer IId,
infer IConfig
>
? IConfig['optional'] extends true
? never
: ExtensionDataValue<IData, IId>
: never
> & {
get<TId extends UExtensionData['id']>(
ref: ExtensionDataRef<any, TId, any>,
): UExtensionData extends ExtensionDataRef<infer IData, TId, infer IConfig>
? IConfig['optional'] extends true
? IData | undefined
: IData
: never;
};
// @public (undocumented)
export type ExtensionDataRef<
TData = unknown,
TId extends string = string,
TConfig extends {
optional?: true;
} = {
optional?: true;
},
> = {
readonly $$type: '@backstage/ExtensionDataRef';
readonly id: TId;
readonly T: TData;
readonly config: TConfig;
};
// @public (undocumented)
export type ExtensionDataValue<TData, TId extends string> = {
readonly $$type: '@backstage/ExtensionDataValue';
readonly id: TId;
readonly value: TData;
};
// @public (undocumented)
export interface ExtensionDefinition<
TParams extends ExtensionDefinitionParameters = ExtensionDefinitionParameters,
> {
// (undocumented)
$$type: '@backstage/ExtensionDefinition';
// (undocumented)
readonly T: TParams;
}
// @public
export type ExtensionDefinitionAttachTo<
UParentInputs extends ExtensionDataRef = ExtensionDataRef,
> =
| {
id: string;
input: string;
relative?: never;
}
| {
relative: {
kind?: string;
name?: string;
};
input: string;
id?: never;
}
| ExtensionInput<UParentInputs>;
// @public (undocumented)
export type ExtensionDefinitionParameters = {
kind?: string;
name?: string;
configInput?: {
[K in string]: any;
};
config?: {
[K in string]: any;
};
output?: ExtensionDataRef;
inputs?: {
[KName in string]: ExtensionInput;
};
params?: object | ExtensionBlueprintDefineParams;
};
// @public (undocumented)
export interface ExtensionInput<
UExtensionData extends ExtensionDataRef<
unknown,
string,
{
optional?: true;
}
> = ExtensionDataRef,
TConfig extends {
singleton: boolean;
optional: boolean;
internal?: boolean;
} = {
singleton: boolean;
optional: boolean;
internal?: boolean;
},
> {
// (undocumented)
readonly $$type: '@backstage/ExtensionInput';
// (undocumented)
readonly config: TConfig;
// (undocumented)
readonly extensionData: Array<UExtensionData>;
// (undocumented)
readonly replaces?: Array<{
id: string;
input: string;
}>;
}
// @public
export interface ExternalRouteRef<
TParams extends AnyRouteRefParams = AnyRouteRefParams,
> {
// (undocumented)
readonly $$type: '@backstage/ExternalRouteRef';
// (undocumented)
readonly T: TParams;
}
// @public (undocumented)
export interface FrontendPlugin<
TRoutes extends {
[name in string]: RouteRef | SubRouteRef;
} = {
[name in string]: RouteRef | SubRouteRef;
},
TExternalRoutes extends {
[name in string]: ExternalRouteRef;
} = {
[name in string]: ExternalRouteRef;
},
> {
// (undocumented)
readonly $$type: '@backstage/FrontendPlugin';
// (undocumented)
readonly externalRoutes: TExternalRoutes;
readonly icon?: IconElement;
// @deprecated
readonly id: string;
info(): Promise<FrontendPluginInfo>;
readonly pluginId: string;
// (undocumented)
readonly routes: TRoutes;
readonly title?: string;
}
// @public
export interface FrontendPluginInfo {
description?: string;
links?: Array<{
title: string;
url: string;
}>;
ownerEntityRefs?: string[];
packageName?: string;
version?: string;
}
// @public
export type IconElement = JSX_2.Element | null;
// @public (undocumented)
export interface OverridableExtensionDefinition<
T extends ExtensionDefinitionParameters = ExtensionDefinitionParameters,
> extends ExtensionDefinition<T> {
readonly inputs: {
[K in keyof T['inputs']]: ExtensionInput<
T['inputs'][K] extends ExtensionInput<infer IData> ? IData : never
>;
};
// (undocumented)
override<
TExtensionConfigSchema extends {
[key in string]: (zImpl: typeof z) => z.ZodType;
},
UFactoryOutput extends ExtensionDataValue<any, any>,
UNewOutput extends ExtensionDataRef,
TExtraInputs extends {
[inputName in string]: ExtensionInput;
},
TParamsInput extends AnyParamsInput_2<NonNullable<T['params']>>,
UParentInputs extends ExtensionDataRef,
>(
args: Expand<
{
attachTo?: ExtensionDefinitionAttachTo<UParentInputs> &
VerifyExtensionAttachTo<
ExtensionDataRef extends UNewOutput
? NonNullable<T['output']>
: UNewOutput,
UParentInputs
>;
disabled?: boolean;
if?: FilterPredicate;
inputs?: TExtraInputs & {
[KName in keyof T['inputs']]?: `Error: Input '${KName &
string}' is already defined in parent definition`;
};
output?: Array<UNewOutput>;
config?: {
schema: TExtensionConfigSchema & {
[KName in keyof T['config']]?: `Error: Config key '${KName &
string}' is already defined in parent schema`;
};
};
factory?(
originalFactory: <
TFactoryParamsReturn extends AnyParamsInput_2<
NonNullable<T['params']>
>,
>(
context?: Expand<
{
config?: T['config'];
inputs?: ResolvedInputValueOverrides<NonNullable<T['inputs']>>;
} & ([T['params']] extends [never]
? {}
: {
params?: TFactoryParamsReturn extends ExtensionBlueprintDefineParams
? TFactoryParamsReturn
: T['params'] extends ExtensionBlueprintDefineParams
? 'Error: This blueprint uses advanced parameter types and requires you to pass parameters as using the following callback syntax: `originalFactory(defineParams => defineParams(<params>))`'
: Partial<T['params']>;
})
>,
) => ExtensionDataContainer<NonNullable<T['output']>>,
context: {
node: AppNode;
apis: ApiHolder;
config: T['config'] & {
[key in keyof TExtensionConfigSchema]: z.infer<
ReturnType<TExtensionConfigSchema[key]>
>;
};
inputs: Expand<ResolvedExtensionInputs<T['inputs'] & TExtraInputs>>;
},
): Iterable<UFactoryOutput>;
} & ([T['params']] extends [never]
? {}
: {
params?: TParamsInput extends ExtensionBlueprintDefineParams
? TParamsInput
: T['params'] extends ExtensionBlueprintDefineParams
? 'Error: This blueprint uses advanced parameter types and requires you to pass parameters as using the following callback syntax: `originalFactory(defineParams => defineParams(<params>))`'
: Partial<T['params']>;
})
> &
VerifyExtensionFactoryOutput<
ExtensionDataRef extends UNewOutput
? NonNullable<T['output']>
: UNewOutput,
UFactoryOutput
>,
): OverridableExtensionDefinition<{
kind: T['kind'];
name: T['name'];
output: ExtensionDataRef extends UNewOutput ? T['output'] : UNewOutput;
inputs: T['inputs'] & TExtraInputs;
config: T['config'] & {
[key in keyof TExtensionConfigSchema]: z.infer<
ReturnType<TExtensionConfigSchema[key]>
>;
};
configInput: T['configInput'] &
z.input<
z.ZodObject<{
[key in keyof TExtensionConfigSchema]: ReturnType<
TExtensionConfigSchema[key]
>;
}>
>;
}>;
}
// @public
export type PluginWrapperApi = {
@@ -24,7 +587,7 @@ export type PluginWrapperApi = {
};
// @public
export const pluginWrapperApiRef: ApiRef<
export const pluginWrapperApiRef: ApiRef_2<
PluginWrapperApi,
'core.plugin-wrapper'
> & {
@@ -32,14 +595,14 @@ export const pluginWrapperApiRef: ApiRef<
};
// @public
export const PluginWrapperBlueprint: ExtensionBlueprint<{
export const PluginWrapperBlueprint: ExtensionBlueprint_2<{
kind: 'plugin-wrapper';
params: <TValue = never>(params: {
loader: () => Promise<PluginWrapperDefinition<TValue>>;
}) => ExtensionBlueprintParams<{
}) => ExtensionBlueprintParams_2<{
loader: () => Promise<PluginWrapperDefinition>;
}>;
output: ExtensionDataRef<
output: ExtensionDataRef_2<
() => Promise<PluginWrapperDefinition>,
'core.plugin-wrapper.loader',
{}
@@ -48,7 +611,7 @@ export const PluginWrapperBlueprint: ExtensionBlueprint<{
config: {};
configInput: {};
dataRefs: {
wrapper: ConfigurableExtensionDataRef<
wrapper: ConfigurableExtensionDataRef_2<
() => Promise<PluginWrapperDefinition>,
'core.plugin-wrapper.loader',
{}
@@ -65,5 +628,33 @@ export type PluginWrapperDefinition<TValue = unknown | never> = {
}>;
};
// @public (undocumented)
export type PortableSchema<TOutput, TInput = TOutput> = {
parse: (input: TInput) => TOutput;
schema: JsonObject;
};
// @public
export interface RouteRef<
TParams extends AnyRouteRefParams = AnyRouteRefParams,
> {
// (undocumented)
readonly $$type: '@backstage/RouteRef';
// (undocumented)
readonly T: TParams;
}
// @public
export interface SubRouteRef<
TParams extends AnyRouteRefParams = AnyRouteRefParams,
> {
// (undocumented)
readonly $$type: '@backstage/SubRouteRef';
// (undocumented)
readonly path: string;
// (undocumented)
readonly T: TParams;
}
// (No @packageDocumentation comment for this package)
```
@@ -14,6 +14,7 @@ import { ExtensionBlueprint as ExtensionBlueprint_2 } from '@backstage/frontend-
import { ExtensionBlueprintParams as ExtensionBlueprintParams_2 } from '@backstage/frontend-plugin-api';
import { ExtensionDataRef as ExtensionDataRef_2 } from '@backstage/frontend-plugin-api';
import { ExtensionInput as ExtensionInput_2 } from '@backstage/frontend-plugin-api';
import { FilterPredicate } from '@backstage/filter-predicates';
import { JsonObject } from '@backstage/types';
import { JsonValue } from '@backstage/types';
import { JSX as JSX_2 } from 'react';
@@ -258,6 +259,8 @@ export interface AppNodeSpec {
// (undocumented)
readonly id: string;
// (undocumented)
readonly if?: FilterPredicate;
// (undocumented)
readonly plugin: FrontendPlugin;
}
@@ -577,6 +580,7 @@ export type CreateExtensionBlueprintOptions<
attachTo: ExtensionDefinitionAttachTo<UParentInputs> &
VerifyExtensionAttachTo<UOutput, UParentInputs>;
disabled?: boolean;
if?: FilterPredicate;
inputs?: TInputs;
output: Array<UOutput>;
config?: {
@@ -663,6 +667,7 @@ export type CreateExtensionOptions<
attachTo: ExtensionDefinitionAttachTo<UParentInputs> &
VerifyExtensionAttachTo<UOutput, UParentInputs>;
disabled?: boolean;
if?: FilterPredicate;
inputs?: TInputs;
output: Array<UOutput>;
config?: {
@@ -751,6 +756,8 @@ export interface CreateFrontendModuleOptions<
// (undocumented)
featureFlags?: FeatureFlagConfig[];
// (undocumented)
if?: FilterPredicate;
// (undocumented)
pluginId: TPluginId;
}
@@ -796,6 +803,8 @@ export interface CreateFrontendPluginOptions<
featureFlags?: FeatureFlagConfig[];
icon?: IconElement;
// (undocumented)
if?: FilterPredicate;
// (undocumented)
info?: FrontendPluginInfoOptions;
// (undocumented)
pluginId: TId;
@@ -1026,6 +1035,7 @@ export interface ExtensionBlueprint<
attachTo?: ExtensionDefinitionAttachTo<UParentInputs> &
VerifyExtensionAttachTo<NonNullable<T['output']>, UParentInputs>;
disabled?: boolean;
if?: FilterPredicate;
params: TParamsInput extends ExtensionBlueprintDefineParams
? TParamsInput
: T['params'] extends ExtensionBlueprintDefineParams
@@ -1061,6 +1071,7 @@ export interface ExtensionBlueprint<
UParentInputs
>;
disabled?: boolean;
if?: FilterPredicate;
inputs?: TExtraInputs & {
[KName in keyof T['inputs']]?: `Error: Input '${KName &
string}' is already defined in parent definition`;
@@ -1702,6 +1713,7 @@ export interface OverridableExtensionDefinition<
UParentInputs
>;
disabled?: boolean;
if?: FilterPredicate;
inputs?: TExtraInputs & {
[KName in keyof T['inputs']]?: `Error: Input '${KName &
string}' is already defined in parent definition`;
@@ -1807,6 +1819,7 @@ export interface OverridableFrontendPlugin<
// (undocumented)
withOverrides(options: {
extensions?: Array<ExtensionDefinition>;
if?: FilterPredicate;
title?: string;
icon?: IconElement;
info?: FrontendPluginInfoOptions;
+37
View File
@@ -20,6 +20,43 @@ export {
PluginWrapperBlueprint,
type PluginWrapperDefinition,
} from './blueprints/PluginWrapperBlueprint';
export type {
ConfigurableExtensionDataRef,
Extension,
ExtensionAttachTo,
ExtensionDefinition,
ExtensionDefinitionParameters,
ExtensionBlueprintDefineParams,
ExtensionBlueprint,
ExtensionBlueprintParameters,
ExtensionBlueprintParams,
ExtensionDataContainer,
ExtensionDataRef,
ExtensionDataValue,
ExtensionDefinitionAttachTo,
ExtensionInput,
FrontendPlugin,
OverridableExtensionDefinition,
} from './wiring';
export type {
ApiHolder,
ApiRef,
AppNode,
AppNodeEdges,
AppNodeInstance,
AppNodeSpec,
AppTree,
} from './apis';
export type { PortableSchema } from './schema';
export type {
AnyRouteRefParams,
RouteRef,
SubRouteRef,
ExternalRouteRef,
} from './routing';
export type { IconElement } from './icons';
export type { FrontendPluginInfo } from './wiring';
export { createExtensionBlueprintParams } from './wiring';
export {
type PluginWrapperApi,
pluginWrapperApiRef,
@@ -17,6 +17,7 @@
import { createApiRef } from '../system';
import { FrontendPlugin, Extension, ExtensionDataRef } from '../../wiring';
import { ExtensionAttachTo } from '../../wiring/resolveExtensionDefinition';
import { FilterPredicate } from '@backstage/filter-predicates';
/**
* The specification for this {@link AppNode} in the {@link AppTree}.
@@ -32,6 +33,7 @@ export interface AppNodeSpec {
readonly attachTo: ExtensionAttachTo;
readonly extension: Extension<unknown, unknown>;
readonly disabled: boolean;
readonly if?: FilterPredicate;
readonly config?: unknown;
readonly plugin: FrontendPlugin;
}
@@ -39,6 +39,7 @@ describe('AnalyticsBlueprint', () => {
"configSchema": undefined,
"disabled": false,
"factory": [Function],
"if": undefined,
"inputs": {},
"kind": "analytics",
"name": "test",
@@ -43,6 +43,7 @@ describe('ApiBlueprint', () => {
"configSchema": undefined,
"disabled": false,
"factory": [Function],
"if": undefined,
"inputs": {},
"kind": "api",
"name": "test",
@@ -196,6 +197,7 @@ describe('ApiBlueprint', () => {
},
"disabled": false,
"factory": [Function],
"if": undefined,
"inputs": {
"test": {
"$$type": "@backstage/ExtensionInput",
@@ -42,6 +42,7 @@ describe('AppRootElementBlueprint', () => {
"configSchema": undefined,
"disabled": false,
"factory": [Function],
"if": undefined,
"inputs": {},
"kind": "app-root-element",
"name": undefined,
@@ -53,6 +53,7 @@ describe('NavItemBlueprint', () => {
},
"disabled": false,
"factory": [Function],
"if": undefined,
"inputs": {},
"kind": "nav-item",
"name": undefined,
@@ -65,6 +65,7 @@ describe('PageBlueprint', () => {
},
"disabled": false,
"factory": [Function],
"if": undefined,
"inputs": {
"pages": {
"$$type": "@backstage/ExtensionInput",
@@ -36,6 +36,7 @@ import {
} from './createExtensionBlueprint';
import { FrontendPlugin } from './createFrontendPlugin';
import { FrontendModule } from './createFrontendModule';
import { FilterPredicate } from '@backstage/filter-predicates';
/**
* This symbol is used to pass parameter overrides from the extension override to the blueprint factory
@@ -174,6 +175,7 @@ export type CreateExtensionOptions<
attachTo: ExtensionDefinitionAttachTo<UParentInputs> &
VerifyExtensionAttachTo<UOutput, UParentInputs>;
disabled?: boolean;
if?: FilterPredicate;
inputs?: TInputs;
output: Array<UOutput>;
config?: {
@@ -255,6 +257,7 @@ export interface OverridableExtensionDefinition<
UParentInputs
>;
disabled?: boolean;
if?: FilterPredicate;
inputs?: TExtraInputs & {
[KName in keyof T['inputs']]?: `Error: Input '${KName &
string}' is already defined in parent definition`;
@@ -474,6 +477,7 @@ export function createExtension<
name: options.name,
attachTo: options.attachTo,
disabled: options.disabled ?? false,
if: options.if,
inputs: bindInputs(options.inputs, options.kind, options.name),
output: options.output,
configSchema,
@@ -545,12 +549,18 @@ export function createExtension<
);
}
let ifPredicate = options.if;
if ('if' in overrideOptions) {
ifPredicate = overrideOptions.if;
}
return createExtension({
kind: options.kind,
name: options.name,
attachTo: (overrideOptions.attachTo ??
options.attachTo) as ExtensionDefinitionAttachTo,
disabled: overrideOptions.disabled ?? options.disabled,
if: ifPredicate,
inputs: bindInputs(
{
...(options.inputs ?? {}),
@@ -36,6 +36,7 @@ import {
} from './resolveInputOverrides';
import { ExtensionDataContainer } from './types';
import { PageBlueprint } from '../blueprints/PageBlueprint';
import { FilterPredicate } from '@backstage/filter-predicates';
/**
* A function used to define a parameter mapping function in order to facilitate
@@ -114,6 +115,7 @@ export type CreateExtensionBlueprintOptions<
attachTo: ExtensionDefinitionAttachTo<UParentInputs> &
VerifyExtensionAttachTo<UOutput, UParentInputs>;
disabled?: boolean;
if?: FilterPredicate;
inputs?: TInputs;
output: Array<UOutput>;
config?: {
@@ -221,6 +223,7 @@ export interface ExtensionBlueprint<
attachTo?: ExtensionDefinitionAttachTo<UParentInputs> &
VerifyExtensionAttachTo<NonNullable<T['output']>, UParentInputs>;
disabled?: boolean;
if?: FilterPredicate;
params: TParamsInput extends ExtensionBlueprintDefineParams
? TParamsInput
: T['params'] extends ExtensionBlueprintDefineParams
@@ -261,6 +264,7 @@ export interface ExtensionBlueprint<
UParentInputs
>;
disabled?: boolean;
if?: FilterPredicate;
inputs?: TExtraInputs & {
[KName in keyof T['inputs']]?: `Error: Input '${KName &
string}' is already defined in parent definition`;
@@ -510,6 +514,7 @@ export function createExtensionBlueprint<
attachTo: (args.attachTo ??
options.attachTo) as ExtensionDefinitionAttachTo,
disabled: args.disabled ?? options.disabled,
if: args.if ?? options.if,
inputs: options.inputs,
output: options.output as ExtensionDataRef[],
config: options.config,
@@ -527,6 +532,7 @@ export function createExtensionBlueprint<
attachTo: (args.attachTo ??
options.attachTo) as ExtensionDefinitionAttachTo,
disabled: args.disabled ?? options.disabled,
if: args.if ?? options.if,
inputs: { ...args.inputs, ...options.inputs },
output: (args.output ?? options.output) as ExtensionDataRef[],
config:
@@ -46,6 +46,7 @@ describe('createFrontendModule', () => {
"disabled": false,
"factory": [Function],
"id": "route:test/test",
"if": undefined,
"inputs": {},
"output": [],
"toString": [Function],
@@ -53,6 +54,7 @@ describe('createFrontendModule', () => {
},
],
"featureFlags": [],
"if": undefined,
"pluginId": "test",
"toString": [Function],
"version": "v1",
@@ -21,6 +21,7 @@ import {
resolveExtensionDefinition,
} from './resolveExtensionDefinition';
import { FeatureFlagConfig } from './types';
import { FilterPredicate } from '@backstage/filter-predicates';
/** @public */
export interface CreateFrontendModuleOptions<
@@ -30,6 +31,7 @@ export interface CreateFrontendModuleOptions<
pluginId: TPluginId;
extensions?: TExtensions;
featureFlags?: FeatureFlagConfig[];
if?: FilterPredicate;
}
/** @public */
@@ -43,6 +45,7 @@ export interface InternalFrontendModule extends FrontendModule {
readonly version: 'v1';
readonly extensions: Extension<unknown>[];
readonly featureFlags: FeatureFlagConfig[];
readonly if?: FilterPredicate;
}
/**
@@ -126,6 +129,7 @@ export function createFrontendModule<
version: 'v1',
pluginId,
featureFlags: options.featureFlags ?? [],
if: options.if,
extensions,
toString() {
return `Module{pluginId=${pluginId}}`;
@@ -171,6 +171,7 @@ describe('createFrontendPlugin', () => {
"configSchema": undefined,
"disabled": false,
"factory": [Function],
"if": undefined,
"inputs": {},
"kind": undefined,
"name": "1",
@@ -359,6 +360,7 @@ describe('createFrontendPlugin', () => {
"configSchema": undefined,
"disabled": false,
"factory": [Function],
"if": undefined,
"inputs": {},
"kind": undefined,
"name": "1",
@@ -32,6 +32,7 @@ import { JsonObject } from '@backstage/types';
import { IconElement } from '../icons/types';
import { RouteRef, SubRouteRef, ExternalRouteRef } from '../routing';
import { ID_PATTERN } from './constants';
import { FilterPredicate } from '@backstage/filter-predicates';
/**
* Information about the plugin.
@@ -115,6 +116,11 @@ export interface OverridableFrontendPlugin<
withOverrides(options: {
extensions?: Array<ExtensionDefinition>;
/**
* Overrides the shared condition that applies to all extensions in the plugin.
*/
if?: FilterPredicate;
/**
* Overrides the display title of the plugin.
*/
@@ -195,6 +201,7 @@ export interface CreateFrontendPluginOptions<
externalRoutes?: TExternalRoutes;
extensions?: TExtensions;
featureFlags?: FeatureFlagConfig[];
if?: FilterPredicate;
info?: FrontendPluginInfoOptions;
}
@@ -304,6 +311,7 @@ export function createFrontendPlugin<
routes: options.routes ?? ({} as TRoutes),
externalRoutes: options.externalRoutes ?? ({} as TExternalRoutes),
featureFlags: options.featureFlags ?? [],
if: options.if,
extensions: extensions,
infoOptions: options.info,
@@ -326,6 +334,10 @@ export function createFrontendPlugin<
return `Plugin{id=${pluginId}}`;
},
withOverrides(overrides) {
let ifPredicate = options.if;
if ('if' in overrides) {
ifPredicate = overrides.if;
}
const overrideExtensions = overrides.extensions ?? [];
const overriddenExtensionIds = new Set(
overrideExtensions.map(
@@ -341,6 +353,7 @@ export function createFrontendPlugin<
return createFrontendPlugin({
...options,
pluginId,
if: ifPredicate,
title: overrides.title ?? options.title,
icon: overrides.icon ?? options.icon,
extensions: [...nonOverriddenExtensions, ...overrideExtensions],
@@ -28,6 +28,7 @@ import {
OpaqueExtensionDefinition,
OpaqueExtensionInput,
} from '@internal/frontend';
import { FilterPredicate } from '@backstage/filter-predicates';
/** @public */
export type ExtensionAttachTo = { id: string; input: string };
@@ -74,6 +75,7 @@ export type InternalExtension<TConfig, TConfigInput> = Extension<
}
| {
readonly version: 'v2';
readonly if?: FilterPredicate;
readonly inputs: { [inputName in string]: ExtensionInput };
readonly output: Array<ExtensionDataRef>;
factory(options: {
@@ -34,6 +34,7 @@
"@backstage/config": "workspace:^",
"@backstage/core-app-api": "workspace:^",
"@backstage/core-plugin-api": "workspace:^",
"@backstage/filter-predicates": "workspace:^",
"@backstage/frontend-app-api": "workspace:^",
"@backstage/frontend-plugin-api": "workspace:^",
"@backstage/plugin-app": "workspace:^",
@@ -38,6 +38,8 @@ describe('TestApiProvider', () => {
<div />
</TestApiProvider>,
);
expect(document.body).toBeInTheDocument();
});
it('should allow partial API implementations', () => {
@@ -46,6 +48,8 @@ describe('TestApiProvider', () => {
<div />
</TestApiProvider>,
);
expect(document.body).toBeInTheDocument();
});
it('should reject mismatched types in tuple syntax', () => {
@@ -55,6 +59,8 @@ describe('TestApiProvider', () => {
<div />
</TestApiProvider>,
);
expect(document.body).toBeInTheDocument();
});
it('should accept MockWithApiFactory entries', () => {
@@ -63,6 +69,8 @@ describe('TestApiProvider', () => {
<div />
</TestApiProvider>,
);
expect(document.body).toBeInTheDocument();
});
it('should accept a mix of tuples and MockWithApiFactory entries', () => {
@@ -71,6 +79,8 @@ describe('TestApiProvider', () => {
<div />
</TestApiProvider>,
);
expect(document.body).toBeInTheDocument();
});
it('should allow empty APIs', () => {
@@ -79,6 +89,8 @@ describe('TestApiProvider', () => {
<div />
</TestApiProvider>,
);
expect(document.body).toBeInTheDocument();
});
it('should provide APIs at runtime', async () => {
@@ -16,7 +16,7 @@
import { Fragment } from 'react';
import { Link, MemoryRouter } from 'react-router-dom';
import { createSpecializedApp } from '@backstage/frontend-app-api';
import { prepareSpecializedApp } from '@backstage/frontend-app-api';
import { RenderResult, render } from '@testing-library/react';
import { ConfigReader } from '@backstage/config';
import { JsonObject } from '@backstage/types';
@@ -233,7 +233,7 @@ export function renderInTestApp<const TApiPairs extends any[] = any[]>(
features.push(...options.features);
}
const app = createSpecializedApp({
const app = prepareSpecializedApp({
features,
config: ConfigReader.fromConfigs([
{
@@ -251,7 +251,7 @@ export function renderInTestApp<const TApiPairs extends any[] = any[]>(
return createApiFactory(apiRef, implementation);
}),
},
} as CreateSpecializedAppInternalOptions);
} as CreateSpecializedAppInternalOptions).finalize();
return render(
app.tree.root.instance!.getData(coreExtensionData.reactElement),
@@ -15,7 +15,7 @@
*/
import { Fragment } from 'react';
import { createSpecializedApp } from '@backstage/frontend-app-api';
import { prepareSpecializedApp } from '@backstage/frontend-app-api';
import {
coreExtensionData,
createApiFactory,
@@ -175,7 +175,7 @@ export function renderTestApp<const TApiPairs extends any[] = any[]>(
features.push(...options.features);
}
const app = createSpecializedApp({
const app = prepareSpecializedApp({
features,
config: ConfigReader.fromConfigs([
{
@@ -193,7 +193,7 @@ export function renderTestApp<const TApiPairs extends any[] = any[]>(
return createApiFactory(apiRef, implementation);
}),
},
} as CreateSpecializedAppInternalOptions);
} as CreateSpecializedAppInternalOptions).finalize();
return render(
app.tree.root.instance!.getData(coreExtensionData.reactElement),