diff --git a/docs/generated/packages/angular/generators/host.json b/docs/generated/packages/angular/generators/host.json index 09c4d288542164..18eef256069feb 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 b2a772a64c0c78..457fb29289c7ee 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 adc850d79a8f00..d9c47472c1ad8e 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 5f7b668844bf37..4a6ade823275c6 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: '', @@ -131,7 +132,7 @@ describe('Angular Module Federation', () => { const process = await runCommandUntil( `serve ${hostApp} --port=${hostPort} --dev-remotes=${remoteApp1}`, (output) => - output.includes(`listening on localhost:${remotePort}`) && + output.includes(`NX All remotes started`) && output.includes(`listening on localhost:${hostPort}`) ); @@ -164,15 +165,16 @@ describe('Angular Module Federation', () => { const process = await runCommandUntil( `serve ${app1} --dev-remotes=${app2}`, (output) => - output.includes(`listening on localhost:${app1Port}`) && - output.includes(`listening on localhost:${app2Port}`) + output.includes(`NX All remotes started`) && + output.includes(`listening on localhost:${app1Port}`) ); // port and process cleanup await killProcessAndPorts(process.pid, app1Port, app2Port); }, 20_000_000); - it('should scaffold MF + SSR setup successfully', async () => { + // todo(Colum): re-enable this after further investigation into why TS configuration is throwing issues + xit('should scaffold MF + SSR setup successfully', async () => { const host = uniq('host'); const remote1 = uniq('remote1'); const remote2 = uniq('remote2'); @@ -235,7 +237,7 @@ describe('Angular Module Federation', () => { const process = await runCommandUntil( `serve ${hostApp} --port=${hostPort} --dev-remotes=${remoteApp}`, (output) => - output.includes(`listening on localhost:${remotePort}`) && + output.includes(`NX All remotes started`) && output.includes(`listening on localhost:${hostPort}`) ); diff --git a/packages/angular/src/builders/module-federation-dev-server/module-federation-dev-server.impl.ts b/packages/angular/src/builders/module-federation-dev-server/module-federation-dev-server.impl.ts index 4cde1007523eb9..c7ae5592c996c9 100644 --- a/packages/angular/src/builders/module-federation-dev-server/module-federation-dev-server.impl.ts +++ b/packages/angular/src/builders/module-federation-dev-server/module-federation-dev-server.impl.ts @@ -1,6 +1,5 @@ import type { Schema } from './schema'; import { logger, readCachedProjectGraph, workspaceRoot } from '@nx/devkit'; -import { scheduleTarget } from 'nx/src/adapter/ngcli-adapter'; import { executeWebpackDevServerBuilder } from '../webpack-dev-server/webpack-dev-server.impl'; import { readProjectsConfigurationFromProjectGraph } from 'nx/src/project-graph/project-graph'; import { getExecutorInformation } from 'nx/src/command-line/run/executor-utils'; @@ -12,11 +11,15 @@ import { import { existsSync } from 'fs'; import { extname, join } from 'path'; import { findMatchingProjects } from 'nx/src/utils/find-matching-projects'; +import { fork } from 'child_process'; +import { waitForPortOpen } from '../utilities/wait-for-port'; +import { from, switchMap } from 'rxjs'; export function executeModuleFederationDevServerBuilder( schema: Schema, context: import('@angular-devkit/architect').BuilderContext ): ReturnType { + const nxBin = require.resolve('nx'); const { ...options } = schema; const projectGraph = readCachedProjectGraph(); const { projects: workspaceProjects } = @@ -78,9 +81,14 @@ export function executeModuleFederationDevServerBuilder( ? findMatchingProjects(options.devRemotes, projectGraph.nodes) : findMatchingProjects([options.devRemotes], projectGraph.nodes); + let isCollectingStaticRemoteOutput = true; + const remotePorts: Set = new Set(); for (const remote of remotes) { const isDev = devServeRemotes.includes(remote); const target = isDev ? 'serve' : 'serve-static'; + remotePorts.add( + projectGraph.nodes[remote].data.targets[target].options.port + ); if (!workspaceProjects[remote].targets?.[target]) { throw new Error( @@ -107,25 +115,71 @@ export function executeModuleFederationDevServerBuilder( } } - scheduleTarget( - context.workspaceRoot, + let outWithErr: null | string[] = []; + const remoteProcess = fork( + nxBin, + [ + 'run', + `${remote}:${target}${ + context.target.configuration ? `:${context.target.configuration}` : '' + }`, + ...(runOptions.verbose ? [`--verbose`] : []), + ], { - project: remote, - target, - configuration: context.target.configuration, - runOptions, - }, - options.verbose - ).then((obs) => { - obs.toPromise().catch((err) => { - throw new Error( - `Remote '${remote}' failed to serve correctly due to the following: \r\n${err.toString()}` - ); - }); + cwd: context.workspaceRoot, + stdio: ['ignore', 'pipe', 'pipe', 'ipc'], + } + ); + + remoteProcess.stdout.on('data', (data) => { + if (isCollectingStaticRemoteOutput) { + outWithErr.push(data.toString()); + } else { + outWithErr = null; + remoteProcess.stdout.removeAllListeners('data'); + } + }); + remoteProcess.stderr.on('data', (data) => logger.info(data.toString())); + remoteProcess.on('exit', (code) => { + if (code !== 0) { + logger.info(outWithErr.join('')); + throw new Error(`Remote failed to start. See above for errors.`); + } }); + process.on('SIGTERM', () => remoteProcess.kill('SIGTERM')); + process.on('exit', () => remoteProcess.kill('SIGTERM')); } - return executeWebpackDevServerBuilder(options, context); + const waitForRemotes = async () => { + if (remotePorts.size === 0) { + return true; + } + try { + await Promise.all( + [...remotePorts.values()].map((port) => + // Allow 20 minutes for each remote to start, which is plenty of time but we can tweak it later if needed. + // Most remotes should start in under 1 minute. + waitForPortOpen(port, { + retries: 480, + retryDelay: 2500, + host: 'localhost', + }) + ) + ); + isCollectingStaticRemoteOutput = false; + logger.info(`NX All remotes started`); + + return true; + } catch { + throw new Error( + `Timed out waiting for remote to start. Check above for any errors.` + ); + } + }; + + return from(waitForRemotes()).pipe( + switchMap(() => executeWebpackDevServerBuilder(options, context)) + ); } export default require('@angular-devkit/architect').createBuilder( diff --git a/packages/angular/src/builders/utilities/wait-for-port.ts b/packages/angular/src/builders/utilities/wait-for-port.ts new file mode 100644 index 00000000000000..fad6aaa0a3c82e --- /dev/null +++ b/packages/angular/src/builders/utilities/wait-for-port.ts @@ -0,0 +1,40 @@ +import * as net from 'net'; + +export function waitForPortOpen( + port: number, + options: { host?: string; retries?: number; retryDelay?: number } = {} +): Promise { + const allowedErrorCodes = ['ECONNREFUSED', 'ECONNRESET']; + + return new Promise((resolve, reject) => { + const checkPort = (retries = options.retries ?? 120) => { + const client = new net.Socket(); + const cleanupClient = () => { + client.removeAllListeners('connect'); + client.removeAllListeners('error'); + client.end(); + client.destroy(); + client.unref(); + }; + client.once('connect', () => { + cleanupClient(); + resolve(); + }); + + client.once('error', (err) => { + if (retries === 0 || !allowedErrorCodes.includes(err['code'])) { + cleanupClient(); + reject(err); + } else { + setTimeout(() => checkPort(retries - 1), options.retryDelay ?? 1000); + } + }); + + // Node will use IPv6 if it is available, but this can cause issues if the server is only listening on IPv4. + // Hard-coding to look on 127.0.0.1 to avoid using the IPv6 loopback address "::1". + client.connect({ port, host: options.host ?? '127.0.0.1' }); + }; + + checkPort(); + }); +} 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 a3562d04943c1c..b61acba74ff4e4 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/js/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/js/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/src/main.server.ts__tmpl__ b/packages/angular/src/generators/host/files/ts/src/main.server.ts__tmpl__ new file mode 100644 index 00000000000000..905a0fd39a60f0 --- /dev/null +++ b/packages/angular/src/generators/host/files/ts/src/main.server.ts__tmpl__ @@ -0,0 +1,66 @@ +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<% if(standalone) { %> bootstrap <% } else { %> { 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(), '<%= browserBundleOutput %>'); + + 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({ + <% if(standalone) { %>bootstrap<% } else { %>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(); + +<% if(standalone) { %>export default bootstrap;<% } else { %>export * from './bootstrap.server';<% } %> 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 00000000000000..1dda6f2cdf3971 --- /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 a6340adc552a81..a12bb33621df86 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' }); @@ -198,6 +318,7 @@ describe('Host App Generator', () => { await generateTestHostApplication(tree, { name: 'test', ssr: true, + typescriptConfiguration: false, }); // ASSERT @@ -224,6 +345,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' }); @@ -233,6 +389,7 @@ describe('Host App Generator', () => { name: 'test', standalone: true, ssr: true, + typescriptConfiguration: false, }); // ASSERT @@ -262,6 +419,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 () => { @@ -292,6 +489,7 @@ describe('Host App Generator', () => { await generateTestRemoteApplication(tree, { name: 'remote1', projectNameAndRootFormat: 'derived', + typescriptConfiguration: false, }); // ACT @@ -299,6 +497,7 @@ describe('Host App Generator', () => { name: 'hostApp', remotes: ['remote1', 'remote2', 'remote3'], projectNameAndRootFormat: 'derived', + typescriptConfiguration: false, }); // ASSERT @@ -316,6 +515,7 @@ describe('Host App Generator', () => { await generateTestRemoteApplication(tree, { name: 'remote1', projectNameAndRootFormat: 'derived', + typescriptConfiguration: false, }); // ACT @@ -324,6 +524,7 @@ describe('Host App Generator', () => { directory: 'foo', remotes: ['remote1', 'remote2', 'remote3'], projectNameAndRootFormat: 'derived', + typescriptConfiguration: false, }); // ASSERT @@ -334,5 +535,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 468680d9d91816..3843d68d0fce48 100644 --- a/packages/angular/src/generators/host/host.ts +++ b/packages/angular/src/generators/host/host.ts @@ -23,14 +23,19 @@ 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, ...options }: Schema = { + ...schema, + typescriptConfiguration: schema.typescriptConfiguration ?? true, + }; + const projects = getProjects(tree); const remotesToGenerate: string[] = []; @@ -78,11 +83,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 +118,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 452da0af06f51b..447f9f0bb65487 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,29 @@ export async function addSsr(tree: Tree, options: Schema, appName: string) { 'browser' ); - generateFiles(tree, join(__dirname, '../files'), project.root, { - appName, - browserBundleOutput, - standalone: options.standalone, - tmpl: '', - }); + const pathToTemplateFiles = typescriptConfiguration ? 'ts' : 'js'; + + generateFiles( + tree, + join(__dirname, '../files', pathToTemplateFiles), + project.root, + { + appName, + browserBundleOutput, + standalone: options.standalone, + 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 4ad7be7d2578f8..4e77dceaabcf40 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 5c751de9db98be..890a298c2992aa 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 d4616f812880f8..abe333132c0a1e 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/src/main.server.ts__tmpl__ b/packages/angular/src/generators/remote/files/base-ts/src/main.server.ts__tmpl__ new file mode 100644 index 00000000000000..2551ff8dd4977a --- /dev/null +++ b/packages/angular/src/generators/remote/files/base-ts/src/main.server.ts__tmpl__ @@ -0,0 +1,74 @@ +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<% if(standalone) { %> bootstrap <% } else { %> { 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(), '<%= browserBundleOutput %>'); + const serverBundles = join(process.cwd(), '<%= serverBundleOutput %>'); + + 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({ + <% if(standalone) { %>bootstrap<% } else { %>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(); + +<% if(standalone) { %>export default bootstrap;<% } else { %>export * from './bootstrap.server';<% } %> 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 00000000000000..1dda6f2cdf3971 --- /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/lib/add-ssr.ts b/packages/angular/src/generators/remote/lib/add-ssr.ts index ff388769457b60..fbc3e3ecf49070 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); @@ -50,9 +56,11 @@ export async function addSsr( 'server' ); + const pathToTemplateFiles = typescriptConfiguration ? 'base-ts' : 'base'; + generateFiles( tree, - joinPathFragments(__dirname, '../files/base'), + joinPathFragments(__dirname, `../files/${pathToTemplateFiles}`), project.root, { appName, @@ -81,7 +89,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 863124c4719ae1..4263bbc03b7274 100644 --- a/packages/angular/src/generators/remote/remote.spec.ts +++ b/packages/angular/src/generators/remote/remote.spec.ts @@ -21,24 +21,42 @@ describe('MF Remote App Generator', () => { await generateTestRemoteApplication(tree, { name: 'test', port: 4201, + typescriptConfiguration: false, }); // ASSERT expect(tree.read('test/webpack.config.js', 'utf-8')).toMatchSnapshot(); }); + 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 @@ -46,6 +64,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' }); @@ -119,6 +158,7 @@ describe('MF Remote App Generator', () => { await generateTestRemoteApplication(tree, { name: 'test', standalone: true, + typescriptConfiguration: false, }); // ASSERT @@ -140,6 +180,36 @@ describe('MF Remote App Generator', () => { ).toMatchSnapshot(); }); + 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' }); @@ -207,6 +277,7 @@ describe('MF Remote App Generator', () => { await generateTestRemoteApplication(tree, { name: 'test', ssr: true, + typescriptConfiguration: false, }); // ASSERT @@ -244,6 +315,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 () => { @@ -275,6 +393,7 @@ describe('MF Remote App Generator', () => { name: 'test', port: 4201, projectNameAndRootFormat: 'derived', + typescriptConfiguration: false, }); expect(tree.exists('apps/test/webpack.config.js')).toBe(true); @@ -289,6 +408,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 633a2ed2eff089..30f43bf4f2b572 100644 --- a/packages/angular/src/generators/remote/remote.ts +++ b/packages/angular/src/generators/remote/remote.ts @@ -21,14 +21,19 @@ 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, ...options }: Schema = { + ...schema, + typescriptConfiguration: schema.typescriptConfiguration ?? true, + }; + const projects = getProjects(tree); if (options.host && !projects.has(options.host)) { throw new Error( @@ -71,6 +76,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 +84,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 e2ec3c49a06d54..a1fe31e6f2f94b 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 2817cdeb89c811..b819e8c7d4af2c 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 5b107fab4be9e7..422e2f2d06695a 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 00000000000000..867ac426a88af7 --- /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 00000000000000..3109786ab24a18 --- /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 00000000000000..eb9b8d26e78239 --- /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 2b126dc6aee435..a1782101c8eacb 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 add11f0daeb736..e2fc0596c4bc28 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 47bd394d5a8439..24c21e8698c693 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 cafa8510d1ec14..04ec9ae4e9d787 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 ed2f4f21a1337c..5717e019318544 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 aed4d9728004eb..1c0c875f28feb7 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 2331bb4ba0ffb8..69ab1639c41d6d 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 65c337ccd065fa..ea3569dfc5782d 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) => ({