Merge pull request #26469 from backstage/rugvip/inline

add support for inline packages
This commit is contained in:
Patrik Oldsberg
2024-09-10 11:51:44 +03:00
committed by GitHub
39 changed files with 677 additions and 168 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli-node': patch
---
Add definition for the new `backstage.inline` field in `package.json`.
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/frontend-plugin-api': patch
'@backstage/frontend-test-utils': patch
---
Internal refactor
+5
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/eslint-plugin': patch
---
Added support for linting dependencies on workspace packages with the `backstage.inline` flag.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/repo-tools': patch
---
Avoid generating API reports for packages with `backstage.inline` set.
+20
View File
@@ -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.
+1
View File
@@ -18,6 +18,7 @@ export interface BackstagePackageJson {
backstage?: {
role?: PackageRole;
moved?: string;
inline?: boolean;
pluginId?: string | null;
pluginPackage?: string;
pluginPackages?: string[];
@@ -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.
*/
@@ -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'));
+2 -1
View File
@@ -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<void> {
return buildPackage({
outputs,
minify: Boolean(opts.minify),
workspacePackages: await PackageGraph.listTargetPackages(),
});
}
+1
View File
@@ -137,6 +137,7 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
outputs,
logPrefix: `${chalk.cyan(relativePath(paths.targetRoot, pkg.dir))}: `,
minify: buildOptions.minify,
workspacePackages: packages,
};
});
@@ -29,6 +29,7 @@ describe('makeRollupConfigs', () => {
version: '0.0.0',
main: './src/index.ts',
},
workspacePackages: [],
});
const external = config.external as Exclude<
ExternalOption,
+33 -13
View File
@@ -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<RollupOptions[]> {
@@ -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<OutputOptions>();
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 })],
});
}
+2 -1
View File
@@ -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<Output>;
minify?: boolean;
workspacePackages: BackstagePackage[];
};
@@ -211,6 +211,7 @@ export async function createDistWorkspace(
outputs: outputs,
logPrefix: `${chalk.cyan(relativePath(paths.targetRoot, pkg.dir))}: `,
minify: options.minify,
workspacePackages: packages,
});
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ const manypkg = require('@manypkg/get-packages');
/**
* @typedef ExtendedPackage
* @type {import('@manypkg/get-packages').Package & { packageJson: { exports?: Record<string, string>, files?: Array<string> }}} packageJson
* @type {import('@manypkg/get-packages').Package & { packageJson: { exports?: Record<string, string>, files?: Array<string>, backstage?: { inline?: boolean } }}} packageJson
*/
/**
@@ -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<string, Set<string>> */
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<string> */
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<string, Map<string, string>> */
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<string, Set<string>> */
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;
}
@@ -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": {
@@ -0,0 +1,12 @@
{
"name": "@internal/inline-dep-direct",
"files": [
"dist",
"type-utils"
],
"dependencies": {
"@internal/inline": "workspace:^",
"@internal/inline-dep-valid": "workspace:^",
"react": "*"
}
}
@@ -0,0 +1,7 @@
{
"name": "@internal/inline-dep-missing",
"files": [
"dist",
"type-utils"
]
}
@@ -0,0 +1,10 @@
{
"name": "@internal/inline-dep-valid",
"files": [
"dist",
"type-utils"
],
"peerDependencies": {
"react": "*"
}
}
@@ -0,0 +1,16 @@
{
"name": "@internal/inline",
"backstage": {
"inline": true
},
"files": [
"dist",
"type-utils"
],
"dependencies": {
"@internal/inline-dep-valid": "workspace:^"
},
"peerDependencies": {
"react": "*"
}
}
@@ -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()],
},
],
});
+5
View File
@@ -0,0 +1,5 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname, {
rules: {
'@backstage/no-top-level-material-ui-4-imports': 'error',
},
});
+3
View File
@@ -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`.
@@ -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
+39
View File
@@ -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"
}
}
+17
View File
@@ -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';
@@ -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<T> & {
readonly kind?: string;
readonly namespace?: string;
readonly name?: string;
readonly attachTo: { id: string; input: string };
readonly disabled: boolean;
readonly configSchema?: PortableSchema<T['config'], T['configInput']>;
} & (
| {
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<AnyExtensionDataRef>;
factory(context: {
node: AppNode;
apis: ApiHolder;
config: object;
inputs: ResolvedExtensionInputs<{
[inputName in string]: ExtensionInput<
AnyExtensionDataRef,
{ optional: boolean; singleton: boolean }
>;
}>;
}): Iterable<ExtensionDataValue<any, any>>;
}
);
/** @internal */
export function toInternalExtensionDefinition<
T extends ExtensionDefinitionParameters,
>(overrides: ExtensionDefinition<T>): InternalExtensionDefinition<T> {
const internal = overrides as InternalExtensionDefinition<T>;
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;
}
@@ -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';
@@ -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<T> & {
readonly kind?: string;
readonly namespace?: string;
readonly name?: string;
readonly attachTo: { id: string; input: string };
readonly disabled: boolean;
readonly configSchema?: PortableSchema<T['config'], T['configInput']>;
} & (
| {
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<AnyExtensionDataRef>;
factory(context: {
node: AppNode;
apis: ApiHolder;
config: object;
inputs: ResolvedExtensionInputs<{
[inputName in string]: ExtensionInput<
AnyExtensionDataRef,
{ optional: boolean; singleton: boolean }
>;
}>;
}): Iterable<ExtensionDataValue<any, any>>;
}
);
/** @internal */
export function toInternalExtensionDefinition<
T extends ExtensionDefinitionParameters,
>(overrides: ExtensionDefinition<T>): InternalExtensionDefinition<T> {
const internal = overrides as InternalExtensionDefinition<T>;
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,
@@ -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[]) {}
@@ -15,10 +15,10 @@
*/
import {
ExtensionDefinition,
InternalExtensionDefinition,
toInternalExtensionDefinition,
} from './createExtension';
} from '@internal/frontend';
import { ExtensionDefinition } from './createExtension';
import {
Extension,
resolveExtensionDefinition,
@@ -15,10 +15,10 @@
*/
import {
ExtensionDefinition,
InternalExtensionDefinition,
toInternalExtensionDefinition,
} from './createExtension';
} from '@internal/frontend';
import { ExtensionDefinition } from './createExtension';
import {
Extension,
ResolveExtensionId,
@@ -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<TConfig, TConfigInput = TConfig> {
+3 -1
View File
@@ -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:^",
@@ -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<UOutput extends AnyExtensionDataRef> {
@@ -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') {
+19
View File
@@ -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"