frontend-app-api: split prepared app wiring helpers

Extract the prepared app tree and API factory lifecycle helpers so prepareSpecializedApp reads as orchestration, while keeping the finalization flow documented and behaviorally unchanged.

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
Made-with: Cursor
This commit is contained in:
Patrik Oldsberg
2026-03-16 11:51:20 +01:00
parent 65805c8117
commit e04955b861
3 changed files with 845 additions and 612 deletions
@@ -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;
}
File diff suppressed because it is too large Load Diff
@@ -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].values();
},
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);
}