From feef0126fcd2e0e0ae20a942f2ebdbc66111ea4b Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 22 Oct 2024 11:38:14 +0200 Subject: [PATCH] cli: add --link option for frontend start Signed-off-by: Patrik Oldsberg --- packages/cli/src/commands/index.ts | 1 + packages/cli/src/commands/start/command.ts | 11 ++++ .../cli/src/commands/start/startFrontend.ts | 2 + packages/cli/src/lib/bundler/config.ts | 10 ++++ .../cli/src/lib/bundler/linkWorkspaces.ts | 57 +++++++++++++++++++ packages/cli/src/lib/bundler/types.ts | 4 ++ 6 files changed, 85 insertions(+) create mode 100644 packages/cli/src/lib/bundler/linkWorkspaces.ts diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index 369138cb1a..674da516a8 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -138,6 +138,7 @@ export function registerScriptCommand(program: Command) { 'Enable debugger in Node.js environments, breaking before code starts', ) .option('--require ', 'Add a --require argument to the node process') + .option('--link ', 'Link an external workspace for module resolution') .action(lazy(() => import('./start').then(m => m.command))); command diff --git a/packages/cli/src/commands/start/command.ts b/packages/cli/src/commands/start/command.ts index aa46b717bb..08d84ee2eb 100644 --- a/packages/cli/src/commands/start/command.ts +++ b/packages/cli/src/commands/start/command.ts @@ -26,6 +26,7 @@ export async function command(opts: OptionValues): Promise { const options = { configPaths: opts.config as string[], checksEnabled: Boolean(opts.check), + linkedWorkspace: opts.link, inspectEnabled: opts.inspect, inspectBrkEnabled: opts.inspectBrk, require: opts.require, @@ -33,10 +34,20 @@ export async function command(opts: OptionValues): Promise { switch (role) { case 'backend': + if (options.linkedWorkspace) { + throw new Error( + 'The --link flag is not supported for this package role', + ); + } return startBackend(options); case 'backend-plugin': case 'backend-plugin-module': case 'node-library': + if (options.linkedWorkspace) { + throw new Error( + 'The --link flag is not supported for this package role', + ); + } return startBackendPlugin(options); case 'frontend': return startFrontend({ diff --git a/packages/cli/src/commands/start/startFrontend.ts b/packages/cli/src/commands/start/startFrontend.ts index 20faf4a9ff..df317799f4 100644 --- a/packages/cli/src/commands/start/startFrontend.ts +++ b/packages/cli/src/commands/start/startFrontend.ts @@ -26,6 +26,7 @@ interface StartAppOptions { configPaths: string[]; skipOpenBrowser?: boolean; isModuleFederationRemote?: boolean; + linkedWorkspace?: string; } export async function startFrontend(options: StartAppOptions) { @@ -37,6 +38,7 @@ export async function startFrontend(options: StartAppOptions) { configPaths: options.configPaths, verifyVersions: options.verifyVersions, skipOpenBrowser: options.skipOpenBrowser, + linkedWorkspace: options.linkedWorkspace, moduleFederation: getModuleFederationOptions( name, options.isModuleFederationRemote, diff --git a/packages/cli/src/lib/bundler/config.ts b/packages/cli/src/lib/bundler/config.ts index 0bca891090..aa380d3b99 100644 --- a/packages/cli/src/lib/bundler/config.ts +++ b/packages/cli/src/lib/bundler/config.ts @@ -36,6 +36,7 @@ import { transforms } from './transforms'; import { version } from '../../lib/version'; import yn from 'yn'; import { hasReactDomClient } from './hasReactDomClient'; +import { createWorkspaceLinkingPlugins } from './linkWorkspaces'; const BUILD_CACHE_ENV_VAR = 'BACKSTAGE_CLI_EXPERIMENTAL_BUILD_CACHE'; @@ -295,6 +296,15 @@ export async function createConfig( }), ); + 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. diff --git a/packages/cli/src/lib/bundler/linkWorkspaces.ts b/packages/cli/src/lib/bundler/linkWorkspaces.ts new file mode 100644 index 0000000000..ff5223725c --- /dev/null +++ b/packages/cli/src/lib/bundler/linkWorkspaces.ts @@ -0,0 +1,57 @@ +/* + * 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 'path'; +import { getPackages } from '@manypkg/get-packages'; +import webpack from 'webpack'; +import { paths } from '../paths'; + +/** + * 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 webpack, + 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(?:-dom)?$/, resource => { + if (!relativePath(linkedRoot.dir, resource.context).startsWith('..')) { + resource.context = paths.targetDir; + } + }), + ]; +} diff --git a/packages/cli/src/lib/bundler/types.ts b/packages/cli/src/lib/bundler/types.ts index 277b93f4a6..c5afb86b88 100644 --- a/packages/cli/src/lib/bundler/types.ts +++ b/packages/cli/src/lib/bundler/types.ts @@ -36,6 +36,8 @@ export type BundlingOptions = { 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; moduleFederation?: ModuleFederationOptions; rspack?: typeof import('@rspack/core').rspack; }; @@ -46,6 +48,8 @@ export type ServeOptions = BundlingPathsOptions & { verifyVersions?: boolean; skipOpenBrowser?: boolean; moduleFederation?: ModuleFederationOptions; + // An external linked workspace to include in the bundling + linkedWorkspace?: string; }; export type BuildOptions = BundlingPathsOptions & {