UNPKG

@backstage/backend-app-api

Version:

Core API used by Backstage backend apps

522 lines (518 loc) • 19 kB
'use strict'; 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