Merge pull request #31082 from backstage/rugvip/errors

frontend-app-api: new error handling for app startup
This commit is contained in:
Patrik Oldsberg
2025-09-15 17:14:48 +02:00
committed by GitHub
24 changed files with 1877 additions and 856 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-test-utils': patch
---
Internal update to use and throw app errors.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-app-api': minor
---
The `createSpecializedApp` no longer throws when encountering many common errors when starting up the app. It will instead return them through the `errors` property so that they can be handled more gracefully in the app.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-defaults': patch
---
The default app now leverages the new error reporting functionality from `@backstage/frontend-app-api`. If there are critical errors during startup, an error screen that shows a summary of all errors will now be shown, rather than leaving the screen blank. Other errors will be logged as warnings in the console.
+113
View File
@@ -4,16 +4,128 @@
```ts
import { ApiHolder } from '@backstage/core-plugin-api';
import { AppNode } from '@backstage/frontend-plugin-api';
import { AppTree } from '@backstage/frontend-plugin-api';
import { ConfigApi } from '@backstage/core-plugin-api';
import { ExtensionFactoryMiddleware } from '@backstage/frontend-plugin-api';
import { ExternalRouteRef } from '@backstage/frontend-plugin-api';
import { FrontendFeature } from '@backstage/frontend-plugin-api';
import { FrontendPlugin } from '@backstage/frontend-plugin-api';
import { FrontendPluginInfo } from '@backstage/frontend-plugin-api';
import { JsonObject } from '@backstage/types';
import { RouteRef } from '@backstage/frontend-plugin-api';
import { SubRouteRef } from '@backstage/frontend-plugin-api';
// @public (undocumented)
export type AppError =
keyof AppErrorTypes extends infer ICode extends keyof AppErrorTypes
? ICode extends any
? {
code: ICode;
message: string;
context: AppErrorTypes[ICode]['context'];
}
: never
: never;
// @public (undocumented)
export type AppErrorTypes = {
EXTENSION_IGNORED: {
context: {
plugin: FrontendPlugin;
extensionId: string;
};
};
INVALID_EXTENSION_CONFIG_KEY: {
context: {
extensionId: string;
};
};
EXTENSION_INPUT_REDIRECT_CONFLICT: {
context: {
node: AppNode;
inputName: string;
};
};
EXTENSION_INPUT_DATA_IGNORED: {
context: {
node: AppNode;
inputName: string;
};
};
EXTENSION_INPUT_DATA_MISSING: {
context: {
node: AppNode;
inputName: string;
};
};
EXTENSION_ATTACHMENT_CONFLICT: {
context: {
node: AppNode;
inputName: string;
};
};
EXTENSION_ATTACHMENT_MISSING: {
context: {
node: AppNode;
inputName: string;
};
};
EXTENSION_CONFIGURATION_INVALID: {
context: {
node: AppNode;
};
};
EXTENSION_INVALID: {
context: {
node: AppNode;
};
};
EXTENSION_OUTPUT_CONFLICT: {
context: {
node: AppNode;
dataRefId: string;
};
};
EXTENSION_OUTPUT_MISSING: {
context: {
node: AppNode;
dataRefId: string;
};
};
EXTENSION_OUTPUT_IGNORED: {
context: {
node: AppNode;
dataRefId: string;
};
};
EXTENSION_FACTORY_ERROR: {
context: {
node: AppNode;
};
};
API_EXTENSION_INVALID: {
context: {
node: AppNode;
};
};
ROUTE_DUPLICATE: {
context: {
routeId: string;
};
};
ROUTE_BINDING_INVALID_VALUE: {
context: {
routeId: string;
};
};
ROUTE_NOT_FOUND: {
context: {
routeId: string;
};
};
};
// @public
export type CreateAppRouteBinder = <
TExternalRoutes extends {
@@ -31,6 +143,7 @@ export type CreateAppRouteBinder = <
export function createSpecializedApp(options?: CreateSpecializedAppOptions): {
apis: ApiHolder;
tree: AppTree;
errors?: AppError[];
};
// @public
@@ -20,6 +20,18 @@ import {
createFrontendPlugin,
} from '@backstage/frontend-plugin-api';
import { collectRouteIds } from './collectRouteIds';
import { createErrorCollector } from '../wiring/createErrorCollector';
const collector = createErrorCollector();
afterEach(() => {
const errors = collector.collectErrors();
if (errors) {
throw new Error(
`Unexpected errors: ${errors.map(e => e.message).join(', ')}`,
);
}
});
describe('collectRouteIds', () => {
it('should assign IDs to routes', () => {
@@ -33,13 +45,16 @@ describe('collectRouteIds', () => {
/^ExternalRouteRef\{created at '.*collectRouteIds\.test\.ts.*'\}$/,
);
const collected = collectRouteIds([
createFrontendPlugin({
pluginId: 'test',
routes: { ref },
externalRoutes: { extRef },
}),
]);
const collected = collectRouteIds(
[
createFrontendPlugin({
pluginId: 'test',
routes: { ref },
externalRoutes: { extRef },
}),
],
collector,
);
expect(Object.fromEntries(collected.routes)).toEqual({
'test.ref': ref,
});
@@ -50,4 +65,40 @@ describe('collectRouteIds', () => {
expect(String(ref)).toBe('RouteRef{test.ref}');
expect(String(extRef)).toBe('ExternalRouteRef{test.extRef}');
});
it('should report duplicate route IDs', () => {
const ref = createRouteRef();
const extRef = createExternalRouteRef();
collectRouteIds(
[
createFrontendPlugin({
pluginId: 'test',
routes: { 'mid.ref': ref },
externalRoutes: { 'mid.extRef': extRef },
}),
createFrontendPlugin({
pluginId: 'test.mid',
routes: { ref },
externalRoutes: { extRef },
}),
],
collector,
);
expect(collector.collectErrors()).toEqual([
{
code: 'ROUTE_DUPLICATE',
message:
"Duplicate route id 'test.mid.ref' encountered while collecting routes",
context: { routeId: 'test.mid.ref' },
},
{
code: 'ROUTE_DUPLICATE',
message:
"Duplicate external route id 'test.mid.extRef' encountered while collecting routes",
context: { routeId: 'test.mid.extRef' },
},
]);
});
});
@@ -30,6 +30,7 @@ import { toInternalExternalRouteRef } from '../../../frontend-plugin-api/src/rou
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { toInternalSubRouteRef } from '../../../frontend-plugin-api/src/routing/SubRouteRef';
import { OpaqueFrontendPlugin } from '@internal/frontend';
import { ErrorCollector } from '../wiring/createErrorCollector';
/** @internal */
export interface RouteRefsById {
@@ -38,7 +39,10 @@ export interface RouteRefsById {
}
/** @internal */
export function collectRouteIds(features: FrontendFeature[]): RouteRefsById {
export function collectRouteIds(
features: FrontendFeature[],
collector: ErrorCollector,
): RouteRefsById {
const routesById = new Map<string, RouteRef | SubRouteRef>();
const externalRoutesById = new Map<string, ExternalRouteRef>();
@@ -50,7 +54,12 @@ export function collectRouteIds(features: FrontendFeature[]): RouteRefsById {
for (const [name, ref] of Object.entries(feature.routes)) {
const refId = `${feature.id}.${name}`;
if (routesById.has(refId)) {
throw new Error(`Unexpected duplicate route '${refId}'`);
collector.report({
code: 'ROUTE_DUPLICATE',
message: `Duplicate route id '${refId}' encountered while collecting routes`,
context: { routeId: refId },
});
continue;
}
if (isRouteRef(ref)) {
@@ -65,7 +74,12 @@ export function collectRouteIds(features: FrontendFeature[]): RouteRefsById {
for (const [name, ref] of Object.entries(feature.externalRoutes)) {
const refId = `${feature.id}.${name}`;
if (externalRoutesById.has(refId)) {
throw new Error(`Unexpected duplicate external route '${refId}'`);
collector.report({
code: 'ROUTE_DUPLICATE',
message: `Duplicate external route id '${refId}' encountered while collecting routes`,
context: { routeId: refId },
});
continue;
}
const internalRef = toInternalExternalRouteRef(ref);
@@ -38,6 +38,18 @@ import { Root } from '../extensions/Root';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { resolveExtensionDefinition } from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition';
import { createRouteAliasResolver } from './RouteAliasResolver';
import { createErrorCollector } from '../wiring/createErrorCollector';
const collector = createErrorCollector();
afterEach(() => {
const errors = collector.collectErrors();
if (errors) {
throw new Error(
`Unexpected errors: ${errors.map(e => e.message).join(', ')}`,
);
}
});
const ref1 = createRouteRef();
const ref2 = createRouteRef();
@@ -102,10 +114,12 @@ function routeInfoFromExtensions(
],
parameters: readAppExtensionsConfig(mockApis.config()),
forbidden: new Set(['root']),
collector,
}),
collector,
);
instantiateAppNodeTree(tree.root, TestApiRegistry.from());
instantiateAppNodeTree(tree.root, TestApiRegistry.from(), collector);
return extractRouteInfoFromAppNode(
tree.root,
@@ -20,6 +20,18 @@ import {
} from '@backstage/frontend-plugin-api';
import { resolveRouteBindings } from './resolveRouteBindings';
import { ConfigReader } from '@backstage/config';
import { createErrorCollector } from '../wiring/createErrorCollector';
const collector = createErrorCollector();
afterEach(() => {
const errors = collector.collectErrors();
if (errors) {
throw new Error(
`Unexpected errors: ${errors.map(e => e.message).join(', ')}`,
);
}
});
const emptyIds = { routes: new Map(), externalRoutes: new Map() };
@@ -33,23 +45,30 @@ describe('resolveRouteBindings', () => {
},
new ConfigReader({}),
emptyIds,
collector,
);
expect(result.get(external.myRoute)).toBe(ref);
});
it('throws on unknown keys', () => {
it('reports error on unknown keys', () => {
const external = { myRoute: createExternalRouteRef() };
const ref = createRouteRef();
expect(() =>
resolveRouteBindings(
({ bind }) => {
bind(external, { someOtherRoute: ref } as any);
},
new ConfigReader({}),
emptyIds,
),
).toThrow('Key someOtherRoute is not an existing external route');
resolveRouteBindings(
({ bind }) => {
bind(external, { someOtherRoute: ref } as any);
},
new ConfigReader({}),
emptyIds,
collector,
);
expect(collector.collectErrors()).toEqual([
{
code: 'ROUTE_NOT_FOUND',
message: 'Key someOtherRoute is not an existing external route',
context: { routeId: 'someOtherRoute' },
},
]);
});
it('reads bindings from config', () => {
@@ -64,6 +83,7 @@ describe('resolveRouteBindings', () => {
routes: new Map([['myTarget', myTarget]]),
externalRoutes: new Map([['mySource', mySource]]),
},
collector,
);
expect(result.get(mySource)).toBe(myTarget);
@@ -85,6 +105,7 @@ describe('resolveRouteBindings', () => {
routes: new Map([['myTarget', myTarget]]),
externalRoutes: new Map([['mySource', mySource]]),
},
collector,
).get(mySource),
).toBe(undefined);
@@ -100,57 +121,74 @@ describe('resolveRouteBindings', () => {
routes: new Map([['myTarget', myTarget]]),
externalRoutes: new Map([['mySource', mySource]]),
},
collector,
).get(mySource),
).toBe(myTarget);
});
it('throws on invalid config', () => {
it('reports errors on invalid config', () => {
expect(() =>
resolveRouteBindings(
() => {},
new ConfigReader({ app: { routes: { bindings: 'derp' } } }),
emptyIds,
collector,
),
).toThrow(
"Invalid type in config for key 'app.routes.bindings' in 'mock-config', got string, wanted object",
);
expect(() =>
resolveRouteBindings(
() => {},
new ConfigReader({ app: { routes: { bindings: { mySource: true } } } }),
emptyIds,
),
).toThrow(
"Invalid config at app.routes.bindings['mySource'], value must be a non-empty string",
resolveRouteBindings(
() => {},
new ConfigReader({ app: { routes: { bindings: { mySource: true } } } }),
emptyIds,
collector,
);
expect(collector.collectErrors()).toEqual([
{
code: 'ROUTE_BINDING_INVALID_VALUE',
message:
"Invalid config at app.routes.bindings['mySource'], value must be a non-empty string or false",
context: { routeId: 'mySource' },
},
]);
expect(() =>
resolveRouteBindings(
() => {},
new ConfigReader({
app: { routes: { bindings: { mySource: 'myTarget' } } },
}),
emptyIds,
),
).toThrow(
"Invalid config at app.routes.bindings, 'mySource' is not a valid external route",
resolveRouteBindings(
() => {},
new ConfigReader({
app: { routes: { bindings: { mySource: 'myTarget' } } },
}),
emptyIds,
collector,
);
expect(collector.collectErrors()).toEqual([
{
code: 'ROUTE_NOT_FOUND',
message:
"Invalid config at app.routes.bindings, 'mySource' is not a valid external route",
context: { routeId: 'mySource' },
},
]);
expect(() =>
resolveRouteBindings(
() => {},
new ConfigReader({
app: { routes: { bindings: { mySource: 'myTarget' } } },
}),
{
...emptyIds,
externalRoutes: new Map([['mySource', createExternalRouteRef()]]),
},
),
).toThrow(
"Invalid config at app.routes.bindings['mySource'], 'myTarget' is not a valid route",
resolveRouteBindings(
() => {},
new ConfigReader({
app: { routes: { bindings: { mySource: 'myTarget' } } },
}),
{
...emptyIds,
externalRoutes: new Map([['mySource', createExternalRouteRef()]]),
},
collector,
);
expect(collector.collectErrors()).toEqual([
{
code: 'ROUTE_NOT_FOUND',
message:
"Invalid config at app.routes.bindings['mySource'], 'myTarget' is not a valid route",
context: { routeId: 'myTarget' },
},
]);
});
it('can have default targets, but at the lowest priority', () => {
@@ -170,6 +208,7 @@ describe('resolveRouteBindings', () => {
() => {},
new ConfigReader({}),
routesById,
collector,
);
expect(result.get(source)).toBe(target1);
@@ -181,6 +220,7 @@ describe('resolveRouteBindings', () => {
app: { routes: { bindings: { source: 'target2' } } },
}),
routesById,
collector,
);
expect(result.get(source)).toBe(target2);
@@ -192,6 +232,7 @@ describe('resolveRouteBindings', () => {
},
new ConfigReader({}),
routesById,
collector,
);
expect(result.get(source)).toBe(target2);
@@ -210,6 +251,7 @@ describe('resolveRouteBindings', () => {
() => {},
new ConfigReader({}),
routesById,
collector,
);
expect(result.get(source)).toBe(target1);
@@ -219,6 +261,7 @@ describe('resolveRouteBindings', () => {
() => {},
new ConfigReader({ app: { routes: { bindings: { source: false } } } }),
routesById,
collector,
);
expect(result.get(source)).toBe(undefined);
@@ -20,6 +20,7 @@ import {
ExternalRouteRef,
} from '@backstage/frontend-plugin-api';
import { RouteRefsById } from './collectRouteIds';
import { ErrorCollector } from '../wiring/createErrorCollector';
import { Config } from '@backstage/config';
import { JsonObject } from '@backstage/types';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
@@ -80,6 +81,7 @@ export function resolveRouteBindings(
bindRoutes: ((context: { bind: CreateAppRouteBinder }) => void) | undefined,
config: Config,
routesById: RouteRefsById,
collector: ErrorCollector,
): Map<ExternalRouteRef, RouteRef | SubRouteRef> {
const result = new Map<ExternalRouteRef, RouteRef | SubRouteRef>();
const disabledExternalRefs = new Set<ExternalRouteRef>();
@@ -93,7 +95,12 @@ export function resolveRouteBindings(
for (const [key, value] of Object.entries(targetRoutes)) {
const externalRoute = externalRoutes[key];
if (!externalRoute) {
throw new Error(`Key ${key} is not an existing external route`);
collector.report({
code: 'ROUTE_NOT_FOUND',
message: `Key ${key} is not an existing external route`,
context: { routeId: String(key) },
});
continue;
}
if (value) {
result.set(externalRoute, value);
@@ -112,16 +119,22 @@ export function resolveRouteBindings(
if (bindings) {
for (const [externalRefId, targetRefId] of Object.entries(bindings)) {
if (!isValidTargetRefId(targetRefId)) {
throw new Error(
`Invalid config at app.routes.bindings['${externalRefId}'], value must be a non-empty string or false`,
);
collector.report({
code: 'ROUTE_BINDING_INVALID_VALUE',
message: `Invalid config at app.routes.bindings['${externalRefId}'], value must be a non-empty string or false`,
context: { routeId: externalRefId },
});
continue;
}
const externalRef = routesById.externalRoutes.get(externalRefId);
if (!externalRef) {
throw new Error(
`Invalid config at app.routes.bindings, '${externalRefId}' is not a valid external route`,
);
collector.report({
code: 'ROUTE_NOT_FOUND',
message: `Invalid config at app.routes.bindings, '${externalRefId}' is not a valid external route`,
context: { routeId: externalRefId },
});
continue;
}
// Skip if binding was already defined in code
@@ -134,9 +147,14 @@ export function resolveRouteBindings(
} else {
const targetRef = routesById.routes.get(targetRefId);
if (!targetRef) {
throw new Error(
`Invalid config at app.routes.bindings['${externalRefId}'], '${targetRefId}' is not a valid route`,
);
collector.report({
code: 'ROUTE_NOT_FOUND',
message: `Invalid config at app.routes.bindings['${externalRefId}'], '${String(
targetRefId,
)}' is not a valid route`,
context: { routeId: String(targetRefId) },
});
continue;
}
result.set(externalRef, targetRef);
File diff suppressed because it is too large Load Diff
@@ -27,6 +27,36 @@ import { AppNode, AppNodeInstance } from '@backstage/frontend-plugin-api';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { toInternalExtension } from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition';
import { createExtensionDataContainer } from '@internal/frontend';
import { ErrorCollector } from '../wiring/createErrorCollector';
const INSTANTIATION_FAILED = new Error('Instantiation failed');
/**
* Like `array.map`, but if `INSTANTIATION_FAILED` is thrown, the iteration will continue but afterwards re-throw `INSTANTIATION_FAILED`
* @returns
*/
function mapWithFailures<T, U>(
iterable: Iterable<T>,
callback: (item: T) => U,
): U[] {
let failed = false;
const results = Array.from(iterable).map(item => {
try {
return callback(item);
} catch (error) {
if (error === INSTANTIATION_FAILED) {
failed = true;
} else {
throw error;
}
return null as any;
}
});
if (failed) {
throw INSTANTIATION_FAILED;
}
return results;
}
type Mutable<T> = {
-readonly [P in keyof T]: T[P];
@@ -39,37 +69,45 @@ function resolveV1InputDataMap(
attachment: AppNode,
inputName: string,
) {
return mapValues(dataMap, ref => {
const value = attachment.instance?.getData(ref);
if (value === undefined && !ref.config.optional) {
const expected = Object.values(dataMap)
.filter(r => !r.config.optional)
.map(r => `'${r.id}'`)
.join(', ');
return Object.fromEntries(
mapWithFailures(Object.entries(dataMap), ([key, ref]) => {
const value = attachment.instance?.getData(ref);
if (value === undefined && !ref.config.optional) {
const expected = Object.values(dataMap)
.filter(r => !r.config.optional)
.map(r => `'${r.id}'`)
.join(', ');
const provided = [...(attachment.instance?.getDataRefs() ?? [])]
.map(r => `'${r.id}'`)
.join(', ');
const provided = [...(attachment.instance?.getDataRefs() ?? [])]
.map(r => `'${r.id}'`)
.join(', ');
throw new Error(
`extension '${attachment.spec.id}' could not be attached because its output data (${provided}) does not match what the input '${inputName}' requires (${expected})`,
);
}
return value;
});
throw new Error(
`extension '${attachment.spec.id}' could not be attached because its output data (${provided}) does not match what the input '${inputName}' requires (${expected})`,
);
}
return [key, value];
}),
);
}
function resolveInputDataContainer(
extensionData: Array<ExtensionDataRef>,
attachment: AppNode,
inputName: string,
collector: ErrorCollector<{ node: AppNode; inputName: string }>,
): { node: AppNode } & ExtensionDataContainer<ExtensionDataRef> {
const dataMap = new Map<string, unknown>();
for (const ref of extensionData) {
mapWithFailures(extensionData, ref => {
if (dataMap.has(ref.id)) {
throw new Error(`Unexpected duplicate input data '${ref.id}'`);
collector.report({
code: 'EXTENSION_INPUT_DATA_IGNORED',
message: `Unexpected duplicate input data '${ref.id}'`,
});
return;
}
const value = attachment.instance?.getData(ref);
if (value === undefined && !ref.config.optional) {
const expected = extensionData
@@ -81,13 +119,15 @@ function resolveInputDataContainer(
.map(r => `'${r.id}'`)
.join(', ');
throw new Error(
`extension '${attachment.spec.id}' could not be attached because its output data (${provided}) does not match what the input '${inputName}' requires (${expected})`,
);
collector.report({
code: 'EXTENSION_INPUT_DATA_MISSING',
message: `extension '${attachment.spec.id}' could not be attached because its output data (${provided}) does not match what the input '${inputName}' requires (${expected})`,
});
throw INSTANTIATION_FAILED;
}
dataMap.set(ref.id, value);
}
});
return {
node: attachment,
@@ -147,40 +187,52 @@ function resolveV1Inputs(
},
attachments: ReadonlyMap<string, AppNode[]>,
) {
return mapValues(inputMap, (input, inputName) => {
const attachedNodes = attachments.get(inputName) ?? [];
return Object.fromEntries(
mapWithFailures(Object.entries(inputMap), ([inputName, input]) => {
const attachedNodes = attachments.get(inputName) ?? [];
if (input.config.singleton) {
if (attachedNodes.length > 1) {
const attachedNodeIds = attachedNodes.map(e => e.spec.id);
throw Error(
`expected ${
input.config.optional ? 'at most' : 'exactly'
} one '${inputName}' input but received multiple: '${attachedNodeIds.join(
"', '",
)}'`,
);
} else if (attachedNodes.length === 0) {
if (input.config.optional) {
return undefined;
if (input.config.singleton) {
if (attachedNodes.length > 1) {
const attachedNodeIds = attachedNodes.map(e => e.spec.id);
throw Error(
`expected ${
input.config.optional ? 'at most' : 'exactly'
} one '${inputName}' input but received multiple: '${attachedNodeIds.join(
"', '",
)}'`,
);
} else if (attachedNodes.length === 0) {
if (input.config.optional) {
return [inputName, undefined];
}
throw Error(`input '${inputName}' is required but was not received`);
}
throw Error(`input '${inputName}' is required but was not received`);
}
return {
node: attachedNodes[0],
output: resolveV1InputDataMap(
input.extensionData,
attachedNodes[0],
return [
inputName,
),
};
}
{
node: attachedNodes[0],
output: resolveV1InputDataMap(
input.extensionData,
attachedNodes[0],
inputName,
),
},
];
}
return attachedNodes.map(attachment => ({
node: attachment,
output: resolveV1InputDataMap(input.extensionData, attachment, inputName),
}));
}) as {
return [
inputName,
attachedNodes.map(attachment => ({
node: attachment,
output: resolveV1InputDataMap(
input.extensionData,
attachment,
inputName,
),
})),
];
}),
) as {
[inputName in string]: {
node: AppNode;
output: {
@@ -198,6 +250,7 @@ function resolveV2Inputs(
>;
},
attachments: ReadonlyMap<string, AppNode[]>,
parentCollector: ErrorCollector<{ node: AppNode }>,
): ResolvedExtensionInputs<{
[inputName in string]: ExtensionInput<
ExtensionDataRef,
@@ -206,32 +259,43 @@ function resolveV2Inputs(
}> {
return mapValues(inputMap, (input, inputName) => {
const attachedNodes = attachments.get(inputName) ?? [];
const collector = parentCollector.child({ inputName });
if (input.config.singleton) {
if (attachedNodes.length > 1) {
const attachedNodeIds = attachedNodes.map(e => e.spec.id);
throw Error(
`expected ${
const attachedNodeIds = attachedNodes.map(e => e.spec.id).join("', '");
collector.report({
code: 'EXTENSION_ATTACHMENT_CONFLICT',
message: `expected ${
input.config.optional ? 'at most' : 'exactly'
} one '${inputName}' input but received multiple: '${attachedNodeIds.join(
"', '",
)}'`,
);
} one '${inputName}' input but received multiple: '${attachedNodeIds}'`,
});
throw INSTANTIATION_FAILED;
} else if (attachedNodes.length === 0) {
if (input.config.optional) {
return undefined;
if (!input.config.optional) {
collector.report({
code: 'EXTENSION_ATTACHMENT_MISSING',
message: `input '${inputName}' is required but was not received`,
});
throw INSTANTIATION_FAILED;
}
throw Error(`input '${inputName}' is required but was not received`);
return undefined;
}
return resolveInputDataContainer(
input.extensionData,
attachedNodes[0],
inputName,
collector,
);
}
return attachedNodes.map(attachment =>
resolveInputDataContainer(input.extensionData, attachment, inputName),
return mapWithFailures(attachedNodes, attachment =>
resolveInputDataContainer(
input.extensionData,
attachment,
inputName,
collector,
),
);
}) as ResolvedExtensionInputs<{
[inputName in string]: ExtensionInput<
@@ -247,8 +311,10 @@ export function createAppNodeInstance(options: {
node: AppNode;
apis: ApiHolder;
attachments: ReadonlyMap<string, AppNode[]>;
}): AppNodeInstance {
collector: ErrorCollector;
}): 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>>();
@@ -259,9 +325,11 @@ export function createAppNodeInstance(options: {
[x: string]: any;
};
} catch (e) {
throw new Error(
`Invalid configuration for extension '${id}'; caused by ${e}`,
);
collector.report({
code: 'EXTENSION_CONFIGURATION_INVALID',
message: `Invalid configuration for extension '${id}'; caused by ${e}`,
});
return undefined;
}
try {
@@ -297,7 +365,11 @@ export function createAppNodeInstance(options: {
node,
apis,
config: parsedConfig,
inputs: resolveV2Inputs(internalExtension.inputs, attachments),
inputs: resolveV2Inputs(
internalExtension.inputs,
attachments,
collector,
),
};
const outputDataValues = options.extensionFactoryMiddleware
? createExtensionDataContainer(
@@ -324,21 +396,34 @@ export function createAppNodeInstance(options: {
}
const outputDataMap = new Map<string, unknown>();
for (const value of outputDataValues) {
mapWithFailures(outputDataValues, value => {
if (outputDataMap.has(value.id)) {
throw new Error(`duplicate extension data output '${value.id}'`);
collector.report({
code: 'EXTENSION_OUTPUT_CONFLICT',
message: `extension factory output duplicate data '${value.id}'`,
context: {
dataRefId: value.id,
},
});
throw INSTANTIATION_FAILED;
} else {
outputDataMap.set(value.id, value.value);
}
outputDataMap.set(value.id, value.value);
}
});
for (const ref of internalExtension.output) {
const value = outputDataMap.get(ref.id);
outputDataMap.delete(ref.id);
if (value === undefined) {
if (!ref.config.optional) {
throw new Error(
`missing required extension data output '${ref.id}'`,
);
collector.report({
code: 'EXTENSION_OUTPUT_MISSING',
message: `missing required extension data output '${ref.id}'`,
context: {
dataRefId: ref.id,
},
});
throw INSTANTIATION_FAILED;
}
} else {
extensionData.set(ref.id, value);
@@ -347,23 +432,36 @@ export function createAppNodeInstance(options: {
}
if (outputDataMap.size > 0) {
throw new Error(
`unexpected output '${Array.from(outputDataMap.keys()).join(
"', '",
)}'`,
);
for (const dataRefId of outputDataMap.keys()) {
// TODO: Make this a warning
collector.report({
code: 'EXTENSION_OUTPUT_IGNORED',
message: `unexpected output '${dataRefId}'`,
context: {
dataRefId: dataRefId,
},
});
}
}
} else {
throw new Error(
`unexpected extension version '${(internalExtension as any).version}'`,
);
collector.report({
code: 'EXTENSION_INVALID',
message: `unexpected extension version '${
(internalExtension as any).version
}'`,
});
throw INSTANTIATION_FAILED;
}
} catch (e) {
throw new Error(
`Failed to instantiate extension '${id}'${
e.name === 'Error' ? `, ${e.message}` : `; caused by ${e.stack}`
}`,
);
if (e !== INSTANTIATION_FAILED) {
collector.report({
code: 'EXTENSION_FACTORY_ERROR',
message: `Failed to instantiate extension '${id}'${
e.name === 'Error' ? `, ${e.message}` : `; caused by ${e}`
}`,
});
}
return undefined;
}
return {
@@ -383,8 +481,9 @@ export function createAppNodeInstance(options: {
export function instantiateAppNodeTree(
rootNode: AppNode,
apis: ApiHolder,
collector: ErrorCollector,
extensionFactoryMiddleware?: ExtensionFactoryMiddleware,
): void {
): boolean {
function createInstance(node: AppNode): AppNodeInstance | undefined {
if (node.instance) {
return node.instance;
@@ -413,10 +512,11 @@ export function instantiateAppNodeTree(
node,
apis,
attachments: instantiatedAttachments,
collector,
});
return node.instance;
}
createInstance(rootNode);
return createInstance(rootNode) !== undefined;
}
@@ -21,6 +21,18 @@ import {
ExtensionDefinition,
} from '@backstage/frontend-plugin-api';
import { resolveAppNodeSpecs } from './resolveAppNodeSpecs';
import { createErrorCollector } from '../wiring/createErrorCollector';
const collector = createErrorCollector();
afterEach(() => {
const errors = collector.collectErrors();
if (errors) {
throw new Error(
`Unexpected errors: ${errors.map(e => e.message).join(', ')}`,
);
}
});
function makeExt(
id: string,
@@ -61,6 +73,7 @@ describe('resolveAppNodeSpecs', () => {
features: [],
builtinExtensions: [a],
parameters: [],
collector,
}),
).toEqual([
{
@@ -83,6 +96,7 @@ describe('resolveAppNodeSpecs', () => {
features: [],
builtinExtensions: [a, b],
parameters: [],
collector,
}),
).toEqual([
{
@@ -122,6 +136,7 @@ describe('resolveAppNodeSpecs', () => {
attachTo: { id: 'derp', input: 'default' },
},
],
collector,
}),
).toEqual([
{
@@ -169,6 +184,7 @@ describe('resolveAppNodeSpecs', () => {
config: { foo: { qux: 3 } },
},
],
collector,
}),
).toEqual([
{
@@ -209,6 +225,7 @@ describe('resolveAppNodeSpecs', () => {
disabled: false,
},
],
collector,
}),
).toEqual([
{
@@ -249,6 +266,7 @@ describe('resolveAppNodeSpecs', () => {
{ id: 'd', disabled: false },
{ id: 'c', disabled: false },
],
collector,
}),
).toEqual([
{
@@ -341,6 +359,7 @@ describe('resolveAppNodeSpecs', () => {
],
builtinExtensions: [],
parameters: [],
collector,
}),
).toEqual([
{
@@ -395,6 +414,7 @@ describe('resolveAppNodeSpecs', () => {
id,
disabled: false,
})),
collector,
});
expect(result.map(r => r.extension.id)).toEqual([
@@ -405,53 +425,83 @@ describe('resolveAppNodeSpecs', () => {
});
it('throws an error when a forbidden extension is overridden by a plugin', () => {
expect(() =>
resolveAppNodeSpecs({
features: [
createFrontendPlugin({
pluginId: 'test',
extensions: [makeExtDef('forbidden')],
}),
],
builtinExtensions: [],
parameters: [],
forbidden: new Set(['test/forbidden']),
}),
).toThrow(
"It is forbidden to override the following extension(s): 'test/forbidden', which is done by the following plugin(s): 'test'",
);
const plugin = createFrontendPlugin({
pluginId: 'test',
extensions: [makeExtDef('forbidden')],
});
const result = resolveAppNodeSpecs({
features: [plugin],
builtinExtensions: [],
parameters: [],
forbidden: new Set(['test/forbidden']),
collector,
});
expect(result).toEqual([]);
expect(collector.collectErrors()).toEqual([
{
code: 'EXTENSION_IGNORED',
message:
"It is forbidden to override the 'test/forbidden' extension, attempted by the 'test' plugin",
context: {
plugin,
extensionId: 'test/forbidden',
},
},
]);
});
it('throws an error when a forbidden extension is overridden by module', () => {
expect(() =>
resolveAppNodeSpecs({
features: [
createFrontendPlugin({
pluginId: 'forbidden',
extensions: [],
}),
createFrontendModule({
pluginId: 'forbidden',
extensions: [makeExtDef()],
}),
],
builtinExtensions: [],
parameters: [],
forbidden: new Set(['forbidden']),
}),
).toThrow(
"It is forbidden to override the following extension(s): 'forbidden', which is done by a module for the following plugin(s): 'forbidden'",
);
const plugin = createFrontendPlugin({
pluginId: 'forbidden',
extensions: [],
});
const result = resolveAppNodeSpecs({
features: [
plugin,
createFrontendModule({
pluginId: 'forbidden',
extensions: [makeExtDef()],
}),
],
builtinExtensions: [],
parameters: [],
forbidden: new Set(['forbidden']),
collector,
});
expect(result).toEqual([]);
expect(collector.collectErrors()).toEqual([
{
code: 'EXTENSION_IGNORED',
message:
"It is forbidden to override the 'forbidden' extension, attempted by the 'forbidden' plugin",
context: {
plugin,
extensionId: 'forbidden',
},
},
]);
});
it('throws an error when a forbidden extension is parametrized', () => {
expect(() =>
resolveAppNodeSpecs({
features: [],
builtinExtensions: [],
parameters: [{ id: 'forbidden', disabled: false }],
forbidden: new Set(['forbidden']),
}),
).toThrow("Configuration of the 'forbidden' extension is forbidden");
const result = resolveAppNodeSpecs({
features: [],
builtinExtensions: [],
parameters: [{ id: 'forbidden', disabled: false }],
forbidden: new Set(['forbidden']),
collector,
});
expect(result).toEqual([]);
expect(collector.collectErrors()).toEqual([
{
code: 'INVALID_EXTENSION_CONFIG_KEY',
message: "Configuration of the 'forbidden' extension is forbidden",
context: {
extensionId: 'forbidden',
},
},
]);
});
});
@@ -18,6 +18,7 @@ import {
createFrontendPlugin,
Extension,
FrontendFeature,
FrontendPlugin,
} from '@backstage/frontend-plugin-api';
import { ExtensionParameters } from './readAppExtensionsConfig';
import { AppNodeSpec } from '@backstage/frontend-plugin-api';
@@ -29,6 +30,7 @@ import {
} from '../../../frontend-plugin-api/src/wiring/createFrontendModule';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { toInternalExtension } from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition';
import { ErrorCollector } from '../wiring/createErrorCollector';
/** @internal */
export function resolveAppNodeSpecs(options: {
@@ -36,61 +38,58 @@ export function resolveAppNodeSpecs(options: {
builtinExtensions?: Extension<any, any>[];
parameters?: Array<ExtensionParameters>;
forbidden?: Set<string>;
allowUnknownExtensionConfig?: boolean;
collector: ErrorCollector;
}): AppNodeSpec[] {
const {
builtinExtensions = [],
parameters = [],
forbidden = new Set(),
features = [],
allowUnknownExtensionConfig = false,
collector,
} = options;
const plugins = features.filter(OpaqueFrontendPlugin.isType);
const modules = features.filter(isInternalFrontendModule);
const filterForbidden = (
extension: Extension<any, any> & { plugin: FrontendPlugin },
) => {
if (forbidden.has(extension.id)) {
collector.report({
code: 'EXTENSION_IGNORED',
message: `It is forbidden to override the '${extension.id}' extension, attempted by the '${extension.plugin.id}' plugin`,
context: {
plugin: extension.plugin,
extensionId: extension.id,
},
});
return false;
}
return true;
};
const pluginExtensions = plugins.flatMap(plugin => {
return OpaqueFrontendPlugin.toInternal(plugin).extensions.map(
extension => ({
return OpaqueFrontendPlugin.toInternal(plugin)
.extensions.map(extension => ({
...extension,
plugin,
}),
);
}))
.filter(filterForbidden);
});
const moduleExtensions = modules.flatMap(mod =>
toInternalFrontendModule(mod).extensions.flatMap(extension => {
// Modules for plugins that are not installed are ignored
const plugin = plugins.find(p => p.id === mod.pluginId);
if (!plugin) {
return [];
}
toInternalFrontendModule(mod)
.extensions.flatMap(extension => {
// Modules for plugins that are not installed are ignored
const plugin = plugins.find(p => p.id === mod.pluginId);
if (!plugin) {
return [];
}
return [{ ...extension, plugin }];
}),
return [{ ...extension, plugin }];
})
.filter(filterForbidden),
);
// Prevent core override
if (pluginExtensions.some(({ id }) => forbidden.has(id))) {
const pluginsStr = pluginExtensions
.filter(({ id }) => forbidden.has(id))
.map(({ plugin }) => `'${plugin.id}'`)
.join(', ');
const forbiddenStr = [...forbidden].map(id => `'${id}'`).join(', ');
throw new Error(
`It is forbidden to override the following extension(s): ${forbiddenStr}, which is done by the following plugin(s): ${pluginsStr}`,
);
}
if (moduleExtensions.some(({ id }) => forbidden.has(id))) {
const pluginsStr = moduleExtensions
.filter(({ id }) => forbidden.has(id))
.map(({ plugin }) => `'${plugin.id}'`)
.join(', ');
const forbiddenStr = [...forbidden].map(id => `'${id}'`).join(', ');
throw new Error(
`It is forbidden to override the following extension(s): ${forbiddenStr}, which is done by a module for the following plugin(s): ${pluginsStr}`,
);
}
const appPlugin =
plugins.find(plugin => plugin.id === 'app') ??
createFrontendPlugin({
@@ -154,52 +153,41 @@ export function resolveAppNodeSpecs(options: {
}
}
const duplicatedExtensionIds = new Set<string>();
const duplicatedExtensionData = configuredExtensions.reduce<
Record<string, Record<string, number>>
>((data, { extension, params }) => {
const extensionId = extension.id;
const extensionData = data?.[extensionId];
if (extensionData) duplicatedExtensionIds.add(extensionId);
const pluginId = params.source?.id ?? 'internal';
const pluginCount = extensionData?.[pluginId] ?? 0;
return {
...data,
[extensionId]: { ...extensionData, [pluginId]: pluginCount + 1 },
};
}, {});
const seenExtensionIds = new Set<string>();
const deduplicatedExtensions = configuredExtensions.filter(
({ extension, params }) => {
if (seenExtensionIds.has(extension.id)) {
collector.report({
code: 'EXTENSION_IGNORED',
message: `The '${extension.id}' extension from the '${params.plugin.id}' plugin is a duplicate and will be ignored`,
context: {
plugin: params.plugin,
extensionId: extension.id,
},
});
return false;
}
seenExtensionIds.add(extension.id);
return true;
},
);
if (duplicatedExtensionIds.size > 0) {
throw new Error(
`The following extensions are duplicated: ${Array.from(
duplicatedExtensionIds,
)
.map(
extensionId =>
`The extension '${extensionId}' was provided ${Object.keys(
duplicatedExtensionData[extensionId],
)
.map(
pluginId =>
`${duplicatedExtensionData[extensionId][pluginId]} time(s) by the plugin '${pluginId}'`,
)
.join(' and ')}`,
)
.join(', ')}`,
);
}
const order = new Map<string, (typeof configuredExtensions)[number]>();
const order = new Map<string, (typeof deduplicatedExtensions)[number]>();
for (const overrideParam of parameters) {
const extensionId = overrideParam.id;
if (forbidden.has(extensionId)) {
throw new Error(
`Configuration of the '${extensionId}' extension is forbidden`,
);
collector.report({
code: 'INVALID_EXTENSION_CONFIG_KEY',
message: `Configuration of the '${extensionId}' extension is forbidden`,
context: {
extensionId,
},
});
continue;
}
const existing = configuredExtensions.find(
const existing = deduplicatedExtensions.find(
e => e.extension.id === extensionId,
);
if (existing) {
@@ -216,14 +204,20 @@ export function resolveAppNodeSpecs(options: {
existing.params.disabled = Boolean(overrideParam.disabled);
}
order.set(extensionId, existing);
} else if (!allowUnknownExtensionConfig) {
throw new Error(`Extension ${extensionId} does not exist`);
} else {
collector.report({
code: 'INVALID_EXTENSION_CONFIG_KEY',
message: `Extension ${extensionId} does not exist`,
context: {
extensionId,
},
});
}
}
const orderedExtensions = [
...order.values(),
...configuredExtensions.filter(e => !order.has(e.extension.id)),
...deduplicatedExtensions.filter(e => !order.has(e.extension.id)),
];
return orderedExtensions.map(param => ({
@@ -24,6 +24,18 @@ import {
import { resolveAppTree } from './resolveAppTree';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { resolveExtensionDefinition } from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition';
import { createErrorCollector } from '../wiring/createErrorCollector';
const collector = createErrorCollector();
afterEach(() => {
const errors = collector.collectErrors();
if (errors) {
throw new Error(
`Unexpected errors: ${errors.map(e => e.message).join(', ')}`,
);
}
});
const extension = resolveExtensionDefinition(
createExtension({
@@ -43,13 +55,13 @@ const baseSpec = {
describe('buildAppTree', () => {
it('should fail to create an empty tree', () => {
expect(() => resolveAppTree('app', [])).toThrow(
expect(() => resolveAppTree('app', [], collector)).toThrow(
"No root node with id 'app' found in app tree",
);
});
it('should create a tree with only one node', () => {
const tree = resolveAppTree('app', [{ ...baseSpec, id: 'app' }]);
const tree = resolveAppTree('app', [{ ...baseSpec, id: 'app' }], collector);
expect(tree.root).toEqual({
spec: { ...baseSpec, id: 'app' },
edges: { attachments: new Map() },
@@ -59,15 +71,19 @@ describe('buildAppTree', () => {
});
it('should create a tree', () => {
const tree = resolveAppTree('b', [
{ ...baseSpec, id: 'a' },
{ ...baseSpec, id: 'b' },
{ ...baseSpec, id: 'c' },
{ ...baseSpec, attachTo: { id: 'b', input: 'x' }, id: 'bx1' },
{ ...baseSpec, attachTo: { id: 'b', input: 'x' }, id: 'bx2' },
{ ...baseSpec, attachTo: { id: 'b', input: 'y' }, id: 'by1' },
{ ...baseSpec, attachTo: { id: 'd', input: 'x' }, id: 'dx1' },
]);
const tree = resolveAppTree(
'b',
[
{ ...baseSpec, id: 'a' },
{ ...baseSpec, id: 'b' },
{ ...baseSpec, id: 'c' },
{ ...baseSpec, attachTo: { id: 'b', input: 'x' }, id: 'bx1' },
{ ...baseSpec, attachTo: { id: 'b', input: 'x' }, id: 'bx2' },
{ ...baseSpec, attachTo: { id: 'b', input: 'y' }, id: 'by1' },
{ ...baseSpec, attachTo: { id: 'd', input: 'x' }, id: 'dx1' },
],
collector,
);
expect(Array.from(tree.nodes.keys())).toEqual([
'a',
@@ -122,26 +138,30 @@ describe('buildAppTree', () => {
});
it('should create a tree with clones', () => {
const tree = resolveAppTree('a', [
{ ...baseSpec, id: 'a' },
{ ...baseSpec, id: 'b', attachTo: { id: 'a', input: 'x' } },
{
...baseSpec,
id: 'c',
attachTo: [
{ id: 'a', input: 'x' },
{ id: 'b', input: 'x' },
],
},
{
...baseSpec,
id: 'd',
attachTo: [
{ id: 'b', input: 'x' },
{ id: 'c', input: 'x' },
],
},
]);
const tree = resolveAppTree(
'a',
[
{ ...baseSpec, id: 'a' },
{ ...baseSpec, id: 'b', attachTo: { id: 'a', input: 'x' } },
{
...baseSpec,
id: 'c',
attachTo: [
{ id: 'a', input: 'x' },
{ id: 'b', input: 'x' },
],
},
{
...baseSpec,
id: 'd',
attachTo: [
{ id: 'b', input: 'x' },
{ id: 'c', input: 'x' },
],
},
],
collector,
);
expect(Array.from(tree.nodes.keys())).toEqual(['a', 'b', 'c', 'd']);
@@ -172,15 +192,19 @@ describe('buildAppTree', () => {
});
it('should create a tree out of order', () => {
const tree = resolveAppTree('b', [
{ ...baseSpec, attachTo: { id: 'b', input: 'x' }, id: 'bx2' },
{ ...baseSpec, id: 'a' },
{ ...baseSpec, attachTo: { id: 'b', input: 'y' }, id: 'by1' },
{ ...baseSpec, id: 'b' },
{ ...baseSpec, attachTo: { id: 'b', input: 'x' }, id: 'bx1' },
{ ...baseSpec, id: 'c' },
{ ...baseSpec, attachTo: { id: 'd', input: 'x' }, id: 'dx1' },
]);
const tree = resolveAppTree(
'b',
[
{ ...baseSpec, attachTo: { id: 'b', input: 'x' }, id: 'bx2' },
{ ...baseSpec, id: 'a' },
{ ...baseSpec, attachTo: { id: 'b', input: 'y' }, id: 'by1' },
{ ...baseSpec, id: 'b' },
{ ...baseSpec, attachTo: { id: 'b', input: 'x' }, id: 'bx1' },
{ ...baseSpec, id: 'c' },
{ ...baseSpec, attachTo: { id: 'd', input: 'x' }, id: 'dx1' },
],
collector,
);
expect(Array.from(tree.nodes.keys())).toEqual([
'bx2',
@@ -214,13 +238,16 @@ describe('buildAppTree', () => {
`);
});
it('throws an error when duplicated extensions are detected', () => {
expect(() =>
resolveAppTree('app', [
it('ignores duplicate extensions', () => {
const tree = resolveAppTree(
'a',
[
{ ...baseSpec, id: 'a' },
{ ...baseSpec, id: 'a' },
]),
).toThrow("Unexpected duplicate extension id 'a'");
],
collector,
);
expect(Array.from(tree.nodes.keys())).toEqual(['a']);
});
describe('redirects', () => {
@@ -253,12 +280,30 @@ describe('buildAppTree', () => {
}),
) as Extension<unknown, unknown>;
expect(() =>
resolveAppTree('a', [
const tree = resolveAppTree(
'a',
[
{ ...baseSpec, id: 'a', extension: e1 },
{ ...baseSpec, id: 'b', extension: e2 },
]),
).toThrow("Duplicate redirect target for input 'test' in extension 'b'");
],
collector,
);
expect(Array.from(tree.nodes.keys())).toEqual(['a', 'b']);
expect(collector.collectErrors()).toEqual([
{
code: 'EXTENSION_INPUT_REDIRECT_CONFLICT',
message:
"Duplicate redirect target for input 'test' in extension 'b'",
context: {
node: expect.objectContaining({
spec: { ...baseSpec, id: 'b', extension: e2 },
}),
inputName: 'test',
},
},
]);
});
it('should set the correct attachment point for a redirect', () => {
@@ -285,22 +330,26 @@ describe('buildAppTree', () => {
}),
) as Extension<unknown, unknown>;
const tree = resolveAppTree('a', [
{
attachTo: e1.attachTo,
id: 'a',
extension: e1,
disabled: false,
plugin: baseSpec.plugin,
},
{
attachTo: e2.attachTo,
id: 'b',
extension: e2,
disabled: false,
plugin: baseSpec.plugin,
},
]);
const tree = resolveAppTree(
'a',
[
{
attachTo: e1.attachTo,
id: 'a',
extension: e1,
disabled: false,
plugin: baseSpec.plugin,
},
{
attachTo: e2.attachTo,
id: 'b',
extension: e2,
disabled: false,
plugin: baseSpec.plugin,
},
],
collector,
);
expect(tree.root).toMatchInlineSnapshot(`
{
@@ -365,29 +414,33 @@ describe('buildAppTree', () => {
}),
) as Extension<unknown, unknown>;
const tree = resolveAppTree('test-2', [
{
attachTo: e1.attachTo,
id: e1.id,
extension: e1,
disabled: false,
plugin: baseSpec.plugin,
},
{
attachTo: e2.attachTo,
id: e2.id,
extension: e2,
disabled: false,
plugin: baseSpec.plugin,
},
{
attachTo: e3.attachTo,
id: e3.id,
extension: e3,
disabled: false,
plugin: baseSpec.plugin,
},
]);
const tree = resolveAppTree(
'test-2',
[
{
attachTo: e1.attachTo,
id: e1.id,
extension: e1,
disabled: false,
plugin: baseSpec.plugin,
},
{
attachTo: e2.attachTo,
id: e2.id,
extension: e2,
disabled: false,
plugin: baseSpec.plugin,
},
{
attachTo: e3.attachTo,
id: e3.id,
extension: e3,
disabled: false,
plugin: baseSpec.plugin,
},
],
collector,
);
expect(tree.nodes.get('test-3')?.edges.attachedTo?.node).toBe(
tree.nodes.get('test-2'),
@@ -23,6 +23,7 @@ import {
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { toInternalExtension } from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition';
import { ErrorCollector } from '../wiring/createErrorCollector';
function indent(str: string) {
return str.replace(/^/gm, ' ');
@@ -114,15 +115,16 @@ const isValidAttachmentPoint = (
export function resolveAppTree(
rootNodeId: string,
specs: AppNodeSpec[],
errorCollector: ErrorCollector,
): AppTree {
const nodes = new Map<string, SerializableAppNode>();
const redirectTargetsByKey = new Map<string, { id: string; input: string }>();
for (const spec of specs) {
// The main check with a more helpful error message happens in resolveAppNodeSpecs
// The main check with a helpful error message happens in resolveAppNodeSpecs
if (nodes.has(spec.id)) {
throw new Error(`Unexpected duplicate extension id '${spec.id}'`);
continue;
}
const node = new SerializableAppNode(spec);
@@ -134,9 +136,15 @@ export function resolveAppTree(
for (const replace of input.replaces) {
const key = makeRedirectKey(replace);
if (redirectTargetsByKey.has(key)) {
throw new Error(
`Duplicate redirect target for input '${inputName}' in extension '${spec.id}'`,
);
errorCollector.report({
code: 'EXTENSION_INPUT_REDIRECT_CONFLICT',
message: `Duplicate redirect target for input '${inputName}' in extension '${spec.id}'`,
context: {
node,
inputName,
},
});
continue;
}
redirectTargetsByKey.set(key, { id: spec.id, input: inputName });
}
@@ -0,0 +1,150 @@
/*
* 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 { AppNode, FrontendPlugin } from '@backstage/frontend-plugin-api';
/**
* @public
*/
export type AppErrorTypes = {
// resolveAppNodeSpecs
EXTENSION_IGNORED: {
context: { plugin: FrontendPlugin; extensionId: string };
};
INVALID_EXTENSION_CONFIG_KEY: {
context: { extensionId: string };
};
// resolveAppTree
EXTENSION_INPUT_REDIRECT_CONFLICT: {
context: { node: AppNode; inputName: string };
};
// instantiateAppNodeTree
EXTENSION_INPUT_DATA_IGNORED: {
context: { node: AppNode; inputName: string };
};
EXTENSION_INPUT_DATA_MISSING: {
context: { node: AppNode; inputName: string };
};
EXTENSION_ATTACHMENT_CONFLICT: {
context: { node: AppNode; inputName: string };
};
EXTENSION_ATTACHMENT_MISSING: {
context: { node: AppNode; inputName: string };
};
EXTENSION_CONFIGURATION_INVALID: {
context: { node: AppNode };
};
EXTENSION_INVALID: {
context: { node: AppNode };
};
EXTENSION_OUTPUT_CONFLICT: {
context: { node: AppNode; dataRefId: string };
};
EXTENSION_OUTPUT_MISSING: {
context: { node: AppNode; dataRefId: string };
};
EXTENSION_OUTPUT_IGNORED: {
context: { node: AppNode; dataRefId: string };
};
EXTENSION_FACTORY_ERROR: {
context: { node: AppNode };
};
// createSpecializedApp
API_EXTENSION_INVALID: {
context: { node: AppNode };
};
// routing
ROUTE_DUPLICATE: {
context: { routeId: string };
};
ROUTE_BINDING_INVALID_VALUE: {
context: { routeId: string };
};
ROUTE_NOT_FOUND: {
context: { routeId: string };
};
};
/**
* @public
*/
export type AppError =
keyof AppErrorTypes extends infer ICode extends keyof AppErrorTypes
? ICode extends any
? {
code: ICode;
message: string;
context: AppErrorTypes[ICode]['context'];
}
: never
: never;
/** @internal */
export interface ErrorCollector<TContext extends {} = {}> {
report<TCode extends keyof AppErrorTypes>(
report: Omit<
AppErrorTypes[TCode]['context'],
keyof TContext
> extends infer IContext extends {}
? {} extends IContext
? {
code: TCode;
message: string;
}
: {
code: TCode;
message: string;
context: IContext;
}
: never,
): void;
child<TAdditionalContext extends {}>(
context: TAdditionalContext,
): ErrorCollector<TContext & TAdditionalContext>;
collectErrors(): AppError[] | undefined;
}
/** @internal */
export function createErrorCollector(
context?: Partial<AppError['context']>,
): ErrorCollector {
const errors: AppError[] = [];
const children: ErrorCollector[] = [];
return {
report(report: { code: string; message: string; context?: {} }) {
errors.push({
...report,
context: { ...context, ...report.context },
} as AppError);
},
collectErrors() {
const allErrors = [
...errors,
...children.flatMap(child => child.collectErrors() ?? []),
];
errors.length = 0;
if (allErrors.length === 0) {
return undefined;
}
return allErrors;
},
child(childContext) {
const child = createErrorCollector({ ...context, ...childContext });
children.push(child);
return child as ErrorCollector<any>;
},
};
}
@@ -83,6 +83,11 @@ import {
FrontendPluginInfoResolver,
} from './createPluginInfoAttacher';
import { createRouteAliasResolver } from '../routing/RouteAliasResolver';
import {
AppError,
createErrorCollector,
ErrorCollector,
} from './createErrorCollector';
function deduplicateFeatures(
allFeatures: FrontendFeature[],
@@ -284,12 +289,15 @@ export type CreateSpecializedAppOptions = {
export function createSpecializedApp(options?: CreateSpecializedAppOptions): {
apis: ApiHolder;
tree: AppTree;
errors?: AppError[];
} {
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({
@@ -299,18 +307,18 @@ export function createSpecializedApp(options?: CreateSpecializedAppOptions): {
],
parameters: readAppExtensionsConfig(config),
forbidden: new Set(['root']),
allowUnknownExtensionConfig:
options?.advanced?.allowUnknownExtensionConfig,
collector,
}),
collector,
);
const factories = createApiFactories({ tree });
const factories = createApiFactories({ tree, collector });
const appBasePath = getBasePath(config);
const appTreeApi = new AppTreeApiProxy(tree, appBasePath);
const routeRefsById = collectRouteIds(features);
const routeRefsById = collectRouteIds(features, collector);
const routeResolutionApi = new RouteResolutionApiProxy(
resolveRouteBindings(options?.bindRoutes, config, routeRefsById),
resolveRouteBindings(options?.bindRoutes, config, routeRefsById, collector),
appBasePath,
);
@@ -353,6 +361,7 @@ export function createSpecializedApp(options?: CreateSpecializedAppOptions): {
instantiateAppNodeTree(
tree.root,
apis,
collector,
mergeExtensionFactoryMiddleware(
options?.advanced?.extensionFactoryMiddleware,
),
@@ -366,22 +375,32 @@ export function createSpecializedApp(options?: CreateSpecializedAppOptions): {
routeResolutionApi.initialize(routeInfo, routeRefsById.routes);
appTreeApi.initialize(routeInfo);
return { apis, tree };
return { apis, tree, errors: collector.collectErrors() };
}
function createApiFactories(options: { tree: AppTree }): AnyApiFactory[] {
function createApiFactories(options: {
tree: AppTree;
collector: ErrorCollector;
}): AnyApiFactory[] {
const emptyApiHolder = ApiRegistry.from([]);
const factories = new Array<AnyApiFactory>();
for (const apiNode of options.tree.root.edges.attachments.get('apis') ?? []) {
instantiateAppNodeTree(apiNode, emptyApiHolder);
const apiFactory = apiNode.instance?.getData(ApiBlueprint.dataRefs.factory);
if (!apiFactory) {
throw new Error(
`No API factory found in for extension ${apiNode.spec.id}`,
);
if (!instantiateAppNodeTree(apiNode, emptyApiHolder, options.collector)) {
continue;
}
const apiFactory = apiNode.instance?.getData(ApiBlueprint.dataRefs.factory);
if (apiFactory) {
factories.push(apiFactory);
} else {
options.collector.report({
code: 'API_EXTENSION_INVALID',
message: `API extension '${apiNode.spec.id}' did not output an API factory`,
context: {
node: apiNode,
},
});
}
factories.push(apiFactory);
}
return factories;
@@ -19,3 +19,4 @@ export {
type CreateSpecializedAppOptions,
} from './createSpecializedApp';
export { type FrontendPluginInfoResolver } from './createPluginInfoAttacher';
export { type AppError, type AppErrorTypes } from './createErrorCollector';
+12
View File
@@ -3,6 +3,8 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { AppError } from '@backstage/frontend-app-api';
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';
@@ -45,6 +47,16 @@ export function discoverAvailableFeatures(config: Config): {
features: (FrontendFeature | FrontendFeatureLoader)[];
};
// @public
export function maybeCreateErrorPage(
app: {
errors?: AppError[];
},
options?: {
warningCodes?: Array<keyof AppErrorTypes>;
},
): JSX_2.Element | undefined;
// @public (undocumented)
export function resolveAsyncFeatures(options: {
config: Config;
@@ -36,6 +36,7 @@ import {
import appPlugin from '@backstage/plugin-app';
import { discoverAvailableFeatures } from './discovery';
import { resolveAsyncFeatures } from './resolution';
import { maybeCreateErrorPage } from './maybeCreateErrorPage';
/**
* Options for {@link createApp}.
@@ -136,6 +137,11 @@ export function createApp(options?: CreateAppOptions): {
advanced: options?.advanced,
});
const errorPage = maybeCreateErrorPage(app);
if (errorPage) {
return { default: () => errorPage };
}
const rootEl = app.tree.root.instance!.getData(
coreExtensionData.reactElement,
);
+1
View File
@@ -24,3 +24,4 @@ export { createApp, type CreateAppOptions } from './createApp';
export { createPublicSignInApp } from './createPublicSignInApp';
export { discoverAvailableFeatures } from './discovery';
export { resolveAsyncFeatures } from './resolution';
export { maybeCreateErrorPage } from './maybeCreateErrorPage';
@@ -0,0 +1,116 @@
/*
* 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 { FrontendPluginInfo } from '@backstage/frontend-plugin-api';
import { JSX, useEffect, useState } from 'react';
import { AppError, AppErrorTypes } from '@backstage/frontend-app-api';
const DEFAULT_WARNING_CODES: Array<keyof AppErrorTypes> = [
'EXTENSION_IGNORED',
'INVALID_EXTENSION_CONFIG_KEY',
'EXTENSION_INPUT_DATA_IGNORED',
'EXTENSION_OUTPUT_IGNORED',
];
function AppErrorItem(props: { error: AppError }): JSX.Element {
const { context } = props.error;
const node = 'node' in context ? context.node : undefined;
const extensionId =
'extensionId' in context ? context.extensionId : node?.spec.id;
const routeId = 'routeId' in context ? context.routeId : undefined;
const plugin = 'plugin' in context ? context.plugin : node?.spec.plugin;
const pluginId = plugin?.id ?? 'N/A';
const [info, setInfo] = useState<FrontendPluginInfo | undefined>(undefined);
useEffect(() => {
plugin?.info().then(setInfo, error => {
// eslint-disable-next-line no-console
console.error(`Failed to load info for plugin ${plugin.id}: ${error}`);
});
}, [plugin]);
return (
<div>
<b>{props.error.code}</b>: {props.error.message}
<pre style={{ marginLeft: '1rem' }}>
{extensionId && <div>extensionId: {extensionId}</div>}
{routeId && <div>routeId: {routeId}</div>}
{pluginId && <div>pluginId: {pluginId}</div>}
{info && (
<div>
package: {info.packageName}@{info.version}
</div>
)}
</pre>
</div>
);
}
function AppErrorPage(props: { errors: AppError[] }): JSX.Element {
return (
<div style={{ margin: '1rem' }}>
<h2>App startup failed</h2>
{props.errors.map((error, index) => (
<AppErrorItem error={error} key={index} />
))}
</div>
);
}
/**
* If there are any unrecoverable errors in the app, this will return an error page in the form of a JSX element.
*
* If there are any recoverable errors, they will always be logged as warnings in the console.
* @public
*/
export function maybeCreateErrorPage(
app: { errors?: AppError[] },
options?: {
warningCodes?: Array<keyof AppErrorTypes>;
},
): JSX.Element | undefined {
if (!app.errors) {
return undefined;
}
const errors = new Array<AppError>();
const warnings = new Array<AppError>();
const warningCodes = new Set(options?.warningCodes ?? DEFAULT_WARNING_CODES);
for (const error of app.errors) {
if (warningCodes.has(error.code)) {
warnings.push(error);
} else {
errors.push(error);
}
}
if (warnings.length > 0) {
// eslint-disable-next-line no-console
console.warn('App startup encountered warnings:');
for (const warning of warnings) {
// eslint-disable-next-line no-console
console.warn(`${warning.code}: ${warning.message}`);
}
}
if (errors.length === 0) {
return undefined;
}
return <AppErrorPage errors={errors} />;
}
@@ -1078,7 +1078,7 @@ describe('createExtension', () => {
.add(multi2Ext)
.get(outputRef),
).toThrowErrorMatchingInlineSnapshot(
`"Failed to instantiate extension 'subject', override data provided for input 'multi' must match the length of the original inputs"`,
`"Failed to resolve the extension tree: Failed to instantiate extension 'subject', override data provided for input 'multi' must match the length of the original inputs"`,
);
// Mix forward and data override
@@ -1101,7 +1101,7 @@ describe('createExtension', () => {
.add(multi2Ext)
.get(outputRef),
).toThrowErrorMatchingInlineSnapshot(
`"Failed to instantiate extension 'subject', override data for input 'multi' may not mix forwarded inputs with data overrides"`,
`"Failed to resolve the extension tree: Failed to instantiate extension 'subject', override data for input 'multi' may not mix forwarded inputs with data overrides"`,
);
// Required input not provided
@@ -1124,7 +1124,7 @@ describe('createExtension', () => {
.add(multi2Ext)
.get(outputRef),
).toThrowErrorMatchingInlineSnapshot(
`"Failed to instantiate extension 'subject', missing required extension data value(s) 'test1'"`,
`"Failed to resolve the extension tree: Failed to instantiate extension 'subject', missing required extension data value(s) 'test1'"`,
);
// Wrong value provided
@@ -1153,7 +1153,7 @@ describe('createExtension', () => {
.add(multi2Ext)
.get(outputRef),
).toThrowErrorMatchingInlineSnapshot(
`"Failed to instantiate extension 'subject', extension data 'test2' was provided but not declared"`,
`"Failed to resolve the extension tree: Failed to instantiate extension 'subject', extension data 'test2' was provided but not declared"`,
);
});
});
@@ -35,6 +35,8 @@ import { resolveAppNodeSpecs } from '../../../frontend-app-api/src/tree/resolveA
import { instantiateAppNodeTree } from '../../../frontend-app-api/src/tree/instantiateAppNodeTree';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { readAppExtensionsConfig } from '../../../frontend-app-api/src/tree/readAppExtensionsConfig';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { createErrorCollector } from '../../../frontend-app-api/src/wiring/createErrorCollector';
import { TestApiRegistry } from '@backstage/test-utils';
import { OpaqueExtensionDefinition } from '@internal/frontend';
@@ -191,16 +193,29 @@ export class ExtensionTester<UOutput extends ExtensionDataRef> {
);
}
const collector = createErrorCollector();
const tree = resolveAppTree(
subject.id,
resolveAppNodeSpecs({
features: [],
builtinExtensions: this.#extensions.map(_ => _.extension),
parameters: readAppExtensionsConfig(this.#getConfig()),
collector,
}),
collector,
);
instantiateAppNodeTree(tree.root, TestApiRegistry.from());
instantiateAppNodeTree(tree.root, TestApiRegistry.from(), collector);
const errors = collector.collectErrors();
if (errors) {
throw new Error(
`Failed to resolve the extension tree: ${errors
.map(e => e.message)
.join(', ')}`,
);
}
this.#tree = tree;