diff --git a/.changeset/thin-dryers-repeat.md b/.changeset/thin-dryers-repeat.md new file mode 100644 index 0000000000..bcf62f21de --- /dev/null +++ b/.changeset/thin-dryers-repeat.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-app-api': patch +--- + +Add support for installing features as package imports, for example `backend.add(import('my-plugin'))`. diff --git a/packages/backend-app-api/src/wiring/BackendInitializer.ts b/packages/backend-app-api/src/wiring/BackendInitializer.ts index 03ca10882c..bc74c21542 100644 --- a/packages/backend-app-api/src/wiring/BackendInitializer.ts +++ b/packages/backend-app-api/src/wiring/BackendInitializer.ts @@ -46,6 +46,7 @@ export class BackendInitializer { #features = new Array(); #extensionPoints = new Map(); #serviceRegistry: ServiceRegistry; + #registeredFeatures = new Array>(); constructor(defaultApiFactories: ServiceFactory[]) { this.#serviceRegistry = ServiceRegistry.create([...defaultApiFactories]); @@ -90,11 +91,11 @@ export class BackendInitializer { return Object.fromEntries(result); } - add(feature: BackendFeature) { + add(feature: Promise) { if (this.#startPromise) { throw new Error('feature can not be added after the backend has started'); } - this.#addFeature(feature); + this.#registeredFeatures.push(feature); } #addFeature(feature: BackendFeature) { @@ -150,6 +151,10 @@ export class BackendInitializer { async #doStart(): Promise { this.#serviceRegistry.checkForCircularDeps(); + for (const feature of this.#registeredFeatures) { + this.#addFeature(await feature); + } + const featureDiscovery = await this.#serviceRegistry.get( featureDiscoveryServiceRef, 'root', diff --git a/packages/backend-app-api/src/wiring/BackstageBackend.ts b/packages/backend-app-api/src/wiring/BackstageBackend.ts index 0ea55bc1c7..5d2a8716a0 100644 --- a/packages/backend-app-api/src/wiring/BackstageBackend.ts +++ b/packages/backend-app-api/src/wiring/BackstageBackend.ts @@ -25,8 +25,17 @@ export class BackstageBackend implements Backend { this.#initializer = new BackendInitializer(defaultServiceFactories); } - add(feature: BackendFeature | (() => BackendFeature)): void { - this.#initializer.add(typeof feature === 'function' ? feature() : feature); + add( + feature: + | BackendFeature + | (() => BackendFeature) + | Promise<{ default: BackendFeature | (() => BackendFeature) }>, + ): void { + if (isPromise(feature)) { + this.#initializer.add(feature.then(f => unwrapFeature(f.default))); + } else { + this.#initializer.add(Promise.resolve(unwrapFeature(feature))); + } } async start(): Promise { @@ -37,3 +46,18 @@ export class BackstageBackend implements Backend { await this.#initializer.stop(); } } + +function isPromise(value: unknown | Promise): value is Promise { + return ( + typeof value === 'object' && + value !== null && + 'then' in value && + typeof value.then === 'function' + ); +} + +function unwrapFeature( + feature: BackendFeature | (() => BackendFeature), +): BackendFeature { + return typeof feature === 'function' ? feature() : feature; +} diff --git a/packages/backend-app-api/src/wiring/types.ts b/packages/backend-app-api/src/wiring/types.ts index adf8128b5b..027b2bf254 100644 --- a/packages/backend-app-api/src/wiring/types.ts +++ b/packages/backend-app-api/src/wiring/types.ts @@ -25,7 +25,12 @@ import { * @public */ export interface Backend { - add(feature: BackendFeature | (() => BackendFeature)): void; + add( + feature: + | BackendFeature + | (() => BackendFeature) + | Promise<{ default: BackendFeature | (() => BackendFeature) }>, + ): void; start(): Promise; stop(): Promise; } diff --git a/packages/backend-defaults/src/CreateBackend.test.ts b/packages/backend-defaults/src/CreateBackend.test.ts index 0b36b57069..23e5c571bd 100644 --- a/packages/backend-defaults/src/CreateBackend.test.ts +++ b/packages/backend-defaults/src/CreateBackend.test.ts @@ -18,27 +18,19 @@ import { coreServices, createServiceFactory, } from '@backstage/backend-plugin-api'; +import { mockServices } from '@backstage/backend-test-utils'; import { createBackend } from './CreateBackend'; describe('createBackend', () => { - it('should not throw when overriding a default service implementation', () => { + it('should not throw when overriding a default service implementation', async () => { const backend = createBackend(); - expect(() => { - backend.add( - createServiceFactory({ - service: coreServices.rootLifecycle, - deps: {}, - factory: async () => ({ - addStartupHook: () => {}, - addShutdownHook: () => {}, - }), - }), - ); - }).not.toThrow(); + backend.add(mockServices.rootConfig.factory()); + + await expect(backend.start()).resolves.toBe(undefined); }); - it('should throw on duplicate service implementations', () => { + it('should throw on duplicate service implementations', async () => { const backend = createBackend(); backend.add( @@ -51,34 +43,34 @@ describe('createBackend', () => { }), }), ); - - expect(() => { - backend.add( - createServiceFactory({ - service: coreServices.rootLifecycle, - deps: {}, - factory: async () => ({ - addStartupHook: () => {}, - addShutdownHook: () => {}, - }), + backend.add( + createServiceFactory({ + service: coreServices.rootLifecycle, + deps: {}, + factory: async () => ({ + addStartupHook: () => {}, + addShutdownHook: () => {}, }), - ); - }).toThrow( + }), + ); + + await expect(backend.start()).rejects.toThrow( 'Duplicate service implementations provided for core.rootLifecycle', ); }); - it('should throw when providing a plugin metadata service implementation', () => { + it('should throw when providing a plugin metadata service implementation', async () => { const backend = createBackend(); + backend.add( + createServiceFactory({ + service: coreServices.pluginMetadata, + deps: {}, + factory: () => ({ getId: () => 'test' }), + }), + ); - expect(() => - backend.add( - createServiceFactory({ - service: coreServices.pluginMetadata, - deps: {}, - factory: async () => ({ getId: () => 'test' }), - }), - ), - ).toThrow('The core.pluginMetadata service cannot be overridden'); + await expect(backend.start()).rejects.toThrow( + 'The core.pluginMetadata service cannot be overridden', + ); }); });