feat(angular): add module-federation-dev-ssr builder (#13496)

This commit is contained in:
Colum Ferry 2022-11-30 11:41:12 +00:00 committed by GitHub
parent 4e672b2ab9
commit 0ce6735084
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 1090 additions and 243 deletions

View File

@ -3941,6 +3941,95 @@
"aliases": [],
"hidden": false,
"path": "/packages/angular/src/builders/module-federation-dev-server/schema.json"
},
{
"name": "module-federation-dev-ssr",
"implementation": "/packages/angular/src/builders/module-federation-dev-ssr/module-federation-dev-ssr.impl.ts",
"schema": {
"$schema": "http://json-schema.org/draft-07/schema",
"title": "Module Federation SSR Dev Server Target",
"description": "SSR Dev Server target options for Module Federation host applications.",
"type": "object",
"properties": {
"browserTarget": {
"type": "string",
"description": "Browser target to build.",
"pattern": ".+:.+(:.+)?"
},
"serverTarget": {
"type": "string",
"description": "Server target to build.",
"pattern": ".+:.+(:.+)?"
},
"host": {
"type": "string",
"description": "Host to listen on.",
"default": "localhost"
},
"port": {
"type": "number",
"default": 4200,
"description": "Port to start the development server at. Default is 4200. Pass 0 to get a dynamically assigned port."
},
"publicHost": {
"type": "string",
"description": "The URL that the browser client should use to connect to the development server. Use for a complex dev server setup, such as one with reverse proxies."
},
"open": {
"type": "boolean",
"description": "Opens the url in default browser.",
"default": false,
"alias": "o"
},
"progress": {
"type": "boolean",
"description": "Log progress to the console while building."
},
"inspect": {
"type": "boolean",
"description": "Launch the development server in inspector mode and listen on address and port '127.0.0.1:9229'.",
"default": false
},
"ssl": {
"type": "boolean",
"description": "Serve using HTTPS.",
"default": false
},
"sslKey": {
"type": "string",
"description": "SSL key to use for serving HTTPS."
},
"sslCert": {
"type": "string",
"description": "SSL certificate to use for serving HTTPS."
},
"proxyConfig": {
"type": "string",
"description": "Proxy configuration file."
},
"devRemotes": {
"type": "array",
"items": { "type": "string" },
"description": "List of remote applications to run in development mode (i.e. using serve target)."
},
"skipRemotes": {
"type": "array",
"items": { "type": "string" },
"description": "List of remote applications to not automatically serve, either statically or in development mode. This can be useful for multi-repository module federation setups where the host application uses a remote application from an external repository."
},
"verbose": {
"type": "boolean",
"description": "Adds more details to output logging."
}
},
"additionalProperties": false,
"required": ["browserTarget", "serverTarget"],
"presets": []
},
"description": "The module-federation-dev-ssr executor is reserved exclusively for use with host Module Federation applications that use SSR. It allows the user to specify which remote applications should be served with the host.",
"aliases": [],
"hidden": false,
"path": "/packages/angular/src/builders/module-federation-dev-ssr/schema.json"
}
]
}

View File

@ -20,7 +20,8 @@
"webpack-browser",
"webpack-dev-server",
"webpack-server",
"module-federation-dev-server"
"module-federation-dev-server",
"module-federation-dev-ssr"
],
"generators": [
"add-linting",

View File

@ -25,19 +25,19 @@
"lint": "nx workspace-lint"
},
"devDependencies": {
"@angular-devkit/architect": "~0.1500.0",
"@angular-devkit/build-angular": "~15.0.0",
"@angular-devkit/core": "~15.0.0",
"@angular-devkit/schematics": "~15.0.0",
"@angular-devkit/architect": "~0.1500.1",
"@angular-devkit/build-angular": "~15.0.1",
"@angular-devkit/core": "~15.0.1",
"@angular-devkit/schematics": "~15.0.1",
"@angular-eslint/eslint-plugin": "~15.0.0",
"@angular-eslint/eslint-plugin-template": "~15.0.0",
"@angular-eslint/template-parser": "~15.0.0",
"@angular/cli": "~15.0.0",
"@angular/common": "~15.0.0",
"@angular/compiler": "~15.0.0",
"@angular/compiler-cli": "~15.0.0",
"@angular/core": "~15.0.0",
"@angular/router": "~15.0.0",
"@angular/cli": "~15.0.1",
"@angular/common": "~15.0.1",
"@angular/compiler": "~15.0.1",
"@angular/compiler-cli": "~15.0.1",
"@angular/core": "~15.0.1",
"@angular/router": "~15.0.1",
"@babel/core": "^7.15.0",
"@babel/helper-create-regexp-features-plugin": "^7.14.5",
"@babel/preset-typescript": "^7.15.0",
@ -52,6 +52,7 @@
"@ngrx/effects": "~14.0.0",
"@ngrx/router-store": "~14.0.0",
"@ngrx/store": "~14.0.0",
"@nguniversal/builders": "~15.0.0",
"@nrwl/cypress": "15.3.0-beta.6",
"@nrwl/devkit": "15.3.0-beta.6",
"@nrwl/eslint-plugin-nx": "15.3.0-beta.6",
@ -73,7 +74,7 @@
"@rollup/plugin-image": "^2.1.0",
"@rollup/plugin-json": "^4.1.0",
"@rollup/plugin-node-resolve": "^13.0.4",
"@schematics/angular": "~15.0.0",
"@schematics/angular": "~15.0.1",
"@storybook/addon-essentials": "~6.5.9",
"@storybook/angular": "^6.5.12",
"@storybook/builder-webpack5": "~6.5.9",

View File

@ -57,6 +57,11 @@
"schema": "./src/builders/module-federation-dev-server/schema.json",
"description": "The module-federation-dev-server executor is reserved exclusively for use with host Module Federation applications. It allows the user to specify which remote applications should be served with the host."
},
"module-federation-dev-ssr": {
"implementation": "./src/builders/module-federation-dev-ssr/module-federation-dev-ssr.impl",
"schema": "./src/builders/module-federation-dev-ssr/schema.json",
"description": "The module-federation-dev-ssr executor is reserved exclusively for use with host Module Federation applications that use SSR. It allows the user to specify which remote applications should be served with the host."
},
"file-server": {
"implementation": "./src/executors/file-server/compat",
"schema": "./src/executors/file-server/schema.json",

View File

@ -1,4 +1,5 @@
export * from './src/builders/module-federation-dev-server/module-federation-dev-server.impl';
export * from './src/builders/module-federation-dev-ssr/module-federation-dev-ssr.impl';
export * from './src/builders/webpack-browser/webpack-browser.impl';
export * from './src/builders/webpack-dev-server/webpack-dev-server.impl';
export * from './src/builders/webpack-server/webpack-server.impl';

View File

@ -7,6 +7,7 @@
"@nrwl/",
"@angular-devkit",
"@angular-eslint/",
"@nguniversal/builders",
"@schematics",
"@phenomnomnominal/tsquery",
"@typescript-eslint/",

View File

@ -39,7 +39,8 @@
"migrations": "./migrations.json"
},
"dependencies": {
"@angular-devkit/schematics": "~15.0.0",
"@angular-devkit/schematics": "~15.0.1",
"@nguniversal/builders": "~15.0.0",
"@nrwl/cypress": "file:../cypress",
"@nrwl/devkit": "file:../devkit",
"@nrwl/jest": "file:../jest",
@ -48,7 +49,7 @@
"@nrwl/webpack": "file:../webpack",
"@nrwl/workspace": "file:../workspace",
"@phenomnomnominal/tsquery": "4.1.1",
"@schematics/angular": "~15.0.0",
"@schematics/angular": "~15.0.1",
"chalk": "4.1.0",
"chokidar": "^3.5.1",
"http-server": "^14.1.0",

View File

@ -1,140 +1,19 @@
import type { Schema } from './schema';
import {
ProjectConfiguration,
readCachedProjectGraph,
Remotes,
workspaceRoot,
Workspaces,
} from '@nrwl/devkit';
import { scheduleTarget } from 'nx/src/adapter/ngcli-adapter';
import { BuilderContext, createBuilder } from '@angular-devkit/architect';
import { JsonObject } from '@angular-devkit/core';
import { join } from 'path';
import { executeWebpackDevServerBuilder } from '../webpack-dev-server/webpack-dev-server.impl';
import { existsSync, readFileSync } from 'fs';
import { readProjectsConfigurationFromProjectGraph } from 'nx/src/project-graph/project-graph';
function getDynamicRemotes(
project: ProjectConfiguration,
context: BuilderContext,
workspaceProjects: Record<string, ProjectConfiguration>,
remotesToSkip: Set<string>
): string[] {
// check for dynamic remotes
// we should only check for dynamic based on what we generate
// and fallback to empty array
const standardPathToGeneratedMFManifestJson = join(
context.workspaceRoot,
project.sourceRoot,
'assets/module-federation.manifest.json'
);
if (!existsSync(standardPathToGeneratedMFManifestJson)) {
return [];
}
const moduleFederationManifestJson = readFileSync(
standardPathToGeneratedMFManifestJson,
'utf-8'
);
if (!moduleFederationManifestJson) {
return [];
}
// This should have shape of
// {
// "remoteName": "remoteLocation",
// }
const parsedManifest = JSON.parse(moduleFederationManifestJson);
if (
!Object.keys(parsedManifest).every(
(key) =>
typeof key === 'string' && typeof parsedManifest[key] === 'string'
)
) {
return [];
}
const dynamicRemotes = Object.entries(parsedManifest)
.map(([remoteName]) => remoteName)
.filter((r) => !remotesToSkip.has(r));
const invalidDynamicRemotes = dynamicRemotes.filter(
(remote) => !workspaceProjects[remote]
);
if (invalidDynamicRemotes.length) {
throw new Error(
invalidDynamicRemotes.length === 1
? `Invalid dynamic remote configured in "${standardPathToGeneratedMFManifestJson}": ${invalidDynamicRemotes[0]}.`
: `Invalid dynamic remotes configured in "${standardPathToGeneratedMFManifestJson}": ${invalidDynamicRemotes.join(
', '
)}.`
);
}
return dynamicRemotes;
}
function getStaticRemotes(
project: ProjectConfiguration,
context: BuilderContext,
workspaceProjects: Record<string, ProjectConfiguration>,
remotesToSkip: Set<string>
): string[] {
const mfConfigPath = join(
context.workspaceRoot,
project.root,
'module-federation.config.js'
);
let mfeConfig: { remotes: Remotes };
try {
mfeConfig = require(mfConfigPath);
} catch {
throw new Error(
`Could not load ${mfConfigPath}. Was this project generated with "@nrwl/angular:host"?`
);
}
const remotesConfig = mfeConfig.remotes.length > 0 ? mfeConfig.remotes : [];
const staticRemotes = remotesConfig
.map((remoteDefinition) =>
Array.isArray(remoteDefinition) ? remoteDefinition[0] : remoteDefinition
)
.filter((r) => !remotesToSkip.has(r));
const invalidStaticRemotes = staticRemotes.filter(
(remote) => !workspaceProjects[remote]
);
if (invalidStaticRemotes.length) {
throw new Error(
invalidStaticRemotes.length === 1
? `Invalid static remote configured in "${mfConfigPath}": ${invalidStaticRemotes[0]}.`
: `Invalid static remotes configured in "${mfConfigPath}": ${invalidStaticRemotes.join(
', '
)}.`
);
}
return staticRemotes;
}
function validateDevRemotes(
options: Schema,
workspaceProjects: Record<string, ProjectConfiguration>
): void {
const invalidDevRemotes = options.devRemotes?.filter(
(remote) => !workspaceProjects[remote]
);
if (invalidDevRemotes.length) {
throw new Error(
invalidDevRemotes.length === 1
? `Invalid dev remote provided: ${invalidDevRemotes[0]}.`
: `Invalid dev remotes provided: ${invalidDevRemotes.join(', ')}.`
);
}
}
import {
getDynamicRemotes,
getStaticRemotes,
validateDevRemotes,
} from '../utilities/module-federation';
export function executeModuleFederationDevServerBuilder(
schema: Schema,

View File

@ -0,0 +1,115 @@
import type { Schema } from './schema';
import type { BuilderContext } from '@angular-devkit/architect';
import { createBuilder } from '@angular-devkit/architect';
import type { JsonObject } from '@angular-devkit/core';
import { executeSSRDevServerBuilder } from '@nguniversal/builders';
import { readProjectsConfigurationFromProjectGraph } from 'nx/src/project-graph/project-graph';
import {
readCachedProjectGraph,
workspaceRoot,
Workspaces,
} from '@nrwl/devkit';
import {
getDynamicRemotes,
getStaticRemotes,
validateDevRemotes,
} from '../utilities/module-federation';
import { switchMap } from 'rxjs/operators';
import { from } from 'rxjs';
import { join } from 'path';
import { execSync, fork } from 'child_process';
export function executeModuleFederationDevSSRBuilder(
schema: Schema,
context: BuilderContext
) {
const { ...options } = schema;
const projectGraph = readCachedProjectGraph();
const { projects: workspaceProjects } =
readProjectsConfigurationFromProjectGraph(projectGraph);
const ws = new Workspaces(workspaceRoot);
const project = workspaceProjects[context.target.project];
validateDevRemotes(options, workspaceProjects);
const remotesToSkip = new Set(options.skipRemotes ?? []);
const staticRemotes = getStaticRemotes(
project,
context,
workspaceProjects,
remotesToSkip
);
const dynamicRemotes = getDynamicRemotes(
project,
context,
workspaceProjects,
remotesToSkip
);
const remotes = [...staticRemotes, ...dynamicRemotes];
const devServeRemotes = !options.devRemotes
? []
: Array.isArray(options.devRemotes)
? options.devRemotes
: [options.devRemotes];
const remoteProcessPromises = [];
for (const remote of remotes) {
const isDev = devServeRemotes.includes(remote);
const target = isDev ? 'serve-ssr' : 'static-server';
if (!workspaceProjects[remote].targets?.[target]) {
throw new Error(
`Could not find "${target}" target in "${remote}" project.`
);
} else if (!workspaceProjects[remote].targets?.[target].executor) {
throw new Error(
`Could not find executor for "${target}" target in "${remote}" project.`
);
}
const runOptions: { verbose?: boolean } = {};
if (options.verbose) {
const [collection, executor] =
workspaceProjects[remote].targets[target].executor.split(':');
const { schema } = ws.readExecutor(collection, executor);
if (schema.additionalProperties || 'verbose' in schema.properties) {
runOptions.verbose = options.verbose;
}
}
const remotePromise = new Promise<void>((res, rej) => {
const remoteProject = workspaceProjects[remote];
const remoteServerOutput = join(
workspaceRoot,
remoteProject.targets.server.options.outputPath,
'main.js'
);
execSync(
`npx nx run ${remote}:server${
context.target.configuration ? `:${context.target.configuration}` : ''
}`,
{ stdio: 'inherit' }
);
const child = fork(remoteServerOutput, {
env: remoteProject.targets['serve-ssr'].options.port,
});
child.on('message', (msg) => {
if (msg === 'nx.server.ready') {
res();
}
});
});
remoteProcessPromises.push(remotePromise);
}
return from(Promise.all(remoteProcessPromises)).pipe(
switchMap(() => executeSSRDevServerBuilder(options, context))
);
}
export default createBuilder<JsonObject & Schema>(
executeModuleFederationDevSSRBuilder
);

View File

@ -0,0 +1,16 @@
export interface Schema {
browserTarget: string;
serverTarget: string;
host?: string;
port?: number;
progress: boolean;
open?: boolean;
publicHost?: string;
ssl?: boolean;
sslKey?: string;
sslCert?: string;
proxyConfig?: string;
devRemotes?: string[];
skipRemotes?: string[];
verbose?: boolean;
}

View File

@ -0,0 +1,84 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"title": "Module Federation SSR Dev Server Target",
"description": "SSR Dev Server target options for Module Federation host applications.",
"type": "object",
"properties": {
"browserTarget": {
"type": "string",
"description": "Browser target to build.",
"pattern": ".+:.+(:.+)?"
},
"serverTarget": {
"type": "string",
"description": "Server target to build.",
"pattern": ".+:.+(:.+)?"
},
"host": {
"type": "string",
"description": "Host to listen on.",
"default": "localhost"
},
"port": {
"type": "number",
"default": 4200,
"description": "Port to start the development server at. Default is 4200. Pass 0 to get a dynamically assigned port."
},
"publicHost": {
"type": "string",
"description": "The URL that the browser client should use to connect to the development server. Use for a complex dev server setup, such as one with reverse proxies."
},
"open": {
"type": "boolean",
"description": "Opens the url in default browser.",
"default": false,
"alias": "o"
},
"progress": {
"type": "boolean",
"description": "Log progress to the console while building."
},
"inspect": {
"type": "boolean",
"description": "Launch the development server in inspector mode and listen on address and port '127.0.0.1:9229'.",
"default": false
},
"ssl": {
"type": "boolean",
"description": "Serve using HTTPS.",
"default": false
},
"sslKey": {
"type": "string",
"description": "SSL key to use for serving HTTPS."
},
"sslCert": {
"type": "string",
"description": "SSL certificate to use for serving HTTPS."
},
"proxyConfig": {
"type": "string",
"description": "Proxy configuration file."
},
"devRemotes": {
"type": "array",
"items": {
"type": "string"
},
"description": "List of remote applications to run in development mode (i.e. using serve target)."
},
"skipRemotes": {
"type": "array",
"items": {
"type": "string"
},
"description": "List of remote applications to not automatically serve, either statically or in development mode. This can be useful for multi-repository module federation setups where the host application uses a remote application from an external repository."
},
"verbose": {
"type": "boolean",
"description": "Adds more details to output logging."
}
},
"additionalProperties": false,
"required": ["browserTarget", "serverTarget"]
}

View File

@ -0,0 +1,127 @@
import { ProjectConfiguration } from 'nx/src/config/workspace-json-project-json';
import { BuilderContext } from '@angular-devkit/architect';
import { join } from 'path';
import { existsSync, readFileSync } from 'fs';
import { Remotes } from '@nrwl/devkit';
export function getDynamicRemotes(
project: ProjectConfiguration,
context: BuilderContext,
workspaceProjects: Record<string, ProjectConfiguration>,
remotesToSkip: Set<string>
): string[] {
// check for dynamic remotes
// we should only check for dynamic based on what we generate
// and fallback to empty array
const standardPathToGeneratedMFManifestJson = join(
context.workspaceRoot,
project.sourceRoot,
'assets/module-federation.manifest.json'
);
if (!existsSync(standardPathToGeneratedMFManifestJson)) {
return [];
}
const moduleFederationManifestJson = readFileSync(
standardPathToGeneratedMFManifestJson,
'utf-8'
);
if (!moduleFederationManifestJson) {
return [];
}
// This should have shape of
// {
// "remoteName": "remoteLocation",
// }
const parsedManifest = JSON.parse(moduleFederationManifestJson);
if (
!Object.keys(parsedManifest).every(
(key) =>
typeof key === 'string' && typeof parsedManifest[key] === 'string'
)
) {
return [];
}
const dynamicRemotes = Object.entries(parsedManifest)
.map(([remoteName]) => remoteName)
.filter((r) => !remotesToSkip.has(r));
const invalidDynamicRemotes = dynamicRemotes.filter(
(remote) => !workspaceProjects[remote]
);
if (invalidDynamicRemotes.length) {
throw new Error(
invalidDynamicRemotes.length === 1
? `Invalid dynamic remote configured in "${standardPathToGeneratedMFManifestJson}": ${invalidDynamicRemotes[0]}.`
: `Invalid dynamic remotes configured in "${standardPathToGeneratedMFManifestJson}": ${invalidDynamicRemotes.join(
', '
)}.`
);
}
return dynamicRemotes;
}
export function getStaticRemotes(
project: ProjectConfiguration,
context: BuilderContext,
workspaceProjects: Record<string, ProjectConfiguration>,
remotesToSkip: Set<string>
): string[] {
const mfConfigPath = join(
context.workspaceRoot,
project.root,
'module-federation.config.js'
);
let mfeConfig: { remotes: Remotes };
try {
mfeConfig = require(mfConfigPath);
} catch {
throw new Error(
`Could not load ${mfConfigPath}. Was this project generated with "@nrwl/angular:host"?`
);
}
const remotesConfig = mfeConfig.remotes.length > 0 ? mfeConfig.remotes : [];
const staticRemotes = remotesConfig
.map((remoteDefinition) =>
Array.isArray(remoteDefinition) ? remoteDefinition[0] : remoteDefinition
)
.filter((r) => !remotesToSkip.has(r));
const invalidStaticRemotes = staticRemotes.filter(
(remote) => !workspaceProjects[remote]
);
if (invalidStaticRemotes.length) {
throw new Error(
invalidStaticRemotes.length === 1
? `Invalid static remote configured in "${mfConfigPath}": ${invalidStaticRemotes[0]}.`
: `Invalid static remotes configured in "${mfConfigPath}": ${invalidStaticRemotes.join(
', '
)}.`
);
}
return staticRemotes;
}
export function validateDevRemotes(
options: { devRemotes?: string[] },
workspaceProjects: Record<string, ProjectConfiguration>
): void {
const invalidDevRemotes = options.devRemotes?.filter(
(remote) => !workspaceProjects[remote]
);
if (invalidDevRemotes.length) {
throw new Error(
invalidDevRemotes.length === 1
? `Invalid dev remote provided: ${invalidDevRemotes[0]}.`
: `Invalid dev remotes provided: ${invalidDevRemotes.join(', ')}.`
);
}
}

733
yarn.lock

File diff suppressed because it is too large Load Diff