frontend-app-api: add prepareSpecializedApp two-phase app wiring
This adds a new prepare/finalize app wiring flow that renders sign-in first and finalizes the full app after identity capture, while keeping createSpecializedApp as a deprecated wrapper. It also updates frontend-defaults/createApp to use the same flow and includes test and API report updates. Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com> Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
"@backstage/frontend-app-api": patch
|
||||
"@backstage/frontend-defaults": patch
|
||||
|
||||
---
|
||||
|
||||
Adds `prepareSpecializedApp` as a new two-phase app wiring API for rendering a sign-in page before full app finalization. The existing `createSpecializedApp` API is now deprecated and backed by `prepareSpecializedApp().finalize()`, while `createApp` has been updated to use the same prepare/finalize flow.
|
||||
@@ -10,6 +10,7 @@ import { ConfigApi } from '@backstage/frontend-plugin-api';
|
||||
import { ExtensionDataContainer } from '@backstage/frontend-plugin-api';
|
||||
import { ExtensionDataRef } from '@backstage/frontend-plugin-api';
|
||||
import { ExtensionDataValue } from '@backstage/frontend-plugin-api';
|
||||
import { ExtensionFactoryMiddleware as ExtensionFactoryMiddleware_2 } from '@backstage/frontend-plugin-api';
|
||||
import { ExternalRouteRef } from '@backstage/frontend-plugin-api';
|
||||
import { FrontendFeature } from '@backstage/frontend-plugin-api';
|
||||
import { FrontendPlugin } from '@backstage/frontend-plugin-api';
|
||||
@@ -127,6 +128,26 @@ export type AppErrorTypes = {
|
||||
existingPluginId: string;
|
||||
};
|
||||
};
|
||||
EXTENSION_BOOTSTRAP_PREDICATE_IGNORED: {
|
||||
context: {
|
||||
node: AppNode;
|
||||
};
|
||||
};
|
||||
EXTENSION_BOOTSTRAP_API_UNAVAILABLE: {
|
||||
context: {
|
||||
node: AppNode;
|
||||
apiRefId: string;
|
||||
};
|
||||
};
|
||||
EXTENSION_BOOTSTRAP_API_OVERRIDE_IGNORED: {
|
||||
context: {
|
||||
node: AppNode;
|
||||
apiRefId: string;
|
||||
bootstrapNode: AppNode;
|
||||
pluginId: string;
|
||||
bootstrapPluginId: string;
|
||||
};
|
||||
};
|
||||
ROUTE_DUPLICATE: {
|
||||
context: {
|
||||
routeId: string;
|
||||
@@ -144,6 +165,12 @@ export type AppErrorTypes = {
|
||||
};
|
||||
};
|
||||
|
||||
// @public
|
||||
export type BootstrapSpecializedApp = {
|
||||
element: JSX.Element;
|
||||
tree: AppTree;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type CreateAppRouteBinder = <
|
||||
TExternalRoutes extends {
|
||||
@@ -157,14 +184,12 @@ export type CreateAppRouteBinder = <
|
||||
>,
|
||||
) => void;
|
||||
|
||||
// @public
|
||||
export function createSpecializedApp(options?: CreateSpecializedAppOptions): {
|
||||
apis: ApiHolder;
|
||||
tree: AppTree;
|
||||
errors?: AppError[];
|
||||
};
|
||||
// @public @deprecated
|
||||
export function createSpecializedApp(
|
||||
options?: CreateSpecializedAppOptions,
|
||||
): FinalizedSpecializedApp;
|
||||
|
||||
// @public
|
||||
// @public @deprecated
|
||||
export type CreateSpecializedAppOptions = {
|
||||
features?: FrontendFeature[];
|
||||
config?: ConfigApi;
|
||||
@@ -172,8 +197,8 @@ export type CreateSpecializedAppOptions = {
|
||||
advanced?: {
|
||||
apis?: ApiHolder;
|
||||
extensionFactoryMiddleware?:
|
||||
| ExtensionFactoryMiddleware
|
||||
| ExtensionFactoryMiddleware[];
|
||||
| ExtensionFactoryMiddleware_2
|
||||
| ExtensionFactoryMiddleware_2[];
|
||||
pluginInfoResolver?: FrontendPluginInfoResolver;
|
||||
};
|
||||
};
|
||||
@@ -190,6 +215,14 @@ export type ExtensionFactoryMiddleware = (
|
||||
},
|
||||
) => Iterable<ExtensionDataValue<any, any>>;
|
||||
|
||||
// @public
|
||||
export type FinalizedSpecializedApp = {
|
||||
element: JSX.Element;
|
||||
sessionState: SpecializedAppSessionState;
|
||||
tree: AppTree;
|
||||
errors?: AppError[];
|
||||
};
|
||||
|
||||
// @public
|
||||
export type FrontendPluginInfoResolver = (ctx: {
|
||||
packageJson(): Promise<JsonObject | undefined>;
|
||||
@@ -203,4 +236,35 @@ export type FrontendPluginInfoResolver = (ctx: {
|
||||
}) => Promise<{
|
||||
info: FrontendPluginInfo;
|
||||
}>;
|
||||
|
||||
// @public
|
||||
export type PreparedSpecializedApp = {
|
||||
getBootstrapApp(): BootstrapSpecializedApp;
|
||||
onFinalized(callback: (app: FinalizedSpecializedApp) => void): () => void;
|
||||
finalize(): FinalizedSpecializedApp;
|
||||
};
|
||||
|
||||
// @public
|
||||
export function prepareSpecializedApp(
|
||||
options?: PrepareSpecializedAppOptions,
|
||||
): PreparedSpecializedApp;
|
||||
|
||||
// @public
|
||||
export type PrepareSpecializedAppOptions = {
|
||||
features?: FrontendFeature[];
|
||||
config?: ConfigApi;
|
||||
bindRoutes?(context: { bind: CreateAppRouteBinder }): void;
|
||||
advanced?: {
|
||||
sessionState?: SpecializedAppSessionState;
|
||||
extensionFactoryMiddleware?:
|
||||
| ExtensionFactoryMiddleware_2
|
||||
| ExtensionFactoryMiddleware_2[];
|
||||
pluginInfoResolver?: FrontendPluginInfoResolver;
|
||||
};
|
||||
};
|
||||
|
||||
// @public
|
||||
export type SpecializedAppSessionState = {
|
||||
$$type: '@backstage/SpecializedAppSessionState';
|
||||
};
|
||||
```
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* Copyright 2026 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
AnyApiFactory,
|
||||
AnyApiRef,
|
||||
ApiFactory,
|
||||
ApiHolder,
|
||||
ApiRef,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
|
||||
export class FrontendApiRegistry {
|
||||
private readonly factories = new Map<string, AnyApiFactory>();
|
||||
|
||||
register(factory: AnyApiFactory) {
|
||||
if (this.factories.has(factory.api.id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.factories.set(factory.api.id, factory);
|
||||
return true;
|
||||
}
|
||||
|
||||
registerAll(factories: AnyApiFactory[]) {
|
||||
for (const factory of factories) {
|
||||
this.register(factory);
|
||||
}
|
||||
}
|
||||
|
||||
get<T>(
|
||||
api: ApiRef<T>,
|
||||
): ApiFactory<T, T, { [name: string]: unknown }> | undefined {
|
||||
const factory = this.factories.get(api.id);
|
||||
if (!factory) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return factory as ApiFactory<T, T, { [name: string]: unknown }>;
|
||||
}
|
||||
|
||||
getAllApis() {
|
||||
const refs = new Set<AnyApiRef>();
|
||||
for (const factory of this.factories.values()) {
|
||||
refs.add(factory.api);
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
}
|
||||
|
||||
export class FrontendApiResolver implements ApiHolder {
|
||||
private readonly apis = new Map<string, unknown>();
|
||||
private readonly primaryRegistry?: FrontendApiRegistry;
|
||||
private readonly secondaryRegistry?: FrontendApiRegistry;
|
||||
private readonly fallbackApis?: ApiHolder;
|
||||
|
||||
constructor(options: {
|
||||
primaryRegistry?: FrontendApiRegistry;
|
||||
secondaryRegistry?: FrontendApiRegistry;
|
||||
fallbackApis?: ApiHolder;
|
||||
}) {
|
||||
this.primaryRegistry = options.primaryRegistry;
|
||||
this.secondaryRegistry = options.secondaryRegistry;
|
||||
this.fallbackApis = options.fallbackApis;
|
||||
}
|
||||
|
||||
get<T>(ref: ApiRef<T>): T | undefined {
|
||||
return this.load(ref);
|
||||
}
|
||||
|
||||
private load<T>(ref: ApiRef<T>, loading: AnyApiRef[] = []): T | undefined {
|
||||
const existing = this.apis.get(ref.id);
|
||||
if (existing) {
|
||||
return existing as T;
|
||||
}
|
||||
|
||||
const factory =
|
||||
this.primaryRegistry?.get(ref) ?? this.secondaryRegistry?.get(ref);
|
||||
if (!factory) {
|
||||
return this.fallbackApis?.get(ref);
|
||||
}
|
||||
|
||||
if (loading.includes(factory.api)) {
|
||||
throw new Error(`Circular dependency of api factory for ${factory.api}`);
|
||||
}
|
||||
|
||||
const deps = {} as { [name: string]: unknown };
|
||||
for (const [key, depRef] of Object.entries(factory.deps)) {
|
||||
const dep = this.load(depRef, [...loading, factory.api]);
|
||||
if (!dep) {
|
||||
throw new Error(
|
||||
`No API factory available for dependency ${depRef} of dependent ${factory.api}`,
|
||||
);
|
||||
}
|
||||
deps[key] = dep;
|
||||
}
|
||||
|
||||
const api = factory.factory(deps);
|
||||
this.apis.set(ref.id, api);
|
||||
return api as T;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,209 +14,28 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import {
|
||||
AnyApiFactory,
|
||||
ApiBlueprint,
|
||||
ApiHolder,
|
||||
AppNode,
|
||||
AppTree,
|
||||
AppTreeApi,
|
||||
appTreeApiRef,
|
||||
AnyRouteRefParams,
|
||||
ConfigApi,
|
||||
configApiRef,
|
||||
createApiFactory,
|
||||
ExternalRouteRef,
|
||||
featureFlagsApiRef,
|
||||
ExtensionFactoryMiddleware,
|
||||
FrontendFeature,
|
||||
identityApiRef,
|
||||
RouteFunc,
|
||||
RouteRef,
|
||||
RouteResolutionApi,
|
||||
routeResolutionApiRef,
|
||||
SubRouteRef,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { ExtensionFactoryMiddleware } from './types';
|
||||
import { ApiFactoryRegistry, ApiResolver } from '@backstage/core-app-api';
|
||||
import {
|
||||
createExtensionDataContainer,
|
||||
OpaqueApiRef,
|
||||
OpaqueFrontendPlugin,
|
||||
} from '@internal/frontend';
|
||||
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import {
|
||||
resolveExtensionDefinition,
|
||||
toInternalExtension,
|
||||
} from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition';
|
||||
|
||||
import {
|
||||
extractRouteInfoFromAppNode,
|
||||
RouteInfo,
|
||||
} from '../routing/extractRouteInfoFromAppNode';
|
||||
|
||||
import { CreateAppRouteBinder } from '../routing';
|
||||
import { RouteResolver } from '../routing/RouteResolver';
|
||||
import { resolveRouteBindings } from '../routing/resolveRouteBindings';
|
||||
import { collectRouteIds } from '../routing/collectRouteIds';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { FrontendPluginInfoResolver } from './createPluginInfoAttacher';
|
||||
import {
|
||||
toInternalFrontendModule,
|
||||
isInternalFrontendModule,
|
||||
} from '../../../frontend-plugin-api/src/wiring/createFrontendModule';
|
||||
import { getBasePath } from '../routing/getBasePath';
|
||||
import { Root } from '../extensions/Root';
|
||||
import { resolveAppTree } from '../tree/resolveAppTree';
|
||||
import { resolveAppNodeSpecs } from '../tree/resolveAppNodeSpecs';
|
||||
import { readAppExtensionsConfig } from '../tree/readAppExtensionsConfig';
|
||||
import { instantiateAppNodeTree } from '../tree/instantiateAppNodeTree';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { ApiRegistry } from '../../../core-app-api/src/apis/system/ApiRegistry';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { AppIdentityProxy } from '../../../core-app-api/src/apis/implementations/IdentityApi/AppIdentityProxy';
|
||||
import { BackstageRouteObject } from '../routing/types';
|
||||
import { matchRoutes } from 'react-router-dom';
|
||||
import {
|
||||
createPluginInfoAttacher,
|
||||
FrontendPluginInfoResolver,
|
||||
} from './createPluginInfoAttacher';
|
||||
import { createRouteAliasResolver } from '../routing/RouteAliasResolver';
|
||||
import {
|
||||
AppError,
|
||||
createErrorCollector,
|
||||
ErrorCollector,
|
||||
} from './createErrorCollector';
|
||||
createSessionStateFromApis,
|
||||
CreateSpecializedAppInternalOptions,
|
||||
FinalizedSpecializedApp,
|
||||
prepareSpecializedApp,
|
||||
} from './prepareSpecializedApp';
|
||||
|
||||
function deduplicateFeatures(
|
||||
allFeatures: FrontendFeature[],
|
||||
): FrontendFeature[] {
|
||||
// Start by removing duplicates by reference
|
||||
const features = Array.from(new Set(allFeatures));
|
||||
|
||||
// Plugins are deduplicated by ID, last one wins
|
||||
const seenIds = new Set<string>();
|
||||
return features
|
||||
.reverse()
|
||||
.filter(feature => {
|
||||
if (!OpaqueFrontendPlugin.isType(feature)) {
|
||||
return true;
|
||||
}
|
||||
if (seenIds.has(feature.id)) {
|
||||
return false;
|
||||
}
|
||||
seenIds.add(feature.id);
|
||||
return true;
|
||||
})
|
||||
.reverse();
|
||||
}
|
||||
|
||||
// Helps delay callers from reaching out to the API before the app tree has been materialized
|
||||
class AppTreeApiProxy implements AppTreeApi {
|
||||
#routeInfo?: RouteInfo;
|
||||
private readonly tree: AppTree;
|
||||
private readonly appBasePath: string;
|
||||
|
||||
constructor(tree: AppTree, appBasePath: string) {
|
||||
this.tree = tree;
|
||||
this.appBasePath = appBasePath;
|
||||
}
|
||||
|
||||
private checkIfInitialized() {
|
||||
if (!this.#routeInfo) {
|
||||
throw new Error(
|
||||
`You can't access the AppTreeApi during initialization of the app tree. Please move occurrences of this out of the initialization of the factory`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getTree() {
|
||||
this.checkIfInitialized();
|
||||
|
||||
return { tree: this.tree };
|
||||
}
|
||||
|
||||
getNodesByRoutePath(routePath: string): { nodes: AppNode[] } {
|
||||
this.checkIfInitialized();
|
||||
|
||||
let path = routePath;
|
||||
if (path.startsWith(this.appBasePath)) {
|
||||
path = path.slice(this.appBasePath.length);
|
||||
}
|
||||
|
||||
const matchedRoutes = matchRoutes(this.#routeInfo!.routeObjects, path);
|
||||
|
||||
const matchedAppNodes =
|
||||
matchedRoutes
|
||||
?.filter(routeObj => !!routeObj.route.appNode)
|
||||
.map(routeObj => routeObj.route.appNode!) || [];
|
||||
|
||||
return { nodes: matchedAppNodes };
|
||||
}
|
||||
|
||||
initialize(routeInfo: RouteInfo) {
|
||||
this.#routeInfo = routeInfo;
|
||||
}
|
||||
}
|
||||
|
||||
// Helps delay callers from reaching out to the API before the app tree has been materialized
|
||||
class RouteResolutionApiProxy implements RouteResolutionApi {
|
||||
#delegate: RouteResolutionApi | undefined;
|
||||
#routeObjects: BackstageRouteObject[] | undefined;
|
||||
|
||||
private readonly routeBindings: Map<ExternalRouteRef, RouteRef | SubRouteRef>;
|
||||
private readonly appBasePath: string;
|
||||
|
||||
constructor(
|
||||
routeBindings: Map<ExternalRouteRef, RouteRef | SubRouteRef>,
|
||||
appBasePath: string,
|
||||
) {
|
||||
this.routeBindings = routeBindings;
|
||||
this.appBasePath = appBasePath;
|
||||
}
|
||||
|
||||
resolve<TParams extends AnyRouteRefParams>(
|
||||
anyRouteRef:
|
||||
| RouteRef<TParams>
|
||||
| SubRouteRef<TParams>
|
||||
| ExternalRouteRef<TParams>,
|
||||
options?: { sourcePath?: string },
|
||||
): RouteFunc<TParams> | undefined {
|
||||
if (!this.#delegate) {
|
||||
throw new Error(
|
||||
`You can't access the RouteResolver during initialization of the app tree. Please move occurrences of this out of the initialization of the factory`,
|
||||
);
|
||||
}
|
||||
|
||||
return this.#delegate.resolve(anyRouteRef, options);
|
||||
}
|
||||
|
||||
initialize(
|
||||
routeInfo: RouteInfo,
|
||||
routeRefsById: Map<string, RouteRef | SubRouteRef>,
|
||||
) {
|
||||
this.#delegate = new RouteResolver(
|
||||
routeInfo.routePaths,
|
||||
routeInfo.routeParents,
|
||||
routeInfo.routeObjects,
|
||||
this.routeBindings,
|
||||
this.appBasePath,
|
||||
routeInfo.routeAliasResolver,
|
||||
routeRefsById,
|
||||
);
|
||||
this.#routeObjects = routeInfo.routeObjects;
|
||||
|
||||
return routeInfo;
|
||||
}
|
||||
|
||||
getRouteObjects() {
|
||||
return this.#routeObjects;
|
||||
}
|
||||
}
|
||||
export type { CreateSpecializedAppInternalOptions };
|
||||
|
||||
/**
|
||||
* Options for {@link createSpecializedApp}.
|
||||
*
|
||||
* @deprecated Use `PrepareSpecializedAppOptions` with `prepareSpecializedApp` instead.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type CreateSpecializedAppOptions = {
|
||||
@@ -245,12 +64,7 @@ export type CreateSpecializedAppOptions = {
|
||||
*/
|
||||
advanced?: {
|
||||
/**
|
||||
* A replacement API holder implementation to use.
|
||||
*
|
||||
* By default, a new API holder will be constructed automatically based on
|
||||
* the other inputs. If you pass in a custom one here, none of that
|
||||
* automation will take place - so you will have to take care to supply all
|
||||
* those APIs yourself.
|
||||
* APIs to expose to the app during startup.
|
||||
*/
|
||||
apis?: ApiHolder;
|
||||
|
||||
@@ -272,257 +86,29 @@ export type CreateSpecializedAppOptions = {
|
||||
};
|
||||
};
|
||||
|
||||
// Internal options type, not exported in the public API
|
||||
export interface CreateSpecializedAppInternalOptions
|
||||
extends CreateSpecializedAppOptions {
|
||||
__internal?: {
|
||||
apiFactoryOverrides?: AnyApiFactory[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an empty app without any default features. This is a low-level API is
|
||||
* intended for use in tests or specialized setups. Typically you want to use
|
||||
* `createApp` from `@backstage/frontend-defaults` instead.
|
||||
*
|
||||
* @deprecated Use `prepareSpecializedApp` instead.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function createSpecializedApp(options?: CreateSpecializedAppOptions): {
|
||||
apis: ApiHolder;
|
||||
tree: AppTree;
|
||||
errors?: AppError[];
|
||||
} {
|
||||
const internalOptions = options as CreateSpecializedAppInternalOptions;
|
||||
const config = options?.config ?? new ConfigReader({}, 'empty-config');
|
||||
const features = deduplicateFeatures(options?.features ?? []).map(
|
||||
createPluginInfoAttacher(config, options?.advanced?.pluginInfoResolver),
|
||||
);
|
||||
export function createSpecializedApp(
|
||||
options?: CreateSpecializedAppOptions,
|
||||
): FinalizedSpecializedApp {
|
||||
const sessionState = options?.advanced?.apis
|
||||
? createSessionStateFromApis(options.advanced.apis)
|
||||
: undefined;
|
||||
|
||||
const collector = createErrorCollector();
|
||||
|
||||
const tree = resolveAppTree(
|
||||
'root',
|
||||
resolveAppNodeSpecs({
|
||||
features,
|
||||
builtinExtensions: [
|
||||
resolveExtensionDefinition(Root, { namespace: 'root' }),
|
||||
],
|
||||
parameters: readAppExtensionsConfig(config),
|
||||
forbidden: new Set(['root']),
|
||||
collector,
|
||||
}),
|
||||
collector,
|
||||
);
|
||||
|
||||
const factories = createApiFactories({ tree, collector });
|
||||
const appBasePath = getBasePath(config);
|
||||
const appTreeApi = new AppTreeApiProxy(tree, appBasePath);
|
||||
|
||||
const routeRefsById = collectRouteIds(features, collector);
|
||||
const routeResolutionApi = new RouteResolutionApiProxy(
|
||||
resolveRouteBindings(options?.bindRoutes, config, routeRefsById, collector),
|
||||
appBasePath,
|
||||
);
|
||||
|
||||
const appIdentityProxy = new AppIdentityProxy();
|
||||
const apis =
|
||||
options?.advanced?.apis ??
|
||||
createApiHolder({
|
||||
factories,
|
||||
staticFactories: [
|
||||
createApiFactory(appTreeApiRef, appTreeApi),
|
||||
createApiFactory(configApiRef, config),
|
||||
createApiFactory(routeResolutionApiRef, routeResolutionApi),
|
||||
createApiFactory(identityApiRef, appIdentityProxy),
|
||||
...(internalOptions?.__internal?.apiFactoryOverrides ?? []),
|
||||
],
|
||||
});
|
||||
|
||||
const featureFlagApi = apis.get(featureFlagsApiRef);
|
||||
if (featureFlagApi) {
|
||||
for (const feature of features) {
|
||||
if (OpaqueFrontendPlugin.isType(feature)) {
|
||||
OpaqueFrontendPlugin.toInternal(feature).featureFlags.forEach(flag =>
|
||||
featureFlagApi.registerFlag({
|
||||
name: flag.name,
|
||||
description: flag.description,
|
||||
pluginId: feature.id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (isInternalFrontendModule(feature)) {
|
||||
toInternalFrontendModule(feature).featureFlags.forEach(flag =>
|
||||
featureFlagApi.registerFlag({
|
||||
name: flag.name,
|
||||
description: flag.description,
|
||||
pluginId: feature.pluginId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Now instantiate the entire tree, which will skip anything that's already been instantiated
|
||||
instantiateAppNodeTree(
|
||||
tree.root,
|
||||
apis,
|
||||
collector,
|
||||
mergeExtensionFactoryMiddleware(
|
||||
options?.advanced?.extensionFactoryMiddleware,
|
||||
),
|
||||
);
|
||||
|
||||
const routeInfo = extractRouteInfoFromAppNode(
|
||||
tree.root,
|
||||
createRouteAliasResolver(routeRefsById),
|
||||
);
|
||||
|
||||
routeResolutionApi.initialize(routeInfo, routeRefsById.routes);
|
||||
appTreeApi.initialize(routeInfo);
|
||||
|
||||
return { apis, tree, errors: collector.collectErrors() };
|
||||
}
|
||||
|
||||
function createApiFactories(options: {
|
||||
tree: AppTree;
|
||||
collector: ErrorCollector;
|
||||
}): AnyApiFactory[] {
|
||||
const emptyApiHolder = ApiRegistry.from([]);
|
||||
const factoriesById = new Map<
|
||||
string,
|
||||
{ pluginId: string; factory: AnyApiFactory }
|
||||
>();
|
||||
|
||||
for (const apiNode of options.tree.root.edges.attachments.get('apis') ?? []) {
|
||||
if (!instantiateAppNodeTree(apiNode, emptyApiHolder, options.collector)) {
|
||||
continue;
|
||||
}
|
||||
const apiFactory = apiNode.instance?.getData(ApiBlueprint.dataRefs.factory);
|
||||
if (apiFactory) {
|
||||
const apiRefId = apiFactory.api.id;
|
||||
const ownerId = getApiOwnerId(apiFactory.api);
|
||||
const pluginId = apiNode.spec.plugin.pluginId ?? 'app';
|
||||
const existingFactory = factoriesById.get(apiRefId);
|
||||
|
||||
// This allows modules to override factories provided by the plugin, but
|
||||
// it rejects API overrides from other plugins. In the event of a
|
||||
// conflict, the owning plugin is inferred from the explicit pluginId or
|
||||
// legacy plugin-prefixed API reference ID.
|
||||
if (existingFactory && existingFactory.pluginId !== pluginId) {
|
||||
const shouldReplace =
|
||||
ownerId === pluginId && existingFactory.pluginId !== ownerId;
|
||||
const acceptedPluginId = shouldReplace
|
||||
? pluginId
|
||||
: existingFactory.pluginId;
|
||||
const rejectedPluginId = shouldReplace
|
||||
? existingFactory.pluginId
|
||||
: pluginId;
|
||||
|
||||
options.collector.report({
|
||||
code: 'API_FACTORY_CONFLICT',
|
||||
message: `API '${apiRefId}' is already provided by plugin '${acceptedPluginId}', cannot also be provided by '${rejectedPluginId}'.`,
|
||||
context: {
|
||||
node: apiNode,
|
||||
apiRefId,
|
||||
pluginId: rejectedPluginId,
|
||||
existingPluginId: acceptedPluginId,
|
||||
},
|
||||
});
|
||||
if (shouldReplace) {
|
||||
factoriesById.set(apiRefId, {
|
||||
pluginId,
|
||||
factory: apiFactory,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
factoriesById.set(apiRefId, { pluginId, factory: apiFactory });
|
||||
} else {
|
||||
options.collector.report({
|
||||
code: 'API_EXTENSION_INVALID',
|
||||
message: `API extension '${apiNode.spec.id}' did not output an API factory`,
|
||||
context: {
|
||||
node: apiNode,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(factoriesById.values(), entry => entry.factory);
|
||||
}
|
||||
|
||||
// TODO(Rugvip): It would be good if this was more explicit, but I think that
|
||||
// might need to wait for some future update for API factories.
|
||||
function getApiOwnerId(apiRef: { id: string }): string {
|
||||
if (OpaqueApiRef.isType(apiRef)) {
|
||||
const { pluginId } = OpaqueApiRef.toInternal(apiRef);
|
||||
if (pluginId) {
|
||||
return pluginId;
|
||||
}
|
||||
}
|
||||
|
||||
const apiRefId = apiRef.id;
|
||||
const [prefix, ...rest] = apiRefId.split('.');
|
||||
if (!prefix) {
|
||||
return apiRefId;
|
||||
}
|
||||
if (prefix === 'plugin' && rest[0]) {
|
||||
return rest[0];
|
||||
}
|
||||
return prefix;
|
||||
}
|
||||
|
||||
function createApiHolder(options: {
|
||||
factories: AnyApiFactory[];
|
||||
staticFactories: AnyApiFactory[];
|
||||
}): ApiHolder {
|
||||
const factoryRegistry = new ApiFactoryRegistry();
|
||||
|
||||
for (const factory of options.factories.slice().reverse()) {
|
||||
factoryRegistry.register('default', factory);
|
||||
}
|
||||
|
||||
for (const factory of options.staticFactories) {
|
||||
factoryRegistry.register('static', factory);
|
||||
}
|
||||
|
||||
ApiResolver.validateFactories(factoryRegistry, factoryRegistry.getAllApis());
|
||||
|
||||
return new ApiResolver(factoryRegistry);
|
||||
}
|
||||
|
||||
function mergeExtensionFactoryMiddleware(
|
||||
middlewares?: ExtensionFactoryMiddleware | ExtensionFactoryMiddleware[],
|
||||
): ExtensionFactoryMiddleware | undefined {
|
||||
if (!middlewares) {
|
||||
return undefined;
|
||||
}
|
||||
if (!Array.isArray(middlewares)) {
|
||||
return middlewares;
|
||||
}
|
||||
if (middlewares.length <= 1) {
|
||||
return middlewares[0];
|
||||
}
|
||||
return middlewares.reduce((prev, next) => {
|
||||
if (!prev || !next) {
|
||||
return prev ?? next;
|
||||
}
|
||||
return (orig, ctx) => {
|
||||
const internalExt = toInternalExtension(ctx.node.spec.extension);
|
||||
if (internalExt.version !== 'v2') {
|
||||
return orig();
|
||||
}
|
||||
return next(ctxOverrides => {
|
||||
return createExtensionDataContainer(
|
||||
prev(orig, {
|
||||
node: ctx.node,
|
||||
apis: ctx.apis,
|
||||
config: ctxOverrides?.config ?? ctx.config,
|
||||
}),
|
||||
'extension factory middleware',
|
||||
);
|
||||
}, ctx);
|
||||
};
|
||||
});
|
||||
return prepareSpecializedApp({
|
||||
features: options?.features,
|
||||
config: options?.config,
|
||||
bindRoutes: options?.bindRoutes,
|
||||
advanced: {
|
||||
...options?.advanced,
|
||||
sessionState,
|
||||
},
|
||||
}).finalize();
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
*/
|
||||
|
||||
export {
|
||||
prepareSpecializedApp,
|
||||
type PreparedSpecializedApp,
|
||||
createSpecializedApp,
|
||||
type CreateSpecializedAppOptions,
|
||||
} from './createSpecializedApp';
|
||||
|
||||
@@ -16,8 +16,11 @@
|
||||
|
||||
import {
|
||||
AppTreeApi,
|
||||
ApiBlueprint,
|
||||
appTreeApiRef,
|
||||
coreExtensionData,
|
||||
createApiRef,
|
||||
createExtensionDataRef,
|
||||
createExtension,
|
||||
PageBlueprint,
|
||||
createFrontendPlugin,
|
||||
@@ -30,9 +33,17 @@ import { ThemeBlueprint } from '@backstage/plugin-app-react';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import { createApp } from './createApp';
|
||||
import { mockApis, renderWithEffects } from '@backstage/test-utils';
|
||||
import { featureFlagsApiRef, useApi } from '@backstage/core-plugin-api';
|
||||
import {
|
||||
featureFlagsApiRef,
|
||||
IdentityApi,
|
||||
useApi,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { default as appPluginOriginal } from '@backstage/plugin-app';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ComponentType, useState, useEffect } from 'react';
|
||||
|
||||
const signInPageComponentDataRef = createExtensionDataRef<
|
||||
ComponentType<{ onSignInSuccess(identity: IdentityApi): void }>
|
||||
>().with({ id: 'core.sign-in-page.component' });
|
||||
|
||||
describe('createApp', () => {
|
||||
const appPlugin = appPluginOriginal.withOverrides({
|
||||
@@ -84,6 +95,98 @@ describe('createApp', () => {
|
||||
await expect(screen.findByText('Derp')).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should provide app APIs to sign-in pages before finalization', async () => {
|
||||
const signInApiRef = createApiRef<{ value: string }>({
|
||||
id: 'test.sign-in-api',
|
||||
});
|
||||
|
||||
const app = createApp({
|
||||
advanced: {
|
||||
configLoader: async () => ({ config: mockApis.config() }),
|
||||
},
|
||||
features: [
|
||||
appPluginOriginal,
|
||||
createFrontendPlugin({
|
||||
pluginId: 'test',
|
||||
extensions: [
|
||||
ApiBlueprint.make({
|
||||
params: defineParams =>
|
||||
defineParams({
|
||||
api: signInApiRef,
|
||||
deps: {},
|
||||
factory: () => ({ value: 'ok' }),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
createFrontendModule({
|
||||
pluginId: 'app',
|
||||
extensions: [
|
||||
appPluginOriginal.getExtension('sign-in-page:app').override({
|
||||
factory: () => {
|
||||
const SignInPage = () => {
|
||||
const api = useApi(signInApiRef);
|
||||
return <div>Sign In API: {api.value}</div>;
|
||||
};
|
||||
|
||||
return [signInPageComponentDataRef(SignInPage)];
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await renderWithEffects(app.createRoot());
|
||||
await expect(
|
||||
screen.findByText('Sign In API: ok'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should provide feature flags to sign-in pages before finalization', async () => {
|
||||
const app = createApp({
|
||||
advanced: {
|
||||
configLoader: async () => ({ config: mockApis.config() }),
|
||||
},
|
||||
features: [
|
||||
appPluginOriginal,
|
||||
createFrontendPlugin({
|
||||
pluginId: 'test',
|
||||
featureFlags: [{ name: 'test-flag' }],
|
||||
extensions: [],
|
||||
}),
|
||||
createFrontendModule({
|
||||
pluginId: 'app',
|
||||
extensions: [
|
||||
appPluginOriginal.getExtension('sign-in-page:app').override({
|
||||
factory: () => {
|
||||
const SignInPage = () => {
|
||||
const flagsApi = useApi(featureFlagsApiRef);
|
||||
return (
|
||||
<div>
|
||||
Flags:{' '}
|
||||
{flagsApi
|
||||
.getRegisteredFlags()
|
||||
.map(flag => flag.name)
|
||||
.join(', ')}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return [signInPageComponentDataRef(SignInPage)];
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await renderWithEffects(app.createRoot());
|
||||
await expect(
|
||||
screen.findByText('Flags: test-flag'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should deduplicate features keeping the last received one', async () => {
|
||||
const duplicatedFeatureId = 'test';
|
||||
const app = createApp({
|
||||
@@ -283,9 +386,7 @@ describe('createApp', () => {
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should warn about unknown extension config', async () => {
|
||||
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
it('should allow unknown extension config if the flag is set', async () => {
|
||||
const app = createApp({
|
||||
features: [
|
||||
appPlugin,
|
||||
@@ -302,6 +403,7 @@ describe('createApp', () => {
|
||||
}),
|
||||
],
|
||||
advanced: {
|
||||
allowUnknownExtensionConfig: true,
|
||||
configLoader: async () => ({
|
||||
config: mockApis.config({
|
||||
data: {
|
||||
@@ -317,12 +419,6 @@ describe('createApp', () => {
|
||||
await renderWithEffects(app.createRoot());
|
||||
|
||||
await expect(screen.findByText('Derp')).resolves.toBeInTheDocument();
|
||||
expect(warnSpy).toHaveBeenCalledWith('App startup encountered warnings:');
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'INVALID_EXTENSION_CONFIG_KEY: Extension unknown:lols/wut does not exist',
|
||||
);
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
it('should make the app structure available through the AppTreeApi', async () => {
|
||||
let appTreeApi: AppTreeApi | undefined = undefined;
|
||||
@@ -428,9 +524,6 @@ describe('createApp', () => {
|
||||
<app-root-element:app/alert-display out=[core.reactElement] />
|
||||
<app-root-element:app/dialog-display out=[core.reactElement] />
|
||||
]
|
||||
signInPage [
|
||||
<sign-in-page:app />
|
||||
]
|
||||
</app/root>
|
||||
]
|
||||
</app>
|
||||
|
||||
@@ -14,10 +14,11 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { JSX, lazy, ReactNode, Suspense } from 'react';
|
||||
import { JSX, lazy, ReactNode, Suspense, useEffect, useState } from 'react';
|
||||
import {
|
||||
ConfigApi,
|
||||
coreExtensionData,
|
||||
ExtensionFactoryMiddleware,
|
||||
FrontendFeature,
|
||||
FrontendFeatureLoader,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
@@ -29,8 +30,8 @@ import { overrideBaseUrlConfigs } from '../../core-app-api/src/app/overrideBaseU
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import {
|
||||
CreateAppRouteBinder,
|
||||
createSpecializedApp,
|
||||
ExtensionFactoryMiddleware,
|
||||
prepareSpecializedApp,
|
||||
PreparedSpecializedApp,
|
||||
FrontendPluginInfoResolver,
|
||||
} from '@backstage/frontend-app-api';
|
||||
import appPlugin from '@backstage/plugin-app';
|
||||
@@ -58,6 +59,17 @@ export interface CreateAppOptions {
|
||||
* Advanced, more rarely used options.
|
||||
*/
|
||||
advanced?: {
|
||||
/**
|
||||
* If set to true, the system will silently accept and move on if
|
||||
* encountering config for extensions that do not exist. The default is to
|
||||
* reject such config to help catch simple mistakes.
|
||||
*
|
||||
* This flag can be useful in some scenarios where you have a dynamic set of
|
||||
* extensions enabled at different times, but also increases the risk of
|
||||
* accidentally missing e.g. simple typos in your config.
|
||||
*/
|
||||
allowUnknownExtensionConfig?: boolean;
|
||||
|
||||
/**
|
||||
* Sets a custom config loader, replacing the builtin one.
|
||||
*
|
||||
@@ -119,23 +131,22 @@ export function createApp(options?: CreateAppOptions): {
|
||||
features: [...discoveredFeaturesAndLoaders, ...(options?.features ?? [])],
|
||||
});
|
||||
|
||||
const app = createSpecializedApp({
|
||||
const preparedApp = prepareSpecializedApp({
|
||||
features: [appPlugin, ...loadedFeatures],
|
||||
config,
|
||||
bindRoutes: options?.bindRoutes,
|
||||
advanced: options?.advanced,
|
||||
});
|
||||
|
||||
const errorPage = maybeCreateErrorPage(app);
|
||||
if (errorPage) {
|
||||
return { default: () => errorPage };
|
||||
if (preparedApp.getSignIn()) {
|
||||
return {
|
||||
default: () => <PreparedAppRoot preparedApp={preparedApp} />,
|
||||
};
|
||||
}
|
||||
|
||||
const rootEl = app.tree.root.instance!.getData(
|
||||
coreExtensionData.reactElement,
|
||||
);
|
||||
|
||||
return { default: () => rootEl };
|
||||
return {
|
||||
default: () => renderFinalizedApp(preparedApp.finalize()),
|
||||
};
|
||||
}
|
||||
|
||||
const LazyApp = lazy(appLoader);
|
||||
@@ -150,3 +161,60 @@ export function createApp(options?: CreateAppOptions): {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function PreparedAppRoot(props: {
|
||||
preparedApp: PreparedSpecializedApp;
|
||||
}): JSX.Element {
|
||||
const signIn = props.preparedApp.getSignIn();
|
||||
const [finalizeError, setFinalizeError] = useState<Error>();
|
||||
const [finalizedApp, setFinalizedApp] = useState(() => {
|
||||
if (!signIn) {
|
||||
return props.preparedApp.finalize();
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (signIn) {
|
||||
void signIn.complete
|
||||
.then(async () => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setFinalizedApp(props.preparedApp.finalize());
|
||||
})
|
||||
.catch(error => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setFinalizeError(error);
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [props.preparedApp, signIn]);
|
||||
|
||||
if (finalizeError) {
|
||||
throw finalizeError;
|
||||
}
|
||||
|
||||
if (!finalizedApp) {
|
||||
return signIn!.element;
|
||||
}
|
||||
|
||||
return renderFinalizedApp(finalizedApp);
|
||||
}
|
||||
|
||||
function renderFinalizedApp(
|
||||
app: ReturnType<PreparedSpecializedApp['finalize']>,
|
||||
) {
|
||||
const errorPage = maybeCreateErrorPage(app);
|
||||
if (errorPage) {
|
||||
return errorPage;
|
||||
}
|
||||
|
||||
return app.tree.root.instance!.getData(coreExtensionData.reactElement)!;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user