From d6bdcb8876236d98ec0ffdfb94d608479965ad15 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 22 Oct 2024 11:16:17 +0200 Subject: [PATCH 1/7] cli: remove LinkedPackageResolvePlugin Signed-off-by: Patrik Oldsberg --- .../LinkedPackageResolvePlugin.test.ts | 176 ------------------ .../lib/bundler/LinkedPackageResolvePlugin.ts | 82 -------- packages/cli/src/lib/bundler/config.ts | 6 - 3 files changed, 264 deletions(-) delete mode 100644 packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.test.ts delete mode 100644 packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.ts diff --git a/packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.test.ts b/packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.test.ts deleted file mode 100644 index a64630d6fb..0000000000 --- a/packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.test.ts +++ /dev/null @@ -1,176 +0,0 @@ -/* - * 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 * as os from 'os'; -import * as path from 'path'; -import { LinkedPackageResolvePlugin } from './LinkedPackageResolvePlugin'; - -describe('LinkedPackageResolvePlugin', () => { - const root = os.platform() === 'win32' ? 'C:\\root' : '/root'; - - it('should re-write paths for external packages', () => { - const plugin = new LinkedPackageResolvePlugin( - path.resolve(root, 'repo/node_modules'), - [ - { - dir: path.resolve(root, 'external-a'), - packageJson: { - name: 'a', - version: '1.0.0', - }, - }, - { - dir: path.resolve(root, 'external-b'), - packageJson: { - name: '@s/b', - version: '1.0.0', - }, - }, - ], - ); - - const tapAsync = jest.fn(); - const doResolve = jest.fn(); - - const resolver = { - hooks: { resolve: { tapAsync } }, - doResolve, - }; - plugin.apply(resolver); - - expect(tapAsync).toHaveBeenCalledTimes(1); - expect(tapAsync).toHaveBeenCalledWith( - 'LinkedPackageResolvePlugin', - expect.any(Function), - ); - expect(doResolve).toHaveBeenCalledTimes(0); - - // Internal module resolution is not affected - const tap = tapAsync.mock.calls[0][1]; - const callbackX = jest.fn(); - tap( - { - request: path.resolve(root, 'repo/package/x/src/module.ts'), - path: path.resolve(root, 'repo/package/x/src'), - context: { - issuer: path.resolve(root, 'repo/package/x/src/index.ts'), - }, - }, - 'some-context', - callbackX, - ); - expect(callbackX).toHaveBeenCalledTimes(1); - expect(callbackX).toHaveBeenCalledWith(); - expect(doResolve).toHaveBeenCalledTimes(0); - - // Path is sometimes false - const callbackFalse = jest.fn(); - tap( - { - request: 'sample', - path: false, - }, - 'some-context', - callbackFalse, - ); - expect(callbackFalse).toHaveBeenCalledTimes(1); - expect(callbackFalse).toHaveBeenCalledWith(); - expect(doResolve).toHaveBeenCalledTimes(0); - - // Internal modules with a path prefix of an external module - const callbackY = jest.fn(); - tap( - { - request: path.resolve(root, 'external-aa/src/module.ts'), - path: path.resolve(root, 'external-aa/src'), - context: { - issuer: path.resolve(root, 'external-aa/src/index.ts'), - }, - }, - 'some-context', - callbackY, - ); - expect(callbackY).toHaveBeenCalledTimes(1); - expect(callbackY).toHaveBeenCalledWith(); - expect(doResolve).toHaveBeenCalledTimes(0); - - // External modules have their path and issuer context rewritten, but not the request - const callbackA = jest.fn(); - tap( - { - request: path.resolve(root, 'external-a/src/module.ts'), - path: path.resolve(root, 'external-a/src'), - context: { - issuer: path.resolve(root, 'external-a/src/index.ts'), - }, - }, - 'some-context', - callbackA, - ); - expect(callbackA).toHaveBeenCalledTimes(0); - expect(doResolve).toHaveBeenCalledTimes(1); - expect(doResolve).toHaveBeenCalledWith( - resolver.hooks.resolve, - { - request: path.resolve(root, 'external-a/src/module.ts'), - path: path.resolve(root, 'repo/node_modules/a/src'), - context: { - issuer: path.resolve(root, 'repo/node_modules/a/src/index.ts'), - }, - }, - `resolve ${path.resolve( - root, - 'external-a/src/module.ts', - )} in ${path.resolve(root, 'repo/node_modules/a')}`, - 'some-context', - callbackA, - ); - - // Also handles scoped packages correctly, and issuer is not required - const callbackB = jest.fn(); - tap( - { - request: path.resolve(root, 'external-b/src/module.ts'), - path: path.resolve(root, 'external-b/src'), - context: { - issuer: false, - }, - }, - 'some-context', - callbackB, - ); - expect(callbackB).toHaveBeenCalledTimes(0); - expect(doResolve).toHaveBeenCalledTimes(2); - expect(doResolve).toHaveBeenLastCalledWith( - resolver.hooks.resolve, - { - request: path.resolve(root, 'external-b/src/module.ts'), - path: path.resolve(root, 'repo/node_modules/@s/b/src'), - context: { - issuer: false, - }, - }, - `resolve ${path.resolve( - root, - 'external-b/src/module.ts', - )} in ${path.resolve(root, 'repo/node_modules/@s/b')}`, - 'some-context', - callbackB, - ); - - expect(tapAsync).toHaveBeenCalledTimes(1); - }); -}); diff --git a/packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.ts b/packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.ts deleted file mode 100644 index 4811afec6f..0000000000 --- a/packages/cli/src/lib/bundler/LinkedPackageResolvePlugin.ts +++ /dev/null @@ -1,82 +0,0 @@ -/* - * 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 'path'; -import { WebpackPluginInstance } from 'webpack'; -import { isChildPath } from '@backstage/cli-common'; -import { Package } from '@manypkg/get-packages'; - -// Enables proper resolution of packages when linking in external packages. -// Without this the packages would depend on dependencies in the node_modules -// of the external packages themselves, leading to module duplication -export class LinkedPackageResolvePlugin implements WebpackPluginInstance { - constructor( - private readonly targetModules: string, - private readonly packages: Package[], - ) {} - - apply(resolver: any) { - resolver.hooks.resolve.tapAsync( - 'LinkedPackageResolvePlugin', - ( - data: { - request: string; - path?: false | string; - context?: { issuer?: string }; - }, - context: unknown, - callback: () => void, - ) => { - const pkg = this.packages.find( - pkge => data.path && isChildPath(pkge.dir, data.path), - ); - if (!pkg) { - callback(); - return; - } - - // pkg here is an external package. We rewrite the context of any imports to resolve - // from the location of the package within the node_modules of the target root rather - // than the real location of the external package. - const modulesLocation = resolvePath( - this.targetModules, - pkg.packageJson.name, - ); - const newContext = data.context?.issuer - ? { - ...data.context, - issuer: data.context.issuer.replace(pkg.dir, modulesLocation), - } - : data.context; - - // Re-run resolution but this time from the point of view of our target monorepo rather - // than the location of the external package. By resolving modules using this method we avoid - // pulling in e.g. `react` from the external repo, which would otherwise lead to conflicts. - resolver.doResolve( - resolver.hooks.resolve, - { - ...data, - context: newContext, - path: data.path && data.path.replace(pkg.dir, modulesLocation), - }, - `resolve ${data.request} in ${modulesLocation}`, - context, - callback, - ); - }, - ); - } -} diff --git a/packages/cli/src/lib/bundler/config.ts b/packages/cli/src/lib/bundler/config.ts index c61c117452..0bca891090 100644 --- a/packages/cli/src/lib/bundler/config.ts +++ b/packages/cli/src/lib/bundler/config.ts @@ -25,13 +25,10 @@ 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/webpack'; -import { LinkedPackageResolvePlugin } from './LinkedPackageResolvePlugin'; import ModuleScopePlugin from 'react-dev-utils/ModuleScopePlugin'; import ReactRefreshPlugin from '@pmmmwh/react-refresh-webpack-plugin'; import { paths as cliPaths } from '../../lib/paths'; import fs from 'fs-extra'; -import { getPackages } from '@manypkg/get-packages'; -import { isChildPath } from '@backstage/cli-common'; import { optimization as optimizationConfig } from './optimization'; import pickBy from 'lodash/pickBy'; import { runPlain } from '../run'; @@ -130,8 +127,6 @@ export async function createConfig( const { plugins, loaders } = transforms(options); // Any package that is part of the monorepo but outside the monorepo root dir need // separate resolution logic. - const { packages } = await getPackages(cliPaths.targetDir); - const externalPkgs = packages.filter(p => !isChildPath(paths.root, p.dir)); const validBaseUrl = resolveBaseUrl(frontendConfig, moduleFederation); let publicPath = validBaseUrl.pathname.replace(/\/$/, ''); @@ -406,7 +401,6 @@ export async function createConfig( // FIXME: see also https://github.com/web-infra-dev/rspack/issues/3408 ...(!rspack && { plugins: [ - new LinkedPackageResolvePlugin(paths.rootNodeModules, externalPkgs), new ModuleScopePlugin( [paths.targetSrc, paths.targetDev], [paths.targetPackageJson, ...reactRefreshFiles], From feef0126fcd2e0e0ae20a942f2ebdbc66111ea4b Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 22 Oct 2024 11:38:14 +0200 Subject: [PATCH 2/7] 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 & { From d8ca7dd34cf2ff4274bfe7537936a650973580d0 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 22 Oct 2024 11:52:39 +0200 Subject: [PATCH 3/7] docs/tooling/local-dev: update workspace linking docs Signed-off-by: Patrik Oldsberg --- .../local-dev/linking-local-packages.md | 86 +++---------------- 1 file changed, 13 insertions(+), 73 deletions(-) diff --git a/docs/tooling/local-dev/linking-local-packages.md b/docs/tooling/local-dev/linking-local-packages.md index eb2a2cba37..a7e967ac59 100644 --- a/docs/tooling/local-dev/linking-local-packages.md +++ b/docs/tooling/local-dev/linking-local-packages.md @@ -18,89 +18,29 @@ together: For example, you might want to make modifications to `@backstage/core-plugin-api` and try them out in your company's instance of Backstage. -## Linking in Backstage NPM Packages +## External workspace linking -To link in external packages, add them to your root `package.json` and `lerna.json` -`"workspace"` paths. These can be either relative or absolute paths with or without -globs. +:::info +Workspace linking is an experimental feature and may not work in all cases. +It currently only works for frontend packages. +::: -For example: +The `backstage-cli package start` command that is used for local development of all packages supports a `--link` flag that can be used to link a single external workspace to the current workspace. It hooks into the module resolution and will override all imports of packages in the linked workspace to be imported from there instead. The only exception are the `react` and `react-dom` packages, which will always be resolved from the target package. -```json title="/lerna.json" -... -"packages": [ - "packages/*", - "plugins/*", - "../backstage/packages/core-plugin-api", // New path added to work on @backstage/core-plugin-api -], -... -``` +When linking an external workspace, make sure that dependencies are installed and up to date in both workspaces, and that the versions of `react` and `react-dom` are the same in both workspaces. -```json title="/package.json" -... -"workspaces": { - "packages": [ - "packages/*", - "plugins/*", - "../backstage/packages/core-plugin-api", // New path added to work on @backstage/core-plugin-api - ], -} -... -``` - -Now reinstall all packages from the root to make yarn set up symlinks from your application to the core Backstage clone: +If you're within the `packages/app` folder inside your `my-backstage-application` workspace in the above example, you can link the `backstage` workspace using the following command: ```bash -yarn install +yarn start --link ../../../backstage ``` -## Making Backstage Changes +The path provided to the `--link` option can be a relative or absolute path, and should point to the root of the external workspace. -With this in place you can now modify the `@backstage/core-plugin-api` package -within the main repo, and have those changes be reflected and tested in your -app. Simply run your app using `yarn dev` (or `yarn start` for just frontend) as -normal. +With the `start` command up and running and serving the development version of your frontend app in the browser, you can now make changes to both workspaces and see the changes reflected in the browser. ## Common Problems -### Backend Issues +### React errors -For backend packages you need to make sure that linked packages are -not dependencies of any non-linked package. If you for example want to work on -`@backstage/backend-common`, you need to also link in other backend plugins and -packages that depend on `@backstage/backend-common`, or temporarily disable -those plugins in your backend. This is because the transformation of backend -module tree stops whenever a non-local package is encountered, and from that -point node will `require` packages directly for that entire module subtree. - -### Typescript Issues - -Type checking can also have issues when linking in external packages, since the -linked in packages will use the types in the external project and dependency -version mismatches between the two projects may cause errors. To fix any of -those errors you need to sync versions of the dependencies in the two projects. -A simple way to do this can be to copy over `yarn.lock` from the external -project and run `yarn install`, although this is quite intrusive and can cause -other issues in existing projects, so use this method with care. It can often be -best to simply ignore the type errors, as app serving will work just fine -anyway. - -Another issue with type checking is that the incremental type cache doesn't -invalidate correctly for the linked in packages, causing type checking to not -reflect changes made to types. You can work around this by either setting -`compilerOptions.incremental = false` in `tsconfig.json`, or by deleting the -types cache folder `dist-types` before running `yarn tsc`. - -### Version Issues - -While `yarn install` might not error, it does not mean that the linking worked properly. -You will know that linking worked properly when: - -1. Your Backstage application root `/node_modules/@backstage/[some package]` is a symlink -2. Your Backstage application `/packages/app/node_modules` and `/packages/backend/node_modules` does - not contain the package you are attempting to link! - -If you see Yarn continuing to download the package you are trying to link from NPM, you might need to be -explicit in your `package.json` version so that it exactly matches what you have in the cloned Backstage -repository on your machine. For example, if you have cloned `/plugins/catalog` with version -`"version": "1.19.1-next.1"` you will need to be explicit in your application to point to `"1.19.1-next.1"`. +If you are encountering errors related to React, it is likely that the versions of React in the two workspaces are different. Make sure that the versions of `react` and `react-dom` are the same in both workspaces, or at least that they are in sync between the package that you're serving the app from and the external workspace. From 946fa3433c0c53138011bf7a6faffa12d2cc3806 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 22 Oct 2024 11:55:24 +0200 Subject: [PATCH 4/7] changesets: added changeset for --link option Signed-off-by: Patrik Oldsberg --- .changeset/witty-socks-repeat.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/witty-socks-repeat.md diff --git a/.changeset/witty-socks-repeat.md b/.changeset/witty-socks-repeat.md new file mode 100644 index 0000000000..bbac3d9a88 --- /dev/null +++ b/.changeset/witty-socks-repeat.md @@ -0,0 +1,7 @@ +--- +'@backstage/cli': patch +--- + +Added a new `--link ` option for frontend builds that allow you to override module resolution to link in an external workspace at runtime. + +As part of this change the Webpack linked workspace resolution plugin for frontend builds has been removed. It was in place to support the old workspace linking where it was done by Yarn, which is no longer a working option. From df231cf395d32a353b2bdae32661f82c78a19f6b Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 22 Oct 2024 12:19:52 +0200 Subject: [PATCH 5/7] cli: update CLI report Signed-off-by: Patrik Oldsberg --- packages/cli/cli-report.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cli/cli-report.md b/packages/cli/cli-report.md index e17af5a312..3fa7198eba 100644 --- a/packages/cli/cli-report.md +++ b/packages/cli/cli-report.md @@ -271,6 +271,7 @@ Options: --inspect [host] --inspect-brk [host] --require + --link -h, --help ``` From d2e166cbb207d633474931fba34bc5e726524064 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 22 Oct 2024 12:54:41 +0200 Subject: [PATCH 6/7] cli: add local linking of backend packages too Signed-off-by: Patrik Oldsberg --- .../local-dev/linking-local-packages.md | 3 +- packages/cli/config/nodeTransform.cjs | 33 +++++++++++++++++++ packages/cli/src/commands/start/command.ts | 10 ------ .../cli/src/commands/start/startBackend.ts | 3 ++ packages/cli/src/lib/runner/runBackend.ts | 3 ++ 5 files changed, 41 insertions(+), 11 deletions(-) diff --git a/docs/tooling/local-dev/linking-local-packages.md b/docs/tooling/local-dev/linking-local-packages.md index a7e967ac59..92e9bd95fa 100644 --- a/docs/tooling/local-dev/linking-local-packages.md +++ b/docs/tooling/local-dev/linking-local-packages.md @@ -22,7 +22,6 @@ instance of Backstage. :::info Workspace linking is an experimental feature and may not work in all cases. -It currently only works for frontend packages. ::: The `backstage-cli package start` command that is used for local development of all packages supports a `--link` flag that can be used to link a single external workspace to the current workspace. It hooks into the module resolution and will override all imports of packages in the linked workspace to be imported from there instead. The only exception are the `react` and `react-dom` packages, which will always be resolved from the target package. @@ -39,6 +38,8 @@ The path provided to the `--link` option can be a relative or absolute path, and With the `start` command up and running and serving the development version of your frontend app in the browser, you can now make changes to both workspaces and see the changes reflected in the browser. +You can also link backend packages using the exact same process, simply start your backend package with the same `--link ` option. + ## Common Problems ### React errors diff --git a/packages/cli/config/nodeTransform.cjs b/packages/cli/config/nodeTransform.cjs index a5eb4e8fb0..9fb3cadeb5 100644 --- a/packages/cli/config/nodeTransform.cjs +++ b/packages/cli/config/nodeTransform.cjs @@ -16,6 +16,39 @@ const { transformSync } = require('@swc/core'); const { addHook } = require('pirates'); +const { Module } = require('module'); + +// This hooks into module resolution and overrides imports of packages that +// exist in the linked workspace to instead be resolved from the linked workspace. +if (process.env.LINKED_WORKSPACE) { + const { join: joinPath } = require('path'); + const { getPackagesSync } = require('@manypkg/get-packages'); + const { packages: linkedPackages, root: linkedRoot } = getPackagesSync( + process.env.LINKED_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('|')})(?:/.*)?$`, + ); + + const origLoad = Module._load; + Module._load = function requireHook(request, parent) { + if (!replacementRegex.test(request)) { + return origLoad.call(this, request, parent); + } + + // The package import that we're overriding will always existing in the root + // node_modules of the linked workspace, so it's enough to override the the + // parent paths with that single entry + return origLoad.call(this, request, { + ...parent, + paths: [joinPath(linkedRoot.dir, 'node_modules')], + }); + }; +} addHook( (code, filename) => { diff --git a/packages/cli/src/commands/start/command.ts b/packages/cli/src/commands/start/command.ts index 08d84ee2eb..10dc19cad8 100644 --- a/packages/cli/src/commands/start/command.ts +++ b/packages/cli/src/commands/start/command.ts @@ -34,20 +34,10 @@ 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/startBackend.ts b/packages/cli/src/commands/start/startBackend.ts index 4f62044de7..84f9eb0890 100644 --- a/packages/cli/src/commands/start/startBackend.ts +++ b/packages/cli/src/commands/start/startBackend.ts @@ -22,6 +22,7 @@ interface StartBackendOptions { checksEnabled: boolean; inspectEnabled: boolean; inspectBrkEnabled: boolean; + linkedWorkspace?: string; require?: string; } @@ -30,6 +31,7 @@ export async function startBackend(options: StartBackendOptions) { entry: 'src/index', inspectEnabled: options.inspectEnabled, inspectBrkEnabled: options.inspectBrkEnabled, + linkedWorkspace: options.linkedWorkspace, require: options.require, }); @@ -52,6 +54,7 @@ export async function startBackendPlugin(options: StartBackendOptions) { inspectEnabled: options.inspectEnabled, inspectBrkEnabled: options.inspectBrkEnabled, require: options.require, + linkedWorkspace: options.linkedWorkspace, }); await waitForExit(); diff --git a/packages/cli/src/lib/runner/runBackend.ts b/packages/cli/src/lib/runner/runBackend.ts index f0ce971fee..2d6722027e 100644 --- a/packages/cli/src/lib/runner/runBackend.ts +++ b/packages/cli/src/lib/runner/runBackend.ts @@ -40,6 +40,8 @@ export type RunBackendOptions = { inspectBrkEnabled: boolean; /** Additional module to require via the --require flag to the node process */ require?: string; + /** An external linked workspace to override module resolution towards */ + linkedWorkspace?: string; }; export async function runBackend(options: RunBackendOptions) { @@ -119,6 +121,7 @@ export async function runBackend(options: RunBackendOptions) { stdio: ['ignore', 'inherit', 'inherit', 'ipc'], env: { ...process.env, + LINKED_WORKSPACE: options.linkedWorkspace, BACKSTAGE_CLI_CHANNEL: '1', ESBK_TSCONFIG_PATH: paths.resolveTargetRoot('tsconfig.json'), }, From 659d28e4115efc5c943e89171a672dd55e61d1f9 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 22 Oct 2024 13:27:47 +0200 Subject: [PATCH 7/7] cli: use namespaced env var to communicate linked workspace Signed-off-by: Patrik Oldsberg --- packages/cli/config/nodeTransform.cjs | 4 ++-- packages/cli/src/lib/runner/runBackend.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cli/config/nodeTransform.cjs b/packages/cli/config/nodeTransform.cjs index 9fb3cadeb5..fc25044fd0 100644 --- a/packages/cli/config/nodeTransform.cjs +++ b/packages/cli/config/nodeTransform.cjs @@ -20,11 +20,11 @@ const { Module } = require('module'); // This hooks into module resolution and overrides imports of packages that // exist in the linked workspace to instead be resolved from the linked workspace. -if (process.env.LINKED_WORKSPACE) { +if (process.env.BACKSTAGE_CLI_LINKED_WORKSPACE) { const { join: joinPath } = require('path'); const { getPackagesSync } = require('@manypkg/get-packages'); const { packages: linkedPackages, root: linkedRoot } = getPackagesSync( - process.env.LINKED_WORKSPACE, + process.env.BACKSTAGE_CLI_LINKED_WORKSPACE, ); // Matches all packages in the linked workspaces, as well as sub-path exports from them diff --git a/packages/cli/src/lib/runner/runBackend.ts b/packages/cli/src/lib/runner/runBackend.ts index 2d6722027e..fb8fdd9d55 100644 --- a/packages/cli/src/lib/runner/runBackend.ts +++ b/packages/cli/src/lib/runner/runBackend.ts @@ -121,7 +121,7 @@ export async function runBackend(options: RunBackendOptions) { stdio: ['ignore', 'inherit', 'inherit', 'ipc'], env: { ...process.env, - LINKED_WORKSPACE: options.linkedWorkspace, + BACKSTAGE_CLI_LINKED_WORKSPACE: options.linkedWorkspace, BACKSTAGE_CLI_CHANNEL: '1', ESBK_TSCONFIG_PATH: paths.resolveTargetRoot('tsconfig.json'), },