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:
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user