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:
@@ -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,
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
};
|
||||
@@ -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'`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
Generated
Vendored
+1
@@ -0,0 +1 @@
|
||||
module.exports = 'a'
|
||||
Generated
Vendored
+1
@@ -0,0 +1 @@
|
||||
exports.value = 'a'
|
||||
Generated
Vendored
+1
@@ -0,0 +1 @@
|
||||
export default 'b'
|
||||
Generated
Vendored
+1
@@ -0,0 +1 @@
|
||||
export const value = 'b'
|
||||
Generated
Vendored
+1
@@ -0,0 +1 @@
|
||||
module.exports = 'c'
|
||||
Generated
Vendored
+1
@@ -0,0 +1 @@
|
||||
exports.value = 'c'
|
||||
Generated
Vendored
+15
@@ -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>
|
||||
}
|
||||
Generated
Vendored
+14
@@ -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),
|
||||
}
|
||||
Generated
Vendored
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "dep-commonjs",
|
||||
"type": "commonjs",
|
||||
"exports": {
|
||||
".": "./main.js"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"main.d.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
Vendored
+1
@@ -0,0 +1 @@
|
||||
module.exports = 'a'
|
||||
Generated
Vendored
+1
@@ -0,0 +1 @@
|
||||
exports.value = 'a'
|
||||
Generated
Vendored
+1
@@ -0,0 +1 @@
|
||||
export default 'b'
|
||||
Generated
Vendored
+1
@@ -0,0 +1 @@
|
||||
export const value = 'b'
|
||||
Generated
Vendored
+1
@@ -0,0 +1 @@
|
||||
module.exports = 'c'
|
||||
Generated
Vendored
+1
@@ -0,0 +1 @@
|
||||
exports.value = 'c'
|
||||
Generated
Vendored
+15
@@ -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>
|
||||
}
|
||||
Generated
Vendored
+14
@@ -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),
|
||||
}
|
||||
Generated
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "dep-default",
|
||||
"exports": {
|
||||
".": "./main.js"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"main.d.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
Vendored
+1
@@ -0,0 +1 @@
|
||||
export default 'a'
|
||||
Generated
Vendored
+1
@@ -0,0 +1 @@
|
||||
export const value = 'a'
|
||||
Generated
Vendored
+1
@@ -0,0 +1 @@
|
||||
export default 'b'
|
||||
Generated
Vendored
+1
@@ -0,0 +1 @@
|
||||
export const value = 'b'
|
||||
Generated
Vendored
+1
@@ -0,0 +1 @@
|
||||
module.exports = 'c'
|
||||
Generated
Vendored
+1
@@ -0,0 +1 @@
|
||||
exports.value = 'c'
|
||||
Generated
Vendored
+15
@@ -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>
|
||||
}
|
||||
Generated
Vendored
+14
@@ -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),
|
||||
}
|
||||
Generated
Vendored
+14
@@ -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';
|
||||
+16
@@ -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';
|
||||
+16
@@ -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
Reference in New Issue
Block a user