feat(module-federation): add and expose share helpers and types (#12989)

This commit is contained in:
Colum Ferry 2022-11-04 12:01:22 +00:00 committed by GitHub
parent d27114c317
commit e08d7848b3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 1050 additions and 3 deletions

View File

@ -0,0 +1,2 @@
export * from './src/models';
export * from './src/utils';

View File

@ -16,8 +16,8 @@
"Module Federation", "Module Federation",
"CLI" "CLI"
], ],
"main": "src/index.js", "main": "index.js",
"typings": "src/index.d.ts", "typings": "index.d.ts",
"license": "MIT", "license": "MIT",
"bugs": { "bugs": {
"url": "https://github.com/nrwl/nx/issues" "url": "https://github.com/nrwl/nx/issues"
@ -27,7 +27,11 @@
"requirements": {}, "requirements": {},
"migrations": "./migrations.json" "migrations": "./migrations.json"
}, },
"dependencies": {}, "dependencies": {
"@nrwl/devkit": "file:../devkit",
"@nrwl/workspace": "file:../workspace",
"webpack": "^5.58.1"
},
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"
} }

View File

@ -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<string, string>;
getLibraries: (eager?: boolean) => Record<string, SharedLibraryConfig>;
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<string, string>;
shared?: SharedFunction;
additionalShared?: AdditionalSharedConfig;
}

View File

@ -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: [],
});
});
});

View File

@ -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<string, WorkspaceLibrary>(),
npmPackages: new Set<string>(),
},
seen: Set<string> = new Set()
): {
workspaceLibraries: Map<string, WorkspaceLibrary>;
npmPackages: Set<string>;
} {
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;
}

View File

@ -0,0 +1 @@
export * from './share';

View File

@ -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);
}

View File

@ -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<string, string[]>
) {
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
);
}

View File

@ -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 });
}

View File

@ -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<string, SharedLibraryConfig> =>
pathMappings.reduce(
(libraries, library) => ({
...libraries,
[library.name]: { requiredVersion: false, eager },
}),
{} as Record<string, SharedLibraryConfig>
),
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<string, SharedLibraryConfig> {
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<string, SharedLibraryConfig>);
}
function getEmptySharedLibrariesConfig() {
return {
getAliases: () => ({}),
getLibraries: () => ({}),
getReplacementPlugin: () =>
new NormalModuleReplacementPlugin(/./, () => {}),
};
}

View File

@ -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'],
});
});
});

View File

@ -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);
}