diff --git a/packages/module-federation/index.ts b/packages/module-federation/index.ts index e69de29bb2..aff2b67acd 100644 --- a/packages/module-federation/index.ts +++ b/packages/module-federation/index.ts @@ -0,0 +1,2 @@ +export * from './src/models'; +export * from './src/utils'; diff --git a/packages/module-federation/package.json b/packages/module-federation/package.json index 0253ec6179..1c452ccfbe 100644 --- a/packages/module-federation/package.json +++ b/packages/module-federation/package.json @@ -16,8 +16,8 @@ "Module Federation", "CLI" ], - "main": "src/index.js", - "typings": "src/index.d.ts", + "main": "index.js", + "typings": "index.d.ts", "license": "MIT", "bugs": { "url": "https://github.com/nrwl/nx/issues" @@ -27,7 +27,11 @@ "requirements": {}, "migrations": "./migrations.json" }, - "dependencies": {}, + "dependencies": { + "@nrwl/devkit": "file:../devkit", + "@nrwl/workspace": "file:../workspace", + "webpack": "^5.58.1" + }, "publishConfig": { "access": "public" } diff --git a/packages/module-federation/src/models/index.ts b/packages/module-federation/src/models/index.ts new file mode 100644 index 0000000000..da44d970b6 --- /dev/null +++ b/packages/module-federation/src/models/index.ts @@ -0,0 +1,43 @@ +import type { NormalModuleReplacementPlugin } from 'webpack'; + +export type ModuleFederationLibrary = { type: string; name: string }; +export type WorkspaceLibrary = { + name: string; + root: string; + importKey: string | undefined; +}; + +export type SharedWorkspaceLibraryConfig = { + getAliases: () => Record; + getLibraries: (eager?: boolean) => Record; + getReplacementPlugin: () => NormalModuleReplacementPlugin; +}; + +export type Remotes = string[] | [remoteName: string, remoteUrl: string][]; + +export interface SharedLibraryConfig { + singleton?: boolean; + strictVersion?: boolean; + requiredVersion?: false | string; + eager?: boolean; +} + +export type SharedFunction = ( + libraryName: string, + sharedConfig: SharedLibraryConfig +) => undefined | false | SharedLibraryConfig; + +export type AdditionalSharedConfig = Array< + | string + | [libraryName: string, sharedConfig: SharedLibraryConfig] + | { libraryName: string; sharedConfig: SharedLibraryConfig } +>; + +export interface ModuleFederationConfig { + name: string; + remotes?: Remotes; + library?: ModuleFederationLibrary; + exposes?: Record; + shared?: SharedFunction; + additionalShared?: AdditionalSharedConfig; +} diff --git a/packages/module-federation/src/utils/dependencies.spec.ts b/packages/module-federation/src/utils/dependencies.spec.ts new file mode 100644 index 0000000000..3e366122ae --- /dev/null +++ b/packages/module-federation/src/utils/dependencies.spec.ts @@ -0,0 +1,158 @@ +import * as tsUtils from './typescript'; +import { getDependentPackagesForProject } from './dependencies'; + +describe('getDependentPackagesForProject', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should collect npm packages and workspaces libraries without duplicates', () => { + jest.spyOn(tsUtils, 'readTsPathMappings').mockReturnValue({ + '@myorg/lib1': ['libs/lib1/src/index.ts'], + '@myorg/lib2': ['libs/lib2/src/index.ts'], + }); + + const dependencies = getDependentPackagesForProject( + { + dependencies: { + shell: [ + { source: 'shell', target: 'lib1', type: 'static' }, + { source: 'shell', target: 'lib2', type: 'static' }, + { source: 'shell', target: 'npm:lodash', type: 'static' }, + ], + lib1: [{ source: 'lib1', target: 'lib2', type: 'static' }], + lib2: [{ source: 'lib2', target: 'npm:lodash', type: 'static' }], + }, + nodes: { + shell: { + name: 'shell', + data: { root: 'apps/shell', sourceRoot: 'apps/shell/src' }, + type: 'app', + }, + lib1: { + name: 'lib1', + data: { root: 'libs/lib1', sourceRoot: 'libs/lib1/src' }, + type: 'lib', + }, + lib2: { + name: 'lib2', + data: { root: 'libs/lib2', sourceRoot: 'libs/lib2/src' }, + type: 'lib', + }, + }, + }, + 'shell' + ); + + expect(dependencies).toEqual({ + workspaceLibraries: [ + { name: 'lib1', root: 'libs/lib1', importKey: '@myorg/lib1' }, + { name: 'lib2', root: 'libs/lib2', importKey: '@myorg/lib2' }, + ], + npmPackages: ['lodash'], + }); + }); + + it('should collect workspaces libraries recursively', () => { + jest.spyOn(tsUtils, 'readTsPathMappings').mockReturnValue({ + '@myorg/lib1': ['libs/lib1/src/index.ts'], + '@myorg/lib2': ['libs/lib2/src/index.ts'], + '@myorg/lib3': ['libs/lib3/src/index.ts'], + }); + + const dependencies = getDependentPackagesForProject( + { + dependencies: { + shell: [{ source: 'shell', target: 'lib1', type: 'static' }], + lib1: [{ source: 'lib1', target: 'lib2', type: 'static' }], + lib2: [{ source: 'lib2', target: 'lib3', type: 'static' }], + }, + nodes: { + shell: { + name: 'shell', + data: { root: 'apps/shell', sourceRoot: 'apps/shell/src' }, + type: 'app', + }, + lib1: { + name: 'lib1', + data: { root: 'libs/lib1', sourceRoot: 'libs/lib1/src' }, + type: 'lib', + }, + lib2: { + name: 'lib2', + data: { root: 'libs/lib2', sourceRoot: 'libs/lib2/src' }, + type: 'lib', + }, + lib3: { + name: 'lib3', + data: { root: 'libs/lib3', sourceRoot: 'libs/lib3/src' }, + type: 'lib', + }, + }, + }, + 'shell' + ); + + expect(dependencies).toEqual({ + workspaceLibraries: [ + { name: 'lib1', root: 'libs/lib1', importKey: '@myorg/lib1' }, + { name: 'lib2', root: 'libs/lib2', importKey: '@myorg/lib2' }, + { name: 'lib3', root: 'libs/lib3', importKey: '@myorg/lib3' }, + ], + npmPackages: [], + }); + }); + + it('should ignore TS path mappings with wildcards', () => { + jest.spyOn(tsUtils, 'readTsPathMappings').mockReturnValue({ + '@myorg/lib1': ['libs/lib1/src/index.ts'], + '@myorg/lib1/*': ['libs/lib1/src/lib/*'], + '@myorg/lib2': ['libs/lib2/src/index.ts'], + '@myorg/lib2/*': ['libs/lib2/src/lib/*'], + '@myorg/lib3': ['libs/lib3/src/index.ts'], + '@myorg/lib3/*': ['libs/lib3/src/lib/*'], + }); + + const dependencies = getDependentPackagesForProject( + { + dependencies: { + shell: [{ source: 'shell', target: 'lib1', type: 'static' }], + lib1: [{ source: 'lib1', target: 'lib2', type: 'static' }], + lib2: [{ source: 'lib2', target: 'lib3', type: 'static' }], + }, + nodes: { + shell: { + name: 'shell', + data: { root: 'apps/shell', sourceRoot: 'apps/shell/src' }, + type: 'app', + }, + lib1: { + name: 'lib1', + data: { root: 'libs/lib1', sourceRoot: 'libs/lib1/src' }, + type: 'lib', + }, + lib2: { + name: 'lib2', + data: { root: 'libs/lib2', sourceRoot: 'libs/lib2/src' }, + type: 'lib', + }, + lib3: { + name: 'lib3', + data: { root: 'libs/lib3', sourceRoot: 'libs/lib3/src' }, + type: 'lib', + }, + }, + }, + 'shell' + ); + + expect(dependencies).toEqual({ + workspaceLibraries: [ + { name: 'lib1', root: 'libs/lib1', importKey: '@myorg/lib1' }, + { name: 'lib2', root: 'libs/lib2', importKey: '@myorg/lib2' }, + { name: 'lib3', root: 'libs/lib3', importKey: '@myorg/lib3' }, + ], + npmPackages: [], + }); + }); +}); diff --git a/packages/module-federation/src/utils/dependencies.ts b/packages/module-federation/src/utils/dependencies.ts new file mode 100644 index 0000000000..65b7d018e3 --- /dev/null +++ b/packages/module-federation/src/utils/dependencies.ts @@ -0,0 +1,75 @@ +import { ProjectGraph } from '@nrwl/devkit'; +import { readTsPathMappings } from './typescript'; + +export type WorkspaceLibrary = { + name: string; + root: string; + importKey: string | undefined; +}; + +export function getDependentPackagesForProject( + projectGraph: ProjectGraph, + name: string +): { + workspaceLibraries: WorkspaceLibrary[]; + npmPackages: string[]; +} { + const { npmPackages, workspaceLibraries } = collectDependencies( + projectGraph, + name + ); + + return { + workspaceLibraries: [...workspaceLibraries.values()], + npmPackages: [...npmPackages], + }; +} + +function collectDependencies( + projectGraph: ProjectGraph, + name: string, + dependencies = { + workspaceLibraries: new Map(), + npmPackages: new Set(), + }, + seen: Set = new Set() +): { + workspaceLibraries: Map; + npmPackages: Set; +} { + if (seen.has(name)) { + return dependencies; + } + seen.add(name); + + (projectGraph.dependencies[name] ?? []).forEach((dependency) => { + if (dependency.target.startsWith('npm:')) { + dependencies.npmPackages.add(dependency.target.replace('npm:', '')); + } else { + dependencies.workspaceLibraries.set(dependency.target, { + name: dependency.target, + root: projectGraph.nodes[dependency.target].data.root, + importKey: getLibraryImportPath(dependency.target, projectGraph), + }); + collectDependencies(projectGraph, dependency.target, dependencies, seen); + } + }); + + return dependencies; +} + +function getLibraryImportPath( + library: string, + projectGraph: ProjectGraph +): string | undefined { + const tsConfigPathMappings = readTsPathMappings(); + + const sourceRoot = projectGraph.nodes[library].data.sourceRoot; + for (const [key, value] of Object.entries(tsConfigPathMappings)) { + if (value.find((path) => path.startsWith(sourceRoot))) { + return key; + } + } + + return undefined; +} diff --git a/packages/module-federation/src/utils/index.ts b/packages/module-federation/src/utils/index.ts new file mode 100644 index 0000000000..c46d87b31a --- /dev/null +++ b/packages/module-federation/src/utils/index.ts @@ -0,0 +1 @@ +export * from './share'; diff --git a/packages/module-federation/src/utils/package-json.ts b/packages/module-federation/src/utils/package-json.ts new file mode 100644 index 0000000000..943dd5bb4f --- /dev/null +++ b/packages/module-federation/src/utils/package-json.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 MF: Could not find root package.json to determine dependency versions.' + ); + } + + return readJsonFile(pkgJsonPath); +} diff --git a/packages/module-federation/src/utils/secondary-entry-points.ts b/packages/module-federation/src/utils/secondary-entry-points.ts new file mode 100644 index 0000000000..bd8e9a96f6 --- /dev/null +++ b/packages/module-federation/src/utils/secondary-entry-points.ts @@ -0,0 +1,141 @@ +import type { WorkspaceLibrary } from '../models'; +import { dirname, join, relative } from 'path'; +import { joinPathFragments, readJsonFile, workspaceRoot } from '@nrwl/devkit'; +import { existsSync, lstatSync, readdirSync } from 'fs'; +import { PackageJson, readModulePackageJson } from 'nx/src/utils/package-json'; + +export function collectWorkspaceLibrarySecondaryEntryPoints( + library: WorkspaceLibrary, + tsconfigPathAliases: Record +) { + const libraryRoot = join(workspaceRoot, library.root); + const needsSecondaryEntryPointsCollected = existsSync( + join(libraryRoot, 'ng-package.json') + ); + + const secondaryEntryPoints = []; + if (needsSecondaryEntryPointsCollected) { + const tsConfigAliasesForLibWithSecondaryEntryPoints = Object.entries( + tsconfigPathAliases + ).reduce((acc, [tsKey, tsPaths]) => { + if (!tsKey.startsWith(library.importKey)) { + return { ...acc }; + } + + if (tsPaths.some((path) => path.startsWith(`${library.root}/`))) { + acc = { ...acc, [tsKey]: tsPaths }; + } + + return acc; + }, {}); + + for (const [alias] of Object.entries( + tsConfigAliasesForLibWithSecondaryEntryPoints + )) { + const pathToLib = dirname( + join(workspaceRoot, tsconfigPathAliases[alias][0]) + ); + let searchDir = pathToLib; + while (searchDir !== libraryRoot) { + if (existsSync(join(searchDir, 'ng-package.json'))) { + secondaryEntryPoints.push({ name: alias, path: pathToLib }); + break; + } + searchDir = dirname(searchDir); + } + } + } + + return secondaryEntryPoints; +} + +export function getNonNodeModulesSubDirs(directory: string): string[] { + return readdirSync(directory) + .filter((file) => file !== 'node_modules') + .map((file) => join(directory, file)) + .filter((file) => lstatSync(file).isDirectory()); +} + +export function recursivelyCollectSecondaryEntryPointsFromDirectory( + pkgName: string, + pkgVersion: string, + pkgRoot: string, + mainEntryPointExports: any | undefined, + directories: string[], + collectedPackages: { name: string; version: string }[] +): void { + for (const directory of directories) { + const packageJsonPath = join(directory, 'package.json'); + const relativeEntryPointPath = relative(pkgRoot, directory); + const entryPointName = joinPathFragments(pkgName, relativeEntryPointPath); + if (existsSync(packageJsonPath)) { + try { + // require the secondary entry point to try to rule out sample code + require.resolve(entryPointName, { paths: [workspaceRoot] }); + const { name } = readJsonFile(packageJsonPath); + // further check to make sure what we were able to require is the + // same as the package name + if (name === entryPointName) { + collectedPackages.push({ name, version: pkgVersion }); + } + } catch {} + } else if (mainEntryPointExports) { + // if the package.json doesn't exist, check if the directory is + // exported by the main entry point + const entryPointExportKey = `./${relativeEntryPointPath}`; + const entryPointInfo = mainEntryPointExports[entryPointExportKey]; + if (entryPointInfo) { + collectedPackages.push({ + name: entryPointName, + version: pkgVersion, + }); + } + } + + const subDirs = getNonNodeModulesSubDirs(directory); + recursivelyCollectSecondaryEntryPointsFromDirectory( + pkgName, + pkgVersion, + pkgRoot, + mainEntryPointExports, + subDirs, + collectedPackages + ); + } +} + +export function collectPackageSecondaryEntryPoints( + pkgName: string, + pkgVersion: string, + collectedPackages: { name: string; version: string }[] +): void { + let pathToPackage: string; + let packageJsonPath: string; + let packageJson: PackageJson; + try { + ({ path: packageJsonPath, packageJson } = readModulePackageJson(pkgName)); + pathToPackage = dirname(packageJsonPath); + } catch { + // the package.json might not resolve if the package has the "exports" + // entry and is not exporting the package.json file, fall back to trying + // to find it from the top-level node_modules + pathToPackage = join(workspaceRoot, 'node_modules', pkgName); + packageJsonPath = join(pathToPackage, 'package.json'); + if (!existsSync(packageJsonPath)) { + // might not exist if it's nested in another package, just return here + return; + } + packageJson = readJsonFile(packageJsonPath); + } + + const { exports } = packageJson; + const subDirs = getNonNodeModulesSubDirs(pathToPackage); + recursivelyCollectSecondaryEntryPointsFromDirectory( + pkgName, + pkgVersion, + pathToPackage, + exports, + subDirs, + collectedPackages + ); +} diff --git a/packages/module-federation/src/utils/share.spec.ts b/packages/module-federation/src/utils/share.spec.ts new file mode 100644 index 0000000000..4e3d43c220 --- /dev/null +++ b/packages/module-federation/src/utils/share.spec.ts @@ -0,0 +1,393 @@ +jest.mock('fs'); +import * as fs from 'fs'; +import * as tsUtils from './typescript'; + +import * as devkit from '@nrwl/devkit'; +import { sharePackages, shareWorkspaceLibraries } from './share'; + +describe('MF Share Utils', () => { + afterEach(() => jest.clearAllMocks()); + + describe('ShareWorkspaceLibraries', () => { + it('should error when the tsconfig file does not exist', () => { + // ARRANGE + (fs.existsSync as jest.Mock).mockReturnValue(false); + + // ACT + try { + shareWorkspaceLibraries([ + { name: 'shared', root: 'libs/shared', importKey: '@myorg/shared' }, + ]); + } catch (error) { + // ASSERT + expect(error.message).toContain( + 'NX MF: TsConfig Path for workspace libraries does not exist!' + ); + } + }); + + it('should create an object with correct setup', () => { + // ARRANGE + (fs.existsSync as jest.Mock).mockReturnValue(true); + jest.spyOn(tsUtils, 'readTsPathMappings').mockReturnValue({ + '@myorg/shared': ['/libs/shared/src/index.ts'], + }); + + // ACT + const sharedLibraries = shareWorkspaceLibraries([ + { name: 'shared', root: 'libs/shared', importKey: '@myorg/shared' }, + ]); + + // ASSERT + expect(sharedLibraries.getAliases()).toHaveProperty('@myorg/shared'); + expect(sharedLibraries.getAliases()['@myorg/shared']).toContain( + 'libs/shared/src/index.ts' + ); + expect(sharedLibraries.getLibraries()).toEqual({ + '@myorg/shared': { + eager: undefined, + requiredVersion: false, + }, + }); + }); + + it('should handle path mappings with wildcards correctly in non-buildable libraries', () => { + // ARRANGE + (fs.existsSync as jest.Mock).mockImplementation( + (file: string) => !file?.endsWith('package.json') + ); + jest.spyOn(tsUtils, 'readTsPathMappings').mockReturnValue({ + '@myorg/shared': ['/libs/shared/src/index.ts'], + '@myorg/shared/*': ['/libs/shared/src/lib/*'], + }); + + // ACT + const sharedLibraries = shareWorkspaceLibraries([ + { name: 'shared', root: 'libs/shared', importKey: '@myorg/shared' }, + ]); + + // ASSERT + expect(sharedLibraries.getAliases()).toHaveProperty('@myorg/shared'); + expect(sharedLibraries.getAliases()['@myorg/shared']).toContain( + 'libs/shared/src/index.ts' + ); + expect(sharedLibraries.getLibraries()).toEqual({ + '@myorg/shared': { + eager: undefined, + requiredVersion: false, + }, + }); + }); + + it('should create an object with empty setup when tsconfig does not contain the shared lib', () => { + // ARRANGE + (fs.existsSync as jest.Mock).mockReturnValue(true); + jest.spyOn(tsUtils, 'readTsPathMappings').mockReturnValue({}); + + // ACT + const sharedLibraries = shareWorkspaceLibraries([ + { name: 'shared', root: 'libs/shared', importKey: '@myorg/shared' }, + ]); + + // ASSERT + expect(sharedLibraries.getAliases()).toEqual({}); + expect(sharedLibraries.getLibraries()).toEqual({}); + }); + }); + + describe('SharePackages', () => { + it('should throw when it cannot find root package.json', () => { + // ARRANGE + (fs.existsSync as jest.Mock).mockReturnValue(false); + + // ACT + try { + sharePackages(['@angular/core']); + } catch (error) { + // ASSERT + expect(error.message).toEqual( + 'NX MF: Could not find root package.json to determine dependency versions.' + ); + } + }); + + it('should correctly map the shared packages to objects', () => { + // ARRANGE + (fs.existsSync as jest.Mock).mockReturnValue(true); + jest.spyOn(devkit, 'readJsonFile').mockImplementation((file) => ({ + name: file.replace(/\\/g, '/').replace(/^.*node_modules[/]/, ''), + dependencies: { + '@angular/core': '~13.2.0', + '@angular/common': '~13.2.0', + rxjs: '~7.4.0', + }, + })); + (fs.readdirSync as jest.Mock).mockReturnValue([]); + + // ACT + const packages = sharePackages([ + '@angular/core', + '@angular/common', + 'rxjs', + ]); + // ASSERT + expect(packages).toEqual({ + '@angular/core': { + singleton: true, + strictVersion: true, + requiredVersion: '~13.2.0', + }, + '@angular/common': { + singleton: true, + strictVersion: true, + requiredVersion: '~13.2.0', + }, + rxjs: { + singleton: true, + strictVersion: true, + requiredVersion: '~7.4.0', + }, + }); + }); + + it('should correctly map the shared packages to objects even with nested entry points', () => { + // ARRANGE + + /** + * This creates a bunch of mocks that aims to test that + * the sharePackages function can handle nested + * entrypoints in the package that is being shared. + * + * This will set up a directory structure that matches + * the following: + * + * - @angular/core/ + * - package.json + * - @angular/common/ + * - http/ + * - testing/ + * - package.json + * - package.json + * - rxjs + * - package.json + * + * The result is that there would be 4 packages that + * need to be shared, as determined by the folders + * containing the package.json files + */ + createMockedFSForNestedEntryPoints(); + + // ACT + const packages = sharePackages([ + '@angular/core', + '@angular/common', + 'rxjs', + ]); + // ASSERT + expect(packages).toEqual({ + '@angular/core': { + singleton: true, + strictVersion: true, + requiredVersion: '~13.2.0', + }, + '@angular/common': { + singleton: true, + strictVersion: true, + requiredVersion: '~13.2.0', + }, + '@angular/common/http/testing': { + singleton: true, + strictVersion: true, + requiredVersion: '~13.2.0', + }, + rxjs: { + singleton: true, + strictVersion: true, + requiredVersion: '~7.4.0', + }, + }); + }); + + it('should not collect a folder with a package.json when cannot be required', () => { + // ARRANGE + (fs.existsSync as jest.Mock).mockReturnValue(true); + jest.spyOn(devkit, 'readJsonFile').mockImplementation((file) => { + // the "schematics" folder is not an entry point + if (file.endsWith('@angular/core/schematics/package.json')) { + return {}; + } + + return { + name: file + .replace(/\\/g, '/') + .replace(/^.*node_modules[/]/, '') + .replace('/package.json', ''), + dependencies: { '@angular/core': '~13.2.0' }, + }; + }); + (fs.readdirSync as jest.Mock).mockImplementation( + (directoryPath: string) => { + const packages = { + '@angular/core': ['testing', 'schematics'], + }; + + for (const key of Object.keys(packages)) { + if (directoryPath.endsWith(key)) { + return packages[key]; + } + } + return []; + } + ); + (fs.lstatSync as jest.Mock).mockReturnValue({ isDirectory: () => true }); + + // ACT + const packages = sharePackages(['@angular/core']); + + // ASSERT + expect(packages).toStrictEqual({ + '@angular/core': { + singleton: true, + strictVersion: true, + requiredVersion: '~13.2.0', + }, + '@angular/core/testing': { + singleton: true, + strictVersion: true, + requiredVersion: '~13.2.0', + }, + }); + }); + + it('should collect secondary entry points from exports and fall back to lookinp up for package.json', () => { + // ARRANGE + (fs.existsSync as jest.Mock).mockImplementation( + (path) => !path.endsWith('/secondary/package.json') + ); + jest.spyOn(devkit, 'readJsonFile').mockImplementation((file) => { + if (file.endsWith('pkg1/package.json')) { + return { + name: 'pkg1', + version: '1.0.0', + exports: { + '.': './index.js', + './package.json': './package.json', + './secondary': './secondary/index.js', + }, + }; + } + + // @angular/core/package.json won't have exports, so it looks up for package.json + return { + name: file + .replace(/\\/g, '/') + .replace(/^.*node_modules[/]/, '') + .replace('/package.json', ''), + dependencies: { pkg1: '1.0.0', '@angular/core': '~13.2.0' }, + }; + }); + (fs.readdirSync as jest.Mock).mockImplementation( + (directoryPath: string) => { + const packages = { + pkg1: ['secondary'], + '@angular/core': ['testing'], + }; + + for (const key of Object.keys(packages)) { + if (directoryPath.endsWith(key)) { + return packages[key]; + } + } + return []; + } + ); + (fs.lstatSync as jest.Mock).mockReturnValue({ isDirectory: () => true }); + + // ACT + const packages = sharePackages(['pkg1', '@angular/core']); + + // ASSERT + expect(packages).toStrictEqual({ + pkg1: { + singleton: true, + strictVersion: true, + requiredVersion: '1.0.0', + }, + 'pkg1/secondary': { + singleton: true, + strictVersion: true, + requiredVersion: '1.0.0', + }, + '@angular/core': { + singleton: true, + strictVersion: true, + requiredVersion: '~13.2.0', + }, + '@angular/core/testing': { + singleton: true, + strictVersion: true, + requiredVersion: '~13.2.0', + }, + }); + }); + + it('should not throw when the main entry point package.json cannot be required', () => { + // ARRANGE + (fs.existsSync as jest.Mock).mockImplementation( + (file) => !file.endsWith('non-existent-top-level-package/package.json') + ); + jest.spyOn(devkit, 'readJsonFile').mockImplementation((file) => ({ + name: file + .replace(/\\/g, '/') + .replace(/^.*node_modules[/]/, '') + .replace('/package.json', ''), + dependencies: { '@angular/core': '~13.2.0' }, + })); + + // ACT & ASSERT + expect(() => + sharePackages(['non-existent-top-level-package']) + ).not.toThrow(); + }); + }); +}); + +function createMockedFSForNestedEntryPoints() { + (fs.existsSync as jest.Mock).mockImplementation((file: string) => { + if (file.endsWith('http/package.json')) { + return false; + } else { + return true; + } + }); + + jest.spyOn(devkit, 'readJsonFile').mockImplementation((file) => ({ + name: file + .replace(/\\/g, '/') + .replace(/^.*node_modules[/]/, '') + .replace('/package.json', ''), + dependencies: { + '@angular/core': '~13.2.0', + '@angular/common': '~13.2.0', + rxjs: '~7.4.0', + }, + })); + + (fs.readdirSync as jest.Mock).mockImplementation((directoryPath: string) => { + const PACKAGE_SETUP = { + '@angular/core': [], + '@angular/common': ['http'], + http: ['testing'], + testing: [], + }; + + for (const key of Object.keys(PACKAGE_SETUP)) { + if (directoryPath.endsWith(key)) { + return PACKAGE_SETUP[key]; + } + } + return []; + }); + + (fs.lstatSync as jest.Mock).mockReturnValue({ isDirectory: () => true }); +} diff --git a/packages/module-federation/src/utils/share.ts b/packages/module-federation/src/utils/share.ts new file mode 100644 index 0000000000..7e3cd26835 --- /dev/null +++ b/packages/module-federation/src/utils/share.ts @@ -0,0 +1,155 @@ +import type { + SharedLibraryConfig, + SharedWorkspaceLibraryConfig, + WorkspaceLibrary, +} from '../models'; +import { NormalModuleReplacementPlugin } from 'webpack'; +import { logger, workspaceRoot } from '@nrwl/devkit'; +import { dirname, join, normalize } from 'path'; +import { getRootTsConfigPath } from '@nrwl/workspace/src/utilities/typescript'; +import { readRootPackageJson } from './package-json'; +import { readTsPathMappings } from './typescript'; +import { + collectPackageSecondaryEntryPoints, + collectWorkspaceLibrarySecondaryEntryPoints, +} from './secondary-entry-points'; + +/** + * Build an object of functions to be used with the ModuleFederationPlugin to + * share Nx Workspace Libraries between Hosts and Remotes. + * + * @param libraries - The Nx Workspace Libraries to share + * @param tsConfigPath - The path to TS Config File that contains the Path Mappings for the Libraries + */ +export function shareWorkspaceLibraries( + libraries: WorkspaceLibrary[], + tsConfigPath = process.env.NX_TSCONFIG_PATH ?? getRootTsConfigPath() +): SharedWorkspaceLibraryConfig { + if (!libraries) { + return getEmptySharedLibrariesConfig(); + } + + const tsconfigPathAliases = readTsPathMappings(tsConfigPath); + if (!Object.keys(tsconfigPathAliases).length) { + return getEmptySharedLibrariesConfig(); + } + + const pathMappings: { name: string; path: string }[] = []; + for (const [key, paths] of Object.entries(tsconfigPathAliases)) { + const library = libraries.find((lib) => lib.importKey === key); + if (!library) { + continue; + } + + // This is for Angular Projects that use ng-package.json + // It will do nothing for React Projects + collectWorkspaceLibrarySecondaryEntryPoints( + library, + tsconfigPathAliases + ).forEach(({ name, path }) => + pathMappings.push({ + name, + path, + }) + ); + + pathMappings.push({ + name: key, + path: normalize(join(workspaceRoot, paths[0])), + }); + } + + return { + getAliases: () => + pathMappings.reduce( + (aliases, library) => ({ ...aliases, [library.name]: library.path }), + {} + ), + getLibraries: (eager?: boolean): Record => + pathMappings.reduce( + (libraries, library) => ({ + ...libraries, + [library.name]: { requiredVersion: false, eager }, + }), + {} as Record + ), + getReplacementPlugin: () => + new NormalModuleReplacementPlugin(/./, (req) => { + if (!req.request.startsWith('.')) { + return; + } + + const from = req.context; + const to = normalize(join(req.context, req.request)); + + for (const library of pathMappings) { + const libFolder = normalize(dirname(library.path)); + if (!from.startsWith(libFolder) && to.startsWith(libFolder)) { + req.request = library.name; + } + } + }), + }; +} + +/** + * Build the Module Federation Share Config for a specific package and the + * specified version of that package. + * @param pkgName - Name of the package to share + * @param version - Version of the package to require by other apps in the Module Federation setup + */ +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; + } + + return { singleton: true, strictVersion: true, requiredVersion: version }; +} + +/** + * Create a dictionary of packages and their Module Federation Shared Config + * from an array of package names. + * + * Lookup the versions of the packages from the root package.json file in the + * workspace. + * @param packages - Array of package names as strings + */ +export function sharePackages( + packages: string[] +): Record { + const pkgJson = readRootPackageJson(); + const allPackages: { name: string; version: string }[] = []; + packages.forEach((pkg) => { + const pkgVersion = + pkgJson.dependencies?.[pkg] ?? pkgJson.devDependencies?.[pkg]; + allPackages.push({ name: pkg, version: pkgVersion }); + collectPackageSecondaryEntryPoints(pkg, pkgVersion, allPackages); + }); + + return allPackages.reduce((shared, pkg) => { + const config = getNpmPackageSharedConfig(pkg.name, pkg.version); + if (config) { + shared[pkg.name] = config; + } + + return shared; + }, {} as Record); +} + +function getEmptySharedLibrariesConfig() { + return { + getAliases: () => ({}), + getLibraries: () => ({}), + getReplacementPlugin: () => + new NormalModuleReplacementPlugin(/./, () => {}), + }; +} diff --git a/packages/module-federation/src/utils/typescript.spec.ts b/packages/module-federation/src/utils/typescript.spec.ts new file mode 100644 index 0000000000..2411766236 --- /dev/null +++ b/packages/module-federation/src/utils/typescript.spec.ts @@ -0,0 +1,24 @@ +import * as tsUtils from '@nrwl/workspace/src/utilities/typescript'; +import * as fs from 'fs'; +import { readTsPathMappings } from './typescript'; + +describe('readTsPathMappings', () => { + it('should normalize paths', () => { + jest.spyOn(fs, 'existsSync').mockReturnValue(true); + jest.spyOn(tsUtils, 'readTsConfig').mockReturnValue({ + options: { + paths: { + '@myorg/lib1': ['./libs/lib1/src/index.ts'], + '@myorg/lib2': ['libs/lib2/src/index.ts'], + }, + }, + } as any); + + const paths = readTsPathMappings('/path/to/tsconfig.json'); + + expect(paths).toEqual({ + '@myorg/lib1': ['libs/lib1/src/index.ts'], + '@myorg/lib2': ['libs/lib2/src/index.ts'], + }); + }); +}); diff --git a/packages/module-federation/src/utils/typescript.ts b/packages/module-federation/src/utils/typescript.ts new file mode 100644 index 0000000000..668c772278 --- /dev/null +++ b/packages/module-federation/src/utils/typescript.ts @@ -0,0 +1,35 @@ +import { + getRootTsConfigPath, + readTsConfig, +} from '@nrwl/workspace/src/utilities/typescript'; +import { existsSync } from 'fs'; +import { ParsedCommandLine } from 'typescript'; + +let tsConfig: ParsedCommandLine; +let tsPathMappings: ParsedCommandLine['options']['paths']; + +export function readTsPathMappings( + tsConfigPath: string = process.env.NX_TSCONFIG_PATH ?? getRootTsConfigPath() +): ParsedCommandLine['options']['paths'] { + if (tsPathMappings) { + return tsPathMappings; + } + + tsConfig ??= readTsConfiguration(tsConfigPath); + tsPathMappings = {}; + Object.entries(tsConfig.options?.paths ?? {}).forEach(([alias, paths]) => { + tsPathMappings[alias] = paths.map((path) => path.replace(/^\.\//, '')); + }); + + return tsPathMappings; +} + +function readTsConfiguration(tsConfigPath: string): ParsedCommandLine { + if (!existsSync(tsConfigPath)) { + throw new Error( + `NX MF: TsConfig Path for workspace libraries does not exist! (${tsConfigPath}).` + ); + } + + return readTsConfig(tsConfigPath); +}