From b8c175f5e0b0ccaa9bbdbbeaf8e77d4a6856bc32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leosvel=20P=C3=A9rez=20Espinosa?= Date: Thu, 5 May 2022 15:08:55 +0100 Subject: [PATCH] feat(angular): add support for passing additional shared dependencies in the module federation config (#10156) --- .../with-module-federation.spec.ts.snap | 74 +++++++ packages/angular/src/utils/mfe/mfe-webpack.ts | 61 +++--- packages/angular/src/utils/mfe/utils.ts | 16 ++ .../utils/mfe/with-module-federation.spec.ts | 186 ++++++++++++++++++ .../src/utils/mfe/with-module-federation.ts | 130 +++++++++--- 5 files changed, 410 insertions(+), 57 deletions(-) create mode 100644 packages/angular/src/utils/mfe/utils.ts diff --git a/packages/angular/src/utils/mfe/__snapshots__/with-module-federation.spec.ts.snap b/packages/angular/src/utils/mfe/__snapshots__/with-module-federation.spec.ts.snap index 0b00a21561e76..e0e4ecd6a53e7 100644 --- a/packages/angular/src/utils/mfe/__snapshots__/with-module-federation.spec.ts.snap +++ b/packages/angular/src/utils/mfe/__snapshots__/with-module-federation.spec.ts.snap @@ -1,5 +1,79 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`withModuleFederation should apply additional shared dependencies when specified 1`] = ` +Array [ + ModuleFederationPlugin { + "_options": Object { + "exposes": Object { + "./Module": "apps/remote1/src/module.ts", + }, + "filename": "remoteEntry.mjs", + "library": Object { + "type": "module", + }, + "name": "remote1", + "remotes": Object {}, + "shared": Object { + "@angular/common": Object { + "requiredVersion": "~13.2.0", + "singleton": true, + "strictVersion": true, + }, + "@angular/core": Object { + "singleton": true, + "strictVersion": false, + }, + "@angular/router": Object { + "requiredVersion": "^13.0.0", + "singleton": false, + "strictVersion": true, + }, + "shared": Object { + "requiredVersion": false, + }, + }, + }, + }, + NormalModuleReplacementPlugin { + "newResource": [Function], + "resourceRegExp": /\\./, + }, +] +`; + +exports[`withModuleFederation should apply the user-specified shared function correctly 1`] = ` +Array [ + ModuleFederationPlugin { + "_options": Object { + "exposes": Object { + "./Module": "apps/remote1/src/module.ts", + }, + "filename": "remoteEntry.mjs", + "library": Object { + "type": "module", + }, + "name": "remote1", + "remotes": Object {}, + "shared": Object { + "@angular/core": Object { + "requiredVersion": undefined, + "singleton": true, + "strictVersion": false, + }, + "shared": Object { + "eager": undefined, + "requiredVersion": false, + }, + }, + }, + }, + NormalModuleReplacementPlugin { + "newResource": [Function], + "resourceRegExp": /\\./, + }, +] +`; + exports[`withModuleFederation should collect dependencies correctly 1`] = ` Array [ ModuleFederationPlugin { diff --git a/packages/angular/src/utils/mfe/mfe-webpack.ts b/packages/angular/src/utils/mfe/mfe-webpack.ts index 71ad35223bd1c..d3f0cd36adf30 100644 --- a/packages/angular/src/utils/mfe/mfe-webpack.ts +++ b/packages/angular/src/utils/mfe/mfe-webpack.ts @@ -12,12 +12,13 @@ import { existsSync, lstatSync, readdirSync } from 'fs'; import { dirname, join, normalize, relative } from 'path'; import { ParsedCommandLine } from 'typescript'; import { NormalModuleReplacementPlugin } from 'webpack'; +import { readRootPackageJson } from './utils'; export interface SharedLibraryConfig { - singleton: boolean; - strictVersion: boolean; - requiredVersion: string; - eager: boolean; + singleton?: boolean; + strictVersion?: boolean; + requiredVersion?: false | string; + eager?: boolean; } export function shareWorkspaceLibraries( @@ -36,7 +37,7 @@ export function shareWorkspaceLibraries( if (!tsconfigPathAliases) { return { getAliases: () => [], - getLibraries: () => {}, + getLibraries: () => ({}), getReplacementPlugin: () => new NormalModuleReplacementPlugin(/./, () => {}), }; @@ -65,7 +66,7 @@ export function shareWorkspaceLibraries( ...libraries, [library.name]: { requiredVersion: false, eager }, }), - {} + {} as Record ), getReplacementPlugin: () => new NormalModuleReplacementPlugin(/./, (req) => { @@ -150,17 +151,27 @@ function collectPackageSecondaryEntryPoints( ); } -export function sharePackages( - packages: string[] -): Record { - const pkgJsonPath = joinPathFragments(workspaceRoot, 'package.json'); - if (!existsSync(pkgJsonPath)) { - throw new Error( - 'NX MFE: Could not find root package.json to determine dependency versions.' +export function getNpmPackageSharedConfig( + pkgName: string, + version: string +): SharedLibraryConfig | undefined { + if (!version) { + logger.warn( + `Could not find a version for "${pkgName}" in the root "package.json" ` + + 'when collecting shared packages for the Module Federation setup. ' + + 'The package will not be shared.' ); + + return undefined; } - const pkgJson = readJsonFile(pkgJsonPath); + return { singleton: true, strictVersion: true, requiredVersion: version }; +} + +export function sharePackages( + packages: string[] +): Record { + const pkgJson = readRootPackageJson(); const allPackages: { name: string; version: string }[] = []; packages.forEach((pkg) => { const pkgVersion = @@ -170,23 +181,11 @@ export function sharePackages( }); return allPackages.reduce((shared, pkg) => { - if (!pkg.version) { - logger.warn( - `Could not find a version for "${pkg.name}" in the root "package.json" ` + - 'when collecting shared packages for the Module Federation setup. ' + - 'The package will not be shared.' - ); - - return shared; + const config = getNpmPackageSharedConfig(pkg.name, pkg.version); + if (config) { + shared[pkg.name] = config; } - return { - ...shared, - [pkg.name]: { - singleton: true, - strictVersion: true, - requiredVersion: pkg.version, - }, - }; - }, {}); + return shared; + }, {} as Record); } diff --git a/packages/angular/src/utils/mfe/utils.ts b/packages/angular/src/utils/mfe/utils.ts new file mode 100644 index 0000000000000..b0fbff11dea14 --- /dev/null +++ b/packages/angular/src/utils/mfe/utils.ts @@ -0,0 +1,16 @@ +import { joinPathFragments, readJsonFile, workspaceRoot } from '@nrwl/devkit'; +import { existsSync } from 'fs'; + +export function readRootPackageJson(): { + dependencies?: { [key: string]: string }; + devDependencies?: { [key: string]: string }; +} { + const pkgJsonPath = joinPathFragments(workspaceRoot, 'package.json'); + if (!existsSync(pkgJsonPath)) { + throw new Error( + 'NX MFE: Could not find root package.json to determine dependency versions.' + ); + } + + return readJsonFile(pkgJsonPath); +} diff --git a/packages/angular/src/utils/mfe/with-module-federation.spec.ts b/packages/angular/src/utils/mfe/with-module-federation.spec.ts index 18057a5d6b7e9..2fb9b92380c30 100644 --- a/packages/angular/src/utils/mfe/with-module-federation.spec.ts +++ b/packages/angular/src/utils/mfe/with-module-federation.spec.ts @@ -286,4 +286,190 @@ describe('withModuleFederation', () => { // ASSERT expect(config.plugins).toMatchSnapshot(); }); + + it('should apply the user-specified shared function correctly', async () => { + // ARRANGE + (graph.readCachedProjectGraph as jest.Mock).mockReturnValue({ + dependencies: { + remote1: [ + { target: 'npm:@angular/core' }, + { target: 'npm:zone.js' }, + { target: 'core' }, + ], + core: [{ target: 'shared' }, { target: 'npm:@angular/core' }], + }, + }); + (fs.existsSync as jest.Mock).mockReturnValue(true); + jest.spyOn(devkit, 'readJsonFile').mockImplementation(() => ({ + dependencies: { '@angular/core': '~13.2.0' }, + })); + (typescriptUtils.readTsConfig as jest.Mock).mockReturnValue({ + options: { + paths: { + shared: ['/libs/shared/src/index.ts'], + core: ['/libs/core/src/index.ts'], + }, + }, + }); + (graph.Workspaces as jest.Mock).mockReturnValue({ + readWorkspaceConfiguration: () => ({ + projects: { + shared: { + sourceRoot: '/libs/shared/src/', + }, + core: { + sourceRoot: '/libs/core/src/', + }, + }, + }), + }); + (fs.readdirSync as jest.Mock).mockReturnValue([]); + + // ACT + const config = ( + await withModuleFederation({ + name: 'remote1', + exposes: { './Module': 'apps/remote1/src/module.ts' }, + shared: (dep, config) => { + if (dep === 'core') { + return false; + } + if (dep === '@angular/core') { + return { + ...config, + strictVersion: false, + requiredVersion: undefined, + }; + } + }, + }) + )({}); + + // ASSERT + expect(config.plugins).toMatchSnapshot(); + }); + + it('should apply additional shared dependencies when specified', async () => { + // ARRANGE + (graph.readCachedProjectGraph as jest.Mock).mockReturnValue({ + dependencies: { + remote1: [{ target: 'npm:@angular/core' }, { target: 'core' }], + core: [{ target: 'npm:@angular/core' }], + }, + nodes: { shared: {} }, + externalNodes: { '@angular/common': {} }, + }); + (fs.existsSync as jest.Mock).mockReturnValue(true); + jest.spyOn(devkit, 'readJsonFile').mockImplementation(() => ({ + dependencies: { + '@angular/core': '~13.2.0', + '@angular/common': '~13.2.0', + '@angular/router': '~13.2.0', + }, + })); + (typescriptUtils.readTsConfig as jest.Mock).mockReturnValue({ + options: { + paths: { + shared: ['/libs/shared/src/index.ts'], + core: ['/libs/core/src/index.ts'], + }, + }, + }); + (graph.Workspaces as jest.Mock).mockReturnValue({ + readWorkspaceConfiguration: () => ({ + projects: { + shared: { + sourceRoot: '/libs/shared/src/', + }, + core: { + sourceRoot: '/libs/core/src/', + }, + }, + }), + }); + (fs.readdirSync as jest.Mock).mockReturnValue([]); + + // ACT + const config = ( + await withModuleFederation({ + name: 'remote1', + exposes: { './Module': 'apps/remote1/src/module.ts' }, + shared: (dep, config) => { + if (dep === 'core') { + return false; + } + }, + additionalShared: [ + '@angular/common', + [ + '@angular/core', + { + strictVersion: false, + singleton: true, + }, + ], + { + libraryName: '@angular/router', + sharedConfig: { + requiredVersion: '^13.0.0', + singleton: false, + strictVersion: true, + }, + }, + 'shared', + ], + }) + )({}); + + // ASSERT + expect(config.plugins).toMatchSnapshot(); + }); + + it('should throw when an additionalShared entry is a string and it is not in the project graph', async () => { + // ARRANGE + (graph.readCachedProjectGraph as jest.Mock).mockReturnValue({ + dependencies: { + remote1: [{ target: 'npm:@angular/core' }, { target: 'core' }], + core: [{ target: 'npm:@angular/core' }], + }, + nodes: {}, + externalNodes: {}, + }); + (fs.existsSync as jest.Mock).mockReturnValue(true); + jest.spyOn(devkit, 'readJsonFile').mockImplementation(() => ({ + dependencies: { '@angular/core': '~13.2.0' }, + })); + (typescriptUtils.readTsConfig as jest.Mock).mockReturnValue({ + options: { + paths: { + shared: ['/libs/shared/src/index.ts'], + core: ['/libs/core/src/index.ts'], + }, + }, + }); + (graph.Workspaces as jest.Mock).mockReturnValue({ + readWorkspaceConfiguration: () => ({ + projects: { + shared: { + sourceRoot: '/libs/shared/src/', + }, + core: { + sourceRoot: '/libs/core/src/', + }, + }, + }), + }); + (fs.readdirSync as jest.Mock).mockReturnValue([]); + + // ACT & ASSERT + await expect( + withModuleFederation({ + name: 'remote1', + exposes: { './Module': 'apps/remote1/src/module.ts' }, + additionalShared: ['shared'], + }) + ).rejects.toThrow( + 'The specified dependency "shared" in the additionalShared configuration does not exist in the project graph.' + ); + }); }); diff --git a/packages/angular/src/utils/mfe/with-module-federation.ts b/packages/angular/src/utils/mfe/with-module-federation.ts index 52bf9d1e4a759..bfc539ede0fb2 100644 --- a/packages/angular/src/utils/mfe/with-module-federation.ts +++ b/packages/angular/src/utils/mfe/with-module-federation.ts @@ -1,4 +1,5 @@ import { + getNpmPackageSharedConfig, SharedLibraryConfig, sharePackages, shareWorkspaceLibraries, @@ -17,17 +18,26 @@ import { import { ParsedCommandLine } from 'typescript'; import { readWorkspaceJson } from 'nx/src/project-graph/file-utils'; import ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin'); +import { readRootPackageJson } from './utils'; export type MFERemotes = string[] | [remoteName: string, remoteUrl: string][]; +type SharedFunction = ( + libraryName: string, + sharedConfig: SharedLibraryConfig +) => SharedLibraryConfig | false; +type AdditionalSharedConfig = Array< + | string + | [libraryName: string, sharedConfig: SharedLibraryConfig] + | { libraryName: string; sharedConfig: SharedLibraryConfig } +>; + export interface MFEConfig { name: string; remotes?: MFERemotes; exposes?: Record; - shared?: ( - libraryName: string, - library: SharedLibraryConfig - ) => SharedLibraryConfig | false; + shared?: SharedFunction; + additionalShared?: AdditionalSharedConfig; } function collectDependencies( @@ -94,14 +104,10 @@ function mapWorkspaceLibrariesToTsConfigImport(workspaceLibraries: string[]) { return mappedLibraries; } -async function getDependentPackagesForProject(name: string) { - let projectGraph: ProjectGraph; - try { - projectGraph = readCachedProjectGraph(); - } catch (e) { - projectGraph = await createProjectGraphAsync(); - } - +async function getDependentPackagesForProject( + projectGraph: ProjectGraph, + name: string +) { const { npmPackages, workspaceLibraries } = collectDependencies( projectGraph, name @@ -150,10 +156,89 @@ function mapRemotes(remotes: MFERemotes) { return mappedRemotes; } +function applySharedFunction( + sharedConfig: Record, + sharedFn: SharedFunction | undefined +): void { + if (!sharedFn) { + return; + } + + for (const [libraryName, library] of Object.entries(sharedConfig)) { + const mappedDependency = sharedFn(libraryName, library); + if (mappedDependency === false) { + delete sharedConfig[libraryName]; + continue; + } else if (!mappedDependency) { + continue; + } + + sharedConfig[libraryName] = mappedDependency; + } +} + +function addStringDependencyToSharedConfig( + sharedConfig: Record, + dependency: string, + projectGraph: ProjectGraph +): void { + if (projectGraph.nodes[dependency]) { + sharedConfig[dependency] = { requiredVersion: false }; + } else if (projectGraph.externalNodes?.[dependency]) { + const pkgJson = readRootPackageJson(); + const config = getNpmPackageSharedConfig( + dependency, + pkgJson.dependencies?.[dependency] ?? + pkgJson.devDependencies?.[dependency] + ); + + if (!config) { + return; + } + + sharedConfig[dependency] = config; + } else { + throw new Error( + `The specified dependency "${dependency}" in the additionalShared configuration does not exist in the project graph. ` + + `Please check your additionalShared configuration and make sure you are including valid workspace projects or npm packages.` + ); + } +} + +function applyAdditionalShared( + sharedConfig: Record, + additionalShared: AdditionalSharedConfig | undefined, + projectGraph: ProjectGraph +): void { + if (!additionalShared) { + return; + } + + for (const shared of additionalShared) { + if (typeof shared === 'string') { + addStringDependencyToSharedConfig(sharedConfig, shared, projectGraph); + } else if (Array.isArray(shared)) { + sharedConfig[shared[0]] = shared[1]; + } else if (typeof shared === 'object') { + sharedConfig[shared.libraryName] = shared.sharedConfig; + } + } +} + export async function withModuleFederation(options: MFEConfig) { const DEFAULT_NPM_PACKAGES_TO_AVOID = ['zone.js', '@nrwl/angular/mfe']; - const dependencies = await getDependentPackagesForProject(options.name); + let projectGraph: ProjectGraph; + try { + projectGraph = readCachedProjectGraph(); + } catch (e) { + projectGraph = await createProjectGraphAsync(); + } + + const dependencies = await getDependentPackagesForProject( + projectGraph, + options.name + ); const sharedLibraries = shareWorkspaceLibraries( dependencies.workspaceLibraries ); @@ -175,19 +260,12 @@ export async function withModuleFederation(options: MFEConfig) { ...npmPackages, }; - if (options.shared) { - for (const [libraryName, library] of Object.entries(sharedDependencies)) { - const mappedDependency = options.shared(libraryName, library); - if (mappedDependency === false) { - delete sharedDependencies[libraryName]; - continue; - } else if (!mappedDependency) { - continue; - } - - sharedDependencies[libraryName] = mappedDependency; - } - } + applySharedFunction(sharedDependencies, options.shared); + applyAdditionalShared( + sharedDependencies, + options.additionalShared, + projectGraph + ); const mappedRemotes = !options.remotes || options.remotes.length === 0