@backstage/backend-app-api
Version:
Core API used by Backstage backend apps
522 lines (518 loc) • 19 kB
JavaScript
;
var backendPluginApi = require('@backstage/backend-plugin-api');
var errors = require('@backstage/errors');
var alpha = require('@backstage/backend-plugin-api/alpha');
var DependencyGraph = require('../lib/DependencyGraph.cjs.js');
var ServiceRegistry = require('./ServiceRegistry.cjs.js');
var createInitializationLogger = require('./createInitializationLogger.cjs.js');
var helpers = require('./helpers.cjs.js');
const instanceRegistry = new class InstanceRegistry {
#registered = false;
#instances = /* @__PURE__ */ new Set();
register(instance) {
if (!this.#registered) {
this.#registered = true;
process.addListener("SIGTERM", this.#exitHandler);
process.addListener("SIGINT", this.#exitHandler);
process.addListener("beforeExit", this.#exitHandler);
}
this.#instances.add(instance);
}
unregister(instance) {
this.#instances.delete(instance);
}
#exitHandler = async () => {
try {
const results = await Promise.allSettled(
Array.from(this.#instances).map((b) => b.stop())
);
const errors = results.flatMap(
(r) => r.status === "rejected" ? [r.reason] : []
);
if (errors.length > 0) {
for (const error of errors) {
console.error(error);
}
process.exit(1);
} else {
process.exit(0);
}
} catch (error) {
console.error(error);
process.exit(1);
}
};
}();
function createInstanceMetadataServiceFactory(registrations) {
const installedFeatures = registrations.map((registration) => {
if (registration.featureType === "registrations") {
return registration.getRegistrations().map((feature) => {
if (feature.type === "plugin") {
return Object.defineProperty(
{
type: "plugin",
pluginId: feature.pluginId
},
"toString",
{
enumerable: false,
configurable: true,
value: () => `plugin{pluginId=${feature.pluginId}}`
}
);
} else if (feature.type === "module") {
return Object.defineProperty(
{
type: "module",
pluginId: feature.pluginId,
moduleId: feature.moduleId
},
"toString",
{
enumerable: false,
configurable: true,
value: () => `module{moduleId=${feature.moduleId},pluginId=${feature.pluginId}}`
}
);
}
return void 0;
}).filter(Boolean);
}
return [];
}).flat();
return backendPluginApi.createServiceFactory({
service: alpha.instanceMetadataServiceRef,
deps: {},
factory: async () => ({ getInstalledFeatures: () => installedFeatures })
});
}
class BackendInitializer {
#startPromise;
#stopPromise;
#registrations = new Array();
#extensionPoints = /* @__PURE__ */ new Map();
#serviceRegistry;
#registeredFeatures = new Array();
#registeredFeatureLoaders = new Array();
constructor(defaultApiFactories) {
this.#serviceRegistry = ServiceRegistry.ServiceRegistry.create([...defaultApiFactories]);
}
async #getInitDeps(deps, pluginId, moduleId) {
const result = /* @__PURE__ */ new Map();
const missingRefs = /* @__PURE__ */ new Set();
for (const [name, ref] of Object.entries(deps)) {
const ep = this.#extensionPoints.get(ref.id);
if (ep) {
if (ep.pluginId !== pluginId) {
throw new Error(
`Illegal dependency: Module '${moduleId}' for plugin '${pluginId}' attempted to depend on extension point '${ref.id}' for plugin '${ep.pluginId}'. Extension points can only be used within their plugin's scope.`
);
}
result.set(name, ep.impl);
} else {
const impl = await this.#serviceRegistry.get(
ref,
pluginId
);
if (impl) {
result.set(name, impl);
} else {
missingRefs.add(ref);
}
}
}
if (missingRefs.size > 0) {
const missing = Array.from(missingRefs).join(", ");
const target = moduleId ? `module '${moduleId}' for plugin '${pluginId}'` : `plugin '${pluginId}'`;
throw new Error(
`Service or extension point dependencies of ${target} are missing for the following ref(s): ${missing}`
);
}
return Object.fromEntries(result);
}
add(feature) {
if (this.#startPromise) {
throw new Error("feature can not be added after the backend has started");
}
this.#registeredFeatures.push(Promise.resolve(feature));
}
#addFeature(feature) {
if (isServiceFactory(feature)) {
this.#serviceRegistry.add(feature);
} else if (isBackendFeatureLoader(feature)) {
this.#registeredFeatureLoaders.push(feature);
} else if (isBackendRegistrations(feature)) {
this.#registrations.push(feature);
} else {
throw new Error(
`Failed to add feature, invalid feature ${JSON.stringify(feature)}`
);
}
}
async start() {
if (this.#startPromise) {
throw new Error("Backend has already started");
}
if (this.#stopPromise) {
throw new Error("Backend has already stopped");
}
instanceRegistry.register(this);
this.#startPromise = this.#doStart();
await this.#startPromise;
}
async #doStart() {
this.#serviceRegistry.checkForCircularDeps();
for (const feature of this.#registeredFeatures) {
this.#addFeature(await feature);
}
await this.#applyBackendFeatureLoaders(this.#registeredFeatureLoaders);
this.#serviceRegistry.add(
createInstanceMetadataServiceFactory(this.#registrations)
);
await this.#serviceRegistry.initializeEagerServicesWithScope("root");
const pluginInits = /* @__PURE__ */ new Map();
const moduleInits = /* @__PURE__ */ new Map();
for (const feature of this.#registrations) {
for (const r of feature.getRegistrations()) {
const provides = /* @__PURE__ */ new Set();
if (r.type === "plugin" || r.type === "module") {
for (const [extRef, extImpl] of r.extensionPoints) {
if (this.#extensionPoints.has(extRef.id)) {
throw new Error(
`ExtensionPoint with ID '${extRef.id}' is already registered`
);
}
this.#extensionPoints.set(extRef.id, {
impl: extImpl,
pluginId: r.pluginId
});
provides.add(extRef);
}
}
if (r.type === "plugin") {
if (pluginInits.has(r.pluginId)) {
throw new Error(`Plugin '${r.pluginId}' is already registered`);
}
pluginInits.set(r.pluginId, {
provides,
consumes: new Set(Object.values(r.init.deps)),
init: r.init
});
} else if (r.type === "module") {
let modules = moduleInits.get(r.pluginId);
if (!modules) {
modules = /* @__PURE__ */ new Map();
moduleInits.set(r.pluginId, modules);
}
if (modules.has(r.moduleId)) {
throw new Error(
`Module '${r.moduleId}' for plugin '${r.pluginId}' is already registered`
);
}
modules.set(r.moduleId, {
provides,
consumes: new Set(Object.values(r.init.deps)),
init: r.init
});
} else {
throw new Error(`Invalid registration type '${r.type}'`);
}
}
}
const allPluginIds = [...pluginInits.keys()];
const initLogger = createInitializationLogger.createInitializationLogger(
allPluginIds,
await this.#serviceRegistry.get(backendPluginApi.coreServices.rootLogger, "root")
);
const rootConfig = await this.#serviceRegistry.get(
backendPluginApi.coreServices.rootConfig,
"root"
);
const results = await Promise.allSettled(
allPluginIds.map(async (pluginId) => {
const isBootFailurePermitted = this.#getPluginBootFailurePredicate(
pluginId,
rootConfig
);
try {
await this.#serviceRegistry.initializeEagerServicesWithScope(
"plugin",
pluginId
);
const modules = moduleInits.get(pluginId);
if (modules) {
const tree = DependencyGraph.DependencyGraph.fromIterable(
Array.from(modules).map(([moduleId, moduleInit]) => ({
value: { moduleId, moduleInit },
// Relationships are reversed at this point since we're only interested in the extension points.
// If a modules provides extension point A we want it to be initialized AFTER all modules
// that depend on extension point A, so that they can provide their extensions.
consumes: Array.from(moduleInit.provides).map((p) => p.id),
provides: Array.from(moduleInit.consumes).map((c) => c.id)
}))
);
const circular = tree.detectCircularDependency();
if (circular) {
throw new errors.ConflictError(
`Circular dependency detected for modules of plugin '${pluginId}', ${circular.map(({ moduleId }) => `'${moduleId}'`).join(" -> ")}`
);
}
await tree.parallelTopologicalTraversal(
async ({ moduleId, moduleInit }) => {
const isModuleBootFailurePermitted = this.#getPluginModuleBootFailurePredicate(
pluginId,
moduleId,
rootConfig
);
try {
const moduleDeps = await this.#getInitDeps(
moduleInit.init.deps,
pluginId,
moduleId
);
await moduleInit.init.func(moduleDeps).catch((error) => {
throw new errors.ForwardedError(
`Module '${moduleId}' for plugin '${pluginId}' startup failed`,
error
);
});
} catch (error) {
errors.assertError(error);
if (isModuleBootFailurePermitted) {
initLogger.onPermittedPluginModuleFailure(
pluginId,
moduleId,
error
);
} else {
initLogger.onPluginModuleFailed(pluginId, moduleId, error);
throw error;
}
}
}
);
}
const pluginInit = pluginInits.get(pluginId);
if (pluginInit) {
const pluginDeps = await this.#getInitDeps(
pluginInit.init.deps,
pluginId
);
await pluginInit.init.func(pluginDeps).catch((error) => {
throw new errors.ForwardedError(
`Plugin '${pluginId}' startup failed`,
error
);
});
}
initLogger.onPluginStarted(pluginId);
const lifecycleService2 = await this.#getPluginLifecycleImpl(pluginId);
await lifecycleService2.startup();
} catch (error) {
errors.assertError(error);
if (isBootFailurePermitted) {
initLogger.onPermittedPluginFailure(pluginId, error);
} else {
initLogger.onPluginFailed(pluginId, error);
throw error;
}
}
})
);
const initErrors = results.flatMap(
(r) => r.status === "rejected" ? [r.reason] : []
);
if (initErrors.length === 1) {
throw initErrors[0];
} else if (initErrors.length > 1) {
throw new AggregateError(initErrors, "Backend startup failed");
}
const lifecycleService = await this.#getRootLifecycleImpl();
await lifecycleService.startup();
initLogger.onAllStarted();
if (process.env.NODE_ENV !== "test") {
const rootLogger = await this.#serviceRegistry.get(
backendPluginApi.coreServices.rootLogger,
"root"
);
process.on("unhandledRejection", (reason) => {
rootLogger?.child({ type: "unhandledRejection" })?.error("Unhandled rejection", reason);
});
process.on("uncaughtException", (error) => {
rootLogger?.child({ type: "uncaughtException" })?.error("Uncaught exception", error);
});
}
}
// It's fine to call .stop() multiple times, which for example can happen with manual stop + process exit
async stop() {
instanceRegistry.unregister(this);
if (!this.#stopPromise) {
this.#stopPromise = this.#doStop();
}
await this.#stopPromise;
}
async #doStop() {
if (!this.#startPromise) {
return;
}
try {
await this.#startPromise;
} catch (error) {
}
const rootLifecycleService = await this.#getRootLifecycleImpl();
await rootLifecycleService.beforeShutdown();
const allPlugins = /* @__PURE__ */ new Set();
for (const feature of this.#registrations) {
for (const r of feature.getRegistrations()) {
if (r.type === "plugin") {
allPlugins.add(r.pluginId);
}
}
}
await Promise.allSettled(
[...allPlugins].map(async (pluginId) => {
const lifecycleService = await this.#getPluginLifecycleImpl(pluginId);
await lifecycleService.shutdown();
})
);
await rootLifecycleService.shutdown();
}
// Bit of a hacky way to grab the lifecycle services, potentially find a nicer way to do this
async #getRootLifecycleImpl() {
const lifecycleService = await this.#serviceRegistry.get(
backendPluginApi.coreServices.rootLifecycle,
"root"
);
const service = lifecycleService;
if (service && typeof service.startup === "function" && typeof service.shutdown === "function") {
return service;
}
throw new Error("Unexpected root lifecycle service implementation");
}
async #getPluginLifecycleImpl(pluginId) {
const lifecycleService = await this.#serviceRegistry.get(
backendPluginApi.coreServices.lifecycle,
pluginId
);
const service = lifecycleService;
if (service && typeof service.startup === "function" && typeof service.shutdown === "function") {
return service;
}
throw new Error("Unexpected plugin lifecycle service implementation");
}
async #applyBackendFeatureLoaders(loaders) {
const servicesAddedByLoaders = /* @__PURE__ */ new Map();
for (const loader of loaders) {
const deps = /* @__PURE__ */ new Map();
const missingRefs = /* @__PURE__ */ new Set();
for (const [name, ref] of Object.entries(loader.deps ?? {})) {
if (ref.scope !== "root") {
throw new Error(
`Feature loaders can only depend on root scoped services, but '${name}' is scoped to '${ref.scope}'. Offending loader is ${loader.description}`
);
}
const impl = await this.#serviceRegistry.get(
ref,
"root"
);
if (impl) {
deps.set(name, impl);
} else {
missingRefs.add(ref);
}
}
if (missingRefs.size > 0) {
const missing = Array.from(missingRefs).join(", ");
throw new Error(
`No service available for the following ref(s): ${missing}, depended on by feature loader ${loader.description}`
);
}
const result = await loader.loader(Object.fromEntries(deps)).then((features) => features.map(helpers.unwrapFeature)).catch((error) => {
throw new errors.ForwardedError(
`Feature loader ${loader.description} failed`,
error
);
});
let didAddServiceFactory = false;
const newLoaders = new Array();
for await (const feature of result) {
if (isBackendFeatureLoader(feature)) {
newLoaders.push(feature);
} else {
if (isServiceFactory(feature) && !feature.service.multiton) {
const conflictingLoader = servicesAddedByLoaders.get(
feature.service.id
);
if (conflictingLoader) {
throw new Error(
`Duplicate service implementations provided for ${feature.service.id} by both feature loader ${loader.description} and feature loader ${conflictingLoader.description}`
);
}
if (!this.#serviceRegistry.hasBeenAdded(feature.service)) {
didAddServiceFactory = true;
servicesAddedByLoaders.set(feature.service.id, loader);
this.#addFeature(feature);
}
} else {
this.#addFeature(feature);
}
}
}
if (didAddServiceFactory) {
this.#serviceRegistry.checkForCircularDeps();
}
if (newLoaders.length > 0) {
await this.#applyBackendFeatureLoaders(newLoaders);
}
}
}
#getPluginBootFailurePredicate(pluginId, config) {
const defaultStartupBootFailureValue = config?.getOptionalString(
"backend.startup.default.onPluginBootFailure"
) ?? "abort";
const pluginStartupBootFailureValue = config?.getOptionalString(
`backend.startup.plugins.${pluginId}.onPluginBootFailure`
) ?? defaultStartupBootFailureValue;
return pluginStartupBootFailureValue === "continue";
}
#getPluginModuleBootFailurePredicate(pluginId, moduleId, config) {
const defaultStartupBootFailureValue = config?.getOptionalString(
"backend.startup.default.onPluginModuleBootFailure"
) ?? "abort";
const pluginModuleStartupBootFailureValue = config?.getOptionalString(
`backend.startup.plugins.${pluginId}.modules.${moduleId}.onPluginModuleBootFailure`
) ?? defaultStartupBootFailureValue;
return pluginModuleStartupBootFailureValue === "continue";
}
}
function toInternalBackendFeature(feature) {
if (feature.$$type !== "@backstage/BackendFeature") {
throw new Error(`Invalid BackendFeature, bad type '${feature.$$type}'`);
}
const internal = feature;
if (internal.version !== "v1") {
throw new Error(
`Invalid BackendFeature, bad version '${internal.version}'`
);
}
return internal;
}
function isServiceFactory(feature) {
const internal = toInternalBackendFeature(feature);
if (internal.featureType === "service") {
return true;
}
return "service" in internal;
}
function isBackendRegistrations(feature) {
const internal = toInternalBackendFeature(feature);
if (internal.featureType === "registrations") {
return true;
}
return "getRegistrations" in internal;
}
function isBackendFeatureLoader(feature) {
return toInternalBackendFeature(feature).featureType === "loader";
}
exports.BackendInitializer = BackendInitializer;
//# sourceMappingURL=BackendInitializer.cjs.js.map