frontend-plugin-api: runtime attachTo by reference

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2025-11-03 11:16:41 +01:00
parent 7c6a66dd9f
commit 8b84f39946
13 changed files with 496 additions and 21 deletions
@@ -390,3 +390,52 @@ const childExtension = createExtension({
},
});
```
## Direct extension input references
Building on the relative attachment point concept, you can also reference extension inputs directly via the `inputs` property of an extension definition. This provides a more convenient and type-safe way to attach child extensions, especially when working with blueprints within a plugin.
Each extension definition exposes an `inputs` property that contains references to all of its defined inputs. These references can be passed directly to the `attachTo` option when creating child extensions:
```tsx
// Create a parent extension using a blueprint
const page = PageBlueprint.make({
params: {
path: '/example',
loader: () => import('./ExamplePage'),
},
});
// Create a child extension that attaches to the parent's input
const widget = CardBlueprint.make({
attachTo: page.inputs.children, // Direct reference to the input
params: {
title: 'Example Widget',
content: <div>Widget Content</div>,
},
});
```
This approach is functionally equivalent to using relative attachment points, but offers several advantages:
- **Type safety**: The TypeScript compiler will verify that the referenced input exists and is spelled correctly
- **IDE support**: Your editor can provide autocomplete suggestions for available inputs
- **Clearer intent**: The code explicitly shows the relationship between parent and child extensions
The direct input reference syntax works with both single attachment points and arrays of attachment points, and can be mixed with other attachment point formats:
```tsx
// Attach to multiple parents using a mix of styles
const multiAttachChild = ExampleBlueprint.make({
attachTo: [
page.inputs.children, // Direct reference
{ relative: { kind: 'sidebar' }, input: 'items' }, // Relative
{ id: 'app/nav', input: 'items' }, // Absolute ID
],
params: {
/* ... */
},
});
```
Under the hood, input references are resolved in the same way as relative attachment points, using the extension's kind, namespace, and name to construct the final attachment target.
@@ -61,6 +61,7 @@ function makeExtDef(
name,
attachTo: { id: attachId, input: 'default' },
disabled: status === 'disabled',
inputs: {},
override: () => ({} as ExtensionDefinition),
} as ExtensionDefinition;
}
@@ -0,0 +1,36 @@
/*
* 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 { ExtensionInput } from '@backstage/frontend-plugin-api';
import { OpaqueType } from '@internal/opaque';
export type ExtensionInputContext = {
input: string;
kind?: string;
name?: string;
};
export const OpaqueExtensionInput = OpaqueType.create<{
public: ExtensionInput;
versions: {
readonly version: undefined;
readonly context?: ExtensionInputContext;
withContext(context: ExtensionInputContext): ExtensionInput;
};
}>({
type: '@backstage/ExtensionInput',
versions: [undefined],
});
@@ -15,6 +15,10 @@
*/
export { createExtensionDataContainer } from './createExtensionDataContainer';
export { OpaqueSwappableComponentRef } from './InternalSwappableComponentRef';
export { OpaqueExtensionDefinition } from './InternalExtensionDefinition';
export {
OpaqueExtensionInput,
type ExtensionInputContext,
} from './InternalExtensionInput';
export { OpaqueFrontendPlugin } from './InternalFrontendPlugin';
export { OpaqueSwappableComponentRef } from './InternalSwappableComponentRef';
+18 -2
View File
@@ -8,7 +8,6 @@ import { alertApiRef } from '@backstage/core-plugin-api';
import { AlertMessage } from '@backstage/core-plugin-api';
import { AnyApiFactory } from '@backstage/core-plugin-api';
import { AnyApiRef } from '@backstage/core-plugin-api';
import { AnyRouteRefParams as AnyRouteRefParams_2 } from '@backstage/frontend-plugin-api';
import { ApiFactory } from '@backstage/core-plugin-api';
import { ApiHolder } from '@backstage/core-plugin-api';
import { ApiRef } from '@backstage/core-plugin-api';
@@ -385,7 +384,7 @@ export const coreExtensionData: {
>;
routePath: ConfigurableExtensionDataRef<string, 'core.routing.path', {}>;
routeRef: ConfigurableExtensionDataRef<
RouteRef<AnyRouteRefParams_2>,
RouteRef<AnyRouteRefParams>,
'core.routing.ref',
{}
>;
@@ -1076,6 +1075,9 @@ export type ExtensionDefinition<
> = {
$$type: '@backstage/ExtensionDefinition';
readonly T: T;
readonly inputs: {
[K in keyof T['inputs']]: ExtensionInputRef;
};
override<
TExtensionConfigSchema extends {
[key in string]: (zImpl: typeof z) => z.ZodType;
@@ -1186,6 +1188,7 @@ export type ExtensionDefinitionAttachTo =
input: string;
id?: never;
}
| ExtensionInputRef
| Array<
| {
id: string;
@@ -1200,6 +1203,7 @@ export type ExtensionDefinitionAttachTo =
input: string;
id?: never;
}
| ExtensionInputRef
>;
// @public (undocumented)
@@ -1261,6 +1265,18 @@ export interface ExtensionInput<
}>;
}
// @public
export interface ExtensionInputRef {
// (undocumented)
$$type: '@backstage/ExtensionInputRef';
// (undocumented)
input: string;
// (undocumented)
kind?: string;
// (undocumented)
name?: string;
}
// @public
export interface ExternalRouteRef<
TParams extends AnyRouteRefParams = AnyRouteRefParams,
@@ -216,7 +216,7 @@ describe('createExtension', () => {
);
});
it('should create an extension with a relative attachment point', () => {
it('should create an extension with relative attachment points', () => {
const extension = createExtension({
attachTo: [
{ relative: {}, input: 'tabs' },
@@ -232,6 +232,62 @@ describe('createExtension', () => {
);
});
it('should create an extension with relative attachment points by reference', () => {
const baseOpts = {
attachTo: { id: 'root', input: 'children' },
inputs: {
tabs: createExtensionInput([stringDataRef]),
},
output: [],
factory: () => [],
};
const parent1 = createExtension({
...baseOpts,
});
const parent2 = createExtension({
...baseOpts,
kind: 'page',
});
const parent3 = createExtension({
...baseOpts,
name: 'index',
});
const parent4 = createExtension({
...baseOpts,
inputs: {},
kind: 'page',
name: 'index',
}).override({
inputs: {
otherTabs: createExtensionInput([stringDataRef]),
},
factory: () => [],
});
const extension = createExtension({
attachTo: [
parent1.inputs.tabs,
parent2.inputs.tabs,
parent3.inputs.tabs,
parent4.inputs.otherTabs,
],
output: [stringDataRef],
factory: () => [stringDataRef('bar')],
});
expect(String(extension)).toBe(
'ExtensionDefinition{attachTo=<plugin>@tabs+page:<plugin>@tabs+<plugin>/index@tabs+page:<plugin>/index@otherTabs}',
);
const overrdeExtension = extension.override({
attachTo: [
parent2.inputs.tabs,
parent3.inputs.tabs,
parent4.inputs.otherTabs,
],
});
expect(String(overrdeExtension)).toBe(
'ExtensionDefinition{attachTo=page:<plugin>@tabs+<plugin>/index@tabs+page:<plugin>/index@otherTabs}',
);
});
it('should create an extension with input', () => {
const extension = createExtension({
attachTo: { id: 'root', input: 'default' },
@@ -20,7 +20,10 @@ import {
ResolvedInputValueOverrides,
resolveInputOverrides,
} from './resolveInputOverrides';
import { createExtensionDataContainer } from '@internal/frontend';
import {
createExtensionDataContainer,
OpaqueExtensionInput,
} from '@internal/frontend';
import { ExtensionDataRef, ExtensionDataValue } from './createExtensionDataRef';
import { ExtensionInput } from './createExtensionInput';
import { z } from 'zod';
@@ -119,10 +122,11 @@ export type VerifyExtensionFactoryOutput<
*
* A standard attachment point declaration will specify the ID of the parent extension, as well as the name of the input to attach to.
*
* There are two more advanced forms that are available for more complex use-cases:
* There are three more advanced forms that are available for more complex use-cases:
*
* 1. Relative attachment points: using the `relative` property instead of `id`, the attachment point is resolved relative to the current plugin.
* 2. Array of attachment points: an array of attachment points can be used to clone and attach to multiple extensions at once.
* 2. Extension input references: using a reference obtained from another extension's `inputs` property.
* 3. Array of attachment points: an array of attachment points can be used to clone and attach to multiple extensions at once.
*
* @example
* ```ts
@@ -132,6 +136,10 @@ export type VerifyExtensionFactoryOutput<
* // Attach to an extension in the same plugin by kind
* { relative: { kind: 'page' }, input: 'actions' }
*
* // Attach to a specific input of another extension
* const page = ParentBlueprint.make({ ... });
* const child = ChildBlueprint.make({ attachTo: page.inputs.children });
*
* // Attach to multiple parents at once
* [
* { id: 'page/home', input: 'widgets' },
@@ -144,6 +152,7 @@ export type VerifyExtensionFactoryOutput<
export type ExtensionDefinitionAttachTo =
| { id: string; input: string; relative?: never }
| { relative: { kind?: string; name?: string }; input: string; id?: never }
| ExtensionInput
| Array<
| { id: string; input: string; relative?: never }
| {
@@ -151,6 +160,7 @@ export type ExtensionDefinitionAttachTo =
input: string;
id?: never;
}
| ExtensionInput
>;
/** @public */
@@ -213,6 +223,13 @@ export type ExtensionDefinition<
$$type: '@backstage/ExtensionDefinition';
readonly T: T;
/**
* References to the inputs of this extension, which can be used to attach child extensions.
*/
readonly inputs: {
[K in keyof T['inputs']]: ExtensionInput;
};
override<
TExtensionConfigSchema extends {
[key in string]: (zImpl: typeof z) => z.ZodType;
@@ -306,6 +323,30 @@ export type ExtensionDefinition<
}>;
};
/**
* @internal
*/
function bindInputs(
inputs: { [inputName in string]: ExtensionInput } | undefined,
kind?: string,
name?: string,
) {
if (!inputs) {
return {};
}
return Object.fromEntries(
Object.entries(inputs).map(([inputName, input]) => [
inputName,
OpaqueExtensionInput.toInternal(input).withContext({
kind,
name,
input: inputName,
}),
]),
);
}
/**
* Creates a new extension definition for installation in a Backstage app.
*
@@ -419,7 +460,7 @@ export function createExtension<
name: options.name,
attachTo: options.attachTo,
disabled: options.disabled ?? false,
inputs: options.inputs ?? {},
inputs: bindInputs(options.inputs, options.kind, options.name),
output: options.output,
configSchema,
factory: options.factory,
@@ -434,6 +475,20 @@ export function createExtension<
const attachTo = [options.attachTo]
.flat()
.map(a => {
if (OpaqueExtensionInput.isType(a)) {
const { context } = OpaqueExtensionInput.toInternal(a);
if (!context) {
return '<detached-input>';
}
let id = '<plugin>';
if (context?.kind) {
id = `${context?.kind}:${id}`;
}
if (context?.name) {
id = `${id}/${context?.name}`;
}
return `${id}@${context.input}`;
}
if ('relative' in a && a.relative) {
let id = '<plugin>';
if (a.relative.kind) {
@@ -444,7 +499,10 @@ export function createExtension<
}
return `${id}@${a.input}`;
}
return `${a.id}@${a.input}`;
if ('id' in a) {
return `${a.id}@${a.input}`;
}
throw new Error('Invalid attachment point specification');
})
.join('+');
parts.push(`attachTo=${attachTo}`);
@@ -477,7 +535,14 @@ export function createExtension<
name: options.name,
attachTo: overrideOptions.attachTo ?? options.attachTo,
disabled: overrideOptions.disabled ?? options.disabled,
inputs: { ...overrideOptions.inputs, ...options.inputs },
inputs: bindInputs(
{
...(options.inputs ?? {}),
...(overrideOptions.inputs ?? {}),
},
options.kind,
options.name,
),
output: (overrideOptions.output ??
options.output) as ExtensionDataRef[],
config:
@@ -30,7 +30,7 @@ import {
} from './createExtensionDataRef';
import { createExtensionInput } from './createExtensionInput';
import { RouteRef } from '../routing';
import { ExtensionDefinition } from './createExtension';
import { createExtension, ExtensionDefinition } from './createExtension';
import {
createExtensionDataContainer,
OpaqueExtensionDefinition,
@@ -1345,6 +1345,126 @@ describe('createExtensionBlueprint', () => {
).toThrow('Refused to override params and factory at the same time');
});
describe('with relative attachment points', () => {
const dataRef = createExtensionDataRef<string>().with({ id: 'test.data' });
it('should create an extension with relative attachment points', () => {
const blueprint = createExtensionBlueprint({
kind: 'test',
attachTo: [
{ relative: {}, input: 'tabs' },
{ relative: { kind: 'page' }, input: 'tabs' },
{ relative: { name: 'index' }, input: 'tabs' },
{ relative: { kind: 'page', name: 'index' }, input: 'tabs' },
],
output: [dataRef],
factory: () => [dataRef('bar')],
});
expect(String(blueprint.make({ params: {} }))).toBe(
'ExtensionDefinition{kind=test,attachTo=<plugin>@tabs+page:<plugin>@tabs+<plugin>/index@tabs+page:<plugin>/index@tabs}',
);
expect(
String(
blueprint.make({
attachTo: [
{ relative: { kind: 'page' }, input: 'tabs' },
{ relative: { name: 'index' }, input: 'tabs' },
{ relative: { kind: 'page', name: 'index' }, input: 'tabs' },
],
params: {},
}),
),
).toBe(
'ExtensionDefinition{kind=test,attachTo=page:<plugin>@tabs+<plugin>/index@tabs+page:<plugin>/index@tabs}',
);
expect(
String(
blueprint.makeWithOverrides({
attachTo: {
relative: { kind: 'page', name: 'index' },
input: 'tabs',
},
factory: orig => orig({}),
}),
),
).toBe(
'ExtensionDefinition{kind=test,attachTo=page:<plugin>/index@tabs}',
);
});
it('should create an extension with relative attachment points by reference', () => {
const baseOpts = {
attachTo: { id: 'root', input: 'children' },
inputs: {
tabs: createExtensionInput([dataRef]),
},
output: [],
factory: () => [],
};
const parent1 = createExtension({
...baseOpts,
});
const parent2 = createExtension({
...baseOpts,
kind: 'page',
});
const parent3 = createExtension({
...baseOpts,
name: 'index',
});
const parent4 = createExtension({
...baseOpts,
inputs: {},
kind: 'page',
name: 'index',
}).override({
inputs: {
otherTabs: createExtensionInput([dataRef]),
},
factory: () => [],
});
const blueprint = createExtensionBlueprint({
kind: 'test',
attachTo: [
parent1.inputs.tabs,
parent2.inputs.tabs,
parent3.inputs.tabs,
parent4.inputs.otherTabs,
],
output: [dataRef],
factory: () => [dataRef('bar')],
});
expect(String(blueprint.make({ params: {} }))).toBe(
'ExtensionDefinition{kind=test,attachTo=<plugin>@tabs+page:<plugin>@tabs+<plugin>/index@tabs+page:<plugin>/index@otherTabs}',
);
expect(
String(
blueprint.make({
attachTo: [
parent2.inputs.tabs,
parent3.inputs.tabs,
parent4.inputs.otherTabs,
],
params: {},
}),
),
).toBe(
'ExtensionDefinition{kind=test,attachTo=page:<plugin>@tabs+<plugin>/index@tabs+page:<plugin>/index@otherTabs}',
);
expect(
String(
blueprint.makeWithOverrides({
attachTo: parent4.inputs.otherTabs,
factory: orig => orig({}),
}),
),
).toBe(
'ExtensionDefinition{kind=test,attachTo=page:<plugin>/index@otherTabs}',
);
});
});
describe('with advanced parameter types', () => {
const testDataRef = createExtensionDataRef<string>().with({ id: 'test' });
@@ -14,6 +14,10 @@
* limitations under the License.
*/
import {
ExtensionInputContext,
OpaqueExtensionInput,
} from '@internal/frontend';
import { ExtensionDataRef } from './createExtensionDataRef';
/** @public */
@@ -28,10 +32,10 @@ export interface ExtensionInput<
optional: boolean;
},
> {
$$type: '@backstage/ExtensionInput';
extensionData: Array<UExtensionData>;
config: TConfig;
replaces?: Array<{ id: string; input: string }>;
readonly $$type: '@backstage/ExtensionInput';
readonly extensionData: Array<UExtensionData>;
readonly config: TConfig;
readonly replaces?: Array<{ id: string; input: string }>;
}
/** @public */
@@ -68,8 +72,7 @@ export function createExtensionInput<
}
}
}
return {
$$type: '@backstage/ExtensionInput',
const baseOptions = {
extensionData,
config: {
singleton: Boolean(config?.singleton) as TConfig['singleton'] extends true
@@ -80,11 +83,21 @@ export function createExtensionInput<
: false,
},
replaces: config?.replaces,
} as ExtensionInput<
};
function createInstance(parent?: ExtensionInputContext): ExtensionInput<
UExtensionData,
{
singleton: TConfig['singleton'] extends true ? true : false;
optional: TConfig['optional'] extends true ? true : false;
}
>;
> {
return OpaqueExtensionInput.createInstance(undefined, {
...baseOptions,
context: parent,
withContext: createInstance,
});
}
return createInstance();
}
@@ -170,6 +170,7 @@ describe('createFrontendPlugin', () => {
"override": [Function],
"toString": [Function],
"version": "v2",
Symbol(@backstage/ExtensionDefinition/internalInputs): {},
}
`);
// @ts-expect-error
@@ -358,6 +359,7 @@ describe('createFrontendPlugin', () => {
"override": [Function],
"toString": [Function],
"version": "v2",
Symbol(@backstage/ExtensionDefinition/internalInputs): {},
}
`);
});
@@ -20,6 +20,7 @@ export {
type ExtensionDefinition,
type ExtensionDefinitionParameters,
type ExtensionDefinitionAttachTo,
type ExtensionInputRef,
type CreateExtensionOptions,
type ResolvedExtensionInput,
type ResolvedExtensionInputs,
@@ -27,6 +27,7 @@ describe('resolveExtensionDefinition', () => {
version: 'v2',
attachTo: { id: '', input: '' },
disabled: false,
inputs: {},
override: () => ({} as ExtensionDefinition),
};
@@ -77,6 +78,85 @@ describe('resolveExtensionDefinition', () => {
{ id: 'page:test/index', input: 'tabs' },
]);
});
it('should resolve extension input references', () => {
const resolved = resolveExtensionDefinition(
{
...baseDef,
attachTo: {
$$type: '@backstage/ExtensionInputRef',
kind: 'parent',
name: 'example',
input: 'children',
},
} as ExtensionDefinition,
{ namespace: 'test' },
);
expect(resolved.attachTo).toEqual({
id: 'parent:test/example',
input: 'children',
});
});
it('should resolve extension input references without name', () => {
const resolved = resolveExtensionDefinition(
{
...baseDef,
attachTo: {
$$type: '@backstage/ExtensionInputRef',
kind: 'parent',
input: 'children',
},
} as ExtensionDefinition,
{ namespace: 'test' },
);
expect(resolved.attachTo).toEqual({
id: 'parent:test',
input: 'children',
});
});
it('should resolve extension input references without kind', () => {
const resolved = resolveExtensionDefinition(
{
...baseDef,
attachTo: {
$$type: '@backstage/ExtensionInputRef',
name: 'example',
input: 'children',
},
} as ExtensionDefinition,
{ namespace: 'test' },
);
expect(resolved.attachTo).toEqual({
id: 'test/example',
input: 'children',
});
});
it('should resolve array with mixed attachment types including input references', () => {
const resolved = resolveExtensionDefinition(
{
...baseDef,
attachTo: [
{ id: 'page:home', input: 'widgets' },
{ relative: { kind: 'page' }, input: 'actions' },
{
$$type: '@backstage/ExtensionInputRef',
kind: 'parent',
name: 'example',
input: 'children',
},
],
} as ExtensionDefinition,
{ namespace: 'test' },
);
expect(resolved.attachTo).toEqual([
{ id: 'page:home', input: 'widgets' },
{ id: 'page:test', input: 'actions' },
{ id: 'parent:test/example', input: 'children' },
]);
});
});
describe('old resolveExtensionDefinition', () => {
@@ -86,6 +166,7 @@ describe('old resolveExtensionDefinition', () => {
version: 'v1',
attachTo: { id: '', input: '' },
disabled: false,
inputs: {},
override: () => ({} as ExtensionDefinition),
};
@@ -24,7 +24,10 @@ import {
import { PortableSchema } from '../schema';
import { ExtensionInput } from './createExtensionInput';
import { ExtensionDataRef, ExtensionDataValue } from './createExtensionDataRef';
import { OpaqueExtensionDefinition } from '@internal/frontend';
import {
OpaqueExtensionDefinition,
OpaqueExtensionInput,
} from '@internal/frontend';
/** @public */
export type ExtensionAttachTo =
@@ -152,6 +155,18 @@ function resolveAttachTo(
const resolveSpec = (
spec: Exclude<ExtensionDefinitionAttachTo, Array<any>>,
): { id: string; input: string } => {
if (OpaqueExtensionInput.isType(spec)) {
const { context } = OpaqueExtensionInput.toInternal(spec);
if (!context) {
throw new Error(
'Invalid input object without a parent extension used as attachment point',
);
}
return {
id: resolveExtensionId(context.kind, namespace, context.name),
input: context.input,
};
}
if ('relative' in spec && spec.relative) {
return {
id: resolveExtensionId(
@@ -162,7 +177,10 @@ function resolveAttachTo(
input: spec.input,
};
}
return { id: spec.id, input: spec.input };
if ('id' in spec) {
return { id: spec.id, input: spec.input };
}
throw new Error('Invalid attachment point specification');
};
if (Array.isArray(attachTo)) {
@@ -180,6 +198,19 @@ export function resolveExtensionDefinition<
context?: { namespace?: string },
): Extension<T['config'], T['configInput']> {
const internalDefinition = OpaqueExtensionDefinition.toInternal(definition);
// Restore internal inputs if they were saved under a symbol
// This is needed because we override the inputs property with ExtensionInputRef objects
const internalInputsSymbol = Symbol.for(
'@backstage/ExtensionDefinition/internalInputs',
);
if ((internalDefinition as any)[internalInputsSymbol]) {
// Restore the internal inputs from the symbol
(internalDefinition as any).inputs = (internalDefinition as any)[
internalInputsSymbol
];
}
const {
name,
kind,