+
+ My Remote Library: { myLib() }
+
+
+
+ } />
+
+
+ );
+ }
+
+ export default App;
+ `
+ );
+
+ // Update e2e test to check the module
+ updateFile(
+ `${host}-e2e/src/e2e/app.cy.ts`,
+ `
+ describe('${host}', () => {
+ beforeEach(() => cy.visit('/'));
+
+ it('should display contain the remote library', () => {
+ expect(cy.get('div.remote')).to.exist;
+ expect(cy.get('div.remote').contains('My Remote Library: Hello from ${lib}'));
+ });
+ });
+
+ `
+ );
+
+ // Build host and remote
+ const buildOutput = runCLI(`build ${host}`);
+ const remoteOutput = runCLI(`build ${remote}`);
+
+ expect(buildOutput).toContain('Successfully ran target build');
+ expect(remoteOutput).toContain('Successfully ran target build');
+
+ if (runE2ETests()) {
+ const hostE2eResults = runCLI(`e2e ${host}-e2e --no-watch --verbose`);
+
+ expect(hostE2eResults).toContain('All specs passed!');
+ }
+ }, 500_000);
+ });
+
function readPort(appName: string): number {
const config = readJson(join('apps', appName, 'project.json'));
return config.targets.serve.options.port;
diff --git a/packages/react/generators.json b/packages/react/generators.json
index 857c124deeba1e..c19b87080d1d45 100644
--- a/packages/react/generators.json
+++ b/packages/react/generators.json
@@ -102,6 +102,13 @@
"schema": "./src/generators/setup-ssr/schema.json",
"description": "Set up SSR configuration for a project.",
"hidden": false
+ },
+
+ "federate-module": {
+ "factory": "./src/generators/federate-module/federate-module#federateModuleSchematic",
+ "schema": "./src/generators/federate-module/schema.json",
+ "description": "Federate a module.",
+ "hidden": false
}
},
"generators": {
@@ -218,6 +225,13 @@
"schema": "./src/generators/setup-ssr/schema.json",
"description": "Set up SSR configuration for a project.",
"hidden": false
+ },
+
+ "federate-module": {
+ "factory": "./src/generators/federate-module/federate-module#federateModuleGenerator",
+ "schema": "./src/generators/federate-module/schema.json",
+ "description": "Federate a module.",
+ "hidden": false
}
}
}
diff --git a/packages/react/src/generators/federate-module/federate-module.spec.ts b/packages/react/src/generators/federate-module/federate-module.spec.ts
new file mode 100644
index 00000000000000..1ebce3f3681031
--- /dev/null
+++ b/packages/react/src/generators/federate-module/federate-module.spec.ts
@@ -0,0 +1,101 @@
+import { Tree, getProjects } from '@nx/devkit';
+import { Schema } from './schema';
+import { Schema as remoteSchma } from '../remote/schema';
+import { federateModuleGenerator } from './federate-module';
+import { createTreeWithEmptyWorkspace } from 'nx/src/devkit-testing-exports';
+import { Linter } from '@nx/linter';
+import { remoteGeneratorInternal } from '../remote/remote';
+
+describe('federate-module', () => {
+ let tree: Tree;
+ let schema: Schema = {
+ name: 'my-federated-module',
+ remote: 'my-remote',
+ path: 'my-remote/src/my-federated-module.ts',
+ style: 'css',
+ };
+
+ beforeAll(() => {
+ tree = createTreeWithEmptyWorkspace();
+ });
+ describe('no remote', () => {
+ it('should generate a remote and e2e', async () => {
+ await federateModuleGenerator(tree, schema);
+
+ const projects = getProjects(tree);
+
+ expect(projects.get('my-remote').root).toEqual('my-remote');
+ expect(projects.get('my-remote-e2e').root).toEqual('my-remote-e2e');
+ });
+
+ it('should contain an entry for the new path for module federation', async () => {
+ await federateModuleGenerator(tree, schema);
+
+ expect(tree.exists('my-remote/module-federation.config.js')).toBe(true);
+
+ const content = tree.read(
+ 'my-remote/module-federation.config.js',
+ 'utf-8'
+ );
+ expect(content).toContain(
+ `'./my-federated-module': 'my-remote/src/my-federated-module.ts'`
+ );
+
+ const tsconfig = JSON.parse(tree.read('tsconfig.base.json', 'utf-8'));
+ expect(
+ tsconfig.compilerOptions.paths['my-remote/my-federated-module']
+ ).toEqual(['my-remote/src/my-federated-module.ts']);
+ });
+ });
+
+ describe('with remote', () => {
+ let remoteSchema: remoteSchma = {
+ name: 'my-remote',
+ e2eTestRunner: 'none',
+ skipFormat: false,
+ linter: Linter.EsLint,
+ style: 'css',
+ unitTestRunner: 'none',
+ };
+
+ beforeEach(async () => {
+ remoteSchema.name = uniq('remote');
+ await remoteGeneratorInternal(tree, remoteSchema);
+ });
+
+ it('should append the new path to the module federation config', async () => {
+ let content = tree.read(
+ `${remoteSchema.name}/module-federation.config.js`,
+ 'utf-8'
+ );
+
+ expect(content).not.toContain(
+ `'./my-federated-module': 'my-remote/src/my-federated-module.ts'`
+ );
+
+ await federateModuleGenerator(tree, {
+ ...schema,
+ remote: remoteSchema.name,
+ });
+
+ content = tree.read(
+ `${remoteSchema.name}/module-federation.config.js`,
+ 'utf-8'
+ );
+ expect(content).toContain(
+ `'./my-federated-module': 'my-remote/src/my-federated-module.ts'`
+ );
+
+ const tsconfig = JSON.parse(tree.read('tsconfig.base.json', 'utf-8'));
+ expect(
+ tsconfig.compilerOptions.paths[
+ `${remoteSchema.name}/my-federated-module`
+ ]
+ ).toEqual(['my-remote/src/my-federated-module.ts']);
+ });
+ });
+});
+
+function uniq(prefix: string) {
+ return `${prefix}${Math.floor(Math.random() * 10000000)}`;
+}
diff --git a/packages/react/src/generators/federate-module/federate-module.ts b/packages/react/src/generators/federate-module/federate-module.ts
new file mode 100644
index 00000000000000..9afb5672821c62
--- /dev/null
+++ b/packages/react/src/generators/federate-module/federate-module.ts
@@ -0,0 +1,79 @@
+import {
+ GeneratorCallback,
+ Tree,
+ convertNxGenerator,
+ formatFiles,
+ logger,
+ readJson,
+ runTasksInSerial,
+} from '@nx/devkit';
+import { Schema } from './schema';
+
+import { remoteGeneratorInternal } from '../remote/remote';
+import { addPathToExposes, checkRemoteExists } from './lib/utils';
+import { determineProjectNameAndRootOptions } from '@nx/devkit/src/generators/project-name-and-root-utils';
+import { addTsConfigPath, getRootTsConfigPathInTree } from '@nx/js';
+
+export async function federateModuleGenerator(tree: Tree, schema: Schema) {
+ const tasks: GeneratorCallback[] = [];
+ // Check remote exists
+ const remote = checkRemoteExists(tree, schema.remote);
+ const { projectName, projectRoot: remoteRoot } =
+ await determineProjectNameAndRootOptions(tree, {
+ name: schema.remote,
+ projectType: 'application',
+ projectNameAndRootFormat: schema.projectNameAndRootFormat,
+ callingGenerator: '@nx/react:federate-module',
+ });
+
+ if (!remote) {
+ // create remote
+ const remoteGenerator = await remoteGeneratorInternal(tree, {
+ name: schema.remote,
+ e2eTestRunner: schema.e2eTestRunner,
+ skipFormat: schema.skipFormat,
+ linter: schema.linter,
+ style: schema.style,
+ unitTestRunner: schema.unitTestRunner,
+ host: schema.host,
+ projectNameAndRootFormat: schema.projectNameAndRootFormat ?? 'derived',
+ });
+
+ tasks.push(remoteGenerator);
+ }
+
+ const projectRoot = remote ? remote.root : remoteRoot;
+ const remoteName = remote ? remote.name : projectName;
+
+ // add path to exposes property
+ addPathToExposes(tree, projectRoot, schema.name, schema.path);
+
+ // Add new path to tsconfig
+ const rootJSON = readJson(tree, getRootTsConfigPathInTree(tree));
+ if (!rootJSON?.compilerOptions?.paths[`${remoteName}/${schema.name}`]) {
+ addTsConfigPath(tree, `${remoteName}/${schema.name}`, [schema.path]);
+ }
+
+ if (!schema.skipFormat) {
+ await formatFiles(tree);
+ }
+
+ logger.info(
+ `✅️ Updated module federation config.
+ Now you can use the module from your host app like this:
+
+ Static import:
+ import { MyComponent } from '${schema.name}/${remoteName}';
+
+ Dynamic import:
+ import('${schema.name}/${remoteName}').then((m) => m.${remoteName});
+ `
+ );
+ return runTasksInSerial(...tasks);
+}
+
+export default federateModuleGenerator;
+
+export const federateModuleSchematic = convertNxGenerator(
+ federateModuleGenerator
+);
diff --git a/packages/react/src/generators/federate-module/lib/utils.spec.ts b/packages/react/src/generators/federate-module/lib/utils.spec.ts
new file mode 100644
index 00000000000000..5fbc63312cd806
--- /dev/null
+++ b/packages/react/src/generators/federate-module/lib/utils.spec.ts
@@ -0,0 +1,89 @@
+import * as ts from 'typescript';
+import { updateExposesProperty, createObjectEntry, findExposes } from './utils';
+import { Tree } from '@nx/devkit';
+import { createTreeWithEmptyWorkspace } from 'nx/src/devkit-testing-exports';
+
+describe('federate-module Utils', () => {
+ let tree: Tree = null;
+
+ beforeAll(() => {
+ tree = createTreeWithEmptyWorkspace();
+ });
+ describe('findExposes', () => {
+ it('should find the exposes object', () => {
+ const fileContent = `
+ module.exports = {
+ name: 'myremote',
+ exposes: {
+ './Module': './src/remote-entry.ts',
+ }
+ };
+ `;
+ const sourceFile = ts.createSourceFile(
+ 'module-federation.config.js',
+ fileContent,
+ ts.ScriptTarget.ES2015,
+ true
+ );
+ const exposesObject = findExposes(sourceFile);
+ expect(exposesObject).toBeDefined();
+ expect(exposesObject?.properties.length).toEqual(1);
+ });
+ });
+
+ describe('createObjectEntry', () => {
+ it('should update the exposes object with a new entry', () => {
+ const newEntry = createObjectEntry(
+ 'NewModule',
+ './src/new-remote-entry.ts'
+ );
+ expect(newEntry).toBeDefined();
+
+ // Creating a printer to convert AST nodes to string, for safer assertions.
+ const printer = ts.createPrinter();
+ const newEntryText = printer.printNode(
+ ts.EmitHint.Unspecified,
+ newEntry,
+ ts.createSourceFile('', '', ts.ScriptTarget.ES2015)
+ );
+
+ expect(newEntryText).toEqual(
+ `'./NewModule': './src/new-remote-entry.ts'`
+ );
+ });
+ });
+
+ describe('updateExposesProperty', () => {
+ it('should update the exposes object with a new entry', () => {
+ const moduleName = 'NewModule';
+ const modulePath = './src/new-remote-entry.ts';
+ const fileName = 'module-federation.config.js';
+
+ const fileContent = `
+ module.exports = {
+ name: 'myremote',
+ exposes: {
+ './Module': './src/remote-entry.ts',
+ }
+ };
+ `;
+
+ tree.write(fileName, fileContent);
+
+ updateExposesProperty(tree, fileName, moduleName, modulePath);
+ const printer = ts.createPrinter();
+
+ const updatedSource = ts.createSourceFile(
+ fileName,
+ tree.read(fileName).toString(),
+ ts.ScriptTarget.ES2015,
+ true
+ );
+
+ const updatedContent = printer.printFile(updatedSource);
+
+ expect(updatedContent).toContain(moduleName);
+ expect(updatedContent).toContain(modulePath);
+ });
+ });
+});
diff --git a/packages/react/src/generators/federate-module/lib/utils.ts b/packages/react/src/generators/federate-module/lib/utils.ts
new file mode 100644
index 00000000000000..eda58b50f43f22
--- /dev/null
+++ b/packages/react/src/generators/federate-module/lib/utils.ts
@@ -0,0 +1,157 @@
+import { Tree, getProjects, joinPathFragments } from '@nx/devkit';
+
+import { ensureTypescript } from '@nx/js/src/utils/typescript/ensure-typescript';
+import * as ts from 'typescript';
+
+let tsModule: typeof import('typescript');
+
+if (!tsModule) {
+ tsModule = ensureTypescript();
+}
+
+/**
+ * Adds a Module Federation path to the exposes property of the module federation config
+ * The assumption here is made the we will only update a TypeScript Module Federation file namely 'module-federation.config.js'
+ * @param tree Tree for the workspace
+ * @param projectPath Project path relative to the workspace
+ * @param moduleName The name of the module to expose
+ * @param modulePath The path to the module to expose (e.g. './src/my-lib/my-lib.ts')
+ */
+export function addPathToExposes(
+ tree: Tree,
+ projectPath: string,
+ moduleName: string,
+ modulePath: string
+) {
+ const moduleFederationConfigPath = joinPathFragments(
+ projectPath,
+ 'module-federation.config.js'
+ );
+
+ updateExposesProperty(
+ tree,
+ moduleFederationConfigPath,
+ moduleName,
+ modulePath
+ );
+}
+
+/**
+ * @param tree The workspace tree
+ * @param remoteName The name of the remote to check
+ * @returns Remote ProjectConfig if it exists, false otherwise
+ */
+export function checkRemoteExists(tree: Tree, remoteName: string) {
+ const remote = getRemote(tree, remoteName);
+ if (!remote) return false;
+ const hasModuleFederationConfig = tree.exists(
+ joinPathFragments(remote.root, 'module-federation.config.js')
+ );
+
+ return hasModuleFederationConfig ? remote : false;
+}
+
+export function getRemote(tree: Tree, remoteName: string) {
+ const projects = getProjects(tree);
+ const remote = projects.get(remoteName);
+ return remote;
+}
+
+// Check if the exposes property exists in the AST
+export function findExposes(sourceFile: ts.SourceFile) {
+ let exposesObject: ts.ObjectLiteralExpression | null = null;
+
+ const visit = (node: ts.Node) => {
+ if (
+ ts.isPropertyAssignment(node) &&
+ ts.isIdentifier(node.name) &&
+ node.name.text === 'exposes' &&
+ ts.isObjectLiteralExpression(node.initializer)
+ ) {
+ exposesObject = node.initializer;
+ } else {
+ ts.forEachChild(node, visit);
+ }
+ };
+
+ ts.forEachChild(sourceFile, visit);
+
+ return exposesObject;
+}
+
+// Create a new property assignment
+export function createObjectEntry(
+ moduleName: string,
+ modulePath: string
+): ts.PropertyAssignment {
+ return ts.factory.createPropertyAssignment(
+ ts.factory.createStringLiteral(`./${moduleName}`, true),
+ ts.factory.createStringLiteral(modulePath, true)
+ );
+}
+
+// Update the exposes property in the AST
+export function updateExposesPropertyinAST(
+ source: ts.SourceFile,
+ exposesObject: ts.ObjectLiteralExpression,
+ newEntry: ts.PropertyAssignment
+) {
+ const updatedExposes = ts.factory.updateObjectLiteralExpression(
+ exposesObject,
+ [...exposesObject.properties, newEntry]
+ );
+
+ const transform: ts.TransformerFactory