Skip to content

Commit

Permalink
feat(angular): withModuleFederation helper
Browse files Browse the repository at this point in the history
  • Loading branch information
Coly010 committed Mar 11, 2022
1 parent a32d46c commit 4bc85cd
Show file tree
Hide file tree
Showing 7 changed files with 385 additions and 4 deletions.
3 changes: 2 additions & 1 deletion packages/angular/package.json
Expand Up @@ -21,7 +21,8 @@
"./generators": "./generators.js",
"./executors": "./executors.js",
"./tailwind": "./tailwind.js",
"./src/generators/utils": "./src/generators/utils/index.js"
"./src/generators/utils": "./src/generators/utils/index.js",
"./module-federation": "./src/utils/mfe/with-module-federation.js"
},
"author": "Victor Savkin",
"license": "MIT",
Expand Down
@@ -0,0 +1,67 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`withModuleFederation should create a host config correctly 1`] = `
Array [
ModuleFederationPlugin {
"_options": Object {
"exposes": undefined,
"filename": "remoteEntry.mjs",
"library": Object {
"type": "module",
},
"name": "host",
"remotes": Object {
"remote1": "http:/localhost:4201/remoteEntry.mjs",
},
"shared": Object {
"@angular/core": Object {
"requiredVersion": "~13.2.0",
"singleton": true,
"strictVersion": true,
},
"shared": Object {
"eager": undefined,
"requiredVersion": false,
},
},
},
},
NormalModuleReplacementPlugin {
"newResource": [Function],
"resourceRegExp": /\\./,
},
]
`;

exports[`withModuleFederation should create a remote config correctly 1`] = `
Array [
ModuleFederationPlugin {
"_options": Object {
"exposes": Object {
"./Module": "apps/remote1/src/module.ts",
},
"filename": "remoteEntry.mjs",
"library": Object {
"type": "module",
},
"name": "remote1",
"remotes": Object {},
"shared": Object {
"@angular/core": Object {
"requiredVersion": "~13.2.0",
"singleton": true,
"strictVersion": true,
},
"shared": Object {
"eager": undefined,
"requiredVersion": false,
},
},
},
},
NormalModuleReplacementPlugin {
"newResource": [Function],
"resourceRegExp": /\\./,
},
]
`;
Expand Up @@ -6,6 +6,13 @@ import { normalizePath, joinPathFragments } from '@nrwl/devkit';
import { dirname } from 'path';
import { ParsedCommandLine } from 'typescript';

export interface SharedLibraryConfig {
singleton: boolean;
strictVersion: boolean;
requiredVersion: string;
eager: boolean;
}

export function shareWorkspaceLibraries(
libraries: string[],
tsConfigPath = process.env.NX_TSCONFIG_PATH
Expand All @@ -22,7 +29,7 @@ export function shareWorkspaceLibraries(
if (!tsconfigPathAliases) {
return {
getAliases: () => [],
getLibraries: () => [],
getLibraries: () => {},
getReplacementPlugin: () =>
new NormalModuleReplacementPlugin(/./, () => {}),
};
Expand All @@ -45,7 +52,7 @@ export function shareWorkspaceLibraries(
(aliases, library) => ({ ...aliases, [library.name]: library.path }),
{}
),
getLibraries: (eager?: boolean) =>
getLibraries: (eager?: boolean): Record<string, SharedLibraryConfig> =>
pathMappings.reduce(
(libraries, library) => ({
...libraries,
Expand All @@ -72,7 +79,9 @@ export function shareWorkspaceLibraries(
};
}

export function sharePackages(packages: string[]) {
export function sharePackages(
packages: string[]
): Record<string, SharedLibraryConfig> {
const pkgJsonPath = joinPathFragments(rootPath, 'package.json');
if (!existsSync(pkgJsonPath)) {
throw new Error(
Expand Down
107 changes: 107 additions & 0 deletions packages/angular/src/utils/mfe/with-module-federation.spec.ts
@@ -0,0 +1,107 @@
jest.mock('fs');
jest.mock('@nrwl/workspace/src/core/project-graph');
jest.mock('@nrwl/workspace');
import * as graph from '@nrwl/workspace/src/core/project-graph';
import * as workspace from '@nrwl/workspace';
import * as fs from 'fs';

import { withModuleFederation } from './with-module-federation';

describe('withModuleFederation', () => {
afterEach(() => jest.clearAllMocks());
it('should create a host config correctly', async () => {
// ARRANGE
(graph.createProjectGraphAsync as jest.Mock).mockReturnValue(
Promise.resolve({
dependencies: {
host: [
{ target: 'npm:@angular/core' },
{ target: 'npm:zone.js' },
{ target: 'shared' },
],
},
})
);

(workspace.readWorkspaceJson as jest.Mock).mockReturnValue({
projects: {
remote1: {
targets: {
serve: {
options: {
publicHost: 'http://localhost:4201',
},
},
},
},
},
});

(fs.existsSync as jest.Mock).mockReturnValue(true);
(fs.readFileSync as jest.Mock).mockReturnValue(
JSON.stringify({
dependencies: {
'@angular/core': '~13.2.0',
},
})
);

(workspace.readTsConfig as jest.Mock).mockReturnValue({
options: {
paths: {
shared: ['/libs/shared/src/index.ts'],
},
},
});

// ACT
const config = await withModuleFederation({
name: 'host',
remotes: ['remote1'],
});

// ASSERT
expect(config.plugins).toMatchSnapshot();
});

it('should create a remote config correctly', async () => {
// ARRANGE
(graph.createProjectGraphAsync as jest.Mock).mockReturnValue(
Promise.resolve({
dependencies: {
remote1: [
{ target: 'npm:@angular/core' },
{ target: 'npm:zone.js' },
{ target: 'shared' },
],
},
})
);

(fs.existsSync as jest.Mock).mockReturnValue(true);
(fs.readFileSync as jest.Mock).mockReturnValue(
JSON.stringify({
dependencies: {
'@angular/core': '~13.2.0',
},
})
);

(workspace.readTsConfig as jest.Mock).mockReturnValue({
options: {
paths: {
shared: ['/libs/shared/src/index.ts'],
},
},
});

// ACT
const config = await withModuleFederation({
name: 'remote1',
exposes: { './Module': 'apps/remote1/src/module.ts' },
});

// ASSERT
expect(config.plugins).toMatchSnapshot();
});
});
137 changes: 137 additions & 0 deletions packages/angular/src/utils/mfe/with-module-federation.ts
@@ -0,0 +1,137 @@
import {
SharedLibraryConfig,
sharePackages,
shareWorkspaceLibraries,
} from './mfe-webpack';
import { createProjectGraphAsync } from '@nrwl/workspace/src/core/project-graph';
import { readWorkspaceJson } from '@nrwl/workspace';
import { joinPathFragments } from '@nrwl/devkit';
import ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');

export type MFERemotes = string[] | [remoteName: string, remoteUrl: string][];

export interface MFEConfig {
name: string;
remotes?: MFERemotes;
exposes?: Record<string, string>;
shared?: (
libraryName: string,
library: SharedLibraryConfig
) => SharedLibraryConfig | false;
}

async function getDependentPackagesForProject(name: string) {
const projectGraph = await createProjectGraphAsync();
return projectGraph.dependencies[name].reduce(
(dependencies, dependency) => {
const workspaceLibraries = dependencies.workspaceLibraries;
const npmPackages = dependencies.npmPackages;

if (dependency.target.startsWith('npm')) {
npmPackages.push(dependency.target.replace('npm:', ''));
} else {
workspaceLibraries.push(dependency.target);
}

return {
workspaceLibraries,
npmPackages,
};
},
{ workspaceLibraries: [], npmPackages: [] }
);
}

function determineRemoteUrl(remote: string) {
const workspace = readWorkspaceJson();
return joinPathFragments(
workspace.projects[remote].targets.serve.options.publicHost,
'remoteEntry.mjs'
);
}

function mapRemotes(remotes: MFERemotes) {
const mappedRemotes = {};

for (const remote of remotes) {
if (Array.isArray(remote)) {
mappedRemotes[remote[0]] = remote[1];
} else if (typeof remote === 'string') {
mappedRemotes[remote] = determineRemoteUrl(remote);
}
}

return mappedRemotes;
}

export async function withModuleFederation(options: MFEConfig) {
const DEFAULT_NPM_PACKAGES_TO_AVOID = ['zone.js'];

const dependencies = await getDependentPackagesForProject(options.name);
const sharedLibraries = shareWorkspaceLibraries(
dependencies.workspaceLibraries
);

const npmPackages = sharePackages(
dependencies.npmPackages.filter(
(pkg) => !DEFAULT_NPM_PACKAGES_TO_AVOID.includes(pkg)
)
);

const sharedDependencies = {
...sharedLibraries.getLibraries(),
...npmPackages,
};

if (options.shared) {
for (const [libraryName, library] of Object.entries(sharedDependencies)) {
const mappedDependency = options.shared(libraryName, library);
if (mappedDependency === false) {
delete sharedDependencies[libraryName];
continue;
} else if (!mappedDependency) {
continue;
}

sharedDependencies[libraryName] = mappedDependency;
}
}

const mappedRemotes =
!options.remotes || options.remotes.length === 0
? {}
: mapRemotes(options.remotes);

return {
output: {
uniqueName: options.name,
publicPath: 'auto',
},
optimization: {
runtimeChunk: false,
},
resolve: {
alias: {
...sharedLibraries.getAliases(),
},
},
experiments: {
outputModule: true,
},
plugins: [
new ModuleFederationPlugin({
name: options.name,
filename: 'remoteEntry.mjs',
exposes: options.exposes,
remotes: mappedRemotes,
shared: {
...sharedDependencies,
},
library: {
type: 'module',
},
}),
sharedLibraries.getReplacementPlugin(),
],
};
}

0 comments on commit 4bc85cd

Please sign in to comment.