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..73add7bc7f --- /dev/null +++ b/.changeset/extension-point-middleware-backend-defaults.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-defaults': patch +--- + +Exported `defaultServiceFactories` to allow use with `createSpecializedBackend` for advanced configuration like `extensionPointFactoryMiddleware`. diff --git a/packages/backend-app-api/report.api.md b/packages/backend-app-api/report.api.md index 62b46b93e6..1fcfc5926e 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(options: { + extensionPoint: ExtensionPoint; + middleware: (original: T) => Promise; +}): ExtensionPointFactoryMiddleware; + // @public (undocumented) export function createSpecializedBackend( options: CreateSpecializedBackendOptions, @@ -51,6 +58,14 @@ export function createSpecializedBackend( export interface CreateSpecializedBackendOptions { // (undocumented) defaultServiceFactories: ServiceFactory[]; + // (undocumented) + extensionPointFactoryMiddleware?: ExtensionPointFactoryMiddleware[]; +} + +// @public +export interface ExtensionPointFactoryMiddleware { + // (undocumented) + $$type: '@backstage/ExtensionPointFactoryMiddleware'; } // @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..fb4da6ca05 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,218 @@ 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, + middleware: async 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({ + extensionPoint: extensionPointA, + middleware: async 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, + middleware: async original => ({ + ...original, + values: [...original.values, 'first'], + }), + }), + createExtensionPointFactoryMiddleware({ + extensionPoint, + middleware: async 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({ + extensionPoint: unregisteredExtensionPoint, + middleware: async 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 9ea716cc0e..94e3e7c960 100644 --- a/packages/backend-app-api/src/wiring/BackendInitializer.ts +++ b/packages/backend-app-api/src/wiring/BackendInitializer.ts @@ -25,7 +25,11 @@ import { createServiceFactory, ExtensionPointFactoryContext, } from '@backstage/backend-plugin-api'; -import { ServiceOrExtensionPoint } from './types'; +import { + ExtensionPointFactoryMiddleware, + ServiceOrExtensionPoint, +} from './types'; +import { OpaqueExtensionPointFactoryMiddleware } from '@internal/backend'; // Direct internal import to avoid duplication // eslint-disable-next-line @backstage/no-relative-monorepo-imports import type { @@ -166,11 +170,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 +205,18 @@ 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) { + const internal = OpaqueExtensionPointFactoryMiddleware.toInternal(mw); + if (internal.extensionPointId === ref.id) { + epImpl = await internal.middleware(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..afd0cecbfb 100644 --- a/packages/backend-app-api/src/wiring/types.ts +++ b/packages/backend-app-api/src/wiring/types.ts @@ -20,6 +20,34 @@ import { ServiceRef, ServiceFactory, } from '@backstage/backend-plugin-api'; +import { OpaqueExtensionPointFactoryMiddleware } from '@internal/backend'; + +/** + * 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 { + $$type: '@backstage/ExtensionPointFactoryMiddleware'; +} + +/** + * 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(options: { + extensionPoint: ExtensionPoint; + middleware: (original: T) => Promise; +}): ExtensionPointFactoryMiddleware { + return OpaqueExtensionPointFactoryMiddleware.createInstance('v1', { + extensionPointId: options.extensionPoint.id, + middleware: options.middleware as (original: unknown) => Promise, + }); +} /** * @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..a6702e59f9 100644 --- a/packages/backend-defaults/report.api.md +++ b/packages/backend-defaults/report.api.md @@ -5,10 +5,14 @@ ```ts import { Backend } from '@backstage/backend-app-api'; import { BackendFeature } from '@backstage/backend-plugin-api'; +import { ServiceFactory } from '@backstage/backend-plugin-api'; // @public (undocumented) export function createBackend(): Backend; +// @public (undocumented) +export const defaultServiceFactories: ServiceFactory[]; + // @public export const discoveryFeatureLoader: BackendFeature; ``` diff --git a/packages/backend-defaults/src/CreateBackend.ts b/packages/backend-defaults/src/CreateBackend.ts index 2b4ee5befa..d069c17b99 100644 --- a/packages/backend-defaults/src/CreateBackend.ts +++ b/packages/backend-defaults/src/CreateBackend.ts @@ -15,6 +15,7 @@ */ import { Backend, createSpecializedBackend } from '@backstage/backend-app-api'; +import { ServiceFactory } from '@backstage/backend-plugin-api'; import { auditorServiceFactory } from '@backstage/backend-defaults/auditor'; import { authServiceFactory } from '@backstage/backend-defaults/auth'; import { cacheServiceFactory } from '@backstage/backend-defaults/cache'; @@ -42,7 +43,8 @@ import { } from '@backstage/backend-defaults/alpha'; import { instanceMetadataServiceFactory } from './alpha/entrypoints/instanceMetadata/instanceMetadataServiceFactory'; -export const defaultServiceFactories = [ +/** @public */ +export const defaultServiceFactories: ServiceFactory[] = [ auditorServiceFactory, authServiceFactory, cacheServiceFactory, diff --git a/packages/backend-defaults/src/index.ts b/packages/backend-defaults/src/index.ts index 5f26df8921..ccf75ed9f8 100644 --- a/packages/backend-defaults/src/index.ts +++ b/packages/backend-defaults/src/index.ts @@ -20,5 +20,5 @@ * @packageDocumentation */ -export { createBackend } from './CreateBackend'; +export { createBackend, defaultServiceFactories } from './CreateBackend'; export { discoveryFeatureLoader } from './discoveryFeatureLoader'; diff --git a/packages/backend-internal/.eslintrc.js b/packages/backend-internal/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/packages/backend-internal/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/packages/backend-internal/README.md b/packages/backend-internal/README.md new file mode 100644 index 0000000000..8994693a1b --- /dev/null +++ b/packages/backend-internal/README.md @@ -0,0 +1,3 @@ +# @internal/backend + +This is an internal package used by the other backend packages. It does not get published to NPM, but instead inlined into consuming packages due to the `backstage.inline` flag in `package.json`. diff --git a/packages/backend-internal/catalog-info.yaml b/packages/backend-internal/catalog-info.yaml new file mode 100644 index 0000000000..37bc00382a --- /dev/null +++ b/packages/backend-internal/catalog-info.yaml @@ -0,0 +1,9 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: internal-backend + title: '@internal/backend' +spec: + lifecycle: experimental + type: backstage-node-library + owner: framework-maintainers diff --git a/packages/backend-internal/package.json b/packages/backend-internal/package.json new file mode 100644 index 0000000000..b043d1d42f --- /dev/null +++ b/packages/backend-internal/package.json @@ -0,0 +1,28 @@ +{ + "name": "@internal/backend", + "version": "0.0.1", + "backstage": { + "role": "node-library", + "inline": true + }, + "private": true, + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "packages/backend-internal" + }, + "license": "Apache-2.0", + "sideEffects": false, + "main": "src/index.ts", + "types": "src/index.ts", + "files": [ + "dist" + ], + "scripts": { + "lint": "backstage-cli package lint", + "test": "backstage-cli package test" + }, + "devDependencies": { + "@backstage/cli": "workspace:^" + } +} diff --git a/packages/backend-internal/src/index.ts b/packages/backend-internal/src/index.ts new file mode 100644 index 0000000000..add807a2c4 --- /dev/null +++ b/packages/backend-internal/src/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export { OpaqueExtensionPointFactoryMiddleware } from './wiring'; diff --git a/packages/backend-internal/src/wiring/OpaqueExtensionPointFactoryMiddleware.ts b/packages/backend-internal/src/wiring/OpaqueExtensionPointFactoryMiddleware.ts new file mode 100644 index 0000000000..d94226ed93 --- /dev/null +++ b/packages/backend-internal/src/wiring/OpaqueExtensionPointFactoryMiddleware.ts @@ -0,0 +1,29 @@ +/* + * 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 { OpaqueType } from '@internal/opaque'; + +export const OpaqueExtensionPointFactoryMiddleware = OpaqueType.create<{ + public: { $$type: '@backstage/ExtensionPointFactoryMiddleware' }; + versions: { + readonly version: 'v1'; + readonly extensionPointId: string; + readonly middleware: (original: unknown) => Promise; + }; +}>({ + type: '@backstage/ExtensionPointFactoryMiddleware', + versions: ['v1'], +}); diff --git a/packages/backend-internal/src/wiring/index.ts b/packages/backend-internal/src/wiring/index.ts new file mode 100644 index 0000000000..72c745bc9b --- /dev/null +++ b/packages/backend-internal/src/wiring/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export { OpaqueExtensionPointFactoryMiddleware } from './OpaqueExtensionPointFactoryMiddleware'; diff --git a/yarn.lock b/yarn.lock index b906ef98aa..f60d2295ea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10089,6 +10089,14 @@ __metadata: languageName: node linkType: hard +"@internal/backend@workspace:packages/backend-internal": + version: 0.0.0-use.local + resolution: "@internal/backend@workspace:packages/backend-internal" + dependencies: + "@backstage/cli": "workspace:^" + languageName: unknown + linkType: soft + "@internal/cli@workspace:packages/cli-internal": version: 0.0.0-use.local resolution: "@internal/cli@workspace:packages/cli-internal"