diff --git a/.changeset/hip-fans-decide.md b/.changeset/hip-fans-decide.md new file mode 100644 index 0000000000..ecdf6d8a4d --- /dev/null +++ b/.changeset/hip-fans-decide.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli-node': patch +--- + +Add definition for the new `backstage.inline` field in `package.json`. diff --git a/.changeset/smart-beers-give.md b/.changeset/smart-beers-give.md new file mode 100644 index 0000000000..5b9d9888ba --- /dev/null +++ b/.changeset/smart-beers-give.md @@ -0,0 +1,6 @@ +--- +'@backstage/frontend-plugin-api': patch +'@backstage/frontend-test-utils': patch +--- + +Internal refactor diff --git a/.changeset/stupid-toes-complain.md b/.changeset/stupid-toes-complain.md new file mode 100644 index 0000000000..246dce3af1 --- /dev/null +++ b/.changeset/stupid-toes-complain.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +The build commands now support the new `backstage.inline` flag in `package.json`, which causes the contents of private packages to be inlined into the consuming package, rather than be treated as an external dependency. diff --git a/.changeset/thirty-bottles-rest.md b/.changeset/thirty-bottles-rest.md new file mode 100644 index 0000000000..51b572d507 --- /dev/null +++ b/.changeset/thirty-bottles-rest.md @@ -0,0 +1,5 @@ +--- +'@backstage/eslint-plugin': patch +--- + +Added support for linting dependencies on workspace packages with the `backstage.inline` flag. diff --git a/.changeset/yellow-panthers-decide.md b/.changeset/yellow-panthers-decide.md new file mode 100644 index 0000000000..a8bf76a17d --- /dev/null +++ b/.changeset/yellow-panthers-decide.md @@ -0,0 +1,5 @@ +--- +'@backstage/repo-tools': patch +--- + +Avoid generating API reports for packages with `backstage.inline` set. diff --git a/docs/tooling/package-metadata.md b/docs/tooling/package-metadata.md index 63ce6d1085..ad9d5ec5a0 100644 --- a/docs/tooling/package-metadata.md +++ b/docs/tooling/package-metadata.md @@ -156,6 +156,26 @@ This field indicates that a package has been renamed and moved to a new location } ``` +### `backstage.inline` + +If set to `true` this field indicates that monorepo package is private and should be inlined into dependent packages rather than being treated as a dependency. This effectively means that all imported code from the inlined package will be copied into the consuming package, once for each package. + +This flag affects various parts of the Backstage tooling, for example the way the Backstage CLI builds work, the way that `@backstage/eslint-plugin` lints dependencies on the package, and how it's treated by `@backstage/repo-tools`. + +The `backstage.inline` field is primarily intended to aid in the implementation of the Backstage core framework in the main Backstage repository, but it can be used in other projects as well. + +Setting this flag also requires the top-level `private` field to be set as well, since inline packages should not be published. + +```js title="Example usage of the backstage.moved field" +{ + "name": "@internal/utils", + "backstage": { + "inline": true + } + ... +} +``` + ## Metadata for Published Packages When publishing a package with the help of the Backstage CLI, there are a number of metadata checks that are performed to ensure that the package is correctly set up for the Backstage ecosystem. These checks are performed by the `backstage-cli package prepack` command, which is used to prepare a package for publishing. These checks can all also be verified separately using the `backstage-cli repo fix --publish` command, and in many cases the required metadata can be generated automatically. It is therefore important to make running the `fix` command part of your workflow in any project that is publishing Backstage packages. diff --git a/packages/cli-node/api-report.md b/packages/cli-node/api-report.md index 9f7e525048..f22583dd25 100644 --- a/packages/cli-node/api-report.md +++ b/packages/cli-node/api-report.md @@ -18,6 +18,7 @@ export interface BackstagePackageJson { backstage?: { role?: PackageRole; moved?: string; + inline?: boolean; pluginId?: string | null; pluginPackage?: string; pluginPackages?: string[]; diff --git a/packages/cli-node/src/monorepo/PackageGraph.ts b/packages/cli-node/src/monorepo/PackageGraph.ts index c8877803d5..69ec99c2dd 100644 --- a/packages/cli-node/src/monorepo/PackageGraph.ts +++ b/packages/cli-node/src/monorepo/PackageGraph.ts @@ -47,6 +47,15 @@ export interface BackstagePackageJson { role?: PackageRole; moved?: string; + /** + * If set to `true`, the package will be treated as an internal package + * where any imports will be inlined into the consuming package. + * + * When set to `true`, the top-level `private` field must be set to `true` + * as well. + */ + inline?: boolean; + /** * The ID of the plugin if this is a plugin package. Must always be set for plugin and module packages, and may be set for library packages. A `null` value means that the package is explicitly not a plugin package. */ diff --git a/packages/cli/src/commands/build/buildBackend.ts b/packages/cli/src/commands/build/buildBackend.ts index 3609e40eb5..88d322d482 100644 --- a/packages/cli/src/commands/build/buildBackend.ts +++ b/packages/cli/src/commands/build/buildBackend.ts @@ -21,6 +21,7 @@ import tar, { CreateOptions } from 'tar'; import { createDistWorkspace } from '../../lib/packager'; import { getEnvironmentParallelism } from '../../lib/parallel'; import { buildPackage, Output } from '../../lib/builder'; +import { PackageGraph } from '@backstage/cli-node'; const BUNDLE_FILE = 'bundle.tar.gz'; const SKELETON_FILE = 'skeleton.tar.gz'; @@ -42,6 +43,7 @@ export async function buildBackend(options: BuildBackendOptions) { packageJson: pkg, outputs: new Set([Output.cjs]), minify, + workspacePackages: await PackageGraph.listTargetPackages(), }); const tmpDir = await fs.mkdtemp(resolvePath(os.tmpdir(), 'backstage-bundle')); diff --git a/packages/cli/src/commands/build/command.ts b/packages/cli/src/commands/build/command.ts index c0ad3c912b..a5f7f60971 100644 --- a/packages/cli/src/commands/build/command.ts +++ b/packages/cli/src/commands/build/command.ts @@ -17,7 +17,7 @@ import { OptionValues } from 'commander'; import { buildPackage, Output } from '../../lib/builder'; import { findRoleFromCommand } from '../../lib/role'; -import { PackageRoles } from '@backstage/cli-node'; +import { PackageGraph, PackageRoles } from '@backstage/cli-node'; import { paths } from '../../lib/paths'; import { buildFrontend } from './buildFrontend'; import { buildBackend } from './buildBackend'; @@ -82,5 +82,6 @@ export async function command(opts: OptionValues): Promise { return buildPackage({ outputs, minify: Boolean(opts.minify), + workspacePackages: await PackageGraph.listTargetPackages(), }); } diff --git a/packages/cli/src/commands/repo/build.ts b/packages/cli/src/commands/repo/build.ts index 6d362c7d98..6a2b9be06d 100644 --- a/packages/cli/src/commands/repo/build.ts +++ b/packages/cli/src/commands/repo/build.ts @@ -137,6 +137,7 @@ export async function command(opts: OptionValues, cmd: Command): Promise { outputs, logPrefix: `${chalk.cyan(relativePath(paths.targetRoot, pkg.dir))}: `, minify: buildOptions.minify, + workspacePackages: packages, }; }); diff --git a/packages/cli/src/lib/builder/config.test.ts b/packages/cli/src/lib/builder/config.test.ts index 733c35fb55..e426e1ffa5 100644 --- a/packages/cli/src/lib/builder/config.test.ts +++ b/packages/cli/src/lib/builder/config.test.ts @@ -29,6 +29,7 @@ describe('makeRollupConfigs', () => { version: '0.0.0', main: './src/index.ts', }, + workspacePackages: [], }); const external = config.external as Exclude< ExternalOption, diff --git a/packages/cli/src/lib/builder/config.ts b/packages/cli/src/lib/builder/config.ts index a514158e9b..a11ffe8845 100644 --- a/packages/cli/src/lib/builder/config.ts +++ b/packages/cli/src/lib/builder/config.ts @@ -53,6 +53,21 @@ function isFileImport(source: string) { return false; } +function buildInternalImportPattern(options: BuildOptions) { + const inlinedPackages = options.workspacePackages.filter( + pkg => pkg.packageJson.backstage?.inline, + ); + for (const { packageJson } of inlinedPackages) { + if (!packageJson.private) { + throw new Error( + `Inlined package ${packageJson.name} must be marked as private`, + ); + } + } + const names = inlinedPackages.map(pkg => pkg.packageJson.name); + return new RegExp(`^(?:${names.join('|')})(?:$|/)`); +} + export async function makeRollupConfigs( options: BuildOptions, ): Promise { @@ -83,6 +98,19 @@ export async function makeRollupConfigs( SCRIPT_EXTS.includes(e.ext), ); + const internalImportPattern = buildInternalImportPattern(options); + const external = ( + source: string, + importer: string | undefined, + isResolved: boolean, + ) => + Boolean( + importer && + !isResolved && + !internalImportPattern.test(source) && + !isFileImport(source), + ); + if (options.outputs.has(Output.cjs) || options.outputs.has(Output.esm)) { const output = new Array(); const mainFields = ['module', 'main']; @@ -121,8 +149,7 @@ export async function makeRollupConfigs( makeAbsoluteExternalsRelative: false, preserveEntrySignatures: 'strict', // All module imports are always marked as external - external: (source, importer, isResolved) => - Boolean(importer && !isResolved && !isFileImport(source)), + external, plugins: [ resolve({ mainFields }), commonjs({ @@ -191,18 +218,11 @@ export async function makeRollupConfigs( chunkFileNames: `types/[name]-[hash].d.ts`, format: 'es', }, - external: [ - /\.css$/, - /\.scss$/, - /\.sass$/, - /\.svg$/, - /\.eot$/, - /\.woff$/, - /\.woff2$/, - /\.ttf$/, - ], + external: (source, importer, isResolved) => + /\.css|scss|sass|svg|eot|woff|woff2|ttf$/.test(source) || + external(source, importer, isResolved), onwarn, - plugins: [dts()], + plugins: [dts({ respectExternal: true })], }); } diff --git a/packages/cli/src/lib/builder/types.ts b/packages/cli/src/lib/builder/types.ts index 7d9c34fe29..937bd157c9 100644 --- a/packages/cli/src/lib/builder/types.ts +++ b/packages/cli/src/lib/builder/types.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { BackstagePackageJson } from '@backstage/cli-node'; +import { BackstagePackage, BackstagePackageJson } from '@backstage/cli-node'; export enum Output { esm, @@ -28,4 +28,5 @@ export type BuildOptions = { packageJson?: BackstagePackageJson; outputs: Set; minify?: boolean; + workspacePackages: BackstagePackage[]; }; diff --git a/packages/cli/src/lib/packager/createDistWorkspace.ts b/packages/cli/src/lib/packager/createDistWorkspace.ts index b966e23a85..54ce9c073f 100644 --- a/packages/cli/src/lib/packager/createDistWorkspace.ts +++ b/packages/cli/src/lib/packager/createDistWorkspace.ts @@ -211,6 +211,7 @@ export async function createDistWorkspace( outputs: outputs, logPrefix: `${chalk.cyan(relativePath(paths.targetRoot, pkg.dir))}: `, minify: options.minify, + workspacePackages: packages, }); } } diff --git a/packages/eslint-plugin/lib/getPackages.js b/packages/eslint-plugin/lib/getPackages.js index 8f4ccfacf3..3b46f41a54 100644 --- a/packages/eslint-plugin/lib/getPackages.js +++ b/packages/eslint-plugin/lib/getPackages.js @@ -21,7 +21,7 @@ const manypkg = require('@manypkg/get-packages'); /** * @typedef ExtendedPackage - * @type {import('@manypkg/get-packages').Package & { packageJson: { exports?: Record, files?: Array }}} packageJson + * @type {import('@manypkg/get-packages').Package & { packageJson: { exports?: Record, files?: Array, backstage?: { inline?: boolean } }}} packageJson */ /** diff --git a/packages/eslint-plugin/rules/no-undeclared-imports.js b/packages/eslint-plugin/rules/no-undeclared-imports.js index d2c92ccb8a..b770ded365 100644 --- a/packages/eslint-plugin/rules/no-undeclared-imports.js +++ b/packages/eslint-plugin/rules/no-undeclared-imports.js @@ -22,11 +22,11 @@ const visitImports = require('../lib/visitImports'); const minimatch = require('minimatch'); const { execFileSync } = require('child_process'); -const depFields = { +const depFields = /** @type {const} */ ({ dep: 'dependencies', dev: 'devDependencies', peer: 'peerDependencies', -}; +}); const devModulePatterns = [ new minimatch.Minimatch('!src/**'), @@ -154,6 +154,124 @@ function addVersionQuery(name, flag, packages) { return `${name}@${mostCommonRange}`; } +/** + * Add missing package imports + * @param {Array<{name: string, flag: string, node: import('estree').Node}>} toAdd + * @param {import('../lib/getPackages').PackageMap} packages + * @param {import('../lib/getPackages').ExtendedPackage} localPkg + */ +function addMissingImports(toAdd, packages, localPkg) { + /** @type Record> */ + const byFlag = {}; + + for (const { name, flag } of toAdd) { + byFlag[flag] = byFlag[flag] ?? new Set(); + byFlag[flag].add(name); + } + + for (const name of byFlag[''] ?? []) { + byFlag['--dev']?.delete(name); + } + for (const name of byFlag['--peer'] ?? []) { + byFlag['']?.delete(name); + byFlag['--dev']?.delete(name); + } + + for (const [flag, names] of Object.entries(byFlag)) { + // Look up existing version queries in the repo for the same dependency + const namesWithQuery = [...names].map(name => + addVersionQuery(name, flag, packages), + ); + + // The security implication of this is a bit interesting, as crafted add-import + // directives could be used to install malicious packages. However, the same is true + // for adding malicious packages to package.json, so there's no significant difference. + execFileSync('yarn', ['add', ...(flag ? [flag] : []), ...namesWithQuery], { + cwd: localPkg.dir, + stdio: 'inherit', + }); + } +} + +/** + * Removes dependency entries pointing to inlined workspace packages. + * @param {Array<{pkg: import('../lib/getPackages').ExtendedPackage, node: import('estree').Node}>} toInline + * @param {import('../lib/getPackages').ExtendedPackage} localPkg + */ +function removeInlineImports(toInline, localPkg) { + /** @type Set */ + const toRemove = new Set(); + + for (const { pkg } of toInline) { + const name = pkg.packageJson.name; + for (const depType of Object.values(depFields)) { + if (localPkg.packageJson[depType]?.[name]) { + toRemove.add(name); + } + } + } + if (toRemove.size > 0) { + execFileSync('yarn', ['remove', ...toRemove], { + cwd: localPkg.dir, + stdio: 'inherit', + }); + } +} + +/** + * Adds dependencies that are not properly forwarded from inline dependencies. + * @param {Array<{pkg: import('../lib/getPackages').ExtendedPackage, node: import('estree').Node}>} toInline + * @param {import('../lib/getPackages').ExtendedPackage} localPkg + */ +function addForwardedInlineImports(toInline, localPkg) { + const declaredProdDeps = new Set([ + ...Object.keys(localPkg.packageJson.dependencies ?? {}), + ...Object.keys(localPkg.packageJson.peerDependencies ?? {}), + localPkg.packageJson.name, // include self + ]); + + /** @type Map> */ + const byFlagByName = new Map(); + + for (const { pkg } of toInline) { + for (const depType of /** @type {const} */ ([ + 'dependencies', + 'peerDependencies', + ])) { + for (const [depName, depQuery] of Object.entries( + pkg.packageJson[depType] ?? {}, + )) { + if (!declaredProdDeps.has(depName)) { + const flag = getAddFlagForDepsField(depType); + const byName = byFlagByName.get(flag); + if (byName) { + const query = byName.get(depName); + if (query && query !== depQuery) { + throw new Error( + `Conflicting dependency queries for inlined package dep ${depName}, got ${query} and ${depQuery}`, + ); + } else { + byName.set(depName, depQuery); + } + } else { + byFlagByName.set(flag, new Map([[depName, depQuery]])); + } + } + } + } + } + + for (const [flag, byName] of byFlagByName) { + const namesWithQuery = [...byName.entries()].map( + ([name, query]) => `${name}@${query}`, + ); + execFileSync('yarn', ['add', ...(flag ? [flag] : []), ...namesWithQuery], { + cwd: localPkg.dir, + stdio: 'inherit', + }); + } +} + /** @type {import('eslint').Rule.RuleModule} */ module.exports = { meta: { @@ -165,6 +283,8 @@ module.exports = { switch: '{{ packageName }} is declared in {{ oldDepsField }}, but should be moved to {{ depsField }} in {{ packageJsonPath }}.', switchBack: 'Switch back to import declaration', + inlineDirect: `The dependency on the inline package {{ packageName }} must not be declared in package dependencies.`, + inlineMissing: `Each production dependency from the inline package {{ packageName }} must be re-declared by this package, the following dependencies are missing: {{ missingDeps }}`, }, docs: { description: @@ -189,57 +309,48 @@ module.exports = { /** @type Array<{name: string, flag: string, node: import('estree').Node}> */ const importsToAdd = []; + /** @type Array<{pkg: import('../lib/getPackages').ExtendedPackage, node: import('estree').Node}> */ + const importsToInline = []; + return { // All missing imports that we detect are collected as we traverse, and then we use // the program exit to execute all install directives that have been found. ['Program:exit']() { - /** @type Record> */ - const byFlag = {}; + if (importsToAdd.length > 0) { + addMissingImports(importsToAdd, packages, localPkg); - for (const { name, flag } of importsToAdd) { - byFlag[flag] = byFlag[flag] ?? new Set(); - byFlag[flag].add(name); + // This switches all import directives back to the original import. + for (const added of importsToAdd) { + context.report({ + node: added.node, + messageId: 'switchBack', + fix(fixer) { + return fixer.replaceText(added.node, `'${added.name}'`); + }, + }); + } + importsToAdd.length = 0; } - for (const name of byFlag[''] ?? []) { - byFlag['--dev']?.delete(name); - } - for (const name of byFlag['--peer'] ?? []) { - byFlag['']?.delete(name); - byFlag['--dev']?.delete(name); + if (importsToInline.length > 0) { + removeInlineImports(importsToInline, localPkg); + addForwardedInlineImports(importsToInline, localPkg); + + for (const inlined of importsToInline) { + context.report({ + node: inlined.node, + messageId: 'switchBack', + fix(fixer) { + return fixer.replaceText( + inlined.node, + `'${inlined.pkg.packageJson.name}'`, + ); + }, + }); + } + importsToInline.length = 0; } - for (const [flag, names] of Object.entries(byFlag)) { - // Look up existing version queries in the repo for the same dependency - const namesWithQuery = [...names].map(name => - addVersionQuery(name, flag, packages), - ); - - // The security implication of this is a bit interesting, as crafted add-import - // directives could be used to install malicious packages. However, the same is true - // for adding malicious packages to package.json, so there's no significant difference. - execFileSync( - 'yarn', - ['add', ...(flag ? [flag] : []), ...namesWithQuery], - { - cwd: localPkg.dir, - stdio: 'inherit', - }, - ); - } - - // This switches all import directives back to the original import. - for (const added of importsToAdd) { - context.report({ - node: added.node, - messageId: 'switchBack', - fix(fixer) { - return fixer.replaceText(added.node, `'${added.name}'`); - }, - }); - } - - importsToAdd.length = 0; packages.clearCache(); }, ...visitImports(context, (node, imp) => { @@ -255,22 +366,99 @@ module.exports = { // Any import directive that is found is collected for processing later if (imp.type === 'directive') { - const parts = imp.path.split(':'); - if (parts[1] !== 'add-import') { - return; - } - const [type, name] = parts.slice(2); - if (!name.match(/^(@[-\w\.~]+\/)?[-\w\.~]*$/i)) { - throw new Error( - `Invalid package name to add as dependency: '${name}'`, - ); + const [, directive, ...args] = imp.path.split(':'); + + if (directive === 'add-import') { + const [type, name] = args; + if (!name.match(/^(@[-\w\.~]+\/)?[-\w\.~]*$/i)) { + throw new Error( + `Invalid package name to add as dependency: '${name}'`, + ); + } + + importsToAdd.push({ + flag: getAddFlagForDepsField(type).trim(), + name, + node: imp.node, + }); + } + + if (directive === 'inline-imports') { + const [name] = args; + const pkg = packages.map.get(name); + if (!pkg) { + throw new Error(`Unexpectedly missing inline package: ${name}`); + } + + importsToInline.push({ + pkg: pkg, + node: imp.node, + }); + } + + return; + } + + // Importing an internal inlined package, whose imports are inlined too + if ( + imp.type === 'internal' && + imp.package.packageJson.backstage?.inline + ) { + for (const depType of Object.values(depFields)) { + if (localPkg.packageJson[depType]?.[imp.packageName]) { + context.report({ + node, + messageId: 'inlineDirect', + data: { + packageName: imp.packageName, + }, + fix: fixer => { + return fixer.replaceText( + imp.node, + `'directive:inline-imports:${imp.packageName}'`, + ); + }, + }); + return; + } + } + + const missingDeps = []; + const declaredProdDeps = new Set([ + ...Object.keys(localPkg.packageJson.dependencies ?? {}), + ...Object.keys(localPkg.packageJson.peerDependencies ?? {}), + localPkg.packageJson.name, // include self + ]); + for (const depType of /** @type {const} */ ([ + 'dependencies', + 'peerDependencies', + ])) { + for (const depName of Object.keys( + imp.package.packageJson[depType] ?? {}, + )) { + if (!declaredProdDeps.has(depName)) { + missingDeps.push(depName); + } + } + } + + if (missingDeps.length > 0) { + context.report({ + node, + messageId: 'inlineMissing', + data: { + packageName: imp.packageName, + missingDeps: missingDeps.join(', '), + }, + fix: fixer => { + return fixer.replaceText( + imp.node, + `'directive:inline-imports:${imp.packageName}'`, + ); + }, + }); } - importsToAdd.push({ - flag: getAddFlagForDepsField(type).trim(), - name, - node: imp.node, - }); return; } diff --git a/packages/eslint-plugin/src/__fixtures__/monorepo/packages/bar/package.json b/packages/eslint-plugin/src/__fixtures__/monorepo/packages/bar/package.json index efef27047b..ba55598dc2 100644 --- a/packages/eslint-plugin/src/__fixtures__/monorepo/packages/bar/package.json +++ b/packages/eslint-plugin/src/__fixtures__/monorepo/packages/bar/package.json @@ -1,14 +1,15 @@ { "name": "@internal/bar", + "backstage": { + "role": "frontend-plugin" + }, "exports": { ".": "./src/index.ts", "./BarPage": "./src/components/Bar.tsx", "./package.json": "./package.json" }, - "backstage": { - "role": "frontend-plugin" - }, "dependencies": { + "inline-dep": "*", "react-router": "*" }, "devDependencies": { diff --git a/packages/eslint-plugin/src/__fixtures__/monorepo/packages/inline-dep-invalid-direct/package.json b/packages/eslint-plugin/src/__fixtures__/monorepo/packages/inline-dep-invalid-direct/package.json new file mode 100644 index 0000000000..352a02d66c --- /dev/null +++ b/packages/eslint-plugin/src/__fixtures__/monorepo/packages/inline-dep-invalid-direct/package.json @@ -0,0 +1,12 @@ +{ + "name": "@internal/inline-dep-direct", + "files": [ + "dist", + "type-utils" + ], + "dependencies": { + "@internal/inline": "workspace:^", + "@internal/inline-dep-valid": "workspace:^", + "react": "*" + } +} diff --git a/packages/eslint-plugin/src/__fixtures__/monorepo/packages/inline-dep-invalid-missing/package.json b/packages/eslint-plugin/src/__fixtures__/monorepo/packages/inline-dep-invalid-missing/package.json new file mode 100644 index 0000000000..595ee4a86b --- /dev/null +++ b/packages/eslint-plugin/src/__fixtures__/monorepo/packages/inline-dep-invalid-missing/package.json @@ -0,0 +1,7 @@ +{ + "name": "@internal/inline-dep-missing", + "files": [ + "dist", + "type-utils" + ] +} diff --git a/packages/eslint-plugin/src/__fixtures__/monorepo/packages/inline-dep-valid/package.json b/packages/eslint-plugin/src/__fixtures__/monorepo/packages/inline-dep-valid/package.json new file mode 100644 index 0000000000..cbb42d0080 --- /dev/null +++ b/packages/eslint-plugin/src/__fixtures__/monorepo/packages/inline-dep-valid/package.json @@ -0,0 +1,10 @@ +{ + "name": "@internal/inline-dep-valid", + "files": [ + "dist", + "type-utils" + ], + "peerDependencies": { + "react": "*" + } +} diff --git a/packages/eslint-plugin/src/__fixtures__/monorepo/packages/inline/package.json b/packages/eslint-plugin/src/__fixtures__/monorepo/packages/inline/package.json new file mode 100644 index 0000000000..fb9345f140 --- /dev/null +++ b/packages/eslint-plugin/src/__fixtures__/monorepo/packages/inline/package.json @@ -0,0 +1,16 @@ +{ + "name": "@internal/inline", + "backstage": { + "inline": true + }, + "files": [ + "dist", + "type-utils" + ], + "dependencies": { + "@internal/inline-dep-valid": "workspace:^" + }, + "peerDependencies": { + "react": "*" + } +} diff --git a/packages/eslint-plugin/src/no-undeclared-imports.test.ts b/packages/eslint-plugin/src/no-undeclared-imports.test.ts index 29e2c3a87c..ee56c34926 100644 --- a/packages/eslint-plugin/src/no-undeclared-imports.test.ts +++ b/packages/eslint-plugin/src/no-undeclared-imports.test.ts @@ -52,6 +52,12 @@ const ERR_SWITCHED = ( const ERR_SWITCH_BACK = () => ({ message: 'Switch back to import declaration', }); +const ERR_INLINE_DIRECT = (name: string) => ({ + message: `The dependency on the inline package ${name} must not be declared in package dependencies.`, +}); +const ERR_INLINE_MISSING = (name: string, missing: string) => ({ + message: `Each production dependency from the inline package ${name} must be re-declared by this package, the following dependencies are missing: ${missing}`, +}); // cwd must be restored const origDir = process.cwd(); @@ -102,6 +108,17 @@ ruleTester.run(RULE, rule, { code: `require('lod' + 'ash')`, filename: joinPath(FIXTURE, 'packages/bar/src/index.ts'), }, + { + code: `import '@internal/inline'`, + filename: joinPath(FIXTURE, 'packages/inline-dep-valid/src/index.ts'), + }, + { + code: `import '@internal/inline'`, + filename: joinPath( + FIXTURE, + 'packages/inline-dep-valid/src/index.test.ts', + ), + }, ], invalid: [ { @@ -264,6 +281,29 @@ ruleTester.run(RULE, rule, { ), ], }, + { + code: `import '@internal/inline'`, + output: `import 'directive:inline-imports:@internal/inline'`, + filename: joinPath( + FIXTURE, + 'packages/inline-dep-invalid-direct/src/index.ts', + ), + errors: [ERR_INLINE_DIRECT('@internal/inline')], + }, + { + code: `import '@internal/inline'`, + output: `import 'directive:inline-imports:@internal/inline'`, + filename: joinPath( + FIXTURE, + 'packages/inline-dep-invalid-missing/src/index.ts', + ), + errors: [ + ERR_INLINE_MISSING( + '@internal/inline', + '@internal/inline-dep-valid, react', + ), + ], + }, // Switching back to original import declarations { @@ -302,5 +342,14 @@ ruleTester.run(RULE, rule, { filename: joinPath(FIXTURE, 'packages/bar/src/index.ts'), errors: [ERR_SWITCH_BACK()], }, + { + code: `import 'directive:inline-imports:@internal/inline'`, + output: `import '@internal/inline'`, + filename: joinPath( + FIXTURE, + 'packages/inline-dep-invalid-direct/src/index.ts', + ), + errors: [ERR_SWITCH_BACK()], + }, ], }); diff --git a/packages/frontend-internal/.eslintrc.js b/packages/frontend-internal/.eslintrc.js new file mode 100644 index 0000000000..e487f765b2 --- /dev/null +++ b/packages/frontend-internal/.eslintrc.js @@ -0,0 +1,5 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname, { + rules: { + '@backstage/no-top-level-material-ui-4-imports': 'error', + }, +}); diff --git a/packages/frontend-internal/README.md b/packages/frontend-internal/README.md new file mode 100644 index 0000000000..72e54f21cf --- /dev/null +++ b/packages/frontend-internal/README.md @@ -0,0 +1,3 @@ +# @internal/frontend + +This is an internal package used by the other frontend 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/frontend-internal/catalog-info.yaml b/packages/frontend-internal/catalog-info.yaml new file mode 100644 index 0000000000..630f53275a --- /dev/null +++ b/packages/frontend-internal/catalog-info.yaml @@ -0,0 +1,9 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: internal-frontend + title: '@internal/frontend' +spec: + lifecycle: experimental + type: backstage-web-library + owner: maintainers diff --git a/packages/frontend-internal/package.json b/packages/frontend-internal/package.json new file mode 100644 index 0000000000..1e637b9d78 --- /dev/null +++ b/packages/frontend-internal/package.json @@ -0,0 +1,39 @@ +{ + "name": "@internal/frontend", + "version": "0.0.0", + "backstage": { + "role": "web-library", + "inline": true + }, + "private": true, + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "packages/frontend-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" + }, + "dependencies": { + "@backstage/frontend-plugin-api": "workspace:^", + "@backstage/types": "workspace:^", + "@backstage/version-bridge": "workspace:^", + "zod": "^3.22.4" + }, + "devDependencies": { + "@backstage/cli": "workspace:^", + "@backstage/frontend-app-api": "workspace:^", + "@backstage/frontend-test-utils": "workspace:^", + "@backstage/test-utils": "workspace:^", + "@testing-library/jest-dom": "^6.0.0", + "@testing-library/react": "^15.0.0" + } +} diff --git a/packages/frontend-internal/src/index.ts b/packages/frontend-internal/src/index.ts new file mode 100644 index 0000000000..a5728f2ff6 --- /dev/null +++ b/packages/frontend-internal/src/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export * from './wiring'; diff --git a/packages/frontend-internal/src/wiring/InternalExtensionDefinition.ts b/packages/frontend-internal/src/wiring/InternalExtensionDefinition.ts new file mode 100644 index 0000000000..840e45ff4a --- /dev/null +++ b/packages/frontend-internal/src/wiring/InternalExtensionDefinition.ts @@ -0,0 +1,104 @@ +/* + * 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 { + AnyExtensionDataRef, + ApiHolder, + AppNode, + ExtensionDataValue, + ExtensionDefinition, + ExtensionDefinitionParameters, + ExtensionInput, + PortableSchema, + ResolvedExtensionInputs, +} from '@backstage/frontend-plugin-api'; + +export type InternalExtensionDefinition< + T extends ExtensionDefinitionParameters = ExtensionDefinitionParameters, +> = ExtensionDefinition & { + readonly kind?: string; + readonly namespace?: string; + readonly name?: string; + readonly attachTo: { id: string; input: string }; + readonly disabled: boolean; + readonly configSchema?: PortableSchema; +} & ( + | { + readonly version: 'v1'; + readonly inputs: { + [inputName in string]: { + $$type: '@backstage/ExtensionInput'; + extensionData: { + [name in string]: AnyExtensionDataRef; + }; + config: { optional: boolean; singleton: boolean }; + }; + }; + readonly output: { + [name in string]: AnyExtensionDataRef; + }; + factory(context: { + node: AppNode; + apis: ApiHolder; + config: object; + inputs: { + [inputName in string]: unknown; + }; + }): { + [inputName in string]: unknown; + }; + } + | { + readonly version: 'v2'; + readonly inputs: { + [inputName in string]: ExtensionInput< + AnyExtensionDataRef, + { optional: boolean; singleton: boolean } + >; + }; + readonly output: Array; + factory(context: { + node: AppNode; + apis: ApiHolder; + config: object; + inputs: ResolvedExtensionInputs<{ + [inputName in string]: ExtensionInput< + AnyExtensionDataRef, + { optional: boolean; singleton: boolean } + >; + }>; + }): Iterable>; + } + ); + +/** @internal */ +export function toInternalExtensionDefinition< + T extends ExtensionDefinitionParameters, +>(overrides: ExtensionDefinition): InternalExtensionDefinition { + const internal = overrides as InternalExtensionDefinition; + if (internal.$$type !== '@backstage/ExtensionDefinition') { + throw new Error( + `Invalid extension definition instance, bad type '${internal.$$type}'`, + ); + } + const version = internal.version; + if (version !== 'v1' && version !== 'v2') { + throw new Error( + `Invalid extension definition instance, bad version '${version}'`, + ); + } + return internal; +} diff --git a/packages/frontend-internal/src/wiring/index.ts b/packages/frontend-internal/src/wiring/index.ts new file mode 100644 index 0000000000..d0aefbed64 --- /dev/null +++ b/packages/frontend-internal/src/wiring/index.ts @@ -0,0 +1,20 @@ +/* + * 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. + */ + +export { + toInternalExtensionDefinition, + type InternalExtensionDefinition, +} from './InternalExtensionDefinition'; diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.ts b/packages/frontend-plugin-api/src/wiring/createExtension.ts index 25615098ef..7aef3fc1b5 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtension.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtension.ts @@ -15,7 +15,6 @@ */ import { ApiHolder, AppNode } from '../apis'; -import { PortableSchema } from '../schema'; import { Expand } from '../types'; import { ResolveInputValueOverrides, @@ -32,6 +31,7 @@ import { import { ExtensionInput } from './createExtensionInput'; import { z } from 'zod'; import { createSchemaFromZod } from '../schema/createSchemaFromZod'; +import { InternalExtensionDefinition } from '@internal/frontend'; /** * Convert a single extension input into a matching resolved input. @@ -235,84 +235,6 @@ export type ExtensionDefinition< }>; }; -/** @internal */ -export type InternalExtensionDefinition< - T extends ExtensionDefinitionParameters = ExtensionDefinitionParameters, -> = ExtensionDefinition & { - readonly kind?: string; - readonly namespace?: string; - readonly name?: string; - readonly attachTo: { id: string; input: string }; - readonly disabled: boolean; - readonly configSchema?: PortableSchema; -} & ( - | { - readonly version: 'v1'; - readonly inputs: { - [inputName in string]: { - $$type: '@backstage/ExtensionInput'; - extensionData: { - [name in string]: AnyExtensionDataRef; - }; - config: { optional: boolean; singleton: boolean }; - }; - }; - readonly output: { - [name in string]: AnyExtensionDataRef; - }; - factory(context: { - node: AppNode; - apis: ApiHolder; - config: object; - inputs: { - [inputName in string]: unknown; - }; - }): { - [inputName in string]: unknown; - }; - } - | { - readonly version: 'v2'; - readonly inputs: { - [inputName in string]: ExtensionInput< - AnyExtensionDataRef, - { optional: boolean; singleton: boolean } - >; - }; - readonly output: Array; - factory(context: { - node: AppNode; - apis: ApiHolder; - config: object; - inputs: ResolvedExtensionInputs<{ - [inputName in string]: ExtensionInput< - AnyExtensionDataRef, - { optional: boolean; singleton: boolean } - >; - }>; - }): Iterable>; - } - ); - -/** @internal */ -export function toInternalExtensionDefinition< - T extends ExtensionDefinitionParameters, ->(overrides: ExtensionDefinition): InternalExtensionDefinition { - const internal = overrides as InternalExtensionDefinition; - if (internal.$$type !== '@backstage/ExtensionDefinition') { - throw new Error( - `Invalid extension definition instance, bad type '${internal.$$type}'`, - ); - } - const version = internal.version; - if (version !== 'v1' && version !== 'v2') { - throw new Error( - `Invalid extension definition instance, bad version '${version}'`, - ); - } - return internal; -} - /** @public */ export function createExtension< UOutput extends AnyExtensionDataRef, diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx index fc77c63e97..47ef7a75b4 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx +++ b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx @@ -27,11 +27,9 @@ import { } from './createExtensionDataRef'; import { createExtensionInput } from './createExtensionInput'; import { RouteRef } from '../routing'; -import { - ExtensionDefinition, - toInternalExtensionDefinition, -} from './createExtension'; +import { ExtensionDefinition } from './createExtension'; import { createExtensionDataContainer } from './createExtensionDataContainer'; +import { toInternalExtensionDefinition } from '@internal/frontend'; function unused(..._any: any[]) {} diff --git a/packages/frontend-plugin-api/src/wiring/createFrontendModule.ts b/packages/frontend-plugin-api/src/wiring/createFrontendModule.ts index aacc34ccf0..e3fa8b6682 100644 --- a/packages/frontend-plugin-api/src/wiring/createFrontendModule.ts +++ b/packages/frontend-plugin-api/src/wiring/createFrontendModule.ts @@ -15,10 +15,10 @@ */ import { - ExtensionDefinition, InternalExtensionDefinition, toInternalExtensionDefinition, -} from './createExtension'; +} from '@internal/frontend'; +import { ExtensionDefinition } from './createExtension'; import { Extension, resolveExtensionDefinition, diff --git a/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts b/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts index 03c843543d..adcd2b0867 100644 --- a/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts +++ b/packages/frontend-plugin-api/src/wiring/createFrontendPlugin.ts @@ -15,10 +15,10 @@ */ import { - ExtensionDefinition, InternalExtensionDefinition, toInternalExtensionDefinition, -} from './createExtension'; +} from '@internal/frontend'; +import { ExtensionDefinition } from './createExtension'; import { Extension, ResolveExtensionId, diff --git a/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts index 5a631819d7..a377cde2b9 100644 --- a/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts +++ b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts @@ -19,7 +19,6 @@ import { ExtensionDefinition, ExtensionDefinitionParameters, ResolvedExtensionInputs, - toInternalExtensionDefinition, } from './createExtension'; import { PortableSchema } from '../schema'; import { ExtensionInput } from './createExtensionInput'; @@ -27,6 +26,7 @@ import { AnyExtensionDataRef, ExtensionDataValue, } from './createExtensionDataRef'; +import { toInternalExtensionDefinition } from '@internal/frontend'; /** @public */ export interface Extension { diff --git a/packages/frontend-test-utils/package.json b/packages/frontend-test-utils/package.json index 988ce42822..ecdf3ace4d 100644 --- a/packages/frontend-test-utils/package.json +++ b/packages/frontend-test-utils/package.json @@ -36,7 +36,9 @@ "@backstage/frontend-plugin-api": "workspace:^", "@backstage/plugin-app": "workspace:^", "@backstage/test-utils": "workspace:^", - "@backstage/types": "workspace:^" + "@backstage/types": "workspace:^", + "@backstage/version-bridge": "workspace:^", + "zod": "^3.22.4" }, "devDependencies": { "@backstage/cli": "workspace:^", diff --git a/packages/frontend-test-utils/src/app/createExtensionTester.tsx b/packages/frontend-test-utils/src/app/createExtensionTester.tsx index 232b41b435..5eda2d6e11 100644 --- a/packages/frontend-test-utils/src/app/createExtensionTester.tsx +++ b/packages/frontend-test-utils/src/app/createExtensionTester.tsx @@ -29,8 +29,6 @@ import { JsonArray, JsonObject, JsonValue } from '@backstage/types'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { resolveExtensionDefinition } from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports -import { toInternalExtensionDefinition } from '../../../frontend-plugin-api/src/wiring/createExtension'; -// eslint-disable-next-line @backstage/no-relative-monorepo-imports import { resolveAppTree } from '../../../frontend-app-api/src/tree/resolveAppTree'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { resolveAppNodeSpecs } from '../../../frontend-app-api/src/tree/resolveAppNodeSpecs'; @@ -39,6 +37,7 @@ import { instantiateAppNodeTree } from '../../../frontend-app-api/src/tree/insta // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { readAppExtensionsConfig } from '../../../frontend-app-api/src/tree/readAppExtensionsConfig'; import { TestApiRegistry } from '@backstage/test-utils'; +import { toInternalExtensionDefinition } from '@internal/frontend'; /** @public */ export class ExtensionQuery { diff --git a/packages/repo-tools/src/commands/api-reports/api-extractor.ts b/packages/repo-tools/src/commands/api-reports/api-extractor.ts index 122f04cc7e..371f122713 100644 --- a/packages/repo-tools/src/commands/api-reports/api-extractor.ts +++ b/packages/repo-tools/src/commands/api-reports/api-extractor.ts @@ -1232,6 +1232,12 @@ export async function categorizePackageDirs(packageDirs: string[]) { if (!role) { return; // Ignore packages without roles } + // TODO(Rugvip): Inlined packages are ignored because we can't handle @internal exports + // gracefully, and we don't want to have to mark all exports @public etc. + // It would be good if we could include these packages though. + if (pkgJson?.backstage?.inline) { + return; + } if (role === 'cli') { cliPackageDirs.push(dir); } else if (role !== 'frontend' && role !== 'backend') { diff --git a/yarn.lock b/yarn.lock index 951dc90935..55269d2433 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4558,8 +4558,10 @@ __metadata: "@backstage/plugin-app": "workspace:^" "@backstage/test-utils": "workspace:^" "@backstage/types": "workspace:^" + "@backstage/version-bridge": "workspace:^" "@testing-library/jest-dom": ^6.0.0 "@types/react": "*" + zod: ^3.22.4 peerDependencies: "@testing-library/react": ^15.0.0 react: ^16.13.1 || ^17.0.0 || ^18.0.0 @@ -9923,6 +9925,23 @@ __metadata: languageName: node linkType: hard +"@internal/frontend@workspace:packages/frontend-internal": + version: 0.0.0-use.local + resolution: "@internal/frontend@workspace:packages/frontend-internal" + dependencies: + "@backstage/cli": "workspace:^" + "@backstage/frontend-app-api": "workspace:^" + "@backstage/frontend-plugin-api": "workspace:^" + "@backstage/frontend-test-utils": "workspace:^" + "@backstage/test-utils": "workspace:^" + "@backstage/types": "workspace:^" + "@backstage/version-bridge": "workspace:^" + "@testing-library/jest-dom": ^6.0.0 + "@testing-library/react": ^15.0.0 + zod: ^3.22.4 + languageName: unknown + linkType: soft + "@internal/plugin-todo-list-backend@workspace:plugins/example-todo-list-backend": version: 0.0.0-use.local resolution: "@internal/plugin-todo-list-backend@workspace:plugins/example-todo-list-backend"