From 3595c974f68910a3a81322ff9c9b3adf9ebd2fc5 Mon Sep 17 00:00:00 2001 From: Jack Palmer Date: Tue, 7 Apr 2026 09:34:54 +0100 Subject: [PATCH] feat(backend): add extensionPointFactoryMiddleware to createBackend Allow the backend to reimplement extension point outputs at creation time via a new extensionPointFactoryMiddleware option on createBackend(). Each middleware entry declaratively targets a specific extension point by reference and the framework handles matching and pass-through automatically. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Jack Palmer --- ...ension-point-middleware-backend-app-api.md | 5 + ...nsion-point-middleware-backend-defaults.md | 5 + packages/backend-app-api/report.api.md | 17 ++ .../src/wiring/BackendInitializer.test.ts | 203 ++++++++++++++++++ .../src/wiring/BackendInitializer.ts | 36 ++-- .../src/wiring/BackstageBackend.ts | 16 +- .../src/wiring/createSpecializedBackend.ts | 5 +- packages/backend-app-api/src/wiring/index.ts | 2 + packages/backend-app-api/src/wiring/types.ts | 29 +++ packages/backend-defaults/report.api.md | 10 +- .../backend-defaults/src/CreateBackend.ts | 15 +- packages/backend-defaults/src/index.ts | 2 + 12 files changed, 323 insertions(+), 22 deletions(-) create mode 100644 .changeset/extension-point-middleware-backend-app-api.md create mode 100644 .changeset/extension-point-middleware-backend-defaults.md diff --git a/.changeset/extension-point-middleware-backend-app-api.md b/.changeset/extension-point-middleware-backend-app-api.md new file mode 100644 index 0000000000..2d0ea0dfbc --- /dev/null +++ b/.changeset/extension-point-middleware-backend-app-api.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-app-api': minor +--- + +Added `ExtensionPointFactoryMiddleware` type and `createExtensionPointFactoryMiddleware` helper to reimplement extension point outputs at backend creation time. diff --git a/.changeset/extension-point-middleware-backend-defaults.md b/.changeset/extension-point-middleware-backend-defaults.md new file mode 100644 index 0000000000..481ba41bed --- /dev/null +++ b/.changeset/extension-point-middleware-backend-defaults.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-defaults': patch +--- + +Added `extensionPointFactoryMiddleware` option to `createBackend()` to reimplement extension point outputs at backend initialization time. Also re-exports `ExtensionPointFactoryMiddleware` type and `createExtensionPointFactoryMiddleware` helper from `@backstage/backend-app-api`. diff --git a/packages/backend-app-api/report.api.md b/packages/backend-app-api/report.api.md index 62b46b93e6..e0557b8fd0 100644 --- a/packages/backend-app-api/report.api.md +++ b/packages/backend-app-api/report.api.md @@ -5,6 +5,7 @@ ```ts import { BackendFeature } from '@backstage/backend-plugin-api'; import { CustomErrorBase } from '@backstage/errors'; +import { ExtensionPoint } from '@backstage/backend-plugin-api'; import { ServiceFactory } from '@backstage/backend-plugin-api'; // @public (undocumented) @@ -42,6 +43,12 @@ export interface BackendStartupResult { resultAt: Date; } +// @public +export function createExtensionPointFactoryMiddleware( + extensionPoint: ExtensionPoint, + middleware: (original: T) => T, +): ExtensionPointFactoryMiddleware; + // @public (undocumented) export function createSpecializedBackend( options: CreateSpecializedBackendOptions, @@ -51,6 +58,16 @@ export function createSpecializedBackend( export interface CreateSpecializedBackendOptions { // (undocumented) defaultServiceFactories: ServiceFactory[]; + // (undocumented) + extensionPointFactoryMiddleware?: ExtensionPointFactoryMiddleware[]; +} + +// @public +export interface ExtensionPointFactoryMiddleware { + // (undocumented) + extensionPoint: ExtensionPoint; + // (undocumented) + middleware: (original: T) => T; } // @public diff --git a/packages/backend-app-api/src/wiring/BackendInitializer.test.ts b/packages/backend-app-api/src/wiring/BackendInitializer.test.ts index f782295e71..c1801f0398 100644 --- a/packages/backend-app-api/src/wiring/BackendInitializer.test.ts +++ b/packages/backend-app-api/src/wiring/BackendInitializer.test.ts @@ -28,6 +28,7 @@ import { import { BackendInitializer } from './BackendInitializer'; import { mockServices } from '@backstage/backend-test-utils'; import { BackendStartupError } from './BackendStartupError'; +import { createExtensionPointFactoryMiddleware } from './types'; const baseFactories = [ mockServices.rootLifecycle.factory(), @@ -2111,4 +2112,206 @@ describe('BackendInitializer', () => { ); }); }); + + describe('extensionPointFactoryMiddleware', () => { + it('should apply middleware to matching extension points', async () => { + expect.assertions(1); + + const extensionPoint = createExtensionPoint<{ values: string[] }>({ + id: 'test.ext', + }); + + const init = new BackendInitializer(baseFactories, [ + createExtensionPointFactoryMiddleware(extensionPoint, original => ({ + ...original, + values: [...original.values, 'from-middleware'], + })), + ]); + + init.add(testPlugin); + init.add( + createBackendModule({ + pluginId: 'test', + moduleId: 'provider', + register(reg) { + reg.registerExtensionPoint(extensionPoint, { + values: ['original'], + }); + reg.registerInit({ deps: {}, async init() {} }); + }, + }), + ); + init.add( + createBackendModule({ + pluginId: 'test', + moduleId: 'consumer', + register(reg) { + reg.registerInit({ + deps: { ext: extensionPoint }, + async init({ ext }) { + expect(ext.values).toEqual(['original', 'from-middleware']); + }, + }); + }, + }), + ); + + await init.start(); + }); + + it('should not affect non-matching extension points', async () => { + expect.assertions(1); + + const extensionPointA = createExtensionPoint<{ values: string[] }>({ + id: 'test.a', + }); + const extensionPointB = createExtensionPoint<{ values: string[] }>({ + id: 'test.b', + }); + + const init = new BackendInitializer(baseFactories, [ + createExtensionPointFactoryMiddleware(extensionPointA, original => ({ + ...original, + values: [...original.values, 'wrapped'], + })), + ]); + + init.add(testPlugin); + init.add( + createBackendModule({ + pluginId: 'test', + moduleId: 'provider', + register(reg) { + reg.registerExtensionPoint(extensionPointB, { + values: ['untouched'], + }); + reg.registerInit({ deps: {}, async init() {} }); + }, + }), + ); + init.add( + createBackendModule({ + pluginId: 'test', + moduleId: 'consumer', + register(reg) { + reg.registerInit({ + deps: { ext: extensionPointB }, + async init({ ext }) { + expect(ext.values).toEqual(['untouched']); + }, + }); + }, + }), + ); + + await init.start(); + }); + + it('should chain multiple middlewares for the same extension point', async () => { + expect.assertions(1); + + const extensionPoint = createExtensionPoint<{ values: string[] }>({ + id: 'test.ext', + }); + + const init = new BackendInitializer(baseFactories, [ + createExtensionPointFactoryMiddleware(extensionPoint, original => ({ + ...original, + values: [...original.values, 'first'], + })), + createExtensionPointFactoryMiddleware(extensionPoint, original => ({ + ...original, + values: [...original.values, 'second'], + })), + ]); + + init.add(testPlugin); + init.add( + createBackendModule({ + pluginId: 'test', + moduleId: 'provider', + register(reg) { + reg.registerExtensionPoint(extensionPoint, { values: ['base'] }); + reg.registerInit({ deps: {}, async init() {} }); + }, + }), + ); + init.add( + createBackendModule({ + pluginId: 'test', + moduleId: 'consumer', + register(reg) { + reg.registerInit({ + deps: { ext: extensionPoint }, + async init({ ext }) { + expect(ext.values).toEqual(['base', 'first', 'second']); + }, + }); + }, + }), + ); + + await init.start(); + }); + + it('should not fail when middleware targets an unregistered extension point', async () => { + const unregisteredExtensionPoint = createExtensionPoint<{ + values: string[]; + }>({ + id: 'test.unregistered', + }); + + const init = new BackendInitializer(baseFactories, [ + createExtensionPointFactoryMiddleware( + unregisteredExtensionPoint, + original => ({ + ...original, + values: [...original.values, 'never-applied'], + }), + ), + ]); + + init.add(testPlugin); + const { result } = await init.start(); + expect(result.outcome).toBe('success'); + }); + + it('should pass through when no middleware is provided', async () => { + expect.assertions(1); + + const extensionPoint = createExtensionPoint<{ values: string[] }>({ + id: 'test.ext', + }); + + const init = new BackendInitializer(baseFactories); + + init.add(testPlugin); + init.add( + createBackendModule({ + pluginId: 'test', + moduleId: 'provider', + register(reg) { + reg.registerExtensionPoint(extensionPoint, { values: ['orig'] }); + reg.registerInit({ deps: {}, async init() {} }); + }, + }), + ); + init.add( + createBackendModule({ + pluginId: 'test', + moduleId: 'consumer', + register(reg) { + reg.registerInit({ + deps: { ext: extensionPoint }, + async init({ ext }) { + expect(ext.values).toEqual(['orig']); + }, + }); + }, + }), + ); + + await init.start(); + }); + }); }); diff --git a/packages/backend-app-api/src/wiring/BackendInitializer.ts b/packages/backend-app-api/src/wiring/BackendInitializer.ts index 8ebab723da..211fb7dccc 100644 --- a/packages/backend-app-api/src/wiring/BackendInitializer.ts +++ b/packages/backend-app-api/src/wiring/BackendInitializer.ts @@ -25,7 +25,10 @@ import { createServiceFactory, ExtensionPointFactoryContext, } from '@backstage/backend-plugin-api'; -import { ServiceOrExtensionPoint } from './types'; +import { + ExtensionPointFactoryMiddleware, + ServiceOrExtensionPoint, +} from './types'; // Direct internal import to avoid duplication // eslint-disable-next-line @backstage/no-relative-monorepo-imports import type { @@ -166,11 +169,17 @@ export class BackendInitializer { #serviceRegistry: ServiceRegistry; #registeredFeatures = new Array>(); #registeredFeatureLoaders = new Array(); + #extensionPointFactoryMiddleware: ExtensionPointFactoryMiddleware[]; #unhandledRejectionHandler?: (reason: Error) => void; #uncaughtExceptionHandler?: (error: Error) => void; - constructor(defaultApiFactories: ServiceFactory[]) { + constructor( + defaultApiFactories: ServiceFactory[], + extensionPointFactoryMiddleware?: ExtensionPointFactoryMiddleware[], + ) { this.#serviceRegistry = ServiceRegistry.create([...defaultApiFactories]); + this.#extensionPointFactoryMiddleware = + extensionPointFactoryMiddleware ?? []; } async #getInitDeps( @@ -195,18 +204,17 @@ export class BackendInitializer { `Rejected dependency on extension point ${ref.id} from outside of a module`, ); } - result.set( - name, - ep.factory({ - reportModuleStartupFailure: ({ error }) => { - resultCollector.amendPluginModuleResult( - pluginId, - moduleId, - error, - ); - }, - }), - ); + let epImpl = ep.factory({ + reportModuleStartupFailure: ({ error }) => { + resultCollector.amendPluginModuleResult(pluginId, moduleId, error); + }, + }); + for (const mw of this.#extensionPointFactoryMiddleware) { + if (mw.extensionPoint.id === ref.id) { + epImpl = (mw.middleware as (original: unknown) => unknown)(epImpl); + } + } + result.set(name, epImpl); } else { const impl = await this.#serviceRegistry.get( ref as ServiceRef, diff --git a/packages/backend-app-api/src/wiring/BackstageBackend.ts b/packages/backend-app-api/src/wiring/BackstageBackend.ts index 79ee55780f..a38d1991cd 100644 --- a/packages/backend-app-api/src/wiring/BackstageBackend.ts +++ b/packages/backend-app-api/src/wiring/BackstageBackend.ts @@ -17,13 +17,23 @@ import { BackendFeature, ServiceFactory } from '@backstage/backend-plugin-api'; import { BackendInitializer } from './BackendInitializer'; import { unwrapFeature } from './helpers'; -import { Backend, BackendStartupResult } from './types'; +import { + Backend, + BackendStartupResult, + ExtensionPointFactoryMiddleware, +} from './types'; export class BackstageBackend implements Backend { #initializer: BackendInitializer; - constructor(defaultServiceFactories: ServiceFactory[]) { - this.#initializer = new BackendInitializer(defaultServiceFactories); + constructor( + defaultServiceFactories: ServiceFactory[], + extensionPointFactoryMiddleware?: ExtensionPointFactoryMiddleware[], + ) { + this.#initializer = new BackendInitializer( + defaultServiceFactories, + extensionPointFactoryMiddleware, + ); } add(feature: BackendFeature | Promise<{ default: BackendFeature }>): void { diff --git a/packages/backend-app-api/src/wiring/createSpecializedBackend.ts b/packages/backend-app-api/src/wiring/createSpecializedBackend.ts index fd449be541..d1a88a21e4 100644 --- a/packages/backend-app-api/src/wiring/createSpecializedBackend.ts +++ b/packages/backend-app-api/src/wiring/createSpecializedBackend.ts @@ -43,5 +43,8 @@ export function createSpecializedBackend( ); } - return new BackstageBackend(options.defaultServiceFactories); + return new BackstageBackend( + options.defaultServiceFactories, + options.extensionPointFactoryMiddleware, + ); } diff --git a/packages/backend-app-api/src/wiring/index.ts b/packages/backend-app-api/src/wiring/index.ts index cc084e5f34..2ddd70e878 100644 --- a/packages/backend-app-api/src/wiring/index.ts +++ b/packages/backend-app-api/src/wiring/index.ts @@ -17,9 +17,11 @@ export type { Backend, CreateSpecializedBackendOptions, + ExtensionPointFactoryMiddleware, BackendStartupResult, PluginStartupResult, ModuleStartupResult, } from './types'; +export { createExtensionPointFactoryMiddleware } from './types'; export { createSpecializedBackend } from './createSpecializedBackend'; export { BackendStartupError } from './BackendStartupError'; diff --git a/packages/backend-app-api/src/wiring/types.ts b/packages/backend-app-api/src/wiring/types.ts index 5782021656..766d737dea 100644 --- a/packages/backend-app-api/src/wiring/types.ts +++ b/packages/backend-app-api/src/wiring/types.ts @@ -21,6 +21,34 @@ import { ServiceFactory, } from '@backstage/backend-plugin-api'; +/** + * A middleware entry that reimplements a specific extension point's output. + * The framework matches by extension point ID and passes through all + * non-matching extension points automatically. + * + * @public + */ +export interface ExtensionPointFactoryMiddleware { + extensionPoint: ExtensionPoint; + middleware: (original: T) => T; +} + +/** + * Creates a typed middleware entry that reimplements a specific extension point. + * Use this helper to preserve type inference for the middleware callback. + * + * @public + */ +export function createExtensionPointFactoryMiddleware( + extensionPoint: ExtensionPoint, + middleware: (original: T) => T, +): ExtensionPointFactoryMiddleware { + return { + extensionPoint: extensionPoint as ExtensionPoint, + middleware: middleware as (original: unknown) => unknown, + }; +} + /** * @public */ @@ -35,6 +63,7 @@ export interface Backend { */ export interface CreateSpecializedBackendOptions { defaultServiceFactories: ServiceFactory[]; + extensionPointFactoryMiddleware?: ExtensionPointFactoryMiddleware[]; } /** diff --git a/packages/backend-defaults/report.api.md b/packages/backend-defaults/report.api.md index def38beda7..ffc67e5473 100644 --- a/packages/backend-defaults/report.api.md +++ b/packages/backend-defaults/report.api.md @@ -5,10 +5,18 @@ ```ts import { Backend } from '@backstage/backend-app-api'; import { BackendFeature } from '@backstage/backend-plugin-api'; +import { createExtensionPointFactoryMiddleware } from '@backstage/backend-app-api'; +import { ExtensionPointFactoryMiddleware } from '@backstage/backend-app-api'; // @public (undocumented) -export function createBackend(): Backend; +export function createBackend(options?: { + extensionPointFactoryMiddleware?: ExtensionPointFactoryMiddleware[]; +}): Backend; + +export { createExtensionPointFactoryMiddleware }; // @public export const discoveryFeatureLoader: BackendFeature; + +export { ExtensionPointFactoryMiddleware }; ``` diff --git a/packages/backend-defaults/src/CreateBackend.ts b/packages/backend-defaults/src/CreateBackend.ts index 2b4ee5befa..e6976f6500 100644 --- a/packages/backend-defaults/src/CreateBackend.ts +++ b/packages/backend-defaults/src/CreateBackend.ts @@ -14,7 +14,11 @@ * limitations under the License. */ -import { Backend, createSpecializedBackend } from '@backstage/backend-app-api'; +import { + Backend, + createSpecializedBackend, + ExtensionPointFactoryMiddleware, +} from '@backstage/backend-app-api'; import { auditorServiceFactory } from '@backstage/backend-defaults/auditor'; import { authServiceFactory } from '@backstage/backend-defaults/auth'; import { cacheServiceFactory } from '@backstage/backend-defaults/cache'; @@ -76,6 +80,11 @@ export const defaultServiceFactories = [ /** * @public */ -export function createBackend(): Backend { - return createSpecializedBackend({ defaultServiceFactories }); +export function createBackend(options?: { + extensionPointFactoryMiddleware?: ExtensionPointFactoryMiddleware[]; +}): Backend { + return createSpecializedBackend({ + defaultServiceFactories, + extensionPointFactoryMiddleware: options?.extensionPointFactoryMiddleware, + }); } diff --git a/packages/backend-defaults/src/index.ts b/packages/backend-defaults/src/index.ts index 5f26df8921..eb3579c88a 100644 --- a/packages/backend-defaults/src/index.ts +++ b/packages/backend-defaults/src/index.ts @@ -21,4 +21,6 @@ */ export { createBackend } from './CreateBackend'; +export type { ExtensionPointFactoryMiddleware } from '@backstage/backend-app-api'; +export { createExtensionPointFactoryMiddleware } from '@backstage/backend-app-api'; export { discoveryFeatureLoader } from './discoveryFeatureLoader';