cli: add initial experimental support for dynamic plugins

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2024-07-15 13:14:03 +02:00
parent 1a966daca7
commit 133464cfe0
11 changed files with 696 additions and 80 deletions
+1
View File
@@ -53,6 +53,7 @@
"@backstage/release-manifests": "workspace:^",
"@backstage/types": "workspace:^",
"@manypkg/get-packages": "^1.1.3",
"@module-federation/enhanced": "^0.1.19",
"@octokit/graphql": "^5.0.0",
"@octokit/graphql-schema": "^13.7.0",
"@octokit/oauth-app": "^4.2.0",
@@ -24,6 +24,7 @@ interface BuildAppOptions {
targetDir: string;
writeStats: boolean;
configPaths: string[];
moduleFederationMode?: 'host' | 'remote';
}
export async function buildFrontend(options: BuildAppOptions) {
@@ -34,6 +35,13 @@ export async function buildFrontend(options: BuildAppOptions) {
entry: 'src/index',
parallelism: getEnvironmentParallelism(),
statsJsonEnabled: writeStats,
moduleFederation: options.moduleFederationMode && {
// 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: name.replaceAll('@', '').replaceAll(/[/\\_-]/g, '_'),
mode: options.moduleFederationMode,
},
...(await loadCliConfig({
args: configPaths,
fromPackage: name,
@@ -22,6 +22,7 @@ import { paths } from '../../lib/paths';
import { buildFrontend } from './buildFrontend';
import { buildBackend } from './buildBackend';
import { isValidUrl } from '../../lib/urls';
import chalk from 'chalk';
export async function command(opts: OptionValues): Promise<void> {
const role = await findRoleFromCommand(opts);
@@ -39,6 +40,9 @@ export async function command(opts: OptionValues): Promise<void> {
targetDir: paths.targetDir,
configPaths,
writeStats: Boolean(opts.stats),
moduleFederationMode: process.env.EXPERIMENTAL_MODULE_FEDERATION
? 'host'
: undefined,
});
}
return buildBackend({
@@ -49,6 +53,21 @@ export async function command(opts: OptionValues): Promise<void> {
});
}
// experimental
if ((role 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.`,
),
);
return buildFrontend({
targetDir: paths.targetDir,
configPaths: [],
writeStats: Boolean(opts.stats),
moduleFederationMode: 'remote',
});
}
const roleInfo = PackageRoles.getRoleInfo(role);
const outputs = new Set<Output>();
+28 -20
View File
@@ -46,37 +46,45 @@ export async function buildBundle(options: BuildOptions) {
dist: 'dist/public',
});
const detectedModulesEntryPoint = await createDetectedModulesEntryPoint({
config: options.fullConfig,
targetPath: paths.targetPath,
});
const commonConfigOptions = {
...options,
checksEnabled: false,
isDev: false,
getFrontendAppConfigs: () => options.frontendAppConfigs,
};
const configs = [
await createConfig(paths, {
...commonConfigOptions,
additionalEntryPoints: detectedModulesEntryPoint,
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.`,
),
);
const configs = [];
if (options.moduleFederation?.mode === 'remote') {
// Package detection is disabled for remote bundles
configs.push(await createConfig(paths, commonConfigOptions));
} else {
const detectedModulesEntryPoint = await createDetectedModulesEntryPoint({
config: options.fullConfig,
targetPath: paths.targetPath,
});
configs.push(
await createConfig(publicPaths, {
await createConfig(paths, {
...commonConfigOptions,
appMode: 'public',
additionalEntryPoints: detectedModulesEntryPoint,
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 });
+79 -13
View File
@@ -24,6 +24,7 @@ import { Config } from '@backstage/config';
import ESLintPlugin from 'eslint-webpack-plugin';
import ForkTsCheckerWebpackPlugin from 'fork-ts-checker-webpack-plugin';
import HtmlWebpackPlugin from 'html-webpack-plugin';
import { ModuleFederationPlugin } from '@module-federation/enhanced';
import { LinkedPackageResolvePlugin } from './LinkedPackageResolvePlugin';
import ModuleScopePlugin from 'react-dev-utils/ModuleScopePlugin';
import { RunScriptWebpackPlugin } from 'run-script-webpack-plugin';
@@ -128,18 +129,81 @@ export async function createConfig(
}),
);
plugins.push(
new HtmlWebpackPlugin({
meta: {
'backstage-app-mode': options?.appMode ?? 'public',
},
template: paths.targetHtml,
templateParameters: {
publicPath,
config: frontendConfig,
},
}),
);
if (options.moduleFederation?.mode !== 'remote') {
plugins.push(
new HtmlWebpackPlugin({
meta: {
'backstage-app-mode': options?.appMode ?? 'public',
},
template: paths.targetHtml,
templateParameters: {
publicPath,
config: frontendConfig,
},
}),
);
}
if (options.moduleFederation) {
plugins.push(
new ModuleFederationPlugin({
...(options.moduleFederation?.mode === 'remote' && {
filename: 'remoteEntry.js',
exposes: {
'.': paths.targetEntry,
},
}),
name: options.moduleFederation.name,
runtime: false,
shared: {
// React
react: {
singleton: true,
requiredVersion: '*',
eager: true,
},
'react-dom': {
singleton: true,
requiredVersion: '*',
eager: true,
},
// React Router
'react-router': {
singleton: true,
requiredVersion: '*',
eager: true,
},
'react-router-dom': {
singleton: true,
requiredVersion: '*',
eager: true,
},
// MUI v4
'@material-ui/core/styles': {
singleton: true,
requiredVersion: '*',
eager: true,
},
'@material-ui/styles': {
singleton: true,
requiredVersion: '*',
eager: true,
},
// MUI v5
'@mui/material/styles/': {
singleton: true,
requiredVersion: '*',
eager: true,
},
'@emotion/react': {
singleton: true,
requiredVersion: '*',
eager: true,
},
},
}),
);
}
const buildInfo = await readBuildInfo();
plugins.push(
@@ -211,8 +275,10 @@ export async function createConfig(
rules: loaders,
},
output: {
uniqueName: options.moduleFederation?.name,
path: paths.targetDist,
publicPath: `${publicPath}/`,
publicPath:
options.moduleFederation?.mode === 'remote' ? 'auto' : `${publicPath}/`,
filename: isDev ? '[name].js' : 'static/[name].[fullhash:8].js',
chunkFilename: isDev
? '[name].chunk.js'
@@ -30,6 +30,13 @@ export const optimization = (
new EsbuildPlugin({
target: 'es2019',
format: 'iife',
exclude: 'remoteEntry.js',
}),
// Avoid iife wrapping of module federation remote entry as it breaks the variable assignment
new EsbuildPlugin({
target: 'es2019',
format: undefined,
include: 'remoteEntry.js',
}),
],
runtimeChunk: 'single',
+9
View File
@@ -18,6 +18,13 @@ import { AppConfig, Config } from '@backstage/config';
import { BundlingPathsOptions } from './paths';
import { ConfigSchema } from '@backstage/config-loader';
export type ModuleFederationOptions = {
// Unique name for this module federation bundle
name: string;
// Whether this is a host or remote bundle
mode: 'host' | 'remote';
};
export type BundlingOptions = {
checksEnabled: boolean;
isDev: boolean;
@@ -29,6 +36,7 @@ export type BundlingOptions = {
publicSubPath?: string;
// Mode that the app is running in, 'protected' or 'public', default is 'public'
appMode?: string;
moduleFederation?: ModuleFederationOptions;
};
export type ServeOptions = BundlingPathsOptions & {
@@ -46,6 +54,7 @@ export type BuildOptions = BundlingPathsOptions & {
frontendConfig: Config;
frontendAppConfigs: AppConfig[];
fullConfig: Config;
moduleFederation?: ModuleFederationOptions;
};
export type BackendBundlingOptions = {