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 +}