Split CLI modules into separate packages

Extract each CLI module from packages/cli/src/modules/ into its own
package under packages/cli-module-*. This enables independent versioning
and clearer dependency boundaries for each CLI capability.

Module mapping:
- auth → @backstage/cli-module-auth
- build → @backstage/cli-module-build
- config → @backstage/cli-module-config
- create-github-app → @backstage/cli-module-create-github-app
- info → @backstage/cli-module-info
- lint → @backstage/cli-module-lint
- maintenance → @backstage/cli-module-maintenance
- migrate → @backstage/cli-module-migrate
- new → @backstage/cli-module-new
- test → @backstage/cli-module-test-jest
- translations → @backstage/cli-module-translations

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
Made-with: Cursor
This commit is contained in:
Patrik Oldsberg
2026-03-13 13:43:02 +01:00
parent 18012b5802
commit a151ad0814
251 changed files with 1327 additions and 339 deletions
@@ -0,0 +1,66 @@
/*
* Copyright 2020 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 fs from 'fs-extra';
import { cli } from 'cleye';
import { createDistWorkspace } from '../lib/packager';
import type { CliCommandContext } from '@backstage/cli-node';
export default async ({ args, info }: CliCommandContext) => {
// Normalize legacy --alwaysYarnPack alias (a genuinely different name, not
// just a casing variant — type-flag handles camelCase/kebab-case natively)
const normalizedArgs = args.map(a => {
if (a === '--alwaysYarnPack') {
return '--always-pack';
}
if (a.startsWith('--alwaysYarnPack=')) {
return `--always-pack${a.substring('--alwaysYarnPack'.length)}`;
}
return a;
});
const {
flags: { alwaysPack },
_: positionals,
} = cli(
{
help: { ...info, usage: `${info.usage} <workspace-dir> [packages...]` },
booleanFlagNegation: true,
parameters: ['<workspace-dir>', '[packages...]'],
flags: {
alwaysPack: {
type: Boolean,
description:
'Force workspace output to be a result of running `yarn pack` on each package (warning: very slow)',
},
},
},
undefined,
normalizedArgs,
);
const [dir, ...packages] = positionals;
if (!(await fs.pathExists(dir))) {
throw new Error(`Target workspace directory doesn't exist, '${dir}'`);
}
await createDistWorkspace(packages, {
targetDir: dir,
alwaysPack,
enableFeatureDetection: true,
});
};
@@ -0,0 +1,163 @@
/*
* Copyright 2020 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 { cli } from 'cleye';
import fs from 'fs-extra';
import { buildPackage, Output } from '../../../lib/builder';
import { findRoleFromCommand } from '../../../lib/role';
import {
BackstagePackageJson,
PackageGraph,
PackageRoles,
} from '@backstage/cli-node';
import { targetPaths } from '@backstage/cli-common';
import { buildFrontend } from '../../../lib/buildFrontend';
import { buildBackend } from '../../../lib/buildBackend';
import { isValidUrl } from '../../../lib/urls';
import chalk from 'chalk';
import type { CliCommandContext } from '@backstage/cli-node';
export default async ({ args, info }: CliCommandContext) => {
const {
flags: {
role,
minify,
skipBuildDependencies,
stats,
config,
moduleFederation,
},
} = cli(
{
help: info,
booleanFlagNegation: true,
flags: {
role: {
type: String,
description: 'Run the command with an explicit package role',
},
minify: {
type: Boolean,
description:
'Minify the generated code. Does not apply to app package (app is minified by default).',
},
skipBuildDependencies: {
type: Boolean,
description:
'Skip the automatic building of local dependencies. Applies to backend packages only.',
},
stats: {
type: Boolean,
description:
'If bundle stats are available, write them to the output directory. Applies to app packages only.',
},
config: {
type: [String],
description:
'Config files to load instead of app-config.yaml. Applies to app packages only.',
default: [],
},
moduleFederation: {
type: Boolean,
description:
'Build a package as a module federation remote. Applies to frontend plugin packages only.',
},
},
},
undefined,
args,
);
const webpack = process.env.LEGACY_WEBPACK_BUILD
? (require('webpack') as typeof import('webpack'))
: undefined;
const resolvedRole = await findRoleFromCommand({ role });
if (resolvedRole === 'frontend' || resolvedRole === 'backend') {
const configPaths = config.map(arg => {
if (isValidUrl(arg)) {
return arg;
}
return targetPaths.resolve(arg);
});
if (resolvedRole === 'frontend') {
return buildFrontend({
targetDir: targetPaths.dir,
configPaths,
writeStats: Boolean(stats),
webpack,
});
}
return buildBackend({
targetDir: targetPaths.dir,
configPaths,
skipBuildDependencies: Boolean(skipBuildDependencies),
minify: Boolean(minify),
});
}
let isModuleFederationRemote: boolean | undefined = undefined;
if ((resolvedRole as string) === 'frontend-dynamic-container') {
console.log(
chalk.yellow(
`⚠️ WARNING: The 'frontend-dynamic-container' package role is experimental and will receive immediate breaking changes in the future.`,
),
);
isModuleFederationRemote = true;
}
if (moduleFederation) {
isModuleFederationRemote = true;
}
if (isModuleFederationRemote) {
console.log('Building package as a module federation remote');
return buildFrontend({
targetDir: targetPaths.dir,
configPaths: [],
writeStats: Boolean(stats),
isModuleFederationRemote,
webpack,
});
}
const roleInfo = PackageRoles.getRoleInfo(resolvedRole);
const outputs = new Set<Output>();
if (roleInfo.output.includes('cjs')) {
outputs.add(Output.cjs);
}
if (roleInfo.output.includes('esm')) {
outputs.add(Output.esm);
}
if (roleInfo.output.includes('types')) {
outputs.add(Output.types);
}
const packageJson = (await fs.readJson(
targetPaths.resolve('package.json'),
)) as BackstagePackageJson;
return buildPackage({
outputs,
packageJson,
minify: Boolean(minify),
workspacePackages: await PackageGraph.listTargetPackages(),
});
};
@@ -0,0 +1,17 @@
/*
* Copyright 2022 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 { default } from './command';
@@ -0,0 +1,27 @@
/*
* Copyright 2020 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 { cli } from 'cleye';
import fs from 'fs-extra';
import { targetPaths } from '@backstage/cli-common';
import type { CliCommandContext } from '@backstage/cli-node';
export default async ({ args, info }: CliCommandContext) => {
cli({ help: info, booleanFlagNegation: true }, undefined, args);
await fs.remove(targetPaths.resolve('dist'));
await fs.remove(targetPaths.resolve('dist-types'));
await fs.remove(targetPaths.resolve('coverage'));
};
@@ -0,0 +1,25 @@
/*
* Copyright 2020 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 { cli } from 'cleye';
import { targetPaths } from '@backstage/cli-common';
import { revertProductionPack } from '../../lib/packager/productionPack';
import type { CliCommandContext } from '@backstage/cli-node';
export default async ({ args, info }: CliCommandContext) => {
cli({ help: info, booleanFlagNegation: true }, undefined, args);
await revertProductionPack(targetPaths.dir);
};
@@ -0,0 +1,37 @@
/*
* Copyright 2020 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 { cli } from 'cleye';
import fs from 'fs-extra';
import { targetPaths } from '@backstage/cli-common';
import { productionPack } from '../../lib/packager/productionPack';
import { publishPreflightCheck } from '../../lib/publishing';
import { createTypeDistProject } from '../../lib/typeDistProject';
import type { CliCommandContext } from '@backstage/cli-node';
export default async ({ args, info }: CliCommandContext) => {
cli({ help: info, booleanFlagNegation: true }, undefined, args);
publishPreflightCheck({
dir: targetPaths.dir,
packageJson: await fs.readJson(targetPaths.resolve('package.json')),
});
await productionPack({
packageDir: targetPaths.dir,
featureDetectionProject: await createTypeDistProject(),
});
};
@@ -0,0 +1,94 @@
/*
* Copyright 2020 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 { cli } from 'cleye';
import { startPackage } from './startPackage';
import { resolveLinkedWorkspace } from './resolveLinkedWorkspace';
import { findRoleFromCommand } from '../../../lib/role';
import { targetPaths } from '@backstage/cli-common';
import type { CliCommandContext } from '@backstage/cli-node';
export default async ({ args, info }: CliCommandContext) => {
const {
flags: {
config,
role,
check,
require: requirePath,
link,
entrypoint,
inspect,
inspectBrk,
},
} = cli(
{
help: info,
booleanFlagNegation: true,
flags: {
config: {
type: [String],
description: 'Config files to load instead of app-config.yaml',
default: [],
},
role: {
type: String,
description: 'Run the command with an explicit package role',
},
check: {
type: Boolean,
description: 'Enable type checking and linting if available',
},
require: {
type: String,
description: 'Add a --require argument to the node process',
},
link: {
type: String,
description: 'Link an external workspace for module resolution',
},
entrypoint: {
type: String,
description:
'The entrypoint to start from, relative to the package root. Can point to either a file (without extension) or a directory (in which case the index file in that directory is used). Defaults to "dev"',
},
inspect: {
type: String,
description:
'Enable the Node.js inspector, optionally at a specific host:port',
},
inspectBrk: {
type: String,
description:
'Enable the Node.js inspector and break before user code starts',
},
},
},
undefined,
args,
);
await startPackage({
role: await findRoleFromCommand({ role }),
entrypoint,
targetDir: targetPaths.dir,
configPaths: config,
checksEnabled: Boolean(check),
linkedWorkspace: await resolveLinkedWorkspace(link),
inspectEnabled: inspect || (inspect === '' ? true : undefined),
inspectBrkEnabled: inspectBrk || (inspectBrk === '' ? true : undefined),
require: requirePath,
});
};
@@ -0,0 +1,17 @@
/*
* Copyright 2022 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 { default } from './command';
@@ -0,0 +1,47 @@
/*
* Copyright 2025 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 { ForwardedError } from '@backstage/errors';
import fs from 'fs-extra';
import { resolve as resolvePath } from 'node:path/posix';
export async function resolveLinkedWorkspace(
linkPath: string | undefined,
): Promise<string | undefined> {
if (!linkPath) {
return undefined;
}
const dir = resolvePath(linkPath);
if (!fs.pathExistsSync(dir)) {
throw new Error(`Invalid workspace link, directory does not exist: ${dir}`);
}
const pkgJson = await fs
.readJson(resolvePath(dir, 'package.json'))
.catch(error => {
throw new ForwardedError(
'Failed to read package.json in linked workspace',
error,
);
});
if (!pkgJson.workspaces) {
throw new Error(
`Invalid workspace link, directory is not a workspace: ${dir}`,
);
}
return dir;
}
@@ -0,0 +1,66 @@
/*
* Copyright 2020 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 fs from 'fs-extra';
import { resolve as resolvePath } from 'node:path';
import { targetPaths } from '@backstage/cli-common';
import { runBackend } from '../../../lib/runner';
interface StartBackendOptions {
targetDir: string;
checksEnabled: boolean;
inspectEnabled?: boolean | string;
inspectBrkEnabled?: boolean | string;
linkedWorkspace?: string;
require?: string;
}
export async function startBackend(options: StartBackendOptions) {
const waitForExit = await runBackend({
targetDir: options.targetDir,
entry: 'src/index',
inspectEnabled: options.inspectEnabled,
inspectBrkEnabled: options.inspectBrkEnabled,
linkedWorkspace: options.linkedWorkspace,
require: options.require,
});
await waitForExit();
}
export async function startBackendPlugin(options: StartBackendOptions) {
const hasDevIndexEntry = await fs.pathExists(
resolvePath(options.targetDir ?? targetPaths.dir, 'dev/index.ts'),
);
if (!hasDevIndexEntry) {
console.warn(
`The 'dev' directory is missing. Please create a proper dev/index.ts in order to start the plugin.`,
);
return;
}
const waitForExit = await runBackend({
targetDir: options.targetDir,
entry: 'dev/index',
inspectEnabled: options.inspectEnabled,
inspectBrkEnabled: options.inspectBrkEnabled,
require: options.require,
linkedWorkspace: options.linkedWorkspace,
});
await waitForExit();
}
@@ -0,0 +1,68 @@
/*
* Copyright 2020 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 { readJson } from 'fs-extra';
import { resolve as resolvePath } from 'node:path';
import {
getModuleFederationRemoteOptions,
serveBundle,
} from '../../../lib/bundler';
import { targetPaths } from '@backstage/cli-common';
import { BackstagePackageJson } from '@backstage/cli-node';
import { hasReactDomClient } from '../../../lib/bundler/hasReactDomClient';
interface StartAppOptions {
verifyVersions?: boolean;
entry: string;
targetDir?: string;
checksEnabled: boolean;
configPaths: string[];
skipOpenBrowser?: boolean;
isModuleFederationRemote?: boolean;
linkedWorkspace?: string;
}
export async function startFrontend(options: StartAppOptions) {
const packageJson = (await readJson(
resolvePath(options.targetDir ?? targetPaths.dir, 'package.json'),
)) as BackstagePackageJson;
if (!hasReactDomClient()) {
console.warn(
'React 17 is now deprecated! Please follow the Backstage migration guide to update to React 18: https://backstage.io/docs/tutorials/react18-migration/',
);
}
const waitForExit = await serveBundle({
entry: options.entry,
targetDir: options.targetDir,
checksEnabled: options.checksEnabled,
configPaths: options.configPaths,
verifyVersions: options.verifyVersions,
skipOpenBrowser: options.skipOpenBrowser,
linkedWorkspace: options.linkedWorkspace,
moduleFederationRemote: options.isModuleFederationRemote
? await getModuleFederationRemoteOptions(
packageJson,
resolvePath(targetPaths.dir),
)
: undefined,
});
await waitForExit();
}
@@ -0,0 +1,89 @@
/*
* Copyright 2020 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 { createMockDirectory } from '@backstage/backend-test-utils';
import { resolveEntryPath } from './startPackage';
describe('resolveEntryPath', () => {
const mockDir = createMockDirectory();
afterEach(() => {
mockDir.clear();
});
it('should remove file extensions', () => {
mockDir.setContent({
'dev/custom.tsx': '// dev app code',
});
const result = resolveEntryPath('dev/custom.tsx', mockDir.path);
expect(result).toBe('dev/custom');
});
it('should remove trailing slashes', () => {
mockDir.setContent({
'dev/alpha.ts': '// dev app code',
});
const result = resolveEntryPath('dev/alpha/', mockDir.path);
expect(result).toBe('dev/alpha');
});
it('should handle multiple dots in filename', () => {
mockDir.setContent({
'index.alpha.ts': 'export const data = {};',
});
const result = resolveEntryPath('index.alpha.ts', mockDir.path);
expect(result).toBe('index.alpha');
});
it('should handle simple directory names', () => {
mockDir.setContent({
'dev/index.ts': '// dev app code',
});
const result = resolveEntryPath('dev', mockDir.path);
expect(result).toBe('dev/index');
});
it('should handle nested directory paths', () => {
mockDir.setContent({
'dev/alpha/index.ts': '// dev app code',
});
const result = resolveEntryPath('dev/alpha', mockDir.path);
expect(result).toBe('dev/alpha/index');
});
it('should return the file when there is a directory with the same name', () => {
mockDir.setContent({
'dev/alpha.ts': '// dev app code',
'dev/app-config.yaml': '// dev app config',
'dev/alpha/index.ts': '// dev app code',
'dev/alpha/app-config.yaml': '// dev app config',
});
const result = resolveEntryPath('dev/alpha', mockDir.path);
expect(result).toBe('dev/alpha');
});
});
@@ -0,0 +1,78 @@
/*
* Copyright 2020 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 { startBackend, startBackendPlugin } from './startBackend';
import { startFrontend } from './startFrontend';
import { parse, resolve, join } from 'node:path';
import { glob } from 'glob';
export function resolveEntryPath(
entrypoint: string = 'dev',
targetDir: string,
): string {
const { dir: entryDir, name: entryName } = parse(entrypoint);
const [entryFile] = glob.sync(`${resolve(targetDir, entryDir, entryName)}.*`);
if (entryFile) {
return join(entryDir, entryName);
}
return join(entryDir, entryName, 'index');
}
export async function startPackage(options: {
role: PackageRole;
entrypoint?: string;
targetDir: string;
configPaths: string[];
checksEnabled: boolean;
inspectEnabled?: boolean | string;
inspectBrkEnabled?: boolean | string;
linkedWorkspace?: string;
require?: string;
}): Promise<void> {
switch (options.role) {
case 'backend':
return startBackend(options);
case 'backend-plugin':
case 'backend-plugin-module':
case 'node-library':
return startBackendPlugin(options);
case 'frontend':
return startFrontend({
...options,
entry: 'src/index',
verifyVersions: true,
});
case 'web-library':
case 'frontend-plugin':
case 'frontend-plugin-module':
return startFrontend({
...options,
entry: resolveEntryPath(options.entrypoint, options.targetDir),
});
case 'frontend-dynamic-container' as PackageRole: // experimental
return startFrontend({
entry: 'src/index',
...options,
skipOpenBrowser: true,
isModuleFederationRemote: true,
});
default:
throw new Error(
`Start command is not supported for package role '${options.role}'`,
);
}
}
@@ -0,0 +1,183 @@
/*
* Copyright 2020 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 chalk from 'chalk';
import { cli } from 'cleye';
import { relative as relativePath } from 'node:path';
import { buildPackages, getOutputsForRole } from '../../lib/builder';
import { targetPaths } from '@backstage/cli-common';
import {
BackstagePackage,
PackageGraph,
PackageRoles,
runConcurrentTasks,
} from '@backstage/cli-node';
import { buildFrontend } from '../../lib/buildFrontend';
import { buildBackend } from '../../lib/buildBackend';
import { createScriptOptionsParser } from '../../lib/optionsParser';
import type { CliCommandContext } from '@backstage/cli-node';
export default async ({ args, info }: CliCommandContext) => {
const {
flags: { all, since, minify },
} = cli(
{
help: info,
booleanFlagNegation: true,
flags: {
all: {
type: Boolean,
description:
'Build all packages, including bundled app and backend packages.',
},
since: {
type: String,
description:
'Only build packages and their dev dependents that changed since the specified ref',
},
minify: {
type: Boolean,
description:
'Minify the generated code. Does not apply to app package (app is minified by default).',
},
},
},
undefined,
args,
);
let packages = await PackageGraph.listTargetPackages();
const webpack = process.env.LEGACY_WEBPACK_BUILD
? (require('webpack') as typeof import('webpack'))
: undefined;
if (since) {
const graph = PackageGraph.fromPackages(packages);
const changedPackages = await graph.listChangedPackages({
ref: since,
analyzeLockfile: true,
});
const withDevDependents = graph.collectPackageNames(
changedPackages.map(pkg => pkg.name),
pkg => pkg.localDevDependents.keys(),
);
packages = Array.from(withDevDependents).map(name => graph.get(name)!);
}
const apps = new Array<BackstagePackage>();
const backends = new Array<BackstagePackage>();
const parseBuildScript = createScriptOptionsParser(['package', 'build'], {
role: { type: 'string' },
minify: { type: 'boolean' },
'skip-build-dependencies': { type: 'boolean' },
stats: { type: 'boolean' },
config: { type: 'string', multiple: true },
'module-federation': { type: 'boolean' },
});
const options = packages.flatMap(pkg => {
const role =
pkg.packageJson.backstage?.role ??
PackageRoles.detectRoleFromPackage(pkg.packageJson);
if (!role) {
console.warn(`Ignored ${pkg.packageJson.name} because it has no role`);
return [];
}
if (role === 'frontend') {
apps.push(pkg);
return [];
} else if (role === 'backend') {
backends.push(pkg);
return [];
}
const outputs = getOutputsForRole(role);
if (outputs.size === 0) {
console.warn(`Ignored ${pkg.packageJson.name} because it has no output`);
return [];
}
const buildOptions = parseBuildScript(pkg.packageJson.scripts?.build);
if (!buildOptions) {
console.warn(
`Ignored ${pkg.packageJson.name} because it does not have a matching build script`,
);
return [];
}
return {
targetDir: pkg.dir,
packageJson: pkg.packageJson,
outputs,
logPrefix: `${chalk.cyan(relativePath(targetPaths.rootDir, pkg.dir))}: `,
workspacePackages: packages,
minify: minify ?? Boolean(buildOptions.minify),
};
});
console.log('Building packages');
await buildPackages(options);
if (all) {
console.log('Building apps');
await runConcurrentTasks({
items: apps,
concurrencyFactor: 1 / 2,
worker: async pkg => {
const buildOptions = parseBuildScript(pkg.packageJson.scripts?.build);
if (!buildOptions) {
console.warn(
`Ignored ${pkg.packageJson.name} because it does not have a matching build script`,
);
return;
}
const configPaths = buildOptions.config;
await buildFrontend({
targetDir: pkg.dir,
configPaths: Array.isArray(configPaths)
? (configPaths as string[])
: [],
writeStats: Boolean(buildOptions.stats),
webpack,
});
},
});
console.log('Building backends');
await runConcurrentTasks({
items: backends,
concurrencyFactor: 1 / 2,
worker: async pkg => {
const buildOptions = parseBuildScript(pkg.packageJson.scripts?.build);
if (!buildOptions) {
console.warn(
`Ignored ${pkg.packageJson.name} because it does not have a matching build script`,
);
return;
}
await buildBackend({
targetDir: pkg.dir,
skipBuildDependencies: true,
minify: minify ?? Boolean(buildOptions.minify),
});
},
});
}
};
@@ -0,0 +1,53 @@
/*
* Copyright 2020 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 { cli } from 'cleye';
import fs from 'fs-extra';
import { resolve as resolvePath } from 'node:path';
import { PackageGraph } from '@backstage/cli-node';
import { run, targetPaths } from '@backstage/cli-common';
import type { CliCommandContext } from '@backstage/cli-node';
export default async ({ args, info }: CliCommandContext) => {
cli({ help: info, booleanFlagNegation: true }, undefined, args);
const packages = await PackageGraph.listTargetPackages();
await fs.remove(targetPaths.resolveRoot('dist'));
await fs.remove(targetPaths.resolveRoot('dist-types'));
await fs.remove(targetPaths.resolveRoot('coverage'));
await Promise.all(
Array.from(Array(10), async () => {
while (packages.length > 0) {
const pkg = packages.pop()!;
const cleanScript = pkg.packageJson.scripts?.clean;
if (
cleanScript === 'backstage-cli clean' ||
cleanScript === 'backstage-cli package clean'
) {
await fs.remove(resolvePath(pkg.dir, 'dist'));
await fs.remove(resolvePath(pkg.dir, 'dist-types'));
await fs.remove(resolvePath(pkg.dir, 'coverage'));
} else if (cleanScript) {
await run(['yarn', 'run', 'clean'], {
cwd: pkg.dir,
}).waitForExit();
}
}
}),
);
};
@@ -0,0 +1,217 @@
/*
* Copyright 2025 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 { PackageGraph } from '@backstage/cli-node';
import { findTargetPackages } from './start';
import { overrideTargetPaths } from '@backstage/cli-common/testUtils';
overrideTargetPaths('/root');
const mocks = {
app: {
packageJson: {
name: 'app',
version: '0',
backstage: { role: 'frontend' },
},
dir: '/root/packages/app',
},
backend: {
packageJson: {
name: 'backend',
version: '0',
backstage: { role: 'backend' },
},
dir: '/root/packages/backend',
},
appNext: {
packageJson: {
name: 'app-next',
version: '0',
backstage: { role: 'frontend' },
},
dir: '/root/packages/app-next',
},
backendNext: {
packageJson: {
name: 'backend-next',
version: '0',
backstage: { role: 'backend' },
},
dir: '/root/packages/backend-next',
},
otherApp: {
packageJson: {
name: 'other-app',
version: '0',
backstage: { role: 'frontend' },
},
dir: '/root/packages/other-app',
},
pluginX: {
packageJson: {
name: 'plugin-x',
version: '0',
backstage: { role: 'frontend-plugin', pluginId: 'x' },
},
dir: '/root/plugins/plugin-x',
},
pluginXBackend: {
packageJson: {
name: 'plugin-x-backend',
version: '0',
backstage: { role: 'backend-plugin', pluginId: 'x' },
},
dir: '/root/plugins/plugin-x-backend',
},
pluginY: {
packageJson: {
name: 'plugin-y',
version: '0',
backstage: { role: 'frontend-plugin', pluginId: 'y' },
},
dir: '/root/plugins/plugin-y',
},
pluginYBackend: {
packageJson: {
name: 'plugin-y-backend',
version: '0',
backstage: { role: 'backend-plugin', pluginId: 'y' },
},
dir: '/root/plugins/plugin-y-backend',
},
} as const;
describe('findTargetPackages', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should select default packages', async () => {
jest
.spyOn(PackageGraph, 'listTargetPackages')
.mockResolvedValue(Object.values(mocks));
const result = await findTargetPackages([], []);
expect(result).toEqual([mocks.app, mocks.backend]);
});
it('should select packages by plugin ID', async () => {
jest
.spyOn(PackageGraph, 'listTargetPackages')
.mockResolvedValue(Object.values(mocks));
const result = await findTargetPackages([], ['x']);
expect(result).toEqual([mocks.pluginX, mocks.pluginXBackend]);
});
it('should throw an error if no packages match the plugin ID', async () => {
jest
.spyOn(PackageGraph, 'listTargetPackages')
.mockResolvedValue(Object.values(mocks));
await expect(
findTargetPackages([], ['nonexistent-plugin']),
).rejects.toThrow(
"Unable to find any plugin packages with plugin ID 'nonexistent-plugin'. Make sure backstage.pluginId is set in your package.json files by running 'yarn fix --publish'.",
);
});
it('should select packages by explicit names', async () => {
jest
.spyOn(PackageGraph, 'listTargetPackages')
.mockResolvedValue(Object.values(mocks));
const result = await findTargetPackages(['other-app'], []);
expect(result).toEqual([mocks.otherApp]);
});
it('should throw an error if no package matches the explicit name', async () => {
jest
.spyOn(PackageGraph, 'listTargetPackages')
.mockResolvedValue(Object.values(mocks));
await expect(
findTargetPackages(['nonexistent-package'], []),
).rejects.toThrow("Unable to find package by name 'nonexistent-package'");
});
it('should select packages by relative path', async () => {
jest
.spyOn(PackageGraph, 'listTargetPackages')
.mockResolvedValue(Object.values(mocks));
const result = await findTargetPackages(
['packages/app', 'packages/backend-next'],
[],
);
expect(result).toEqual([mocks.app, mocks.backendNext]);
});
it('should throw an error if no package matches the relative path', async () => {
jest
.spyOn(PackageGraph, 'listTargetPackages')
.mockResolvedValue(Object.values(mocks));
await expect(findTargetPackages(['nonexistent/path'], [])).rejects.toThrow(
"Unable to find package by name 'nonexistent/path'",
);
});
it('should select a single frontend or backend package if no arguments are provided', async () => {
jest
.spyOn(PackageGraph, 'listTargetPackages')
.mockResolvedValue([mocks.app]);
const result = await findTargetPackages([], []);
expect(result).toEqual([mocks.app]);
});
it('should throw an error if multiple frontend packages other than packages/app are found without explicit selection', async () => {
jest
.spyOn(PackageGraph, 'listTargetPackages')
.mockResolvedValue([mocks.otherApp, mocks.appNext]);
await expect(findTargetPackages([], [])).rejects.toThrow(
"Found multiple packages with role 'frontend' but none of the use the default path '/root/packages/app',choose which packages you want to run by passing the package names explicitly as arguments, for example 'yarn backstage-cli repo start my-app my-backend'.",
);
});
it('should select a single plugin package if no app or backend packages are found', async () => {
jest
.spyOn(PackageGraph, 'listTargetPackages')
.mockResolvedValue([mocks.pluginX]);
const result = await findTargetPackages([], []);
expect(result).toEqual([mocks.pluginX]);
});
it('should select a pair of plugin packages if no app or backend packages are found', async () => {
jest
.spyOn(PackageGraph, 'listTargetPackages')
.mockResolvedValue([mocks.pluginX, mocks.pluginXBackend]);
const result = await findTargetPackages([], []);
expect(result).toEqual([mocks.pluginX, mocks.pluginXBackend]);
});
// Right now we're not validating this because it requires backstage.pluginId to be set, and it's a strange case anyway
it('should select a pair of plugin packages even if they are from different plugins', async () => {
jest
.spyOn(PackageGraph, 'listTargetPackages')
.mockResolvedValue([mocks.pluginX, mocks.pluginYBackend]);
const result = await findTargetPackages([], []);
expect(result).toEqual([mocks.pluginX, mocks.pluginYBackend]);
});
it('should throw an error if multiple plugin packages are found without explicit selection', async () => {
jest
.spyOn(PackageGraph, 'listTargetPackages')
.mockResolvedValue([mocks.pluginX, mocks.pluginY]);
await expect(findTargetPackages([], [])).rejects.toThrow(
"Found multiple packages with role 'frontend-plugin', please choose which packages you want to run by passing the package names explicitly as arguments, for example 'yarn backstage-cli repo start my-plugin my-plugin-backend'.",
);
});
});
@@ -0,0 +1,274 @@
/*
* Copyright 2025 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 {
BackstagePackage,
PackageGraph,
PackageRole,
} from '@backstage/cli-node';
import { relative as relativePath } from 'node:path';
import { targetPaths } from '@backstage/cli-common';
import { cli } from 'cleye';
import { resolveLinkedWorkspace } from '../package/start/resolveLinkedWorkspace';
import { startPackage } from '../package/start/startPackage';
import { parseArgs } from 'node:util';
import type { CliCommandContext } from '@backstage/cli-node';
const ACCEPTED_PACKAGE_ROLES: Array<PackageRole | undefined> = [
'frontend',
'backend',
'frontend-plugin',
'backend-plugin',
];
export default async ({ args, info }: CliCommandContext) => {
const {
flags: { plugin, config, require: requirePath, link, inspect, inspectBrk },
_: namesOrPaths,
} = cli(
{
help: { ...info, usage: `${info.usage} [packages...]` },
booleanFlagNegation: true,
parameters: ['[packages...]'],
flags: {
plugin: {
type: [String],
description:
'Start the dev entry-point for any matching plugin package in the repo',
default: [],
},
config: {
type: [String],
description: 'Config files to load instead of app-config.yaml',
default: [],
},
require: {
type: String,
description:
'Add a --require argument to the node process. Applies to backend package only',
},
link: {
type: String,
description: 'Link an external workspace for module resolution',
},
inspect: {
type: String,
description:
'Enable the Node.js inspector, optionally at a specific host:port',
},
inspectBrk: {
type: String,
description:
'Enable the Node.js inspector and break before user code starts',
},
},
},
undefined,
args,
);
const targetPackages = await findTargetPackages(namesOrPaths, plugin);
const packageOptions = await resolvePackageOptions(targetPackages, {
plugin,
config,
inspect: inspect || (inspect === '' ? true : undefined),
inspectBrk: inspectBrk || (inspectBrk === '' ? true : undefined),
require: requirePath,
link,
});
if (packageOptions.length === 0) {
console.log('No packages found to start');
return;
}
console.log(
`Starting ${packageOptions
.map(({ pkg }) => pkg.packageJson.name)
.join(', ')}`,
);
// Each of these block until interrupted by user
await Promise.all(packageOptions.map(entry => startPackage(entry.options)));
};
export async function findTargetPackages(
namesOrPaths: string[],
pluginIds: string[],
) {
const targetPackages = new Array<BackstagePackage>();
const packages = await PackageGraph.listTargetPackages();
// Prioritize plugin options, so that the `start` script can contain a list of packages,
// but make them easy to override by running for example `yarn start --plugin catalog`
for (const pluginId of pluginIds) {
const matchingPackages = packages.filter(pkg => {
return (
pluginId === pkg.packageJson.backstage?.pluginId &&
ACCEPTED_PACKAGE_ROLES.includes(pkg.packageJson.backstage.role)
);
});
if (matchingPackages.length === 0) {
throw new Error(
`Unable to find any plugin packages with plugin ID '${pluginId}'. Make sure backstage.pluginId is set in your package.json files by running 'yarn fix --publish'.`,
);
}
targetPackages.push(...matchingPackages);
}
if (targetPackages.length > 0) {
return targetPackages;
}
// Next check if explicit package names are provided, use them in that case.
for (const nameOrPath of namesOrPaths) {
let matchingPackage = packages.find(
pkg => nameOrPath === pkg.packageJson.name,
);
if (!matchingPackage) {
const absPath = targetPaths.resolveRoot(nameOrPath);
matchingPackage = packages.find(
pkg => relativePath(pkg.dir, absPath) === '',
);
}
if (!matchingPackage) {
throw new Error(`Unable to find package by name '${nameOrPath}'`);
}
targetPackages.push(matchingPackage);
}
if (targetPackages.length > 0) {
return targetPackages;
}
// If no package names are provided, default to expect a single frontend and/or backend package
for (const role of ['frontend', 'backend']) {
const matchingPackages = packages.filter(
pkg => pkg.packageJson.backstage?.role === role,
);
if (matchingPackages.length > 1) {
// Final fallback is to check for the package path within the monorepo, packages/app or packages/backend
const expectedPath = targetPaths.resolveRoot(
role === 'frontend' ? 'packages/app' : 'packages/backend',
);
const matchByPath = matchingPackages.find(
pkg => relativePath(expectedPath, pkg.dir) === '',
);
if (matchByPath) {
targetPackages.push(matchByPath);
continue;
}
throw new Error(
`Found multiple packages with role '${role}' but none of the use the default path '${expectedPath}',` +
`choose which packages you want to run by passing the package names explicitly ` +
`as arguments, for example 'yarn backstage-cli repo start my-app my-backend'.`,
);
}
targetPackages.push(...matchingPackages);
}
if (targetPackages.length > 0) {
return targetPackages;
}
// If no app or backend packages are found, fall back to expecting single plugin packages
for (const role of ['frontend-plugin', 'backend-plugin']) {
const matchingPackages = packages.filter(
pkg => pkg.packageJson.backstage?.role === role,
);
if (matchingPackages.length > 1) {
throw new Error(
`Found multiple packages with role '${role}', please choose which packages you want ` +
`to run by passing the package names explicitly as arguments, for example ` +
`'yarn backstage-cli repo start my-plugin my-plugin-backend'.`,
);
}
targetPackages.push(...matchingPackages);
}
if (targetPackages.length > 0) {
return targetPackages;
}
throw new Error(
`Unable to find any packages with role 'frontend', 'backend', 'frontend-plugin', or 'backend-plugin'.`,
);
}
type CommandOptions = {
plugin: string[];
config: string[];
inspect?: boolean | string;
inspectBrk?: boolean | string;
require?: string;
link?: string;
};
async function resolvePackageOptions(
targetPackages: BackstagePackage[],
options: CommandOptions,
) {
const linkedWorkspace = await resolveLinkedWorkspace(options.link);
return targetPackages.flatMap(pkg => {
const startScript = pkg.packageJson.scripts?.start;
if (!startScript) {
console.log(
`No start script found for package ${pkg.packageJson.name}, skipping...`,
);
return [];
}
// Grab and parse --config and --require options from the start scripts, the rest are ignored
// TODO(Rugvip): Prolly switch over to completely different arg parsing to avoid this duplication
const { values: parsedOpts } = parseArgs({
args: startScript.split(' '),
strict: false,
options: {
config: {
type: 'string',
multiple: true,
},
require: {
type: 'string',
},
},
});
const parsedRequire =
typeof parsedOpts.require === 'string' ? parsedOpts.require : undefined;
const parsedConfig =
parsedOpts.config?.filter(c => typeof c === 'string') ?? [];
return [
{
pkg,
options: {
role: pkg.packageJson.backstage?.role!,
targetDir: pkg.dir,
configPaths:
options.config.length > 0 ? options.config : parsedConfig,
checksEnabled: false,
linkedWorkspace,
inspectEnabled: options.inspect,
inspectBrkEnabled: options.inspectBrk,
require: options.require ?? parsedRequire,
},
},
];
});
}
+89
View File
@@ -0,0 +1,89 @@
/*
* 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 { createCliModule } from '@backstage/cli-node';
import packageJson from '../package.json';
export const buildPlugin = createCliModule({
packageJson,
init: async reg => {
reg.addCommand({
path: ['package', 'build'],
description: 'Build a package for production deployment or publishing',
execute: { loader: () => import('./commands/package/build') },
});
reg.addCommand({
path: ['repo', 'build'],
description:
'Build packages in the project, excluding bundled app and backend packages.',
execute: { loader: () => import('./commands/repo/build') },
});
reg.addCommand({
path: ['package', 'start'],
description: 'Start a package for local development',
execute: { loader: () => import('./commands/package/start') },
});
reg.addCommand({
path: ['repo', 'start'],
description: 'Starts packages in the repo for local development',
execute: { loader: () => import('./commands/repo/start') },
});
reg.addCommand({
path: ['package', 'clean'],
description: 'Delete cache directories',
execute: {
loader: () => import('./commands/package/clean'),
},
});
reg.addCommand({
path: ['package', 'prepack'],
description: 'Prepares a package for packaging before publishing',
execute: {
loader: () => import('./commands/package/prepack'),
},
});
reg.addCommand({
path: ['package', 'postpack'],
description: 'Restores the changes made by the prepack command',
execute: {
loader: () => import('./commands/package/postpack'),
},
});
reg.addCommand({
path: ['repo', 'clean'],
description: 'Delete cache and output directories',
execute: {
loader: () => import('./commands/repo/clean'),
},
});
reg.addCommand({
path: ['build-workspace'],
description:
'Builds a temporary dist workspace from the provided packages',
execute: { loader: () => import('./commands/buildWorkspace') },
});
},
});
export default buildPlugin;
@@ -0,0 +1,144 @@
/*
* 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, BackstagePackageFeatureType } from '@backstage/cli-node';
import { Project } from 'ts-morph';
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,
};
}
@@ -0,0 +1,85 @@
/*
* Copyright 2020 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 os from 'node:os';
import fs from 'fs-extra';
import { resolve as resolvePath } from 'node:path';
import * as tar from 'tar';
import { createDistWorkspace } from './packager';
import { buildPackage, Output } from './builder';
import { PackageGraph } from '@backstage/cli-node';
const BUNDLE_FILE = 'bundle.tar.gz';
const SKELETON_FILE = 'skeleton.tar.gz';
interface BuildBackendOptions {
targetDir: string;
skipBuildDependencies: boolean;
configPaths?: string[];
minify?: boolean;
}
export async function buildBackend(options: BuildBackendOptions) {
const { targetDir, skipBuildDependencies, configPaths, minify } = options;
const pkg = await fs.readJson(resolvePath(targetDir, 'package.json'));
// We build the target package without generating type declarations.
await buildPackage({
targetDir,
packageJson: pkg,
outputs: new Set([Output.cjs]),
minify,
workspacePackages: await PackageGraph.listTargetPackages(),
});
const tmpDir = await fs.mkdtemp(resolvePath(os.tmpdir(), 'backstage-bundle'));
try {
await createDistWorkspace([pkg.name], {
targetDir: tmpDir,
configPaths,
buildDependencies: !skipBuildDependencies,
buildExcludes: [pkg.name],
skeleton: SKELETON_FILE,
minify,
});
// We built the target backend package using the regular build process, but the result of
// that has now been packed into the dist workspace, so clean up the dist dir.
const distDir = resolvePath(targetDir, 'dist');
await fs.remove(distDir);
await fs.mkdir(distDir);
// Move out skeleton.tar.gz before we create the main bundle, no point having that included up twice.
await fs.move(
resolvePath(tmpDir, SKELETON_FILE),
resolvePath(distDir, SKELETON_FILE),
);
// Create main bundle.tar.gz, with some tweaks to make it more likely hit Docker build cache.
await tar.create(
{
file: resolvePath(distDir, BUNDLE_FILE),
cwd: tmpDir,
portable: true,
noMtime: true,
gzip: true,
},
[''],
);
} finally {
await fs.remove(tmpDir);
}
}
@@ -0,0 +1,52 @@
/*
* Copyright 2020 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 fs from 'fs-extra';
import { resolve as resolvePath } from 'node:path';
import { buildBundle, getModuleFederationRemoteOptions } from './bundler';
import { BackstagePackageJson } from '@backstage/cli-node';
import { loadCliConfig } from './config';
interface BuildAppOptions {
targetDir: string;
writeStats: boolean;
configPaths: string[];
isModuleFederationRemote?: boolean;
webpack?: typeof import('webpack');
}
export async function buildFrontend(options: BuildAppOptions) {
const { targetDir, writeStats, configPaths, webpack } = options;
const packageJson = (await fs.readJson(
resolvePath(targetDir, 'package.json'),
)) as BackstagePackageJson;
await buildBundle({
targetDir,
entry: 'src/index',
statsJsonEnabled: writeStats,
moduleFederationRemote: options.isModuleFederationRemote
? await getModuleFederationRemoteOptions(
packageJson,
resolvePath(targetDir),
)
: undefined,
...(await loadCliConfig({
args: configPaths,
fromPackage: packageJson.name,
})),
webpack,
});
}
@@ -0,0 +1,65 @@
/*
* Copyright 2020 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 { ExternalOption } from 'rollup';
import { makeRollupConfigs } from './config';
import { Output } from './types';
describe('makeRollupConfigs', () => {
it('should mark external modules correctly', async () => {
const importerPath = '/some/path.ts'; // when specified we don't care about the path
const [config] = await makeRollupConfigs({
outputs: new Set([Output.cjs]),
packageJson: {
name: 'test',
version: '0.0.0',
main: './src/index.ts',
},
workspacePackages: [],
});
const external = config.external as Exclude<
ExternalOption,
string | RegExp | (string | RegExp)[]
>;
expect(external('foo', importerPath, false)).toBe(true);
expect(external('./foo', importerPath, false)).toBe(false);
expect(external('/foo', importerPath, false)).toBe(false);
expect(external('.\\foo', importerPath, false)).toBe(false);
expect(external('c:\\foo', importerPath, false)).toBe(false);
expect(external('@foo/bar', importerPath, false)).toBe(true);
expect(external('../foo', importerPath, false)).toBe(false);
// Modules without an importer are entry points, i.e. not external
expect(external('foo', undefined, false)).toBe(false);
expect(external('./foo', undefined, false)).toBe(false);
expect(external('/foo', undefined, false)).toBe(false);
expect(external('.\\foo', undefined, false)).toBe(false);
expect(external('c:\\foo', undefined, false)).toBe(false);
expect(external('@foo/bar', undefined, false)).toBe(false);
expect(external('../foo', undefined, false)).toBe(false);
// After modules have been resolved they're never marked as external
expect(external('foo', importerPath, true)).toBe(false);
expect(external('./foo', importerPath, true)).toBe(false);
expect(external('/foo', importerPath, true)).toBe(false);
expect(external('.\\foo', importerPath, true)).toBe(false);
expect(external('c:\\foo', importerPath, true)).toBe(false);
expect(external('@foo/bar', importerPath, true)).toBe(false);
expect(external('../foo', importerPath, true)).toBe(false);
});
});
@@ -0,0 +1,325 @@
/*
* Copyright 2020 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 chalk from 'chalk';
import fs from 'fs-extra';
import { createHash } from 'node:crypto';
import {
basename,
extname,
relative as relativePath,
resolve as resolvePath,
} from 'node:path';
import commonjs from '@rollup/plugin-commonjs';
import resolve from '@rollup/plugin-node-resolve';
import postcss from 'rollup-plugin-postcss';
import esbuild from 'rollup-plugin-esbuild';
import dts from 'rollup-plugin-dts';
import json from '@rollup/plugin-json';
import yaml from '@rollup/plugin-yaml';
import {
RollupOptions,
OutputOptions,
WarningHandlerWithDefault,
OutputPlugin,
} from 'rollup';
import { forwardFileImports, cssEntryPoints } from './plugins';
import { BuildOptions, Output } from './types';
import { targetPaths } from '@backstage/cli-common';
import { BackstagePackageJson } from '@backstage/cli-node';
import { readEntryPoints } from '../entryPoints';
const SCRIPT_EXTS = ['.js', '.jsx', '.ts', '.tsx'];
const MODULE_EXTS = ['.mjs', '.mts'];
const COMMONJS_EXTS = ['.cjs', '.cts'];
const MOD_EXT = '.mjs';
const CJS_EXT = '.cjs';
const CJS_JS_EXT = '.cjs.js';
function isFileImport(source: string) {
if (source.startsWith('.')) {
return true;
}
if (source.startsWith('/')) {
return true;
}
if (source.match(/[a-z]:/i)) {
return true;
}
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('|')})(?:$|/)`);
}
// This Rollup output plugin enables support for mixed CommonJS and ESM output.
// It does it be filtering out the unwanted output files that don't match the
// input file format, allowing the rollup configuration to have overlapping
// output configurations for different formats.
function multiOutputFormat(): OutputPlugin {
return {
name: 'backstage-multi-output-format',
generateBundle(opts, bundle) {
const filter: (name: string) => boolean =
opts.format === 'cjs'
? s => s.endsWith(MOD_EXT)
: s => !s.endsWith(MOD_EXT);
// Delete any files that don't match the current output format
for (const name in bundle) {
if (filter(name)) {
delete bundle[name];
delete bundle[`${name}.map`];
}
}
},
renderDynamicImport(opts) {
if (opts.format === 'cjs') {
return {
left: 'import(',
right: ')',
};
}
return undefined;
},
};
}
export async function makeRollupConfigs(
options: BuildOptions,
): Promise<RollupOptions[]> {
const configs = new Array<RollupOptions>();
const targetDir = options.targetDir ?? targetPaths.dir;
let targetPkg = options.packageJson;
if (!targetPkg) {
const packagePath = resolvePath(targetDir, 'package.json');
targetPkg = (await fs.readJson(packagePath)) as BackstagePackageJson;
}
const onwarn: WarningHandlerWithDefault = ({ code, message }) => {
if (code === 'EMPTY_BUNDLE') {
return; // We don't care about this one
}
if (options.logPrefix) {
console.log(options.logPrefix + message);
} else {
console.log(message);
}
};
const distDir = resolvePath(targetDir, 'dist');
const entryPoints = readEntryPoints(targetPkg);
const scriptEntryPoints = entryPoints.filter(e =>
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'];
// Avoid using node_modules as a directory name, since it's trimmed from published packages.
// This can happen when inlining dependencies such as style-inject added for css injection.
const rewriteNodeModules = (name: string) =>
name.replaceAll('node_modules', 'node_modules_dist');
// For CommonJS we build both CommonJS and ESM output. Each of these outputs
// can output both .cjs and .mjs files. The files from each of these outputs
// will overlap, but we trim away files where the format doesn't match the
// file extensions. That way we are left with a combination of .cjs and .mjs
// files where the module format in the file matches the file extension.
if (options.outputs.has(Output.cjs)) {
const defaultExt = targetPkg.type === 'module' ? MOD_EXT : CJS_JS_EXT;
const outputOpts: OutputOptions = {
dir: distDir,
entryFileNames(chunkInfo) {
const cleanName = rewriteNodeModules(chunkInfo.name);
const inputId = chunkInfo.facadeModuleId;
if (!inputId) {
return cleanName + defaultExt;
}
const inputExt = extname(inputId);
if (MODULE_EXTS.includes(inputExt)) {
return cleanName + MOD_EXT;
}
if (COMMONJS_EXTS.includes(inputExt)) {
return cleanName + CJS_EXT;
}
return cleanName + defaultExt;
},
sourcemap: true,
preserveModules: true,
preserveModulesRoot: `${targetDir}/src`,
interop: 'compat',
exports: 'named',
plugins: [multiOutputFormat()],
};
output.push({
...outputOpts,
format: 'cjs',
});
output.push({
...outputOpts,
format: 'module',
});
}
if (options.outputs.has(Output.esm)) {
output.push({
dir: distDir,
entryFileNames: chunkInfo =>
`${rewriteNodeModules(chunkInfo.name)}.esm.js`,
chunkFileNames: `esm/[name]-[hash].esm.js`,
format: 'module',
sourcemap: true,
preserveModules: true,
preserveModulesRoot: `${targetDir}/src`,
});
// Assume we're building for the browser if ESM output is included
mainFields.unshift('browser');
}
configs.push({
input: Object.fromEntries(
scriptEntryPoints.map(e => [e.name, resolvePath(targetDir, e.path)]),
),
output,
onwarn,
makeAbsoluteExternalsRelative: false,
preserveEntrySignatures: 'strict',
// All module imports are always marked as external
external,
plugins: [
resolve({
mainFields,
extensions: SCRIPT_EXTS,
}),
commonjs({
include: /node_modules/,
exclude: [/\/[^/]+\.(?:stories|test)\.[^/]+$/],
}),
postcss({
modules: {
generateScopedName(name: string, filename: string, css: string) {
const hash = createHash('md5')
.update(css)
.digest('hex')
.slice(0, 10);
const file = basename(filename, '.module.css');
return `${file}_${name}__${hash}`;
},
},
}),
forwardFileImports({
exclude: /\.icon\.svg$/,
include: [
/\.svg$/,
/\.png$/,
/\.gif$/,
/\.jpg$/,
/\.jpeg$/,
/\.webp$/,
/\.eot$/,
/\.woff$/,
/\.woff2$/,
/\.ttf$/,
/\.md$/,
],
}),
json(),
yaml(),
esbuild({
target: 'ES2023',
minify: options.minify,
}),
cssEntryPoints({ entryPoints, targetDir }),
],
});
}
if (options.outputs.has(Output.types)) {
const input = Object.fromEntries(
scriptEntryPoints.map(e => [
e.name,
targetPaths.resolveRoot(
'dist-types',
relativePath(targetPaths.rootDir, targetDir),
e.path.replace(/\.(?:ts|tsx)$/, '.d.ts'),
),
]),
);
for (const path of Object.values(input)) {
const declarationsExist = await fs.pathExists(path);
if (!declarationsExist) {
const declarationPath = relativePath(targetDir, path);
throw new Error(
`No declaration files found at ${declarationPath}, be sure to run ${chalk.bgRed.white(
'yarn tsc',
)} to generate .d.ts files before packaging`,
);
}
}
configs.push({
input,
output: {
dir: distDir,
entryFileNames: `[name].d.ts`,
chunkFileNames: `types/[name]-[hash].d.ts`,
format: 'es',
},
external: (source, importer, isResolved) =>
/\.css|scss|sass|svg|eot|woff|woff2|ttf$/.test(source) ||
external(source, importer, isResolved),
onwarn,
plugins: [dts({ respectExternal: true })],
});
}
return configs;
}
@@ -0,0 +1,19 @@
/*
* Copyright 2020 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 { buildPackage, buildPackages, getOutputsForRole } from './packager';
export { Output } from './types';
export type { BuildOptions } from './types';
@@ -0,0 +1,38 @@
/*
* Copyright 2021 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 { formatErrorMessage } from './packager';
describe('formatErrorMessage with esbuild plugin error', () => {
it('given error with missing errors array then error message should be shown', () => {
const msg = formatErrorMessage({
code: 'PLUGIN_ERROR',
plugin: 'esbuild',
message: 'test',
});
expect(msg).toBe('test');
});
it('given error with errors array then error message should have new lines', () => {
const msg = formatErrorMessage({
code: 'PLUGIN_ERROR',
plugin: 'esbuild',
message: 'test',
id: 'index.js',
errors: [{ text: 'Sample', location: { line: 1, column: 1 } }],
});
expect(msg).toContain('test\n\n');
});
});
@@ -0,0 +1,152 @@
/*
* Copyright 2020 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 fs from 'fs-extra';
import { rollup, RollupOptions } from 'rollup';
import chalk from 'chalk';
import { relative as relativePath, resolve as resolvePath } from 'node:path';
import { targetPaths } from '@backstage/cli-common';
import { makeRollupConfigs } from './config';
import { BuildOptions, Output } from './types';
import { PackageRoles, runConcurrentTasks } from '@backstage/cli-node';
export function formatErrorMessage(error: any) {
let msg = '';
if (error.code === 'PLUGIN_ERROR') {
if (error.plugin === 'esbuild') {
msg += `${error.message}`;
if (error.errors?.length) {
msg += `\n\n`;
for (const { text, location } of error.errors) {
const { line, column } = location;
const path = relativePath(targetPaths.dir, error.id);
const loc = chalk.cyan(`${path}:${line}:${column}`);
if (text === 'Unexpected "<"' && error.id.endsWith('.js')) {
msg += `${loc}: ${text}, JavaScript files with JSX should use a .jsx extension`;
} else {
msg += `${loc}: ${text}`;
}
}
}
} else {
// Log which plugin is causing errors to make it easier to identity.
// If we see these in logs we likely want to provide some custom error
// output for those plugins too.
msg += `(plugin ${error.plugin}) ${error}\n`;
}
} else {
// Generic rollup errors, log what's available
if (error.loc) {
const file = `${targetPaths.resolve((error.loc.file || error.id)!)}`;
const pos = `${error.loc.line}:${error.loc.column}`;
msg += `${file} [${pos}]\n`;
} else if (error.id) {
msg += `${targetPaths.resolve(error.id)}\n`;
}
msg += `${error}\n`;
if (error.url) {
msg += `${chalk.cyan(error.url)}\n`;
}
if (error.frame) {
msg += `${chalk.dim(error.frame)}\n`;
}
}
return msg;
}
async function rollupBuild(config: RollupOptions) {
try {
const bundle = await rollup(config);
if (config.output) {
for (const output of [config.output].flat()) {
await bundle.generate(output);
await bundle.write(output);
}
}
} catch (error) {
throw new Error(formatErrorMessage(error));
}
}
export const buildPackage = async (options: BuildOptions) => {
try {
const { resolutions } = await fs.readJson(
targetPaths.resolveRoot('package.json'),
);
if (resolutions?.esbuild) {
console.warn(
chalk.red(
'Your root package.json contains a "resolutions" entry for "esbuild". This was ' +
'included in older @backstage/create-app templates in order to work around build ' +
'issues that have since been fixed. Please remove the entry and run `yarn install`',
),
);
}
} catch {
/* Errors ignored, this is just a warning */
}
const rollupConfigs = await makeRollupConfigs(options);
const targetDir = options.targetDir ?? targetPaths.dir;
await fs.remove(resolvePath(targetDir, 'dist'));
const buildTasks = rollupConfigs.map(rollupBuild);
await Promise.all(buildTasks);
};
export const buildPackages = async (options: BuildOptions[]) => {
if (options.some(opt => !opt.targetDir)) {
throw new Error('targetDir must be set for all build options');
}
const rollupConfigs = await Promise.all(options.map(makeRollupConfigs));
await Promise.all(
options.map(({ targetDir }) => fs.remove(resolvePath(targetDir!, 'dist'))),
);
const buildTasks = rollupConfigs.flat().map(opts => () => rollupBuild(opts));
await runConcurrentTasks({
items: buildTasks,
worker: async task => task(),
});
};
export function getOutputsForRole(role: string): Set<Output> {
const outputs = new Set<Output>();
for (const output of PackageRoles.getRoleInfo(role).output) {
if (output === 'cjs') {
outputs.add(Output.cjs);
}
if (output === 'esm') {
outputs.add(Output.esm);
}
if (output === 'types') {
outputs.add(Output.types);
}
}
return outputs;
}
@@ -0,0 +1,348 @@
/*
* Copyright 2020 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 fs from 'fs-extra';
import {
NormalizedOutputOptions,
OutputAsset,
OutputChunk,
PluginContext,
} from 'rollup';
import { forwardFileImports, cssEntryPoints } from './plugins';
import { createMockDirectory } from '@backstage/backend-test-utils';
// Helper to call generateBundle hook which can be a function or ObjectHook
async function callGenerateBundle(
plugin: ReturnType<typeof cssEntryPoints>,
ctx: PluginContext,
options: NormalizedOutputOptions,
bundle: Record<string, OutputChunk | OutputAsset>,
isWrite: boolean,
) {
const hook = plugin.generateBundle;
if (typeof hook === 'function') {
await hook.call(ctx, options, bundle, isWrite);
} else if (hook && typeof hook === 'object' && 'handler' in hook) {
await hook.handler.call(ctx, options, bundle, isWrite);
}
}
const context = {
meta: {
rollupVersion: '0.0.0',
watchMode: false,
},
} as PluginContext;
describe('forwardFileImports', () => {
it('should be created', () => {
const plugin = forwardFileImports({ include: /\.png$/ });
expect(plugin.name).toBe('forward-file-imports');
});
it('should call through to original external option', async () => {
const plugin = forwardFileImports({ include: /\.png$/ });
const external = jest.fn((id: string) => id.endsWith('external'));
const options = (await plugin.options?.call(context, { external }))!;
if (typeof options.external !== 'function') {
throw new Error('options.external is not a function');
}
expect(external).toHaveBeenCalledTimes(0);
expect(options.external('./my-module', '/dev/src/index.ts', false)).toBe(
false,
);
expect(external).toHaveBeenCalledTimes(1);
expect(options.external('./my-external', '/dev/src/index.ts', false)).toBe(
true,
);
expect(external).toHaveBeenCalledTimes(2);
expect(options.external('./my-image.png', '/dev/src/index.ts', false)).toBe(
true,
);
expect(external).toHaveBeenCalledTimes(3);
expect(options.external('./my-image.png', '/dev/src/index.ts', true)).toBe(
true,
);
expect(external).toHaveBeenCalledTimes(4);
expect(() =>
(options as any).external('./my-image.png', undefined, false),
).toThrow('Unknown importer of file module ./my-image.png');
});
it('should handle original external array', async () => {
const plugin = forwardFileImports({ include: /\.png$/ });
const options = (await plugin.options?.call(context, {
external: ['my-external'],
}))!;
if (typeof options.external !== 'function') {
throw new Error('options.external is not a function');
}
expect(options.external('my-module', '/dev/src/index.ts', false)).toBe(
false,
);
expect(options.external('my-external', '/dev/src/index.ts', false)).toBe(
true,
);
expect(options.external('my-image.png', '/dev/src/index.ts', false)).toBe(
true,
);
});
describe('with createMockDirectory', () => {
const mockDir = createMockDirectory();
beforeEach(() => {
mockDir.setContent({
dev: {
src: {
'my-module.ts': '',
dir: { 'my-image.png': 'my-image' },
},
},
});
});
it('should extract files', async () => {
const plugin = forwardFileImports({ include: /\.png$/ });
const options = (await plugin.options?.call(context, {}))!;
if (typeof options.external !== 'function') {
throw new Error('options.external is not a function');
}
expect(
options.external(
'./my-module',
mockDir.resolve('dev/src/index.ts'),
false,
),
).toBe(false);
expect(
options.external(
'./my-image.png',
mockDir.resolve('dev', 'src', 'dir', 'index.ts'),
false,
),
).toBe(true);
const outPath = mockDir.resolve('dev', 'dist', 'dir', 'my-image.png');
await expect(fs.pathExists(outPath)).resolves.toBe(false);
await plugin.generateBundle?.call(
context,
{
dir: mockDir.resolve('dev/dist'),
} as NormalizedOutputOptions,
{
['index.js']: {
type: 'chunk',
facadeModuleId: mockDir.resolve('dev/src/index.ts'),
} as OutputChunk,
},
false, // isWrite = false -> no write
);
await expect(fs.pathExists(outPath)).resolves.toBe(false);
await plugin.generateBundle?.call(
context,
{
dir: mockDir.resolve('dev/dist'),
} as NormalizedOutputOptions,
{
// output assets should not cause a write
['index.js']: { type: 'asset' } as OutputAsset,
// missing facadeModuleId should not cause a write either
['index2.js']: { type: 'chunk' } as OutputChunk,
},
true,
);
await expect(fs.pathExists(outPath)).resolves.toBe(false);
// output chunk + isWrite -> generate files
await plugin.generateBundle?.call(
context,
{
dir: mockDir.resolve('dev/dist'),
} as NormalizedOutputOptions,
{
['index.js']: {
type: 'chunk',
facadeModuleId: mockDir.resolve('dev/src/index.ts'),
} as OutputChunk,
},
true,
);
await expect(fs.pathExists(outPath)).resolves.toBe(true);
// should not break when triggering another write
await plugin.generateBundle?.call(
context,
{
file: mockDir.resolve('dev/dist/my-output.js'),
} as NormalizedOutputOptions,
{
['index.js']: {
type: 'chunk',
facadeModuleId: mockDir.resolve('dev/src/index.ts'),
} as OutputChunk,
},
true,
);
});
});
});
describe('cssEntryPoints', () => {
it('should be created with correct name', () => {
const plugin = cssEntryPoints({
entryPoints: [],
targetDir: '/dev',
});
expect(plugin.name).toBe('backstage-css-entry-points');
});
describe('with createMockDirectory', () => {
const mockDir = createMockDirectory();
const emittedFiles: Array<{ fileName: string; source: string }> = [];
const emitContext = {
...context,
emitFile: (file: { fileName: string; source: string }) => {
emittedFiles.push(file);
return 'asset-id';
},
} as unknown as PluginContext;
beforeEach(() => {
emittedFiles.length = 0;
mockDir.setContent({
dev: {
src: {
css: {
'styles.css': '@import "./base.css";\n.root { color: red; }',
'base.css': '.base { margin: 0; }',
},
},
},
});
});
it('should not emit when isWrite is false', async () => {
const plugin = cssEntryPoints({
entryPoints: [
{
mount: './css/styles.css',
path: './src/css/styles.css',
name: 'css/styles.css',
ext: '.css',
},
],
targetDir: mockDir.resolve('dev'),
});
await callGenerateBundle(
plugin,
emitContext,
{ dir: mockDir.resolve('dev/dist') } as NormalizedOutputOptions,
{},
false,
);
expect(emittedFiles).toHaveLength(0);
});
it('should emit only CSS entry points with resolved imports', async () => {
const plugin = cssEntryPoints({
entryPoints: [
// Non-CSS entry should be ignored
{ mount: '.', path: './src/index.ts', name: 'index', ext: '.ts' },
{
mount: './css/styles.css',
path: './src/css/styles.css',
name: 'css/styles.css',
ext: '.css',
},
],
targetDir: mockDir.resolve('dev'),
});
await callGenerateBundle(
plugin,
emitContext,
{ dir: mockDir.resolve('dev/dist') } as NormalizedOutputOptions,
{},
true,
);
// Only CSS file should be emitted, not the .ts entry
expect(emittedFiles).toHaveLength(1);
expect(emittedFiles[0].fileName).toBe('css/styles.css');
expect(emittedFiles[0].source).toContain('.base { margin: 0; }');
expect(emittedFiles[0].source).toContain('.root { color: red; }');
expect(emittedFiles[0].source).not.toContain('@import');
});
it('should only emit once per output directory', async () => {
const plugin = cssEntryPoints({
entryPoints: [
{
mount: './css/styles.css',
path: './src/css/styles.css',
name: 'css/styles.css',
ext: '.css',
},
],
targetDir: mockDir.resolve('dev'),
});
// First call should emit
await callGenerateBundle(
plugin,
emitContext,
{ dir: mockDir.resolve('dev/dist') } as NormalizedOutputOptions,
{},
true,
);
expect(emittedFiles).toHaveLength(1);
// Second call to same dir should not emit again
await callGenerateBundle(
plugin,
emitContext,
{ dir: mockDir.resolve('dev/dist') } as NormalizedOutputOptions,
{},
true,
);
expect(emittedFiles).toHaveLength(1);
// Call to different dir should emit
await callGenerateBundle(
plugin,
emitContext,
{ dir: mockDir.resolve('dev/dist2') } as NormalizedOutputOptions,
{},
true,
);
expect(emittedFiles).toHaveLength(2);
});
});
});
@@ -0,0 +1,229 @@
/*
* Copyright 2020 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 fs from 'fs-extra';
import postcss from 'postcss';
import postcssImport from 'postcss-import';
import {
dirname,
resolve as resolvePath,
relative as relativePath,
} from 'node:path';
import { createFilter } from 'rollup-pluginutils';
import {
Plugin,
InputOptions,
OutputChunk,
HasModuleSideEffects,
} from 'rollup';
import { EntryPoint } from '../entryPoints';
type ForwardFileImportsOptions = {
include: Array<string | RegExp> | string | RegExp | null;
exclude?: Array<string | RegExp> | string | RegExp | null;
};
/**
* This rollup plugin leaves all encountered asset imports as-is, but
* copies the imported files into the output directory.
*
* For example `import ImageUrl from './my-image.png'` inside `src/MyComponent` will
* cause `src/MyComponent/my-image.png` to be copied to the output directory at the
* path `dist/MyComponent/my-image.png`. The import itself will stay, but be resolved,
* resulting in something like `import ImageUrl from './MyComponent/my-image.png'`
*/
export function forwardFileImports(options: ForwardFileImportsOptions) {
const filter = createFilter(options.include, options.exclude);
// We collect the absolute paths to all files we want to bundle into the
// output dir here. Resolving to relative paths in the output dir happens later.
const exportedFiles = new Set<string>();
// We keep track of output directories that we've already copied files
// into, so that we don't duplicate that work
const generatedFor = new Set<string>();
return {
name: 'forward-file-imports',
async generateBundle(outputOptions, bundle, isWrite) {
if (!isWrite) {
return;
}
const dir = outputOptions.dir || dirname(outputOptions.file!);
if (generatedFor.has(dir)) {
return;
}
for (const output of Object.values(bundle)) {
if (output.type !== 'chunk') {
continue;
}
const chunk = output as OutputChunk;
// This'll be an absolute path pointing to the initial index file of the
// build, and we use it to find the location of the `src` dir
if (!chunk.facadeModuleId) {
continue;
}
generatedFor.add(dir);
// We're assuming that the index file is at the root of the source dir, and
// that all assets exist within that dir.
const srcRoot = dirname(chunk.facadeModuleId);
// Copy all the files we found into the dist dir
await Promise.all(
Array.from(exportedFiles).map(async exportedFile => {
const outputPath = relativePath(srcRoot, exportedFile);
const targetFile = resolvePath(dir, outputPath);
await fs.ensureDir(dirname(targetFile));
await fs.copyFile(exportedFile, targetFile);
}),
);
return;
}
},
options(inputOptions) {
// We're in control of the config ourselves, so these are just checks to
// make sure we don't update the config but forget about the config
// overrides here
const treeshake = inputOptions.treeshake;
if (treeshake !== undefined && typeof treeshake !== 'object') {
throw new Error(
'Expected treeshake input config to be an object or not set',
);
}
if (treeshake?.moduleSideEffects) {
throw new Error('treeshake.moduleSideEffects must not be set');
}
// All external assets are treated as being side-effect free.
//
// This also works around an apparent bug in rollup where the
// `makeAbsoluteExternalsRelative: false` option sometimes caused relative
// asset paths to be rewritten with an incorrect path. They are rewritten
// in the first place because they are being treated as external by this
// plugin, but that seems to be the best way to handle asset files.
const moduleSideEffects: HasModuleSideEffects = id => {
if (filter(id)) {
return false;
}
return true;
};
const origExternal = inputOptions.external;
// We decorate any existing `external` option with our own way of determining
// if a module should be external. The can't use `resolveId`, since asset files
// aren't passed there, might be some better way to do this though.
const external: InputOptions['external'] = (id, importer, isResolved) => {
// Call to inner external option
if (
typeof origExternal === 'function' &&
origExternal(id, importer, isResolved)
) {
return true;
}
if (Array.isArray(origExternal) && origExternal.includes(id)) {
return true;
}
// The piece that we're adding
if (!filter(id)) {
return false;
}
// Confidence check, dunno if this can happen
if (!importer) {
throw new Error(`Unknown importer of file module ${id}`);
}
// Resolve relative imports to the full file URL, for deduping and copying later
const fullId = isResolved ? id : resolvePath(dirname(importer), id);
exportedFiles.add(fullId);
// Treating this module as external from here, meaning rollup won't try to
// put it in the output bundle, but still keep track of the relative imports
// as needed in the output code.
return true;
};
return {
...inputOptions,
external,
treeshake: { ...treeshake, moduleSideEffects },
};
},
} satisfies Plugin;
}
interface CssEntryPointsOptions {
entryPoints: EntryPoint[];
targetDir: string;
}
/**
* Rollup plugin that bundles CSS entry points using postcss-import.
* CSS files declared in package.json exports are processed and emitted
* as part of the Rollup bundle.
*/
export function cssEntryPoints(options: CssEntryPointsOptions): Plugin {
const cssEntries = options.entryPoints.filter(ep => ep.ext === '.css');
// Track output directories we've already emitted CSS to, to avoid duplicates
// when Rollup runs generateBundle multiple times (once per output format)
const generatedFor = new Set<string>();
return {
name: 'backstage-css-entry-points',
async generateBundle(outputOptions, _bundle, isWrite) {
if (!isWrite) {
return;
}
const dir = outputOptions.dir || dirname(outputOptions.file!);
if (generatedFor.has(dir)) {
return;
}
generatedFor.add(dir);
for (const entryPoint of cssEntries) {
const sourcePath = resolvePath(options.targetDir, entryPoint.path);
// Strip the src/ prefix to create an output filename relative to the Rollup output directory
const outputPath = entryPoint.path.replace(/^(\.\/)?src\//, '');
// Read source CSS
const source = await fs.readFile(sourcePath, 'utf8');
// Bundle @import statements using postcss-import
const result = await postcss([postcssImport()]).process(source, {
from: sourcePath,
});
// Emit the bundled CSS as an asset
this.emitFile({
type: 'asset',
fileName: outputPath,
source: result.css,
});
}
},
};
}
@@ -0,0 +1,48 @@
/*
* Copyright 2020 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.
*/
/* We want to maintain the same information as an enum, so we disable the redeclaration warning */
/* eslint-disable @typescript-eslint/no-redeclare */
import { BackstagePackage, BackstagePackageJson } from '@backstage/cli-node';
export const Output = {
esm: 0,
cjs: 1,
types: 2,
} as const;
/**
* @public
*/
export type Output = (typeof Output)[keyof typeof Output];
/**
* @public
*/
export namespace Output {
export type esm = typeof Output.esm;
export type cjs = typeof Output.cjs;
export type types = typeof Output.types;
}
export type BuildOptions = {
logPrefix?: string;
targetDir?: string;
packageJson?: BackstagePackageJson;
outputs: Set<Output>;
minify?: boolean;
workspacePackages: BackstagePackage[];
};
@@ -0,0 +1,55 @@
/*
* 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 { AppConfig } from '@backstage/config';
import HtmlWebpackPlugin from 'html-webpack-plugin';
export class ConfigInjectingHtmlWebpackPlugin extends HtmlWebpackPlugin {
readonly name = 'ConfigInjectingHtmlWebpackPlugin';
readonly #getFrontendAppConfigs: () => AppConfig[];
constructor(
options: HtmlWebpackPlugin.Options,
getFrontendAppConfigs: () => AppConfig[],
) {
super(options);
this.#getFrontendAppConfigs = getFrontendAppConfigs;
}
apply: HtmlWebpackPlugin['apply'] = compiler => {
super.apply(compiler);
compiler.hooks.compilation.tap(this.name, compilation => {
const hooks = HtmlWebpackPlugin.getCompilationHooks(compilation);
hooks.alterAssetTagGroups.tap(this.name, ctx => {
if (ctx.plugin !== this) {
return ctx;
}
return {
...ctx,
headTags: [
...ctx.headTags,
HtmlWebpackPlugin.createHtmlTagObject(
'script',
{ type: 'backstage.io/config' },
`\n${JSON.stringify(this.#getFrontendAppConfigs(), null, 2)}\n`,
),
],
};
});
});
};
}
@@ -0,0 +1,234 @@
/*
* Copyright 2020 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 yn from 'yn';
import fs from 'fs-extra';
import { resolve as resolvePath } from 'node:path';
import { rspack, Configuration, MultiStats } from '@rspack/core';
import {
measureFileSizesBeforeBuild,
printFileSizesAfterBuild,
} from 'react-dev-utils/FileSizeReporter';
import formatWebpackMessages from 'react-dev-utils/formatWebpackMessages';
import { createConfig } from './config';
import { BuildOptions } from './types';
import { resolveBundlingPaths, resolveOptionalBundlingPaths } from './paths';
import chalk from 'chalk';
import { createDetectedModulesEntryPoint } from './packageDetection';
import { createRuntimeSharedDependenciesEntryPoint } from './moduleFederation';
// TODO(Rugvip): Limits from CRA, we might want to tweak these though.
const WARN_AFTER_BUNDLE_GZIP_SIZE = 512 * 1024;
const WARN_AFTER_CHUNK_GZIP_SIZE = 1024 * 1024;
function applyContextToError(error: string, moduleName: string): string {
return `Failed to compile '${moduleName}':\n ${error}`;
}
export async function buildBundle(options: BuildOptions) {
const { statsJsonEnabled, schema: configSchema, webpack } = options;
const paths = resolveBundlingPaths(options);
const publicPaths = await resolveOptionalBundlingPaths({
targetDir: options.targetDir,
entry: 'src/index-public-experimental',
dist: 'dist/public',
});
const commonConfigOptions = {
...options,
checksEnabled: false,
isDev: false,
getFrontendAppConfigs: () => options.frontendAppConfigs,
};
const configs: Configuration[] = [];
if (options.moduleFederationRemote) {
// Package detection is disabled for remote bundles
configs.push(await createConfig(paths, commonConfigOptions));
} else {
const detectedModulesEntryPoint = await createDetectedModulesEntryPoint({
config: options.fullConfig,
targetPath: paths.targetPath,
});
const moduleFederationSharedDependenciesEntryPoint =
await createRuntimeSharedDependenciesEntryPoint({
targetPath: paths.targetPath,
});
configs.push(
await createConfig(paths, {
...commonConfigOptions,
additionalEntryPoints: [
...detectedModulesEntryPoint,
...moduleFederationSharedDependenciesEntryPoint,
],
appMode: publicPaths ? 'protected' : 'public',
}),
);
if (publicPaths) {
console.log(
chalk.yellow(
`⚠️ WARNING: The app /public entry point is an experimental feature that may receive immediate breaking changes.`,
),
);
configs.push(
await createConfig(publicPaths, {
...commonConfigOptions,
appMode: 'public',
}),
);
}
}
const isCi = yn(process.env.CI, { default: false });
const previousFileSizes = await measureFileSizesBeforeBuild(paths.targetDist);
const previousAuthSizes = publicPaths
? await measureFileSizesBeforeBuild(publicPaths.targetDist)
: undefined;
await fs.emptyDir(paths.targetDist);
if (paths.targetPublic) {
await fs.copy(paths.targetPublic, paths.targetDist, {
dereference: true,
filter: file => file !== paths.targetHtml,
});
// If we've got a separate public index entry point, copy public content there too
if (publicPaths) {
await fs.copy(paths.targetPublic, publicPaths.targetDist, {
dereference: true,
filter: file => file !== paths.targetHtml,
});
}
}
if (configSchema) {
await fs.writeJson(
resolvePath(paths.targetDist, '.config-schema.json'),
configSchema.serialize(),
{ spaces: 2 },
);
}
if (webpack) {
console.log(chalk.yellow(`⚠️ WARNING: Using legacy WebPack bundler`));
}
const { stats } = await build(configs, isCi, webpack);
if (!stats) {
throw new Error('No stats returned');
}
const [mainStats, authStats] = stats.stats;
if (statsJsonEnabled) {
// No @types/bfj
await require('bfj').write(
resolvePath(paths.targetDist, 'bundle-stats.json'),
mainStats.toJson(),
);
}
printFileSizesAfterBuild(
mainStats,
previousFileSizes,
paths.targetDist,
WARN_AFTER_BUNDLE_GZIP_SIZE,
WARN_AFTER_CHUNK_GZIP_SIZE,
);
if (publicPaths && previousAuthSizes) {
printFileSizesAfterBuild(
authStats,
previousAuthSizes,
publicPaths.targetDist,
WARN_AFTER_BUNDLE_GZIP_SIZE,
WARN_AFTER_CHUNK_GZIP_SIZE,
);
}
}
async function build(
configs: Configuration[],
isCi: boolean,
webpack?: typeof import('webpack'),
) {
const bundler = (webpack ?? rspack) as typeof rspack;
const stats = await new Promise<MultiStats | undefined>((resolve, reject) => {
bundler(configs, (err, buildStats) => {
if (err) {
if (err.message) {
const { errors } = formatWebpackMessages({
errors: [err.message],
warnings: new Array<string>(),
_showErrors: true,
_showWarnings: true,
});
throw new Error(errors[0]);
} else {
reject(err);
}
} else {
resolve(buildStats);
}
});
});
if (!stats) {
throw new Error('Failed to compile: No stats provided');
}
const serializedStats = stats.toJson({
all: false,
warnings: true,
errors: true,
});
const { errors, warnings } = formatWebpackMessages({
errors: serializedStats.errors,
warnings: serializedStats.warnings,
});
if (errors.length) {
// Only keep the first error. Others are often indicative
// of the same problem, but confuse the reader with noise.
const errorWithContext = applyContextToError(
errors[0],
serializedStats.errors?.[0]?.moduleName ?? '',
);
throw new Error(errorWithContext);
}
if (isCi && warnings.length) {
const warningsWithContext = warnings.map((warning, i) => {
return applyContextToError(
warning,
serializedStats.warnings?.[i]?.moduleName ?? '',
);
});
console.log(
chalk.yellow(
'\nTreating warnings as errors because process.env.CI = true.\n',
),
);
throw new Error(warningsWithContext.join('\n\n'));
}
return { stats };
}
@@ -0,0 +1,397 @@
/*
* Copyright 2020 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 { resolve as resolvePath } from 'node:path';
import { BundlingOptions, ModuleFederationRemoteOptions } from './types';
import { rspack, Configuration } from '@rspack/core';
import { BundlingPaths } from './paths';
import { Config } from '@backstage/config';
import ESLintRspackPlugin from 'eslint-rspack-plugin';
import { TsCheckerRspackPlugin } from 'ts-checker-rspack-plugin';
import HtmlWebpackPlugin from 'html-webpack-plugin';
import ModuleScopePlugin from 'react-dev-utils/ModuleScopePlugin';
import { ModuleFederationPlugin } from '@module-federation/enhanced/rspack';
import fs from 'fs-extra';
import { optimization as optimizationConfig } from './optimization';
import pickBy from 'lodash/pickBy';
import { runOutput, targetPaths } from '@backstage/cli-common';
import { transforms } from './transforms';
const { version } = require('../../../../package.json') as { version: string };
import yn from 'yn';
import { hasReactDomClient } from './hasReactDomClient';
import { createWorkspaceLinkingPlugins } from './linkWorkspaces';
import { ConfigInjectingHtmlWebpackPlugin } from './ConfigInjectingHtmlWebpackPlugin';
export function resolveBaseUrl(
config: Config,
moduleFederationRemote?: ModuleFederationRemoteOptions,
): URL {
const baseUrl = config.getOptionalString('app.baseUrl');
const defaultBaseUrl = moduleFederationRemote
? `http://localhost:${process.env.PORT ?? '3000'}`
: 'http://localhost:3000';
try {
return new URL(baseUrl ?? '/', defaultBaseUrl);
} catch (error) {
throw new Error(`Invalid app.baseUrl, ${error}`);
}
}
export function resolveEndpoint(
config: Config,
moduleFederationRemote?: ModuleFederationRemoteOptions,
): {
host: string;
port: number;
} {
const url = resolveBaseUrl(config, moduleFederationRemote);
return {
host: config.getOptionalString('app.listen.host') ?? url.hostname,
port:
config.getOptionalNumber('app.listen.port') ??
Number(url.port) ??
(url.protocol === 'https:' ? 443 : 80),
};
}
async function readBuildInfo() {
const timestamp = Date.now();
let commit: string | undefined;
try {
commit = await runOutput(['git', 'rev-parse', 'HEAD']);
} catch (error) {
// ignore, see below
}
let gitVersion: string | undefined;
try {
gitVersion = await runOutput(['git', 'describe', '--always']);
} catch (error) {
// ignore, see below
}
if (commit === undefined || gitVersion === undefined) {
console.info(
'NOTE: Did not compute git version or commit hash, could not execute the git command line utility',
);
}
const { version: packageVersion } = await fs.readJson(
targetPaths.resolve('package.json'),
);
return {
cliVersion: version,
gitVersion: gitVersion ?? 'unknown',
packageVersion,
timestamp,
commit: commit ?? 'unknown',
};
}
export async function createConfig(
paths: BundlingPaths,
options: BundlingOptions,
): Promise<Configuration> {
const {
checksEnabled,
isDev,
frontendConfig,
moduleFederationRemote,
publicSubPath = '',
webpack,
} = options;
const { plugins, loaders } = transforms(options);
// Any package that is part of the monorepo but outside the monorepo root dir need
// separate resolution logic.
const validBaseUrl = resolveBaseUrl(frontendConfig, moduleFederationRemote);
let publicPath = validBaseUrl.pathname.replace(/\/$/, '');
if (publicSubPath) {
publicPath = `${publicPath}${publicSubPath}`.replace('//', '/');
}
if (isDev) {
const { host, port } = resolveEndpoint(
options.frontendConfig,
options.moduleFederationRemote,
);
const refreshOptions = {
overlay: {
sockProtocol: 'ws',
sockHost: host,
sockPort: port,
},
} as const;
if (webpack) {
const ReactRefreshPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
plugins.push(new ReactRefreshPlugin(refreshOptions));
} else {
const RspackReactRefreshPlugin = require('@rspack/plugin-react-refresh');
plugins.push(new RspackReactRefreshPlugin(refreshOptions));
}
}
if (checksEnabled) {
const TsCheckerPlugin = webpack
? (require('fork-ts-checker-webpack-plugin') as typeof import('fork-ts-checker-webpack-plugin'))
: TsCheckerRspackPlugin;
const ESLintPlugin = webpack
? (require('eslint-webpack-plugin') as typeof import('eslint-webpack-plugin'))
: ESLintRspackPlugin;
plugins.push(
new TsCheckerPlugin({
typescript: { configFile: paths.targetTsConfig, memoryLimit: 8192 },
}),
new ESLintPlugin({
cache: false, // Cache seems broken
context: paths.targetPath,
files: ['**/*.(ts|tsx|mts|cts|js|jsx|mjs|cjs)'],
}),
);
}
const bundler = webpack ? (webpack as unknown as typeof rspack) : rspack;
// TODO(blam): process is no longer auto polyfilled by webpack in v5.
// we use the provide plugin to provide this polyfill, but lets look
// to remove this eventually!
plugins.push(
new bundler.ProvidePlugin({
process: require.resolve('process/browser'),
Buffer: ['buffer', 'Buffer'],
}),
);
if (!options.moduleFederationRemote) {
const templateOptions = {
meta: {
'backstage-app-mode': options?.appMode ?? 'public',
},
template: paths.targetHtml,
templateParameters: {
publicPath,
config: frontendConfig,
},
};
if (webpack) {
// Config injection via index.html doesn't work across reloads with
// WebPack, so we rely on the APP_CONFIG injection instead
plugins.push(new HtmlWebpackPlugin(templateOptions));
} else {
// With Rspack we inject config via index.html, this is both because we
// can't use APP_CONFIG due to the lack of support for runtime values, but
// also because we are able to do it and it lines up better with what the
// app-backend is doing.
//
// We still use the html plugin from WebPack, since the Rspack one won't
// let us inject complex objects like the config.
plugins.push(
new ConfigInjectingHtmlWebpackPlugin(
templateOptions,
options.getFrontendAppConfigs,
),
);
}
plugins.push(
new HtmlWebpackPlugin({
meta: {
'backstage-app-mode': options?.appMode ?? 'public',
// This is added to be written in the later step, and finally read by the extra entry point
'backstage-public-path': '<%= publicPath %>/',
},
minify: false,
publicPath: '<%= publicPath %>',
filename: 'index.html.tmpl',
template: `${require.resolve('raw-loader')}!${paths.targetHtml}`,
}),
);
}
if (options.moduleFederationRemote) {
const AdaptedModuleFederationPlugin = webpack
? (require('@module-federation/enhanced/webpack')
.ModuleFederationPlugin as unknown as typeof ModuleFederationPlugin)
: ModuleFederationPlugin;
const exposes = options.moduleFederationRemote.exposes
? Object.fromEntries(
Object.entries(options.moduleFederationRemote?.exposes).map(
([k, v]) => [k, resolvePath(paths.targetPath, v)],
),
)
: {
'.': paths.targetEntry,
};
plugins.push(
new AdaptedModuleFederationPlugin({
filename: 'remoteEntry.js',
exposes,
name: options.moduleFederationRemote.name,
runtime: false,
shared: options.moduleFederationRemote.sharedDependencies,
}),
);
}
const buildInfo = await readBuildInfo();
plugins.push(
webpack
? new webpack.DefinePlugin({
'process.env.BUILD_INFO': JSON.stringify(buildInfo),
'process.env.APP_CONFIG': webpack.DefinePlugin.runtimeValue(
() => JSON.stringify(options.getFrontendAppConfigs()),
true,
),
// This allows for conditional imports of react-dom/client, since there's no way
// to check for presence of it in source code without module resolution errors.
'process.env.HAS_REACT_DOM_CLIENT': JSON.stringify(
hasReactDomClient(),
),
})
: new bundler.DefinePlugin({
'process.env.BUILD_INFO': JSON.stringify(buildInfo),
'process.env.APP_CONFIG': JSON.stringify([]), // Inject via index.html instead
// This allows for conditional imports of react-dom/client, since there's no way
// to check for presence of it in source code without module resolution errors.
'process.env.HAS_REACT_DOM_CLIENT': JSON.stringify(
hasReactDomClient(),
),
}),
);
if (options.linkedWorkspace) {
plugins.push(
...(await createWorkspaceLinkingPlugins(
bundler,
options.linkedWorkspace,
)),
);
}
// These files are required by the transpiled code when using React Refresh.
// They need to be excluded to the module scope plugin which ensures that files
// that exist in the package are required.
const reactRefreshFiles = webpack
? [
require.resolve(
'@pmmmwh/react-refresh-webpack-plugin/lib/runtime/RefreshUtils.js',
),
require.resolve(
'@pmmmwh/react-refresh-webpack-plugin/overlay/index.js',
),
require.resolve('react-refresh'),
]
: [];
const mode = isDev ? 'development' : 'production';
const optimization = optimizationConfig(options);
return {
mode,
profile: false,
...(isDev
? {
watchOptions: {
ignored: /node_modules\/(?!__backstage-autodetected-plugins__)/,
},
}
: {}),
optimization,
bail: false,
performance: {
hints: false, // we check the gzip size instead
},
devtool: isDev ? 'eval-cheap-module-source-map' : 'source-map',
context: paths.targetPath,
entry: [
require.resolve('@backstage/cli/config/webpack-public-path'),
...(options.additionalEntryPoints ?? []),
paths.targetEntry,
],
resolve: {
extensions: ['.ts', '.tsx', '.mjs', '.js', '.jsx', '.json', '.wasm'],
mainFields: ['browser', 'module', 'main'],
fallback: {
...pickBy(require('node-stdlib-browser')),
module: false,
dgram: false,
dns: false,
fs: false,
http2: false,
net: false,
tls: false,
child_process: false,
/* new ignores */
path: false,
https: false,
http: false,
util: require.resolve('util/'),
},
// FIXME: see also https://github.com/web-infra-dev/rspack/issues/3408
...(webpack && {
plugins: [
new ModuleScopePlugin(
[paths.targetSrc, paths.targetDev],
[paths.targetPackageJson, ...reactRefreshFiles],
),
],
}),
},
module: {
rules: loaders,
},
output: {
uniqueName: options.moduleFederationRemote?.name,
path: paths.targetDist,
publicPath: options.moduleFederationRemote ? 'auto' : `${publicPath}/`,
filename: isDev ? '[name].js' : 'static/[name].[contenthash:8].js',
chunkFilename: isDev
? '[name].chunk.js'
: 'static/[name].[contenthash:8].chunk.js',
...(isDev
? {
devtoolModuleFilenameTemplate: (info: any) =>
`file:///${resolvePath(info.absoluteResourcePath).replace(
/\\/g,
'/',
)}`,
}
: {}),
},
experiments: {
lazyCompilation: yn(process.env.EXPERIMENTAL_LAZY_COMPILATION),
...(!webpack && {
// We're still using `style-loader` for custom `insert` option
css: false,
}),
},
plugins,
};
}
@@ -0,0 +1,28 @@
/*
* Copyright 2023 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 { targetPaths } from '@backstage/cli-common';
export function hasReactDomClient() {
try {
require.resolve('react-dom/client', {
paths: [targetPaths.dir],
});
return true;
} catch {
return false;
}
}
@@ -0,0 +1,19 @@
/*
* Copyright 2020 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 { buildBundle } from './bundle';
export { getModuleFederationRemoteOptions } from './moduleFederation';
export { serveBundle } from './server';
@@ -0,0 +1,60 @@
/*
* 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 { relative as relativePath } from 'node:path';
import { getPackages } from '@manypkg/get-packages';
import { rspack } from '@rspack/core';
import { targetPaths } from '@backstage/cli-common';
/**
* This returns of collection of plugins that links a separate workspace into
* the target one. Any packages that are present in the linked workspaces will
* always be used in place of the ones in the target workspace, with the exception
* of react and react-dom which are always resolved from the target workspace.
*/
export async function createWorkspaceLinkingPlugins(
bundler: typeof rspack,
workspace: string,
) {
const { packages: linkedPackages, root: linkedRoot } = await getPackages(
workspace,
);
// Matches all packages in the linked workspaces, as well as sub-path exports from them
const replacementRegex = new RegExp(
`^(?:${linkedPackages
.map(pkg => pkg.packageJson.name)
.join('|')})(?:/.*)?$`,
);
return [
// Any imports of a package that is present in the linked workspace will
// be redirected to be resolved within the context of the linked workspace
new bundler.NormalModuleReplacementPlugin(replacementRegex, resource => {
resource.context = linkedRoot.dir;
}),
// react and react-dom are always resolved from the target directory
// Note: this often requires that the linked and target workspace use the same versions of React
new bundler.NormalModuleReplacementPlugin(
/^react(?:-router)?(?:-dom)?$/,
resource => {
if (!relativePath(linkedRoot.dir, resource.context).startsWith('..')) {
resource.context = targetPaths.dir;
}
},
),
];
}
@@ -0,0 +1,158 @@
/*
* Copyright 2025 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 { prepareRuntimeSharedDependenciesScript } from './moduleFederation';
import { BACKSTAGE_RUNTIME_SHARED_DEPENDENCIES_GLOBAL } from '@backstage/module-federation-common';
const GLOBAL = BACKSTAGE_RUNTIME_SHARED_DEPENDENCIES_GLOBAL;
describe('prepareRuntimeSharedDependenciesScript', () => {
it('should generate script with a single dependency', () => {
const result = prepareRuntimeSharedDependenciesScript({
react: {
version: '18.2.0',
requiredVersion: '*',
singleton: true,
eager: false,
},
});
expect(result).toBe(`window['${GLOBAL}'] = {
"items": [
{
"name": "react",
"version": "18.2.0",
"lib": () => import("react"),
"shareConfig": {
"singleton": true,
"requiredVersion": "*",
"eager": false
}
}
],
"version": "v1"
};`);
});
it('should generate script with multiple dependencies', () => {
const result = prepareRuntimeSharedDependenciesScript({
react: {
version: '18.2.0',
requiredVersion: '*',
singleton: true,
eager: true,
},
'react-dom': {
version: '18.2.0',
requiredVersion: '*',
singleton: true,
eager: true,
},
lodash: {
version: '4.17.21',
requiredVersion: '*',
singleton: true,
eager: false,
},
});
expect(result).toBe(`window['${GLOBAL}'] = {
"items": [
{
"name": "react",
"version": "18.2.0",
"lib": () => import("react"),
"shareConfig": {
"singleton": true,
"requiredVersion": "*",
"eager": true
}
},
{
"name": "react-dom",
"version": "18.2.0",
"lib": () => import("react-dom"),
"shareConfig": {
"singleton": true,
"requiredVersion": "*",
"eager": true
}
},
{
"name": "lodash",
"version": "4.17.21",
"lib": () => import("lodash"),
"shareConfig": {
"singleton": true,
"requiredVersion": "*",
"eager": false
}
}
],
"version": "v1"
};`);
});
it('should handle custom requiredVersion', () => {
const result = prepareRuntimeSharedDependenciesScript({
react: {
version: '18.2.0',
requiredVersion: '^18.0.0',
singleton: true,
eager: false,
},
});
expect(result).toContain('"requiredVersion": "^18.0.0"');
});
it('should handle scoped package names', () => {
const result = prepareRuntimeSharedDependenciesScript({
'@backstage/core-plugin-api': {
version: '1.0.0',
requiredVersion: '*',
singleton: true,
eager: false,
},
});
expect(result).toContain('"name": "@backstage/core-plugin-api"');
expect(result).toContain(
'"lib": () => import("@backstage/core-plugin-api")',
);
});
it('should handle empty dependencies', () => {
const result = prepareRuntimeSharedDependenciesScript({});
expect(result).toBe(`window['${GLOBAL}'] = {
"items": [],
"version": "v1"
};`);
});
it('should throw if version is missing', () => {
expect(() =>
prepareRuntimeSharedDependenciesScript({
react: {
requiredVersion: '*',
singleton: true,
eager: false,
},
}),
).toThrow("Version is required for shared dependency 'react'");
});
});
@@ -0,0 +1,204 @@
/*
* 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 { ModuleFederationRemoteOptions } from './types';
import { BackstagePackageJson } from '@backstage/cli-node';
import { readEntryPoints } from '../entryPoints';
import {
createTypeDistProject,
getEntryPointDefaultFeatureType,
} from '../typeDistProject';
import {
BACKSTAGE_RUNTIME_SHARED_DEPENDENCIES_GLOBAL,
defaultRemoteSharedDependencies,
defaultHostSharedDependencies,
HostSharedDependencies,
RuntimeSharedDependenciesGlobal,
} from '@backstage/module-federation-common';
import { dirname, join as joinPath, resolve as resolvePath } from 'node:path';
import fs from 'fs-extra';
import chokidar from 'chokidar';
import PQueue from 'p-queue';
// Remote modules management utilities
export async function getModuleFederationRemoteOptions(
packageJson: BackstagePackageJson,
packageDir: string,
): Promise<ModuleFederationRemoteOptions | undefined> {
let exposes: ModuleFederationRemoteOptions['exposes'];
const packageRole = packageJson.backstage?.role;
if (packageJson.exports && packageRole) {
const project = await createTypeDistProject();
exposes = Object.fromEntries(
readEntryPoints(packageJson)
.filter(ep => {
if (ep.mount === './package.json') {
return false;
}
if (ep.mount === '.') {
return true;
}
// Include this additional entry point in the exposed modules
// if it exports a feature as default export.
return (
getEntryPointDefaultFeatureType(
packageRole,
packageDir,
project,
ep.path,
) !== null
);
})
.map(ep => [ep.mount, ep.path]),
);
}
return {
// The default output mode requires the name to be a usable as a code
// symbol, there might be better options here but for now we need to
// sanitize the name.
name: packageJson.name
.replaceAll('@', '')
.replaceAll('/', '__')
.replaceAll('-', '_'),
exposes,
sharedDependencies: defaultRemoteSharedDependencies(),
};
}
// Module federation host management utilities
/**
* Prepares the runtime shared dependencies script for the module federation host,
* which will be written by the CLI into a Javascript file added as an additional entry point for the frontend bundler.
* This script is used in the browser to build the list of shared dependencies provided to the module federation runtime.
*
* @internal
*/
export function prepareRuntimeSharedDependenciesScript(
hostSharedDependencies: HostSharedDependencies,
) {
const items = Object.entries(hostSharedDependencies).map(
([name, sharedDep]) => {
if (!sharedDep.version) {
throw new Error(`Version is required for shared dependency '${name}'`);
}
return {
name,
version: sharedDep.version,
lib: name as unknown as () => Promise<unknown>, // Coverted into import below
shareConfig: {
singleton: sharedDep.singleton,
requiredVersion: sharedDep.requiredVersion,
eager: sharedDep.eager,
},
};
},
);
return `window['${BACKSTAGE_RUNTIME_SHARED_DEPENDENCIES_GLOBAL}'] = ${JSON.stringify(
{ items, version: 'v1' } satisfies RuntimeSharedDependenciesGlobal,
null,
2,
).replace(
/"lib": ("[^"]+")/gm,
(_, name) => `"lib": () => import(${name})`,
)};`;
}
const RUNTIME_SHARED_DEPENDENCIES_MODULE_NAME =
'__backstage-module-federation-runtime-shared-dependencies__';
// Make sure we're not issuing multiple writes at the same time, which can cause partial overwrites
const writeQueue = new PQueue({ concurrency: 1 });
async function writeRuntimeSharedDependenciesModule(
targetPath: string,
runtimeSharedDependencies: HostSharedDependencies,
) {
const script = prepareRuntimeSharedDependenciesScript(
runtimeSharedDependencies,
);
await writeQueue.add(async () => {
const path = joinPath(
targetPath,
'node_modules',
`${RUNTIME_SHARED_DEPENDENCIES_MODULE_NAME}.js`,
);
await fs.ensureDir(dirname(path));
await fs.writeFile(path, script);
});
}
function resolveSharedDependencyVersions(
targetPath: string,
hostSharedDependencies: HostSharedDependencies,
): HostSharedDependencies {
return Object.fromEntries(
Object.entries(hostSharedDependencies)
.filter(([_, sharedDep]) => sharedDep !== undefined)
.flatMap(([importPath, sharedDep]) => {
// Remove any sub-path exports from the import path
const moduleName = importPath.startsWith('@')
? importPath.split('/').slice(0, 2).join('/')
: importPath.split('/')[0];
let version: string;
try {
const packagePath = require.resolve(`${moduleName}/package.json`, {
paths: [targetPath],
});
version = require(packagePath).version;
} catch (e) {
console.log(
`Skipping module federation shared dependency '${importPath}' because it could not be resolved.`,
);
return [];
}
return [[importPath, { ...sharedDep, version }]];
}),
);
}
export async function createRuntimeSharedDependenciesEntryPoint(options: {
targetPath: string;
watch?: () => void;
}): Promise<string[]> {
const { targetPath, watch } = options;
const doWriteSharedDependenciesModule = async () => {
const sharedDependencies = defaultHostSharedDependencies();
await writeRuntimeSharedDependenciesModule(
targetPath,
resolveSharedDependencyVersions(targetPath, sharedDependencies),
);
};
if (watch) {
const watcher = chokidar.watch(resolvePath(targetPath, 'package.json'));
watcher.on('change', async () => {
await doWriteSharedDependenciesModule();
watch();
});
}
await doWriteSharedDependenciesModule();
return [RUNTIME_SHARED_DEPENDENCIES_MODULE_NAME];
}
@@ -0,0 +1,95 @@
/*
* Copyright 2020 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 { BundlingOptions } from './types';
import {
SwcJsMinimizerRspackPlugin,
LightningCssMinimizerRspackPlugin,
RspackOptionsNormalized,
} from '@rspack/core';
export const optimization = (
options: BundlingOptions,
): RspackOptionsNormalized['optimization'] => {
const { isDev, webpack } = options;
const MinifyPlugin = webpack
? require('esbuild-loader').EsbuildPlugin
: SwcJsMinimizerRspackPlugin;
return {
minimize: !isDev,
minimizer: [
new MinifyPlugin({
target: 'ES2023',
format: 'iife',
exclude: 'remoteEntry.js',
}),
// Avoid iife wrapping of module federation remote entry as it breaks the variable assignment
new MinifyPlugin({
target: 'ES2023',
format: undefined,
include: 'remoteEntry.js',
}),
webpack ? undefined : new LightningCssMinimizerRspackPlugin(),
],
runtimeChunk: 'single',
splitChunks: {
automaticNameDelimiter: '-',
cacheGroups: {
default: false,
// Put all vendor code needed for initial page load in individual files if they're big
// enough, if they're smaller they end up in the main
packages: {
chunks: 'initial',
test(module: any) {
return Boolean(
module?.resource?.match(/[\\/]node_modules[\\/](.*?)([\\/]|$)/),
);
},
name(module: any) {
// get the name. E.g. node_modules/packageName/not/this/part.js
// or node_modules/packageName
const packageName = module.resource.match(
/[\\/]node_modules[\\/](.*?)([\\/]|$)/,
)[1];
// npm package names are URL-safe, but some servers don't like @ symbols
return packageName.replace('@', '');
},
filename: isDev
? 'module-[name].js'
: 'static/module-[name].[contenthash:8].js',
priority: 10,
minSize: 100000,
minChunks: 1,
...(webpack && {
maxAsyncRequests: Infinity,
maxInitialRequests: Infinity,
}),
}, // filename is not included in type, but we need it
// Group together the smallest modules
vendor: {
chunks: 'initial',
test: /[\\/]node_modules[\\/]/,
name: 'vendor',
priority: 5,
enforce: true,
},
},
},
};
};
@@ -0,0 +1,175 @@
/*
* Copyright 2020 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 { BackstagePackageJson } from '@backstage/cli-node';
import { Config, ConfigReader } from '@backstage/config';
import chokidar from 'chokidar';
import fs from 'fs-extra';
import PQueue from 'p-queue';
import { dirname, join as joinPath, resolve as resolvePath } from 'node:path';
import { targetPaths } from '@backstage/cli-common';
const DETECTED_MODULES_MODULE_NAME = '__backstage-autodetected-plugins__';
interface PackageDetectionConfig {
include?: string[];
exclude?: string[];
}
function readPackageDetectionConfig(
config: Config,
): PackageDetectionConfig | undefined {
const packages = config.getOptional('app.packages');
if (packages === undefined || packages === null) {
return undefined;
}
if (typeof packages === 'string') {
if (packages !== 'all') {
throw new Error(
`Invalid app.packages mode, got '${packages}', expected 'all'`,
);
}
return {};
}
if (typeof packages !== 'object' || Array.isArray(packages)) {
throw new Error("Invalid config at 'app.packages', expected object");
}
const packagesConfig = new ConfigReader(packages, 'app.packages');
return {
include: packagesConfig.getOptionalStringArray('include'),
exclude: packagesConfig.getOptionalStringArray('exclude'),
};
}
async function detectPackages(
targetPath: string,
{ include, exclude }: PackageDetectionConfig,
) {
const pkg: BackstagePackageJson = await fs.readJson(
resolvePath(targetPath, 'package.json'),
);
return Object.keys(pkg.dependencies ?? {}).flatMap(depName => {
if (exclude?.includes(depName)) {
return [];
}
if (include && !include.includes(depName)) {
return [];
}
try {
const depPackageJson: BackstagePackageJson = require(require.resolve(
`${depName}/package.json`,
{ paths: [targetPath] },
));
if (
['frontend-plugin', 'frontend-plugin-module'].includes(
depPackageJson.backstage?.role ?? '',
)
) {
// Include alpha entry point if available. If there's no default export it will be ignored
const exp = depPackageJson.exports;
if (exp && typeof exp === 'object' && './alpha' in exp) {
return [
{ name: depName, import: depName },
{ name: depName, export: './alpha', import: `${depName}/alpha` },
];
}
return [{ name: depName, import: depName }];
}
} catch {
/* ignore packages that don't make package.json available */
}
return [];
});
}
// Make sure we're not issuing multiple writes at the same time, which can cause partial overwrites
const writeQueue = new PQueue({ concurrency: 1 });
async function writeDetectedPackagesModule(
targetPath: string,
pkgs: { name: string; export?: string; import: string }[],
) {
const requirePackageScript = pkgs
?.map(
pkg =>
`{ name: ${JSON.stringify(pkg.name)}, export: ${JSON.stringify(
pkg.export,
)}, default: require('${pkg.import}').default }`,
)
.join(',');
await writeQueue.add(async () => {
const detectedModulesPath = joinPath(
targetPath,
'node_modules',
`${DETECTED_MODULES_MODULE_NAME}.js`,
);
await fs.ensureDir(dirname(detectedModulesPath));
await fs.writeFile(
detectedModulesPath,
`window['__@backstage/discovered__'] = { modules: [${requirePackageScript}] };`,
);
});
}
export async function createDetectedModulesEntryPoint(options: {
config: Config;
targetPath: string;
watch?: () => void;
}): Promise<string[]> {
const { config, watch, targetPath } = options;
const detectionConfig = readPackageDetectionConfig(config);
if (!detectionConfig) {
return [];
}
// Previous versions of the CLI would write the detected modules file to the
// root `node_modules`, this makes sure that doesn't exist to minimize risk of conflicts
const legacyDetectedModulesPath = joinPath(
targetPaths.rootDir,
'node_modules',
`${DETECTED_MODULES_MODULE_NAME}.js`,
);
if (await fs.pathExists(legacyDetectedModulesPath)) {
await fs.remove(legacyDetectedModulesPath);
}
if (watch) {
const watcher = chokidar.watch(resolvePath(targetPath, 'package.json'));
watcher.on('change', async () => {
await writeDetectedPackagesModule(
targetPath,
await detectPackages(targetPath, detectionConfig),
);
watch();
});
}
await writeDetectedPackagesModule(
targetPath,
await detectPackages(targetPath, detectionConfig),
);
return [DETECTED_MODULES_MODULE_NAME];
}
@@ -0,0 +1,90 @@
/*
* Copyright 2020 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 fs from 'fs-extra';
import { resolve as resolvePath } from 'node:path';
import { targetPaths } from '@backstage/cli-common';
export type BundlingPathsOptions = {
// bundle entrypoint, e.g. 'src/index'
entry: string;
// Target directory, defaulting to targetPaths.dir
targetDir?: string;
// Relative dist directory, defaulting to 'dist'
dist?: string;
};
export function resolveBundlingPaths(options: BundlingPathsOptions) {
const { entry, targetDir = targetPaths.dir } = options;
const resolveTargetModule = (pathString: string) => {
for (const ext of ['mjs', 'js', 'ts', 'tsx', 'jsx']) {
const filePath = resolvePath(targetDir, `${pathString}.${ext}`);
if (fs.pathExistsSync(filePath)) {
return filePath;
}
}
return resolvePath(targetDir, `${pathString}.js`);
};
let targetPublic = undefined;
let targetHtml = resolvePath(targetDir, 'public/index.html');
// Prefer public folder
if (fs.pathExistsSync(targetHtml)) {
targetPublic = resolvePath(targetDir, 'public');
} else {
targetHtml = resolvePath(targetDir, `${entry}.html`);
if (!fs.pathExistsSync(targetHtml)) {
/* eslint-disable-next-line no-restricted-syntax */
targetHtml = require.resolve(
'@backstage/cli/templates/serve_index.html',
);
}
}
// Backend plugin dev run file
const targetRunFile = resolvePath(targetDir, 'src/run.ts');
const runFileExists = fs.pathExistsSync(targetRunFile);
return {
targetHtml,
targetPublic,
targetPath: resolvePath(targetDir, '.'),
targetRunFile: runFileExists ? targetRunFile : undefined,
targetDist: resolvePath(targetDir, options.dist ?? 'dist'),
targetAssets: resolvePath(targetDir, 'assets'),
targetSrc: resolvePath(targetDir, 'src'),
targetDev: resolvePath(targetDir, 'dev'),
targetEntry: resolveTargetModule(entry),
targetTsConfig: targetPaths.resolveRoot('tsconfig.json'),
targetPackageJson: resolvePath(targetDir, 'package.json'),
rootNodeModules: targetPaths.resolveRoot('node_modules'),
root: targetPaths.rootDir,
};
}
export async function resolveOptionalBundlingPaths(
options: BundlingPathsOptions,
) {
const resolvedPaths = resolveBundlingPaths(options);
if (await fs.pathExists(resolvedPaths.targetEntry)) {
return resolvedPaths;
}
return undefined;
}
export type BundlingPaths = ReturnType<typeof resolveBundlingPaths>;
@@ -0,0 +1,293 @@
/*
* Copyright 2020 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 { AppConfig } from '@backstage/config';
import chalk from 'chalk';
import fs from 'fs-extra';
import { resolve as resolvePath } from 'node:path';
import openBrowser from 'react-dev-utils/openBrowser';
import { rspack } from '@rspack/core';
import { RspackDevServer } from '@rspack/dev-server';
import { targetPaths } from '@backstage/cli-common';
import { loadCliConfig } from '../config';
import { createConfig, resolveBaseUrl, resolveEndpoint } from './config';
import { createDetectedModulesEntryPoint } from './packageDetection';
import { resolveBundlingPaths, resolveOptionalBundlingPaths } from './paths';
import { ServeOptions } from './types';
import { createRuntimeSharedDependenciesEntryPoint } from './moduleFederation';
export async function serveBundle(options: ServeOptions) {
const paths = resolveBundlingPaths(options);
const targetPkg = await fs.readJson(paths.targetPackageJson);
if (options.verifyVersions) {
if (
targetPkg.dependencies?.['react-router']?.includes('beta') ||
targetPkg.dependencies?.['react-router-dom']?.includes('beta')
) {
// eslint-disable-next-line no-console
console.warn(
chalk.yellow(`
DEPRECATION WARNING: React Router Beta is deprecated and support for it will be removed in a future release.
Please migrate to use React Router v6 stable.
See https://backstage.io/docs/tutorials/react-router-stable-migration
`),
);
}
}
checkReactVersion();
const { name } = await fs.readJson(
resolvePath(options.targetDir ?? targetPaths.dir, 'package.json'),
);
let devServer: RspackDevServer | undefined = undefined;
let latestFrontendAppConfigs: AppConfig[] = [];
/** Triggers a full reload of all clients */
const triggerReload = () => {
if (devServer) {
devServer.invalidate();
// For the Rspack server it's not enough to invalidate, we also need to
// tell the browser to reload, which we do with a 'static-changed' message
if (!process.env.LEGACY_WEBPACK_BUILD) {
devServer.sendMessage(
devServer.webSocketServer?.clients ?? [],
'static-changed',
);
}
}
};
const cliConfig = await loadCliConfig({
args: options.configPaths,
targetDir: options.targetDir,
fromPackage: name,
withFilteredKeys: true,
watch(appConfigs) {
latestFrontendAppConfigs = appConfigs;
triggerReload();
},
});
latestFrontendAppConfigs = cliConfig.frontendAppConfigs;
const appBaseUrl = cliConfig.frontendConfig.getOptionalString('app.baseUrl');
const backendBaseUrl =
cliConfig.frontendConfig.getOptionalString('backend.baseUrl');
if (appBaseUrl && appBaseUrl === backendBaseUrl) {
console.log(
chalk.yellow(
`⚠️ Conflict between app baseUrl and backend baseUrl:
app.baseUrl: ${appBaseUrl}
backend.baseUrl: ${backendBaseUrl}
Must have unique hostname and/or ports.
This can be resolved by changing app.baseUrl and backend.baseUrl to point to their respective local development ports.
`,
),
);
}
const { frontendConfig, fullConfig } = cliConfig;
const url = resolveBaseUrl(frontendConfig, options.moduleFederationRemote);
const { host, port } = resolveEndpoint(
frontendConfig,
options.moduleFederationRemote,
);
const detectedModulesEntryPoint = await createDetectedModulesEntryPoint({
config: fullConfig,
targetPath: paths.targetPath,
watch() {
triggerReload();
},
});
const moduleFederationSharedDependenciesEntryPoint =
await createRuntimeSharedDependenciesEntryPoint({
targetPath: paths.targetPath,
watch() {
triggerReload();
},
});
const webpack = process.env.LEGACY_WEBPACK_BUILD
? (require('webpack') as typeof import('webpack'))
: undefined;
const commonConfigOptions = {
...options,
checksEnabled: options.checksEnabled,
isDev: true,
baseUrl: url,
frontendConfig,
webpack,
getFrontendAppConfigs: () => {
return latestFrontendAppConfigs;
},
};
const config = await createConfig(paths, {
...commonConfigOptions,
additionalEntryPoints: [
...detectedModulesEntryPoint,
...moduleFederationSharedDependenciesEntryPoint,
],
moduleFederationRemote: options.moduleFederationRemote,
});
const bundler = (webpack ?? rspack) as typeof rspack;
const DevServer: typeof RspackDevServer = webpack
? require('webpack-dev-server')
: RspackDevServer;
if (webpack) {
console.log(chalk.yellow(`⚠️ WARNING: Using legacy WebPack dev server.`));
}
const publicPaths = await resolveOptionalBundlingPaths({
entry: 'src/index-public-experimental',
dist: 'dist/public',
});
if (publicPaths) {
console.log(
chalk.yellow(
`⚠️ WARNING: The app /public entry point is an experimental feature that may receive immediate breaking changes.`,
),
);
}
const compiler = publicPaths
? bundler([config, await createConfig(publicPaths, commonConfigOptions)])
: bundler(config);
devServer = new DevServer(
{
hot: !process.env.CI,
devMiddleware: {
publicPath: config.output?.publicPath as string,
stats: 'errors-warnings',
},
static: paths.targetPublic
? {
publicPath: config.output?.publicPath as string,
directory: paths.targetPublic,
}
: undefined,
historyApiFallback: options.moduleFederationRemote
? false
: {
// Paths with dots should still use the history fallback.
// See https://github.com/facebookincubator/create-react-app/issues/387.
disableDotRule: true,
// The index needs to be rewritten relative to the new public path, including subroutes.
index: `${config.output?.publicPath}index.html`,
},
server:
url.protocol === 'https:'
? {
type: 'https',
options: {
cert: fullConfig.getOptionalString(
'app.https.certificate.cert',
),
key: fullConfig.getOptionalString('app.https.certificate.key'),
},
}
: {},
host,
port,
proxy: targetPkg.proxy,
// When the dev server is behind a proxy, the host and public hostname differ
allowedHosts: [url.hostname],
client: {
webSocketURL: { hostname: host, port },
},
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers':
'X-Requested-With, content-type, Authorization',
},
},
compiler,
);
await new Promise<void>(async (resolve, reject) => {
if (devServer) {
devServer.startCallback((err?: Error) => {
if (err) {
reject(err);
return;
}
resolve();
});
} else {
resolve();
}
});
if (!options.skipOpenBrowser) {
openBrowser(url.href);
}
const waitForExit = async () => {
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
process.on(signal, () => {
devServer?.stop();
// exit instead of resolve. The process is shutting down and resolving a promise here logs an error
process.exit();
});
}
// Block indefinitely and wait for the interrupt signal
return new Promise(() => {});
};
return waitForExit;
}
function checkReactVersion() {
try {
// Make sure we're looking at the root of the target repo
const reactPkgPath = require.resolve('react/package.json', {
paths: [targetPaths.rootDir],
});
const reactPkg = require(reactPkgPath);
if (reactPkg.version.startsWith('16.')) {
console.log(
chalk.yellow(
`
⚠️ ⚠️
⚠️ You are using React version 16, which is deprecated for use in Backstage. ⚠️
⚠️ Please upgrade to React 17 by updating your packages/app dependencies. ⚠️
⚠️ ⚠️
`,
),
);
}
} catch {
/* ignored */
}
}
@@ -0,0 +1,196 @@
/*
* Copyright 2020 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 {
RuleSetRule,
RspackPluginInstance,
CssExtractRspackPlugin,
WebpackPluginInstance,
} from '@rspack/core';
type Transforms = {
loaders: RuleSetRule[];
plugins: Array<RspackPluginInstance | WebpackPluginInstance>;
};
type TransformOptions = {
isDev: boolean;
isBackend?: boolean;
webpack?: typeof import('webpack').webpack;
};
export const transforms = (options: TransformOptions): Transforms => {
const { isDev, isBackend, webpack } = options;
const CssExtractPlugin: typeof CssExtractRspackPlugin = webpack
? (require('mini-css-extract-plugin') as unknown as typeof CssExtractRspackPlugin)
: CssExtractRspackPlugin;
// This ensures that styles inserted from the style-loader and any
// async style chunks are always given lower priority than JSS styles.
// Note that this function is stringified and executed in the browser
// after transpilation, so stick to simple syntax
function insertBeforeJssStyles(element: any) {
const head = document.head;
// This makes sure that any style elements we insert get put before the
// dynamic styles from JSS, such as the ones from `makeStyles()`.
// TODO(Rugvip): This will likely break in material-ui v5, keep an eye on it.
const firstJssNode = head.querySelector('style[data-jss]');
if (!firstJssNode) {
head.appendChild(element);
} else {
head.insertBefore(element, firstJssNode);
}
}
const loaders = [
{
test: /\.(tsx?)$/,
exclude: /node_modules/,
use: [
{
loader: webpack
? require.resolve('swc-loader')
: 'builtin:swc-loader',
options: {
jsc: {
target: 'es2023',
externalHelpers: !isBackend,
parser: {
syntax: 'typescript',
tsx: !isBackend,
dynamicImport: true,
},
transform: {
react: isBackend
? undefined
: {
runtime: 'automatic',
refresh: isDev,
},
},
},
},
},
],
},
{
test: /\.(jsx?|mjs|cjs)$/,
exclude: /node_modules/,
use: [
{
loader: webpack
? require.resolve('swc-loader')
: 'builtin:swc-loader',
options: {
jsc: {
target: 'es2023',
externalHelpers: !isBackend,
parser: {
syntax: 'ecmascript',
jsx: !isBackend,
dynamicImport: true,
},
transform: {
react: isBackend
? undefined
: {
runtime: 'automatic',
refresh: isDev,
},
},
},
},
},
],
},
{
test: /\.(js|mjs|cjs)$/,
resolve: {
fullySpecified: false,
},
},
{
test: [
/\.bmp$/,
/\.gif$/,
/\.jpe?g$/,
/\.png$/,
/\.frag$/,
/\.vert$/,
{ and: [/\.svg$/, { not: [/\.icon\.svg$/] }] },
/\.xml$/,
/\.ico$/,
/\.webp$/,
],
type: 'asset/resource',
generator: {
filename: 'static/[name].[hash:8][ext]',
},
},
{
test: /\.(eot|woff|woff2|ttf)$/i,
type: 'asset/resource',
generator: {
filename: 'static/[name].[hash][ext][query]',
},
},
{
test: /\.ya?ml$/,
use: require.resolve('yml-loader'),
},
{
include: /\.(md)$/,
type: 'asset/resource',
generator: {
filename: 'static/[name].[hash][ext][query]',
},
},
{
test: /\.css$/i,
use: [
isDev
? {
loader: require.resolve('style-loader'),
options: {
insert: insertBeforeJssStyles,
},
}
: CssExtractPlugin.loader,
{
loader: require.resolve('css-loader'),
options: {
sourceMap: true,
},
},
],
},
];
const plugins = new Array<RspackPluginInstance | WebpackPluginInstance>();
if (!isDev) {
plugins.push(
new CssExtractPlugin({
filename: 'static/[name].[contenthash:8].css',
chunkFilename: 'static/[name].[id].[contenthash:8].css',
insert: insertBeforeJssStyles, // Only applies to async chunks
}),
);
}
return { loaders, plugins };
};
@@ -0,0 +1,80 @@
/*
* Copyright 2020 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 { AppConfig, Config } from '@backstage/config';
import { BundlingPathsOptions } from './paths';
import { ConfigSchema } from '@backstage/config-loader';
import { RemoteSharedDependencies } from '@backstage/module-federation-common';
export type ModuleFederationRemoteOptions = {
// Unique name for this module federation bundle
name: string;
exposes?: {
/**
* Modules that should be exposed by this container.
*/
[k: string]: string;
};
sharedDependencies: RemoteSharedDependencies;
};
export type BundlingOptions = {
checksEnabled: boolean;
isDev: boolean;
frontendConfig: Config;
getFrontendAppConfigs(): AppConfig[];
additionalEntryPoints?: string[];
// Path to append to the detected public path, e.g. '/public'
publicSubPath?: string;
// Mode that the app is running in, 'protected' or 'public', default is 'public'
appMode?: string;
// An external linked workspace to include in the bundling
linkedWorkspace?: string;
moduleFederationRemote?: ModuleFederationRemoteOptions;
webpack?: typeof import('webpack');
};
export type ServeOptions = BundlingPathsOptions & {
targetDir?: string;
checksEnabled: boolean;
configPaths: string[];
verifyVersions?: boolean;
skipOpenBrowser?: boolean;
moduleFederationRemote?: ModuleFederationRemoteOptions;
// An external linked workspace to include in the bundling
linkedWorkspace?: string;
};
export type BuildOptions = BundlingPathsOptions & {
// Target directory, defaulting to paths.targetDir
targetDir?: string;
statsJsonEnabled: boolean;
schema?: ConfigSchema;
frontendConfig: Config;
frontendAppConfigs: AppConfig[];
fullConfig: Config;
moduleFederationRemote?: ModuleFederationRemoteOptions;
webpack?: typeof import('webpack');
};
export type BackendBundlingOptions = {
checksEnabled: boolean;
isDev: boolean;
inspectEnabled: boolean;
inspectBrkEnabled: boolean;
require?: string;
webpack?: typeof import('webpack');
};
+128
View File
@@ -0,0 +1,128 @@
/*
* Copyright 2020 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 { ConfigSources, loadConfigSchema } from '@backstage/config-loader';
import { AppConfig, ConfigReader } from '@backstage/config';
import { targetPaths } from '@backstage/cli-common';
import { getPackages } from '@manypkg/get-packages';
import { PackageGraph } from '@backstage/cli-node';
import { resolve as resolvePath } from 'node:path';
type Options = {
args: string[];
targetDir?: string;
fromPackage?: string;
withFilteredKeys?: boolean;
watch?: (newFrontendAppConfigs: AppConfig[]) => void;
};
export async function loadCliConfig(options: Options) {
const targetDir = options.targetDir ?? targetPaths.dir;
const { packages } = await getPackages(targetDir);
let localPackageNames;
if (options.fromPackage) {
if (packages.length) {
const graph = PackageGraph.fromPackages(packages);
localPackageNames = Array.from(
graph.collectPackageNames([options.fromPackage], node => {
// Workaround for Backstage main repo only, since the CLI has some artificial devDependencies
if (node.name === '@backstage/cli') {
return undefined;
}
return node.localDependencies.keys();
}),
);
} else {
localPackageNames = [options.fromPackage];
}
} else {
localPackageNames = packages.map(p => p.packageJson.name);
}
const schema = await loadConfigSchema({
dependencies: localPackageNames,
packagePaths: [targetPaths.resolveRoot('package.json')],
});
const source = ConfigSources.default({
allowMissingDefaultConfig: true,
watch: Boolean(options.watch),
rootDir: targetPaths.rootDir,
argv: options.args.flatMap(t => ['--config', resolvePath(targetDir, t)]),
});
const appConfigs = await new Promise<AppConfig[]>((resolve, reject) => {
async function loadConfigReaderLoop() {
let loaded = false;
try {
const abortController = new AbortController();
for await (const { configs } of source.readConfigData({
signal: abortController.signal,
})) {
if (loaded) {
const newFrontendAppConfigs = schema.process(configs, {
visibility: ['frontend'],
withFilteredKeys: options.withFilteredKeys,
ignoreSchemaErrors: true,
});
options.watch?.(newFrontendAppConfigs);
} else {
resolve(configs);
loaded = true;
if (!options.watch) {
abortController.abort();
}
}
}
} catch (error) {
if (loaded) {
console.error(`Failed to reload configuration, ${error}`);
} else {
reject(error);
}
}
}
loadConfigReaderLoop();
});
const configurationLoadedMessage = appConfigs.length
? `Loaded config from ${appConfigs.map(c => c.context).join(', ')}`
: `No configuration files found, running without config`;
process.stderr.write(`${configurationLoadedMessage}\n`);
const frontendAppConfigs = schema.process(appConfigs, {
visibility: ['frontend'],
withFilteredKeys: options.withFilteredKeys,
ignoreSchemaErrors: true,
});
const frontendConfig = ConfigReader.fromConfigs(frontendAppConfigs);
const fullConfig = ConfigReader.fromConfigs(appConfigs);
return {
schema,
appConfigs,
frontendConfig,
frontendAppConfigs,
fullConfig,
};
}
@@ -0,0 +1,89 @@
/*
* Copyright 2023 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 { extname } from 'node:path';
import { BackstagePackageJson } from '@backstage/cli-node';
export interface EntryPoint {
mount: string;
path: string;
name: string;
ext: string;
}
// Unless explicitly specified in exports, the index entrypoint is always
// assumed to be at src/index.ts for backwards compatibility.
const defaultIndex = {
mount: '.',
path: 'src/index.ts',
name: 'index',
ext: '.ts',
};
const SCRIPT_EXTS = ['.js', '.jsx', '.ts', '.tsx'];
function parseEntryPoint(mount: string, path: string): EntryPoint {
const ext = extname(path);
let name = mount;
if (name === '.') {
name = 'index';
} else if (name.startsWith('./')) {
name = name.slice(2);
}
// Script entry points can't have slashes because we create backward-compat
// directories for them. Non-script files (like CSS) can have nested paths.
if (name.includes('/') && SCRIPT_EXTS.includes(ext)) {
throw new Error(`Mount point '${mount}' may not contain multiple slashes`);
}
return { mount, path, name, ext };
}
export function readEntryPoints(pkg: BackstagePackageJson): Array<EntryPoint> {
const exp = pkg.exports;
if (typeof exp === 'string') {
return [defaultIndex];
} else if (exp && typeof exp === 'object' && !Array.isArray(exp)) {
const entryPoints = new Array<{
mount: string;
path: string;
name: string;
ext: string;
}>();
for (const mount of Object.keys(exp)) {
const path = exp[mount];
if (typeof path !== 'string') {
throw new Error(
`Exports field value must be a string, got '${JSON.stringify(path)}'`,
);
}
// Setting the EXPERIMENTAL_TRIM_NEXT_ENTRY flag will remove any `./next` entry points
if (process.env.EXPERIMENTAL_TRIM_NEXT_ENTRY && mount === './next') {
continue;
}
entryPoints.push(parseEntryPoint(mount, path));
}
return entryPoints;
}
return [defaultIndex];
}
@@ -0,0 +1,102 @@
/*
* Copyright 2023 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 { serializeError } from '@backstage/errors';
import { ChildProcess } from 'node:child_process';
interface RequestMeta {
generation: number;
}
type MethodHandler<TRequest, TResponse> = (
req: TRequest,
meta: RequestMeta,
) => Promise<TResponse>;
interface Request {
id: number;
method: string;
body: unknown;
type: string;
}
const requestType = '@backstage/cli/channel/request';
const responseType = '@backstage/cli/channel/response';
export class IpcServer {
#generation = 1;
#methods = new Map<string, MethodHandler<any, any>>();
addChild(child: ChildProcess) {
const generation = this.#generation++;
const sendMessage = child.send?.bind(child);
if (!sendMessage) {
return;
}
const messageListener = (request: Request) => {
if (request.type !== requestType) {
return;
}
const handler = this.#methods.get(request.method);
if (!handler) {
sendMessage({
type: responseType,
id: request.id,
error: {
name: 'NotFoundError',
message: `No handler registered for method ${request.method}`,
},
});
return;
}
Promise.resolve()
.then(() => handler(request.body, { generation }))
.then(response =>
sendMessage({
type: responseType,
id: request.id,
body: response,
}),
)
.catch(error =>
sendMessage({
type: responseType,
id: request.id,
error: serializeError(error),
}),
);
};
child.addListener('message', messageListener as (req: unknown) => void);
child.addListener('exit', () => {
child.removeListener('message', messageListener);
});
}
registerMethod<TRequest, TResponse>(
method: string,
handler: MethodHandler<TRequest, TResponse>,
) {
if (this.#methods.has(method)) {
throw new Error(`A handler is already registered for method ${method}`);
}
this.#methods.set(method, handler);
}
}
@@ -0,0 +1,78 @@
/*
* Copyright 2023 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 { IpcServer } from './IpcServer';
interface StorageItem {
generation: number;
data: unknown;
}
interface SaveRequest {
key: string;
data: unknown;
}
interface SaveResponse {
saved: boolean;
}
interface LoadRequest {
key: string;
}
interface LoadResponse {
loaded: boolean;
data: unknown;
}
export class ServerDataStore {
static bind(server: IpcServer): void {
const store = new Map<string, StorageItem>();
server.registerMethod<SaveRequest, SaveResponse>(
'DevDataStore.save',
async (request, { generation }) => {
const { key, data } = request;
if (!key) {
throw new Error('Key is required in DevDataStore.save');
}
const item = store.get(key);
if (!item) {
store.set(key, { generation, data });
return { saved: true };
}
if (item.generation > generation) {
return { saved: false };
}
store.set(key, { generation, data });
return { saved: true };
},
);
server.registerMethod<LoadRequest, LoadResponse>(
'DevDataStore.load',
async request => {
const item = store.get(request.key);
return { loaded: Boolean(item), data: item?.data };
},
);
}
}
@@ -0,0 +1,18 @@
/*
* 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 { IpcServer } from './IpcServer';
export { ServerDataStore } from './ServerDataStore';
@@ -0,0 +1,40 @@
/*
* 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 { parseArgs, type ParseArgsConfig } from 'node:util';
import { parse as parseShellArgs } from 'shell-quote';
export function createScriptOptionsParser(
commandPath: string[],
options: ParseArgsConfig['options'],
) {
const expectedScript = `backstage-cli ${commandPath.join(' ')}`;
return (scriptStr?: string) => {
if (!scriptStr || !scriptStr.startsWith(expectedScript)) {
return undefined;
}
const argsStr = scriptStr.slice(expectedScript.length).trim();
const args = argsStr
? parseShellArgs(argsStr).filter(
(e): e is string => typeof e === 'string',
)
: [];
const { values } = parseArgs({ args, strict: false, options });
return values;
};
}
@@ -0,0 +1,378 @@
/*
* Copyright 2020 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 chalk from 'chalk';
import fs from 'fs-extra';
import {
join as joinPath,
resolve as resolvePath,
relative as relativePath,
} from 'node:path';
import { tmpdir } from 'node:os';
import * as tar from 'tar';
import partition from 'lodash/partition';
import { run, targetPaths } from '@backstage/cli-common';
const {
dependencies: cliDependencies,
devDependencies: cliDevDependencies,
} = require('../../../../package.json') as {
dependencies: Record<string, string>;
devDependencies: Record<string, string>;
};
import {
BuildOptions,
buildPackages,
getOutputsForRole,
Output,
} from '../builder';
import { productionPack } from './productionPack';
import {
PackageRoles,
PackageGraph,
PackageGraphNode,
runConcurrentTasks,
} from '@backstage/cli-node';
import { createTypeDistProject } from '../typeDistProject';
// These packages aren't safe to pack in parallel since the CLI depends on them
const UNSAFE_PACKAGES = [
...Object.keys(cliDependencies),
...Object.keys(cliDevDependencies),
];
type FileEntry =
| string
| {
src: string;
dest: string;
};
type Options = {
/**
* Target directory for the dist workspace, defaults to a temporary directory
*/
targetDir?: string;
/**
* Configuration files to load during packaging.
*/
configPaths?: string[];
/**
* Files to copy into the target workspace.
*
* Defaults to ['yarn.lock', 'package.json'].
*/
files?: FileEntry[];
/**
* If set to true, the target packages are built before they are packaged into the workspace.
*/
buildDependencies?: boolean;
/**
* When `buildDependencies` is set, this list of packages will not be built even if they are dependencies.
*/
buildExcludes?: string[];
/**
* If set, creates a skeleton tarball that contains all package.json files
* with the same structure as the workspace dir.
*/
skeleton?: 'skeleton.tar' | 'skeleton.tar.gz';
/**
* If set to true, `yarn pack` is always preferred when creating the dist
* workspace. This ensures correct workspace output at significant cost to
* command performance.
*/
alwaysPack?: boolean;
/**
* If set to true, the TypeScript feature detection will be enabled, which
* annotates the package exports field with the `backstage` export type.
*/
enableFeatureDetection?: boolean;
/**
* If set to true, the generated code will be minified.
*/
minify?: boolean;
};
function prefixLogFunc(prefix: string, out: 'stdout' | 'stderr') {
return (data: Buffer) => {
for (const line of data.toString('utf8').split(/\r?\n/)) {
process[out].write(`${prefix} ${line}\n`);
}
};
}
/**
* Uses `yarn pack` to package local packages and unpacks them into a dist workspace.
* The target workspace will end up containing dist version of each package and
* will be suitable for packaging e.g. into a docker image.
*
* This creates a structure that is functionally similar to if the packages were
* installed from npm, but uses Yarn workspaces to link to them at runtime.
*/
export async function createDistWorkspace(
packageNames: string[],
options: Options = {},
) {
const targetDir =
options.targetDir ??
(await fs.mkdtemp(resolvePath(tmpdir(), 'dist-workspace')));
const packages = await PackageGraph.listTargetPackages();
const packageGraph = PackageGraph.fromPackages(packages);
const targetNames = packageGraph.collectPackageNames(packageNames, node => {
// Don't include dependencies of packages that are marked as bundled
if (node.packageJson.bundled) {
return undefined;
}
return node.publishedLocalDependencies.keys();
});
const targets = Array.from(targetNames).map(name => packageGraph.get(name)!);
if (options.buildDependencies) {
const exclude = options.buildExcludes ?? [];
const configPaths = options.configPaths ?? [];
const toBuild = new Set(
targets.map(_ => _.name).filter(name => !exclude.includes(name)),
);
const standardBuilds = new Array<BuildOptions>();
const customBuild = new Array<{
dir: string;
name: string;
args?: string[];
}>();
for (const pkg of packages) {
if (!toBuild.has(pkg.packageJson.name)) {
continue;
}
const role = pkg.packageJson.backstage?.role;
if (!role) {
console.warn(
`Building ${pkg.packageJson.name} separately because it has no role`,
);
customBuild.push({ dir: pkg.dir, name: pkg.packageJson.name });
continue;
}
const buildScript = pkg.packageJson.scripts?.build;
if (!buildScript) {
customBuild.push({ dir: pkg.dir, name: pkg.packageJson.name });
continue;
}
if (!buildScript.startsWith('backstage-cli package build')) {
console.warn(
`Building ${pkg.packageJson.name} separately because it has a custom build script, '${buildScript}'`,
);
customBuild.push({ dir: pkg.dir, name: pkg.packageJson.name });
continue;
}
if (PackageRoles.getRoleInfo(role).output.includes('bundle')) {
console.warn(
`Building ${pkg.packageJson.name} separately because it is a bundled package`,
);
const args = buildScript.includes('--config')
? []
: configPaths.map(p => ['--config', p]).flat();
customBuild.push({ dir: pkg.dir, name: pkg.packageJson.name, args });
continue;
}
const outputs = getOutputsForRole(role);
// No need to build and include types in the production runtime
outputs.delete(Output.types);
if (outputs.size > 0) {
standardBuilds.push({
targetDir: pkg.dir,
packageJson: pkg.packageJson,
outputs: outputs,
logPrefix: `${chalk.cyan(
relativePath(targetPaths.rootDir, pkg.dir),
)}: `,
minify: options.minify,
workspacePackages: packages,
});
}
}
await buildPackages(standardBuilds);
if (customBuild.length > 0) {
await runConcurrentTasks({
items: customBuild,
worker: async ({ name, dir, args }) => {
await run(['yarn', 'run', 'build', ...(args || [])], {
cwd: dir,
onStdout: prefixLogFunc(`${name}: `, 'stdout'),
onStderr: prefixLogFunc(`${name}: `, 'stderr'),
}).waitForExit();
},
});
}
}
await moveToDistWorkspace(
targetDir,
targets,
Boolean(options.alwaysPack),
Boolean(options.enableFeatureDetection),
);
const files: FileEntry[] = options.files ?? ['yarn.lock', 'package.json'];
for (const file of files) {
const src = typeof file === 'string' ? file : file.src;
const dest = typeof file === 'string' ? file : file.dest;
await fs.copy(targetPaths.resolveRoot(src), resolvePath(targetDir, dest));
}
if (options.skeleton) {
const skeletonFiles = targets
.map(target => {
const dir = relativePath(targetPaths.rootDir, target.dir);
return joinPath(dir, 'package.json');
})
.sort();
await tar.create(
{
file: resolvePath(targetDir, options.skeleton),
cwd: targetDir,
portable: true,
noMtime: true,
gzip: options.skeleton.endsWith('.gz'),
},
skeletonFiles,
);
}
return targetDir;
}
const FAST_PACK_SCRIPTS = [
undefined,
'backstage-cli prepack',
'backstage-cli package prepack',
];
async function moveToDistWorkspace(
workspaceDir: string,
localPackages: PackageGraphNode[],
alwaysPack: boolean,
enableFeatureDetection: boolean,
): Promise<void> {
const [fastPackPackages, slowPackPackages] = partition(
localPackages,
pkg =>
!alwaysPack &&
FAST_PACK_SCRIPTS.includes(pkg.packageJson.scripts?.prepack),
);
const featureDetectionProject =
fastPackPackages.length > 0 && enableFeatureDetection
? await createTypeDistProject()
: undefined;
// New an improved flow where we avoid calling `yarn pack`
await Promise.all(
fastPackPackages.map(async target => {
console.log(`Moving ${target.name} into dist workspace`);
const outputDir = relativePath(targetPaths.rootDir, target.dir);
const absoluteOutputPath = resolvePath(workspaceDir, outputDir);
await productionPack({
packageDir: target.dir,
targetDir: absoluteOutputPath,
featureDetectionProject,
});
}),
);
// Old flow is below, which calls `yarn pack` and extracts the tarball
async function pack(target: PackageGraphNode, archive: string) {
console.log(`Repacking ${target.name} into dist workspace`);
const archivePath = resolvePath(workspaceDir, archive);
await run(['yarn', 'pack', '--filename', archivePath], {
cwd: target.dir,
}).waitForExit();
const outputDir = relativePath(targetPaths.rootDir, target.dir);
const absoluteOutputPath = resolvePath(workspaceDir, outputDir);
await fs.ensureDir(absoluteOutputPath);
await tar.extract({
file: archivePath,
cwd: absoluteOutputPath,
strip: 1,
});
await fs.remove(archivePath);
// We remove the dependencies from package.json of packages that are marked
// as bundled, so that yarn doesn't try to install them.
if (target.packageJson.bundled) {
const pkgJson = await fs.readJson(
resolvePath(absoluteOutputPath, 'package.json'),
);
delete pkgJson.dependencies;
delete pkgJson.devDependencies;
delete pkgJson.peerDependencies;
delete pkgJson.optionalDependencies;
await fs.writeJson(
resolvePath(absoluteOutputPath, 'package.json'),
pkgJson,
{
spaces: 2,
},
);
}
}
const [unsafePackages, safePackages] = partition(slowPackPackages, p =>
UNSAFE_PACKAGES.includes(p.name),
);
// The unsafe package are packed first one by one in order to avoid race conditions
// where the CLI is being executed with broken dependencies.
for (const target of unsafePackages) {
await pack(target, `temp-package.tgz`);
}
// Repacking in parallel is much faster and safe for all packages outside of the Backstage repo
await runConcurrentTasks({
items: safePackages.map((target, index) => ({ target, index })),
worker: async ({ target, index }) => {
await pack(target, `temp-package-${index}.tgz`);
},
});
}
@@ -0,0 +1,17 @@
/*
* Copyright 2022 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 { createDistWorkspace } from './createDistWorkspace';
@@ -0,0 +1,239 @@
/*
* Copyright 2023 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 fs from 'fs-extra';
import npmPackList from 'npm-packlist';
import { resolve as resolvePath, posix as posixPath } from 'node:path';
import { BackstagePackageJson } from '@backstage/cli-node';
import { readEntryPoints } from '../entryPoints';
import { getEntryPointDefaultFeatureType } from '../typeDistProject';
import { Project } from 'ts-morph';
const PKG_PATH = 'package.json';
const PKG_BACKUP_PATH = 'package.json-prepack';
const SKIPPED_KEYS = ['access', 'registry', 'tag'];
const SCRIPT_EXTS = ['.js', '.jsx', '.ts', '.tsx'];
interface ProductionPackOptions {
packageDir: string;
targetDir?: string;
/**
* Enables package feature detection using this TS-morph project.
*/
featureDetectionProject?: Project;
}
export async function productionPack(options: ProductionPackOptions) {
const { packageDir, targetDir } = options;
const pkgPath = resolvePath(packageDir, PKG_PATH);
const pkgContent = await fs.readFile(pkgPath, 'utf8');
const pkg = JSON.parse(pkgContent) as BackstagePackageJson;
// If we're making the update in-line, back up the package.json
if (!targetDir) {
await fs.writeFile(PKG_BACKUP_PATH, pkgContent);
}
// This mutates pkg to fill in index exports, so call it before applying publishConfig
await rewriteEntryPoints(pkg, packageDir, options.featureDetectionProject);
// TODO(Rugvip): Once exports are rolled out more broadly we should deprecate and remove this behavior
const publishConfig = pkg.publishConfig ?? {};
for (const key of Object.keys(publishConfig)) {
if (!SKIPPED_KEYS.includes(key)) {
(pkg as any)[key] = publishConfig[key as keyof typeof publishConfig];
}
}
// We remove the dependencies from package.json of packages that are marked
// as bundled, so that yarn doesn't try to install them.
if (pkg.bundled) {
delete pkg.dependencies;
delete pkg.devDependencies;
delete pkg.peerDependencies;
delete pkg.optionalDependencies;
}
if (targetDir) {
// Lists all dist files, respecting .npmignore, files field in package.json, etc.
const filePaths = await npmPackList({
path: packageDir,
// This makes sure we use the updated package.json when listing files
packageJsonCache: new Map([
[resolvePath(packageDir, PKG_PATH), pkg],
]) as any, // Seems like this parameter type is wrong,
});
await fs.ensureDir(targetDir);
for (const filePath of filePaths.sort()) {
const target = resolvePath(targetDir, filePath);
if (filePath === PKG_PATH) {
await fs.writeJson(target, pkg, { encoding: 'utf8', spaces: 2 });
} else {
await fs.copy(resolvePath(packageDir, filePath), target);
}
}
} else {
await fs.writeJson(pkgPath, pkg, { encoding: 'utf8', spaces: 2 });
}
}
// Reverts the changes made by productionPack when called without a targetDir.
export async function revertProductionPack(packageDir: string) {
// postpack isn't called by yarn right now, so it needs to be called manually
try {
await fs.move(PKG_BACKUP_PATH, PKG_PATH, { overwrite: true });
// Check if we're shipping types for other release stages, clean up in that case
const pkg = await fs.readJson(PKG_PATH);
// Remove any extra entrypoint backwards compatibility directories
const entryPoints = readEntryPoints(pkg);
for (const entryPoint of entryPoints) {
if (entryPoint.mount !== '.' && SCRIPT_EXTS.includes(entryPoint.ext)) {
await fs.remove(resolvePath(packageDir, entryPoint.name));
}
}
} catch (error) {
console.warn(
`Failed to restore package.json, ${error}. ` +
'Your package will be fine but you may have ended up with some garbage in the repo.',
);
}
}
const EXPORT_MAP = {
import: '.esm.js',
require: '.cjs.js',
types: '.d.ts',
};
/**
* Rewrites the exports field in package.json to point to dist files, as
* well as returning a function that creates backwards compatibility
* entry points for importers that don't support exports.
*/
async function rewriteEntryPoints(
pkg: BackstagePackageJson,
packageDir: string,
featureDetectionProject?: Project,
) {
const distPath = resolvePath(packageDir, 'dist');
if (!(await fs.pathExists(distPath))) {
return undefined;
}
const distFiles = await fs.readdir(distPath);
const outputExports = {} as Record<string, string | Record<string, string>>;
const entryPoints = readEntryPoints(pkg);
// Clear to ensure a clean slate before adding entries back in further down
if (pkg.typesVersions) {
pkg.typesVersions = undefined;
}
for (const entryPoint of entryPoints) {
if (!SCRIPT_EXTS.includes(entryPoint.ext)) {
// Non-script files (like CSS) get their paths rewritten from src/ to dist/
outputExports[entryPoint.mount] = entryPoint.path.replace(
/^(\.\/)?src\//,
'./dist/',
);
continue;
}
let exp = {} as Record<string, string>;
for (const [key, ext] of Object.entries(EXPORT_MAP)) {
const name = `${entryPoint.name}${ext}`;
if (distFiles.includes(name)) {
exp[key] = `./${posixPath.join(`dist`, name)}`;
}
}
// Our current tooling relies on the typesVersions field rather than export.*.types
if (exp.types) {
if (!pkg.typesVersions) {
pkg.typesVersions = { '*': {} };
}
if (entryPoint.name !== 'index') {
pkg.typesVersions['*'][entryPoint.name] = [
`dist/${entryPoint.name}.d.ts`,
];
}
}
exp.default = exp.require ?? exp.import;
// Find the default export type for the entry point, if feature detection is active
if (exp.types && featureDetectionProject) {
const defaultFeatureType =
pkg.backstage?.role &&
getEntryPointDefaultFeatureType(
pkg.backstage?.role,
packageDir,
featureDetectionProject,
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
//
// Adding this to the `exports` field in the package.json is to temporarily
// support any existing behavior that relies on this, however not all packages
// have exports field in their package.json.
exp = { backstage: defaultFeatureType, ...exp };
// Add the default feature type to the backstage metadata in the package.json
pkg.backstage = pkg.backstage ?? {};
pkg.backstage.features = pkg.backstage.features ?? {};
pkg.backstage.features[entryPoint.mount] = defaultFeatureType;
}
}
if (entryPoint.mount === '.') {
if (exp.default) {
pkg.main = exp.default;
}
if (exp.import) {
pkg.module = exp.import;
}
if (exp.types) {
pkg.types = exp.types;
}
}
if (Object.keys(exp).length > 0) {
outputExports[entryPoint.mount] = exp;
}
}
// Make sure package.json is also available in typesVersions if present
if (pkg.typesVersions?.['*']) {
pkg.typesVersions['*']['package.json'] = ['package.json'];
}
if (pkg.exports) {
pkg.exports = outputExports;
// We treat package.json as a fixed export that is always available in the published package
pkg.exports['./package.json'] = './package.json';
}
return undefined;
}
@@ -0,0 +1,73 @@
/*
* 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 { BackstagePackage } from '@backstage/cli-node';
/**
* A basic check that throws if a packages doesn't contain required backstage metadata for publishing
*/
export function publishPreflightCheck(pkg: BackstagePackage): void {
const { name, backstage } = pkg.packageJson;
if (!backstage || !name) {
return;
}
const { role } = backstage;
if (
role === 'backend-plugin' ||
role === 'backend-plugin-module' ||
role === 'frontend-plugin'
// TODO(Rugvip): We currently support plugin-less frontend modules for the new frontend system, but it needs a different solution
// || role === 'frontend-plugin-module'
) {
if (!backstage.pluginId) {
throw new Error(
`Plugin package ${name} is missing a backstage.pluginId, please run 'backstage-cli repo fix --publish'`,
);
}
}
if (role === 'backend-plugin' || role === 'frontend-plugin') {
if (!backstage.pluginPackages) {
throw new Error(
`Plugin package ${name} is missing a backstage.pluginPackages, please run 'backstage-cli repo fix --publish'`,
);
}
}
if (
backstage.pluginId &&
(role === 'common-library' ||
role === 'node-library' ||
role === 'web-library')
) {
if (!backstage.pluginPackages) {
throw new Error(
`Plugin library package ${name} is missing a backstage.pluginPackages, please run 'backstage-cli repo fix --publish'`,
);
}
}
if (role === 'backend-plugin-module' || role === 'frontend-plugin-module') {
// TODO(Rugvip): Remove this .pluginId check once frontend modules are required to have a plugin ID
if (backstage.pluginId && !backstage.pluginPackage) {
throw new Error(
`Plugin module package ${name} is missing a backstage.pluginPackage, please run 'backstage-cli repo fix --publish'`,
);
}
}
}
@@ -0,0 +1,47 @@
/*
* Copyright 2022 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 { createMockDirectory } from '@backstage/backend-test-utils';
import { overrideTargetPaths } from '@backstage/cli-common/testUtils';
import { findRoleFromCommand } from './role';
const mockDir = createMockDirectory();
overrideTargetPaths(mockDir.path);
describe('findRoleFromCommand', () => {
beforeEach(() => {
mockDir.setContent({
'package.json': JSON.stringify({
name: 'test',
backstage: {
role: 'web-library',
},
}),
});
});
it('provides role info by role', async () => {
await expect(findRoleFromCommand({})).resolves.toEqual('web-library');
await expect(
findRoleFromCommand({ role: 'node-library' }),
).resolves.toEqual('node-library');
await expect(findRoleFromCommand({ role: 'invalid' })).rejects.toThrow(
`Unknown package role 'invalid'`,
);
});
});
+35
View File
@@ -0,0 +1,35 @@
/*
* Copyright 2023 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 fs from 'fs-extra';
import { targetPaths } from '@backstage/cli-common';
import { PackageRoles, PackageRole } from '@backstage/cli-node';
export async function findRoleFromCommand(opts: {
role?: string;
}): Promise<PackageRole> {
if (opts.role) {
return PackageRoles.getRoleInfo(opts.role).role;
}
const pkg = await fs.readJson(targetPaths.resolve('package.json'));
const info = PackageRoles.getRoleFromPackage(pkg);
if (!info) {
throw new Error(`Target package must have 'backstage.role' set`);
}
return info;
}
@@ -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 { runBackend } from './runBackend';
@@ -0,0 +1,178 @@
/*
* Copyright 2020 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 { runBackend } from './runBackend';
import spawn from 'cross-spawn';
// Mock external dependencies
jest.mock('chokidar', () => ({
watch: jest.fn(() => ({
on: jest.fn().mockReturnThis(),
add: jest.fn(),
})),
}));
jest.mock('cross-spawn', () =>
jest.fn(() => ({
on: jest.fn().mockReturnThis(),
once: jest.fn().mockReturnThis(),
kill: jest.fn(),
killed: false,
exitCode: null,
pid: 12345,
})),
);
jest.mock('../ipc', () => ({
IpcServer: jest.fn().mockImplementation(() => ({
addChild: jest.fn(),
})),
ServerDataStore: {
bind: jest.fn(),
},
}));
jest.mock('ctrlc-windows', () => ({
ctrlc: jest.fn(),
}));
describe('runBackend', () => {
let originalEnv: NodeJS.ProcessEnv;
let originalPlatform: string;
const mockSpawn = spawn as jest.MockedFunction<typeof spawn>;
beforeEach(() => {
// Use fake timers to control debounce
jest.useFakeTimers();
// Save original environment
originalEnv = { ...process.env };
process.env = { NODE_ENV: 'test' };
originalPlatform = process.platform;
// Mock process.stdin.on to prevent actual stdin reading
jest.spyOn(process.stdin, 'on').mockReturnValue(process.stdin);
// Mock process.once to prevent actual signal handling
jest.spyOn(process, 'once').mockReturnValue(process);
});
afterEach(() => {
// Restore original environment
process.env = originalEnv;
Object.defineProperty(process, 'platform', {
value: originalPlatform,
});
jest.clearAllMocks();
jest.useRealTimers();
});
describe('--no-node-snapshot argument handling', () => {
it('should pass --no-node-snapshot when NODE_OPTIONS is not set', () => {
delete process.env.NODE_OPTIONS;
runBackend({
entry: 'src/index',
});
// Fast-forward past the debounce delay (100ms)
jest.advanceTimersByTime(100);
expect(mockSpawn).toHaveBeenCalled();
const spawnArgs = mockSpawn.mock.calls[0][1] as string[];
expect(spawnArgs).toContain('--no-node-snapshot');
});
it('should pass --no-node-snapshot when NODE_OPTIONS exists without --node-snapshot', () => {
process.env.NODE_OPTIONS = '--max-old-space-size=4096';
runBackend({
entry: 'src/index',
});
// Fast-forward past the debounce delay (100ms)
jest.advanceTimersByTime(100);
expect(mockSpawn).toHaveBeenCalled();
const spawnArgs = mockSpawn.mock.calls[0][1] as string[];
expect(spawnArgs).toContain('--no-node-snapshot');
});
it('should not pass --no-node-snapshot when --node-snapshot already exists in NODE_OPTIONS', () => {
process.env.NODE_OPTIONS = '--node-snapshot --max-old-space-size=4096';
runBackend({
entry: 'src/index',
});
// Fast-forward past the debounce delay (100ms)
jest.advanceTimersByTime(100);
expect(mockSpawn).toHaveBeenCalled();
const spawnArgs = mockSpawn.mock.calls[0][1] as string[];
expect(spawnArgs).not.toContain('--no-node-snapshot');
});
it('should not pass --no-node-snapshot when --node-snapshot exists in the middle of NODE_OPTIONS', () => {
process.env.NODE_OPTIONS =
'--max-old-space-size=4096 --node-snapshot --inspect';
runBackend({
entry: 'src/index',
});
// Fast-forward past the debounce delay (100ms)
jest.advanceTimersByTime(100);
expect(mockSpawn).toHaveBeenCalled();
const spawnArgs = mockSpawn.mock.calls[0][1] as string[];
expect(spawnArgs).not.toContain('--no-node-snapshot');
});
it('should pass --no-node-snapshot even with trailing spaces in NODE_OPTIONS', () => {
process.env.NODE_OPTIONS = '--max-old-space-size=4096 ';
runBackend({
entry: 'src/index',
});
// Fast-forward past the debounce delay (100ms)
jest.advanceTimersByTime(100);
expect(mockSpawn).toHaveBeenCalled();
const spawnArgs = mockSpawn.mock.calls[0][1] as string[];
expect(spawnArgs).toContain('--no-node-snapshot');
});
it('should pass --no-node-snapshot alongside other option args like --inspect', () => {
delete process.env.NODE_OPTIONS;
runBackend({
entry: 'src/index',
inspectEnabled: true,
});
// Fast-forward past the debounce delay (100ms)
jest.advanceTimersByTime(100);
expect(mockSpawn).toHaveBeenCalled();
const spawnArgs = mockSpawn.mock.calls[0][1] as string[];
expect(spawnArgs).toContain('--no-node-snapshot');
expect(spawnArgs).toContain('--inspect');
});
});
});
@@ -0,0 +1,197 @@
/*
* Copyright 2020 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 { FSWatcher, watch } from 'chokidar';
import type { ChildProcess } from 'node:child_process';
import { ctrlc } from 'ctrlc-windows';
import { IpcServer, ServerDataStore } from '../ipc';
import debounce from 'lodash/debounce';
import { fileURLToPath } from 'node:url';
import { isAbsolute as isAbsolutePath } from 'node:path';
import { targetPaths } from '@backstage/cli-common';
import spawn from 'cross-spawn';
const loaderArgs = [
'--enable-source-maps',
'--require',
require.resolve('@backstage/cli/config/nodeTransform.cjs'),
// TODO: Support modules, although there's currently no way to load them since import() is transpiled tp require()
];
export type RunBackendOptions = {
/** The directory to run the backend process in, defaults to cwd */
targetDir?: string;
/** relative entry point path without extension, e.g. 'src/index' */
entry: string;
/** Whether to forward the --inspect flag to the node process */
inspectEnabled?: boolean | string;
/** Whether to forward the --inspect-brk flag to the node process */
inspectBrkEnabled?: boolean | string;
/** Additional module to require via the --require flag to the node process */
require?: string | string[];
/** An external linked workspace to override module resolution towards */
linkedWorkspace?: string;
};
export async function runBackend(options: RunBackendOptions) {
const envEnv = process.env as { NODE_ENV: string; NODE_OPTIONS?: string };
if (!envEnv.NODE_ENV) {
envEnv.NODE_ENV = 'development';
}
// Set up the parent IPC server and bind the available services
const server = new IpcServer();
ServerDataStore.bind(server);
let exiting = false;
let firstStart = true;
let child: ChildProcess | undefined;
let watcher: FSWatcher | undefined = undefined;
let shutdownPromise: Promise<void> | undefined = undefined;
const watchedPaths = new Set<string>();
const restart = debounce(async () => {
if (firstStart) {
firstStart = false;
} else {
console.log();
console.log('Change detected, restarting the development server...');
console.log();
}
// If a re-trigger happens during an existing shutdown, we just ignore it
if (shutdownPromise) {
return;
}
if (child && !child.killed && child.exitCode === null) {
// We always wait for the existing process to exit, to make sure we don't get IPC conflicts
shutdownPromise = new Promise(resolve => child!.once('exit', resolve));
if (process.platform === 'win32' && child.pid) {
ctrlc(child.pid);
} else {
child.kill();
}
await shutdownPromise;
shutdownPromise = undefined;
}
// We've received a shutdown signal
if (exiting) {
return;
}
const optionArgs = new Array<string>();
if (options.inspectEnabled) {
const inspect =
typeof options.inspectEnabled === 'string'
? `--inspect=${options.inspectEnabled}`
: '--inspect';
optionArgs.push(inspect);
} else if (options.inspectBrkEnabled) {
const inspect =
typeof options.inspectBrkEnabled === 'string'
? `--inspect-brk=${options.inspectBrkEnabled}`
: '--inspect-brk';
optionArgs.push(inspect);
}
if (options.require) {
const requires = [options.require].flat();
for (const r of requires) {
optionArgs.push(`--require=${r}`);
}
}
// Unless the user explicitly toggles node-snapshot, default to provide --no-node-snapshot to reduce number of steps to run scaffolder
// on Node LTS.
if (!envEnv.NODE_OPTIONS?.includes('--node-snapshot')) {
optionArgs.push('--no-node-snapshot');
}
const userArgs = process.argv
.slice(['node', 'backstage-cli', 'package', 'start'].length)
.filter(arg => !optionArgs.includes(arg));
child = spawn(
process.execPath,
[...loaderArgs, ...optionArgs, options.entry, ...userArgs],
{
stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
cwd: options.targetDir,
env: {
...process.env,
BACKSTAGE_CLI_LINKED_WORKSPACE: options.linkedWorkspace,
BACKSTAGE_CLI_CHANNEL: '1',
ESBK_TSCONFIG_PATH: targetPaths.resolveRoot('tsconfig.json'),
},
serialization: 'advanced',
},
);
server.addChild(child);
// This captures messages sent by @esbuild-kit/cjs-loader
child.on('message', (data: { type?: string } | null) => {
if (!watcher) {
return;
}
if (typeof data === 'object' && data?.type === 'watch') {
let path = (data as { path: string }).path;
if (path.startsWith('file:')) {
path = fileURLToPath(path);
}
if (isAbsolutePath(path) && !watchedPaths.has(path)) {
watchedPaths.add(path);
watcher.add(path);
}
}
});
}, 100);
restart();
watcher = watch(['./package.json'], {
cwd: process.cwd(),
ignoreInitial: true,
ignorePermissionErrors: true,
}).on('all', restart);
// Trigger restart on hitting enter in the terminal
process.stdin.on('data', restart);
const exitPromise = new Promise<void>(resolveExitPromise => {
async function handleSignal(signal: NodeJS.Signals) {
exiting = true;
// Forward signals to child and wait for it to exit if still running
if (child && child.exitCode === null) {
await new Promise(resolve => {
child!.on('close', resolve);
child!.kill(signal);
});
}
resolveExitPromise();
}
process.once('SIGINT', handleSignal);
process.once('SIGTERM', handleSignal);
});
return () => exitPromise;
}
@@ -0,0 +1,129 @@
/*
* 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, BackstagePackageFeatureType } from '@backstage/cli-node';
import createFeatureEnvironment from './__testUtils__/createFeatureEnvironment';
import { getEntryPointDefaultFeatureType } from './typeDistProject';
describe('typeDistProject', () => {
describe('for package role', () => {
// This Record makes sure we're checking all package roles
const packageRoles: Record<PackageRole, boolean> = {
// 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,
'cli-module': 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<BackstagePackageFeatureType | string, boolean> =
{
// 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');
});
});
@@ -0,0 +1,144 @@
/*
* 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 {
BackstagePackageFeatureType,
packageFeatureType,
PackageRole,
} from '@backstage/cli-node';
import { resolve as resolvePath } from 'node:path';
import { Project, SourceFile, SyntaxKind, ts, Type } from 'ts-morph';
import { targetPaths } from '@backstage/cli-common';
export const createTypeDistProject = async () => {
return new Project({
tsConfigFilePath: targetPaths.resolveRoot('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',
];
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<ts.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: '(?<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 && packageFeatureType.includes(type as BackstagePackageFeatureType)
);
}
@@ -0,0 +1,34 @@
/*
* Copyright 2021 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 { isValidUrl } from './urls';
describe('isValidUrl', () => {
it('should return true for url', () => {
const validUrl = isValidUrl('http://some.valid.url');
expect(validUrl).toBe(true);
});
it('should return false for absolute path', () => {
const validUrl = isValidUrl('/some/absolute/path');
expect(validUrl).toBe(false);
});
it('should return false for relative path', () => {
const validUrl = isValidUrl('../some/relative/path');
expect(validUrl).toBe(false);
});
});
+25
View File
@@ -0,0 +1,25 @@
/*
* Copyright 2021 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 function isValidUrl(url: string): boolean {
try {
// eslint-disable-next-line no-new
new URL(url);
return true;
} catch {
return false;
}
}
@@ -0,0 +1,2 @@
!node_modules
dist
@@ -0,0 +1 @@
module.exports = 'a'
@@ -0,0 +1 @@
exports.value = 'a'
@@ -0,0 +1 @@
export default 'b'
@@ -0,0 +1 @@
export const value = 'b'
@@ -0,0 +1 @@
module.exports = 'c'
@@ -0,0 +1 @@
exports.value = 'c'
@@ -0,0 +1,15 @@
export const namedA: string
export const namedB: string
export const namedC: string
export const defaultA: string
export const defaultB: string
export const defaultC: string
export namespace dyn {
export const namedA: Promise<string>
export const namedB: Promise<string>
export const namedC: Promise<string>
export const defaultA: Promise<string>
export const defaultB: Promise<string>
export const defaultC: Promise<string>
}
@@ -0,0 +1,14 @@
exports.namedA = require('./a-named').value;
// exports.namedB = require('./b-named.mjs').value;
exports.namedC = require('./c-named.cjs').value;
exports.defaultA = require('./a-default');
// exports.defaultB = require('./b-default.mjs').default;
exports.defaultC = require('./c-default.cjs');
exports.dyn = {
namedA: import('./a-named').then(m => m.value),
namedB: import('./b-named.mjs').then(m => m.value),
namedC: import('./c-named.cjs').then(m => m.value),
defaultA: import('./a-default').then(m => m.default),
defaultB: import('./b-default.mjs').then(m => m.default),
defaultC: import('./c-default.cjs').then(m => m.default),
}
@@ -0,0 +1,14 @@
{
"name": "dep-commonjs",
"type": "commonjs",
"exports": {
".": "./main.js"
},
"typesVersions": {
"*": {
"*": [
"main.d.ts"
]
}
}
}
@@ -0,0 +1 @@
module.exports = 'a'
@@ -0,0 +1 @@
exports.value = 'a'
@@ -0,0 +1 @@
export default 'b'
@@ -0,0 +1 @@
export const value = 'b'
@@ -0,0 +1 @@
module.exports = 'c'
@@ -0,0 +1 @@
exports.value = 'c'
@@ -0,0 +1,15 @@
export const namedA: string
export const namedB: string
export const namedC: string
export const defaultA: string
export const defaultB: string
export const defaultC: string
export namespace dyn {
export const namedA: Promise<string>
export const namedB: Promise<string>
export const namedC: Promise<string>
export const defaultA: Promise<string>
export const defaultB: Promise<string>
export const defaultC: Promise<string>
}
@@ -0,0 +1,14 @@
exports.namedA = require('./a-named').value;
// exports.namedB = require('./b-named.mjs').value;
exports.namedC = require('./c-named.cjs').value;
exports.defaultA = require('./a-default');
// exports.defaultB = require('./b-default.mjs').default;
exports.defaultC = require('./c-default.cjs');
exports.dyn = {
namedA: import('./a-named').then(m => m.value),
namedB: import('./b-named.mjs').then(m => m.value),
namedC: import('./c-named.cjs').then(m => m.value),
defaultA: import('./a-default').then(m => m.default),
defaultB: import('./b-default.mjs').then(m => m.default),
defaultC: import('./c-default.cjs').then(m => m.default),
}
@@ -0,0 +1,13 @@
{
"name": "dep-default",
"exports": {
".": "./main.js"
},
"typesVersions": {
"*": {
"*": [
"main.d.ts"
]
}
}
}
@@ -0,0 +1 @@
export default 'a'
@@ -0,0 +1 @@
export const value = 'a'
@@ -0,0 +1 @@
export default 'b'
@@ -0,0 +1 @@
export const value = 'b'
@@ -0,0 +1 @@
module.exports = 'c'
@@ -0,0 +1 @@
exports.value = 'c'
@@ -0,0 +1,15 @@
export const namedA: string
export const namedB: string
export const namedC: string
export const defaultA: string
export const defaultB: string
export const defaultC: string
export namespace dyn {
export const namedA: Promise<string>
export const namedB: Promise<string>
export const namedC: Promise<string>
export const defaultA: Promise<string>
export const defaultB: Promise<string>
export const defaultC: Promise<string>
}
@@ -0,0 +1,14 @@
export { value as namedA } from './a-named'
export { value as namedB } from './b-named.mjs'
export { value as namedC } from './c-named.cjs'
export { default as defaultA } from './a-default'
export { default as defaultB } from './b-default.mjs'
export { default as defaultC } from './c-default.cjs'
export const dyn = {
namedA: import('./a-named').then(m => m.value),
namedB: import('./b-named.mjs').then(m => m.value),
namedC: import('./c-named.cjs').then(m => m.value),
defaultA: import('./a-default').then(m => m.default),
defaultB: import('./b-default.mjs').then(m => m.default),
defaultC: import('./c-default.cjs').then(m => m.default),
}
@@ -0,0 +1,14 @@
{
"name": "dep-module",
"type": "module",
"exports": {
".": "./main.js"
},
"typesVersions": {
"*": {
"*": [
"main.d.ts"
]
}
}
}
@@ -0,0 +1,16 @@
/*
* 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 default 'a';
@@ -0,0 +1,16 @@
/*
* 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 const value = 'a';
@@ -0,0 +1,16 @@
/*
* 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 default 'b';
@@ -0,0 +1,16 @@
/*
* 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 const value = 'b';
@@ -0,0 +1,16 @@
/*
* 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 default 'c';
@@ -0,0 +1,16 @@
/*
* 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 const value = 'c';
@@ -0,0 +1,71 @@
/*
* 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 * as depCommonJs from 'dep-commonjs';
// import * as depModule from 'dep-module';
import * as depDefault from 'dep-default';
import { value as namedA } from './a-named';
// import { value as namedB } from './b-named.mts';
import { value as namedC } from './c-named.cts';
import { default as defaultA } from './a-default';
// import { default as defaultB } from './b-default.mts';
import { default as defaultC } from './c-default.cts';
async function resolveAll(obj: object): Promise<unknown> {
const val = await obj;
if (typeof val !== 'object' || val === null) {
return val;
}
if (Array.isArray(val)) {
return await Promise.all(val.map(resolveAll));
}
return Object.fromEntries(
await Promise.all(
Object.entries(obj).map(async ([key, value]) => [
key,
await resolveAll(await value),
]),
),
);
}
export const values = resolveAll({
depCommonJs,
// depModule,
depDefault,
dynCommonJs: import('dep-commonjs'),
dynModule: import('dep-module'),
dynDefault: import('dep-default'),
dep: {
namedA,
// namedB,
namedC,
defaultA,
// defaultB,
defaultC,
},
dyn: {
// @ts-expect-error Default exports from CommonJS are not well supported
namedA: import('./a-named').then(m => m.default.value),
namedB: import('./b-named.mts').then(m => m.value),
namedC: import('./c-named.cts').then(m => m.value),
// @ts-expect-error Default exports from CommonJS are not well supported
defaultA: import('./a-default').then(m => m.default.default),
defaultB: import('./b-default.mts').then(m => m.default),
// @ts-expect-error Default exports from CommonJS are not well supported
defaultC: import('./c-default.cts').then(m => m.default.default),
},
});
@@ -0,0 +1,8 @@
{
"name": "pkg-commonjs",
"type": "commonjs",
"exports": {
".": "./main.ts",
"./print": "./print.ts"
}
}
@@ -0,0 +1,19 @@
/*
* 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 { values } from './main';
values.then(obj => console.log(JSON.stringify(obj, null, 2)));
@@ -0,0 +1,16 @@
/*
* 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 default 'a';

Some files were not shown because too many files have changed in this diff Show More