Merge master and update toastApiRef to new ApiRef pattern

Resolve merge conflicts from master introducing the new ApiRef_2 type
pattern with `.with()` builder. Update toastApiRef to use the same
`createApiRef<T>().with({ id, pluginId })` pattern as all other API
refs, and regenerate API reports.

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
Made-with: Cursor
This commit is contained in:
Patrik Oldsberg
2026-03-17 12:28:51 +01:00
190 changed files with 8385 additions and 978 deletions
+336 -2
View File
@@ -23,6 +23,9 @@ import {
PageBlueprint,
FrontendPluginInfo,
useAppNode,
createExtensionBlueprint,
createExtensionInput,
coreExtensionData,
} from '@backstage/frontend-plugin-api';
import { useEffect, useState } from 'react';
import { Route, Routes } from 'react-router-dom';
@@ -65,7 +68,8 @@ const IndexPage = PageBlueprint.make({
const page1Link = useRouteRef(page1RouteRef);
return (
<div>
op
<h1>Example Pages Plugin</h1>
<h2>Navigation</h2>
{page1Link && (
<div>
<Link to={page1Link()}>Page 1</Link>
@@ -83,6 +87,54 @@ const IndexPage = PageBlueprint.make({
<div>
<Link to="/settings">Settings</Link>
</div>
<h2>Permission Enablement Examples</h2>
<p>
The following pages demonstrate conditional extension enablement
via the <code>if</code> predicate using permissions. They will
only appear when the user has the required permissions.
</p>
<ul>
<li>
<Link to="/permission-gated-example">
Permission Gated Example
</Link>{' '}
requires <code>catalog.entity.create</code>
</li>
<li>
<Link to="/permission-card-example">
Permission Card Example
</Link>{' '}
a page that is always visible, but individual cards on it are
toggled by permissions
</li>
</ul>
<h2>Feature Flag Enablement Examples</h2>
<p>
The following pages demonstrate conditional extension enablement
via the <code>if</code> predicate. They will only appear in the
router tree when their conditions are satisfied. Toggle the
relevant feature flags in <Link to="/settings">Settings</Link>,
then refresh the app to see the pages appear.
</p>
<ul>
<li>
<Link to="/feature-flag-example">Feature Flag Example</Link>
requires the <code>experimental-features</code> flag
</li>
<li>
<Link to="/all-flags-example">All Flags Example</Link>
requires <em>both</em> <code>experimental-features</code> and{' '}
<code>advanced-features</code> (<code>$all</code>)
</li>
<li>
<Link to="/any-flag-example">Any Flag Example</Link> requires{' '}
<em>either</em> <code>experimental-features</code> or{' '}
<code>beta-access</code> (<code>$any</code>)
</li>
</ul>
<PluginInfo />
</div>
);
@@ -150,6 +202,270 @@ const ExternalPage = PageBlueprint.make({
},
});
// Example: Page enabled only when a single feature flag is active.
//
// The `if` predicate is evaluated once at app startup (before the router
// tree is built), so this page simply won't exist in the app until the flag is
// toggled and the page is refreshed.
//
// To test: enable the 'experimental-features' flag in Settings, then refresh.
const FeatureFlagPage = PageBlueprint.make({
name: 'featureFlagExample',
params: {
path: '/feature-flag-example',
loader: async () => {
const Component = () => {
const indexLink = useRouteRef(indexRouteRef);
return (
<div>
<h1>Feature Flag Enabled Page</h1>
<p>
This page is only present in the app when the{' '}
<code>experimental-features</code> feature flag is active.
</p>
<p>
It uses a simple{' '}
<code>
{'{ featureFlags: { $contains: "experimental-features" } }'}
</code>{' '}
predicate.
</p>
{indexLink && <Link to={indexLink()}>Go back</Link>}
</div>
);
};
return <Component />;
},
},
if: { featureFlags: { $contains: 'experimental-features' } },
});
// Example: Page enabled only when ALL of several feature flags are active.
//
// The $all operator requires every nested predicate to be satisfied. This page
// won't appear unless both 'experimental-features' and 'advanced-features' are
// enabled at the same time.
//
// To test: enable BOTH flags in Settings, then refresh.
const AllFlagsPage = PageBlueprint.make({
name: 'allFlagsExample',
params: {
path: '/all-flags-example',
loader: async () => {
const Component = () => {
const indexLink = useRouteRef(indexRouteRef);
return (
<div>
<h1>All Flags Required Page</h1>
<p>
This page requires <em>both</em>{' '}
<code>experimental-features</code> and{' '}
<code>advanced-features</code> to be active simultaneously.
</p>
<p>
It uses a <code>$all</code> predicate to AND the two conditions
together.
</p>
{indexLink && <Link to={indexLink()}>Go back</Link>}
</div>
);
};
return <Component />;
},
},
if: {
$all: [
{ featureFlags: { $contains: 'experimental-features' } },
{ featureFlags: { $contains: 'advanced-features' } },
],
},
});
// Example: Page enabled when ANY one of several feature flags is active.
//
// The $any operator is satisfied as soon as at least one nested predicate
// matches. Enabling either 'experimental-features' or 'beta-access' will make
// this page appear.
//
// To test: enable at least one of the two flags in Settings, then refresh.
const AnyFlagPage = PageBlueprint.make({
name: 'anyFlagExample',
params: {
path: '/any-flag-example',
loader: async () => {
const Component = () => {
const indexLink = useRouteRef(indexRouteRef);
return (
<div>
<h1>Any Flag Sufficient Page</h1>
<p>
This page appears when <em>either</em>{' '}
<code>experimental-features</code> or <code>beta-access</code> is
active.
</p>
<p>
It uses a <code>$any</code> predicate to OR the two conditions
together.
</p>
{indexLink && <Link to={indexLink()}>Go back</Link>}
</div>
);
};
return <Component />;
},
},
if: {
$any: [
{ featureFlags: { $contains: 'experimental-features' } },
{ featureFlags: { $contains: 'beta-access' } },
],
},
});
// Blueprint for cards that attach to the PermissionCardPage below.
//
// Each card receives a title and description and renders a simple bordered card.
// Individual card instances can be selectively enabled via the `if`
// predicate, so only the cards the user is allowed to see will be instantiated.
const PermissionExampleCardBlueprint = createExtensionBlueprint({
kind: 'permission-example-card',
attachTo: { id: 'page:pages/permissionCardExample', input: 'cards' },
output: [coreExtensionData.reactElement],
*factory(params: { title: string; description: string }) {
yield coreExtensionData.reactElement(
<div
style={{
border: '1px solid #ccc',
borderRadius: '4px',
padding: '1rem',
}}
>
<h3 style={{ marginTop: 0 }}>{params.title}</h3>
<p style={{ marginBottom: 0 }}>{params.description}</p>
</div>,
);
},
});
// Example: Page with cards that are individually toggled by permissions.
//
// The page itself is always present. What changes is which cards are
// instantiated inside it — each card declares its own `enabled` predicate
// and is only wired into the page if that predicate is satisfied at startup.
//
// To test: make sure you do NOT have the catalog.entity.create permission and
// refresh the page — the "Restricted Card" below should disappear.
const PermissionCardPage = PageBlueprint.makeWithOverrides({
name: 'permissionCardExample',
inputs: {
cards: createExtensionInput([coreExtensionData.reactElement]),
},
factory(originalFactory, { inputs }) {
return originalFactory({
path: '/permission-card-example',
loader: async () => {
const Component = () => {
const indexLink = useRouteRef(indexRouteRef);
const cards = inputs.cards.map(card =>
card.get(coreExtensionData.reactElement),
);
return (
<div>
<h1>Permission-Gated Card Example</h1>
<p>
This page is always visible. The cards below are individually
gated each one declares its own{' '}
<code>{'if: { permissions: { $contains: "..." } }'}</code>{' '}
predicate. Cards whose predicate fails are never instantiated,
so they simply won't appear here.
</p>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
gap: '1rem',
}}
>
{cards.length > 0 ? (
cards
) : (
<p>
No cards are visible — you may lack the required
permissions.
</p>
)}
</div>
{indexLink && <Link to={indexLink()}>Go back</Link>}
</div>
);
};
return <Component />;
},
});
},
});
// Always-visible card — no predicate, every user sees this.
const PublicCard = PermissionExampleCardBlueprint.make({
name: 'public',
params: {
title: 'Public Card',
description: 'This card is visible to everyone regardless of permissions.',
},
});
// Permission-gated card — only instantiated when the user has
// the catalog.entity.create permission.
const RestrictedCard = PermissionExampleCardBlueprint.make({
name: 'restricted',
params: {
title: 'Restricted Card',
description:
'This card is only visible to users who have the catalog.entity.create permission.',
},
if: { permissions: { $contains: 'catalog.entity.create' } },
});
// Feature flag-gated card — only instantiated when the user has
// the experimental-card FF enabled.
const FeatureFlagCard = PermissionExampleCardBlueprint.make({
name: 'feature-flag',
params: {
title: 'Feature Flagged Card',
description: 'Visible only with the experimental-card FF active.',
},
if: { featureFlags: { $contains: 'experimental-card' } },
});
// Example: Page enabled only when the user is allowed to create catalog entities.
//
// The `if` predicate is evaluated once at app startup (after sign-in),
// so this page simply won't exist in the router tree if the user lacks the
// required permission.
const PermissionGatedPage = PageBlueprint.make({
name: 'permissionGatedExample',
params: {
path: '/permission-gated-example',
loader: async () => {
const Component = () => {
const indexLink = useRouteRef(indexRouteRef);
return (
<div>
<h1>Permission Gated Page</h1>
<p>
This page is only present when the user has the{' '}
<code>catalog.entity.create</code> permission.
</p>
{indexLink && <Link to={indexLink()}>Go back</Link>}
</div>
);
};
return <Component />;
},
},
if: { permissions: { $contains: 'catalog.entity.create' } },
});
export const pagesPlugin = createFrontendPlugin({
pluginId: 'pages',
// routes: {
@@ -170,5 +486,23 @@ export const pagesPlugin = createFrontendPlugin({
externalRoutes: {
pageX: externalPageXRouteRef,
},
extensions: [IndexPage, Page1, ExternalPage],
featureFlags: [
{ name: 'experimental-features' },
{ name: 'advanced-features' },
{ name: 'beta-access' },
{ name: 'experimental-card' },
],
extensions: [
IndexPage,
Page1,
ExternalPage,
FeatureFlagPage,
AllFlagsPage,
AnyFlagPage,
PermissionCardPage,
PublicCard,
RestrictedCard,
PermissionGatedPage,
FeatureFlagCard,
],
});
@@ -131,15 +131,9 @@ export class DefaultActionsRegistryService implements ActionsRegistryService {
'/.backstage/actions/v1/actions/:actionId/invoke',
async (req, res) => {
const credentials = await this.httpAuth.credentials(req);
if (this.auth.isPrincipal(credentials, 'user')) {
if (!credentials.principal.actor) {
throw new NotAllowedError(
`Actions must be invoked by a service, not a user`,
);
}
} else if (this.auth.isPrincipal(credentials, 'none')) {
if (this.auth.isPrincipal(credentials, 'none')) {
throw new NotAllowedError(
`Actions must be invoked by a service, not an anonymous request`,
`Actions must be invoked by an authenticated principal, not an anonymous request`,
);
}
@@ -443,7 +443,7 @@ describe('actionsRegistryServiceFactory', () => {
});
});
it('should throw an error if the action is invoked by a user', async () => {
it('should allow actions to be invoked by a user', async () => {
const testServices = [
actionsRegistryServiceFactory,
httpRouterServiceFactory,
@@ -464,12 +464,8 @@ describe('actionsRegistryServiceFactory', () => {
name: 'test',
});
expect(status).toBe(403);
expect(body).toMatchObject({
error: {
message: 'Actions must be invoked by a service, not a user',
},
});
expect(status).toBe(200);
expect(body).toMatchObject({ output: { ok: true } });
});
it('should validate the output of the action if provided', async () => {
+1
View File
@@ -30,6 +30,7 @@
"postpack": "backstage-cli package postpack"
},
"dependencies": {
"@backstage/cli-module-actions": "workspace:^",
"@backstage/cli-module-auth": "workspace:^",
"@backstage/cli-module-build": "workspace:^",
"@backstage/cli-module-config": "workspace:^",
+2
View File
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import actions from '@backstage/cli-module-actions';
import auth from '@backstage/cli-module-auth';
import build from '@backstage/cli-module-build';
import config from '@backstage/cli-module-config';
@@ -31,6 +32,7 @@ import translations from '@backstage/cli-module-translations';
* @public
*/
export default [
actions,
auth,
build,
config,
+1
View File
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
@@ -0,0 +1,32 @@
#!/usr/bin/env node
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const path = require('node:path');
/* eslint-disable-next-line no-restricted-syntax */
const isLocal = require('node:fs').existsSync(
path.resolve(__dirname, '../src'),
);
if (isLocal) {
require('@backstage/cli-node/config/nodeTransform.cjs');
}
const { runCliModule } = require('@backstage/cli-node');
const cliModule = require(isLocal ? '../src/index' : '..').default;
const pkg = require('../package.json');
runCliModule({ module: cliModule, name: pkg.name, version: pkg.version });
@@ -0,0 +1,10 @@
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: backstage-cli-module-actions
title: '@backstage/cli-module-actions'
description: CLI module for executing distributed actions
spec:
lifecycle: experimental
type: backstage-cli-module
owner: tooling-maintainers
+94
View File
@@ -0,0 +1,94 @@
## CLI Report file for "@backstage/cli-module-actions"
> Do not edit this file. It is a report generated by `yarn build:api-reports`
### `backstage-cli-module-actions`
```
Usage: @backstage/cli-module-actions [options] [command]
Options:
-V, --version
-h, --help
Commands:
actions [command]
help [command]
```
### `backstage-cli-module-actions actions`
```
Usage: @backstage/cli-module-actions actions [options] [command] [command]
Options:
-h, --help
Commands:
execute
help [command]
list
sources [command]
```
### `backstage-cli-module-actions actions execute`
```
Usage: @backstage/cli-module-actions actions execute
Options:
--instance <string>
-h, --help
```
### `backstage-cli-module-actions actions list`
```
Usage: @backstage/cli-module-actions actions list
Options:
--instance <string>
-h, --help
```
### `backstage-cli-module-actions actions sources`
```
Usage: @backstage/cli-module-actions actions sources [options] [command] [command]
Options:
-h, --help
Commands:
add
help [command]
list
remove
```
### `backstage-cli-module-actions actions sources add`
```
Usage: @backstage/cli-module-actions actions sources add
Options:
-h, --help
```
### `backstage-cli-module-actions actions sources list`
```
Usage: @backstage/cli-module-actions actions sources list
Options:
-h, --help
```
### `backstage-cli-module-actions actions sources remove`
```
Usage: @backstage/cli-module-actions actions sources remove
Options:
-h, --help
```
+44
View File
@@ -0,0 +1,44 @@
{
"name": "@backstage/cli-module-actions",
"version": "0.0.0",
"description": "CLI module for executing distributed actions",
"backstage": {
"role": "cli-module"
},
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "packages/cli-module-actions"
},
"license": "Apache-2.0",
"main": "src/index.ts",
"types": "src/index.ts",
"files": [
"dist",
"bin"
],
"bin": "bin/backstage-cli-module-actions",
"scripts": {
"build": "backstage-cli package build",
"clean": "backstage-cli package clean",
"lint": "backstage-cli package lint",
"prepack": "backstage-cli package prepack",
"postpack": "backstage-cli package postpack",
"test": "backstage-cli package test"
},
"dependencies": {
"@backstage/cli-module-auth": "workspace:^",
"@backstage/cli-node": "workspace:^",
"cleye": "^2.3.0"
},
"devDependencies": {
"@backstage/backend-test-utils": "workspace:^",
"@backstage/cli": "workspace:^"
}
}
+13
View File
@@ -0,0 +1,13 @@
## API Report File for "@backstage/cli-module-actions"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { CliModule } from '@backstage/cli-node';
// @public (undocumented)
const _default: CliModule;
export default _default;
// (No @packageDocumentation comment for this package)
```
@@ -0,0 +1,109 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { cli } from 'cleye';
import type { CliCommandContext } from '@backstage/cli-node';
import { ActionsClient } from '../lib/ActionsClient';
import { schemaToFlags } from '../lib/schemaToFlags';
import { resolveAuth } from '../lib/resolveAuth';
export default async ({ args, info }: CliCommandContext) => {
if (args.includes('--help') || args.includes('-h')) {
cli(
{
help: info,
parameters: ['<action-id>'],
flags: {
instance: {
type: String,
description: 'Name of the instance to use',
},
},
},
undefined,
args,
);
return;
}
const instanceIdx = args.indexOf('--instance');
const instanceFlag = instanceIdx !== -1 ? args[instanceIdx + 1] : undefined;
// Skip flag names, flag values (the argument after a known flag), and
// the --instance value position so we only pick up positional arguments.
const skipIndices = new Set<number>();
if (instanceIdx !== -1) {
skipIndices.add(instanceIdx);
skipIndices.add(instanceIdx + 1);
}
let actionId: string | undefined;
let actionIdIdx = -1;
for (let i = 0; i < args.length; i++) {
if (!skipIndices.has(i) && !args[i].startsWith('-')) {
actionId = args[i];
actionIdIdx = i;
break;
}
}
if (!actionId) {
process.stderr.write('Usage: actions execute <action-id> [flags]\n');
process.exit(1);
}
const { accessToken, instance } = await resolveAuth(instanceFlag);
const client = new ActionsClient(instance.baseUrl, accessToken);
const actions = await client.listForPlugin(actionId);
const action = actions.find(a => a.id === actionId);
if (!action) {
throw new Error(
`Action "${actionId}" not found. Run "actions list" to see available actions.`,
);
}
const schemaFlags = schemaToFlags(action.schema.input as any);
const flagArgs = args.filter((_, i) => i !== actionIdIdx);
const { flags } = cli(
{
help: info,
flags: {
instance: {
type: String,
description: 'Name of the instance to use',
},
...schemaFlags,
},
},
undefined,
flagArgs,
);
const allFlags = flags as Record<string, unknown>;
const input: Record<string, unknown> = {};
for (const [key, value] of Object.entries(allFlags)) {
if (key !== 'instance' && value !== undefined) {
input[key] = value;
}
}
const output = await client.execute(actionId, input);
process.stdout.write(`${JSON.stringify(output, null, 2)}\n`);
};
@@ -0,0 +1,62 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { cli } from 'cleye';
import type { CliCommandContext } from '@backstage/cli-node';
import { ActionsClient } from '../lib/ActionsClient';
import { resolveAuth } from '../lib/resolveAuth';
export default async ({ args, info }: CliCommandContext) => {
const {
flags: { instance: instanceFlag },
} = cli(
{
help: info,
flags: {
instance: {
type: String,
description: 'Name of the instance to use',
},
},
},
undefined,
args,
);
const { accessToken, pluginSources, instance } = await resolveAuth(
instanceFlag,
);
if (!pluginSources.length) {
process.stderr.write(
'No plugin sources configured. Run "actions sources add <plugin-id>" to add one.\n',
);
return;
}
const client = new ActionsClient(instance.baseUrl, accessToken);
const actions = await client.list(pluginSources);
if (!actions.length) {
process.stderr.write('No actions found.\n');
return;
}
for (const action of actions) {
const desc = action.description ? ` - ${action.description}` : '';
process.stdout.write(`${action.id}${desc}\n`);
}
};
@@ -0,0 +1,54 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { cli } from 'cleye';
import type { CliCommandContext } from '@backstage/cli-node';
import {
getSelectedInstance,
getInstanceConfig,
updateInstanceConfig,
} from '@backstage/cli-module-auth';
export default async ({ args, info }: CliCommandContext) => {
const parsed = cli(
{
help: info,
parameters: ['<plugin-id>'],
},
undefined,
args,
);
const pluginId = parsed._[0];
const instance = await getSelectedInstance();
const existing =
(await getInstanceConfig<string[]>(instance.name, 'pluginSources')) ?? [];
if (existing.includes(pluginId)) {
process.stderr.write(
`Plugin source "${pluginId}" is already configured.\n`,
);
return;
}
await updateInstanceConfig(instance.name, 'pluginSources', [
...existing,
pluginId,
]);
process.stdout.write(`Added plugin source "${pluginId}".\n`);
};
@@ -0,0 +1,39 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { cli } from 'cleye';
import type { CliCommandContext } from '@backstage/cli-node';
import {
getSelectedInstance,
getInstanceConfig,
} from '@backstage/cli-module-auth';
export default async ({ args, info }: CliCommandContext) => {
cli({ help: info }, undefined, args);
const instance = await getSelectedInstance();
const sources =
(await getInstanceConfig<string[]>(instance.name, 'pluginSources')) ?? [];
if (!sources.length) {
process.stderr.write('No plugin sources configured.\n');
return;
}
for (const source of sources) {
process.stdout.write(`${source}\n`);
}
};
@@ -0,0 +1,53 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { cli } from 'cleye';
import type { CliCommandContext } from '@backstage/cli-node';
import {
getSelectedInstance,
getInstanceConfig,
updateInstanceConfig,
} from '@backstage/cli-module-auth';
export default async ({ args, info }: CliCommandContext) => {
const parsed = cli(
{
help: info,
parameters: ['<plugin-id>'],
},
undefined,
args,
);
const pluginId = parsed._[0];
const instance = await getSelectedInstance();
const existing =
(await getInstanceConfig<string[]>(instance.name, 'pluginSources')) ?? [];
if (!existing.includes(pluginId)) {
process.stderr.write(`Plugin source "${pluginId}" is not configured.\n`);
return;
}
await updateInstanceConfig(
instance.name,
'pluginSources',
existing.filter(s => s !== pluginId),
);
process.stdout.write(`Removed plugin source "${pluginId}".\n`);
};
+49
View File
@@ -0,0 +1,49 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createCliModule } from '@backstage/cli-node';
import packageJson from '../package.json';
export default createCliModule({
packageJson,
init: async reg => {
reg.addCommand({
path: ['actions', 'list'],
description: 'List available actions from configured plugin sources',
execute: { loader: () => import('./commands/list') },
});
reg.addCommand({
path: ['actions', 'execute'],
description: 'Execute an action',
execute: { loader: () => import('./commands/execute') },
});
reg.addCommand({
path: ['actions', 'sources', 'add'],
description: 'Add a plugin source for action discovery',
execute: { loader: () => import('./commands/sourcesAdd') },
});
reg.addCommand({
path: ['actions', 'sources', 'list'],
description: 'List configured plugin sources',
execute: { loader: () => import('./commands/sourcesList') },
});
reg.addCommand({
path: ['actions', 'sources', 'remove'],
description: 'Remove a plugin source',
execute: { loader: () => import('./commands/sourcesRemove') },
});
},
});
@@ -0,0 +1,129 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ActionsClient } from './ActionsClient';
import { httpJson } from '@backstage/cli-module-auth';
jest.mock('@backstage/cli-module-auth', () => ({
httpJson: jest.fn(),
}));
const mockHttpJson = httpJson as jest.MockedFunction<typeof httpJson>;
describe('ActionsClient', () => {
const baseUrl = 'https://backstage.example.com';
const accessToken = 'test-token';
let client: ActionsClient;
beforeEach(() => {
jest.clearAllMocks();
client = new ActionsClient(baseUrl, accessToken);
});
describe('list', () => {
it('returns empty array when no plugin sources provided', async () => {
const result = await client.list([]);
expect(result).toEqual([]);
expect(mockHttpJson).not.toHaveBeenCalled();
});
it('fetches actions from each plugin source', async () => {
const catalogActions = [
{
id: 'catalog:refresh',
name: 'refresh',
schema: { input: {}, output: {} },
},
];
const scaffolderActions = [
{
id: 'scaffolder:run',
name: 'run',
schema: { input: {}, output: {} },
},
];
mockHttpJson
.mockResolvedValueOnce({ actions: catalogActions })
.mockResolvedValueOnce({ actions: scaffolderActions });
const result = await client.list(['catalog', 'scaffolder']);
expect(mockHttpJson).toHaveBeenCalledTimes(2);
expect(mockHttpJson).toHaveBeenCalledWith(
'https://backstage.example.com/api/catalog/.backstage/actions/v1/actions',
expect.objectContaining({
headers: { Authorization: 'Bearer test-token' },
}),
);
expect(mockHttpJson).toHaveBeenCalledWith(
'https://backstage.example.com/api/scaffolder/.backstage/actions/v1/actions',
expect.objectContaining({
headers: { Authorization: 'Bearer test-token' },
}),
);
expect(result).toEqual([...catalogActions, ...scaffolderActions]);
});
it('propagates errors from httpJson', async () => {
mockHttpJson.mockRejectedValue(new Error('Network error'));
await expect(client.list(['catalog'])).rejects.toThrow('Network error');
});
});
describe('execute', () => {
it('posts to the correct invoke endpoint', async () => {
mockHttpJson.mockResolvedValue({ output: { result: 'ok' } });
const output = await client.execute('catalog:refresh', {
entityRef: 'component:default/foo',
});
expect(mockHttpJson).toHaveBeenCalledWith(
'https://backstage.example.com/api/catalog/.backstage/actions/v1/actions/catalog%3Arefresh/invoke',
expect.objectContaining({
method: 'POST',
headers: { Authorization: 'Bearer test-token' },
body: { entityRef: 'component:default/foo' },
}),
);
expect(output).toEqual({ result: 'ok' });
});
it('sends empty object when no input provided', async () => {
mockHttpJson.mockResolvedValue({ output: null });
await client.execute('catalog:refresh');
expect(mockHttpJson).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ body: {} }),
);
});
it('extracts pluginId from actionId to build correct URL', async () => {
mockHttpJson.mockResolvedValue({ output: {} });
await client.execute('my-plugin:some-action');
expect(mockHttpJson).toHaveBeenCalledWith(
expect.stringContaining('/api/my-plugin/'),
expect.any(Object),
);
});
});
});
@@ -0,0 +1,98 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { httpJson } from '@backstage/cli-module-auth';
export type ActionDef = {
id: string;
name: string;
description?: string;
schema: {
input: object;
output: object;
};
};
type ListActionsResponse = {
actions: ActionDef[];
};
type InvokeResponse = {
output: unknown;
};
function extractPluginId(actionId: string): string {
const colonIndex = actionId.indexOf(':');
if (colonIndex === -1) {
throw new Error(
`Invalid action ID "${actionId}". Expected format "pluginId:actionName".`,
);
}
return actionId.substring(0, colonIndex);
}
function pluginActionsUrl(baseUrl: string, pluginId: string): string {
return new URL(
`/api/${encodeURIComponent(pluginId)}/.backstage/actions/v1/actions`,
baseUrl,
).toString();
}
export class ActionsClient {
constructor(
private readonly baseUrl: string,
private readonly accessToken: string,
) {}
async list(pluginSources: string[]): Promise<ActionDef[]> {
const results: ActionDef[] = [];
for (const pluginId of pluginSources) {
const url = pluginActionsUrl(this.baseUrl, pluginId);
const response = await httpJson<ListActionsResponse>(url, {
headers: { Authorization: `Bearer ${this.accessToken}` },
signal: AbortSignal.timeout(30_000),
});
results.push(...response.actions);
}
return results;
}
async listForPlugin(actionId: string): Promise<ActionDef[]> {
const pluginId = extractPluginId(actionId);
return this.list([pluginId]);
}
async execute(actionId: string, input?: unknown): Promise<unknown> {
const pluginId = extractPluginId(actionId);
const url = `${pluginActionsUrl(
this.baseUrl,
pluginId,
)}/${encodeURIComponent(actionId)}/invoke`;
const response = await httpJson<InvokeResponse>(url, {
method: 'POST',
headers: { Authorization: `Bearer ${this.accessToken}` },
body: input ?? {},
signal: AbortSignal.timeout(30_000),
});
return response.output;
}
}
@@ -0,0 +1,124 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { resolveAuth } from './resolveAuth';
import {
getSelectedInstance,
getInstanceConfig,
accessTokenNeedsRefresh,
refreshAccessToken,
getSecretStore,
type StoredInstance,
} from '@backstage/cli-module-auth';
jest.mock('@backstage/cli-module-auth', () => ({
getSelectedInstance: jest.fn(),
getInstanceConfig: jest.fn(),
accessTokenNeedsRefresh: jest.fn(),
refreshAccessToken: jest.fn(),
getSecretStore: jest.fn(),
}));
const mockGetSelectedInstance = getSelectedInstance as jest.MockedFunction<
typeof getSelectedInstance
>;
const mockGetInstanceConfig = getInstanceConfig as jest.MockedFunction<
typeof getInstanceConfig
>;
const mockAccessTokenNeedsRefresh =
accessTokenNeedsRefresh as jest.MockedFunction<
typeof accessTokenNeedsRefresh
>;
const mockRefreshAccessToken = refreshAccessToken as jest.MockedFunction<
typeof refreshAccessToken
>;
const mockGetSecretStore = getSecretStore as jest.MockedFunction<
typeof getSecretStore
>;
describe('resolveAuth', () => {
const mockInstance: StoredInstance = {
name: 'production',
baseUrl: 'https://backstage.example.com',
clientId: 'my-client',
issuedAt: Date.now(),
accessTokenExpiresAt: Date.now() + 3600_000,
};
const mockSecretStore = {
get: jest.fn(),
set: jest.fn(),
delete: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
mockGetSelectedInstance.mockResolvedValue(mockInstance);
mockAccessTokenNeedsRefresh.mockReturnValue(false);
mockGetSecretStore.mockResolvedValue(mockSecretStore);
mockSecretStore.get.mockResolvedValue('test-access-token');
mockGetInstanceConfig.mockResolvedValue(['catalog', 'scaffolder']);
});
it('resolves auth with the selected instance and stored token', async () => {
const result = await resolveAuth();
expect(mockGetSelectedInstance).toHaveBeenCalledWith(undefined);
expect(mockAccessTokenNeedsRefresh).toHaveBeenCalledWith(mockInstance);
expect(mockRefreshAccessToken).not.toHaveBeenCalled();
expect(result).toEqual({
instance: mockInstance,
accessToken: 'test-access-token',
pluginSources: ['catalog', 'scaffolder'],
});
});
it('passes instance name flag to getSelectedInstance', async () => {
await resolveAuth('staging');
expect(mockGetSelectedInstance).toHaveBeenCalledWith('staging');
});
it('refreshes the access token when it is about to expire', async () => {
const refreshedInstance = {
...mockInstance,
accessTokenExpiresAt: Date.now() + 7200_000,
};
mockAccessTokenNeedsRefresh.mockReturnValue(true);
mockRefreshAccessToken.mockResolvedValue(refreshedInstance);
const result = await resolveAuth();
expect(mockRefreshAccessToken).toHaveBeenCalledWith('production');
expect(result.instance).toBe(refreshedInstance);
});
it('throws when no access token is stored', async () => {
mockSecretStore.get.mockResolvedValue(undefined);
await expect(resolveAuth()).rejects.toThrow(
'No access token found. Run "auth login" to authenticate.',
);
});
it('returns empty plugin sources when none are configured', async () => {
mockGetInstanceConfig.mockResolvedValue(undefined);
const result = await resolveAuth();
expect(result.pluginSources).toEqual([]);
});
});
@@ -0,0 +1,48 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
getSelectedInstance,
getInstanceConfig,
accessTokenNeedsRefresh,
refreshAccessToken,
getSecretStore,
type StoredInstance,
} from '@backstage/cli-module-auth';
export async function resolveAuth(instanceFlag?: string): Promise<{
instance: StoredInstance;
accessToken: string;
pluginSources: string[];
}> {
let instance = await getSelectedInstance(instanceFlag);
if (accessTokenNeedsRefresh(instance)) {
instance = await refreshAccessToken(instance.name);
}
const secretStore = await getSecretStore();
const service = `backstage-cli:auth-instance:${instance.name}`;
const accessToken = await secretStore.get(service, 'accessToken');
if (!accessToken) {
throw new Error('No access token found. Run "auth login" to authenticate.');
}
const pluginSources =
(await getInstanceConfig<string[]>(instance.name, 'pluginSources')) ?? [];
return { instance, accessToken, pluginSources };
}
@@ -0,0 +1,163 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { schemaToFlags } from './schemaToFlags';
describe('schemaToFlags', () => {
it('returns empty object when schema has no properties', () => {
expect(schemaToFlags({})).toEqual({});
expect(schemaToFlags({ properties: {} })).toEqual({});
});
it('converts string properties to String flags', () => {
const flags = schemaToFlags({
properties: {
myProp: { type: 'string', description: 'A string prop' },
},
});
expect(flags).toEqual({
myProp: { type: String, description: 'A string prop' },
});
});
it('converts number and integer properties to Number flags', () => {
const flags = schemaToFlags({
properties: {
count: { type: 'integer' },
amount: { type: 'number', description: 'An amount' },
},
});
expect(flags.count).toEqual({ type: Number, description: undefined });
expect(flags.amount).toEqual({ type: Number, description: 'An amount' });
});
it('converts boolean properties to Boolean flags', () => {
const flags = schemaToFlags({
properties: {
verbose: { type: 'boolean', description: 'Enable verbose output' },
},
});
expect(flags.verbose).toEqual({
type: Boolean,
description: 'Enable verbose output',
});
});
it('skips non-primitive properties like object and array', () => {
const flags = schemaToFlags({
properties: {
name: { type: 'string' },
metadata: { type: 'object' },
tags: { type: 'array' },
},
});
expect(Object.keys(flags)).toEqual(['name']);
});
it('skips properties with no type or composite types', () => {
const flags = schemaToFlags({
properties: {
noType: {},
name: { type: 'string' },
},
});
expect(Object.keys(flags)).toEqual(['name']);
});
it('uses first type when type is an array', () => {
const flags = schemaToFlags({
properties: {
value: { type: ['string', 'null'] },
},
});
expect(flags.value).toEqual({ type: String, description: undefined });
});
it('appends enum values to description', () => {
const flags = schemaToFlags({
properties: {
color: {
type: 'string',
description: 'Pick a color',
enum: ['red', 'green', 'blue'],
},
bare: { type: 'string', enum: ['a', 'b'] },
},
});
expect(flags.color.description).toBe('Pick a color [red, green, blue]');
expect(flags.bare.description).toBe('[a, b]');
});
it('marks required fields in description', () => {
const flags = schemaToFlags({
properties: {
name: { type: 'string', description: 'The name' },
optional: { type: 'string', description: 'Optional field' },
bare: { type: 'string' },
},
required: ['name', 'bare'],
});
expect(flags.name.description).toBe('The name (required)');
expect(flags.optional.description).toBe('Optional field');
expect(flags.bare.description).toBe('(required)');
});
it('applies default values from schema', () => {
const flags = schemaToFlags({
properties: {
count: { type: 'number', default: 10 },
name: { type: 'string' },
},
});
expect(flags.count.default).toBe(10);
expect(flags.name.default).toBeUndefined();
});
it('combines enum and required in description', () => {
const flags = schemaToFlags({
properties: {
env: {
type: 'string',
description: 'Target env',
enum: ['dev', 'prod'],
},
},
required: ['env'],
});
expect(flags.env.description).toBe('Target env [dev, prod] (required)');
});
it('preserves camelCase property names as flag keys', () => {
const flags = schemaToFlags({
properties: {
targetEntityRef: { type: 'string' },
maxResults: { type: 'integer' },
},
});
expect(Object.keys(flags)).toEqual(['targetEntityRef', 'maxResults']);
});
});
@@ -0,0 +1,77 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
type JsonSchemaProperty = {
type?: string | string[];
description?: string;
enum?: unknown[];
default?: unknown;
};
type JsonSchemaObject = {
properties?: Record<string, JsonSchemaProperty>;
required?: string[];
};
type CleyeFlag = {
type: StringConstructor | NumberConstructor | BooleanConstructor;
description?: string;
default?: unknown;
};
export function schemaToFlags(
schema: JsonSchemaObject,
): Record<string, CleyeFlag> {
const flags: Record<string, CleyeFlag> = {};
const required = new Set(schema.required ?? []);
if (!schema.properties) {
return flags;
}
for (const [key, prop] of Object.entries(schema.properties)) {
const rawType = Array.isArray(prop.type) ? prop.type[0] : prop.type;
let flagType: StringConstructor | NumberConstructor | BooleanConstructor;
if (rawType === 'string') {
flagType = String;
} else if (rawType === 'number' || rawType === 'integer') {
flagType = Number;
} else if (rawType === 'boolean') {
flagType = Boolean;
} else {
continue;
}
let desc = prop.description ?? '';
if (prop.enum?.length) {
const values = prop.enum.map(v => String(v)).join(', ');
desc = desc ? `${desc} [${values}]` : `[${values}]`;
}
if (required.has(key)) {
desc = desc ? `${desc} (required)` : '(required)';
}
const flag: CleyeFlag = { type: flagType, description: desc || undefined };
if (prop.default !== undefined) {
flag.default = prop.default;
}
flags[key] = flag;
}
return flags;
}
-1
View File
@@ -35,7 +35,6 @@
"@backstage/cli-node": "workspace:^",
"@backstage/errors": "workspace:^",
"cleye": "^2.3.0",
"cross-fetch": "^4.0.0",
"fs-extra": "^11.2.0",
"glob": "^7.1.7",
"inquirer": "^8.2.0",
+58
View File
@@ -5,9 +5,67 @@
```ts
import { CliModule } from '@backstage/cli-node';
// @public (undocumented)
export function accessTokenNeedsRefresh(instance: StoredInstance): boolean;
// @public (undocumented)
const _default: CliModule;
export default _default;
// @public (undocumented)
export function getInstanceConfig<T = unknown>(
instanceName: string,
key: string,
): Promise<T | undefined>;
// @public (undocumented)
export function getSecretStore(): Promise<SecretStore>;
// @public (undocumented)
export function getSelectedInstance(
instanceName?: string,
): Promise<StoredInstance>;
// @public (undocumented)
export type HttpInit = {
headers?: Record<string, string>;
method?: string;
body?: any;
signal?: AbortSignal;
};
// @public (undocumented)
export function httpJson<T>(url: string, init?: HttpInit): Promise<T>;
// @public (undocumented)
export function refreshAccessToken(
instanceName: string,
): Promise<StoredInstance>;
// @public (undocumented)
export type SecretStore = {
get(service: string, account: string): Promise<string | undefined>;
set(service: string, account: string, secret: string): Promise<void>;
delete(service: string, account: string): Promise<void>;
};
// @public (undocumented)
export type StoredInstance = {
name: string;
baseUrl: string;
clientId: string;
issuedAt: number;
accessTokenExpiresAt: number;
selected?: boolean;
config?: Record<string, unknown>;
};
// @public (undocumented)
export function updateInstanceConfig(
instanceName: string,
key: string,
value: unknown,
): Promise<void>;
// (No @packageDocumentation comment for this package)
```
+14
View File
@@ -52,3 +52,17 @@ export default createCliModule({
});
},
});
/** @public */
export {
getSelectedInstance,
getInstanceConfig,
updateInstanceConfig,
type StoredInstance,
} from './lib/storage';
/** @public */
export { accessTokenNeedsRefresh, refreshAccessToken } from './lib/auth';
/** @public */
export { getSecretStore, type SecretStore } from './lib/secretStore';
/** @public */
export { httpJson, type HttpInit } from './lib/http';
+2
View File
@@ -31,10 +31,12 @@ const TokenResponseSchema = z.object({
refresh_token: z.string().min(1).optional(),
});
/** @public */
export function accessTokenNeedsRefresh(instance: StoredInstance): boolean {
return instance.accessTokenExpiresAt <= Date.now() + 2 * 60_000; // 2 minutes before expiration
}
/** @public */
export async function refreshAccessToken(
instanceName: string,
): Promise<StoredInstance> {
@@ -14,12 +14,13 @@
* limitations under the License.
*/
import fetch from 'cross-fetch';
import { httpJson } from './http';
jest.mock('cross-fetch');
const mockFetch = jest.fn() as jest.MockedFunction<typeof global.fetch>;
const mockFetch = fetch as jest.MockedFunction<typeof fetch>;
beforeEach(() => {
global.fetch = mockFetch;
});
describe('http', () => {
beforeEach(() => {
+3 -2
View File
@@ -14,16 +14,17 @@
* limitations under the License.
*/
import fetch from 'cross-fetch';
import { ResponseError } from '@backstage/errors';
type HttpInit = {
/** @public */
export type HttpInit = {
headers?: Record<string, string>;
method?: string;
body?: any;
signal?: AbortSignal;
};
/** @public */
export async function httpJson<T>(url: string, init?: HttpInit): Promise<T> {
const res = await fetch(url, {
...init,
@@ -18,7 +18,8 @@ import fs from 'fs-extra';
import os from 'node:os';
import path from 'node:path';
type SecretStore = {
/** @public */
export type SecretStore = {
get(service: string, account: string): Promise<string | undefined>;
set(service: string, account: string, secret: string): Promise<void>;
delete(service: string, account: string): Promise<void>;
@@ -89,6 +90,7 @@ class FileSecretStore implements SecretStore {
let singleton: SecretStore | undefined;
/** @public */
export async function getSecretStore(): Promise<SecretStore> {
if (!singleton) {
const keytar = await loadKeytar();
@@ -22,6 +22,8 @@ import {
getAllInstances,
getSelectedInstance,
getInstanceByName,
getInstanceConfig,
updateInstanceConfig,
upsertInstance,
removeInstance,
setSelectedInstance,
@@ -357,6 +359,69 @@ describe('storage', () => {
});
});
describe('getInstanceConfig', () => {
it('should return undefined when no config set', async () => {
await upsertInstance(mockInstance1);
const result = await getInstanceConfig('production', 'someKey');
expect(result).toBeUndefined();
});
it('should return config value for a key', async () => {
await upsertInstance(mockInstance1);
await updateInstanceConfig('production', 'myKey', 'myValue');
const result = await getInstanceConfig('production', 'myKey');
expect(result).toBe('myValue');
});
it('should throw NotFoundError for unknown instance', async () => {
await expect(getInstanceConfig('nonexistent', 'key')).rejects.toThrow(
NotFoundError,
);
});
});
describe('updateInstanceConfig', () => {
it('should set a config value', async () => {
await upsertInstance(mockInstance1);
await updateInstanceConfig('production', 'key1', 'value1');
const result = await getInstanceConfig('production', 'key1');
expect(result).toBe('value1');
});
it('should preserve existing config keys', async () => {
await upsertInstance(mockInstance1);
await updateInstanceConfig('production', 'key1', 'value1');
await updateInstanceConfig('production', 'key2', 'value2');
const result1 = await getInstanceConfig('production', 'key1');
const result2 = await getInstanceConfig('production', 'key2');
expect(result1).toBe('value1');
expect(result2).toBe('value2');
});
it('should throw NotFoundError for unknown instance', async () => {
await expect(
updateInstanceConfig('nonexistent', 'key', 'value'),
).rejects.toThrow(NotFoundError);
});
it('should remove instance along with its config', async () => {
await upsertInstance(mockInstance1);
await updateInstanceConfig('production', 'key1', 'value1');
await removeInstance('production');
const { instances } = await getAllInstances();
expect(instances.find(i => i.name === 'production')).toBeUndefined();
await upsertInstance(mockInstance1);
const result = await getInstanceConfig('production', 'key1');
expect(result).toBeUndefined();
});
});
describe('file path resolution', () => {
it('should use XDG_CONFIG_HOME when set', async () => {
const customConfigHome = mockDir.resolve('custom-config');
+41 -1
View File
@@ -36,9 +36,19 @@ const storedInstanceSchema = z.object({
issuedAt: z.number().int().nonnegative(),
accessTokenExpiresAt: z.number().int().nonnegative(),
selected: z.boolean().optional(),
config: z.record(z.string(), z.unknown()).optional(),
});
export type StoredInstance = z.infer<typeof storedInstanceSchema>;
/** @public */
export type StoredInstance = {
name: string;
baseUrl: string;
clientId: string;
issuedAt: number;
accessTokenExpiresAt: number;
selected?: boolean;
config?: Record<string, unknown>;
};
const authYamlSchema = z.object({
instances: z.array(storedInstanceSchema).default([]),
@@ -98,6 +108,7 @@ export async function getAllInstances(): Promise<{
};
}
/** @public */
export async function getSelectedInstance(
instanceName?: string,
): Promise<StoredInstance> {
@@ -160,6 +171,35 @@ export async function setSelectedInstance(name: string): Promise<void> {
});
}
/** @public */
export async function getInstanceConfig<T = unknown>(
instanceName: string,
key: string,
): Promise<T | undefined> {
const instance = await getInstanceByName(instanceName);
return instance.config?.[key] as T | undefined;
}
/** @public */
export async function updateInstanceConfig(
instanceName: string,
key: string,
value: unknown,
): Promise<void> {
return withMetadataLock(async () => {
const data = await readAll();
const idx = data.instances.findIndex(i => i.name === instanceName);
if (idx === -1) {
throw new NotFoundError(`Instance '${instanceName}' not found`);
}
data.instances[idx] = {
...data.instances[idx],
config: { ...data.instances[idx].config, [key]: value },
};
await writeAll(data);
});
}
export async function withMetadataLock<T>(fn: () => Promise<T>): Promise<T> {
const file = getMetadataFilePath();
await fs.ensureDir(path.dirname(file));
@@ -23,6 +23,7 @@ export const defaultTemplates = [
'@backstage/cli/templates/plugin-common-library',
'@backstage/cli/templates/web-library',
'@backstage/cli/templates/node-library',
'@backstage/cli/templates/cli-module',
'@backstage/cli/templates/catalog-provider-module',
'@backstage/cli/templates/scaffolder-backend-module',
];
@@ -160,6 +160,7 @@ export function getPromptsForRole(
case 'web-library':
case 'node-library':
case 'common-library':
case 'cli-module':
return [namePrompt()];
case 'plugin-web-library':
case 'plugin-node-library':
@@ -37,6 +37,13 @@ describe.each([
packagePath: 'packages/test',
},
],
[
{ role: 'cli-module', name: 'test' },
{
packageName: '@internal/cli-module-test',
packagePath: 'packages/cli-module-test',
},
],
[
{ role: 'plugin-web-library', pluginId: 'test' },
{
@@ -48,6 +48,8 @@ function getBaseNameForRole(
case 'node-library':
case 'common-library':
return roleParams.name;
case 'cli-module':
return `cli-module-${roleParams.name}`;
case 'plugin-web-library':
return `${roleParams.pluginId}-react`;
case 'plugin-node-library':
+2 -1
View File
@@ -50,6 +50,7 @@ export const TEMPLATE_ROLES = [
'web-library',
'node-library',
'common-library',
'cli-module',
'plugin-web-library',
'plugin-node-library',
'plugin-common-library',
@@ -80,7 +81,7 @@ export type PortableTemplateParams = {
export type PortableTemplateInputRoleParams =
| {
role: 'web-library' | 'node-library' | 'common-library';
role: 'web-library' | 'node-library' | 'common-library' | 'cli-module';
name: string;
}
| {
+78
View File
@@ -12,6 +12,7 @@ Options:
-h, --help
Commands:
actions [command]
auth [command]
build-workspace
config [command]
@@ -31,6 +32,83 @@ Commands:
versions:migrate
```
### `backstage-cli actions`
```
Usage: backstage-cli actions [options] [command] [command]
Options:
-h, --help
Commands:
execute
help [command]
list
sources [command]
```
### `backstage-cli actions execute`
```
Usage: backstage-cli actions execute
Options:
--instance <string>
-h, --help
```
### `backstage-cli actions list`
```
Usage: backstage-cli actions list
Options:
--instance <string>
-h, --help
```
### `backstage-cli actions sources`
```
Usage: backstage-cli actions sources [options] [command] [command]
Options:
-h, --help
Commands:
add
help [command]
list
remove
```
### `backstage-cli actions sources add`
```
Usage: backstage-cli actions sources add
Options:
-h, --help
```
### `backstage-cli actions sources list`
```
Usage: backstage-cli actions sources list
Options:
-h, --help
```
### `backstage-cli actions sources remove`
```
Usage: backstage-cli actions sources remove
Options:
-h, --help
```
### `backstage-cli auth`
```
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
@@ -0,0 +1,5 @@
# {{packageName}}
A CLI module that adds commands to the Backstage CLI.
_This package was created through the Backstage CLI_
@@ -0,0 +1,32 @@
#!/usr/bin/env node
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const path = require('node:path');
/* eslint-disable-next-line no-restricted-syntax */
const isLocal = require('node:fs').existsSync(
path.resolve(__dirname, '../src'),
);
if (isLocal) {
require('@backstage/cli-node/config/nodeTransform.cjs');
}
const { runCliModule } = require('@backstage/cli-node');
const cliModule = require(isLocal ? '../src/index' : '..').default;
const pkg = require('../package.json');
runCliModule({ module: cliModule, name: pkg.name, version: pkg.version });
@@ -0,0 +1,35 @@
{
"name": "{{packageName}}",
"description": "CLI module for Backstage CLI",
"main": "src/index.ts",
"types": "src/index.ts",
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"backstage": {
"role": "cli-module"
},
"scripts": {
"build": "backstage-cli package build",
"lint": "backstage-cli package lint",
"test": "backstage-cli package test",
"clean": "backstage-cli package clean",
"prepack": "backstage-cli package prepack",
"postpack": "backstage-cli package postpack"
},
"dependencies": {
"@backstage/cli-common": "{{versionQuery '@backstage/cli-common'}}",
"@backstage/cli-node": "{{versionQuery '@backstage/cli-node'}}",
"cleye": "^2.3.0"
},
"devDependencies": {
"@backstage/cli": "{{versionQuery '@backstage/cli'}}"
},
"files": [
"dist",
"bin"
],
"bin": "bin/{{binName}}"
}
@@ -0,0 +1,5 @@
name: cli-module
role: cli-module
description: A CLI module that adds commands to the Backstage CLI
values:
binName: 'backstage-cli-module-{{ name }}'
@@ -0,0 +1,22 @@
import { cli } from 'cleye';
import type { CliCommandContext } from '@backstage/cli-node';
export default async ({ args, info }: CliCommandContext) => {
const { flags } = cli(
{
help: info,
booleanFlagNegation: true,
flags: {
name: {
type: String,
description: 'Your name',
},
},
},
undefined,
args,
);
const name = flags.name ?? 'World';
console.log(`Hello, ${name}!`);
};
@@ -0,0 +1,20 @@
/***/
/**
* CLI module for the Backstage CLI.
*
* @packageDocumentation
*/
import { createCliModule } from '@backstage/cli-node';
import packageJson from '../package.json';
export default createCliModule({
packageJson,
init: async reg => {
reg.addCommand({
path: ['example'],
description: 'An example command',
execute: { loader: () => import('./commands/example') },
});
},
});
+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 -2
View File
@@ -28,7 +28,6 @@ import { ComponentType } from 'react';
import { ConfigApi } from '@backstage/frontend-plugin-api';
import { configApiRef } from '@backstage/frontend-plugin-api';
import { createApiFactory } from '@backstage/frontend-plugin-api';
import { createApiRef } from '@backstage/frontend-plugin-api';
import { DiscoveryApi } from '@backstage/frontend-plugin-api';
import { discoveryApiRef } from '@backstage/frontend-plugin-api';
import { ErrorApi } from '@backstage/frontend-plugin-api';
@@ -256,7 +255,8 @@ export { configApiRef };
export { createApiFactory };
export { createApiRef };
// @public
export function createApiRef<T>(config: ApiRefConfig): ApiRef<T>;
// @public
export function createComponentExtension<
@@ -19,9 +19,15 @@ import { createApiRef } from './ApiRef';
describe('ApiRef', () => {
it('should be created', () => {
const ref = createApiRef({ id: 'abc' });
expect(ref.$$type).toBe('@backstage/ApiRef');
expect(ref.id).toBe('abc');
expect(String(ref)).toBe('apiRef{abc}');
expect(() => ref.T).toThrow('tried to read ApiRef.T of apiRef{abc}');
expect(ref.T).toBeNull();
});
it('should not accept pluginId in the core createApiRef config', () => {
// @ts-expect-error pluginId is not supported in core-plugin-api
createApiRef<string>({ id: 'abc', pluginId: 'test' });
});
it('should reject invalid ids', () => {
@@ -14,5 +14,23 @@
* limitations under the License.
*/
export { createApiRef } from '@backstage/frontend-plugin-api';
export type { ApiRefConfig } from '@backstage/frontend-plugin-api';
import {
createApiRef as createFrontendApiRef,
type ApiRef,
type ApiRefConfig,
} from '@backstage/frontend-plugin-api';
const createFrontendApiRefCompat = createFrontendApiRef as <T>(
config: ApiRefConfig,
) => ApiRef<T>;
/**
* Creates a reference to an API.
*
* @public
*/
export function createApiRef<T>(config: ApiRefConfig): ApiRef<T> {
return createFrontendApiRefCompat<T>(config);
}
export type { ApiRefConfig };
+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",
+76 -13
View File
@@ -3,14 +3,14 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { ApiHolder } from '@backstage/core-plugin-api';
import { ApiHolder as ApiHolder_2 } from '@backstage/frontend-plugin-api';
import { ApiHolder } from '@backstage/frontend-plugin-api';
import { AppNode } from '@backstage/frontend-plugin-api';
import { AppTree } from '@backstage/frontend-plugin-api';
import { ConfigApi } from '@backstage/core-plugin-api';
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';
@@ -128,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;
@@ -145,6 +165,12 @@ export type AppErrorTypes = {
};
};
// @public
export type BootstrapSpecializedApp = {
element: JSX.Element;
tree: AppTree;
};
// @public
export type CreateAppRouteBinder = <
TExternalRoutes extends {
@@ -158,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;
@@ -173,8 +197,8 @@ export type CreateSpecializedAppOptions = {
advanced?: {
apis?: ApiHolder;
extensionFactoryMiddleware?:
| ExtensionFactoryMiddleware
| ExtensionFactoryMiddleware[];
| ExtensionFactoryMiddleware_2
| ExtensionFactoryMiddleware_2[];
pluginInfoResolver?: FrontendPluginInfoResolver;
};
};
@@ -186,11 +210,19 @@ export type ExtensionFactoryMiddleware = (
}) => ExtensionDataContainer<ExtensionDataRef>,
context: {
node: AppNode;
apis: ApiHolder_2;
apis: ApiHolder;
config?: JsonObject;
},
) => 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>;
@@ -204,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();
});
});
});
@@ -19,8 +19,9 @@ import {
ExtensionDataContainer,
ExtensionDataRef,
ExtensionInput,
ResolvedExtensionInputs,
} from '@backstage/frontend-plugin-api';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { ResolvedExtensionInputs } from '../../../frontend-plugin-api/src/wiring/createExtension';
import { ExtensionFactoryMiddleware } from '../wiring/types';
import mapValues from 'lodash/mapValues';
import { AppNode, AppNodeInstance } from '@backstage/frontend-plugin-api';
@@ -28,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');
@@ -63,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;
@@ -336,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 {
@@ -366,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),
});
@@ -387,7 +418,7 @@ export function createAppNodeInstance(options: {
} else if (internalExtension.version === 'v2') {
const context = {
node,
apis,
apis: scopedApis,
config: parsedConfig,
inputs: resolveV2Inputs(
internalExtension.inputs,
@@ -499,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
@@ -508,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 };
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { ConfigApi } from '@backstage/core-plugin-api';
import { ConfigApi } from '@backstage/frontend-plugin-api';
import {
FrontendFeature,
FrontendPluginInfo,
File diff suppressed because it is too large Load Diff
@@ -14,210 +14,28 @@
* limitations under the License.
*/
import { ConfigReader } from '@backstage/config';
import {
ApiBlueprint,
AppTree,
AppTreeApi,
appTreeApiRef,
RouteRef,
ExternalRouteRef,
SubRouteRef,
AnyRouteRefParams,
RouteFunc,
RouteResolutionApi,
createApiFactory,
routeResolutionApiRef,
AppNode,
FrontendFeature,
} from '@backstage/frontend-plugin-api';
import { ExtensionFactoryMiddleware } from './types';
import {
AnyApiFactory,
ApiHolder,
ConfigApi,
configApiRef,
featureFlagsApiRef,
identityApiRef,
} from '@backstage/core-plugin-api';
import { ApiFactoryRegistry, ApiResolver } from '@backstage/core-app-api';
import {
createExtensionDataContainer,
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';
ExtensionFactoryMiddleware,
FrontendFeature,
} from '@backstage/frontend-plugin-api';
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 = {
@@ -246,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;
@@ -273,252 +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(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,
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(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;
}
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;
@@ -430,9 +1092,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:^"
@@ -0,0 +1,31 @@
/*
* Copyright 2024 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 { ApiRef } from '@backstage/frontend-plugin-api';
import { OpaqueType } from '@internal/opaque';
export const OpaqueApiRef = OpaqueType.create<{
public: ApiRef<unknown> & {
readonly $$type: '@backstage/ApiRef';
};
versions: {
readonly version: 'v1';
readonly pluginId?: string;
};
}>({
type: '@backstage/ApiRef',
versions: ['v1'],
});
@@ -0,0 +1,17 @@
/*
* Copyright 2024 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.
*/
export * from './OpaqueApiRef';
+1
View File
@@ -15,4 +15,5 @@
*/
export * from './routing';
export * from './apis';
export * from './wiring';
@@ -24,9 +24,11 @@ import {
ExtensionDefinitionParameters,
ExtensionInput,
PortableSchema,
ResolvedExtensionInputs,
} from '@backstage/frontend-plugin-api';
// 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>;
@@ -69,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",
+606 -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,17 +587,22 @@ export type PluginWrapperApi = {
};
// @public
export const pluginWrapperApiRef: ApiRef<PluginWrapperApi>;
export const pluginWrapperApiRef: ApiRef_2<
PluginWrapperApi,
'core.plugin-wrapper'
> & {
readonly $$type: '@backstage/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',
{}
@@ -43,7 +611,7 @@ export const PluginWrapperBlueprint: ExtensionBlueprint<{
config: {};
configInput: {};
dataRefs: {
wrapper: ConfigurableExtensionDataRef<
wrapper: ConfigurableExtensionDataRef_2<
() => Promise<PluginWrapperDefinition>,
'core.plugin-wrapper.loader',
{}
@@ -60,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)
```
+213 -90
View File
@@ -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';
@@ -30,8 +31,10 @@ export type AlertApi = {
alert$(): Observable<AlertMessage>;
};
// @public @deprecated
export const alertApiRef: ApiRef<AlertApi>;
// @public
export const alertApiRef: ApiRef_2<AlertApi, 'core.alert'> & {
readonly $$type: '@backstage/ApiRef';
};
// @public @deprecated
export type AlertMessage = {
@@ -46,7 +49,9 @@ export type AnalyticsApi = {
};
// @public
export const analyticsApiRef: ApiRef<AnalyticsApi>;
export const analyticsApiRef: ApiRef_2<AnalyticsApi, 'core.analytics'> & {
readonly $$type: '@backstage/ApiRef';
};
// @public
export const AnalyticsContext: (options: {
@@ -187,9 +192,10 @@ export type ApiHolder = {
};
// @public
export type ApiRef<T> = {
id: string;
T: T;
export type ApiRef<T, TId extends string = string> = {
readonly $$type?: '@backstage/ApiRef';
readonly id: TId;
readonly T: T;
};
// @public
@@ -212,7 +218,9 @@ export type AppLanguageApi = {
};
// @public (undocumented)
export const appLanguageApiRef: ApiRef<AppLanguageApi>;
export const appLanguageApiRef: ApiRef_2<AppLanguageApi, 'core.applanguage'> & {
readonly $$type: '@backstage/ApiRef';
};
// @public
export interface AppNode {
@@ -251,6 +259,8 @@ export interface AppNodeSpec {
// (undocumented)
readonly id: string;
// (undocumented)
readonly if?: FilterPredicate;
// (undocumented)
readonly plugin: FrontendPlugin;
}
@@ -285,7 +295,9 @@ export type AppThemeApi = {
};
// @public
export const appThemeApiRef: ApiRef<AppThemeApi>;
export const appThemeApiRef: ApiRef_2<AppThemeApi, 'core.apptheme'> & {
readonly $$type: '@backstage/ApiRef';
};
// @public
export interface AppTree {
@@ -305,12 +317,17 @@ export interface AppTreeApi {
}
// @public
export const appTreeApiRef: ApiRef_2<AppTreeApi>;
export const appTreeApiRef: ApiRef_2<AppTreeApi, 'core.app-tree'> & {
readonly $$type: '@backstage/ApiRef';
};
// @public
export const atlassianAuthApiRef: ApiRef<
OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi
>;
export const atlassianAuthApiRef: ApiRef_2<
OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi,
'core.auth.atlassian'
> & {
readonly $$type: '@backstage/ApiRef';
};
// @public
export type AuthProviderInfo = {
@@ -348,20 +365,28 @@ export type BackstageUserIdentity = {
};
// @public
export const bitbucketAuthApiRef: ApiRef<
OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi
>;
export const bitbucketAuthApiRef: ApiRef_2<
OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi,
'core.auth.bitbucket'
> & {
readonly $$type: '@backstage/ApiRef';
};
// @public
export const bitbucketServerAuthApiRef: ApiRef<
OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi
>;
export const bitbucketServerAuthApiRef: ApiRef_2<
OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi,
'core.auth.bitbucket-server'
> & {
readonly $$type: '@backstage/ApiRef';
};
// @public
export type ConfigApi = Config;
// @public
export const configApiRef: ApiRef<ConfigApi>;
export const configApiRef: ApiRef_2<Config, 'core.config'> & {
readonly $$type: '@backstage/ApiRef';
};
// @public (undocumented)
export interface ConfigurableExtensionDataRef<
@@ -415,8 +440,22 @@ export function createApiFactory<Api, Impl extends Api>(
instance: Impl,
): ApiFactory<Api, Impl, {}>;
// @public @deprecated
export function createApiRef<T>(config: ApiRefConfig): ApiRef<T> & {
readonly $$type: '@backstage/ApiRef';
};
// @public
export function createApiRef<T>(config: ApiRefConfig): ApiRef<T>;
export function createApiRef<T>(): {
with<const TId extends string>(
config: ApiRefConfig & {
id: TId;
pluginId?: string;
},
): ApiRef<T, TId> & {
readonly $$type: '@backstage/ApiRef';
};
};
// @public
export function createExtension<
@@ -541,6 +580,7 @@ export type CreateExtensionBlueprintOptions<
attachTo: ExtensionDefinitionAttachTo<UParentInputs> &
VerifyExtensionAttachTo<UOutput, UParentInputs>;
disabled?: boolean;
if?: FilterPredicate;
inputs?: TInputs;
output: Array<UOutput>;
config?: {
@@ -627,6 +667,7 @@ export type CreateExtensionOptions<
attachTo: ExtensionDefinitionAttachTo<UParentInputs> &
VerifyExtensionAttachTo<UOutput, UParentInputs>;
disabled?: boolean;
if?: FilterPredicate;
inputs?: TInputs;
output: Array<UOutput>;
config?: {
@@ -715,6 +756,8 @@ export interface CreateFrontendModuleOptions<
// (undocumented)
featureFlags?: FeatureFlagConfig[];
// (undocumented)
if?: FilterPredicate;
// (undocumented)
pluginId: TPluginId;
}
@@ -729,13 +772,47 @@ export function createFrontendPlugin<
[name in string]: ExternalRouteRef;
} = {},
>(
options: PluginOptions<TId, TRoutes, TExternalRoutes, TExtensions>,
options: CreateFrontendPluginOptions<
TId,
TRoutes,
TExternalRoutes,
TExtensions
>,
): OverridableFrontendPlugin<
TRoutes,
TExternalRoutes,
MakeSortedExtensionsMap<TExtensions[number], TId>
>;
// @public
export interface CreateFrontendPluginOptions<
TId extends string,
TRoutes extends {
[name in string]: RouteRef | SubRouteRef;
},
TExternalRoutes extends {
[name in string]: ExternalRouteRef;
},
TExtensions extends readonly ExtensionDefinition[],
> {
// (undocumented)
extensions?: TExtensions;
// (undocumented)
externalRoutes?: TExternalRoutes;
// (undocumented)
featureFlags?: FeatureFlagConfig[];
icon?: IconElement;
// (undocumented)
if?: FilterPredicate;
// (undocumented)
info?: FrontendPluginInfoOptions;
// (undocumented)
pluginId: TId;
// (undocumented)
routes?: TRoutes;
title?: string;
}
// @public
export function createRouteRef<
TParams extends
@@ -869,7 +946,9 @@ export interface DialogApiDialog<TResult = void> {
}
// @public
export const dialogApiRef: ApiRef_2<DialogApi>;
export const dialogApiRef: ApiRef_2<DialogApi, 'core.dialog'> & {
readonly $$type: '@backstage/ApiRef';
};
// @public
export type DiscoveryApi = {
@@ -877,7 +956,9 @@ export type DiscoveryApi = {
};
// @public
export const discoveryApiRef: ApiRef<DiscoveryApi>;
export const discoveryApiRef: ApiRef_2<DiscoveryApi, 'core.discovery'> & {
readonly $$type: '@backstage/ApiRef';
};
// @public
export type ErrorApi = {
@@ -901,7 +982,9 @@ export type ErrorApiErrorContext = {
};
// @public
export const errorApiRef: ApiRef<ErrorApi>;
export const errorApiRef: ApiRef_2<ErrorApi, 'core.error'> & {
readonly $$type: '@backstage/ApiRef';
};
// @public (undocumented)
export const ErrorDisplay: {
@@ -952,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
@@ -987,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`;
@@ -1285,7 +1370,12 @@ export interface FeatureFlagsApi {
}
// @public
export const featureFlagsApiRef: ApiRef<FeatureFlagsApi>;
export const featureFlagsApiRef: ApiRef_2<
FeatureFlagsApi,
'core.featureflags'
> & {
readonly $$type: '@backstage/ApiRef';
};
// @public
export type FeatureFlagsSaveOptions = {
@@ -1317,7 +1407,9 @@ export type FetchApi = {
};
// @public
export const fetchApiRef: ApiRef<FetchApi>;
export const fetchApiRef: ApiRef_2<FetchApi, 'core.fetch'> & {
readonly $$type: '@backstage/ApiRef';
};
// @public (undocumented)
export type FrontendFeature =
@@ -1390,27 +1482,36 @@ export type FrontendPluginInfoOptions = {
};
// @public
export const githubAuthApiRef: ApiRef<
OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi
>;
export const githubAuthApiRef: ApiRef_2<
OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi,
'core.auth.github'
> & {
readonly $$type: '@backstage/ApiRef';
};
// @public
export const gitlabAuthApiRef: ApiRef<
export const gitlabAuthApiRef: ApiRef_2<
OAuthApi &
OpenIdConnectApi &
ProfileInfoApi &
BackstageIdentityApi &
SessionApi
>;
SessionApi,
'core.auth.gitlab'
> & {
readonly $$type: '@backstage/ApiRef';
};
// @public
export const googleAuthApiRef: ApiRef<
export const googleAuthApiRef: ApiRef_2<
OAuthApi &
OpenIdConnectApi &
ProfileInfoApi &
BackstageIdentityApi &
SessionApi
>;
SessionApi,
'core.auth.google'
> & {
readonly $$type: '@backstage/ApiRef';
};
// @public @deprecated
export type IconComponent = ComponentType<{
@@ -1430,7 +1531,9 @@ export interface IconsApi {
}
// @public
export const iconsApiRef: ApiRef_2<IconsApi>;
export const iconsApiRef: ApiRef_2<IconsApi, 'core.icons'> & {
readonly $$type: '@backstage/ApiRef';
};
// @public
export type IdentityApi = {
@@ -1443,16 +1546,21 @@ export type IdentityApi = {
};
// @public
export const identityApiRef: ApiRef<IdentityApi>;
export const identityApiRef: ApiRef_2<IdentityApi, 'core.identity'> & {
readonly $$type: '@backstage/ApiRef';
};
// @public
export const microsoftAuthApiRef: ApiRef<
export const microsoftAuthApiRef: ApiRef_2<
OAuthApi &
OpenIdConnectApi &
ProfileInfoApi &
BackstageIdentityApi &
SessionApi
>;
SessionApi,
'core.auth.microsoft'
> & {
readonly $$type: '@backstage/ApiRef';
};
// @public @deprecated
export const NavItemBlueprint: ExtensionBlueprint_2<{
@@ -1515,7 +1623,12 @@ export type OAuthRequestApi = {
};
// @public
export const oauthRequestApiRef: ApiRef<OAuthRequestApi>;
export const oauthRequestApiRef: ApiRef_2<
OAuthRequestApi,
'core.oauthrequest'
> & {
readonly $$type: '@backstage/ApiRef';
};
// @public
export type OAuthRequester<TAuthResponse> = (
@@ -1532,22 +1645,28 @@ export type OAuthRequesterOptions<TOAuthResponse> = {
export type OAuthScope = string | string[];
// @public
export const oktaAuthApiRef: ApiRef<
export const oktaAuthApiRef: ApiRef_2<
OAuthApi &
OpenIdConnectApi &
ProfileInfoApi &
BackstageIdentityApi &
SessionApi
>;
SessionApi,
'core.auth.okta'
> & {
readonly $$type: '@backstage/ApiRef';
};
// @public
export const oneloginAuthApiRef: ApiRef<
export const oneloginAuthApiRef: ApiRef_2<
OAuthApi &
OpenIdConnectApi &
ProfileInfoApi &
BackstageIdentityApi &
SessionApi
>;
SessionApi,
'core.auth.onelogin'
> & {
readonly $$type: '@backstage/ApiRef';
};
// @public
export type OpenIdConnectApi = {
@@ -1555,9 +1674,12 @@ export type OpenIdConnectApi = {
};
// @public
export const openshiftAuthApiRef: ApiRef<
OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi
>;
export const openshiftAuthApiRef: ApiRef_2<
OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi,
'core.auth.openshift'
> & {
readonly $$type: '@backstage/ApiRef';
};
// @public (undocumented)
export interface OverridableExtensionDefinition<
@@ -1591,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`;
@@ -1696,6 +1819,7 @@ export interface OverridableFrontendPlugin<
// (undocumented)
withOverrides(options: {
extensions?: Array<ExtensionDefinition>;
if?: FilterPredicate;
title?: string;
icon?: IconElement;
info?: FrontendPluginInfoOptions;
@@ -1845,10 +1969,15 @@ export type PluginHeaderActionsApi = {
};
// @public
export const pluginHeaderActionsApiRef: ApiRef_2<PluginHeaderActionsApi>;
export const pluginHeaderActionsApiRef: ApiRef_2<
PluginHeaderActionsApi,
'core.plugin-header-actions'
> & {
readonly $$type: '@backstage/ApiRef';
};
// @public (undocumented)
export interface PluginOptions<
// @public @deprecated (undocumented)
export type PluginOptions<
TId extends string,
TRoutes extends {
[name in string]: RouteRef | SubRouteRef;
@@ -1857,22 +1986,7 @@ export interface PluginOptions<
[name in string]: ExternalRouteRef;
},
TExtensions extends readonly ExtensionDefinition[],
> {
// (undocumented)
extensions?: TExtensions;
// (undocumented)
externalRoutes?: TExternalRoutes;
// (undocumented)
featureFlags?: FeatureFlagConfig[];
icon?: IconElement;
// (undocumented)
info?: FrontendPluginInfoOptions;
// (undocumented)
pluginId: TId;
// (undocumented)
routes?: TRoutes;
title?: string;
}
> = CreateFrontendPluginOptions<TId, TRoutes, TExternalRoutes, TExtensions>;
// @public
export type PluginWrapperApi = {
@@ -1887,7 +2001,12 @@ export type PluginWrapperApi = {
};
// @public
export const pluginWrapperApiRef: ApiRef_2<PluginWrapperApi>;
export const pluginWrapperApiRef: ApiRef_2<
PluginWrapperApi,
'core.plugin-wrapper'
> & {
readonly $$type: '@backstage/ApiRef';
};
// @public
export const PluginWrapperBlueprint: ExtensionBlueprint_2<{
@@ -1950,19 +2069,6 @@ export const Progress: {
// @public (undocumented)
export type ProgressProps = {};
// @public
export type ResolvedExtensionInputs<
TInputs extends {
[name in string]: ExtensionInput;
},
> = {
[InputName in keyof TInputs]: false extends TInputs[InputName]['config']['singleton']
? Array<Expand<ResolvedExtensionInput<TInputs[InputName]>>>
: false extends TInputs[InputName]['config']['optional']
? Expand<ResolvedExtensionInput<TInputs[InputName]>>
: Expand<ResolvedExtensionInput<TInputs[InputName]> | undefined>;
};
// @public
export type RouteFunc<TParams extends AnyRouteRefParams> = (
...input: TParams extends undefined ? readonly [] : readonly [params: TParams]
@@ -1993,7 +2099,12 @@ export interface RouteResolutionApi {
}
// @public
export const routeResolutionApiRef: ApiRef_2<RouteResolutionApi>;
export const routeResolutionApiRef: ApiRef_2<
RouteResolutionApi,
'core.route-resolution'
> & {
readonly $$type: '@backstage/ApiRef';
};
// @public
export type SessionApi = {
@@ -2031,7 +2142,9 @@ export interface StorageApi {
}
// @public
export const storageApiRef: ApiRef<StorageApi>;
export const storageApiRef: ApiRef_2<StorageApi, 'core.storage'> & {
readonly $$type: '@backstage/ApiRef';
};
// @public
export type StorageValueSnapshot<TValue extends JsonValue> =
@@ -2121,7 +2234,12 @@ export interface SwappableComponentsApi {
}
// @public
export const swappableComponentsApiRef: ApiRef_2<SwappableComponentsApi>;
export const swappableComponentsApiRef: ApiRef_2<
SwappableComponentsApi,
'core.swappable-components'
> & {
readonly $$type: '@backstage/ApiRef';
};
// @public
export type ToastApi = {
@@ -2170,7 +2288,9 @@ export type TranslationApi = {
};
// @public (undocumented)
export const translationApiRef: ApiRef<TranslationApi>;
export const translationApiRef: ApiRef_2<TranslationApi, 'core.translation'> & {
readonly $$type: '@backstage/ApiRef';
};
// @public (undocumented)
export type TranslationFunction<
@@ -2360,13 +2480,16 @@ export const useTranslationRef: <TMessages extends { [key in string]: string }>(
};
// @public
export const vmwareCloudAuthApiRef: ApiRef<
export const vmwareCloudAuthApiRef: ApiRef_2<
OAuthApi &
OpenIdConnectApi &
ProfileInfoApi &
BackstageIdentityApi &
SessionApi
>;
SessionApi,
'core.auth.vmware-cloud'
> & {
readonly $$type: '@backstage/ApiRef';
};
// @public @deprecated
export function withApis<T extends {}>(
+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,
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { createApiRef, ApiRef } from '../system';
import { createApiRef } from '../system';
import { Observable } from '@backstage/types';
/**
@@ -77,6 +77,7 @@ export type AlertApi = {
* @public
* @deprecated Use {@link toastApiRef} instead. AlertApi will be removed in a future release.
*/
export const alertApiRef: ApiRef<AlertApi> = createApiRef({
export const alertApiRef = createApiRef<AlertApi>().with({
id: 'core.alert',
pluginId: 'app',
});
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { ApiRef, createApiRef } from '../system';
import { createApiRef } from '../system';
import { AnalyticsContextValue } from '../../analytics/types';
/**
@@ -151,6 +151,7 @@ export type AnalyticsApi = {
*
* @public
*/
export const analyticsApiRef: ApiRef<AnalyticsApi> = createApiRef({
export const analyticsApiRef = createApiRef<AnalyticsApi>().with({
id: 'core.analytics',
pluginId: 'app',
});
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { ApiRef, createApiRef } from '../system';
import { createApiRef } from '../system';
import { Observable } from '@backstage/types';
/** @public */
@@ -31,6 +31,7 @@ export type AppLanguageApi = {
/**
* @public
*/
export const appLanguageApiRef: ApiRef<AppLanguageApi> = createApiRef({
export const appLanguageApiRef = createApiRef<AppLanguageApi>().with({
id: 'core.applanguage',
pluginId: 'app',
});
@@ -15,7 +15,7 @@
*/
import { ReactNode } from 'react';
import { ApiRef, createApiRef } from '../system';
import { createApiRef } from '../system';
import { Observable } from '@backstage/types';
/**
@@ -82,6 +82,7 @@ export type AppThemeApi = {
*
* @public
*/
export const appThemeApiRef: ApiRef<AppThemeApi> = createApiRef({
export const appThemeApiRef = createApiRef<AppThemeApi>().with({
id: 'core.apptheme',
pluginId: 'app',
});
@@ -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;
}
@@ -117,4 +119,7 @@ export interface AppTreeApi {
*
* @public
*/
export const appTreeApiRef = createApiRef<AppTreeApi>({ id: 'core.app-tree' });
export const appTreeApiRef = createApiRef<AppTreeApi>().with({
id: 'core.app-tree',
pluginId: 'app',
});
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ApiRef, createApiRef } from '../system';
import { createApiRef } from '../system';
import type { Config } from '@backstage/config';
/**
@@ -29,6 +29,7 @@ export type ConfigApi = Config;
*
* @public
*/
export const configApiRef: ApiRef<ConfigApi> = createApiRef({
export const configApiRef = createApiRef<ConfigApi>().with({
id: 'core.config',
pluginId: 'app',
});
@@ -173,6 +173,7 @@ export interface DialogApi {
*
* @public
*/
export const dialogApiRef = createApiRef<DialogApi>({
export const dialogApiRef = createApiRef<DialogApi>().with({
id: 'core.dialog',
pluginId: 'app',
});
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ApiRef, createApiRef } from '../system';
import { createApiRef } from '../system';
/**
* The discovery API is used to provide a mechanism for plugins to
@@ -50,6 +50,7 @@ export type DiscoveryApi = {
*
* @public
*/
export const discoveryApiRef: ApiRef<DiscoveryApi> = createApiRef({
export const discoveryApiRef = createApiRef<DiscoveryApi>().with({
id: 'core.discovery',
pluginId: 'app',
});
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { ApiRef, createApiRef } from '../system';
import { createApiRef } from '../system';
import { Observable } from '@backstage/types';
/**
@@ -86,6 +86,7 @@ export type ErrorApi = {
*
* @public
*/
export const errorApiRef: ApiRef<ErrorApi> = createApiRef({
export const errorApiRef = createApiRef<ErrorApi>().with({
id: 'core.error',
pluginId: 'app',
});
@@ -16,7 +16,7 @@
/* We want to maintain the same information as an enum, so we disable the redeclaration warning */
/* eslint-disable @typescript-eslint/no-redeclare */
import { ApiRef, createApiRef } from '../system';
import { createApiRef } from '../system';
/**
* Feature flag descriptor.
@@ -121,6 +121,7 @@ export interface FeatureFlagsApi {
*
* @public
*/
export const featureFlagsApiRef: ApiRef<FeatureFlagsApi> = createApiRef({
export const featureFlagsApiRef = createApiRef<FeatureFlagsApi>().with({
id: 'core.featureflags',
pluginId: 'app',
});
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { ApiRef, createApiRef } from '../system';
import { createApiRef } from '../system';
/**
* A wrapper for the fetch API, that has additional behaviors such as the
@@ -46,6 +46,7 @@ export type FetchApi = {
*
* @public
*/
export const fetchApiRef: ApiRef<FetchApi> = createApiRef({
export const fetchApiRef = createApiRef<FetchApi>().with({
id: 'core.fetch',
pluginId: 'app',
});
@@ -41,6 +41,7 @@ export interface IconsApi {
*
* @public
*/
export const iconsApiRef = createApiRef<IconsApi>({
export const iconsApiRef = createApiRef<IconsApi>().with({
id: 'core.icons',
pluginId: 'app',
});
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ApiRef, createApiRef } from '../system';
import { createApiRef } from '../system';
import { BackstageUserIdentity, ProfileInfo } from './auth';
/**
@@ -51,6 +51,7 @@ export type IdentityApi = {
*
* @public
*/
export const identityApiRef: ApiRef<IdentityApi> = createApiRef({
export const identityApiRef = createApiRef<IdentityApi>().with({
id: 'core.identity',
pluginId: 'app',
});
@@ -15,7 +15,7 @@
*/
import { Observable } from '@backstage/types';
import { ApiRef, createApiRef } from '../system';
import { createApiRef } from '../system';
import { AuthProviderInfo } from './auth';
/**
@@ -126,6 +126,7 @@ export type OAuthRequestApi = {
*
* @public
*/
export const oauthRequestApiRef: ApiRef<OAuthRequestApi> = createApiRef({
export const oauthRequestApiRef = createApiRef<OAuthRequestApi>().with({
id: 'core.oauthrequest',
pluginId: 'app',
});
@@ -40,6 +40,8 @@ export type PluginHeaderActionsApi = {
*
* @public
*/
export const pluginHeaderActionsApiRef = createApiRef<PluginHeaderActionsApi>({
id: 'core.plugin-header-actions',
});
export const pluginHeaderActionsApiRef =
createApiRef<PluginHeaderActionsApi>().with({
id: 'core.plugin-header-actions',
pluginId: 'app',
});

Some files were not shown because too many files have changed in this diff Show More