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
@@ -0,0 +1,5 @@
---
'@backstage/core-compat-api': patch
---
Added a missing dependency on `@backstage/filter-predicates` to `@backstage/core-compat-api`. This fixes package metadata for consumers that use compatibility helpers relying on filter predicate support.
@@ -0,0 +1,5 @@
---
'@backstage/core-components': patch
---
Fixed the shared `Progress` component to provide an accessible name for its loading indicator by default.
@@ -0,0 +1,5 @@
---
'@backstage/frontend-test-utils': patch
---
Added a missing dependency on `@backstage/filter-predicates` to `@backstage/frontend-test-utils`. This fixes package metadata for consumers using the frontend test app helpers with predicate-based behavior.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-permission-react': patch
---
Permission checks made in the same tick are now batched into a single call to the permission backend.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-app': patch
---
Updated the default app root to better support phased app preparation by allowing the app layout to be absent during bootstrap, routing bootstrap failures through the app root boundary, and avoiding installation of a guest identity in protected apps that do not provide a sign-in page.
@@ -0,0 +1,5 @@
---
'@backstage/frontend-app-api': patch
---
Added `prepareSpecializedApp` for two-phase app wiring so apps can render a bootstrap tree before full app finalization. The bootstrap phase now supports deferred `app/root.elements`, predicate-gated APIs, reusable `sessionState`, and warnings for bootstrap-visible predicates or bootstrap code that accessed APIs that only became available after finalization. Utility APIs that are materialized during bootstrap are also frozen for the lifetime of the app instance, causing deferred overrides of those APIs to be ignored and reported as app errors.
@@ -0,0 +1,5 @@
---
'@backstage/frontend-defaults': patch
---
Updated `createApp` to use the phased `prepareSpecializedApp` flow, allowing apps to render a bootstrap tree before the full app is finalized.
@@ -0,0 +1,5 @@
---
'@backstage/frontend-plugin-api': patch
---
Added support for `if` predicates on `createFrontendPlugin` and `createFrontendModule`, applying shared conditions to every extension in the feature. Plugin and extension overrides can now also replace or remove existing `if` predicates.
@@ -48,6 +48,52 @@ Because feature discovery needs to interact with the compilation process, it is
For information on how to configure feature discovery and other installation options, see [Installing Plugins](../building-apps/05-installing-plugins.md).
## Preparing an App in Phases
Most apps should use `createApp` from `@backstage/frontend-defaults`, which takes care of all app preparation internally. For more advanced use cases there is also a lower-level `prepareSpecializedApp` API in `@backstage/frontend-app-api`.
This API is useful when you need to render a bootstrap tree before the full app can be finalized, for example while waiting for sign-in or other session-dependent state. It gives you access to a bootstrap app tree immediately, lets you either subscribe to finalization with `onFinalized()` or finalize synchronously with `finalize()`, and lets you reuse a prepared session in a later app instance.
```tsx
import {
FinalizedSpecializedApp,
prepareSpecializedApp,
} from '@backstage/frontend-app-api';
const preparedApp = prepareSpecializedApp({
config,
features: [appPlugin, ...features],
});
const bootstrapApp = preparedApp.getBootstrapApp();
const unsubscribe = preparedApp.onFinalized(
(finalizedApp: FinalizedSpecializedApp) => {
console.log(finalizedApp.sessionState);
},
);
```
The `getBootstrapApp()` method exposes the partial app tree that is available during bootstrap. If you call `onFinalized()`, you are subscribing to the bootstrap-owned finalization flow. In the sign-in case, the sign-in page receives an `onSignInSuccess` callback, and once it provides an identity through that callback the full app is finalized and `onFinalized()` subscribers are notified.
If you instead call `finalize()`, you are taking ownership of finalization yourself. This only works when the app can be finalized synchronously, for example when all predicate context is already available or when you passed a reusable session state to `prepareSpecializedApp()` up front:
```tsx
const preparedApp = prepareSpecializedApp({
config,
features: [appPlugin, ...features],
advanced: {
sessionState,
},
});
const app = preparedApp.finalize();
```
When using phased app preparation, `app/root.children` acts as the main session boundary. Conditional extensions behind that boundary are evaluated during finalization. Conditional `app/root.elements` and API branches are also deferred until finalization, while other bootstrap-visible predicates are ignored and reported as warnings.
Utility APIs that are first materialized during bootstrap are frozen for the lifetime of that app instance. Finalization may still add new APIs and may override existing API refs that were not materialized during bootstrap, but any deferred override of an already materialized bootstrap API is ignored and reported as an app error.
## Plugin Info Resolution
When a plugin is installed in an app it may provide sources of information about the plugin that can be useful to end users and admins. This includes things like what version of a plugin is running, what team owns the plugin, and who to contact for support. You can read more about how the plugins provide this information in the [plugins `info` option section](./15-plugins.md#info).
@@ -76,6 +76,20 @@ These are the routes that the plugin exposes to the app. The `routes` option dec
This is a list of feature flag declarations that your plugin provides to the app. This makes sure that the feature flags are correctly registered and can be toggled in the app. To read a feature flag you can use the feature flags [Utility API](../architecture/33-utility-apis.md), accessible via `featureFlagsApiRef`.
### `if` option
The `if` option lets you apply a shared condition to all extensions that are provided by a plugin instance. This is useful when you want to gate an entire plugin behind a feature flag or permission without repeating the same predicate on every individual extension.
```tsx
export default createFrontendPlugin({
pluginId: 'my-plugin',
if: { featureFlags: { $contains: 'my-plugin-enabled' } },
extensions: [...],
});
```
This predicate is applied to every extension from that plugin instance. If any extension already has its own `if` predicate, the two are combined using logical `AND`.
### `info` option
This options is used to provide loaders for different sources of information about the plugin that may be useful to users and admins. The two available loaders are `packageJson` and `manifest`, and a plugin can use either or both as needed. The resulting information is available via the `info()` method on the plugin instance once it is installed in an app, but it is up to each app to decide how to derive the information from the provided sources.
@@ -41,6 +41,43 @@ Each extension in the app can be disabled, meaning it will not be instantiated a
The ordering of extensions is sometimes very important, as it may for example affect in which order they show up in the UI. When an extension is toggled from disabled to enabled through configuration it resets the ordering of the extension, pushing it to the end of the list. It is generally recommended to leave extensions as disabled by default if their order is important, allowing for the order in which their are enabled in the configuration to determine their order in the app.
### Conditions
Extensions can also be conditionally enabled by providing an `if` predicate. This is available both on `createExtension(...)` directly and when creating extensions from blueprints.
The predicate uses the same `FilterPredicate` syntax as elsewhere in Backstage, but in the frontend system it is evaluated against app-level data such as `featureFlags` and `permissions`. For example, the following page is only installed when the `experimental-features` flag is active:
```tsx
const examplePage = PageBlueprint.make({
params: {
path: '/example',
loader: () => import('./ExamplePage').then(m => <m.ExamplePage />),
},
if: { featureFlags: { $contains: 'experimental-features' } },
});
```
You can also combine conditions using logical operators such as `$all`, `$any`, and `$not`:
```tsx
const guardedCard = CardBlueprint.make({
params: {
title: 'Guarded Card',
loader: () => import('./GuardedCard').then(m => <m.GuardedCard />),
},
if: {
$all: [
{ featureFlags: { $contains: 'experimental-features' } },
{ permissions: { $contains: 'catalog.entity.create' } },
],
},
});
```
Conditions are evaluated when the app tree is prepared, not continuously while the app is running. If the underlying feature flags or permissions change, the app needs to be prepared again in order for the extension tree to change, which in practice typically means reloading the app.
If a plugin or module also provides an `if` predicate, it is combined with the extension-level predicate using logical `AND`. See the [plugin `if` option](./15-plugins.md#if-option) and [frontend modules](./25-extension-overrides.md#creating-a-frontend-module) sections for more details.
### Configuration & configuration schema
Each extension can define a configuration schema that describes the configuration that it accepts. This schema is used to validate the configuration provided by integrators, but also to fill in default configuration values. The configuration itself is provided by integrators in order to customize the extension. It is not possible to provide a default configuration of an extension, this must instead be done through defaults in the configuration schema. This allows for a simpler configuration logic where multiple configurations of the same extension completely replace each other rather than being merged.
@@ -9,7 +9,7 @@ The `createExtension` function and related APIs is considered a low-level buildi
## Creating an extension from a blueprint
Every extension blueprint provides a `make` method that can be used to create new extensions. It is a simple way to create a new extension where the base blueprint provides all the necessary functionality. All you need to do is to provide the necessary blueprint parameters, but you also have the ability to provide additional options, for example a `name` for the extension.
Every extension blueprint provides a `make` method that can be used to create new extensions. It is a simple way to create a new extension where the base blueprint provides all the necessary functionality. All you need to do is to provide the necessary blueprint parameters, but you also have the ability to provide additional options, for example a `name`, `attachTo`, `disabled`, or `if` predicate for the extension.
The following is a simple example of how one might use the blueprint `make` method to create a new extension:
@@ -11,6 +11,8 @@ An important customization point in the frontend system is the ability to overri
In general, most features should have a good level of customization built into them, so that users do not have to leverage extension overrides to achieve common goals. A well written feature often has [configuration](../../conf/) settings, or uses extension inputs for extensibility where applicable. An example of this is the search plugin, which allows you to provide result renderers as inputs rather than replacing the result page wholesale just to tweak how results are shown. Adopters should take advantage of those when possible in order to reduce the need and size of extension overrides.
Extension overrides can also replace or remove existing `if` predicates. This applies both to direct extension overrides through `.override(...)` and to plugin-level overrides through `plugin.withOverrides(...)`. Frontend modules can use the same extension override mechanism to adjust or clear the condition for an overridden extension.
## Overriding an extension
Every extension created with `createExtension` comes with an `override` method, including those created from an [extension blueprint](./23-extension-blueprints.md). The `override` method **creates a new extension**, it does not mutate the existing extension. This new extension in created in such a way that if it is installed adjacent to the existing extension, it will take precedence and override the existing extension. While the `override` method does create new extension instances, it is not intended to be used as a way to create multiple new extensions from a base template, for that use-case you will want to use an [extension blueprint](./23-extension-blueprints.md) instead.
@@ -343,3 +345,5 @@ export default app.createRoot();
```
You must define a `pluginId` when creating a frontend module, and the plugin must also be installed for the module to be loaded.
Frontend modules also support an `if` option. Just like for plugins, that predicate is applied to every extension that comes from the module, and is combined with any extension-level `if` predicate using logical `AND`. This is useful when you want to enable or disable an entire override package based on a feature flag or permission.
@@ -57,6 +57,8 @@ Note that `createRoot` returns the root element that is rendered by React. The a
Visit the [built-in extensions](#customize-or-override-built-in-extensions) section to see what is installed by default in a Backstage application.
For advanced bootstrap flows that need access to the app tree before the full app is finalized, see [preparing an app in phases](../architecture/10-app.md#preparing-an-app-in-phases).
## Configure your app
### Bind external routes
@@ -99,7 +99,7 @@ This is the extension that creates the app root element, so it renders root leve
| ---------- | --------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| router | A React component that should manager the app routes context. | It must be one [router](https://reactrouter.com/en/main/routers/picking-a-router#web-projects) component or a custom component compatible with the 'react-router' library. | true | [BrowserRouter](https://reactrouter.com/en/main/router-components/browser-router) | [createRouterExtension](https://backstage.io/docs/reference/frontend-plugin-api.createrouterextension) |
| signInPage | A React component that should render the app sign-in page. | Should call the `onSignInSuccess` prop when the user has been successfully authorized, otherwise the user will not be correctly redirected to the application home page. | true | The default `AppRoot` extension does not use a default component for this input, it bypasses the user authentication check and always renders all routes when a login page is not installed. | [createSignInPageExtension](https://backstage.io/docs/reference/frontend-plugin-api.createsigninpageextension/) |
| children | A React component that renders the app sidebar and main content in a particular layout. | - | false | The [`App/Layout`](#app-layout) extension output. | No creator available, configure or override the [`App/Layout`](#app-layout) extension. |
| children | A React component that renders the app sidebar and main content in a particular layout. | - | true | The [`App/Layout`](#app-layout) extension output. | No creator available, configure or override the [`App/Layout`](#app-layout) extension. |
| elements | React elements to be rendered outside of the app layout, such as shared popups. | - | false | See [default elements](#default-app-root-elements-extensions). | [createAppRootElementExtension](https://backstage.io/docs/reference/frontend-plugin-api.createapprootelementextension/) |
| wrappers | React components that should wrap the root element. | - | true | - | [createAppRootWrapperExtension](https://backstage.io/docs/reference/frontend-plugin-api.createapprootwrapperextension/) |
+1 -1
View File
@@ -54,7 +54,7 @@ module.exports = {
preset: 'desktop',
},
startServerCommand: 'yarn start',
startServerReadyPattern: 'webpack compiled successfully',
startServerReadyPattern: 'compiled.*successfully',
startServerReadyTimeout: 600000,
numberOfRuns: 1,
puppeteerScript: './.lighthouseci/scripts/guest-auth.js',
+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),
+2 -2
View File
@@ -86,7 +86,7 @@ export const IconBundleBlueprint: ExtensionBlueprint<{
};
output: ExtensionDataRef<
{
[x: string]: IconElement | IconComponent;
[x: string]: IconComponent | IconElement;
},
'core.icons',
{}
@@ -97,7 +97,7 @@ export const IconBundleBlueprint: ExtensionBlueprint<{
dataRefs: {
icons: ConfigurableExtensionDataRef<
{
[x: string]: IconElement | IconComponent;
[x: string]: IconComponent | IconElement;
},
'core.icons',
{}
@@ -39,6 +39,7 @@ describe('AnalyticsBlueprint', () => {
"configSchema": undefined,
"disabled": false,
"factory": [Function],
"if": undefined,
"inputs": {},
"kind": "analytics",
"name": "test",
@@ -43,6 +43,7 @@ describe('AppRootWrapperBlueprint', () => {
"configSchema": undefined,
"disabled": false,
"factory": [Function],
"if": undefined,
"inputs": {},
"kind": "app-root-wrapper",
"name": undefined,
@@ -86,6 +86,7 @@ describe('NavContentBlueprint', () => {
"configSchema": undefined,
"disabled": false,
"factory": [Function],
"if": undefined,
"inputs": {},
"kind": "nav-content",
"name": undefined,
@@ -42,6 +42,7 @@ describe('RouterBlueprint', () => {
"configSchema": undefined,
"disabled": false,
"factory": [Function],
"if": undefined,
"inputs": {},
"kind": "app-router-component",
"name": undefined,
@@ -38,6 +38,7 @@ describe('SignInPageBlueprint', () => {
"configSchema": undefined,
"disabled": false,
"factory": [Function],
"if": undefined,
"inputs": {},
"kind": "sign-in-page",
"name": undefined,
@@ -39,6 +39,7 @@ describe('ThemeBlueprint', () => {
"configSchema": undefined,
"disabled": false,
"factory": [Function],
"if": undefined,
"inputs": {},
"kind": "theme",
"name": "light",
@@ -54,6 +54,7 @@ describe('TranslationBlueprint', () => {
"configSchema": undefined,
"disabled": false,
"factory": [Function],
"if": undefined,
"inputs": {},
"kind": "translation",
"name": "blob",
+1
View File
@@ -53,6 +53,7 @@
"dependencies": {
"@backstage/core-components": "workspace:^",
"@backstage/core-plugin-api": "workspace:^",
"@backstage/filter-predicates": "workspace:^",
"@backstage/frontend-plugin-api": "workspace:^",
"@backstage/integration-react": "workspace:^",
"@backstage/plugin-app-react": "workspace:^",
+2 -2
View File
@@ -147,7 +147,7 @@ const appPlugin: OverridableFrontendPlugin<
ConfigurableExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>,
{
singleton: true;
optional: false;
optional: true;
internal: false;
}
>;
@@ -478,7 +478,7 @@ const appPlugin: OverridableFrontendPlugin<
icons: ExtensionInput<
ConfigurableExtensionDataRef<
{
[x: string]: IconElement | IconComponent;
[x: string]: IconComponent | IconElement;
},
'core.icons',
{}
+43 -39
View File
@@ -22,6 +22,7 @@ import {
JSX,
} from 'react';
import {
ExtensionBoundary,
coreExtensionData,
discoveryApiRef,
fetchApiRef,
@@ -73,6 +74,7 @@ export const AppRoot = createExtension({
}),
children: createExtensionInput([coreExtensionData.reactElement], {
singleton: true,
optional: true,
}),
elements: createExtensionInput([coreExtensionData.reactElement]),
wrappers: createExtensionInput(
@@ -83,7 +85,7 @@ export const AppRoot = createExtension({
),
},
output: [coreExtensionData.reactElement],
factory({ inputs, apis }) {
factory({ inputs, apis, node }) {
if (isProtectedApp()) {
const identityApi = apis.get(identityApiRef);
if (!identityApi) {
@@ -105,9 +107,7 @@ export const AppRoot = createExtension({
});
}
let content: ReactNode = inputs.children.get(
coreExtensionData.reactElement,
);
let content = inputs.children?.get(coreExtensionData.reactElement);
for (const wrapper of inputs.wrappers) {
const Component = wrapper.get(AppRootWrapperBlueprint.dataRefs.component);
@@ -124,19 +124,21 @@ export const AppRoot = createExtension({
return [
coreExtensionData.reactElement(
<AppRouter
SignInPageComponent={inputs.signInPage?.get(
SignInPageBlueprint.dataRefs.component,
)}
RouterComponent={inputs.router?.get(
RouterBlueprint.dataRefs.component,
)}
extraElements={inputs.elements?.map(el =>
el.get(coreExtensionData.reactElement),
)}
>
{content}
</AppRouter>,
<ExtensionBoundary node={node}>
<AppRouter
SignInPageComponent={inputs.signInPage?.get(
SignInPageBlueprint.dataRefs.component,
)}
RouterComponent={inputs.router?.get(
RouterBlueprint.dataRefs.component,
)}
extraElements={inputs.elements?.map(el =>
el.get(coreExtensionData.reactElement),
)}
>
{content}
</AppRouter>
</ExtensionBoundary>,
),
];
},
@@ -253,28 +255,30 @@ export function AppRouter(props: AppRouterProps) {
// If the app hasn't configured a sign-in page, we just continue as guest.
if (!SignInPageComponent) {
appIdentityProxy.setTarget(
{
getUserId: () => 'guest',
getIdToken: async () => undefined,
getProfile: () => ({
email: 'guest@example.com',
displayName: 'Guest',
}),
getProfileInfo: async () => ({
email: 'guest@example.com',
displayName: 'Guest',
}),
getBackstageIdentity: async () => ({
type: 'user',
userEntityRef: 'user:default/guest',
ownershipEntityRefs: ['user:default/guest'],
}),
getCredentials: async () => ({}),
signOut: async () => {},
},
{ signOutTargetUrl: basePath || '/' },
);
if (!isProtectedApp()) {
appIdentityProxy.setTarget(
{
getUserId: () => 'guest',
getIdToken: async () => undefined,
getProfile: () => ({
email: 'guest@example.com',
displayName: 'Guest',
}),
getProfileInfo: async () => ({
email: 'guest@example.com',
displayName: 'Guest',
}),
getBackstageIdentity: async () => ({
type: 'user',
userEntityRef: 'user:default/guest',
ownershipEntityRefs: ['user:default/guest'],
}),
getCredentials: async () => ({}),
signOut: async () => {},
},
{ signOutTargetUrl: basePath || '/' },
);
}
return (
<RouterComponent>
@@ -43,6 +43,7 @@ describe('CatalogFilterBlueprint', () => {
"configSchema": undefined,
"disabled": false,
"factory": [Function],
"if": undefined,
"inputs": {},
"kind": "catalog-filter",
"name": undefined,
@@ -200,6 +200,7 @@ describe('EntityCardBlueprint', () => {
},
"disabled": false,
"factory": [Function],
"if": undefined,
"inputs": {},
"kind": "entity-card",
"name": "test",
@@ -215,6 +215,7 @@ describe('EntityContentBlueprint', () => {
},
"disabled": false,
"factory": [Function],
"if": undefined,
"inputs": {},
"kind": "entity-content",
"name": "test",
@@ -204,6 +204,7 @@ describe('EntityContextMenuItemBlueprint', () => {
},
"disabled": false,
"factory": [Function],
"if": undefined,
"inputs": {},
"kind": "entity-context-menu-item",
"name": "test",
+1
View File
@@ -45,6 +45,7 @@
"@backstage/config": "workspace:^",
"@backstage/core-plugin-api": "workspace:^",
"@backstage/plugin-permission-common": "workspace:^",
"dataloader": "^2.0.0",
"swr": "^2.0.0"
},
"devDependencies": {
@@ -0,0 +1,97 @@
/*
* 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 { ConfigReader } from '@backstage/config';
import { mockApis } from '@backstage/test-utils';
import {
createPermission,
PermissionClient,
} from '@backstage/plugin-permission-common';
import { AuthorizeResult } from '@backstage/plugin-permission-common';
import { IdentityPermissionApi } from './IdentityPermissionApi';
describe('IdentityPermissionApi', () => {
const permission = createPermission({
name: 'test.permission',
attributes: {},
});
afterEach(() => {
jest.restoreAllMocks();
});
it('should batch requests that arrive on the same tick', async () => {
const authorizeSpy = jest
.spyOn(PermissionClient.prototype, 'authorize')
.mockResolvedValue([
{ result: AuthorizeResult.ALLOW },
{ result: AuthorizeResult.DENY },
]);
const api = IdentityPermissionApi.create({
config: new ConfigReader({}),
discovery: mockApis.discovery(),
identity: mockApis.identity(),
});
const firstRequest = { permission };
const secondRequest = { permission };
const [firstResponse, secondResponse] = await Promise.all([
api.authorize(firstRequest),
api.authorize(secondRequest),
]);
expect(firstResponse.result).toBe(AuthorizeResult.ALLOW);
expect(secondResponse.result).toBe(AuthorizeResult.DENY);
expect(authorizeSpy).toHaveBeenCalledTimes(1);
expect(authorizeSpy).toHaveBeenCalledWith(
[firstRequest, secondRequest],
expect.anything(),
);
});
it('should not cache requests across ticks', async () => {
const authorizeSpy = jest
.spyOn(PermissionClient.prototype, 'authorize')
.mockResolvedValue([{ result: AuthorizeResult.ALLOW }]);
const identityApi = mockApis.identity();
const credentialsSpy = jest
.spyOn(identityApi, 'getCredentials')
.mockResolvedValueOnce({ token: 'first-token' })
.mockResolvedValueOnce({ token: 'second-token' });
const api = IdentityPermissionApi.create({
config: new ConfigReader({}),
discovery: mockApis.discovery(),
identity: identityApi,
});
const request = { permission };
await api.authorize(request);
await api.authorize(request);
expect(authorizeSpy).toHaveBeenCalledTimes(2);
expect(credentialsSpy).toHaveBeenCalledTimes(2);
expect(authorizeSpy).toHaveBeenNthCalledWith(
1,
[request],
expect.objectContaining({ token: 'first-token' }),
);
expect(authorizeSpy).toHaveBeenNthCalledWith(
2,
[request],
expect.objectContaining({ token: 'second-token' }),
);
});
});
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import DataLoader from 'dataloader';
import { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api';
import { PermissionApi } from './PermissionApi';
import {
@@ -24,20 +25,30 @@ import {
import { Config } from '@backstage/config';
/**
* The default implementation of the PermissionApi, which simply calls the authorize method of the given
* {@link @backstage/plugin-permission-common#PermissionClient}.
* The default implementation of the PermissionApi, which batches calls to
* {@link @backstage/plugin-permission-common#PermissionClient} that are made
* within the same microtask into a single HTTP request.
* @public
*/
export class IdentityPermissionApi implements PermissionApi {
private readonly permissionClient: PermissionClient;
private readonly identityApi: IdentityApi;
private readonly loader: DataLoader<
AuthorizePermissionRequest,
AuthorizePermissionResponse
>;
private constructor(
permissionClient: PermissionClient,
identityApi: IdentityApi,
) {
this.permissionClient = permissionClient;
this.identityApi = identityApi;
this.loader = new DataLoader(
async (requests: readonly AuthorizePermissionRequest[]) => {
const credentials = await identityApi.getCredentials();
return permissionClient.authorize([...requests], credentials);
},
{
cache: false,
},
);
}
static create(options: {
@@ -50,13 +61,12 @@ export class IdentityPermissionApi implements PermissionApi {
return new IdentityPermissionApi(permissionClient, identity);
}
async authorize(
request: AuthorizePermissionRequest,
): Promise<AuthorizePermissionResponse>;
async authorize(
request: AuthorizePermissionRequest,
): Promise<AuthorizePermissionResponse> {
const response = await this.permissionClient.authorize(
[request],
await this.identityApi.getCredentials(),
);
return response[0];
return await this.loader.load(request);
}
}
@@ -154,7 +154,7 @@ export const createGitlabRepoPushAction: (options: {
sourcePath?: string | undefined;
targetPath?: string | undefined;
token?: string | undefined;
commitAction?: 'auto' | 'update' | 'delete' | 'create' | undefined;
commitAction?: 'auto' | 'update' | 'create' | 'delete' | undefined;
},
{
projectid: string;
@@ -271,7 +271,7 @@ export const createPublishGitlabMergeRequestAction: (options: {
sourcePath?: string | undefined;
targetPath?: string | undefined;
token?: string | undefined;
commitAction?: 'auto' | 'update' | 'delete' | 'create' | 'skip' | undefined;
commitAction?: 'auto' | 'update' | 'create' | 'delete' | 'skip' | undefined;
projectid?: string | undefined;
removeSourceBranch?: boolean | undefined;
assignee?: string | undefined;
@@ -45,6 +45,7 @@ describe('SearchFilterBlueprint', () => {
"configSchema": undefined,
"disabled": false,
"factory": [Function],
"if": undefined,
"inputs": {},
"kind": "search-filter",
"name": "test",
@@ -47,6 +47,7 @@ describe('SearchFilterResultTypeBlueprint', () => {
"configSchema": undefined,
"disabled": false,
"factory": [Function],
"if": undefined,
"inputs": {},
"kind": "search-filter-result-type",
"name": "test",
@@ -60,6 +60,7 @@ describe('SearchResultListItemBlueprint', () => {
},
"disabled": false,
"factory": [Function],
"if": undefined,
"inputs": {},
"kind": "search-result-list-item",
"name": "test",
+10
View File
@@ -3359,6 +3359,7 @@ __metadata:
"@backstage/core-app-api": "workspace:^"
"@backstage/core-plugin-api": "workspace:^"
"@backstage/errors": "workspace:^"
"@backstage/filter-predicates": "workspace:^"
"@backstage/frontend-app-api": "workspace:^"
"@backstage/frontend-plugin-api": "workspace:^"
"@backstage/frontend-test-utils": "workspace:^"
@@ -3628,10 +3629,12 @@ __metadata:
"@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/frontend-test-utils": "workspace:^"
"@backstage/plugin-app": "workspace:^"
"@backstage/plugin-permission-common": "workspace:^"
"@backstage/test-utils": "workspace:^"
"@backstage/types": "workspace:^"
"@backstage/version-bridge": "workspace:^"
@@ -3667,6 +3670,8 @@ __metadata:
"@backstage/frontend-plugin-api": "workspace:^"
"@backstage/plugin-app": "workspace:^"
"@backstage/plugin-app-react": "workspace:^"
"@backstage/plugin-permission-common": "workspace:^"
"@backstage/plugin-permission-react": "workspace:^"
"@backstage/test-utils": "workspace:^"
"@react-hookz/web": "npm:^24.0.0"
"@testing-library/jest-dom": "npm:^6.0.0"
@@ -3752,6 +3757,7 @@ __metadata:
"@backstage/cli": "workspace:^"
"@backstage/config": "workspace:^"
"@backstage/errors": "workspace:^"
"@backstage/filter-predicates": "workspace:^"
"@backstage/frontend-app-api": "workspace:^"
"@backstage/frontend-test-utils": "workspace:^"
"@backstage/test-utils": "workspace:^"
@@ -3785,6 +3791,7 @@ __metadata:
"@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:^"
@@ -4096,6 +4103,7 @@ __metadata:
"@backstage/core-components": "workspace:^"
"@backstage/core-plugin-api": "workspace:^"
"@backstage/dev-utils": "workspace:^"
"@backstage/filter-predicates": "workspace:^"
"@backstage/frontend-defaults": "workspace:^"
"@backstage/frontend-plugin-api": "workspace:^"
"@backstage/frontend-test-utils": "workspace:^"
@@ -6451,6 +6459,7 @@ __metadata:
"@testing-library/jest-dom": "npm:^6.0.0"
"@testing-library/react": "npm:^16.0.0"
"@types/react": "npm:^18.0.0"
dataloader: "npm:^2.0.0"
react: "npm:^18.0.2"
react-dom: "npm:^18.0.2"
react-router-dom: "npm:^6.30.2"
@@ -9941,6 +9950,7 @@ __metadata:
resolution: "@internal/frontend@workspace:packages/frontend-internal"
dependencies:
"@backstage/cli": "workspace:^"
"@backstage/filter-predicates": "workspace:^"
"@backstage/frontend-app-api": "workspace:^"
"@backstage/frontend-plugin-api": "workspace:^"
"@backstage/frontend-test-utils": "workspace:^"