feat(module-federation): add and expose share helpers and types (#12989)
This commit is contained in:
parent
d27114c317
commit
e08d7848b3
@ -0,0 +1,2 @@
|
||||
export * from './src/models';
|
||||
export * from './src/utils';
|
||||
@ -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"
|
||||
}
|
||||
|
||||
43
packages/module-federation/src/models/index.ts
Normal file
43
packages/module-federation/src/models/index.ts
Normal 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;
|
||||
}
|
||||
158
packages/module-federation/src/utils/dependencies.spec.ts
Normal file
158
packages/module-federation/src/utils/dependencies.spec.ts
Normal 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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
75
packages/module-federation/src/utils/dependencies.ts
Normal file
75
packages/module-federation/src/utils/dependencies.ts
Normal 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;
|
||||
}
|
||||
1
packages/module-federation/src/utils/index.ts
Normal file
1
packages/module-federation/src/utils/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './share';
|
||||
16
packages/module-federation/src/utils/package-json.ts
Normal file
16
packages/module-federation/src/utils/package-json.ts
Normal 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);
|
||||
}
|
||||
141
packages/module-federation/src/utils/secondary-entry-points.ts
Normal file
141
packages/module-federation/src/utils/secondary-entry-points.ts
Normal 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
|
||||
);
|
||||
}
|
||||
393
packages/module-federation/src/utils/share.spec.ts
Normal file
393
packages/module-federation/src/utils/share.spec.ts
Normal 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 });
|
||||
}
|
||||
155
packages/module-federation/src/utils/share.ts
Normal file
155
packages/module-federation/src/utils/share.ts
Normal 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(/./, () => {}),
|
||||
};
|
||||
}
|
||||
24
packages/module-federation/src/utils/typescript.spec.ts
Normal file
24
packages/module-federation/src/utils/typescript.spec.ts
Normal 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'],
|
||||
});
|
||||
});
|
||||
});
|
||||
35
packages/module-federation/src/utils/typescript.ts
Normal file
35
packages/module-federation/src/utils/typescript.ts
Normal 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);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user