diff --git a/.changeset/clever-paws-stare.md b/.changeset/clever-paws-stare.md new file mode 100644 index 0000000000..ad56a5306e --- /dev/null +++ b/.changeset/clever-paws-stare.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Added functionality to the prepack script that will append the default export type for entry points to the `exports` object before publishing. This is to help with identifying the declarative integration points for plugins without needing to fetch or run the plugins first. diff --git a/packages/cli/package.json b/packages/cli/package.json index 8a7ba7843a..e4bb68c097 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -147,6 +147,7 @@ "swc-loader": "^0.2.3", "tar": "^6.1.12", "terser-webpack-plugin": "^5.1.3", + "ts-morph": "^23.0.0", "util": "^0.12.3", "webpack": "^5.70.0", "webpack-dev-server": "^5.0.0", diff --git a/packages/cli/src/lib/__testUtils__/createFeatureEnvironment.ts b/packages/cli/src/lib/__testUtils__/createFeatureEnvironment.ts new file mode 100644 index 0000000000..a12a48c523 --- /dev/null +++ b/packages/cli/src/lib/__testUtils__/createFeatureEnvironment.ts @@ -0,0 +1,145 @@ +/* + * Copyright 2024 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 { PackageRole } from '@backstage/cli-node'; +import { Project } from 'ts-morph'; +import { BackstagePackageFeatureType } from '../typeDistProject'; + +const mockEntryPoint = 'dist/index.d.ts'; + +type CreateFeatureEnvironmentOptions = { + $$type?: BackstagePackageFeatureType; + format?: + | 'DefaultExportAssignment' + | 'DefaultExportFromFile' + | 'DefaultExportFromFileAsDefault' + | 'DefaultExportFromFileWithSibling'; + role?: PackageRole; +}; + +type FeatureEnvironment = { + project: Project; + role: PackageRole; + dir: string; + entryPoint: string; +}; + +type File = { + path: string; + content: string; +}; + +const createTestType = ($$type: BackstagePackageFeatureType): File[] => [ + { + path: './dist/createTestType.d.ts', + content: ` +export interface TestType { + readonly $$type: '${$$type}'; +}; + +export function createTestType(): TestType { + return { + $$type: '${$$type}', + }; +}; + `, + }, +]; + +const createMockDefaultExportAssignment = (): File[] => [ + { + path: mockEntryPoint, + content: ` +declare const _default: import("./createTestType").TestType; +export default _default; + `, + }, +]; + +const createMockDefaultExportFromFile = (): File[] => [ + { + path: mockEntryPoint, + content: `export { default } from './linked';`, + }, + { + path: './dist/linked.d.ts', + content: ` +declare const _default: import("./createTestType").TestType; +export default _default; +`, + }, +]; + +const createMockDefaultExportFromFileAsDefault = (): File[] => [ + { + path: mockEntryPoint, + content: `export { test as default } from './linked';`, + }, + { + path: './dist/linked.d.ts', + content: ` +export declare const test: import("./createTestType").TestType; + `, + }, +]; + +const createMockDefaultExportFromFileWithSibling = (): File[] => [ + { + path: mockEntryPoint, + content: `export { default, test } from './linked';`, + }, + { + path: './dist/linked.d.ts', + content: ` +import { createTestType } from './createTestType'; + +export declare const test: import("./createTestType").TestType; +declare const _default: import("./createTestType").TestType; +export default _default; + `, + }, +]; + +const formatToFiles = { + DefaultExportAssignment: createMockDefaultExportAssignment, + DefaultExportFromFile: createMockDefaultExportFromFile, + DefaultExportFromFileAsDefault: createMockDefaultExportFromFileAsDefault, + DefaultExportFromFileWithSibling: createMockDefaultExportFromFileWithSibling, +}; + +export default function createFeatureEnvironment( + options?: CreateFeatureEnvironmentOptions, +): FeatureEnvironment { + const { + $$type = '@backstage/BackendFeature', + format = 'DefaultExportAssignment', + role = 'backend-plugin', + } = options ?? {}; + + const project = new Project(); + const files = [...createTestType($$type), ...formatToFiles[format]()]; + + for (const file of files) { + project.createSourceFile(file.path, file.content); + } + + return { + project, + role, + dir: project.getFileSystem().getCurrentDirectory(), + entryPoint: mockEntryPoint, + }; +} diff --git a/packages/cli/src/lib/packager/productionPack.ts b/packages/cli/src/lib/packager/productionPack.ts index 3c2b5aa89e..8e89be603c 100644 --- a/packages/cli/src/lib/packager/productionPack.ts +++ b/packages/cli/src/lib/packager/productionPack.ts @@ -19,6 +19,10 @@ import npmPackList from 'npm-packlist'; import { resolve as resolvePath, posix as posixPath } from 'path'; import { BackstagePackageJson } from '@backstage/cli-node'; import { readEntryPoints } from '../entryPoints'; +import { + createTypeDistProject, + getEntryPointDefaultFeatureType, +} from '../typeDistProject'; const PKG_PATH = 'package.json'; const PKG_BACKUP_PATH = 'package.json-prepack'; @@ -147,20 +151,44 @@ async function prepareExportsEntryPoints( >(); const entryPoints = readEntryPoints(pkg); + const project = await createTypeDistProject(); + for (const entryPoint of entryPoints) { if (!SCRIPT_EXTS.includes(entryPoint.ext)) { outputExports[entryPoint.mount] = entryPoint.path; continue; } - const exp = {} as Record; + + let exp = {} as Record; + for (const [key, ext] of Object.entries(EXPORT_MAP)) { const name = `${entryPoint.name}${ext}`; if (distFiles.includes(name)) { exp[key] = `./${posixPath.join(`dist`, name)}`; } } + exp.default = exp.require ?? exp.import; + // Find the default export type for the entry point + if (exp.types) { + const defaultFeatureType = + pkg.backstage?.role && + getEntryPointDefaultFeatureType( + pkg.backstage?.role, + packageDir, + project, + exp.types, + ); + + if (defaultFeatureType) { + // This ensures that the `backstage` field is at the top of the + // `exports` field in the package.json because order is important. + // https://nodejs.org/docs/latest-v20.x/api/packages.html#conditional-exports + exp = { backstage: defaultFeatureType, ...exp }; + } + } + // This creates a directory with a lone package.json for backwards compatibility if (entryPoint.mount === '.') { if (exp.default) { diff --git a/packages/cli/src/lib/typeDistProject.test.ts b/packages/cli/src/lib/typeDistProject.test.ts new file mode 100644 index 0000000000..dfe3e68ec0 --- /dev/null +++ b/packages/cli/src/lib/typeDistProject.test.ts @@ -0,0 +1,131 @@ +/* + * Copyright 2024 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 { PackageRole } from '@backstage/cli-node'; +import createFeatureEnvironment from './__testUtils__/createFeatureEnvironment'; +import { + getEntryPointDefaultFeatureType, + BackstagePackageFeatureType, +} from './typeDistProject'; + +describe('typeDistProject', () => { + describe('for package role', () => { + // This Record makes sure we're checking all package roles + const packageRoles: Record = { + // Allowed + 'backend-plugin': true, + 'backend-plugin-module': true, + 'frontend-plugin': true, + 'frontend-plugin-module': true, + 'web-library': true, + 'node-library': true, + // Disallowed + frontend: false, + backend: false, + cli: false, + 'common-library': false, + }; + + const allowedPackageRoles = Object.keys(packageRoles).filter( + role => packageRoles[role as PackageRole], + ); + + const disallowedPackageRoles = Object.keys(packageRoles).filter( + role => !packageRoles[role as PackageRole], + ); + + it.each(allowedPackageRoles)(`returns features for %s`, r => { + const { project, role, dir, entryPoint } = createFeatureEnvironment({ + role: r as PackageRole, + }); + + expect( + getEntryPointDefaultFeatureType(role, dir, project, entryPoint), + ).toEqual('@backstage/BackendFeature'); + }); + + it.each(disallowedPackageRoles)(`does not return features for %s`, r => { + const { project, role, dir, entryPoint } = createFeatureEnvironment({ + role: r as PackageRole, + }); + + expect( + getEntryPointDefaultFeatureType(role, dir, project, entryPoint), + ).toEqual(null); + }); + }); + + describe('for feature $$type', () => { + // This Record makes sure we're checking all feature types + const featureTypes: Record = + { + // Allowed + '@backstage/BackendFeature': true, + '@backstage/BackstagePlugin': true, + '@backstage/FrontendPlugin': true, + '@backstage/FrontendModule': true, + // Disallowed + '@backstage/Extension': false, + '@backstage/RouteRef': false, + }; + + const allowedFeatureTypes = Object.keys(featureTypes).filter( + $$type => featureTypes[$$type as BackstagePackageFeatureType], + ); + + const disallowedFeatureTypes = Object.keys(featureTypes).filter( + $$type => !featureTypes[$$type as BackstagePackageFeatureType], + ); + + it.each(allowedFeatureTypes)(`returns features for "%s" $$type`, $$type => { + const { project, role, dir, entryPoint } = createFeatureEnvironment({ + $$type: $$type as BackstagePackageFeatureType, + }); + + expect( + getEntryPointDefaultFeatureType(role, dir, project, entryPoint), + ).toEqual($$type); + }); + + it.each(disallowedFeatureTypes)( + `does not return features for "%s" $$type`, + $$type => { + const { project, role, dir, entryPoint } = createFeatureEnvironment({ + $$type: $$type as BackstagePackageFeatureType, + }); + + expect( + getEntryPointDefaultFeatureType(role, dir, project, entryPoint), + ).toEqual(null); + }, + ); + }); + + it.each([ + 'DefaultExportAssignment', + 'DefaultExportFromFile', + 'DefaultExportFromFileAsDefault', + 'DefaultExportFromFileWithSibling', + ] as const)('returns features for format "%s"', format => { + const { project, role, dir, entryPoint } = createFeatureEnvironment({ + format, + }); + + expect( + getEntryPointDefaultFeatureType(role, dir, project, entryPoint), + ).toEqual('@backstage/BackendFeature'); + }); +}); diff --git a/packages/cli/src/lib/typeDistProject.ts b/packages/cli/src/lib/typeDistProject.ts new file mode 100644 index 0000000000..59dc503f22 --- /dev/null +++ b/packages/cli/src/lib/typeDistProject.ts @@ -0,0 +1,151 @@ +/* + * Copyright 2024 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 { PackageRole } from '@backstage/cli-node'; +import { resolve as resolvePath } from 'path'; +import { Project, SourceFile, SyntaxKind, ts, Type } from 'ts-morph'; +import { paths } from './paths'; + +export const createTypeDistProject = async () => { + return new Project({ + tsConfigFilePath: paths.resolveTargetRoot('tsconfig.json'), + skipAddingFilesFromTsConfig: true, + }); +}; + +// A list of the package roles we want to extract features for +const targetPackageRoles: PackageRole[] = [ + 'backend-plugin', + 'backend-plugin-module', + 'frontend-plugin', + 'frontend-plugin-module', + 'web-library', + 'node-library', +]; + +// A list of the feature types we want to extract from the project +// and include in the metadata +const targetFeatureTypes = [ + '@backstage/BackendFeature', + '@backstage/BackstagePlugin', + '@backstage/FrontendPlugin', + '@backstage/FrontendModule', +] as const; + +export type BackstagePackageFeatureType = (typeof targetFeatureTypes)[number]; + +export const getEntryPointDefaultFeatureType = ( + role: PackageRole, + packageDir: string, + project: Project, + entryPoint: string, +): BackstagePackageFeatureType | null => { + if (isTargetPackageRole(role)) { + const distPath = resolvePath(packageDir, entryPoint); + + try { + const defaultFeatureType = getSourceFileDefaultFeatureType( + project.addSourceFileAtPath(distPath), + ); + + if (defaultFeatureType) { + return defaultFeatureType; + } + } catch (error) { + console.error( + `Failed to extract default feature type from ${distPath}, ${error}. ` + + 'Your package will publish fine but it may be missing metadata about its default feature.', + ); + } + } + + return null; +}; + +// Returns all exports (default and named) from an entry point +// that are valid Backstage package features +function getSourceFileDefaultFeatureType( + sourceFile: SourceFile, +): BackstagePackageFeatureType | null { + for (const exportSymbol of sourceFile.getExportSymbols()) { + const declaration = exportSymbol.getDeclarations()[0]; + const exportName = declaration.getSymbol()?.getName(); + + if (exportName !== 'default') { + continue; + } + + let exportType: Type | undefined; + + if (declaration) { + if (declaration.isKind(SyntaxKind.ExportAssignment)) { + exportType = declaration.getExpression().getType(); + } else if (declaration.isKind(SyntaxKind.ExportSpecifier)) { + if (!declaration.isTypeOnly()) { + exportType = declaration.getType(); + } + } else if (declaration.isKind(SyntaxKind.VariableDeclaration)) { + exportType = declaration.getType(); + } + } + + if (exportName && exportType) { + const $$type = getBackstagePackageFeature$$TypeFromType(exportType); + + if ($$type) { + return $$type; + } + } + } + + return null; +} + +// Given a TS type, returns the Backstage package feature $$type value +function getBackstagePackageFeature$$TypeFromType( + type: Type, +): BackstagePackageFeatureType | null { + // Returns the concrete type of a generic type + const exportType = type.getTargetType() ?? type; + + for (const property of exportType.getProperties()) { + if (property.getName() === '$$type') { + const $$type = property + .getValueDeclaration() + ?.getText() + .match(/(\$\$type: '(?.+)')/)?.groups?.type; + + if ($$type && isTargetFeatureType($$type)) { + return $$type; + } + } + } + + return null; +} + +// Condition for a package role matches a target package role +function isTargetPackageRole(role: PackageRole): boolean { + return !!role && targetPackageRoles.includes(role); +} + +// Returns whether an export is a valid Backstage package feature type +function isTargetFeatureType( + type: string | BackstagePackageFeatureType, +): type is BackstagePackageFeatureType { + return ( + !!type && targetFeatureTypes.includes(type as BackstagePackageFeatureType) + ); +} diff --git a/yarn.lock b/yarn.lock index c046b6d31a..7a0bff43c9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3999,6 +3999,7 @@ __metadata: swc-loader: ^0.2.3 tar: ^6.1.12 terser-webpack-plugin: ^5.1.3 + ts-morph: ^23.0.0 util: ^0.12.3 vite: ^5.0.0 vite-plugin-html: ^3.2.2