From 9be869ff7bf7c39bfa05c35bb5cd9f73ba34ed2d Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Tue, 10 Oct 2023 15:36:28 +0100 Subject: [PATCH] feat(angular): switch default to typescript configuration for module federation (#18998) --- .../packages/angular/generators/host.json | 5 + .../packages/angular/generators/remote.json | 5 + .../packages/angular/generators/setup-mf.json | 5 + .../src/module-federation.test.ts | 106 ++++- e2e/utils/command-utils.ts | 6 +- .../host/__snapshots__/host.spec.ts.snap | 419 ++++++++++++++++++ .../{ => common}/src/main.server.ts__tmpl__ | 0 .../{ => js}/webpack.server.config.js__tmpl__ | 0 .../files/ts/webpack.server.config.ts__tmpl__ | 4 + .../angular/src/generators/host/host.spec.ts | 227 ++++++++++ packages/angular/src/generators/host/host.ts | 15 +- .../src/generators/host/lib/add-ssr.ts | 25 +- .../angular/src/generators/host/schema.d.ts | 1 + .../angular/src/generators/host/schema.json | 5 + .../remote/__snapshots__/remote.spec.ts.snap | 322 ++++++++++++++ .../base-ts/webpack.server.config.ts__tmpl__ | 4 + .../src/main.server.ts__tmpl__ | 0 .../src/generators/remote/lib/add-ssr.ts | 26 +- .../src/generators/remote/remote.spec.ts | 120 +++++ .../angular/src/generators/remote/remote.ts | 8 +- .../angular/src/generators/remote/schema.d.ts | 1 + .../angular/src/generators/remote/schema.json | 5 + .../__snapshots__/setup-mf.spec.ts.snap | 95 ++++ .../module-federation.config.ts__tmpl__ | 12 + .../ts-webpack/webpack.config.ts__tmpl__ | 4 + .../ts-webpack/webpack.prod.config.ts__tmpl__ | 16 + .../setup-mf/lib/add-remote-to-host.ts | 20 +- .../setup-mf/lib/change-build-target.ts | 6 +- .../setup-mf/lib/generate-config.ts | 11 +- .../setup-mf/lib/normalize-options.ts | 1 + .../setup-mf/lib/setup-host-if-dynamic.ts | 2 +- .../src/generators/setup-mf/schema.d.ts | 1 + .../src/generators/setup-mf/schema.json | 5 + .../src/generators/setup-mf/setup-mf.spec.ts | 195 +++++++- 34 files changed, 1632 insertions(+), 45 deletions(-) rename packages/angular/src/generators/host/files/{ => common}/src/main.server.ts__tmpl__ (100%) rename packages/angular/src/generators/host/files/{ => js}/webpack.server.config.js__tmpl__ (100%) create mode 100644 packages/angular/src/generators/host/files/ts/webpack.server.config.ts__tmpl__ create mode 100644 packages/angular/src/generators/remote/files/base-ts/webpack.server.config.ts__tmpl__ rename packages/angular/src/generators/remote/files/{base => common}/src/main.server.ts__tmpl__ (100%) create mode 100644 packages/angular/src/generators/setup-mf/files/ts-webpack/module-federation.config.ts__tmpl__ create mode 100644 packages/angular/src/generators/setup-mf/files/ts-webpack/webpack.config.ts__tmpl__ create mode 100644 packages/angular/src/generators/setup-mf/files/ts-webpack/webpack.prod.config.ts__tmpl__ diff --git a/docs/generated/packages/angular/generators/host.json b/docs/generated/packages/angular/generators/host.json index 09c4d28854216..18eef256069fe 100644 --- a/docs/generated/packages/angular/generators/host.json +++ b/docs/generated/packages/angular/generators/host.json @@ -170,6 +170,11 @@ "type": "boolean", "default": false, "x-priority": "important" + }, + "typescriptConfiguration": { + "type": "boolean", + "description": "Whether the module federation configuration and webpack configuration files should use TS.", + "default": true } }, "additionalProperties": false, diff --git a/docs/generated/packages/angular/generators/remote.json b/docs/generated/packages/angular/generators/remote.json index b2a772a64c0c7..457fb29289c7e 100644 --- a/docs/generated/packages/angular/generators/remote.json +++ b/docs/generated/packages/angular/generators/remote.json @@ -163,6 +163,11 @@ "description": "Whether to configure SSR for the remote application to be consumed by a host application using SSR.", "type": "boolean", "default": false + }, + "typescriptConfiguration": { + "type": "boolean", + "description": "Whether the module federation configuration and webpack configuration files should use TS.", + "default": true } }, "additionalProperties": false, diff --git a/docs/generated/packages/angular/generators/setup-mf.json b/docs/generated/packages/angular/generators/setup-mf.json index adc850d79a8f0..d9c47472c1ad8 100644 --- a/docs/generated/packages/angular/generators/setup-mf.json +++ b/docs/generated/packages/angular/generators/setup-mf.json @@ -73,6 +73,11 @@ "type": "boolean", "description": "Whether the application is a standalone application. _Note: This is only supported in Angular versions >= 14.1.0_", "default": false + }, + "typescriptConfiguration": { + "type": "boolean", + "description": "Whether the module federation configuration and webpack configuration files should use TS.", + "default": true } }, "required": ["appName", "mfType"], diff --git a/e2e/angular-core/src/module-federation.test.ts b/e2e/angular-core/src/module-federation.test.ts index 5f7b668844bf3..07ee72b91a723 100644 --- a/e2e/angular-core/src/module-federation.test.ts +++ b/e2e/angular-core/src/module-federation.test.ts @@ -70,7 +70,7 @@ describe('Angular Module Federation', () => { }Module } from '@${proj}/${sharedLib}'; import { ${ names(secondaryEntry).className - }Module } from '@${proj}/${secondaryEntry}'; + }Module } from '@${proj}/${sharedLib}/${secondaryEntry}'; import { AppComponent } from './app.component'; import { NxWelcomeComponent } from './nx-welcome.component'; import { RouterModule } from '@angular/router'; @@ -79,7 +79,7 @@ describe('Angular Module Federation', () => { declarations: [AppComponent, NxWelcomeComponent], imports: [ BrowserModule, - SharedModule, + ${names(sharedLib).className}Module, RouterModule.forRoot( [ { @@ -107,14 +107,15 @@ describe('Angular Module Federation', () => { import { ${names(sharedLib).className}Module } from '@${proj}/${sharedLib}'; import { ${ names(secondaryEntry).className - }Module } from '@${proj}/${secondaryEntry}'; + }Module } from '@${proj}/${sharedLib}/${secondaryEntry}'; import { RemoteEntryComponent } from './entry.component'; + import { NxWelcomeComponent } from './nx-welcome.component'; @NgModule({ - declarations: [RemoteEntryComponent], + declarations: [RemoteEntryComponent, NxWelcomeComponent], imports: [ CommonModule, - SharedModule, + ${names(sharedLib).className}Module, RouterModule.forChild([ { path: '', @@ -128,15 +129,23 @@ describe('Angular Module Federation', () => { ` ); - const process = await runCommandUntil( + const processSwc = await runCommandUntil( `serve ${hostApp} --port=${hostPort} --dev-remotes=${remoteApp1}`, (output) => - output.includes(`listening on localhost:${remotePort}`) && + !output.includes(`Remote '${remoteApp1}' failed to serve correctly`) && output.includes(`listening on localhost:${hostPort}`) ); + await killProcessAndPorts(processSwc.pid, hostPort, remotePort); - // port and process cleanup - await killProcessAndPorts(process.pid, hostPort, remotePort); + const processTsNode = await runCommandUntil( + `serve ${hostApp} --port=${hostPort} --dev-remotes=${remoteApp1}`, + (output) => + !output.includes(`Remote '${remoteApp1}' failed to serve correctly`) && + output.includes(`listening on localhost:${hostPort}`), + { env: { NX_PREFER_TS_NODE: 'true' } } + ); + + await killProcessAndPorts(processTsNode.pid, hostPort, remotePort); }, 20_000_000); it('should convert apps to MF successfully', async () => { @@ -161,15 +170,24 @@ describe('Angular Module Federation', () => { `generate @nx/angular:setup-mf ${app2} --mfType=remote --host=${app1} --port=${app2Port} --no-interactive` ); - const process = await runCommandUntil( + const processSwc = await runCommandUntil( + `serve ${app1} --dev-remotes=${app2}`, + (output) => + !output.includes(`Remote '${app2}' failed to serve correctly`) && + output.includes(`listening on localhost:${app1Port}`) + ); + + await killProcessAndPorts(processSwc.pid, app1Port, app2Port); + + const processTsNode = await runCommandUntil( `serve ${app1} --dev-remotes=${app2}`, (output) => - output.includes(`listening on localhost:${app1Port}`) && - output.includes(`listening on localhost:${app2Port}`) + !output.includes(`Remote '${app2}' failed to serve correctly`) && + output.includes(`listening on localhost:${app1Port}`), + { env: { NX_PREFER_TS_NODE: 'true' } } ); - // port and process cleanup - await killProcessAndPorts(process.pid, app1Port, app2Port); + await killProcessAndPorts(processTsNode.pid, app1Port, app2Port); }, 20_000_000); it('should scaffold MF + SSR setup successfully', async () => { @@ -189,7 +207,7 @@ describe('Angular Module Federation', () => { const remote2Port = readJson(join(remote2, 'project.json')).targets.serve .options.port; - const process = await runCommandUntil( + const processSwc = await runCommandUntil( `serve-ssr ${host} --port=${hostPort}`, (output) => output.includes( @@ -203,8 +221,34 @@ describe('Angular Module Federation', () => { ) ); - // port and process cleanup - await killProcessAndPorts(process.pid, hostPort, remote1Port, remote2Port); + await killProcessAndPorts( + processSwc.pid, + hostPort, + remote1Port, + remote2Port + ); + + const processTsNode = await runCommandUntil( + `serve-ssr ${host} --port=${hostPort}`, + (output) => + output.includes( + `Node Express server listening on http://localhost:${remote1Port}` + ) && + output.includes( + `Node Express server listening on http://localhost:${remote2Port}` + ) && + output.includes( + `Angular Universal Live Development Server is listening` + ), + { env: { NX_PREFER_TS_NODE: 'true' } } + ); + + await killProcessAndPorts( + processTsNode.pid, + hostPort, + remote1Port, + remote2Port + ); }, 20_000_000); it('should should support generating host and remote apps with --project-name-and-root-format=derived', async () => { @@ -229,17 +273,33 @@ describe('Angular Module Federation', () => { ); // check default generated host is built successfully - const buildOutput = runCLI(`build ${hostApp}`); - expect(buildOutput).toContain('Successfully ran target build'); + const buildOutputSwc = runCLI(`build ${hostApp}`); + expect(buildOutputSwc).toContain('Successfully ran target build'); - const process = await runCommandUntil( + const buildOutputTsNode = runCLI(`build ${hostApp}`, { + env: { NX_PREFER_TS_NODE: 'true' }, + }); + expect(buildOutputTsNode).toContain('Successfully ran target build'); + + const processSwc = await runCommandUntil( `serve ${hostApp} --port=${hostPort} --dev-remotes=${remoteApp}`, (output) => - output.includes(`listening on localhost:${remotePort}`) && + !output.includes(`Remote '${remoteApp}' failed to serve correctly`) && output.includes(`listening on localhost:${hostPort}`) ); - // port and process cleanup - await killProcessAndPorts(process.pid, hostPort, remotePort); + await killProcessAndPorts(processSwc.pid, hostPort, remotePort); + + const processTsNode = await runCommandUntil( + `serve ${hostApp} --port=${hostPort} --dev-remotes=${remoteApp}`, + (output) => + !output.includes(`Remote '${remoteApp}' failed to serve correctly`) && + output.includes(`listening on localhost:${hostPort}`), + { + env: { NX_PREFER_TS_NODE: 'true' }, + } + ); + + await killProcessAndPorts(processTsNode.pid, hostPort, remotePort); }, 20_000_000); }); diff --git a/e2e/utils/command-utils.ts b/e2e/utils/command-utils.ts index 9f0144e3f8276..a145318f09293 100644 --- a/e2e/utils/command-utils.ts +++ b/e2e/utils/command-utils.ts @@ -229,7 +229,10 @@ export function runCommandAsync( export function runCommandUntil( command: string, - criteria: (output: string) => boolean + criteria: (output: string) => boolean, + opts: RunCmdOpts = { + env: undefined, + } ): Promise { const pm = getPackageManagerCommand(); const p = exec(`${pm.runNx} ${command}`, { @@ -238,6 +241,7 @@ export function runCommandUntil( env: { CI: 'true', ...getStrippedEnvironmentVariables(), + ...opts.env, FORCE_COLOR: 'false', }, }); diff --git a/packages/angular/src/generators/host/__snapshots__/host.spec.ts.snap b/packages/angular/src/generators/host/__snapshots__/host.spec.ts.snap index 8d0e67c7e35a7..8a5311a66028a 100644 --- a/packages/angular/src/generators/host/__snapshots__/host.spec.ts.snap +++ b/packages/angular/src/generators/host/__snapshots__/host.spec.ts.snap @@ -385,6 +385,401 @@ exports[`Host App Generator --ssr should generate the correct files for standalo } `; +exports[`Host App Generator --ssr should generate the correct files for standalone when --typescript=true 1`] = ` +"import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { AppComponent } from './app/app.component'; + +bootstrapApplication(AppComponent, appConfig).catch((err) => + console.error(err) +); +" +`; + +exports[`Host App Generator --ssr should generate the correct files for standalone when --typescript=true 2`] = ` +"import { bootstrapApplication } from '@angular/platform-browser'; +import { AppComponent } from './app/app.component'; +import { config } from './app/app.config.server'; + +const bootstrap = () => bootstrapApplication(AppComponent, config); + +export default bootstrap; +" +`; + +exports[`Host App Generator --ssr should generate the correct files for standalone when --typescript=true 3`] = ` +"import 'zone.js/dist/zone-node'; + +import { APP_BASE_HREF } from '@angular/common'; +import { ngExpressEngine } from '@nguniversal/express-engine'; +import * as express from 'express'; +import * as cors from 'cors'; +import { existsSync } from 'fs'; +import { join } from 'path'; + +import bootstrap from './bootstrap.server'; + +// The Express app is exported so that it can be used by serverless Functions. +export function app(): express.Express { + const server = express(); + const browserBundles = join(process.cwd(), 'dist/test/browser'); + + server.use(cors()); + const indexHtml = existsSync(join(browserBundles, 'index.original.html')) + ? 'index.original.html' + : 'index'; + + // Our Universal express-engine (found @ https://github.com/angular/universal/tree/main/modules/express-engine) + server.engine( + 'html', + ngExpressEngine({ + bootstrap, + }) + ); + + server.set('view engine', 'html'); + server.set('views', browserBundles); + + // Serve static files from /browser + server.get( + '*.*', + express.static(browserBundles, { + maxAge: '1y', + }) + ); + + // All regular routes use the Universal engine + server.get('*', (req, res) => { + // keep it async to avoid blocking the server thread + + res.render(indexHtml, { + providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }], + req, + }); + }); + + return server; +} + +function run(): void { + const port = process.env['PORT'] || 4000; + + // Start up the Node server + const server = app(); + server.listen(port, () => { + console.log(\`Node Express server listening on http://localhost:\${port}\`); + }); +} + +run(); + +export default bootstrap; +" +`; + +exports[`Host App Generator --ssr should generate the correct files for standalone when --typescript=true 4`] = ` +"import('./src/main.server'); +" +`; + +exports[`Host App Generator --ssr should generate the correct files for standalone when --typescript=true 5`] = ` +"import { ModuleFederationConfig } from '@nx/webpack'; + +const config: ModuleFederationConfig = { + name: 'test', + remotes: [], +}; + +export default config; +" +`; + +exports[`Host App Generator --ssr should generate the correct files for standalone when --typescript=true 6`] = ` +"import { withModuleFederationForSSR } from '@nx/angular/module-federation'; +import config from './module-federation.config'; + +export default withModuleFederationForSSR(config); +" +`; + +exports[`Host App Generator --ssr should generate the correct files for standalone when --typescript=true 7`] = ` +"import { NxWelcomeComponent } from './nx-welcome.component'; +import { Route } from '@angular/router'; + +export const appRoutes: Route[] = [ + { + path: '', + component: NxWelcomeComponent, + }, +]; +" +`; + +exports[`Host App Generator --ssr should generate the correct files for standalone when --typescript=true 8`] = ` +"import { ApplicationConfig } from '@angular/core'; +import { + provideRouter, + withEnabledBlockingInitialNavigation, +} from '@angular/router'; +import { appRoutes } from './app.routes'; + +export const appConfig: ApplicationConfig = { + providers: [provideRouter(appRoutes, withEnabledBlockingInitialNavigation())], +}; +" +`; + +exports[`Host App Generator --ssr should generate the correct files for standalone when --typescript=true 9`] = ` +"import { mergeApplicationConfig, ApplicationConfig } from '@angular/core'; +import { provideServerRendering } from '@angular/platform-server'; +import { appConfig } from './app.config'; + +const serverConfig: ApplicationConfig = { + providers: [provideServerRendering()], +}; + +export const config = mergeApplicationConfig(appConfig, serverConfig); +" +`; + +exports[`Host App Generator --ssr should generate the correct files for standalone when --typescript=true 10`] = ` +{ + "configurations": { + "development": { + "buildOptimizer": false, + "extractLicenses": false, + "optimization": false, + "sourceMap": true, + "vendorChunk": true, + }, + "production": { + "outputHashing": "media", + }, + }, + "defaultConfiguration": "production", + "dependsOn": [ + "build", + ], + "executor": "@nx/angular:webpack-server", + "options": { + "customWebpackConfig": { + "path": "test/webpack.server.config.ts", + }, + "main": "test/server.ts", + "outputPath": "dist/test/server", + "tsConfig": "test/tsconfig.server.json", + }, +} +`; + +exports[`Host App Generator --ssr should generate the correct files for standalone when --typescript=true 11`] = ` +{ + "configurations": { + "development": { + "browserTarget": "test:build:development", + "serverTarget": "test:server:development", + }, + "production": { + "browserTarget": "test:build:production", + "serverTarget": "test:server:production", + }, + }, + "defaultConfiguration": "development", + "executor": "@nx/angular:module-federation-dev-ssr", +} +`; + +exports[`Host App Generator --ssr should generate the correct files when --typescript=true 1`] = ` +"import { NgModule } from '@angular/core'; +import { BrowserModule } from '@angular/platform-browser'; +import { RouterModule } from '@angular/router'; +import { AppComponent } from './app.component'; +import { appRoutes } from './app.routes'; +import { NxWelcomeComponent } from './nx-welcome.component'; + +@NgModule({ + declarations: [AppComponent, NxWelcomeComponent], + imports: [ + BrowserModule, + RouterModule.forRoot(appRoutes, { initialNavigation: 'enabledBlocking' }), + ], + providers: [], + bootstrap: [AppComponent], +}) +export class AppModule {} +" +`; + +exports[`Host App Generator --ssr should generate the correct files when --typescript=true 2`] = ` +"import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; +import { AppModule } from './app/app.module'; + +platformBrowserDynamic() + .bootstrapModule(AppModule) + .catch((err) => console.error(err)); +" +`; + +exports[`Host App Generator --ssr should generate the correct files when --typescript=true 3`] = ` +"export { AppServerModule } from './app/app.server.module'; +" +`; + +exports[`Host App Generator --ssr should generate the correct files when --typescript=true 4`] = ` +"import 'zone.js/dist/zone-node'; + +import { APP_BASE_HREF } from '@angular/common'; +import { ngExpressEngine } from '@nguniversal/express-engine'; +import * as express from 'express'; +import * as cors from 'cors'; +import { existsSync } from 'fs'; +import { join } from 'path'; + +import { AppServerModule } from './bootstrap.server'; + +// The Express app is exported so that it can be used by serverless Functions. +export function app(): express.Express { + const server = express(); + const browserBundles = join(process.cwd(), 'dist/test/browser'); + + server.use(cors()); + const indexHtml = existsSync(join(browserBundles, 'index.original.html')) + ? 'index.original.html' + : 'index'; + + // Our Universal express-engine (found @ https://github.com/angular/universal/tree/main/modules/express-engine) + server.engine( + 'html', + ngExpressEngine({ + bootstrap: AppServerModule, + }) + ); + + server.set('view engine', 'html'); + server.set('views', browserBundles); + + // Serve static files from /browser + server.get( + '*.*', + express.static(browserBundles, { + maxAge: '1y', + }) + ); + + // All regular routes use the Universal engine + server.get('*', (req, res) => { + // keep it async to avoid blocking the server thread + + res.render(indexHtml, { + providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }], + req, + }); + }); + + return server; +} + +function run(): void { + const port = process.env['PORT'] || 4000; + + // Start up the Node server + const server = app(); + server.listen(port, () => { + console.log(\`Node Express server listening on http://localhost:\${port}\`); + }); +} + +run(); + +export * from './bootstrap.server'; +" +`; + +exports[`Host App Generator --ssr should generate the correct files when --typescript=true 5`] = ` +"import('./src/main.server'); +" +`; + +exports[`Host App Generator --ssr should generate the correct files when --typescript=true 6`] = ` +"import { ModuleFederationConfig } from '@nx/webpack'; + +const config: ModuleFederationConfig = { + name: 'test', + remotes: [], +}; + +export default config; +" +`; + +exports[`Host App Generator --ssr should generate the correct files when --typescript=true 7`] = ` +"import { withModuleFederationForSSR } from '@nx/angular/module-federation'; +import config from './module-federation.config'; + +export default withModuleFederationForSSR(config); +" +`; + +exports[`Host App Generator --ssr should generate the correct files when --typescript=true 8`] = ` +"import { NxWelcomeComponent } from './nx-welcome.component'; +import { Route } from '@angular/router'; + +export const appRoutes: Route[] = [ + { + path: '', + component: NxWelcomeComponent, + }, +]; +" +`; + +exports[`Host App Generator --ssr should generate the correct files when --typescript=true 9`] = ` +{ + "configurations": { + "development": { + "buildOptimizer": false, + "extractLicenses": false, + "optimization": false, + "sourceMap": true, + "vendorChunk": true, + }, + "production": { + "outputHashing": "media", + }, + }, + "defaultConfiguration": "production", + "dependsOn": [ + "build", + ], + "executor": "@nx/angular:webpack-server", + "options": { + "customWebpackConfig": { + "path": "test/webpack.server.config.ts", + }, + "main": "test/server.ts", + "outputPath": "dist/test/server", + "tsConfig": "test/tsconfig.server.json", + }, +} +`; + +exports[`Host App Generator --ssr should generate the correct files when --typescript=true 10`] = ` +{ + "configurations": { + "development": { + "browserTarget": "test:build:development", + "serverTarget": "test:server:development", + }, + "production": { + "browserTarget": "test:build:production", + "serverTarget": "test:server:production", + }, + }, + "defaultConfiguration": "development", + "executor": "@nx/angular:module-federation-dev-ssr", +} +`; + exports[`Host App Generator should generate a host app with a remote 1`] = ` "const { withModuleFederation } = require('@nx/angular/module-federation'); const config = require('./module-federation.config'); @@ -399,6 +794,22 @@ module.exports = withModuleFederation(config); " `; +exports[`Host App Generator should generate a host app with a remote when --typesscript=true 1`] = ` +"import { withModuleFederation } from '@nx/angular/module-federation'; +import config from './module-federation.config'; + +export default withModuleFederation(config); +" +`; + +exports[`Host App Generator should generate a host app with a remote when --typesscript=true 2`] = ` +"import { withModuleFederation } from '@nx/angular/module-federation'; +import config from './module-federation.config'; + +export default withModuleFederation(config); +" +`; + exports[`Host App Generator should generate a host app with no remotes 1`] = ` "const { withModuleFederation } = require('@nx/angular/module-federation'); const config = require('./module-federation.config'); @@ -406,6 +817,14 @@ module.exports = withModuleFederation(config); " `; +exports[`Host App Generator should generate a host app with no remotes when --typescript=true 1`] = ` +"import { withModuleFederation } from '@nx/angular/module-federation'; +import config from './module-federation.config'; + +export default withModuleFederation(config); +" +`; + exports[`Host App Generator should generate a host with remotes using standalone components 1`] = ` "import { bootstrapApplication } from '@angular/platform-browser'; import { appConfig } from './app/app.config'; diff --git a/packages/angular/src/generators/host/files/src/main.server.ts__tmpl__ b/packages/angular/src/generators/host/files/common/src/main.server.ts__tmpl__ similarity index 100% rename from packages/angular/src/generators/host/files/src/main.server.ts__tmpl__ rename to packages/angular/src/generators/host/files/common/src/main.server.ts__tmpl__ diff --git a/packages/angular/src/generators/host/files/webpack.server.config.js__tmpl__ b/packages/angular/src/generators/host/files/js/webpack.server.config.js__tmpl__ similarity index 100% rename from packages/angular/src/generators/host/files/webpack.server.config.js__tmpl__ rename to packages/angular/src/generators/host/files/js/webpack.server.config.js__tmpl__ diff --git a/packages/angular/src/generators/host/files/ts/webpack.server.config.ts__tmpl__ b/packages/angular/src/generators/host/files/ts/webpack.server.config.ts__tmpl__ new file mode 100644 index 0000000000000..1dda6f2cdf397 --- /dev/null +++ b/packages/angular/src/generators/host/files/ts/webpack.server.config.ts__tmpl__ @@ -0,0 +1,4 @@ +import {withModuleFederationForSSR} from '@nx/angular/module-federation'; +import config from './module-federation.config'; + +export default withModuleFederationForSSR(config) diff --git a/packages/angular/src/generators/host/host.spec.ts b/packages/angular/src/generators/host/host.spec.ts index 70d7de742b036..e06ea779b7e9b 100644 --- a/packages/angular/src/generators/host/host.spec.ts +++ b/packages/angular/src/generators/host/host.spec.ts @@ -18,11 +18,25 @@ describe('Host App Generator', () => { // ACT await generateTestHostApplication(tree, { name: 'test', + typescriptConfiguration: false, }); // ASSERT expect(tree.read('test/webpack.config.js', 'utf-8')).toMatchSnapshot(); }); + it('should generate a host app with no remotes when --typescript=true', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); + + // ACT + await generateTestHostApplication(tree, { + name: 'test', + typescriptConfiguration: true, + }); + + // ASSERT + expect(tree.read('test/webpack.config.ts', 'utf-8')).toMatchSnapshot(); + }); it('should generate a host app with a remote', async () => { // ARRANGE @@ -30,18 +44,40 @@ describe('Host App Generator', () => { await generateTestRemoteApplication(tree, { name: 'remote', + typescriptConfiguration: false, }); // ACT await generateTestHostApplication(tree, { name: 'test', remotes: ['remote'], + typescriptConfiguration: false, }); // ASSERT expect(tree.read('remote/webpack.config.js', 'utf-8')).toMatchSnapshot(); expect(tree.read('test/webpack.config.js', 'utf-8')).toMatchSnapshot(); }); + it('should generate a host app with a remote when --typesscript=true', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); + + await generateTestRemoteApplication(tree, { + name: 'remote', + typescriptConfiguration: true, + }); + + // ACT + await generateTestHostApplication(tree, { + name: 'test', + remotes: ['remote'], + typescriptConfiguration: true, + }); + + // ASSERT + expect(tree.read('remote/webpack.config.ts', 'utf-8')).toMatchSnapshot(); + expect(tree.read('test/webpack.config.ts', 'utf-8')).toMatchSnapshot(); + }); it('should generate a host and any remotes that dont exist with correct routing setup', async () => { // ARRANGE @@ -52,6 +88,7 @@ describe('Host App Generator', () => { await generateTestHostApplication(tree, { name: 'hostApp', remotes: ['remote1', 'remote2'], + typescriptConfiguration: false, }); // ASSERT @@ -72,17 +109,49 @@ describe('Host App Generator', () => { `); }); + it('should generate a host and any remotes that dont exist with correct routing setup when --typescript=true', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); + + // ACT + + await generateTestHostApplication(tree, { + name: 'hostApp', + remotes: ['remote1', 'remote2'], + typescriptConfiguration: true, + }); + + // ASSERT + expect(tree.exists('remote1/project.json')).toBeTruthy(); + expect(tree.exists('remote2/project.json')).toBeTruthy(); + expect( + tree.read('host-app/module-federation.config.ts', 'utf-8') + ).toContain(`'remote1', 'remote2'`); + expect(tree.read('host-app/src/app/app.component.html', 'utf-8')) + .toMatchInlineSnapshot(` + " + + " + `); + }); + it('should generate a host, integrate existing remotes and generate any remotes that dont exist', async () => { // ARRANGE const tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); await generateTestRemoteApplication(tree, { name: 'remote1', + typescriptConfiguration: false, }); // ACT await generateTestHostApplication(tree, { name: 'hostApp', remotes: ['remote1', 'remote2', 'remote3'], + typescriptConfiguration: false, }); // ASSERT @@ -94,11 +163,36 @@ describe('Host App Generator', () => { ).toContain(`'remote1', 'remote2', 'remote3'`); }); + it('should generate a host, integrate existing remotes and generate any remotes that dont exist when --typescript=true', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); + await generateTestRemoteApplication(tree, { + name: 'remote1', + typescriptConfiguration: true, + }); + + // ACT + await generateTestHostApplication(tree, { + name: 'hostApp', + remotes: ['remote1', 'remote2', 'remote3'], + typescriptConfiguration: true, + }); + + // ASSERT + expect(tree.exists('remote1/project.json')).toBeTruthy(); + expect(tree.exists('remote2/project.json')).toBeTruthy(); + expect(tree.exists('remote3/project.json')).toBeTruthy(); + expect( + tree.read('host-app/module-federation.config.ts', 'utf-8') + ).toContain(`'remote1', 'remote2', 'remote3'`); + }); + it('should generate a host, integrate existing remotes and generate any remotes that dont exist, in a directory', async () => { // ARRANGE const tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); await generateTestRemoteApplication(tree, { name: 'remote1', + typescriptConfiguration: false, }); // ACT @@ -106,6 +200,7 @@ describe('Host App Generator', () => { name: 'hostApp', directory: 'foo/hostApp', remotes: ['remote1', 'remote2', 'remote3'], + typescriptConfiguration: false, }); // ASSERT @@ -117,6 +212,31 @@ describe('Host App Generator', () => { ).toContain(`'remote1', 'remote2', 'remote3'`); }); + it('should generate a host, integrate existing remotes and generate any remotes that dont exist, in a directory when --typescript=true', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); + await generateTestRemoteApplication(tree, { + name: 'remote1', + typescriptConfiguration: true, + }); + + // ACT + await generateTestHostApplication(tree, { + name: 'hostApp', + directory: 'foo/hostApp', + remotes: ['remote1', 'remote2', 'remote3'], + typescriptConfiguration: true, + }); + + // ASSERT + expect(tree.exists('remote1/project.json')).toBeTruthy(); + expect(tree.exists('foo/remote2/project.json')).toBeTruthy(); + expect(tree.exists('foo/remote3/project.json')).toBeTruthy(); + expect( + tree.read('foo/host-app/module-federation.config.ts', 'utf-8') + ).toContain(`'remote1', 'remote2', 'remote3'`); + }); + it('should generate a host with remotes using standalone components', async () => { // ARRANGE const tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); @@ -197,6 +317,7 @@ describe('Host App Generator', () => { await generateTestHostApplication(tree, { name: 'test', ssr: true, + typescriptConfiguration: false, }); // ASSERT @@ -223,6 +344,41 @@ describe('Host App Generator', () => { expect(project.targets['serve-ssr']).toMatchSnapshot(); }); + it('should generate the correct files when --typescript=true', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); + + // ACT + await generateTestHostApplication(tree, { + name: 'test', + ssr: true, + typescriptConfiguration: true, + }); + + // ASSERT + const project = readProjectConfiguration(tree, 'test'); + expect( + tree.read(`test/src/app/app.module.ts`, 'utf-8') + ).toMatchSnapshot(); + expect(tree.read(`test/src/bootstrap.ts`, 'utf-8')).toMatchSnapshot(); + expect( + tree.read(`test/src/bootstrap.server.ts`, 'utf-8') + ).toMatchSnapshot(); + expect(tree.read(`test/src/main.server.ts`, 'utf-8')).toMatchSnapshot(); + expect(tree.read(`test/server.ts`, 'utf-8')).toMatchSnapshot(); + expect( + tree.read(`test/module-federation.config.ts`, 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read(`test/webpack.server.config.ts`, 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read(`test/src/app/app.routes.ts`, 'utf-8') + ).toMatchSnapshot(); + expect(project.targets.server).toMatchSnapshot(); + expect(project.targets['serve-ssr']).toMatchSnapshot(); + }); + it('should generate the correct files for standalone', async () => { // ARRANGE const tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); @@ -232,6 +388,7 @@ describe('Host App Generator', () => { name: 'test', standalone: true, ssr: true, + typescriptConfiguration: false, }); // ASSERT @@ -261,6 +418,46 @@ describe('Host App Generator', () => { expect(project.targets.server).toMatchSnapshot(); expect(project.targets['serve-ssr']).toMatchSnapshot(); }); + + it('should generate the correct files for standalone when --typescript=true', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); + + // ACT + await generateTestHostApplication(tree, { + name: 'test', + standalone: true, + ssr: true, + typescriptConfiguration: true, + }); + + // ASSERT + const project = readProjectConfiguration(tree, 'test'); + expect(tree.exists(`test/src/app/app.module.ts`)).toBeFalsy(); + expect(tree.read(`test/src/bootstrap.ts`, 'utf-8')).toMatchSnapshot(); + expect( + tree.read(`test/src/bootstrap.server.ts`, 'utf-8') + ).toMatchSnapshot(); + expect(tree.read(`test/src/main.server.ts`, 'utf-8')).toMatchSnapshot(); + expect(tree.read(`test/server.ts`, 'utf-8')).toMatchSnapshot(); + expect( + tree.read(`test/module-federation.config.ts`, 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read(`test/webpack.server.config.ts`, 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read(`test/src/app/app.routes.ts`, 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read(`test/src/app/app.config.ts`, 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read(`test/src/app/app.config.server.ts`, 'utf-8') + ).toMatchSnapshot(); + expect(project.targets.server).toMatchSnapshot(); + expect(project.targets['serve-ssr']).toMatchSnapshot(); + }); }); it('should error correctly when Angular version does not support standalone', async () => { @@ -291,6 +488,7 @@ describe('Host App Generator', () => { await generateTestRemoteApplication(tree, { name: 'remote1', projectNameAndRootFormat: 'derived', + typescriptConfiguration: false, }); // ACT @@ -298,6 +496,7 @@ describe('Host App Generator', () => { name: 'hostApp', remotes: ['remote1', 'remote2', 'remote3'], projectNameAndRootFormat: 'derived', + typescriptConfiguration: false, }); // ASSERT @@ -315,6 +514,7 @@ describe('Host App Generator', () => { await generateTestRemoteApplication(tree, { name: 'remote1', projectNameAndRootFormat: 'derived', + typescriptConfiguration: false, }); // ACT @@ -323,6 +523,7 @@ describe('Host App Generator', () => { directory: 'foo', remotes: ['remote1', 'remote2', 'remote3'], projectNameAndRootFormat: 'derived', + typescriptConfiguration: false, }); // ASSERT @@ -333,5 +534,31 @@ describe('Host App Generator', () => { tree.read('apps/foo/host-app/module-federation.config.js', 'utf-8') ).toContain(`'remote1', 'foo-remote2', 'foo-remote3'`); }); + it('should generate a host, integrate existing remotes and generate any remotes that dont exist, in a directory when --typescript=true', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); + await generateTestRemoteApplication(tree, { + name: 'remote1', + projectNameAndRootFormat: 'derived', + typescriptConfiguration: true, + }); + + // ACT + await generateTestHostApplication(tree, { + name: 'hostApp', + directory: 'foo', + remotes: ['remote1', 'remote2', 'remote3'], + projectNameAndRootFormat: 'derived', + typescriptConfiguration: true, + }); + + // ASSERT + expect(tree.exists('apps/remote1/project.json')).toBeTruthy(); + expect(tree.exists('apps/foo/remote2/project.json')).toBeTruthy(); + expect(tree.exists('apps/foo/remote3/project.json')).toBeTruthy(); + expect( + tree.read('apps/foo/host-app/module-federation.config.ts', 'utf-8') + ).toContain(`'remote1', 'foo-remote2', 'foo-remote3'`); + }); }); }); diff --git a/packages/angular/src/generators/host/host.ts b/packages/angular/src/generators/host/host.ts index 468680d9d9181..7cff6f5780c9a 100644 --- a/packages/angular/src/generators/host/host.ts +++ b/packages/angular/src/generators/host/host.ts @@ -23,14 +23,16 @@ export async function host(tree: Tree, options: Schema) { }); } -export async function hostInternal(tree: Tree, options: Schema) { +export async function hostInternal(tree: Tree, schema: Schema) { const installedAngularVersionInfo = getInstalledAngularVersionInfo(tree); - if (lt(installedAngularVersionInfo.version, '14.1.0') && options.standalone) { + 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 { typescriptConfiguration = true, ...options }: Schema = schema; + const projects = getProjects(tree); const remotesToGenerate: string[] = []; @@ -78,11 +80,17 @@ export async function hostInternal(tree: Tree, options: Schema) { skipE2E, e2eProjectName: skipE2E ? undefined : `${hostProjectName}-e2e`, prefix: options.prefix, + typescriptConfiguration, }); let installTasks = [appInstallTask]; if (options.ssr) { - let ssrInstallTask = await addSsr(tree, options, hostProjectName); + let ssrInstallTask = await addSsr( + tree, + options, + hostProjectName, + typescriptConfiguration + ); installTasks.push(ssrInstallTask); } @@ -107,6 +115,7 @@ export async function hostInternal(tree: Tree, options: Schema) { host: hostProjectName, skipFormat: true, standalone: options.standalone, + typescriptConfiguration, }); } diff --git a/packages/angular/src/generators/host/lib/add-ssr.ts b/packages/angular/src/generators/host/lib/add-ssr.ts index 452da0af06f51..b162c36a67ba5 100644 --- a/packages/angular/src/generators/host/lib/add-ssr.ts +++ b/packages/angular/src/generators/host/lib/add-ssr.ts @@ -18,7 +18,12 @@ import { } from '../../../utils/versions'; import { join } from 'path'; -export async function addSsr(tree: Tree, options: Schema, appName: string) { +export async function addSsr( + tree: Tree, + options: Schema, + appName: string, + typescriptConfiguration: boolean +) { let project = readProjectConfiguration(tree, appName); await setupSsr(tree, { @@ -40,19 +45,33 @@ export async function addSsr(tree: Tree, options: Schema, appName: string) { 'browser' ); - generateFiles(tree, join(__dirname, '../files'), project.root, { + generateFiles(tree, join(__dirname, '../files/common'), project.root, { appName, browserBundleOutput, standalone: options.standalone, tmpl: '', }); + const pathToTemplateFiles = typescriptConfiguration ? 'ts' : 'js'; + + generateFiles( + tree, + join(__dirname, '../files', pathToTemplateFiles), + project.root, + { + tmpl: '', + } + ); + // update project.json project = readProjectConfiguration(tree, appName); project.targets.server.executor = '@nx/angular:webpack-server'; project.targets.server.options.customWebpackConfig = { - path: joinPathFragments(project.root, 'webpack.server.config.js'), + path: joinPathFragments( + project.root, + `webpack.server.config.${pathToTemplateFiles}` + ), }; project.targets['serve-ssr'].executor = diff --git a/packages/angular/src/generators/host/schema.d.ts b/packages/angular/src/generators/host/schema.d.ts index 4ad7be7d2578f..4e77dceaabcf4 100644 --- a/packages/angular/src/generators/host/schema.d.ts +++ b/packages/angular/src/generators/host/schema.d.ts @@ -29,4 +29,5 @@ export interface Schema { skipFormat?: boolean; standalone?: boolean; ssr?: boolean; + typescriptConfiguration?: boolean; } diff --git a/packages/angular/src/generators/host/schema.json b/packages/angular/src/generators/host/schema.json index 5c751de9db98b..890a298c2992a 100644 --- a/packages/angular/src/generators/host/schema.json +++ b/packages/angular/src/generators/host/schema.json @@ -173,6 +173,11 @@ "type": "boolean", "default": false, "x-priority": "important" + }, + "typescriptConfiguration": { + "type": "boolean", + "description": "Whether the module federation configuration and webpack configuration files should use TS.", + "default": true } }, "additionalProperties": false, diff --git a/packages/angular/src/generators/remote/__snapshots__/remote.spec.ts.snap b/packages/angular/src/generators/remote/__snapshots__/remote.spec.ts.snap index d4616f812880f..abe333132c0a1 100644 --- a/packages/angular/src/generators/remote/__snapshots__/remote.spec.ts.snap +++ b/packages/angular/src/generators/remote/__snapshots__/remote.spec.ts.snap @@ -230,6 +230,241 @@ exports[`MF Remote App Generator --ssr should generate the correct files 13`] = } `; +exports[`MF Remote App Generator --ssr should generate the correct files when --typescriptConfiguration=true 1`] = ` +"import { NgModule } from '@angular/core'; +import { BrowserModule } from '@angular/platform-browser'; +import { RouterModule } from '@angular/router'; +import { AppComponent } from './app.component'; + +@NgModule({ + declarations: [AppComponent], + imports: [ + BrowserModule, + RouterModule.forRoot( + [ + { + path: '', + loadChildren: () => + import('./remote-entry/entry.module').then( + (m) => m.RemoteEntryModule + ), + }, + ], + { initialNavigation: 'enabledBlocking' } + ), + ], + providers: [], + bootstrap: [AppComponent], +}) +export class AppModule {} +" +`; + +exports[`MF Remote App Generator --ssr should generate the correct files when --typescriptConfiguration=true 2`] = ` +"import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; +import { AppModule } from './app/app.module'; + +platformBrowserDynamic() + .bootstrapModule(AppModule) + .catch((err) => console.error(err)); +" +`; + +exports[`MF Remote App Generator --ssr should generate the correct files when --typescriptConfiguration=true 3`] = ` +"export { AppServerModule } from './app/app.server.module'; +" +`; + +exports[`MF Remote App Generator --ssr should generate the correct files when --typescriptConfiguration=true 4`] = ` +"import 'zone.js/dist/zone-node'; + +import { APP_BASE_HREF } from '@angular/common'; +import { ngExpressEngine } from '@nguniversal/express-engine'; +import * as express from 'express'; +import * as cors from 'cors'; +import { existsSync } from 'fs'; +import { join } from 'path'; + +import { AppServerModule } from './bootstrap.server'; + +// The Express app is exported so that it can be used by serverless Functions. +export function app(): express.Express { + const server = express(); + const browserBundles = join(process.cwd(), 'dist/test/browser'); + const serverBundles = join(process.cwd(), 'dist/test/server'); + + server.use(cors()); + const indexHtml = existsSync(join(browserBundles, 'index.original.html')) + ? 'index.original.html' + : 'index'; + + // Our Universal express-engine (found @ https://github.com/angular/universal/tree/main/modules/express-engine) + server.engine( + 'html', + ngExpressEngine({ + bootstrap: AppServerModule, + }) + ); + + server.set('view engine', 'html'); + server.set('views', browserBundles); + + // Example Express Rest API endpoints + // server.get('/api/**', (req, res) => { }); + // Serve static files from /browser + // serve static files + server.use('/', express.static(browserBundles, { maxAge: '1y' })); + server.use('/server', express.static(serverBundles, { maxAge: '1y' })); + + // All regular routes use the Universal engine + server.get('*', (req, res) => { + res.render(indexHtml, { + req, + providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }], + }); + }); + + return server; +} + +function run(): void { + const port = process.env['PORT'] || 4000; + + // Start up the Node server + const server = app(); + server.listen(port, () => { + console.log(\`Node Express server listening on http://localhost:\${port}\`); + + /** + * DO NOT REMOVE IF USING @nx/angular:module-federation-dev-ssr executor + * to serve your Host application with this Remote application. + * This message allows Nx to determine when the Remote is ready to be + * consumed by the Host. + */ + process.send && process.send('nx.server.ready'); + }); +} + +run(); + +export * from './bootstrap.server'; +" +`; + +exports[`MF Remote App Generator --ssr should generate the correct files when --typescriptConfiguration=true 5`] = ` +"import('./src/main.server'); +" +`; + +exports[`MF Remote App Generator --ssr should generate the correct files when --typescriptConfiguration=true 6`] = ` +"import { ModuleFederationConfig } from '@nx/webpack'; + +const config: ModuleFederationConfig = { + name: 'test', + exposes: { + './Module': 'test/src/app/remote-entry/entry.module.ts', + }, +}; + +export default config; +" +`; + +exports[`MF Remote App Generator --ssr should generate the correct files when --typescriptConfiguration=true 7`] = ` +"import { withModuleFederationForSSR } from '@nx/angular/module-federation'; +import config from './module-federation.config'; + +export default withModuleFederationForSSR(config); +" +`; + +exports[`MF Remote App Generator --ssr should generate the correct files when --typescriptConfiguration=true 8`] = ` +"import { Component } from '@angular/core'; + +@Component({ + selector: 'proj-test-entry', + template: \`\`, +}) +export class RemoteEntryComponent {} +" +`; + +exports[`MF Remote App Generator --ssr should generate the correct files when --typescriptConfiguration=true 9`] = ` +"import { Route } from '@angular/router'; + +export const appRoutes: Route[] = [ + { + path: '', + loadChildren: () => + import('./remote-entry/entry.module').then((m) => m.RemoteEntryModule), + }, +]; +" +`; + +exports[`MF Remote App Generator --ssr should generate the correct files when --typescriptConfiguration=true 10`] = ` +"import { Route } from '@angular/router'; +import { RemoteEntryComponent } from './entry.component'; + +export const remoteRoutes: Route[] = [ + { path: '', component: RemoteEntryComponent }, +]; +" +`; + +exports[`MF Remote App Generator --ssr should generate the correct files when --typescriptConfiguration=true 11`] = ` +{ + "configurations": { + "development": { + "buildOptimizer": false, + "extractLicenses": false, + "optimization": false, + "sourceMap": true, + "vendorChunk": true, + }, + "production": { + "outputHashing": "media", + }, + }, + "defaultConfiguration": "production", + "dependsOn": [ + "build", + ], + "executor": "@nx/angular:webpack-server", + "options": { + "customWebpackConfig": { + "path": "test/webpack.server.config.ts", + }, + "main": "test/server.ts", + "outputPath": "dist/test/server", + "tsConfig": "test/tsconfig.server.json", + }, +} +`; + +exports[`MF Remote App Generator --ssr should generate the correct files when --typescriptConfiguration=true 12`] = ` +"import { Route } from '@angular/router'; +import { RemoteEntryComponent } from './entry.component'; + +export const remoteRoutes: Route[] = [ + { path: '', component: RemoteEntryComponent }, +]; +" +`; + +exports[`MF Remote App Generator --ssr should generate the correct files when --typescriptConfiguration=true 13`] = ` +{ + "dependsOn": [ + "build", + "server", + ], + "executor": "nx:run-commands", + "options": { + "command": "PORT=4201 node dist/test/server/main.js", + }, +} +`; + exports[`MF Remote App Generator should generate a remote mf app with a host 1`] = ` "const { withModuleFederation } = require('@nx/angular/module-federation'); const config = require('./module-federation.config'); @@ -244,6 +479,22 @@ module.exports = withModuleFederation(config); " `; +exports[`MF Remote App Generator should generate a remote mf app with a host when --typescriptConfiguration=true 1`] = ` +"import { withModuleFederation } from '@nx/angular/module-federation'; +import config from './module-federation.config'; + +export default withModuleFederation(config); +" +`; + +exports[`MF Remote App Generator should generate a remote mf app with a host when --typescriptConfiguration=true 2`] = ` +"import { withModuleFederation } from '@nx/angular/module-federation'; +import config from './module-federation.config'; + +export default withModuleFederation(config); +" +`; + exports[`MF Remote App Generator should generate a remote mf app with no host 1`] = ` "const { withModuleFederation } = require('@nx/angular/module-federation'); const config = require('./module-federation.config'); @@ -251,6 +502,14 @@ module.exports = withModuleFederation(config); " `; +exports[`MF Remote App Generator should generate a remote mf app with no host when --typescriptConfiguration=true 1`] = ` +"import { withModuleFederation } from '@nx/angular/module-federation'; +import config from './module-federation.config'; + +export default withModuleFederation(config); +" +`; + exports[`MF Remote App Generator should generate the a remote setup for standalone components 1`] = ` "import { bootstrapApplication } from '@angular/platform-browser'; import { appConfig } from './app/app.config'; @@ -309,3 +568,66 @@ export const remoteRoutes: Route[] = [ ]; " `; + +exports[`MF Remote App Generator should generate the a remote setup for standalone components when --typescriptConfiguration=true 1`] = ` +"import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { RemoteEntryComponent } from './app/remote-entry/entry.component'; + +bootstrapApplication(RemoteEntryComponent, appConfig).catch((err) => + console.error(err) +); +" +`; + +exports[`MF Remote App Generator should generate the a remote setup for standalone components when --typescriptConfiguration=true 2`] = ` +"import { ModuleFederationConfig } from '@nx/webpack'; + +const config: ModuleFederationConfig = { + name: 'test', + exposes: { + './Routes': 'test/src/app/remote-entry/entry.routes.ts', + }, +}; + +export default config; +" +`; + +exports[`MF Remote App Generator should generate the a remote setup for standalone components when --typescriptConfiguration=true 3`] = ` +"import { Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { NxWelcomeComponent } from './nx-welcome.component'; + +@Component({ + standalone: true, + imports: [CommonModule, NxWelcomeComponent], + selector: 'proj-test-entry', + template: \`\`, +}) +export class RemoteEntryComponent {} +" +`; + +exports[`MF Remote App Generator should generate the a remote setup for standalone components when --typescriptConfiguration=true 4`] = ` +"import { Route } from '@angular/router'; + +export const appRoutes: Route[] = [ + { + path: '', + loadChildren: () => + import('./remote-entry/entry.routes').then((m) => m.remoteRoutes), + }, +]; +" +`; + +exports[`MF Remote App Generator should generate the a remote setup for standalone components when --typescriptConfiguration=true 5`] = ` +"import { Route } from '@angular/router'; +import { RemoteEntryComponent } from './entry.component'; + +export const remoteRoutes: Route[] = [ + { path: '', component: RemoteEntryComponent }, +]; +" +`; diff --git a/packages/angular/src/generators/remote/files/base-ts/webpack.server.config.ts__tmpl__ b/packages/angular/src/generators/remote/files/base-ts/webpack.server.config.ts__tmpl__ new file mode 100644 index 0000000000000..1dda6f2cdf397 --- /dev/null +++ b/packages/angular/src/generators/remote/files/base-ts/webpack.server.config.ts__tmpl__ @@ -0,0 +1,4 @@ +import {withModuleFederationForSSR} from '@nx/angular/module-federation'; +import config from './module-federation.config'; + +export default withModuleFederationForSSR(config) diff --git a/packages/angular/src/generators/remote/files/base/src/main.server.ts__tmpl__ b/packages/angular/src/generators/remote/files/common/src/main.server.ts__tmpl__ similarity index 100% rename from packages/angular/src/generators/remote/files/base/src/main.server.ts__tmpl__ rename to packages/angular/src/generators/remote/files/common/src/main.server.ts__tmpl__ diff --git a/packages/angular/src/generators/remote/lib/add-ssr.ts b/packages/angular/src/generators/remote/lib/add-ssr.ts index ff388769457b6..1604f1f5bc9b3 100644 --- a/packages/angular/src/generators/remote/lib/add-ssr.ts +++ b/packages/angular/src/generators/remote/lib/add-ssr.ts @@ -22,7 +22,13 @@ export async function addSsr( appName, port, standalone, - }: { appName: string; port: number; standalone: boolean } + typescriptConfiguration, + }: { + appName: string; + port: number; + standalone: boolean; + typescriptConfiguration: boolean; + } ) { let project = readProjectConfiguration(tree, appName); @@ -52,7 +58,7 @@ export async function addSsr( generateFiles( tree, - joinPathFragments(__dirname, '../files/base'), + joinPathFragments(__dirname, `../files/common`), project.root, { appName, @@ -63,6 +69,17 @@ export async function addSsr( } ); + const pathToTemplateFiles = typescriptConfiguration ? 'base-ts' : 'base'; + + generateFiles( + tree, + joinPathFragments(__dirname, `../files/${pathToTemplateFiles}`), + project.root, + { + tmpl: '', + } + ); + if (standalone) { generateFiles( tree, @@ -81,7 +98,10 @@ export async function addSsr( project.targets.server.executor = '@nx/angular:webpack-server'; project.targets.server.options.customWebpackConfig = { - path: joinPathFragments(project.root, 'webpack.server.config.js'), + path: joinPathFragments( + project.root, + `webpack.server.config.${typescriptConfiguration ? 'ts' : 'js'}` + ), }; project.targets['serve-ssr'].options = { ...(project.targets['serve-ssr'].options ?? {}), diff --git a/packages/angular/src/generators/remote/remote.spec.ts b/packages/angular/src/generators/remote/remote.spec.ts index 2c981d46d8c30..f96a97af65a49 100644 --- a/packages/angular/src/generators/remote/remote.spec.ts +++ b/packages/angular/src/generators/remote/remote.spec.ts @@ -23,6 +23,7 @@ describe('MF Remote App Generator', () => { await generateTestRemoteApplication(tree, { name: 'test', port: 4201, + typescriptConfiguration: false, }); // ASSERT @@ -33,18 +34,35 @@ describe('MF Remote App Generator', () => { ]); }); + it('should generate a remote mf app with no host when --typescriptConfiguration=true', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); + + // ACT + await generateTestRemoteApplication(tree, { + name: 'test', + port: 4201, + typescriptConfiguration: true, + }); + + // ASSERT + expect(tree.read('test/webpack.config.ts', 'utf-8')).toMatchSnapshot(); + }); + it('should generate a remote mf app with a host', async () => { // ARRANGE const tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); await generateTestHostApplication(tree, { name: 'host', + typescriptConfiguration: false, }); // ACT await generateTestRemoteApplication(tree, { name: 'test', host: 'host', + typescriptConfiguration: false, }); // ASSERT @@ -52,6 +70,27 @@ describe('MF Remote App Generator', () => { expect(tree.read('test/webpack.config.js', 'utf-8')).toMatchSnapshot(); }); + it('should generate a remote mf app with a host when --typescriptConfiguration=true', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); + + await generateTestHostApplication(tree, { + name: 'host', + typescriptConfiguration: true, + }); + + // ACT + await generateTestRemoteApplication(tree, { + name: 'test', + host: 'host', + typescriptConfiguration: true, + }); + + // ASSERT + expect(tree.read('host/webpack.config.ts', 'utf-8')).toMatchSnapshot(); + expect(tree.read('test/webpack.config.ts', 'utf-8')).toMatchSnapshot(); + }); + it('should error when a remote app is attempted to be generated with an incorrect host', async () => { // ARRANGE const tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); @@ -125,6 +164,7 @@ describe('MF Remote App Generator', () => { await generateTestRemoteApplication(tree, { name: 'test', standalone: true, + typescriptConfiguration: false, }); // ASSERT @@ -150,6 +190,36 @@ describe('MF Remote App Generator', () => { ]); }); + it('should generate the a remote setup for standalone components when --typescriptConfiguration=true', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); + + // ACT + await generateTestRemoteApplication(tree, { + name: 'test', + standalone: true, + typescriptConfiguration: true, + }); + + // ASSERT + expect(tree.exists(`test/src/app/app.module.ts`)).toBeFalsy(); + expect(tree.exists(`test/src/app/app.component.ts`)).toBeFalsy(); + expect( + tree.exists(`test/src/app/remote-entry/entry.module.ts`) + ).toBeFalsy(); + expect(tree.read(`test/src/bootstrap.ts`, 'utf-8')).toMatchSnapshot(); + expect( + tree.read(`test/module-federation.config.ts`, 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read(`test/src/app/remote-entry/entry.component.ts`, 'utf-8') + ).toMatchSnapshot(); + expect(tree.read(`test/src/app/app.routes.ts`, 'utf-8')).toMatchSnapshot(); + expect( + tree.read(`test/src/app/remote-entry/entry.routes.ts`, 'utf-8') + ).toMatchSnapshot(); + }); + it('should not generate an e2e project when e2eTestRunner is none', async () => { // ARRANGE const tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); @@ -217,6 +287,7 @@ describe('MF Remote App Generator', () => { await generateTestRemoteApplication(tree, { name: 'test', ssr: true, + typescriptConfiguration: false, }); // ASSERT @@ -254,6 +325,53 @@ describe('MF Remote App Generator', () => { ).toMatchSnapshot(); expect(project.targets['static-server']).toMatchSnapshot(); }); + + it('should generate the correct files when --typescriptConfiguration=true', async () => { + // ARRANGE + const tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); + + // ACT + await generateTestRemoteApplication(tree, { + name: 'test', + ssr: true, + typescriptConfiguration: true, + }); + + // ASSERT + const project = readProjectConfiguration(tree, 'test'); + expect( + tree.exists(`test/src/app/remote-entry/entry.module.ts`) + ).toBeTruthy(); + expect( + tree.read(`test/src/app/app.module.ts`, 'utf-8') + ).toMatchSnapshot(); + expect(tree.read(`test/src/bootstrap.ts`, 'utf-8')).toMatchSnapshot(); + expect( + tree.read(`test/src/bootstrap.server.ts`, 'utf-8') + ).toMatchSnapshot(); + expect(tree.read(`test/src/main.server.ts`, 'utf-8')).toMatchSnapshot(); + expect(tree.read(`test/server.ts`, 'utf-8')).toMatchSnapshot(); + expect( + tree.read(`test/module-federation.config.ts`, 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read(`test/webpack.server.config.ts`, 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read(`test/src/app/remote-entry/entry.component.ts`, 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read(`test/src/app/app.routes.ts`, 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read(`test/src/app/remote-entry/entry.routes.ts`, 'utf-8') + ).toMatchSnapshot(); + expect(project.targets.server).toMatchSnapshot(); + expect( + tree.read(`test/src/app/remote-entry/entry.routes.ts`, 'utf-8') + ).toMatchSnapshot(); + expect(project.targets['static-server']).toMatchSnapshot(); + }); }); it('should error correctly when Angular version does not support standalone', async () => { @@ -285,6 +403,7 @@ describe('MF Remote App Generator', () => { name: 'test', port: 4201, projectNameAndRootFormat: 'derived', + typescriptConfiguration: false, }); expect(tree.exists('apps/test/webpack.config.js')).toBe(true); @@ -299,6 +418,7 @@ describe('MF Remote App Generator', () => { port: 4201, directory: 'shared', projectNameAndRootFormat: 'derived', + typescriptConfiguration: false, }); expect(tree.exists('apps/shared/test/webpack.config.js')).toBe(true); diff --git a/packages/angular/src/generators/remote/remote.ts b/packages/angular/src/generators/remote/remote.ts index 633a2ed2eff08..a6e3f5b20299f 100644 --- a/packages/angular/src/generators/remote/remote.ts +++ b/packages/angular/src/generators/remote/remote.ts @@ -21,14 +21,16 @@ export async function remote(tree: Tree, options: Schema) { }); } -export async function remoteInternal(tree: Tree, options: Schema) { +export async function remoteInternal(tree: Tree, schema: Schema) { const installedAngularVersionInfo = getInstalledAngularVersionInfo(tree); - if (lt(installedAngularVersionInfo.version, '14.1.0') && options.standalone) { + 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 { typescriptConfiguration = true, ...options }: Schema = schema; + const projects = getProjects(tree); if (options.host && !projects.has(options.host)) { throw new Error( @@ -71,6 +73,7 @@ export async function remoteInternal(tree: Tree, options: Schema) { e2eProjectName: skipE2E ? undefined : `${remoteProjectName}-e2e`, standalone: options.standalone, prefix: options.prefix, + typescriptConfiguration, }); let installTasks = [appInstallTask]; @@ -78,6 +81,7 @@ export async function remoteInternal(tree: Tree, options: Schema) { let ssrInstallTask = await addSsr(tree, { appName: remoteProjectName, port, + typescriptConfiguration, standalone: options.standalone, }); installTasks.push(ssrInstallTask); diff --git a/packages/angular/src/generators/remote/schema.d.ts b/packages/angular/src/generators/remote/schema.d.ts index e2ec3c49a06d5..a1fe31e6f2f94 100644 --- a/packages/angular/src/generators/remote/schema.d.ts +++ b/packages/angular/src/generators/remote/schema.d.ts @@ -28,4 +28,5 @@ export interface Schema { skipFormat?: boolean; standalone?: boolean; ssr?: boolean; + typescriptConfiguration?: boolean; } diff --git a/packages/angular/src/generators/remote/schema.json b/packages/angular/src/generators/remote/schema.json index 2817cdeb89c81..b819e8c7d4af2 100644 --- a/packages/angular/src/generators/remote/schema.json +++ b/packages/angular/src/generators/remote/schema.json @@ -166,6 +166,11 @@ "description": "Whether to configure SSR for the remote application to be consumed by a host application using SSR.", "type": "boolean", "default": false + }, + "typescriptConfiguration": { + "type": "boolean", + "description": "Whether the module federation configuration and webpack configuration files should use TS.", + "default": true } }, "additionalProperties": false, diff --git a/packages/angular/src/generators/setup-mf/__snapshots__/setup-mf.spec.ts.snap b/packages/angular/src/generators/setup-mf/__snapshots__/setup-mf.spec.ts.snap index 5b107fab4be9e..422e2f2d06695 100644 --- a/packages/angular/src/generators/setup-mf/__snapshots__/setup-mf.spec.ts.snap +++ b/packages/angular/src/generators/setup-mf/__snapshots__/setup-mf.spec.ts.snap @@ -10,6 +10,16 @@ fetch('/assets/module-federation.manifest.json') " `; +exports[`Init MF --federationType=dynamic should create a host with the correct configurations when --typescriptConfiguration=true 1`] = ` +"import { setRemoteDefinitions } from '@nx/angular/mf'; + +fetch('/assets/module-federation.manifest.json') + .then((res) => res.json()) + .then((definitions) => setRemoteDefinitions(definitions)) + .then(() => import('./bootstrap').catch((err) => console.error(err))); +" +`; + exports[`Init MF should add a remote application and add it to a specified host applications router config 1`] = ` "import { NxWelcomeComponent } from './nx-welcome.component'; import { Route } from '@angular/router'; @@ -41,6 +51,18 @@ exports[`Init MF should add a remote application and add it to a specified host " `; +exports[`Init MF should add a remote application and add it to a specified host applications webpack config that contains a remote application already when --typescriptConfiguration=true 1`] = ` +"import { ModuleFederationConfig } from '@nx/webpack'; + +const config: ModuleFederationConfig = { + name: 'app1', + remotes: ['remote1', 'remote2'], +}; + +export default config; +" +`; + exports[`Init MF should add a remote application and add it to a specified host applications webpack config when no other remote has been added to it 1`] = ` "module.exports = { name: 'app1', @@ -49,6 +71,18 @@ exports[`Init MF should add a remote application and add it to a specified host " `; +exports[`Init MF should add a remote application and add it to a specified host applications webpack config when no other remote has been added to it when --typescriptConfiguration=true 1`] = ` +"import { ModuleFederationConfig } from '@nx/webpack'; + +const config: ModuleFederationConfig = { + name: 'app1', + remotes: ['remote1'], +}; + +export default config; +" +`; + exports[`Init MF should add a remote to dynamic host correctly 1`] = ` "import { NxWelcomeComponent } from './nx-welcome.component'; import { Route } from '@angular/router'; @@ -68,6 +102,25 @@ export const appRoutes: Route[] = [ " `; +exports[`Init MF should add a remote to dynamic host correctly when --typescriptConfiguration=true 1`] = ` +"import { NxWelcomeComponent } from './nx-welcome.component'; +import { Route } from '@angular/router'; +import { loadRemoteModule } from '@nx/angular/mf'; + +export const appRoutes: Route[] = [ + { + path: 'remote1', + loadChildren: () => + loadRemoteModule('remote1', './Module').then((m) => m.RemoteEntryModule), + }, + { + path: '', + component: NxWelcomeComponent, + }, +]; +" +`; + exports[`Init MF should create webpack and mf configs correctly 1`] = ` "const { withModuleFederation } = require('@nx/angular/module-federation'); const config = require('./module-federation.config'); @@ -100,6 +153,48 @@ exports[`Init MF should create webpack and mf configs correctly 4`] = ` " `; +exports[`Init MF should create webpack and mf configs correctly when --typescriptConfiguration=true 1`] = ` +"import { withModuleFederation } from '@nx/angular/module-federation'; +import config from './module-federation.config'; + +export default withModuleFederation(config); +" +`; + +exports[`Init MF should create webpack and mf configs correctly when --typescriptConfiguration=true 2`] = ` +"import { ModuleFederationConfig } from '@nx/webpack'; + +const config: ModuleFederationConfig = { + name: 'app1', + remotes: [], +}; + +export default config; +" +`; + +exports[`Init MF should create webpack and mf configs correctly when --typescriptConfiguration=true 3`] = ` +"import { withModuleFederation } from '@nx/angular/module-federation'; +import config from './module-federation.config'; + +export default withModuleFederation(config); +" +`; + +exports[`Init MF should create webpack and mf configs correctly when --typescriptConfiguration=true 4`] = ` +"import { ModuleFederationConfig } from '@nx/webpack'; + +const config: ModuleFederationConfig = { + name: 'remote1', + exposes: { + './Module': 'remote1/src/app/remote-entry/entry.module.ts', + }, +}; + +export default config; +" +`; + exports[`Init MF should generate the remote entry component correctly when prefix is not provided 1`] = ` "import { Component } from '@angular/core'; diff --git a/packages/angular/src/generators/setup-mf/files/ts-webpack/module-federation.config.ts__tmpl__ b/packages/angular/src/generators/setup-mf/files/ts-webpack/module-federation.config.ts__tmpl__ new file mode 100644 index 0000000000000..867ac426a88af --- /dev/null +++ b/packages/angular/src/generators/setup-mf/files/ts-webpack/module-federation.config.ts__tmpl__ @@ -0,0 +1,12 @@ +import { ModuleFederationConfig } from '@nx/webpack'; + +const config: ModuleFederationConfig = { + name: '<%= name %>',<% if(type === 'host') { %> + remotes: [<% remotes.forEach(function(remote) { %>'<%= remote.remoteName %>',<% }); %>]<% } %><% if(type === 'remote') { %> + exposes: {<% if(standalone) { %> + './Routes': '<%= projectRoot %>/src/app/remote-entry/entry.routes.ts',<% } else { %> + './Module': '<%= projectRoot %>/src/app/remote-entry/entry.module.ts',<% } %> + },<% } %> +}; + +export default config; diff --git a/packages/angular/src/generators/setup-mf/files/ts-webpack/webpack.config.ts__tmpl__ b/packages/angular/src/generators/setup-mf/files/ts-webpack/webpack.config.ts__tmpl__ new file mode 100644 index 0000000000000..3109786ab24a1 --- /dev/null +++ b/packages/angular/src/generators/setup-mf/files/ts-webpack/webpack.config.ts__tmpl__ @@ -0,0 +1,4 @@ +import {withModuleFederation} from '@nx/angular/module-federation'; +import config from './module-federation.config'; + +export default withModuleFederation(config); diff --git a/packages/angular/src/generators/setup-mf/files/ts-webpack/webpack.prod.config.ts__tmpl__ b/packages/angular/src/generators/setup-mf/files/ts-webpack/webpack.prod.config.ts__tmpl__ new file mode 100644 index 0000000000000..eb9b8d26e7823 --- /dev/null +++ b/packages/angular/src/generators/setup-mf/files/ts-webpack/webpack.prod.config.ts__tmpl__ @@ -0,0 +1,16 @@ +import {withModuleFederation} from '@nx/angular/module-federation'; +import config from './module-federation.config'; + +export default withModuleFederation({ + ...config, + /* + * Remote overrides for production. + * Each entry is a pair of a unique name and the URL where it is deployed. + * + * e.g. + * remotes: [ + * ['app1', 'https://app1.example.com'], + * ['app2', 'https://app2.example.com'], + * ] + */ +}); diff --git a/packages/angular/src/generators/setup-mf/lib/add-remote-to-host.ts b/packages/angular/src/generators/setup-mf/lib/add-remote-to-host.ts index 8e2ff44059644..51e8bd4096154 100644 --- a/packages/angular/src/generators/setup-mf/lib/add-remote-to-host.ts +++ b/packages/angular/src/generators/setup-mf/lib/add-remote-to-host.ts @@ -35,8 +35,17 @@ export function addRemoteToHost(tree: Tree, options: Schema) { pathToMFManifest ); + const isHostUsingTypescriptConfig = tree.exists( + joinPathFragments(hostProject.root, 'module-federation.config.ts') + ); + if (hostFederationType === 'static') { - addRemoteToStaticHost(tree, options, hostProject); + addRemoteToStaticHost( + tree, + options, + hostProject, + isHostUsingTypescriptConfig + ); } else if (hostFederationType === 'dynamic') { addRemoteToDynamicHost(tree, options, pathToMFManifest); } @@ -69,16 +78,19 @@ function determineHostFederationType( function addRemoteToStaticHost( tree: Tree, options: Schema, - hostProject: ProjectConfiguration + hostProject: ProjectConfiguration, + isHostUsingTypescrpt: boolean ) { const hostMFConfigPath = joinPathFragments( hostProject.root, - 'module-federation.config.js' + isHostUsingTypescrpt + ? 'module-federation.config.ts' + : 'module-federation.config.js' ); if (!hostMFConfigPath || !tree.exists(hostMFConfigPath)) { throw new Error( - `The selected host application, ${options.host}, does not contain a module-federation.config.js or module-federation.manifest.json file. Are you sure it has been set up as a host application?` + `The selected host application, ${options.host}, does not contain a module-federation.config.{ts,js} or module-federation.manifest.json file. Are you sure it has been set up as a host application?` ); } diff --git a/packages/angular/src/generators/setup-mf/lib/change-build-target.ts b/packages/angular/src/generators/setup-mf/lib/change-build-target.ts index add11f0daeb73..e2fc0596c4bc2 100644 --- a/packages/angular/src/generators/setup-mf/lib/change-build-target.ts +++ b/packages/angular/src/generators/setup-mf/lib/change-build-target.ts @@ -9,18 +9,20 @@ import { export function changeBuildTarget(host: Tree, options: Schema) { const appConfig = readProjectConfiguration(host, options.appName); + const configExtName = options.typescriptConfiguration ? 'ts' : 'js'; + appConfig.targets.build.executor = '@nx/angular:webpack-browser'; appConfig.targets.build.options = { ...appConfig.targets.build.options, customWebpackConfig: { - path: `${appConfig.root}/webpack.config.js`, + path: `${appConfig.root}/webpack.config.${configExtName}`, }, }; appConfig.targets.build.configurations.production = { ...appConfig.targets.build.configurations.production, customWebpackConfig: { - path: `${appConfig.root}/webpack.prod.config.js`, + path: `${appConfig.root}/webpack.prod.config.${configExtName}`, }, }; diff --git a/packages/angular/src/generators/setup-mf/lib/generate-config.ts b/packages/angular/src/generators/setup-mf/lib/generate-config.ts index 47bd394d5a843..24c21e8698c69 100644 --- a/packages/angular/src/generators/setup-mf/lib/generate-config.ts +++ b/packages/angular/src/generators/setup-mf/lib/generate-config.ts @@ -11,7 +11,10 @@ export function generateWebpackConfig( if ( tree.exists(`${appRoot}/module-federation.config.js`) || tree.exists(`${appRoot}/webpack.config.js`) || - tree.exists(`${appRoot}/webpack.prod.config.js`) + tree.exists(`${appRoot}/webpack.prod.config.js`) || + tree.exists(`${appRoot}/module-federation.config.ts`) || + tree.exists(`${appRoot}/webpack.config.ts`) || + tree.exists(`${appRoot}/webpack.prod.config.ts`) ) { logger.warn( `NOTE: We encountered an existing webpack config for the app ${options.appName}. We have overwritten this file with the Module Federation Config.\n @@ -19,9 +22,13 @@ export function generateWebpackConfig( ); } + const pathToWebpackTemplateFiles = options.typescriptConfiguration + ? 'ts-webpack' + : 'webpack'; + generateFiles( tree, - joinPathFragments(__dirname, '../files/webpack'), + joinPathFragments(__dirname, `../files/${pathToWebpackTemplateFiles}`), appRoot, { tmpl: '', diff --git a/packages/angular/src/generators/setup-mf/lib/normalize-options.ts b/packages/angular/src/generators/setup-mf/lib/normalize-options.ts index cafa8510d1ec1..04ec9ae4e9d78 100644 --- a/packages/angular/src/generators/setup-mf/lib/normalize-options.ts +++ b/packages/angular/src/generators/setup-mf/lib/normalize-options.ts @@ -8,6 +8,7 @@ export function normalizeOptions( ): NormalizedOptions { return { ...options, + typescriptConfiguration: options.typescriptConfiguration ?? true, federationType: options.federationType ?? 'static', prefix: options.prefix ?? getProjectPrefix(tree, options.appName), }; diff --git a/packages/angular/src/generators/setup-mf/lib/setup-host-if-dynamic.ts b/packages/angular/src/generators/setup-mf/lib/setup-host-if-dynamic.ts index ed2f4f21a1337..5717e01931854 100644 --- a/packages/angular/src/generators/setup-mf/lib/setup-host-if-dynamic.ts +++ b/packages/angular/src/generators/setup-mf/lib/setup-host-if-dynamic.ts @@ -23,7 +23,7 @@ export function setupHostIfDynamic(tree: Tree, options: Schema) { const pathToProdWebpackConfig = joinPathFragments( project.root, - 'webpack.prod.config.js' + `webpack.prod.config.${options.typescriptConfiguration ? 'ts' : 'js'}` ); if (tree.exists(pathToProdWebpackConfig)) { tree.delete(pathToProdWebpackConfig); diff --git a/packages/angular/src/generators/setup-mf/schema.d.ts b/packages/angular/src/generators/setup-mf/schema.d.ts index aed4d9728004e..1c0c875f28feb 100644 --- a/packages/angular/src/generators/setup-mf/schema.d.ts +++ b/packages/angular/src/generators/setup-mf/schema.d.ts @@ -14,6 +14,7 @@ export interface Schema { prefix?: string; standalone?: boolean; skipE2E?: boolean; + typescriptConfiguration?: boolean; } export interface NormalizedOptions extends Schema { diff --git a/packages/angular/src/generators/setup-mf/schema.json b/packages/angular/src/generators/setup-mf/schema.json index 2331bb4ba0ffb..69ab1639c41d6 100644 --- a/packages/angular/src/generators/setup-mf/schema.json +++ b/packages/angular/src/generators/setup-mf/schema.json @@ -73,6 +73,11 @@ "type": "boolean", "description": "Whether the application is a standalone application. _Note: This is only supported in Angular versions >= 14.1.0_", "default": false + }, + "typescriptConfiguration": { + "type": "boolean", + "description": "Whether the module federation configuration and webpack configuration files should use TS.", + "default": true } }, "required": ["appName", "mfType"], diff --git a/packages/angular/src/generators/setup-mf/setup-mf.spec.ts b/packages/angular/src/generators/setup-mf/setup-mf.spec.ts index 65c337ccd065f..ea3569dfc5782 100644 --- a/packages/angular/src/generators/setup-mf/setup-mf.spec.ts +++ b/packages/angular/src/generators/setup-mf/setup-mf.spec.ts @@ -33,6 +33,7 @@ describe('Init MF', () => { await setupMf(tree, { appName: app, mfType: type, + typescriptConfiguration: false, }); // ASSERT @@ -51,6 +52,35 @@ describe('Init MF', () => { } ); + test.each([ + ['app1', 'host'], + ['remote1', 'remote'], + ])( + 'should create webpack and mf configs correctly when --typescriptConfiguration=true', + async (app, type: 'host' | 'remote') => { + // ACT + await setupMf(tree, { + appName: app, + mfType: type, + typescriptConfiguration: true, + }); + + // ASSERT + expect(tree.exists(`${app}/module-federation.config.ts`)).toBeTruthy(); + expect(tree.exists(`${app}/webpack.config.ts`)).toBeTruthy(); + expect(tree.exists(`${app}/webpack.prod.config.ts`)).toBeTruthy(); + + const webpackContents = tree.read(`${app}/webpack.config.ts`, 'utf-8'); + expect(webpackContents).toMatchSnapshot(); + + const mfConfigContents = tree.read( + `${app}/module-federation.config.ts`, + 'utf-8' + ); + expect(mfConfigContents).toMatchSnapshot(); + } + ); + test.each([ ['app1', 'host'], ['remote1', 'remote'], @@ -110,6 +140,7 @@ describe('Init MF', () => { await setupMf(tree, { appName: app, mfType: type, + typescriptConfiguration: false, }); // ASSERT @@ -127,6 +158,34 @@ describe('Init MF', () => { } ); + test.each([ + ['app1', 'host'], + ['remote1', 'remote'], + ])( + 'should change the build and serve target and set correct path to webpack config when --typescriptConfiguration=true', + async (app, type: 'host' | 'remote') => { + // ACT + await setupMf(tree, { + appName: app, + mfType: type, + typescriptConfiguration: true, + }); + + // ASSERT + const { build, serve } = readProjectConfiguration(tree, app).targets; + + expect(serve.executor).toEqual( + type === 'host' + ? '@nx/angular:module-federation-dev-server' + : '@nx/angular:webpack-dev-server' + ); + expect(build.executor).toEqual('@nx/angular:webpack-browser'); + expect(build.options.customWebpackConfig.path).toEqual( + `${app}/webpack.config.ts` + ); + } + ); + it('should not generate a webpack prod file for dynamic host', async () => { // ACT await setupMf(tree, { @@ -137,7 +196,7 @@ describe('Init MF', () => { // ASSERT const { build } = readProjectConfiguration(tree, 'app1').targets; - expect(tree.exists('app1/webpack.prod.config.js')).toBeFalsy(); + expect(tree.exists('app1/webpack.prod.config.ts')).toBeFalsy(); expect(build.configurations.production.customWebpackConfig).toBeUndefined(); }); @@ -174,6 +233,7 @@ describe('Init MF', () => { appName: 'app1', mfType: 'host', remotes: ['remote1'], + typescriptConfiguration: false, }); // ASSERT @@ -185,11 +245,30 @@ describe('Init MF', () => { expect(mfConfigContents).toContain(`'remote1'`); }); + it('should add the remote config to the host when --remotes flag supplied when --typescriptConfiguration=true', async () => { + // ACT + await setupMf(tree, { + appName: 'app1', + mfType: 'host', + remotes: ['remote1'], + typescriptConfiguration: true, + }); + + // ASSERT + const mfConfigContents = tree.read( + `app1/module-federation.config.ts`, + 'utf-8' + ); + + expect(mfConfigContents).toContain(`'remote1'`); + }); + it('should add a remote application and add it to a specified host applications webpack config when no other remote has been added to it', async () => { // ARRANGE await setupMf(tree, { appName: 'app1', mfType: 'host', + typescriptConfiguration: false, }); // ACT @@ -197,6 +276,7 @@ describe('Init MF', () => { appName: 'remote1', mfType: 'remote', host: 'app1', + typescriptConfiguration: false, }); // ASSERT @@ -204,6 +284,27 @@ describe('Init MF', () => { expect(hostMfConfig).toMatchSnapshot(); }); + it('should add a remote application and add it to a specified host applications webpack config when no other remote has been added to it when --typescriptConfiguration=true', async () => { + // ARRANGE + await setupMf(tree, { + appName: 'app1', + mfType: 'host', + typescriptConfiguration: true, + }); + + // ACT + await setupMf(tree, { + appName: 'remote1', + mfType: 'remote', + host: 'app1', + typescriptConfiguration: true, + }); + + // ASSERT + const hostMfConfig = tree.read('app1/module-federation.config.ts', 'utf-8'); + expect(hostMfConfig).toMatchSnapshot(); + }); + it('should add a remote application and add it to a specified host applications webpack config that contains a remote application already', async () => { // ARRANGE await generateTestApplication(tree, { @@ -213,6 +314,7 @@ describe('Init MF', () => { await setupMf(tree, { appName: 'app1', mfType: 'host', + typescriptConfiguration: false, }); await setupMf(tree, { @@ -220,6 +322,7 @@ describe('Init MF', () => { mfType: 'remote', host: 'app1', port: 4201, + typescriptConfiguration: false, }); // ACT @@ -228,6 +331,7 @@ describe('Init MF', () => { mfType: 'remote', host: 'app1', port: 4202, + typescriptConfiguration: false, }); // ASSERT @@ -235,6 +339,40 @@ describe('Init MF', () => { expect(hostMfConfig).toMatchSnapshot(); }); + it('should add a remote application and add it to a specified host applications webpack config that contains a remote application already when --typescriptConfiguration=true', async () => { + // ARRANGE + await generateTestApplication(tree, { + name: 'remote2', + }); + + await setupMf(tree, { + appName: 'app1', + mfType: 'host', + typescriptConfiguration: true, + }); + + await setupMf(tree, { + appName: 'remote1', + mfType: 'remote', + host: 'app1', + port: 4201, + typescriptConfiguration: true, + }); + + // ACT + await setupMf(tree, { + appName: 'remote2', + mfType: 'remote', + host: 'app1', + port: 4202, + typescriptConfiguration: true, + }); + + // ASSERT + const hostMfConfig = tree.read('app1/module-federation.config.ts', 'utf-8'); + expect(hostMfConfig).toMatchSnapshot(); + }); + it('should add a remote application and add it to a specified host applications router config', async () => { // ARRANGE await generateTestApplication(tree, { @@ -303,6 +441,7 @@ describe('Init MF', () => { mfType: 'host', routing: true, federationType: 'dynamic', + typescriptConfiguration: false, }); // ASSERT @@ -314,6 +453,26 @@ describe('Init MF', () => { ).toBeTruthy(); expect(tree.read('app1/src/main.ts', 'utf-8')).toMatchSnapshot(); }); + + it('should create a host with the correct configurations when --typescriptConfiguration=true', async () => { + // ARRANGE & ACT + await setupMf(tree, { + appName: 'app1', + mfType: 'host', + routing: true, + federationType: 'dynamic', + typescriptConfiguration: true, + }); + + // ASSERT + expect(tree.read('app1/module-federation.config.ts', 'utf-8')).toContain( + 'remotes: []' + ); + expect( + tree.exists('app1/src/assets/module-federation.manifest.json') + ).toBeTruthy(); + expect(tree.read('app1/src/main.ts', 'utf-8')).toMatchSnapshot(); + }); }); it('should generate bootstrap with environments for ng14', async () => { @@ -365,6 +524,7 @@ describe('Init MF', () => { mfType: 'host', routing: true, federationType: 'dynamic', + typescriptConfiguration: false, }); // ACT @@ -374,6 +534,7 @@ describe('Init MF', () => { port: 4201, host: 'app1', routing: true, + typescriptConfiguration: false, }); // ASSERT @@ -388,6 +549,38 @@ describe('Init MF', () => { expect(tree.read('app1/src/app/app.routes.ts', 'utf-8')).toMatchSnapshot(); }); + it('should add a remote to dynamic host correctly when --typescriptConfiguration=true', async () => { + // ARRANGE + await setupMf(tree, { + appName: 'app1', + mfType: 'host', + routing: true, + federationType: 'dynamic', + typescriptConfiguration: true, + }); + + // ACT + await setupMf(tree, { + appName: 'remote1', + mfType: 'remote', + port: 4201, + host: 'app1', + routing: true, + typescriptConfiguration: true, + }); + + // ASSERT + expect(tree.read('app1/module-federation.config.ts', 'utf-8')).toContain( + 'remotes: []' + ); + expect( + readJson(tree, 'app1/src/assets/module-federation.manifest.json') + ).toEqual({ + remote1: 'http://localhost:4201', + }); + expect(tree.read('app1/src/app/app.routes.ts', 'utf-8')).toMatchSnapshot(); + }); + it('should throw an error when installed version of angular < 14.1.0 and --standalone is used', async () => { // ARRANGE updateJson(tree, 'package.json', (json) => ({