diff --git a/docs/generated/manifests/menus.json b/docs/generated/manifests/menus.json
index 331218e86d..b8b1173cc7 100644
--- a/docs/generated/manifests/menus.json
+++ b/docs/generated/manifests/menus.json
@@ -6079,6 +6079,14 @@
"isExternal": false,
"disableCollapsible": false
},
+ {
+ "id": "federate-module",
+ "path": "/nx-api/angular/generators/federate-module",
+ "name": "federate-module",
+ "children": [],
+ "isExternal": false,
+ "disableCollapsible": false
+ },
{
"id": "init",
"path": "/nx-api/angular/generators/init",
diff --git a/docs/generated/manifests/nx-api.json b/docs/generated/manifests/nx-api.json
index 53f73c8372..9e0f2de6b1 100644
--- a/docs/generated/manifests/nx-api.json
+++ b/docs/generated/manifests/nx-api.json
@@ -177,6 +177,15 @@
"path": "/nx-api/angular/generators/directive",
"type": "generator"
},
+ "/nx-api/angular/generators/federate-module": {
+ "description": "Create a federated module, which is exposed by a remote and can be subsequently loaded by a host.",
+ "file": "generated/packages/angular/generators/federate-module.json",
+ "hidden": false,
+ "name": "federate-module",
+ "originalFilePath": "/packages/angular/src/generators/federate-module/schema.json",
+ "path": "/nx-api/angular/generators/federate-module",
+ "type": "generator"
+ },
"/nx-api/angular/generators/init": {
"description": "Initializes the `@nrwl/angular` plugin.",
"file": "generated/packages/angular/generators/init.json",
diff --git a/docs/generated/packages-metadata.json b/docs/generated/packages-metadata.json
index 98284e85a0..0d75fd5d3d 100644
--- a/docs/generated/packages-metadata.json
+++ b/docs/generated/packages-metadata.json
@@ -172,6 +172,15 @@
"path": "angular/generators/directive",
"type": "generator"
},
+ {
+ "description": "Create a federated module, which is exposed by a remote and can be subsequently loaded by a host.",
+ "file": "generated/packages/angular/generators/federate-module.json",
+ "hidden": false,
+ "name": "federate-module",
+ "originalFilePath": "/packages/angular/src/generators/federate-module/schema.json",
+ "path": "angular/generators/federate-module",
+ "type": "generator"
+ },
{
"description": "Initializes the `@nrwl/angular` plugin.",
"file": "generated/packages/angular/generators/init.json",
diff --git a/docs/generated/packages/angular/generators/federate-module.json b/docs/generated/packages/angular/generators/federate-module.json
new file mode 100644
index 0000000000..a23dceaf79
--- /dev/null
+++ b/docs/generated/packages/angular/generators/federate-module.json
@@ -0,0 +1,86 @@
+{
+ "name": "federate-module",
+ "factory": "./src/generators/federate-module/federate-module",
+ "schema": {
+ "$schema": "http://json-schema.org/schema",
+ "cli": "nx",
+ "$id": "NxReactFederateModule",
+ "title": "Federate Module",
+ "description": "Create a federated module, which is exposed by a remote and can be subsequently loaded by a host.",
+ "examples": [
+ {
+ "command": "nx g federate-module MyModule --path=./src/component/my-cmp.ts --remote=my-remote-app",
+ "description": "Create a federated module from my-remote-app, that exposes my-cmp from ./src/component/my-cmp.ts as MyModule."
+ }
+ ],
+ "type": "object",
+ "properties": {
+ "name": {
+ "description": "The name of the module.",
+ "type": "string",
+ "$default": { "$source": "argv", "index": 0 },
+ "x-prompt": "What name would you like to use for the module?",
+ "pattern": "^[a-zA-Z][^:]*$",
+ "x-priority": "important"
+ },
+ "path": {
+ "type": "string",
+ "description": "The path to locate the federated module.",
+ "x-prompt": "What is the path to the module to be federated?"
+ },
+ "remote": {
+ "type": "string",
+ "description": "The name of the remote.",
+ "x-prompt": "What is/should the remote be named?"
+ },
+ "projectNameAndRootFormat": {
+ "description": "Whether to generate the project name and root directory as provided (`as-provided`) or generate them composing their values and taking the configured layout into account (`derived`).",
+ "type": "string",
+ "enum": ["as-provided", "derived"]
+ },
+ "style": {
+ "description": "The file extension to be used for style files for the remote if one needs to be created.",
+ "type": "string",
+ "default": "css",
+ "enum": ["css", "scss", "sass", "less"]
+ },
+ "skipFormat": {
+ "description": "Skip formatting files.",
+ "type": "boolean",
+ "default": false,
+ "x-priority": "internal"
+ },
+ "unitTestRunner": {
+ "type": "string",
+ "enum": ["jest", "none"],
+ "description": "Test runner to use for unit tests of the remote if it needs to be created.",
+ "default": "jest"
+ },
+ "e2eTestRunner": {
+ "type": "string",
+ "enum": ["cypress", "none"],
+ "description": "Test runner to use for end to end (e2e) tests of the remote if it needs to be created.",
+ "default": "cypress"
+ },
+ "standalone": {
+ "description": "Whether to generate the remote application with standalone components if it needs to be created. _Note: This is only supported in Angular versions >= 14.1.0_",
+ "type": "boolean",
+ "default": false
+ },
+ "host": {
+ "type": "string",
+ "description": "The host / shell application for this remote."
+ }
+ },
+ "required": ["name", "path", "remote"],
+ "additionalProperties": false,
+ "presets": []
+ },
+ "x-type": "application",
+ "description": "Create a federated module, which is exposed by a remote and can be subsequently loaded by a host.",
+ "implementation": "/packages/angular/src/generators/federate-module/federate-module.ts",
+ "aliases": [],
+ "hidden": false,
+ "path": "/packages/angular/src/generators/federate-module/schema.json",
+ "type": "generator"
+}
diff --git a/docs/shared/reference/sitemap.md b/docs/shared/reference/sitemap.md
index 44e508b87c..178cb8df65 100644
--- a/docs/shared/reference/sitemap.md
+++ b/docs/shared/reference/sitemap.md
@@ -315,6 +315,7 @@
- [component-story](/nx-api/angular/generators/component-story)
- [component-test](/nx-api/angular/generators/component-test)
- [directive](/nx-api/angular/generators/directive)
+ - [federate-module](/nx-api/angular/generators/federate-module)
- [init](/nx-api/angular/generators/init)
- [library](/nx-api/angular/generators/library)
- [library-secondary-entry-point](/nx-api/angular/generators/library-secondary-entry-point)
diff --git a/e2e/angular-core/src/module-federation.test.ts b/e2e/angular-core/src/module-federation.test.ts
index 07ee72b91a..4cea70b1eb 100644
--- a/e2e/angular-core/src/module-federation.test.ts
+++ b/e2e/angular-core/src/module-federation.test.ts
@@ -7,6 +7,7 @@ import {
readJson,
runCLI,
runCommandUntil,
+ runE2ETests,
uniq,
updateFile,
} from '@nx/e2e/utils';
@@ -273,13 +274,19 @@ describe('Angular Module Federation', () => {
);
// check default generated host is built successfully
- const buildOutputSwc = runCLI(`build ${hostApp}`);
- expect(buildOutputSwc).toContain('Successfully ran target build');
+ const buildOutputSwc = await runCommandUntil(`build ${hostApp}`, (output) =>
+ output.includes('Successfully ran target build')
+ );
+ await killProcessAndPorts(buildOutputSwc.pid);
- const buildOutputTsNode = runCLI(`build ${hostApp}`, {
- env: { NX_PREFER_TS_NODE: 'true' },
- });
- expect(buildOutputTsNode).toContain('Successfully ran target build');
+ const buildOutputTsNode = await runCommandUntil(
+ `build ${hostApp}`,
+ (output) => output.includes('Successfully ran target build'),
+ {
+ env: { NX_PREFER_TS_NODE: 'true' },
+ }
+ );
+ await killProcessAndPorts(buildOutputTsNode.pid);
const processSwc = await runCommandUntil(
`serve ${hostApp} --port=${hostPort} --dev-remotes=${remoteApp}`,
@@ -302,4 +309,80 @@ describe('Angular Module Federation', () => {
await killProcessAndPorts(processTsNode.pid, hostPort, remotePort);
}, 20_000_000);
+
+ it('should federate a module from a library and update an existing remote', async () => {
+ const lib = uniq('lib');
+ const remote = uniq('remote');
+ const module = uniq('module');
+ const host = uniq('host');
+
+ runCLI(
+ `generate @nx/angular:host ${host} --remotes=${remote} --no-interactive --projectNameAndRootFormat=as-provided`
+ );
+
+ runCLI(
+ `generate @nx/js:lib ${lib} --no-interactive --projectNameAndRootFormat=as-provided`
+ );
+
+ // Federate Module
+ runCLI(
+ `generate @nx/angular:federate-module ${module} --remote=${remote} --path=${lib}/src/index.ts --no-interactive`
+ );
+
+ updateFile(`${lib}/src/index.ts`, `export { isEven } from './lib/${lib}';`);
+ updateFile(
+ `${lib}/src/lib/${lib}.ts`,
+ `export function isEven(num: number) { return num % 2 === 0; }`
+ );
+
+ // Update Host to use the module
+ updateFile(
+ `${host}/src/app/app.component.ts`,
+ `
+ import { Component } from '@angular/core';
+ import { isEven } from '${remote}/${module}';
+
+ @Component({
+ selector: 'proj-root',
+ template: \`
{{title}}
\`
+ })
+ export class AppComponent {
+ title = \`shell is \${isEven(2) ? 'even' : 'odd'}\`;
+ }`
+ );
+
+ // 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.host')).to.exist;
+ expect(cy.get('div.host').contains('shell is even'));
+ });
+ });
+
+ `
+ );
+
+ // Build host and remote
+ const buildOutput = await runCommandUntil(`build ${host}`, (output) =>
+ output.includes('Successfully ran target build')
+ );
+ await killProcessAndPorts(buildOutput.pid);
+ const remoteOutput = await runCommandUntil(`build ${remote}`, (output) =>
+ output.includes('Successfully ran target build')
+ );
+ await killProcessAndPorts(remoteOutput.pid);
+
+ if (runE2ETests()) {
+ const hostE2eResults = await runCommandUntil(
+ `e2e ${host}-e2e --no-watch --verbose`,
+ (output) => output.includes('All specs passed!')
+ );
+ await killProcessAndPorts(hostE2eResults.pid);
+ }
+ }, 500_000);
});
diff --git a/packages/angular/generators.json b/packages/angular/generators.json
index 5d632cdaec..90906d24f3 100644
--- a/packages/angular/generators.json
+++ b/packages/angular/generators.json
@@ -76,6 +76,12 @@
"schema": "./src/generators/convert-to-with-mf/schema.json",
"description": "Converts an old micro frontend configuration to use the new `withModuleFederation` helper. It will run successfully if the following conditions are met: \n - Is either a host or remote application \n - Shared npm package configurations have not been modified \n - Name used to identify the Micro Frontend application matches the project name \n\n{% callout type=\"warning\" title=\"Overrides\" %}This generator will overwrite your webpack config. If you have additional custom configuration in your config file, it will be lost!{% /callout %}"
},
+ "federate-module": {
+ "factory": "./src/generators/federate-module/federate-module.compat",
+ "schema": "./src/generators/federate-module/schema.json",
+ "x-type": "application",
+ "description": "Create a federated module, which is exposed by a remote and can be subsequently loaded by a host."
+ },
"host": {
"factory": "./src/generators/host/host.compat",
"schema": "./src/generators/host/schema.json",
@@ -193,6 +199,12 @@
"aliases": ["d"],
"description": "Generate an Angular directive."
},
+ "federate-module": {
+ "factory": "./src/generators/federate-module/federate-module",
+ "schema": "./src/generators/federate-module/schema.json",
+ "x-type": "application",
+ "description": "Create a federated module, which is exposed by a remote and can be subsequently loaded by a host."
+ },
"init": {
"factory": "./src/generators/init/init",
"schema": "./src/generators/init/schema.json",
diff --git a/packages/angular/generators.ts b/packages/angular/generators.ts
index b86fb43f9a..c831734186 100644
--- a/packages/angular/generators.ts
+++ b/packages/angular/generators.ts
@@ -4,6 +4,7 @@ export * from './src/generators/component-cypress-spec/component-cypress-spec';
export * from './src/generators/component-story/component-story';
export * from './src/generators/component/component';
export * from './src/generators/directive/directive';
+export * from './src/generators/federate-module/federate-module';
export * from './src/generators/host/host';
export * from './src/generators/init/init';
export * from './src/generators/library-secondary-entry-point/library-secondary-entry-point';
diff --git a/packages/angular/src/generators/federate-module/federate-module.compat.ts b/packages/angular/src/generators/federate-module/federate-module.compat.ts
new file mode 100644
index 0000000000..49e0658a27
--- /dev/null
+++ b/packages/angular/src/generators/federate-module/federate-module.compat.ts
@@ -0,0 +1,5 @@
+import { convertNxGenerator } from '@nx/devkit';
+import { warnForSchematicUsage } from '../utils/warn-for-schematic-usage';
+import federateModule from './federate-module';
+
+export default warnForSchematicUsage(convertNxGenerator(federateModule));
diff --git a/packages/angular/src/generators/federate-module/federate-module.spec.ts b/packages/angular/src/generators/federate-module/federate-module.spec.ts
new file mode 100644
index 0000000000..3180291799
--- /dev/null
+++ b/packages/angular/src/generators/federate-module/federate-module.spec.ts
@@ -0,0 +1,102 @@
+import { getProjects, Tree } 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 remoteGenerator from '../remote/remote';
+import { E2eTestRunner, UnitTestRunner } from '../../utils/test-runners';
+
+describe('federate-module', () => {
+ let schema: Schema = {
+ name: 'my-federated-module',
+ remote: 'my-remote',
+ path: 'apps/my-remote/src/my-federated-module.ts',
+ };
+
+ describe('no remote', () => {
+ const tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' });
+
+ beforeEach(() => {
+ tree.write(schema.path, `export const isEven = true;`);
+ });
+
+ it('should generate a remote and e2e and should contain an entry for the new path for module federation', async () => {
+ await federateModuleGenerator(tree, schema);
+
+ const projects = getProjects(tree);
+
+ expect(projects.get('my-remote').root).toEqual('apps/my-remote');
+
+ expect(tree.exists('apps/my-remote/module-federation.config.ts')).toBe(
+ true
+ );
+
+ const content = tree.read(
+ 'apps/my-remote/module-federation.config.ts',
+ 'utf-8'
+ );
+ expect(content).toContain(
+ `'./my-federated-module': 'apps/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(['apps/my-remote/src/my-federated-module.ts']);
+ });
+ });
+
+ describe('with remote', () => {
+ const tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' });
+ let remoteSchema: remoteSchma = {
+ name: 'my-remote',
+ e2eTestRunner: E2eTestRunner.Cypress,
+ skipFormat: true,
+ linter: Linter.EsLint,
+ style: 'css',
+ unitTestRunner: UnitTestRunner.Jest,
+ };
+
+ beforeEach(async () => {
+ remoteSchema.name = uniq('remote');
+ await remoteGenerator(tree, remoteSchema);
+ tree.write(schema.path, `export const isEven = true;`);
+ });
+
+ it('should append the new path to the module federation config', async () => {
+ let content = tree.read(
+ `apps/${remoteSchema.name}/module-federation.config.ts`,
+ 'utf-8'
+ );
+
+ expect(content).not.toContain(
+ `'./my-federated-module': 'apps/my-remote/src/my-federated-module.ts'`
+ );
+
+ await federateModuleGenerator(tree, {
+ ...schema,
+ remote: remoteSchema.name,
+ });
+
+ content = tree.read(
+ `apps/${remoteSchema.name}/module-federation.config.ts`,
+ 'utf-8'
+ );
+ expect(content).toContain(
+ `'./my-federated-module': 'apps/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(['apps/my-remote/src/my-federated-module.ts']);
+ });
+ });
+});
+
+function uniq(prefix: string) {
+ return `${prefix}${Math.floor(Math.random() * 10000000)}`;
+}
diff --git a/packages/angular/src/generators/federate-module/federate-module.ts b/packages/angular/src/generators/federate-module/federate-module.ts
new file mode 100644
index 0000000000..e8637789ed
--- /dev/null
+++ b/packages/angular/src/generators/federate-module/federate-module.ts
@@ -0,0 +1,56 @@
+import {
+ formatFiles,
+ logger,
+ runTasksInSerial,
+ stripIndents,
+ type Tree,
+} from '@nx/devkit';
+import { type Schema } from './schema';
+import {
+ addFileToRemoteTsconfig,
+ addPathToExposes,
+ addPathToTsConfig,
+ addRemote,
+} from './lib';
+
+export async function federateModuleGenerator(tree: Tree, schema: Schema) {
+ if (!tree.exists(schema.path)) {
+ throw new Error(stripIndents`The "path" provided does not exist. Please verify the path is correct and pointing to a file that exists in the workspace.
+
+ Path: ${schema.path}`);
+ }
+ const { tasks, projectRoot, remoteName } = await addRemote(tree, schema);
+
+ addFileToRemoteTsconfig(tree, remoteName, schema.path);
+
+ addPathToExposes(tree, {
+ projectPath: projectRoot,
+ modulePath: schema.path,
+ moduleName: schema.name,
+ });
+
+ addPathToTsConfig(tree, {
+ remoteName,
+ moduleName: schema.name,
+ pathToFile: schema.path,
+ });
+
+ if (!schema.skipFormat) {
+ await formatFiles(tree);
+ }
+
+ logger.info(
+ `✅️ Updated module federation config.
+ Now you can use the module from your remote app like this:
+
+ Static import:
+ import { MyComponent } from '${remoteName}/${schema.name}';
+
+ Dynamic import:
+ import('${remoteName}/${schema.name}').then((m) => m.MyComponent);
+ `
+ );
+ return runTasksInSerial(...tasks);
+}
+
+export default federateModuleGenerator;
diff --git a/packages/angular/src/generators/federate-module/lib/add-file-to-remote-tsconfig.ts b/packages/angular/src/generators/federate-module/lib/add-file-to-remote-tsconfig.ts
new file mode 100644
index 0000000000..b159871f2f
--- /dev/null
+++ b/packages/angular/src/generators/federate-module/lib/add-file-to-remote-tsconfig.ts
@@ -0,0 +1,22 @@
+import {
+ type Tree,
+ updateJson,
+ readProjectConfiguration,
+ offsetFromRoot,
+ joinPathFragments,
+} from '@nx/devkit';
+
+export function addFileToRemoteTsconfig(
+ tree: Tree,
+ remoteName: string,
+ pathToExpose: string
+) {
+ const remote = readProjectConfiguration(tree, remoteName);
+ updateJson(tree, remote.targets.build.options.tsConfig, (json) => ({
+ ...json,
+ files: [
+ ...json.files,
+ joinPathFragments(offsetFromRoot(remote.root), pathToExpose),
+ ],
+ }));
+}
diff --git a/packages/angular/src/generators/federate-module/lib/add-path-to-exposes.ts b/packages/angular/src/generators/federate-module/lib/add-path-to-exposes.ts
new file mode 100644
index 0000000000..9ee3de4d86
--- /dev/null
+++ b/packages/angular/src/generators/federate-module/lib/add-path-to-exposes.ts
@@ -0,0 +1,155 @@
+import type {
+ Node,
+ ObjectLiteralExpression,
+ PropertyAssignment,
+ SourceFile,
+ TransformerFactory,
+ Visitor,
+} from 'typescript';
+import { joinPathFragments, type Tree } from '@nx/devkit';
+import { ensureTypescript } from '@nx/js/src/utils/typescript/ensure-typescript';
+
+let tsModule: typeof import('typescript');
+
+type AddPathToExposesOptions = {
+ projectPath: string;
+ moduleName: string;
+ modulePath: string;
+};
+
+export function addPathToExposes(
+ tree: Tree,
+ { projectPath, moduleName, modulePath }: AddPathToExposesOptions
+) {
+ const moduleFederationConfigPath = joinPathFragments(
+ projectPath,
+ tree.exists(joinPathFragments(projectPath, 'module-federation.config.ts'))
+ ? 'module-federation.config.ts'
+ : 'module-federation.config.js'
+ );
+
+ updateExposesProperty(
+ tree,
+ moduleFederationConfigPath,
+ moduleName,
+ modulePath
+ );
+}
+
+function updateExposesProperty(
+ tree: Tree,
+ moduleFederationConfigPath: string,
+ moduleName: string,
+ modulePath: string
+) {
+ if (!tsModule) {
+ tsModule = ensureTypescript();
+ }
+
+ const fileContent = tree.read(moduleFederationConfigPath, 'utf-8');
+ const source = tsModule.createSourceFile(
+ moduleFederationConfigPath,
+ fileContent,
+ tsModule.ScriptTarget.ES2015,
+ true
+ );
+
+ const exposesObject = findExposes(source);
+ if (!exposesObject) return;
+
+ const newEntry = createObjectEntry(moduleName, modulePath);
+ const updatedSourceFile = updateExposesPropertyinAST(
+ source,
+ exposesObject,
+ newEntry
+ );
+ writeToConfig(tree, moduleFederationConfigPath, source, updatedSourceFile);
+}
+
+function findExposes(sourceFile: SourceFile) {
+ if (!tsModule) {
+ tsModule = ensureTypescript();
+ }
+
+ let exposesObject: ObjectLiteralExpression | null = null;
+
+ const visit = (node: Node) => {
+ if (
+ tsModule.isPropertyAssignment(node) &&
+ tsModule.isIdentifier(node.name) &&
+ node.name.text === 'exposes' &&
+ tsModule.isObjectLiteralExpression(node.initializer)
+ ) {
+ exposesObject = node.initializer;
+ } else {
+ tsModule.forEachChild(node, visit);
+ }
+ };
+
+ tsModule.forEachChild(sourceFile, visit);
+
+ return exposesObject;
+}
+
+// Create a new property assignment
+function createObjectEntry(
+ moduleName: string,
+ modulePath: string
+): PropertyAssignment {
+ if (!tsModule) {
+ tsModule = ensureTypescript();
+ }
+
+ return tsModule.factory.createPropertyAssignment(
+ tsModule.factory.createStringLiteral(`./${moduleName}`, true),
+ tsModule.factory.createStringLiteral(modulePath, true)
+ );
+}
+
+// Update the exposes property in the AST
+function updateExposesPropertyinAST(
+ source: SourceFile,
+ exposesObject: ObjectLiteralExpression,
+ newEntry: PropertyAssignment
+) {
+ if (!tsModule) {
+ tsModule = ensureTypescript();
+ }
+
+ const updatedExposes = tsModule.factory.updateObjectLiteralExpression(
+ exposesObject,
+ [...exposesObject.properties, newEntry]
+ );
+
+ const transform: TransformerFactory = (context) => {
+ const visit: Visitor = (node) => {
+ // Comparing nodes indirectly to ensure type compatibility. You must ensure that the nodes are identical.
+ return tsModule.isObjectLiteralExpression(node) && node === exposesObject
+ ? updatedExposes
+ : tsModule.visitEachChild(node, visit, context);
+ };
+ return (node) => tsModule.visitNode(node, visit) as SourceFile;
+ };
+
+ return tsModule.transform(source, [transform]).transformed[0];
+}
+
+// Write the updated AST to the file (module-federation.config.js)
+function writeToConfig(
+ tree: Tree,
+ filename: string,
+ source: SourceFile,
+ updatedSourceFile: SourceFile
+) {
+ if (!tsModule) {
+ tsModule = ensureTypescript();
+ }
+
+ const printer = tsModule.createPrinter();
+ const update = printer.printNode(
+ tsModule.EmitHint.Unspecified,
+ updatedSourceFile,
+ source
+ );
+ tree.write(filename, update);
+}
diff --git a/packages/angular/src/generators/federate-module/lib/add-path-to-ts-config.ts b/packages/angular/src/generators/federate-module/lib/add-path-to-ts-config.ts
new file mode 100644
index 0000000000..808254ee34
--- /dev/null
+++ b/packages/angular/src/generators/federate-module/lib/add-path-to-ts-config.ts
@@ -0,0 +1,18 @@
+import { readJson, type Tree } from '@nx/devkit';
+import { addTsConfigPath, getRootTsConfigPathInTree } from '@nx/js';
+
+type AddPathToTsConfigOptions = {
+ remoteName: string;
+ moduleName: string;
+ pathToFile: string;
+};
+
+export function addPathToTsConfig(
+ tree: Tree,
+ { remoteName, moduleName, pathToFile }: AddPathToTsConfigOptions
+) {
+ const rootTsConfig = readJson(tree, getRootTsConfigPathInTree(tree));
+ if (!rootTsConfig.compilerOptions?.paths[`${remoteName}/${moduleName}`]) {
+ addTsConfigPath(tree, `${remoteName}/${moduleName}`, [pathToFile]);
+ }
+}
diff --git a/packages/angular/src/generators/federate-module/lib/add-remote.ts b/packages/angular/src/generators/federate-module/lib/add-remote.ts
new file mode 100644
index 0000000000..3b174183a1
--- /dev/null
+++ b/packages/angular/src/generators/federate-module/lib/add-remote.ts
@@ -0,0 +1,56 @@
+import { GeneratorCallback, stripIndents, type Tree } from '@nx/devkit';
+import { determineProjectNameAndRootOptions } from '@nx/devkit/src/generators/project-name-and-root-utils';
+import { lt } from 'semver';
+import { getRemoteIfExists } from './check-remote-exists';
+import { getInstalledAngularVersionInfo } from '../../utils/version-utils';
+import { type Schema } from '../schema';
+import remoteGenerator from '../../remote/remote';
+import { E2eTestRunner, UnitTestRunner } from '../../../utils/test-runners';
+
+export async function addRemote(tree: Tree, schema: Schema) {
+ const tasks: GeneratorCallback[] = [];
+ const remote = getRemoteIfExists(tree, schema.remote);
+
+ if (!remote) {
+ const installedAngularVersionInfo = getInstalledAngularVersionInfo(tree);
+
+ if (
+ lt(installedAngularVersionInfo.version, '14.1.0') &&
+ schema.standalone
+ ) {
+ throw new Error(stripIndents`The "standalone" option is only supported in Angular >= 14.1.0. You are currently using ${installedAngularVersionInfo.version}.
+ You can resolve this error by removing the "standalone" option or by migrating to Angular 14.1.0.`);
+ }
+
+ const remoteGeneratorCallback = await remoteGenerator(tree, {
+ name: schema.remote,
+ host: schema.host,
+ standalone: schema.standalone,
+ projectNameAndRootFormat: schema.projectNameAndRootFormat ?? 'derived',
+ unitTestRunner: schema.unitTestRunner ?? UnitTestRunner.Jest,
+ e2eTestRunner: schema.e2eTestRunner ?? E2eTestRunner.Cypress,
+ skipFormat: true,
+ });
+
+ tasks.push(remoteGeneratorCallback);
+ }
+
+ const { projectName, projectRoot: remoteRoot } =
+ await determineProjectNameAndRootOptions(tree, {
+ name: schema.remote,
+ projectType: 'application',
+ projectNameAndRootFormat: schema.projectNameAndRootFormat ?? 'derived',
+ callingGenerator: '@nx/angular:federate-module',
+ });
+
+ const projectRoot = remote ? remote.root : remoteRoot;
+ const remoteName = remote ? remote.name : projectName;
+
+ // TODO(Colum): add implicit dependency if the path points to a file in a different project
+
+ return {
+ tasks,
+ projectRoot,
+ remoteName,
+ };
+}
diff --git a/packages/angular/src/generators/federate-module/lib/check-remote-exists.ts b/packages/angular/src/generators/federate-module/lib/check-remote-exists.ts
new file mode 100644
index 0000000000..643ffff90b
--- /dev/null
+++ b/packages/angular/src/generators/federate-module/lib/check-remote-exists.ts
@@ -0,0 +1,30 @@
+import {
+ type Tree,
+ readProjectConfiguration,
+ joinPathFragments,
+} from '@nx/devkit';
+
+export function getRemoteIfExists(tree: Tree, remote: string) {
+ const remoteProject = getRemote(tree, remote);
+ if (!remoteProject) {
+ return false;
+ }
+
+ const hasModuleFederationConfig =
+ tree.exists(
+ joinPathFragments(remoteProject.root, 'module-federation.config.ts')
+ ) ||
+ tree.exists(
+ joinPathFragments(remoteProject.root, 'module-federation.config.js')
+ );
+
+ return hasModuleFederationConfig ? remoteProject : false;
+}
+
+function getRemote(tree: Tree, remote: string) {
+ try {
+ return readProjectConfiguration(tree, remote);
+ } catch {
+ return false;
+ }
+}
diff --git a/packages/angular/src/generators/federate-module/lib/index.ts b/packages/angular/src/generators/federate-module/lib/index.ts
new file mode 100644
index 0000000000..d097d31f73
--- /dev/null
+++ b/packages/angular/src/generators/federate-module/lib/index.ts
@@ -0,0 +1,4 @@
+export * from './add-remote';
+export * from './add-path-to-ts-config';
+export * from './add-path-to-exposes';
+export * from './add-file-to-remote-tsconfig';
diff --git a/packages/angular/src/generators/federate-module/schema.d.ts b/packages/angular/src/generators/federate-module/schema.d.ts
new file mode 100644
index 0000000000..b8f36050e7
--- /dev/null
+++ b/packages/angular/src/generators/federate-module/schema.d.ts
@@ -0,0 +1,14 @@
+import { type ProjectNameAndRootFormat } from '@nx/devkit/src/generators/project-name-and-root-utils';
+import { UnitTestRunner, E2eTestRunner } from '../utils/testing';
+
+export interface Schema {
+ name: string;
+ path: string;
+ remote: string;
+ host?: string;
+ projectNameAndRootFormat?: ProjectNameAndRootFormat;
+ unitTestRunner?: UnitTestRunner;
+ e2eTestRunner?: E2eTestRunner;
+ standalone?: boolean;
+ skipFormat?: boolean;
+}
diff --git a/packages/angular/src/generators/federate-module/schema.json b/packages/angular/src/generators/federate-module/schema.json
new file mode 100644
index 0000000000..fdc33b510d
--- /dev/null
+++ b/packages/angular/src/generators/federate-module/schema.json
@@ -0,0 +1,77 @@
+{
+ "$schema": "http://json-schema.org/schema",
+ "cli": "nx",
+ "$id": "NxReactFederateModule",
+ "title": "Federate Module",
+ "description": "Create a federated module, which is exposed by a remote and can be subsequently loaded by a host.",
+ "examples": [
+ {
+ "command": "nx g federate-module MyModule --path=./src/component/my-cmp.ts --remote=my-remote-app",
+ "description": "Create a federated module from my-remote-app, that exposes my-cmp from ./src/component/my-cmp.ts as MyModule."
+ }
+ ],
+ "type": "object",
+ "properties": {
+ "name": {
+ "description": "The name of the module.",
+ "type": "string",
+ "$default": {
+ "$source": "argv",
+ "index": 0
+ },
+ "x-prompt": "What name would you like to use for the module?",
+ "pattern": "^[a-zA-Z][^:]*$",
+ "x-priority": "important"
+ },
+ "path": {
+ "type": "string",
+ "description": "The path to locate the federated module.",
+ "x-prompt": "What is the path to the module to be federated?"
+ },
+ "remote": {
+ "type": "string",
+ "description": "The name of the remote.",
+ "x-prompt": "What is/should the remote be named?"
+ },
+ "projectNameAndRootFormat": {
+ "description": "Whether to generate the project name and root directory as provided (`as-provided`) or generate them composing their values and taking the configured layout into account (`derived`).",
+ "type": "string",
+ "enum": ["as-provided", "derived"]
+ },
+ "style": {
+ "description": "The file extension to be used for style files for the remote if one needs to be created.",
+ "type": "string",
+ "default": "css",
+ "enum": ["css", "scss", "sass", "less"]
+ },
+ "skipFormat": {
+ "description": "Skip formatting files.",
+ "type": "boolean",
+ "default": false,
+ "x-priority": "internal"
+ },
+ "unitTestRunner": {
+ "type": "string",
+ "enum": ["jest", "none"],
+ "description": "Test runner to use for unit tests of the remote if it needs to be created.",
+ "default": "jest"
+ },
+ "e2eTestRunner": {
+ "type": "string",
+ "enum": ["cypress", "none"],
+ "description": "Test runner to use for end to end (e2e) tests of the remote if it needs to be created.",
+ "default": "cypress"
+ },
+ "standalone": {
+ "description": "Whether to generate the remote application with standalone components if it needs to be created. _Note: This is only supported in Angular versions >= 14.1.0_",
+ "type": "boolean",
+ "default": false
+ },
+ "host": {
+ "type": "string",
+ "description": "The host / shell application for this remote."
+ }
+ },
+ "required": ["name", "path", "remote"],
+ "additionalProperties": false
+}