From 48662c357a8e5f463fc696951994bd53d0b661c9 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Fri, 11 Mar 2022 11:24:11 +0000 Subject: [PATCH] feat(angular): withModuleFederation helper --- packages/angular/package.json | 3 +- .../with-module-federation.spec.ts.snap | 101 +++++++++++++ .../src/utils/{ => mfe}/mfe-webpack.spec.ts | 0 .../src/utils/{ => mfe}/mfe-webpack.ts | 15 +- .../utils/mfe/with-module-federation.spec.ts | 107 ++++++++++++++ .../src/utils/mfe/with-module-federation.ts | 137 ++++++++++++++++++ yarn.lock | 60 ++++++++ 7 files changed, 419 insertions(+), 4 deletions(-) create mode 100644 packages/angular/src/utils/mfe/__snapshots__/with-module-federation.spec.ts.snap rename packages/angular/src/utils/{ => mfe}/mfe-webpack.spec.ts (100%) rename packages/angular/src/utils/{ => mfe}/mfe-webpack.ts (88%) create mode 100644 packages/angular/src/utils/mfe/with-module-federation.spec.ts create mode 100644 packages/angular/src/utils/mfe/with-module-federation.ts diff --git a/packages/angular/package.json b/packages/angular/package.json index e7bd942fbc4e04..79d1567ed0dcea 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -21,7 +21,8 @@ "./generators": "./generators.js", "./executors": "./executors.js", "./tailwind": "./tailwind.js", - "./src/generators/utils": "./src/generators/utils/index.js" + "./src/generators/utils": "./src/generators/utils/index.js", + "./module-federation": "./src/utils/mfe/with-module-federation.js" }, "author": "Victor Savkin", "license": "MIT", 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 new file mode 100644 index 00000000000000..56143251250a4c --- /dev/null +++ b/packages/angular/src/utils/mfe/__snapshots__/with-module-federation.spec.ts.snap @@ -0,0 +1,101 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`withModuleFederation should create a host config correctly 1`] = ` +Object { + "experiments": Object { + "outputModule": true, + }, + "optimization": Object { + "runtimeChunk": false, + }, + "output": Object { + "publicPath": "auto", + "uniqueName": "host", + }, + "plugins": Array [ + ModuleFederationPlugin { + "_options": Object { + "exposes": undefined, + "filename": "remoteEntry.mjs", + "library": Object { + "type": "module", + }, + "name": "host", + "remotes": Object { + "remote1": "http:/localhost:4201/remoteEntry.mjs", + }, + "shared": Object { + "@angular/core": Object { + "requiredVersion": "~13.2.0", + "singleton": true, + "strictVersion": true, + }, + "shared": Object { + "eager": undefined, + "requiredVersion": false, + }, + }, + }, + }, + NormalModuleReplacementPlugin { + "newResource": [Function], + "resourceRegExp": /\\./, + }, + ], + "resolve": Object { + "alias": Object { + "shared": "/Users/columferry/dev/nrwl/nx/libs/shared/src/index.ts", + }, + }, +} +`; + +exports[`withModuleFederation should create a remote config correctly 1`] = ` +Object { + "experiments": Object { + "outputModule": true, + }, + "optimization": Object { + "runtimeChunk": false, + }, + "output": Object { + "publicPath": "auto", + "uniqueName": "remote1", + }, + "plugins": 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": "~13.2.0", + "singleton": true, + "strictVersion": true, + }, + "shared": Object { + "eager": undefined, + "requiredVersion": false, + }, + }, + }, + }, + NormalModuleReplacementPlugin { + "newResource": [Function], + "resourceRegExp": /\\./, + }, + ], + "resolve": Object { + "alias": Object { + "shared": "/Users/columferry/dev/nrwl/nx/libs/shared/src/index.ts", + }, + }, +} +`; diff --git a/packages/angular/src/utils/mfe-webpack.spec.ts b/packages/angular/src/utils/mfe/mfe-webpack.spec.ts similarity index 100% rename from packages/angular/src/utils/mfe-webpack.spec.ts rename to packages/angular/src/utils/mfe/mfe-webpack.spec.ts diff --git a/packages/angular/src/utils/mfe-webpack.ts b/packages/angular/src/utils/mfe/mfe-webpack.ts similarity index 88% rename from packages/angular/src/utils/mfe-webpack.ts rename to packages/angular/src/utils/mfe/mfe-webpack.ts index 57a6ecdd1dfa22..97ffa45f93af83 100644 --- a/packages/angular/src/utils/mfe-webpack.ts +++ b/packages/angular/src/utils/mfe/mfe-webpack.ts @@ -6,6 +6,13 @@ import { normalizePath, joinPathFragments } from '@nrwl/devkit'; import { dirname } from 'path'; import { ParsedCommandLine } from 'typescript'; +export interface SharedLibraryConfig { + singleton: boolean; + strictVersion: boolean; + requiredVersion: string; + eager: boolean; +} + export function shareWorkspaceLibraries( libraries: string[], tsConfigPath = process.env.NX_TSCONFIG_PATH @@ -22,7 +29,7 @@ export function shareWorkspaceLibraries( if (!tsconfigPathAliases) { return { getAliases: () => [], - getLibraries: () => [], + getLibraries: () => {}, getReplacementPlugin: () => new NormalModuleReplacementPlugin(/./, () => {}), }; @@ -45,7 +52,7 @@ export function shareWorkspaceLibraries( (aliases, library) => ({ ...aliases, [library.name]: library.path }), {} ), - getLibraries: (eager?: boolean) => + getLibraries: (eager?: boolean): Record => pathMappings.reduce( (libraries, library) => ({ ...libraries, @@ -72,7 +79,9 @@ export function shareWorkspaceLibraries( }; } -export function sharePackages(packages: string[]) { +export function sharePackages( + packages: string[] +): Record { const pkgJsonPath = joinPathFragments(rootPath, 'package.json'); if (!existsSync(pkgJsonPath)) { throw new Error( diff --git a/packages/angular/src/utils/mfe/with-module-federation.spec.ts b/packages/angular/src/utils/mfe/with-module-federation.spec.ts new file mode 100644 index 00000000000000..06e5a775fd5dac --- /dev/null +++ b/packages/angular/src/utils/mfe/with-module-federation.spec.ts @@ -0,0 +1,107 @@ +jest.mock('fs'); +jest.mock('@nrwl/workspace/src/core/project-graph'); +jest.mock('@nrwl/workspace'); +import * as graph from '@nrwl/workspace/src/core/project-graph'; +import * as workspace from '@nrwl/workspace'; +import * as fs from 'fs'; + +import { withModuleFederation } from './with-module-federation'; + +describe('withModuleFederation', () => { + afterEach(() => jest.clearAllMocks()); + it('should create a host config correctly', async () => { + // ARRANGE + (graph.createProjectGraphAsync as jest.Mock).mockReturnValue( + Promise.resolve({ + dependencies: { + host: [ + { target: 'npm:@angular/core' }, + { target: 'npm:zone.js' }, + { target: 'shared' }, + ], + }, + }) + ); + + (workspace.readWorkspaceJson as jest.Mock).mockReturnValue({ + projects: { + remote1: { + targets: { + serve: { + options: { + publicHost: 'http://localhost:4201', + }, + }, + }, + }, + }, + }); + + (fs.existsSync as jest.Mock).mockReturnValue(true); + (fs.readFileSync as jest.Mock).mockReturnValue( + JSON.stringify({ + dependencies: { + '@angular/core': '~13.2.0', + }, + }) + ); + + (workspace.readTsConfig as jest.Mock).mockReturnValue({ + options: { + paths: { + shared: ['/libs/shared/src/index.ts'], + }, + }, + }); + + // ACT + const config = await withModuleFederation({ + name: 'host', + remotes: ['remote1'], + }); + + // ASSERT + expect(config).toMatchSnapshot(); + }); + + it('should create a remote config correctly', async () => { + // ARRANGE + (graph.createProjectGraphAsync as jest.Mock).mockReturnValue( + Promise.resolve({ + dependencies: { + remote1: [ + { target: 'npm:@angular/core' }, + { target: 'npm:zone.js' }, + { target: 'shared' }, + ], + }, + }) + ); + + (fs.existsSync as jest.Mock).mockReturnValue(true); + (fs.readFileSync as jest.Mock).mockReturnValue( + JSON.stringify({ + dependencies: { + '@angular/core': '~13.2.0', + }, + }) + ); + + (workspace.readTsConfig as jest.Mock).mockReturnValue({ + options: { + paths: { + shared: ['/libs/shared/src/index.ts'], + }, + }, + }); + + // ACT + const config = await withModuleFederation({ + name: 'remote1', + exposes: { './Module': 'apps/remote1/src/module.ts' }, + }); + + // ASSERT + expect(config).toMatchSnapshot(); + }); +}); diff --git a/packages/angular/src/utils/mfe/with-module-federation.ts b/packages/angular/src/utils/mfe/with-module-federation.ts new file mode 100644 index 00000000000000..b51d1afb48244a --- /dev/null +++ b/packages/angular/src/utils/mfe/with-module-federation.ts @@ -0,0 +1,137 @@ +import { + SharedLibraryConfig, + sharePackages, + shareWorkspaceLibraries, +} from './mfe-webpack'; +import { createProjectGraphAsync } from '@nrwl/workspace/src/core/project-graph'; +import { readWorkspaceJson } from '@nrwl/workspace'; +import { joinPathFragments } from '@nrwl/devkit'; +import ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin'); + +export type MFERemotes = string[] | [remoteName: string, remoteUrl: string][]; + +export interface MFEConfig { + name: string; + remotes?: MFERemotes; + exposes?: Record; + shared?: ( + libraryName: string, + library: SharedLibraryConfig + ) => SharedLibraryConfig | false; +} + +async function getDependentPackagesForProject(name: string) { + const projectGraph = await createProjectGraphAsync(); + return projectGraph.dependencies[name].reduce( + (dependencies, dependency) => { + const workspaceLibraries = dependencies.workspaceLibraries; + const npmPackages = dependencies.npmPackages; + + if (dependency.target.startsWith('npm')) { + npmPackages.push(dependency.target.replace('npm:', '')); + } else { + workspaceLibraries.push(dependency.target); + } + + return { + workspaceLibraries, + npmPackages, + }; + }, + { workspaceLibraries: [], npmPackages: [] } + ); +} + +function determineRemoteUrl(remote: string) { + const workspace = readWorkspaceJson(); + return joinPathFragments( + workspace.projects[remote].targets.serve.options.publicHost, + 'remoteEntry.mjs' + ); +} + +function mapRemotes(remotes: MFERemotes) { + const mappedRemotes = {}; + + for (const remote of remotes) { + if (Array.isArray(remote)) { + mappedRemotes[remote[0]] = remote[1]; + } else if (typeof remote === 'string') { + mappedRemotes[remote] = determineRemoteUrl(remote); + } + } + + return mappedRemotes; +} + +export async function withModuleFederation(options: MFEConfig) { + const DEFAULT_NPM_PACKAGES_TO_AVOID = ['zone.js']; + + const dependencies = await getDependentPackagesForProject(options.name); + const sharedLibraries = shareWorkspaceLibraries( + dependencies.workspaceLibraries + ); + + const npmPackages = sharePackages( + dependencies.npmPackages.filter( + (pkg) => !DEFAULT_NPM_PACKAGES_TO_AVOID.includes(pkg) + ) + ); + + const sharedDependencies = { + ...sharedLibraries.getLibraries(), + ...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; + } + } + + const mappedRemotes = + !options.remotes || options.remotes.length === 0 + ? {} + : mapRemotes(options.remotes); + + return { + output: { + uniqueName: options.name, + publicPath: 'auto', + }, + optimization: { + runtimeChunk: false, + }, + resolve: { + alias: { + ...sharedLibraries.getAliases(), + }, + }, + experiments: { + outputModule: true, + }, + plugins: [ + new ModuleFederationPlugin({ + name: options.name, + filename: 'remoteEntry.mjs', + exposes: options.exposes, + remotes: mappedRemotes, + shared: { + ...sharedDependencies, + }, + library: { + type: 'module', + }, + }), + sharedLibraries.getReplacementPlugin(), + ], + }; +} diff --git a/yarn.lock b/yarn.lock index 62e6eeea92d7e3..48967db279ef17 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5903,6 +5903,14 @@ "@types/eslint" "*" "@types/estree" "*" +"@types/eslint-scope@^3.7.3": + version "3.7.3" + resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.3.tgz#125b88504b61e3c8bc6f870882003253005c3224" + integrity sha512-PB3ldyrcnAicT35TWPs5IcwKD8S333HMaa2VVv4+wdvebJkjWuW/xESoB8IwRcog8HYVYamb1g/R31Qv5Bx03g== + dependencies: + "@types/eslint" "*" + "@types/estree" "*" + "@types/eslint@*": version "8.2.0" resolved "https://registry.npmjs.org/@types/eslint/-/eslint-8.2.0.tgz" @@ -5929,6 +5937,11 @@ resolved "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz" integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw== +"@types/estree@^0.0.51": + version "0.0.51" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.51.tgz#cfd70924a25a3fd32b218e5e420e6897e1ac4f40" + integrity sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ== + "@types/express-serve-static-core@*", "@types/express-serve-static-core@^4.17.18": version "4.17.28" resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.28.tgz#c47def9f34ec81dc6328d0b1b5303d1ec98d86b8" @@ -6394,6 +6407,15 @@ anymatch "^3.0.0" source-map "^0.6.0" +"@types/webpack@^5.28.0": + version "5.28.0" + resolved "https://registry.yarnpkg.com/@types/webpack/-/webpack-5.28.0.tgz#78dde06212f038d77e54116cfe69e88ae9ed2c03" + integrity sha512-8cP0CzcxUiFuA9xGJkfeVpqmWTk9nx6CWwamRGCj95ph1SmlRRk9KlCZ6avhCbZd4L68LvYT6l1kpdEnQXrF8w== + dependencies: + "@types/node" "*" + tapable "^2.2.0" + webpack "^5" + "@types/ws@^8.2.2": version "8.2.2" resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.2.2.tgz#7c5be4decb19500ae6b3d563043cd407bf366c21" @@ -11121,6 +11143,14 @@ enhanced-resolve@^5.0.0, enhanced-resolve@^5.7.0, enhanced-resolve@^5.8.3: graceful-fs "^4.2.4" tapable "^2.2.0" +enhanced-resolve@^5.9.2: + version "5.9.2" + resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.9.2.tgz#0224dcd6a43389ebfb2d55efee517e5466772dd9" + integrity sha512-GIm3fQfwLJ8YZx2smuHpBKkXC1yOk+OBEmKckVyL0i/ea8mqDEykK3ld5dgH1QYPNyT/lIllxV2LULnxCHaHkA== + dependencies: + graceful-fs "^4.2.4" + tapable "^2.2.0" + enquirer@^2.3.6, enquirer@~2.3.6: version "2.3.6" resolved "https://registry.npmjs.org/enquirer/-/enquirer-2.3.6.tgz" @@ -24579,6 +24609,36 @@ webpack@5.67.0: watchpack "^2.3.1" webpack-sources "^3.2.3" +webpack@^5: + version "5.70.0" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.70.0.tgz#3461e6287a72b5e6e2f4872700bc8de0d7500e6d" + integrity sha512-ZMWWy8CeuTTjCxbeaQI21xSswseF2oNOwc70QSKNePvmxE7XW36i7vpBMYZFAUHPwQiEbNGCEYIOOlyRbdGmxw== + dependencies: + "@types/eslint-scope" "^3.7.3" + "@types/estree" "^0.0.51" + "@webassemblyjs/ast" "1.11.1" + "@webassemblyjs/wasm-edit" "1.11.1" + "@webassemblyjs/wasm-parser" "1.11.1" + acorn "^8.4.1" + acorn-import-assertions "^1.7.6" + browserslist "^4.14.5" + chrome-trace-event "^1.0.2" + enhanced-resolve "^5.9.2" + es-module-lexer "^0.9.0" + eslint-scope "5.1.1" + events "^3.2.0" + glob-to-regexp "^0.4.1" + graceful-fs "^4.2.9" + json-parse-better-errors "^1.0.2" + loader-runner "^4.2.0" + mime-types "^2.1.27" + neo-async "^2.6.2" + schema-utils "^3.1.0" + tapable "^2.1.1" + terser-webpack-plugin "^5.1.3" + watchpack "^2.3.1" + webpack-sources "^3.2.3" + webpack@^5.37.0, webpack@^5.58.1: version "5.64.1" resolved "https://registry.npmjs.org/webpack/-/webpack-5.64.1.tgz"