chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:38:36 +08:00
commit 8e2a6eb840
10194 changed files with 1593658 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
{
"outDir": "packages/angular/dist",
"assets": [
{
"glob": "**/files/**"
},
{
"glob": "**/files/**/.gitkeep"
},
{
"glob": "src/**/schema.json"
},
{
"glob": "src/**/schema.d.ts"
},
{
"glob": "src/migrations/**/*.md"
},
"LICENSE"
]
}
@@ -0,0 +1,33 @@
## Examples
##### Simple Application
Create an application named `my-app`:
```bash
nx g @nx/angular:application apps/my-app
```
##### Specify style extension
Create an application named `my-app` in the `my-dir` directory and use `scss` for styles:
```bash
nx g @nx/angular:app my-dir/my-app --style=scss
```
##### Single File Components application
Create an application with Single File Components (inline styles and inline templates):
```bash
nx g @nx/angular:app apps/my-app --inlineStyle --inlineTemplate
```
##### Set custom prefix and tags
Set the prefix to apply to generated selectors and add tags to the application (used for linting).
```bash
nx g @nx/angular:app apps/my-app --prefix=admin --tags=scope:admin,type:ui
```
@@ -0,0 +1,103 @@
This executor is a drop-in replacement for the `@angular-devkit/build-angular:application` builder provided by the Angular CLI. It builds an Angular application using [esbuild](https://esbuild.github.io/) with integrated SSR and prerendering capabilities.
In addition to the features provided by the Angular CLI builder, the `@nx/angular:application` executor also supports the following:
- Providing esbuild plugins
- Providing a function to transform the application's `index.html` file
- Incremental builds
:::tip[Dev Server]
The [`@nx/angular:dev-server` executor](/nx-api/angular/executors/dev-server) is required to serve your application when using the `@nx/angular:application` to build it. It is a drop-in replacement for the Angular CLI's `@angular-devkit/build-angular:dev-server` builder and ensures the application is correctly served with Vite when using the `@nx/angular:application` executor.
:::
## Examples
##### Providing esbuild plugins
The executor accepts a `plugins` option that allows you to provide esbuild plugins that will be used when building your application. It allows providing a path to a plugin file or an object with a `path` and `options` property to provide options to the plugin.
```json title="apps/my-app/project.json" {8-16}
{
...
"targets": {
"build": {
"executor": "@nx/angular:application",
"options": {
...
"plugins": [
"apps/my-app/plugins/plugin1.js",
{
"path": "apps/my-app/plugins/plugin2.js",
"options": {
"someOption": "some value"
}
}
]
}
}
...
}
}
```
```ts title="apps/my-app/plugins/plugin1.js"
const plugin1 = {
name: 'plugin1',
setup(build) {
const options = build.initialOptions;
options.define.PLUGIN1_TEXT = '"Value was provided at build time"';
},
};
module.exports = plugin1;
```
```ts title="apps/my-app/plugins/plugin2.js"
function plugin2({ someOption }) {
return {
name: 'plugin2',
setup(build) {
const options = build.initialOptions;
options.define.PLUGIN2_TEXT = JSON.stringify(someOption);
},
};
}
module.exports = plugin2;
```
Additionally, we need to inform TypeScript of the defined variables to prevent type-checking errors during the build. We can achieve this by creating or updating a type definition file included in the TypeScript build process (e.g. `src/types.d.ts`) with the following content:
```ts title="apps/my-app/src/types.d.ts"
declare const PLUGIN1_TEXT: number;
declare const PLUGIN2_TEXT: string;
```
##### Transforming the 'index.html' file
The executor accepts an `indexHtmlTransformer` option to provide a path to a file with a default export for a function that receives the application's `index.html` file contents and outputs the updated contents.
```json title="apps/my-app/project.json" {8}
{
...
"targets": {
"build": {
"executor": "@nx/angular:application",
"options": {
...
"indexHtmlTransformer": "apps/my-app/index-html.transformer.ts"
}
}
...
}
}
```
```ts title="apps/my-app/index-html.transformer.ts"
export default function (indexContent: string) {
return indexContent.replace(
'<title>my-app</title>',
'<title>my-app (transformed)</title>'
);
}
```
@@ -0,0 +1,73 @@
This executor is a drop-in replacement for the `@angular-devkit/build-angular:browser-esbuild` builder provided by the Angular CLI. It builds an Angular application using esbuild.
In addition to the features provided by the Angular CLI builder, the `@nx/angular:browser-esbuild` executor also supports the following:
- Providing esbuild plugins
- Incremental builds
:::tip[Dev Server]
The [`@nx/angular:dev-server` executor](/nx-api/angular/executors/dev-server) is required to serve your application when using the `@nx/angular:browser-esbuild` to build it. It is a drop-in replacement for the Angular CLI's `@angular-devkit/build-angular:dev-server` builder and ensures the application is correctly served with Vite when using the `@nx/angular:browser-esbuild` executor.
:::
## Examples
##### Providing esbuild plugins
The executor accepts a `plugins` option that allows you to provide esbuild plugins that will be used when building your application. It allows providing a path to a plugin file or an object with a `path` and `options` property to provide options to the plugin.
```json title="apps/my-app/project.json" {8-16}
{
...
"targets": {
"build": {
"executor": "@nx/angular:browser-esbuild",
"options": {
...
"plugins": [
"apps/my-app/plugins/plugin1.js",
{
"path": "apps/my-app/plugins/plugin2.js",
"options": {
"someOption": "some value"
}
}
]
}
}
...
}
}
```
```ts title="apps/my-app/plugins/plugin1.js"
const plugin1 = {
name: 'plugin1',
setup(build) {
const options = build.initialOptions;
options.define.PLUGIN1_TEXT = '"Value was provided at build time"';
},
};
module.exports = plugin1;
```
```ts title="apps/my-app/plugins/plugin2.js"
function plugin2({ someOption }) {
return {
name: 'plugin2',
setup(build) {
const options = build.initialOptions;
options.define.PLUGIN2_TEXT = JSON.stringify(someOption);
},
};
}
module.exports = plugin2;
```
Additionally, we need to inform TypeScript of the defined variables to prevent type-checking errors during the build. We can achieve this by creating or updating a type definition file included in the TypeScript build process (e.g. `src/types.d.ts`) with the following content:
```ts title="apps/my-app/src/types.d.ts"
declare const PLUGIN1_TEXT: number;
declare const PLUGIN2_TEXT: string;
```
@@ -0,0 +1,49 @@
## Examples
##### Simple Component
Generate a component named `Card` at `apps/my-app/src/lib/card/card.ts`:
```bash
nx g @nx/angular:component apps/my-app/src/lib/card/card.ts
```
##### Without Providing the File Extension
Generate a component named `Card` at `apps/my-app/src/lib/card/card.ts`:
```bash
nx g @nx/angular:component apps/my-app/src/lib/card/card
```
##### With Different Symbol Name
Generate a component named `Custom` at `apps/my-app/src/lib/card/card.ts`:
```bash
nx g @nx/angular:component apps/my-app/src/lib/card/card --name=custom
```
##### With a Component Type
Generate a component named `CardComponent` at `apps/my-app/src/lib/card/card.component.ts`:
```bash
nx g @nx/angular:component apps/my-app/src/lib/card/card --type=component
```
##### Single File Component
Create a component named `Card` with inline styles and inline template:
```bash
nx g @nx/angular:component apps/my-app/src/lib/card/card --inlineStyle --inlineTemplate
```
##### Component with OnPush Change Detection Strategy
Create a component named `Card` with `OnPush` Change Detection Strategy:
```bash
nx g @nx/angular:component apps/my-app/src/lib/card/card --changeDetection=OnPush
```
@@ -0,0 +1,15 @@
:::caution[Can I use component testing?]
Angular component testing with Nx requires **Cypress version 10.7.0** and up.
You can migrate with to v11 via the [migrate-to-cypress-11 generator](/nx-api/cypress/generators/migrate-to-cypress-11).
:::
This generator is used to create a Cypress component test file for a given Angular component.
```shell
nx g @nx/angular:component-test --project=my-cool-angular-project --componentName=CoolBtnComponent --componentDir=src/cool-btn --componentFileName=cool-btn.component
```
Test file are generated with the `.cy.ts` suffix. this is to prevent colliding with any existing `.spec.` files contained in the project.
It's currently expected the generated `.cy.ts` file will live side by side with the component. It is also assumed the project is already setup for component testing. If it isn't, then you can run the [cypress-component-project generator](/nx-api/angular/generators/cypress-component-configuration) to set up the project for component testing.
@@ -0,0 +1,103 @@
:::caution[Can I use component testing?]
Angular component testing with Nx requires **Cypress version 10.7.0** and up.
You can migrate to v11 via the [migrate-to-cypress-11 generator](/nx-api/cypress/generators/migrate-to-cypress-11).
This generator is for Cypress based component testing.
:::
This generator is designed to get your Angular project up and running with Cypress Component Testing.
```shell
nx g @nx/angular:cypress-component-configuration --project=my-cool-angular-project
```
Running this generator, adds the required files to the specified project with a preconfigured `cypress.config.ts` designed for Nx workspaces.
```ts title="cypress.config.ts"
import { defineConfig } from 'cypress';
import { nxComponentTestingPreset } from '@nx/angular/plugins/component-testing';
export default defineConfig({
component: nxComponentTestingPreset(__filename),
});
```
Here is an example on how to add custom options to the configuration
```ts title="cypress.config.ts"
import { defineConfig } from 'cypress';
import { nxComponentTestingPreset } from '@nx/angular/plugins/component-testing';
export default defineConfig({
component: {
...nxComponentTestingPreset(__filename),
// extra options here
},
});
```
## Specifying a Build Target
Component testing requires a _build target_ to correctly run the component test dev server. This option can be manually specified with `--build-target=some-angular-app:build`, but Nx will infer this usage from the [project graph](/concepts/mental-model#the-project-graph) if one isn't provided.
For Angular projects, the build target needs to be using the `@nx/angular:webpack-browser` or
`@angular-devkit/build-angular:browser` executor.
The generator will throw an error if a build target can't be found and suggest passing one in manually.
Letting Nx infer the build target by default
```shell
nx g @nx/angular:cypress-component-configuration --project=my-cool-angular-project
```
Manually specifying the build target
```shell
nx g @nx/angular:cypress-component-configuration --project=my-cool-angular-project --build-target:some-angular-app:build --generate-tests
```
:::note[Build Target with Configuration]
If you're wanting to use a build target with a specific configuration. i.e. `my-app:build:production`,
then manually providing `--build-target=my-app:build:production` is the best way to do that.
:::
## Auto Generating Tests
You can optionally use the `--generate-tests` flag to generate a test file for each component in your project.
```shell
nx g @nx/angular:cypress-component-configuration --project=my-cool-angular-project --generate-tests
```
## Running Component Tests
A new `component-test` target will be added to the specified project to run your component tests.
```shell
nx g component-test my-cool-angular-project
```
Here is an example of the project configuration that is generated. The `--build-target` option is added as the `devServerTarget` which can be changed as needed.
```json title="project.json"
{
"targets" {
"component-test": {
"executor": "@nx/cypress:cypress",
"options": {
"cypressConfig": "<path-to-project-root>/cypress.config.ts",
"testingType": "component",
"devServerTarget": "some-angular-app:build",
"skipServe": true
}
}
}
}
```
## What is bundled
When the project being tested is a dependent of the specified `--build-target`, then **assets, scripts, and styles** are applied to the component being tested. You can determine if the project is dependent by using the [project graph](/features/explore-graph). If there is no link between the two projects, then the **assets, scripts, and styles** won't be included in the build; therefore, they will not be applied to the component. To have a link between projects, you can import from the project being tested into the specified `--build-target` project, or set the `--build-target` project to [implicitly depend](/reference/project-configuration#implicitdependencies) on the project being tested.
Nx also supports [React component testing](/nx-api/react/generators/cypress-component-configuration).
@@ -0,0 +1,37 @@
## Examples
##### Basic Usage
Delegate the build of the project to a different target.
```json
{
"prod-build": {
"executor": "@nx/angular:delegate-build",
"options": {
"buildTarget": "app:build:production",
"outputPath": "dist/apps/app/production",
"tsConfig": "apps/app/tsconfig.json",
"watch": false
}
}
}
```
##### Watch for build changes
Delegate the build of the project to a different target.
```json
{
"prod-build": {
"executor": "@nx/angular:delegate-build",
"options": {
"buildTarget": "app:build:production",
"outputPath": "dist/apps/app/production",
"tsConfig": "apps/app/tsconfig.json",
"watch": true
}
}
}
```
@@ -0,0 +1,84 @@
This executor is a drop-in replacement for the `@angular-devkit/build-angular:dev-server` builder provided by the Angular CLI. In addition to the features provided by the Angular CLI builder, the `@nx/angular:dev-server` executor also supports the following:
- Serving applications with Vite when using the `@nx/angular:application` or `@nx/angular:browser-esbuild` executors to build them
- Serving applications with webpack when using the `@nx/angular:webpack-browser` executor
- Providing HTTP request middleware functions when the build target is using an esbuild-based executor
- Incremental builds
## Examples
##### Using a custom webpack configuration
This executor should be used along with `@nx/angular:webpack-browser` to serve an application using a custom webpack configuration.
Add the `serve` target using the `@nx/angular:dev-server` executor, set the `build` target executor as `@nx/angular:webpack-browser` and set the `customWebpackConfig` option as shown below:
```json title="apps/my-app/project.json" {2,5-7,10-20}
"build": {
"executor": "@nx/angular:webpack-browser",
"options": {
...
"customWebpackConfig": {
"path": "apps/my-app/webpack.config.js"
}
}
},
"serve": {
"executor": "@nx/angular:dev-server",
"configurations": {
"production": {
"buildTarget": "my-app:build:production"
},
"development": {
"buildTarget": "my-app:build:development"
}
},
"defaultConfiguration": "development",
}
```
```js title="apps/my-app/webpack.config.js"
module.exports = (config) => {
// update the config with your custom configuration
return config;
};
```
##### Providing HTTP request middleware function
The executor accepts an `esbuildMiddleware` option that allows you to provide HTTP require middleware functions that will be used by the Vite development server.
```json title="apps/my-app/project.json" {8}
{
...
"targets": {
"serve": {
"executor": "@nx/angular:dev-server",
"options": {
...
"esbuildMiddleware": ["apps/my-app/hello-world.middleware.ts"]
}
}
...
}
}
```
```ts title="apps/my-app/hello-world.middleware.ts"
import type { IncomingMessage, ServerResponse } from 'node:http';
const helloWorldMiddleware = (
req: IncomingMessage,
res: ServerResponse,
next: (err?: unknown) => void
) => {
if (req.url === '/hello-world') {
res.end('<h1>Hello World!</h1>');
} else {
next();
}
};
export default helloWorldMiddleware;
```
+33
View File
@@ -0,0 +1,33 @@
## Examples
##### Simple Library
Creates the `my-ui-lib` library with an `ui` tag:
```bash
nx g @nx/angular:library libs/my-ui-lib --tags=ui
```
##### Publishable Library
Creates the `my-lib` library that can be built producing an output following the Angular Package Format (APF) to be distributed as an NPM package:
```bash
nx g @nx/angular:library libs/my-lib --publishable --import-path=@my-org/my-lib
```
##### Buildable Library
Creates the `my-lib` library with support for incremental builds:
```bash
nx g @nx/angular:library libs/my-lib --buildable
```
##### Nested Folder & Import
Creates the `my-lib` library in the `nested` directory and sets the import path to `@myorg/nested/my-lib`:
```bash
nx g @nx/angular:library libs/nested/my-lib --importPath=@myorg/nested/my-lib
```
@@ -0,0 +1,17 @@
## Examples
##### Basic Usage
Create a secondary entrypoint named `button` in the `ui` library.
```bash
nx g @nx/angular:library-secondary-entry-point --library=ui --name=button
```
##### Skip generating module
Create a secondary entrypoint named `button` in the `ui` library but skip creating an NgModule.
```bash
nx g @nx/angular:library-secondary-entry-point --library=ui --name=button --skipModule
```
@@ -0,0 +1,60 @@
## Examples
##### Basic Usage
The Module Federation Dev Server will serve a host application and find the remote applications associated with the host and serve them statically also.
See an example set up of it below:
```json
{
"serve": {
"executor": "@nx/angular:module-federation-dev-server",
"configurations": {
"production": {
"buildTarget": "host:build:production"
},
"development": {
"buildTarget": "host:build:development"
}
},
"defaultConfiguration": "development",
"options": {
"port": 4200,
"publicHost": "http://localhost:4200"
}
}
}
```
##### Serve host with remotes that can be live reloaded
The Module Federation Dev Server will serve a host application and find the remote applications associated with the host and serve a set selection with live reloading enabled also.
See an example set up of it below:
```json
{
"serve-with-hmr-remotes": {
"executor": "@nx/angular:module-federation-dev-server",
"configurations": {
"production": {
"buildTarget": "host:build:production"
},
"development": {
"buildTarget": "host:build:development"
}
},
"defaultConfiguration": "development",
"options": {
"port": 4200,
"publicHost": "http://localhost:4200",
"devRemotes": [
"remote1",
{
"remoteName": "remote2",
"configuration": "development"
}
]
}
}
}
```
+5
View File
@@ -0,0 +1,5 @@
## Information
This generator is usually used as part of the process of migrating from an Angular CLI Workspace to an Nx monorepo using `npx nx@latest init --integrated`.
You can read more about [migrating from Angular CLI to Nx here](/recipes/angular/migration/angular).
@@ -0,0 +1,11 @@
## Examples
##### Basic Usage
This generator allows you to convert an Inline SCAM to a Standalone Component. It's important that the SCAM you wish to convert has it's NgModule within the same file for the generator to be able to correctly convert the component to Standalone.
```bash
nx g @nx/angular:scam-to-standalone --component=libs/mylib/src/lib/myscam/myscam.ts --project=mylib
```
@@ -0,0 +1,35 @@
## Examples
The `setup-mf` generator is used to add Module Federation support to existing applications.
##### Convert to Host
To convert an existing application to a host application, run the following
```bash
nx g setup-mf myapp --mfType=host --routing=true
```
##### Convert to Remote
To convert an existing application to a remote application, run the following
```bash
nx g setup-mf myapp --mfType=remote --routing=true
```
##### Convert to Remote and attach to a host application
To convert an existing application to a remote application and attach it to an existing host application name `myhostapp`, run the following
```bash
nx g setup-mf myapp --mfType=remote --routing=true --host=myhostapp
```
##### Convert to Host and attach to existing remote applications
To convert an existing application to a host application and attaching existing remote applications named `remote1` and `remote2`, run the following
```bash
nx g setup-mf myapp --mfType=host --routing=true --remotes=remote1,remote2
```
+38
View File
@@ -0,0 +1,38 @@
This generator will generate stories for all your components in your project. The stories will be generated using [Component Story Format 3 (CSF3)](https://storybook.js.org/blog/storybook-csf3-is-here/).
```bash
nx g @nx/angular:stories project-name
```
You can read more about how this generator works, in the [Storybook for Angular overview page](/recipes/storybook/overview-angular#auto-generate-stories).
When running this generator, you will be prompted to provide the following:
- The `name` of the project you want to generate the configuration for.
- Whether you want to set up [Storybook interaction tests](https://storybook.js.org/docs/angular/writing-tests/interaction-testing) (`interactionTests`). If you choose `yes`, a `play` function will be added to your stories, and all the necessary dependencies will be installed. You can read more about this in the [Nx Storybook interaction tests documentation page](/recipes/storybook/storybook-interaction-tests#setup-storybook-interaction-tests).
You must provide a `name` for the generator to work.
By default, this generator will also set up [Storybook interaction tests](https://storybook.js.org/docs/angular/writing-tests/interaction-testing). If you don't want to set up Storybook interaction tests, you can pass the `--interactionTests=false` option, but it's not recommended.
There are a number of other options available. Let's take a look at some examples.
## Examples
### Ignore certain paths when generating stories
```bash
nx g @nx/angular:stories ui --ignorePaths=libs/ui/src/not-stories/**,**/**/src/**/*.other.*
```
This will generate stories for all the components in the `ui` project, except for the ones in the `libs/ui/src/not-stories` directory, and also for components that their file name is of the pattern `*.other.*`.
This is useful if you have a project that contains components that are not meant to be used in isolation, but rather as part of a larger component.
By default, Nx will ignore the following paths:
```text
*.stories.ts, *.stories.tsx, *.stories.js, *.stories.jsx, *.stories.mdx
```
but you can change this behaviour easily, as explained above.
@@ -0,0 +1,55 @@
This generator will set up Storybook for your **Angular** project. By default, Storybook v10 is used.
```bash
nx g @nx/angular:storybook-configuration project-name
```
You can read more about how this generator works, in the [Storybook for Angular overview page](/recipes/storybook/overview-angular#generate-storybook-configuration-for-an-angular-project).
When running this generator, you will be prompted to provide the following:
- The `name` of the project you want to generate the configuration for.
- Whether you want to set up [Storybook interaction tests](https://storybook.js.org/docs/angular/writing-tests/interaction-testing) (`interactionTests`). If you choose `yes`, a `play` function will be added to your stories, and all the necessary dependencies will be installed. Also, a `test-storybook` target will be generated in your project's `project.json`, with a command to invoke the [Storybook `test-runner`](https://storybook.js.org/docs/angular/writing-tests/test-runner). You can read more about this in the [Nx Storybook interaction tests documentation page](/recipes/storybook/storybook-interaction-tests#setup-storybook-interaction-tests).
- Whether you want to `generateStories` for the components in your project. If you choose `yes`, a `.stories.ts` file will be generated next to each of your components in your project.
You must provide a `name` for the generator to work.
By default, this generator will also set up [Storybook interaction tests](https://storybook.js.org/docs/angular/writing-tests/interaction-testing). If you don't want to set up Storybook interaction tests, you can pass the `--interactionTests=false` option, but it's not recommended.
There are a number of other options available. Let's take a look at some examples.
## Examples
### Generate Storybook configuration
```bash
nx g @nx/angular:storybook-configuration ui
```
This will generate Storybook configuration for the `ui` project using TypeScript for the Storybook configuration files (the files inside the `.storybook` directory, eg. `.storybook/main.ts`).
### Ignore certain paths when generating stories
```bash
nx g @nx/angular:storybook-configuration ui --generateStories=true --ignorePaths=libs/ui/src/not-stories/**,**/**/src/**/*.other.*,apps/my-app/**/*.something.ts
```
This will generate a Storybook configuration for the `ui` project and generate stories for all components in the `libs/ui/src/lib` directory, except for the ones in the `libs/ui/src/not-stories` directory, and the ones in the `apps/my-app` directory that end with `.something.ts`, and also for components that their file name is of the pattern `*.other.*`.
This is useful if you have a project that contains components that are not meant to be used in isolation, but rather as part of a larger component.
By default, Nx will ignore the following paths:
```text
*.stories.ts, *.stories.tsx, *.stories.js, *.stories.jsx, *.stories.mdx
```
but you can change this behaviour easily, as explained above.
### Generate Storybook configuration using JavaScript
```bash
nx g @nx/angular:storybook-configuration ui --tsConfiguration=false
```
By default, our generator generates TypeScript Storybook configuration files. You can choose to use JavaScript for the Storybook configuration files of your project (the files inside the `.storybook` directory, eg. `.storybook/main.js`).
@@ -0,0 +1,9 @@
## Examples
##### Simple Usage
The basic usage of the `web-worker` generator is defined below. You must provide a name for the web worker and the project to assign it to.
```bash
nx g @nx/angular:web-worker myWebWorker --project=myapp
```
@@ -0,0 +1,110 @@
This executor is a drop-in replacement for the `@angular-devkit/build-angular:browser` builder provided by the Angular CLI. It builds an Angular application using [webpack](https://webpack.js.org/).
In addition to the features provided by the Angular CLI builder, the `@nx/angular:webpack-browser` executor also supports the following:
- Providing a custom webpack configuration
- Incremental builds
:::tip[Dev Server]
The [`@nx/angular:dev-server` executor](/nx-api/angular/executors/dev-server) is required to serve your application when using the `@nx/angular:webpack-browser` to build it. It is a drop-in replacement for the Angular CLI's `@angular-devkit/build-angular:dev-server` builder and ensures the application is correctly served with Webpack when using the `@nx/angular:webpack-browser` executor.
:::
## Examples
##### Using a custom webpack configuration
The executor supports providing a path to a custom webpack configuration. This allows you to customize how your Angular application is built. It currently supports the following types of webpack configurations:
- `object`
- `Function`
- `Promise<object|Function>`
The executor will merge the provided configuration with the webpack configuration that Angular Devkit uses. The merge order is:
- Angular Devkit Configuration
- Provided Configuration
To use a custom webpack configuration when building your Angular application, change the `build` target in your `project.json` to match the following:
```json title="project.json" {5,8-10}
{
...
"targets": {
"build": {
"executor": "@nx/angular:webpack-browser",
"options": {
...
"customWebpackConfig": {
"path": "apps/appName/webpack.config.js"
}
}
},
...
}
}
```
##### Incrementally Building your Application
The executor supports incrementally building your Angular application by building the workspace libraries it depends on _(that have been marked as buildable)_ and then building your application using the built source of the libraries.
This can improve build time as the building of the workspace libraries can be cached, meaning they only have to be rebuilt if they have changed.
:::note[Performance]
There may be some additional overhead in the linking of the built libraries' sources which may reduce the overall improvement in build time. Therefore this approach only benefits large applications and would likely have a negative impact on small and medium applications.
:::
To allow your Angular application to take advantage of incremental building, change the `build` target in your `project.json` to match the following:
```json title="project.json" {5,8}
{
...
"targets": {
"build": {
"executor": "@nx/angular:webpack-browser",
"options": {
...
"buildLibsFromSource": false
}
},
...
}
}
```
##### Using a custom index.html transformer
The executor supports providing a custom function to transform the `index.html` file after it has been generated. This allows you to modify the HTML content before it is written to disk.
To use a custom index.html transformer, create a file with a function that exports the transformer:
```javascript title="apps/appName/index-html-transformer.js"
module.exports = (target, indexHtml) => {
// Add a custom meta tag
const customMeta = `<meta name="custom-meta" content="Custom value">`;
return indexHtml.replace('</head>', `${customMeta}\n</head>`);
};
```
The transformer function receives two parameters:
- `target`: The Angular build target configuration object containing properties like `project`, `target`, and `configuration`
- `indexHtml`: The generated HTML content as a string
Then update your `project.json` to use the transformer:
```json title="project.json" {5,8}
{
...
"targets": {
"build": {
"executor": "@nx/angular:webpack-browser",
"options": {
...
"indexHtmlTransformer": "apps/appName/index-html-transformer.js"
}
},
...
}
}
```
+81
View File
@@ -0,0 +1,81 @@
import { baseConfig } from '../../eslint.config.mjs';
import * as jsoncEslintParser from 'jsonc-eslint-parser';
export default [
...baseConfig,
{ ignores: ['dist'] },
{
files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
rules: {
'no-restricted-imports': [
'error',
{
name: 'chalk',
message:
'Please use `picocolors` in place of `chalk` for rendering terminal colors',
},
],
},
},
{
files: ['**/*.ts'],
rules: {
'no-restricted-imports': ['error', '@nx/workspace'],
},
ignores: ['./src/migrations/**'],
},
{
files: [
'./package.json',
'./generators.json',
'./executors.json',
'./migrations.json',
],
rules: {
'@nx/nx-plugin-checks': 'error',
},
languageOptions: {
parser: jsoncEslintParser,
},
},
{
files: ['./package.json'],
rules: {
'@nx/dependency-checks': [
'error',
{
buildTargets: ['build-base'],
ignoredDependencies: [
'@angular-devkit/architect',
'@angular-devkit/core',
'@angular-devkit/schematics',
'@nx/cypress',
'@nx/jest',
'@nx/playwright',
'@nx/storybook',
'@nx/vite',
'@nx/vitest',
'@schematics/angular',
'browserslist',
'cypress',
'eslint',
'injection-js',
'nx',
'piscina',
'typescript',
'vite',
],
},
],
},
languageOptions: {
parser: jsoncEslintParser,
},
},
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/prefer-standalone': 'off',
},
},
];
+66
View File
@@ -0,0 +1,66 @@
{
"executors": {
"delegate-build": {
"implementation": "./dist/src/executors/delegate-build/delegate-build.impl",
"schema": "./dist/src/executors/delegate-build/schema.json",
"description": "Delegates the build to a different target while supporting incremental builds."
},
"ng-packagr-lite": {
"implementation": "./dist/src/executors/ng-packagr-lite/ng-packagr-lite.impl",
"schema": "./dist/src/executors/ng-packagr-lite/schema.json",
"description": "Builds an Angular library with support for incremental builds.\n\nThis executor is meant to be used with buildable libraries in an incremental build scenario. It is similar to the `@nx/angular:package` executor but it only produces ESM2022 bundles."
},
"package": {
"implementation": "./dist/src/executors/package/package.impl",
"schema": "./dist/src/executors/package/schema.json",
"description": "Builds and packages an Angular library producing an output following the Angular Package Format (APF) to be distributed as an NPM package.\n\nThis executor is a drop-in replacement for the `@angular-devkit/build-angular:ng-packagr` and `@angular/build:ng-packagr` builders, with additional support for incremental builds."
},
"browser-esbuild": {
"implementation": "./dist/src/executors/browser-esbuild/browser-esbuild.impl",
"schema": "./dist/src/executors/browser-esbuild/schema.json",
"description": "Builds an Angular application using [esbuild](https://esbuild.github.io/)."
},
"module-federation-dev-server": {
"implementation": "./dist/src/executors/module-federation-dev-server/module-federation-dev-server.impl",
"schema": "./dist/src/executors/module-federation-dev-server/schema.json",
"description": "Serves host [Module Federation](https://module-federation.io/) applications ([webpack](https://webpack.js.org/)-based) allowing to specify which remote applications should be served with the host."
},
"module-federation-dev-ssr": {
"implementation": "./dist/src/executors/module-federation-ssr-dev-server/module-federation-ssr-dev-server.impl",
"schema": "./dist/src/executors/module-federation-ssr-dev-server/schema.json",
"description": "The module-federation-ssr-dev-server executor is reserved exclusively for use with host SSR Module Federation applications. It allows the user to specify which remote applications should be served with the host."
},
"application": {
"implementation": "./dist/src/executors/application/application.impl",
"schema": "./dist/src/executors/application/schema.json",
"description": "Builds an Angular application using [esbuild](https://esbuild.github.io/) with integrated SSR and prerendering capabilities."
},
"extract-i18n": {
"implementation": "./dist/src/executors/extract-i18n/extract-i18n.impl",
"schema": "./dist/src/executors/extract-i18n/schema.json",
"description": "Extracts i18n messages from source code."
},
"unit-test": {
"implementation": "./dist/src/executors/unit-test/unit-test.impl",
"schema": "./dist/src/executors/unit-test/schema.json",
"description": "Run application unit tests. _Note: this is only supported in Angular versions >= 21.0.0_."
}
},
"builders": {
"webpack-browser": {
"implementation": "./dist/src/builders/webpack-browser/webpack-browser.impl",
"schema": "./dist/src/builders/webpack-browser/schema.json",
"description": "Builds an Angular application using [webpack](https://webpack.js.org/)."
},
"dev-server": {
"implementation": "./dist/src/builders/dev-server/dev-server.impl",
"schema": "./dist/src/builders/dev-server/schema.json",
"description": "Serves an Angular application using [webpack](https://webpack.js.org/) when the build target is using a webpack-based executor, or [Vite](https://vite.dev/) when the build target uses an [esbuild](https://esbuild.github.io/)-based executor."
},
"webpack-server": {
"implementation": "./dist/src/builders/webpack-server/webpack-server.impl",
"schema": "./dist/src/builders/webpack-server/schema.json",
"description": "Builds a server Angular application using [webpack](https://webpack.js.org/). This executor is a drop-in replacement for the `@angular-devkit/build-angular:server` builder provided by the Angular CLI. It is usually used in tandem with the `@nx/angular:webpack-browser` executor when your Angular application uses a custom webpack configuration."
}
}
}
+12
View File
@@ -0,0 +1,12 @@
export * from './src/builders/webpack-browser/webpack-browser.impl';
export * from './src/builders/webpack-server/webpack-server.impl';
export * from './src/executors/module-federation-dev-server/module-federation-dev-server.impl';
export * from './src/executors/delegate-build/delegate-build.impl';
export * from './src/executors/ng-packagr-lite/ng-packagr-lite.impl';
export * from './src/executors/package/package.impl';
export * from './src/executors/browser-esbuild/browser-esbuild.impl';
export * from './src/executors/application/application.impl';
export * from './src/executors/extract-i18n/extract-i18n.impl';
export * from './src/executors/module-federation-ssr-dev-server/module-federation-ssr-dev-server.impl';
export { executeDevServerBuilder } from './src/builders/dev-server/dev-server.impl';
+178
View File
@@ -0,0 +1,178 @@
{
"name": "Nx Angular",
"version": "0.1",
"extends": ["@schematics/angular", "@nx/workspace"],
"generators": {
"add-linting": {
"factory": "./dist/src/generators/add-linting/add-linting",
"schema": "./dist/src/generators/add-linting/schema.json",
"description": "Adds linting configuration to an Angular project.",
"hidden": true
},
"application": {
"factory": "./dist/src/generators/application/application",
"schema": "./dist/src/generators/application/schema.json",
"aliases": ["app"],
"x-type": "application",
"description": "Creates an Angular application."
},
"component": {
"factory": "./dist/src/generators/component/component",
"schema": "./dist/src/generators/component/schema.json",
"aliases": ["c"],
"description": "Generate an Angular Component."
},
"component-story": {
"factory": "./dist/src/generators/component-story/component-story",
"schema": "./dist/src/generators/component-story/schema.json",
"description": "Creates a stories.ts file for a component.",
"hidden": true
},
"component-test": {
"factory": "./dist/src/generators/component-test/component-test",
"schema": "./dist/src/generators/component-test/schema.json",
"description": "Creates a cypress component test file for a component."
},
"convert-to-application-executor": {
"factory": "./dist/src/generators/convert-to-application-executor/convert-to-application-executor",
"schema": "./dist/src/generators/convert-to-application-executor/schema.json",
"description": "Converts projects to use the `@nx/angular:application` executor or the `@angular-devkit/build-angular:application` builder."
},
"convert-to-rspack": {
"factory": "./dist/src/generators/convert-to-rspack/convert-to-rspack",
"schema": "./dist/src/generators/convert-to-rspack/schema.json",
"description": "Converts Angular Webpack projects to use Rspack."
},
"directive": {
"factory": "./dist/src/generators/directive/directive",
"schema": "./dist/src/generators/directive/schema.json",
"aliases": ["d"],
"description": "Generate an Angular directive."
},
"federate-module": {
"factory": "./dist/src/generators/federate-module/federate-module",
"schema": "./dist/src/generators/federate-module/schema.json",
"x-type": "application",
"description": "Create a federated module, which is exposed by a remote and can be subsequently loaded by a host.",
"x-deprecated": "Angular Module Federation in Nx is no longer supported. Use `@angular-architects/native-federation` instead. See https://nx.dev/docs/technologies/module-federation/consumer-and-provider for the v23 migration guide. Removed in Nx v24."
},
"init": {
"factory": "./dist/src/generators/init/init",
"schema": "./dist/src/generators/init/schema.json",
"description": "Initializes the `@nrwl/angular` plugin.",
"hidden": true
},
"library": {
"factory": "./dist/src/generators/library/library",
"schema": "./dist/src/generators/library/schema.json",
"aliases": ["lib"],
"x-type": "library",
"description": "Creates an Angular library."
},
"library-secondary-entry-point": {
"factory": "./dist/src/generators/library-secondary-entry-point/library-secondary-entry-point",
"schema": "./dist/src/generators/library-secondary-entry-point/schema.json",
"aliases": ["secondary-entry-point", "entry-point"],
"description": "Creates a secondary entry point for an Angular publishable library."
},
"remote": {
"factory": "./dist/src/generators/remote/remote",
"schema": "./dist/src/generators/remote/schema.json",
"x-type": "application",
"description": "Generate a Remote Angular Module Federation Application.",
"aliases": ["producer"],
"x-deprecated": "Angular Module Federation in Nx is no longer supported. Use `@angular-architects/native-federation` instead. See https://nx.dev/docs/technologies/module-federation/consumer-and-provider for the v23 migration guide. Removed in Nx v24."
},
"convert-to-with-mf": {
"factory": "./dist/src/generators/convert-to-with-mf/convert-to-with-mf",
"schema": "./dist/src/generators/convert-to-with-mf/schema.json",
"description": "Converts an old micro frontend configuration to use the new withModuleFederation helper. It will run successfully if the following conditions are met: \n - Is either a host or remote application \n - Shared npm package configurations have not been modified \n - Name used to identify the Micro Frontend application matches the project name \n\n{% callout type=\"warning\" title=\"Overrides\" %}This generator will overwrite your webpack config. If you have additional custom configuration in your config file, it will be lost!{% /callout %}",
"x-deprecated": "Angular Module Federation in Nx is no longer supported. Use `@angular-architects/native-federation` instead. See https://nx.dev/docs/technologies/module-federation/consumer-and-provider for the v23 migration guide. Removed in Nx v24."
},
"host": {
"factory": "./dist/src/generators/host/host",
"schema": "./dist/src/generators/host/schema.json",
"x-type": "application",
"description": "Generate a Host Angular Module Federation Application.",
"aliases": ["consumer"],
"x-deprecated": "Angular Module Federation in Nx is no longer supported. Use `@angular-architects/native-federation` instead. See https://nx.dev/docs/technologies/module-federation/consumer-and-provider for the v23 migration guide. Removed in Nx v24."
},
"ng-add": {
"factory": "./dist/src/generators/ng-add/ng-add",
"schema": "./dist/src/generators/ng-add/schema.json",
"description": "Migrates an Angular CLI workspace to Nx or adds the Angular plugin to an Nx workspace.",
"hidden": true
},
"ngrx-feature-store": {
"factory": "./dist/src/generators/ngrx-feature-store/ngrx-feature-store",
"schema": "./dist/src/generators/ngrx-feature-store/schema.json",
"description": "Adds an NgRx Feature Store to an application or library."
},
"ngrx-root-store": {
"factory": "./dist/src/generators/ngrx-root-store/ngrx-root-store",
"schema": "./dist/src/generators/ngrx-root-store/schema.json",
"description": "Adds an NgRx Root Store to an application."
},
"pipe": {
"factory": "./dist/src/generators/pipe/pipe",
"schema": "./dist/src/generators/pipe/schema.json",
"description": "Generate an Angular Pipe",
"aliases": ["p"]
},
"scam-to-standalone": {
"factory": "./dist/src/generators/scam-to-standalone/scam-to-standalone",
"schema": "./dist/src/generators/scam-to-standalone/schema.json",
"description": "Convert an existing Single Component Angular Module (SCAM) to a Standalone Component.",
"x-deprecated": "SCAMs are superseded by Angular standalone components. Convert any remaining SCAMs before upgrading. It will be removed in Nx v24."
},
"scam": {
"factory": "./dist/src/generators/scam/scam",
"schema": "./dist/src/generators/scam/schema.json",
"description": "Generate a component with an accompanying Single Component Angular Module (SCAM).",
"x-deprecated": "SCAMs are superseded by Angular standalone components. Use the `@nx/angular:component` generator instead. It will be removed in Nx v24."
},
"scam-directive": {
"factory": "./dist/src/generators/scam-directive/scam-directive",
"schema": "./dist/src/generators/scam-directive/schema.json",
"description": "Generate a directive with an accompanying Single Component Angular Module (SCAM).",
"x-deprecated": "SCAMs are superseded by Angular standalone components. Use the `@nx/angular:directive` generator instead. It will be removed in Nx v24."
},
"scam-pipe": {
"factory": "./dist/src/generators/scam-pipe/scam-pipe",
"schema": "./dist/src/generators/scam-pipe/schema.json",
"description": "Generate a pipe with an accompanying Single Component Angular Module (SCAM).",
"x-deprecated": "SCAMs are superseded by Angular standalone components. Use the `@nx/angular:pipe` generator instead. It will be removed in Nx v24."
},
"setup-mf": {
"factory": "./dist/src/generators/setup-mf/setup-mf",
"schema": "./dist/src/generators/setup-mf/schema.json",
"description": "Generate a Module Federation configuration for a given Angular application.",
"x-deprecated": "Angular Module Federation in Nx is no longer supported. Use `@angular-architects/native-federation` instead. See https://nx.dev/docs/technologies/module-federation/consumer-and-provider for the v23 migration guide. Removed in Nx v24."
},
"setup-ssr": {
"factory": "./dist/src/generators/setup-ssr/setup-ssr",
"schema": "./dist/src/generators/setup-ssr/schema.json",
"description": "Generate Angular Universal (SSR) setup for an Angular application."
},
"stories": {
"factory": "./dist/src/generators/stories/stories",
"schema": "./dist/src/generators/stories/schema.json",
"description": "Creates stories/specs for all components declared in a project."
},
"storybook-configuration": {
"factory": "./dist/src/generators/storybook-configuration/storybook-configuration",
"schema": "./dist/src/generators/storybook-configuration/schema.json",
"description": "Adds Storybook configuration to a project."
},
"cypress-component-configuration": {
"factory": "./dist/src/generators/cypress-component-configuration/cypress-component-configuration",
"schema": "./dist/src/generators/cypress-component-configuration/schema.json",
"description": "Setup Cypress component testing for a project."
},
"web-worker": {
"factory": "./dist/src/generators/web-worker/web-worker",
"schema": "./dist/src/generators/web-worker/schema.json",
"description": "Creates a Web Worker."
}
}
}
+26
View File
@@ -0,0 +1,26 @@
export * from './src/generators/add-linting/add-linting';
export * from './src/generators/application/application';
export * from './src/generators/component-story/component-story';
export * from './src/generators/component/component';
export * from './src/generators/directive/directive';
export * from './src/generators/federate-module/federate-module';
export * from './src/generators/host/host';
export * from './src/generators/init/init';
export * from './src/generators/library-secondary-entry-point/library-secondary-entry-point';
export * from './src/generators/library/library';
export * from './src/generators/ngrx-feature-store/ngrx-feature-store';
export * from './src/generators/ngrx-root-store/ngrx-root-store';
export * from './src/generators/pipe/pipe';
export * from './src/generators/remote/remote';
export * from './src/generators/scam-directive/scam-directive';
export * from './src/generators/scam-pipe/scam-pipe';
export * from './src/generators/scam-to-standalone/scam-to-standalone';
export * from './src/generators/scam/scam';
export * from './src/generators/setup-mf/setup-mf';
export * from './src/generators/setup-ssr/setup-ssr';
export * from './src/generators/stories/stories';
export * from './src/generators/storybook-configuration/storybook-configuration';
export * from './src/generators/web-worker/web-worker';
export { cypressComponentConfiguration } from './src/generators/cypress-component-configuration/cypress-component-configuration';
export * from './src/generators/component-test/component-test';
export * from './src/utils/test-runners';
+1
View File
@@ -0,0 +1 @@
export {};
+15
View File
@@ -0,0 +1,15 @@
// Semi-private surface for first-party Nx packages.
//
// External plugins should NOT import from here — this entry is curated for
// internal consumers and may change without semver protection. Mirrors
// `@nx/devkit/internal`.
//
// These mirror the previously-public `@nx/angular/src/utils`,
// `@nx/angular/src/generators/utils` and `@nx/angular/src/generators/move/move-impl`
// subpaths (removed from the exports map in 23.0.0). The
// `rewrite-internal-subpath-imports` migration routes consumers of those
// subpaths here, so every symbol they exposed must be re-exported.
export * from './src/utils';
export * from './src/generators/utils';
export { move } from './src/generators/move/move-impl';
+8
View File
@@ -0,0 +1,8 @@
/* eslint-disable */
module.exports = {
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'html'],
globals: {},
displayName: 'angular',
preset: '../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/test-setup.ts'],
};
+6
View File
@@ -0,0 +1,6 @@
export {
setRemoteUrlResolver,
setRemoteDefinitions,
setRemoteDefinition,
loadRemoteModule,
} from './mf';
+143
View File
@@ -0,0 +1,143 @@
import { processRuntimeRemoteUrl } from './url-helpers';
export type ResolveRemoteUrlFunction = (
remoteName: string
) => string | Promise<string>;
declare const __webpack_init_sharing__: (scope: 'default') => Promise<void>;
declare const __webpack_share_scopes__: { default: unknown };
let resolveRemoteUrl: ResolveRemoteUrlFunction;
/**
* @deprecated Use Runtime Helpers from '@module-federation/enhanced/runtime' instead. This will be removed in Nx 22.
*/
export function setRemoteUrlResolver(
_resolveRemoteUrl: ResolveRemoteUrlFunction
) {
resolveRemoteUrl = _resolveRemoteUrl;
}
let remoteUrlDefinitions: Record<string, string>;
/**
* @deprecated Use init() from '@module-federation/enhanced/runtime' instead. This will be removed in Nx 22.
* If you have a remote app called `my-remote-app` and you want to use the `http://localhost:4201/mf-manifest.json` as the remote url, you should change it from:
* ```ts
* import { setRemoteDefinitions } from '@nx/angular/mf';
*
* setRemoteDefinitions({
* 'my-remote-app': 'http://localhost:4201/mf-manifest.json'
* });
* ```
* to use init():
* ```ts
* import { init } from '@module-federation/enhanced/runtime';
*
* init({
* name: 'host',
* remotes: [{
* name: 'my-remote-app',
* entry: 'http://localhost:4201/mf-manifest.json'
* }]
* });
* ```
*/
export function setRemoteDefinitions(definitions: Record<string, string>) {
remoteUrlDefinitions = definitions;
}
/**
* @deprecated Use registerRemotes() from '@module-federation/enhanced/runtime' instead. This will be removed in Nx 22.
* If you set a remote app with `setRemoteDefinition` such as:
* ```ts
* import { setRemoteDefinition } from '@nx/angular/mf';
*
* setRemoteDefinition(
* 'my-remote-app',
* 'http://localhost:4201/mf-manifest.json'
* );
* ```
* change it to use registerRemotes():
* ```ts
* import { registerRemotes } from '@module-federation/enhanced/runtime';
*
* registerRemotes([
* {
* name: 'my-remote-app',
* entry: 'http://localhost:4201/mf-manifest.json'
* }
* ]);
* ```
*/
export function setRemoteDefinition(remoteName: string, remoteUrl: string) {
remoteUrlDefinitions ??= {};
remoteUrlDefinitions[remoteName] = remoteUrl;
}
let remoteModuleMap = new Map<string, unknown>();
let remoteContainerMap = new Map<string, unknown>();
/**
* @deprecated Use loadRemote() from '@module-federation/enhanced/runtime' instead. This will be removed in Nx 22.
* If you set a load a remote with `loadRemoteModule` such as:
* ```ts
* import { loadRemoteModule } from '@nx/angular/mf';
*
* loadRemoteModule('my-remote-app', './Module').then(m => m.RemoteEntryModule);
* ```
* change it to use loadRemote():
* ```ts
* import { loadRemote } from '@module-federation/enhanced/runtime';
*
* loadRemote<typeof import('my-remote-app/Module')>('my-remote-app/Module').then(m => m.RemoteEntryModule);
* ```
*/
export async function loadRemoteModule(remoteName: string, moduleName: string) {
const remoteModuleKey = `${remoteName}:${moduleName}`;
if (remoteModuleMap.has(remoteModuleKey)) {
return remoteModuleMap.get(remoteModuleKey);
}
const container = remoteContainerMap.has(remoteName)
? remoteContainerMap.get(remoteName)
: await loadRemoteContainer(remoteName);
const factory = await container.get(moduleName);
const Module = factory();
remoteModuleMap.set(remoteModuleKey, Module);
return Module;
}
function loadModule(url: string) {
return import(/* webpackIgnore:true */ url);
}
let initialSharingScopeCreated = false;
async function loadRemoteContainer(remoteName: string) {
if (!resolveRemoteUrl && !remoteUrlDefinitions) {
throw new Error(
'Call setRemoteDefinitions or setRemoteUrlResolver to allow Dynamic Federation to find the remote apps correctly.'
);
}
if (!initialSharingScopeCreated) {
initialSharingScopeCreated = true;
await __webpack_init_sharing__('default');
}
const remoteUrl = remoteUrlDefinitions
? remoteUrlDefinitions[remoteName]
: await resolveRemoteUrl(remoteName);
const containerUrl = processRuntimeRemoteUrl(remoteUrl, 'mjs');
const container = await loadModule(containerUrl);
await container.init(__webpack_share_scopes__.default);
remoteContainerMap.set(remoteName, container);
return container;
}
+6
View File
@@ -0,0 +1,6 @@
{
"$schema": "../../../node_modules/ng-packagr/ng-package.schema.json",
"lib": {
"entryFile": "index.ts"
}
}
+85
View File
@@ -0,0 +1,85 @@
// Helper function to extract file extension from a path
function extname(path: string): string {
const lastDot = path.lastIndexOf('.');
const lastSlash = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'));
if (lastDot === -1 || lastDot < lastSlash) {
return '';
}
return path.slice(lastDot);
}
/**
* Checks if a URL string is absolute (has protocol)
*/
export function isAbsoluteUrl(url: string): boolean {
try {
new URL(url);
return true;
} catch {
return false;
}
}
/**
* Safely processes remote locations, handling both relative and absolute URLs
* while preserving query parameters and hash fragments for absolute URLs
*/
export function processRemoteLocation(
remoteLocation: string,
remoteEntryExt: 'js' | 'mjs'
): string {
// Handle promise-based remotes as-is
if (remoteLocation.startsWith('promise new Promise')) {
return remoteLocation;
}
if (isAbsoluteUrl(remoteLocation)) {
// Use new URL parsing for absolute URLs (supports query params/hash)
const url = new URL(remoteLocation);
const ext = extname(url.pathname);
const needsRemoteEntry = !['.js', '.mjs', '.json'].includes(ext);
if (needsRemoteEntry) {
url.pathname = url.pathname.endsWith('/')
? `${url.pathname}remoteEntry.${remoteEntryExt}`
: `${url.pathname}/remoteEntry.${remoteEntryExt}`;
}
return url.href;
} else {
// Use string manipulation for relative URLs (backward compatibility)
const ext = extname(remoteLocation);
const needsRemoteEntry = !['.js', '.mjs', '.json'].includes(ext);
if (needsRemoteEntry) {
const baseRemote = remoteLocation.endsWith('/')
? remoteLocation.slice(0, -1)
: remoteLocation;
return `${baseRemote}/remoteEntry.${remoteEntryExt}`;
}
return remoteLocation;
}
}
/**
* Processes remote URLs for runtime environments, resolving relative URLs against window.location.origin
*/
export function processRuntimeRemoteUrl(
remoteUrl: string,
remoteEntryExt: 'js' | 'mjs'
): string {
if (isAbsoluteUrl(remoteUrl)) {
return processRemoteLocation(remoteUrl, remoteEntryExt);
} else {
// For runtime relative URLs, resolve against current origin
const baseUrl =
typeof globalThis !== 'undefined' &&
typeof (globalThis as any).window !== 'undefined' &&
(globalThis as any).window.location
? (globalThis as any).window.location.origin
: 'http://localhost';
const absoluteUrl = new URL(remoteUrl, baseUrl).href;
return processRemoteLocation(absoluteUrl, remoteEntryExt);
}
}
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
import path = require('path');
import json = require('./migrations.json');
import { assertValidMigrationPaths } from '@nx/devkit/internal-testing-utils';
describe('Angular migrations', () => {
assertValidMigrationPaths(json, __dirname);
});
+22
View File
@@ -0,0 +1,22 @@
{
"$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
"lib": {
"entryFile": "index.ts"
},
"dest": "dist",
"deleteDestPath": false,
"allowedNonPeerDependencies": [
"nx",
"@nx/",
"@phenomnomnominal/tsquery",
"@typescript-eslint/",
"enquirer",
"jsonc-parser",
"magic-string",
"picocolors",
"picomatch",
"semver",
"webpack-merge"
],
"keepLifecycleScripts": true
}
+120
View File
@@ -0,0 +1,120 @@
{
"name": "@nx/angular",
"version": "0.0.1",
"private": false,
"type": "commonjs",
"description": "The Nx Plugin for Angular contains executors, generators, and utilities for managing Angular applications and libraries within an Nx workspace. It provides: \n\n- Integration with libraries such as Storybook, Jest, ESLint, Playwright and Cypress. \n\n- Generators to help scaffold code quickly (like: Micro Frontends, Libraries, both internal to your codebase and publishable to npm) \n\n- Single Component Application Modules (SCAMs) \n\n- NgRx helpers. \n\n- Utilities for automatic workspace refactoring.",
"repository": {
"type": "git",
"url": "https://github.com/nrwl/nx.git",
"directory": "packages/angular"
},
"keywords": [
"Monorepo",
"Angular",
"Jest",
"Cypress",
"CLI",
"Front-end"
],
"module": "./dist/fesm2022/nx-angular.mjs",
"types": "./dist/types/nx-angular.d.ts",
"files": [
"dist",
"!dist/tsconfig.tsbuildinfo",
"!dist/package.json",
"migrations.json",
"executors.json",
"generators.json"
],
"exports": {
".": {
"types": "./dist/types/nx-angular.d.ts",
"default": "./dist/fesm2022/nx-angular.mjs"
},
"./mf": {
"types": "./dist/types/nx-angular-mf.d.ts",
"default": "./dist/fesm2022/nx-angular-mf.mjs"
},
"./package.json": {
"default": "./package.json"
},
"./migrations.json": "./migrations.json",
"./generators.json": "./generators.json",
"./executors.json": "./executors.json",
"./generators": "./dist/generators.js",
"./executors": "./dist/executors.js",
"./plugin": "./dist/plugin.js",
"./tailwind": "./dist/tailwind.js",
"./plugins/component-testing": "./dist/plugins/component-testing.js",
"./internal": "./dist/internal.js",
"./src/builders/*/schema.json": "./dist/src/builders/*/schema.json",
"./src/builders/*.impl": "./dist/src/builders/*.impl.js",
"./src/builders/*/schema": "./dist/src/builders/*/schema.d.ts",
"./src/executors/*/schema.json": "./dist/src/executors/*/schema.json",
"./src/executors/*.impl": "./dist/src/executors/*.impl.js",
"./src/executors/*/schema": "./dist/src/executors/*/schema.d.ts",
"./src/generators/*/schema.json": "./dist/src/generators/*/schema.json",
"./src/generators/*/schema": "./dist/src/generators/*/schema.d.ts"
},
"author": "Victor Savkin",
"license": "MIT",
"bugs": {
"url": "https://github.com/nrwl/nx/issues"
},
"homepage": "https://nx.dev",
"executors": "./executors.json",
"generators": "./generators.json",
"ng-update": {
"requirements": {},
"migrations": "./migrations.json",
"supportsOptionalMigrations": true
},
"dependencies": {
"@nx/devkit": "workspace:*",
"@nx/eslint": "workspace:*",
"@nx/js": "workspace:*",
"@nx/module-federation": "workspace:*",
"@nx/rspack": "workspace:*",
"@nx/web": "workspace:*",
"@nx/webpack": "workspace:*",
"@phenomnomnominal/tsquery": "catalog:typescript",
"@typescript-eslint/type-utils": "catalog:eslint",
"enquirer": "catalog:",
"jsonc-parser": "catalog:",
"magic-string": "~0.30.2",
"picocolors": "catalog:",
"picomatch": "catalog:",
"semver": "catalog:",
"tslib": "catalog:typescript",
"webpack-merge": "^5.8.0"
},
"devDependencies": {
"@nx/cypress": "workspace:*",
"@nx/vitest": "workspace:*",
"nx": "workspace:*"
},
"peerDependencies": {
"@angular/build": "catalog:angular-supported-versions",
"@angular-devkit/build-angular": "catalog:angular-supported-versions",
"@angular-devkit/core": "catalog:angular-supported-versions",
"@angular-devkit/schematics": "catalog:angular-supported-versions",
"@schematics/angular": "catalog:angular-supported-versions",
"ng-packagr": "catalog:angular-supported-versions",
"rxjs": "catalog:angular-supported-versions"
},
"peerDependenciesMeta": {
"@angular/build": {
"optional": true
},
"@angular-devkit/build-angular": {
"optional": true
},
"ng-packagr": {
"optional": true
}
},
"publishConfig": {
"access": "public"
}
}
+1
View File
@@ -0,0 +1 @@
export { createNodes, createNodesV2 } from './src/plugins/plugin';
@@ -0,0 +1,461 @@
import {
nxBaseCypressPreset,
NxComponentTestingPresetOptions,
} from '@nx/cypress/plugins/cypress-preset';
import {
createExecutorContext,
getProjectConfigByPath,
getTempTailwindPath,
isCtProjectUsingBuildProject,
} from '@nx/cypress/internal';
import {
ExecutorContext,
joinPathFragments,
logger,
offsetFromRoot,
parseTargetString,
ProjectConfiguration,
ProjectGraph,
readCachedProjectGraph,
readTargetOptions,
stripIndents,
} from '@nx/devkit';
import { getProjectSourceRoot } from '@nx/js/internal';
import { existsSync, lstatSync, mkdirSync, writeFileSync } from 'fs';
import { dirname, join, relative, sep } from 'path';
import { gte } from 'semver';
import type { BrowserBuilderSchema } from '../src/builders/webpack-browser/schema';
/**
* Angular nx preset for Cypress Component Testing
*
* This preset contains the base configuration
* for your component tests that nx recommends.
* including a devServer that supports nx workspaces.
* you can easily extend this within your cypress config via spreading the preset
* @example
* export default defineConfig({
* component: {
* ...nxComponentTestingPreset(__filename)
* // add your own config here
* }
* })
*
* @param pathToConfig will be used for loading project options and to construct the output paths for videos and screenshots
* @param options override options
*/
export function nxComponentTestingPreset(
pathToConfig: string,
options?: NxComponentTestingPresetOptions
) {
if (global.NX_GRAPH_CREATION) {
// this is only used by plugins, so we don't need the component testing
// options, cast to any to avoid type errors
return nxBaseCypressPreset(pathToConfig, {
testingType: 'component',
}) as any;
}
let graph: ProjectGraph;
try {
graph = readCachedProjectGraph();
} catch (e) {
throw new Error(
// don't want to strip indents so error stack has correct indentation
`Unable to read the project graph for component testing.
This is likely due to not running via nx. i.e. 'nx component-test my-project'.
Please open an issue if this error persists.
${e.stack ? e.stack : e}`
);
}
const ctProjectConfig = getProjectConfigByPath(graph, pathToConfig);
const ctConfigurationName = process.env.NX_CYPRESS_TARGET_CONFIGURATION;
const ctContext = createExecutorContext(
graph,
ctProjectConfig.targets,
ctProjectConfig.name,
options?.ctTargetName || 'component-test',
ctConfigurationName
);
const buildTarget = options?.buildTarget
? parseTargetString(options.buildTarget, graph)
: // for backwards compat, if no buildTargetin the preset options, get it from the target options
getBuildableTarget(ctContext);
if (!buildTarget.project && !graph.nodes?.[buildTarget.project]?.data) {
throw new Error(stripIndents`Unable to find project configuration for build target.
Project Name? ${buildTarget.project}
Has project config? ${!!graph.nodes?.[buildTarget.project]?.data}`);
}
const fromWorkspaceRoot = relative(ctContext.root, pathToConfig);
const normalizedFromWorkspaceRootPath = lstatSync(pathToConfig).isFile()
? dirname(fromWorkspaceRoot)
: fromWorkspaceRoot;
const offset = isOffsetNeeded(ctContext, ctProjectConfig)
? offsetFromRoot(normalizedFromWorkspaceRootPath)
: undefined;
const buildContext = createExecutorContext(
graph,
graph.nodes[buildTarget.project]?.data.targets,
buildTarget.project,
buildTarget.target,
buildTarget.configuration
);
const buildableProjectConfig = normalizeBuildTargetOptions(
buildContext,
ctContext,
offset
);
return {
...nxBaseCypressPreset(pathToConfig, { testingType: 'component' }),
// NOTE: cannot use a glob pattern since it will break cypress generated tsconfig.
specPattern: ['src/**/*.cy.ts', 'src/**/*.cy.js'],
// Cy v12.17.0+ does not work with aboslute paths for index file
// but does with relative pathing, since relative path is the default location, we can omit it
indexHtmlFile: requiresAbsolutePath()
? joinPathFragments(
ctContext.root,
ctProjectConfig.root,
'cypress',
'support',
'component-index.html'
)
: undefined,
devServer: {
// cypress uses string union type,
// need to use const to prevent typing to string
...({
framework: 'angular',
bundler: 'webpack',
} as const),
options: {
projectConfig: buildableProjectConfig,
},
},
};
}
function getBuildableTarget(ctContext: ExecutorContext) {
const targets =
ctContext.projectGraph.nodes[ctContext.projectName]?.data?.targets;
const targetConfig = targets?.[ctContext.targetName];
if (!targetConfig) {
throw new Error(
stripIndents`Unable to find component testing target configuration in project '${
ctContext.projectName
}'.
Has targets? ${!!targets}
Has target name? ${ctContext.targetName}
Has ct project name? ${ctContext.projectName}
`
);
}
const cypressCtOptions = readTargetOptions(
{
project: ctContext.projectName,
target: ctContext.targetName,
configuration: ctContext.configurationName,
},
ctContext
);
if (!cypressCtOptions.devServerTarget) {
throw new Error(
`Unable to find the 'devServerTarget' executor option in the '${ctContext.targetName}' target of the '${ctContext.projectName}' project`
);
}
return parseTargetString(
cypressCtOptions.devServerTarget,
ctContext.projectGraph
);
}
function normalizeBuildTargetOptions(
buildContext: ExecutorContext,
ctContext: ExecutorContext,
offset?: string
): {
root: string;
sourceRoot: string;
buildOptions: BrowserBuilderSchema & { workspaceRoot: string };
} {
const options = readTargetOptions<BrowserBuilderSchema>(
{
project: buildContext.projectName,
target: buildContext.targetName,
configuration: buildContext.configurationName,
},
buildContext
);
const project =
buildContext.projectsConfigurations.projects[buildContext.projectName];
const sourceRoot = getProjectSourceRoot(project);
const buildOptions = withSchemaDefaults(
options,
sourceRoot,
buildContext.root
);
// cypress creates a tsconfig if one isn't preset
// that contains all the support required for angular and component tests
delete buildOptions.tsConfig;
if (offset) {
// polyfill entries might be local files or files that are resolved from node_modules
// like zone.js.
// prevents error from webpack saying can't find <offset>/zone.js.
const handlePolyfillPath = (polyfill: string) => {
const maybeFullPath = join(ctContext.root, polyfill.split('/').join(sep));
if (existsSync(maybeFullPath)) {
return joinPathFragments(offset, polyfill);
}
return polyfill;
};
// paths need to be unix paths for angular devkit
if (buildOptions.polyfills) {
buildOptions.polyfills =
Array.isArray(buildOptions.polyfills) &&
buildOptions.polyfills.length > 0
? (buildOptions.polyfills as string[]).map((p) =>
handlePolyfillPath(p)
)
: handlePolyfillPath(buildOptions.polyfills as string);
}
buildOptions.main = joinPathFragments(offset, buildOptions.main);
buildOptions.index =
typeof buildOptions.index === 'string'
? joinPathFragments(offset, buildOptions.index)
: {
...buildOptions.index,
input: joinPathFragments(offset, buildOptions.index.input),
};
buildOptions.fileReplacements = buildOptions.fileReplacements.map((fr) => {
fr.replace = joinPathFragments(offset, fr.replace);
fr.with = joinPathFragments(offset, fr.with);
return fr;
});
}
// if the ct project isn't being used in the build project
// then we don't want to have the assets/scripts/styles be included to
// prevent inclusion of unintended stuff like tailwind
if (
buildContext.projectName === ctContext.projectName ||
isCtProjectUsingBuildProject(
ctContext.projectGraph,
buildContext.projectName,
ctContext.projectName
)
) {
if (offset) {
buildOptions.assets = buildOptions.assets.map((asset) => {
return typeof asset === 'string'
? joinPathFragments(offset, asset)
: { ...asset, input: joinPathFragments(offset, asset.input) };
});
buildOptions.styles = buildOptions.styles.map((style) => {
return typeof style === 'string'
? joinPathFragments(offset, style)
: { ...style, input: joinPathFragments(offset, style.input) };
});
buildOptions.scripts = buildOptions.scripts.map((script) => {
return typeof script === 'string'
? joinPathFragments(offset, script)
: { ...script, input: joinPathFragments(offset, script.input) };
});
if (buildOptions.stylePreprocessorOptions?.includePaths.length > 0) {
buildOptions.stylePreprocessorOptions = {
includePaths: buildOptions.stylePreprocessorOptions.includePaths.map(
(path) => {
return joinPathFragments(offset, path);
}
),
};
}
}
} else {
const stylePath = getTempStylesForTailwind(ctContext);
buildOptions.styles = stylePath ? [stylePath] : [];
buildOptions.assets = [];
buildOptions.scripts = [];
buildOptions.stylePreprocessorOptions = { includePaths: [] };
}
return {
root: offset ? joinPathFragments(offset, project.root) : project.root,
sourceRoot: offset ? joinPathFragments(offset, sourceRoot) : sourceRoot,
buildOptions: {
...buildOptions,
// this property is only valid for cy v12.9.0+
workspaceRoot: offset ? undefined : ctContext.root,
},
};
}
function withSchemaDefaults(
options: any,
sourceRoot: string,
workspaceRoot: string
): BrowserBuilderSchema {
if (!options.main && !options.browser) {
options.browser = joinPathFragments(sourceRoot, 'main.ts');
if (!existsSync(join(workspaceRoot, options.browser))) {
throw new Error('Missing executor options "main" and "browser"');
}
}
if (!options.index) {
throw new Error('Missing executor options "index"');
}
if (!options.tsConfig) {
throw new Error('Missing executor options "tsConfig"');
}
// cypress defaults aot to false so we cannot use buildOptimizer
// otherwise the 'buildOptimizer' cannot be used without 'aot' error is thrown
options.buildOptimizer = false;
options.aot = false;
options.assets ??= [];
options.allowedCommonJsDependencies ??= [];
options.budgets ??= [];
options.commonChunk ??= true;
options.crossOrigin ??= 'none';
options.deleteOutputPath ??= true;
options.extractLicenses ??= true;
options.fileReplacements ??= [];
options.inlineStyleLanguage ??= 'css';
options.i18nDuplicateTranslation ??= 'warning';
options.outputHashing ??= 'none';
options.progress ??= true;
options.scripts ??= [];
options.main ??= options.browser;
return options;
}
/**
* @returns a path from the workspace root to a temp file containing the base tailwind setup
* if tailwind is being used in the project root or workspace root
* this file should get cleaned up via the cypress executor
*/
function getTempStylesForTailwind(ctExecutorContext: ExecutorContext) {
const ctProjectConfig = ctExecutorContext.projectGraph.nodes[
ctExecutorContext.projectName
]?.data as ProjectConfiguration;
// angular only supports `tailwind.config.{js,cjs}`
const ctProjectTailwindConfig = join(
ctExecutorContext.root,
ctProjectConfig.root,
'tailwind.config'
);
const exts = ['js', 'cjs'];
const isTailWindInCtProject = exts.some((ext) =>
existsSync(`${ctProjectTailwindConfig}.${ext}`)
);
const rootTailwindPath = join(ctExecutorContext.root, 'tailwind.config');
const isTailWindInRoot = exts.some((ext) =>
existsSync(`${rootTailwindPath}.${ext}`)
);
if (isTailWindInRoot || isTailWindInCtProject) {
const pathToStyle = getTempTailwindPath(ctExecutorContext);
try {
mkdirSync(dirname(pathToStyle), { recursive: true });
writeFileSync(
pathToStyle,
`
@tailwind base;
@tailwind components;
@tailwind utilities;
`,
{ encoding: 'utf-8' }
);
return pathToStyle;
} catch (makeTmpFileError) {
logger.warn(stripIndents`Issue creating a temp file for tailwind styles. Defaulting to no tailwind setup.
Temp file path? ${pathToStyle}`);
logger.error(makeTmpFileError);
}
}
}
function isOffsetNeeded(
ctExecutorContext: ExecutorContext,
ctProjectConfig: ProjectConfiguration
) {
try {
const supportsWorkspaceRoot = isCyVersionGreaterThanOrEqual('12.9.0');
// if using cypress <v12.9.0 then we require the offset
if (!supportsWorkspaceRoot) {
return true;
}
if (
ctProjectConfig.projectType === 'library' &&
// angular will only see this config if the library root is the build project config root
// otherwise it will be set to the buildTarget root which is the app root where this config doesn't exist
// causing tailwind styles from the libs project root to not work
['js', 'cjs'].some((ext) =>
existsSync(
join(
ctExecutorContext.root,
ctProjectConfig.root,
`tailwind.config.${ext}`
)
)
)
) {
return true;
}
return false;
} catch (e) {
if (process.env.NX_VERBOSE_LOGGING === 'true') {
logger.error(e);
}
// unable to determine if we don't require an offset
// safest to assume we do
return true;
}
}
/**
* check if the cypress version is able to understand absolute paths to the indexHtmlFile option
* this is required for nx to work with cypress <v12.17.0 since the relative pathing is causes issues
* with invalid pathing.
* v12.17.0+ works with relative pathing
*
* if there is an error thrown then we assume it is an older version of cypress and use the absolute path
* as that was supported for longer.
*
* */
function requiresAbsolutePath() {
try {
return !isCyVersionGreaterThanOrEqual('12.17.0');
} catch (e) {
if (process.env.NX_VERBOSE_LOGGING === 'true') {
logger.error(e);
}
return true;
}
}
/**
* Checks if the install cypress version is greater than or equal to the provided version.
* Does not catch errors as any custom logic for error handling is required on consumer side.
* */
function isCyVersionGreaterThanOrEqual(version: string) {
const { version: cyVersion = null } = require('cypress/package.json');
return !!cyVersion && gte(cyVersion, version);
}
+24
View File
@@ -0,0 +1,24 @@
{
"name": "angular",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "packages/angular",
"projectType": "library",
"targets": {
"build-ng": {
"dependsOn": ["build-base", "typecheck"],
"executor": "@nx/angular:package",
"options": {
"project": "packages/angular/ng-package.json",
"tsConfig": "packages/angular/tsconfig.lib.runtime.json"
},
"outputs": ["{projectRoot}/dist"]
},
"build": {
"dependsOn": ["^build", "build-ng", "typecheck", "build-base"],
"outputs": ["{projectRoot}/README.md"],
"command": "node ./scripts/copy-readme.js angular packages/angular/readme-template.md packages/angular/README.md",
"inputs": ["copyReadme"]
}
},
"implicitDependencies": ["vite", "storybook", "playwright", "jest"]
}
+18
View File
@@ -0,0 +1,18 @@
<p style="text-align: center;">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/nrwl/nx/master/images/nx-dark.svg">
<img alt="Nx - Smart Monorepos · Fast Builds" src="https://raw.githubusercontent.com/nrwl/nx/master/images/nx-light.svg" width="100%">
</picture>
</p>
{{links}}
<hr>
# Nx: Smart Monorepos · Fast Builds
Get to green PRs in half the time. Nx optimizes your builds, scales your CI, and fixes failed PRs. Built for developers and AI agents.
This package is an [Angular plugin for Nx](https://nx.dev/nx-api/angular).
{{content}}
@@ -0,0 +1,284 @@
import type { BuilderContext } from '@angular-devkit/architect';
import type { DevServerBuilderOptions } from '@angular-devkit/build-angular';
import {
joinPathFragments,
normalizePath,
parseTargetString,
readCachedProjectGraph,
readProjectsConfigurationFromProjectGraph,
workspaceRoot,
} from '@nx/devkit';
import { getRootTsConfigPath } from '@nx/js';
import type { DependentBuildableProjectNode } from '@nx/js/internal';
import { WebpackNxBuildCoordinationPlugin } from '@nx/webpack/internal';
import { existsSync } from 'fs';
import { readNxJson } from 'nx/src/config/configuration';
import { isNpmProject } from 'nx/src/project-graph/operators';
import { readCachedProjectConfiguration } from 'nx/src/project-graph/project-graph';
import { relative } from 'path';
import { combineLatest, from } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { assertBuilderPackageIsInstalled } from '../../executors/utilities/builder-package';
import {
loadIndexHtmlTransformer,
loadMiddleware,
loadPlugins,
type PluginSpec,
} from '../../executors/utilities/esbuild-extensions';
import { patchBuilderContext } from '../../executors/utilities/patch-builder-context';
import { createTmpTsConfigForBuildableLibs } from '../utilities/buildable-libs';
import {
mergeCustomWebpackConfig,
resolveIndexHtmlTransformer,
} from '../utilities/webpack';
import { normalizeOptions, validateOptions } from './lib';
import type { NormalizedSchema, Schema } from './schema';
type BuildTargetOptions = {
tsConfig: string;
buildLibsFromSource?: boolean;
customWebpackConfig?: { path?: string };
indexHtmlTransformer?: string;
indexFileTransformer?: string;
plugins?: string[] | PluginSpec[];
esbuildMiddleware?: string[];
};
export function executeDevServerBuilder(
rawOptions: Schema,
context: import('@angular-devkit/architect').BuilderContext
) {
validateOptions(rawOptions);
process.env.NX_TSCONFIG_PATH = getRootTsConfigPath();
const options = normalizeOptions(rawOptions);
const projectGraph = readCachedProjectGraph();
const parsedBuildTarget = parseTargetString(options.buildTarget, {
cwd: context.currentDirectory,
projectGraph,
projectName: context.target.project,
projectsConfigurations:
readProjectsConfigurationFromProjectGraph(projectGraph),
root: context.workspaceRoot,
nxJsonConfiguration: readNxJson(workspaceRoot),
isVerbose: false,
});
const browserTargetProjectConfiguration = readCachedProjectConfiguration(
parsedBuildTarget.project
);
const buildTarget =
browserTargetProjectConfiguration.targets[parsedBuildTarget.target];
const buildTargetOptions: BuildTargetOptions = {
...buildTarget.options,
...(parsedBuildTarget.configuration
? buildTarget.configurations[parsedBuildTarget.configuration]
: buildTarget.defaultConfiguration
? buildTarget.configurations[buildTarget.defaultConfiguration]
: {}),
};
const buildLibsFromSource =
options.buildLibsFromSource ??
buildTargetOptions.buildLibsFromSource ??
true;
process.env.NX_BUILD_LIBS_FROM_SOURCE = `${buildLibsFromSource}`;
process.env.NX_BUILD_TARGET = options.buildTarget;
let pathToWebpackConfig: string;
if (buildTargetOptions.customWebpackConfig?.path) {
pathToWebpackConfig = joinPathFragments(
context.workspaceRoot,
buildTargetOptions.customWebpackConfig.path
);
if (pathToWebpackConfig && !existsSync(pathToWebpackConfig)) {
throw new Error(
`Custom Webpack Config File Not Found!\nTo use a custom webpack config, please ensure the path to the custom webpack file is correct: \n${pathToWebpackConfig}`
);
}
}
const normalizedIndexHtmlTransformer =
buildTargetOptions.indexHtmlTransformer ??
buildTargetOptions.indexFileTransformer;
let pathToIndexFileTransformer: string;
if (normalizedIndexHtmlTransformer) {
pathToIndexFileTransformer = joinPathFragments(
context.workspaceRoot,
normalizedIndexHtmlTransformer
);
if (pathToIndexFileTransformer && !existsSync(pathToIndexFileTransformer)) {
throw new Error(
`File containing Index File Transformer function Not Found!\n Please ensure the path to the file containing the function is correct: \n${pathToIndexFileTransformer}`
);
}
}
let dependencies: DependentBuildableProjectNode[];
if (!buildLibsFromSource) {
const { tsConfigPath, dependencies: foundDependencies } =
createTmpTsConfigForBuildableLibs(buildTargetOptions.tsConfig, context, {
target: parsedBuildTarget.target,
});
dependencies = foundDependencies;
const relativeTsConfigPath = normalizePath(
relative(context.workspaceRoot, tsConfigPath)
);
// We can't just pass the tsconfig path in memory to the angular builder
// function because we can't pass the build target options to it, the build
// targets options will be retrieved by the builder from the project
// configuration. Therefore, we patch the method in the context to retrieve
// the target options to overwrite the tsconfig path to use the generated
// one with the updated path mappings.
const originalGetTargetOptions = context.getTargetOptions;
context.getTargetOptions = async (target) => {
const options = await originalGetTargetOptions(target);
options.tsConfig = relativeTsConfigPath;
return options;
};
// The buildTargetConfiguration also needs to use the generated tsconfig path
// otherwise the build will fail if customWebpack function/file is referencing
// local libs. This synchronize the behavior with webpack-browser and
// webpack-server implementation.
buildTargetOptions.tsConfig = relativeTsConfigPath;
}
const delegateBuilderOptions = getDelegateBuilderOptions(options);
const isUsingWebpackBuilder = ![
'@angular/build:application',
'@angular-devkit/build-angular:application',
'@angular-devkit/build-angular:browser-esbuild',
'@nx/angular:application',
'@nx/angular:browser-esbuild',
].includes(buildTarget.executor);
/**
* The Angular CLI dev-server builder make some decisions based on the build
* target builder but it only considers `@angular-devkit/build-angular:*`
* builders. Since we are using a custom builder, we patch the context to
* handle `@nx/angular:*` executors.
*/
patchBuilderContext(context, !isUsingWebpackBuilder, parsedBuildTarget);
assertBuilderPackageIsInstalled('@angular-devkit/build-angular');
return combineLatest([
from(import('@angular-devkit/build-angular')),
from(loadPlugins(buildTargetOptions.plugins, buildTargetOptions.tsConfig)),
from(
loadMiddleware(options.esbuildMiddleware, buildTargetOptions.tsConfig)
),
from(
loadIndexHtmlFileTransformer(
pathToIndexFileTransformer,
buildTargetOptions.tsConfig,
context,
isUsingWebpackBuilder
)
),
]).pipe(
switchMap(
([
{ executeDevServerBuilder },
plugins,
middleware,
indexHtmlTransformer,
]) =>
executeDevServerBuilder(
delegateBuilderOptions,
context,
{
webpackConfiguration: isUsingWebpackBuilder
? async (baseWebpackConfig) => {
if (!buildLibsFromSource) {
const workspaceDependencies = dependencies
.filter((dep) => !isNpmProject(dep.node))
.map((dep) => dep.node.name);
// default for `nx run-many` is --all projects
// by passing an empty string for --projects, run-many will default to
// run the target for all projects.
// This will occur when workspaceDependencies = []
if (workspaceDependencies.length > 0) {
baseWebpackConfig.plugins.push(
new WebpackNxBuildCoordinationPlugin(
`nx run-many --target=${
parsedBuildTarget.target
} --projects=${workspaceDependencies.join(',')}`,
{ skipWatchingDeps: !options.watchDependencies }
) as any // TODO(Colum): this can be removed when angular 20.2 is merged
);
}
}
if (!pathToWebpackConfig) {
return baseWebpackConfig;
}
return mergeCustomWebpackConfig(
baseWebpackConfig,
pathToWebpackConfig,
buildTargetOptions,
context.target
);
}
: undefined,
...(indexHtmlTransformer
? {
indexHtml: indexHtmlTransformer,
}
: {}),
},
{
buildPlugins: plugins,
middleware,
}
)
)
);
}
export default require('@angular-devkit/architect').createBuilder(
executeDevServerBuilder
) as any;
function getDelegateBuilderOptions(
options: NormalizedSchema
): DevServerBuilderOptions {
const delegateBuilderOptions: NormalizedSchema & DevServerBuilderOptions = {
...options,
};
// delete extra option not supported by the delegate builder
delete delegateBuilderOptions.buildLibsFromSource;
delete delegateBuilderOptions.watchDependencies;
return delegateBuilderOptions;
}
async function loadIndexHtmlFileTransformer(
pathToIndexFileTransformer: string | undefined,
tsConfig: string,
context: BuilderContext,
isUsingWebpackBuilder: boolean
) {
if (!pathToIndexFileTransformer) {
return undefined;
}
return isUsingWebpackBuilder
? resolveIndexHtmlTransformer(
pathToIndexFileTransformer,
tsConfig,
context.target
)
: await loadIndexHtmlTransformer(pathToIndexFileTransformer, tsConfig);
}
@@ -0,0 +1,2 @@
export * from './normalize-options';
export * from './validate-options';
@@ -0,0 +1,14 @@
import type { NormalizedSchema, Schema } from '../schema';
export function normalizeOptions(schema: Schema): NormalizedSchema {
return {
...schema,
host: schema.host ?? 'localhost',
port: schema.port ?? 4200,
liveReload: schema.liveReload ?? true,
hmr: schema.hmr,
open: schema.open ?? false,
ssl: schema.ssl ?? false,
watchDependencies: schema.watchDependencies ?? true,
};
}
@@ -0,0 +1,93 @@
import { validateOptions } from './validate-options';
import * as angularVersionUtils from '../../../executors/utilities/angular-version-utils';
describe('validateOptions', () => {
let getInstalledAngularVersionInfoSpy: jest.SpyInstance;
beforeEach(() => {
getInstalledAngularVersionInfoSpy = jest.spyOn(
angularVersionUtils,
'getInstalledAngularVersionInfo'
);
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('when Angular version is < 21', () => {
beforeEach(() => {
getInstalledAngularVersionInfoSpy.mockReturnValue({
major: 20,
version: '20.0.0',
});
});
it('should not throw error when define is undefined', () => {
expect(() => {
validateOptions({ buildTarget: 'app:build' });
}).not.toThrow();
});
it('should not throw error when define is an empty object', () => {
expect(() => {
validateOptions({ buildTarget: 'app:build', define: {} });
}).not.toThrow();
});
it('should throw error when define has keys', () => {
expect(() => {
validateOptions({
buildTarget: 'app:build',
define: { API_URL: '"http://localhost:3000"' },
});
}).toThrow(
'The "define" option is only supported in Angular >= 21.0.0. You are currently using "20.0.0".'
);
});
it('should include full Angular version in error message', () => {
getInstalledAngularVersionInfoSpy.mockReturnValue({
major: 20,
version: '20.0.0-next.5',
});
expect(() => {
validateOptions({
buildTarget: 'app:build',
define: { API_URL: '"http://localhost:3000"' },
});
}).toThrow('You are currently using "20.0.0-next.5".');
});
});
describe('when Angular version is >= 21', () => {
beforeEach(() => {
getInstalledAngularVersionInfoSpy.mockReturnValue({
major: 21,
version: '21.0.0',
});
});
it('should not throw error when define is undefined', () => {
expect(() => {
validateOptions({ buildTarget: 'app:build' });
}).not.toThrow();
});
it('should not throw error when define is an empty object', () => {
expect(() => {
validateOptions({ buildTarget: 'app:build', define: {} });
}).not.toThrow();
});
it('should not throw error when define has keys', () => {
expect(() => {
validateOptions({
buildTarget: 'app:build',
define: { API_URL: '"http://localhost:3000"' },
});
}).not.toThrow();
});
});
});
@@ -0,0 +1,15 @@
import { stripIndents } from '@nx/devkit';
import { getInstalledAngularVersionInfo } from '../../../executors/utilities/angular-version-utils';
import type { Schema } from '../schema';
export function validateOptions(options: Schema): void {
const { major: angularMajorVersion, version: angularVersion } =
getInstalledAngularVersionInfo();
if (angularMajorVersion < 21) {
if (options.define && Object.keys(options.define).length > 0) {
throw new Error(stripIndents`The "define" option is only supported in Angular >= 21.0.0. You are currently using "${angularVersion}".
You can resolve this error by removing the "define" option or by migrating to Angular 21.0.0.`);
}
}
}
+33
View File
@@ -0,0 +1,33 @@
interface Schema {
buildTarget: string;
port?: number;
host?: string;
proxyConfig?: string;
ssl?: boolean;
sslKey?: string;
sslCert?: string;
headers?: Record<string, string>;
open?: boolean;
verbose?: boolean;
liveReload?: boolean;
publicHost?: string;
allowedHosts?: string[];
define?: Record<string, string>;
servePath?: string;
disableHostCheck?: boolean;
hmr?: boolean;
watch?: boolean;
poll?: number;
forceEsbuild?: boolean;
inspect?: boolean | string;
prebundle?: boolean | { exclude: string[] };
buildLibsFromSource?: boolean;
esbuildMiddleware?: string[];
watchDependencies?: boolean;
}
export type NormalizedSchema = Schema & {
liveReload: boolean;
open: boolean;
ssl: boolean;
};
@@ -0,0 +1,170 @@
{
"version": 2,
"continuous": true,
"outputCapture": "direct-nodejs",
"$schema": "http://json-schema.org/draft-07/schema",
"title": "Schema for Webpack Dev Server",
"description": "Serves an Angular application using [webpack](https://webpack.js.org/) when the build target is using a webpack-based executor, or [Vite](https://vite.dev/) when the build target uses an [esbuild](https://esbuild.github.io/)-based executor.",
"examplesFile": "../../../docs/dev-server-examples.md",
"type": "object",
"presets": [
{
"name": "Using a Different Port",
"keys": ["buildTarget", "port"]
}
],
"properties": {
"buildTarget": {
"type": "string",
"description": "A build builder target to serve in the format of `project:target[:configuration]`.",
"pattern": "^[^:\\s]+:[^:\\s]+(:[^\\s]+)?$"
},
"port": {
"type": "number",
"description": "Port to listen on.",
"default": 4200
},
"host": {
"type": "string",
"description": "Host to listen on.",
"default": "localhost"
},
"proxyConfig": {
"type": "string",
"description": "Proxy configuration file. For more information, see https://angular.dev/tools/cli/serve#proxying-to-a-backend-server."
},
"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."
},
"headers": {
"type": "object",
"description": "Custom HTTP headers to be added to all responses.",
"propertyNames": {
"pattern": "^[-_A-Za-z0-9]+$"
},
"additionalProperties": {
"type": "string"
}
},
"open": {
"type": "boolean",
"description": "Opens the url in default browser.",
"default": false,
"alias": "o"
},
"verbose": {
"type": "boolean",
"description": "Adds more details to output logging."
},
"liveReload": {
"type": "boolean",
"description": "Whether to reload the page on change, using live-reload.",
"default": true
},
"publicHost": {
"type": "string",
"description": "The URL that the browser client (or live-reload client, if enabled) should use to connect to the development server. Use for a complex dev server setup, such as one with reverse proxies. This option has no effect when using the 'application' or other esbuild-based builders."
},
"allowedHosts": {
"type": "array",
"description": "List of hosts that are allowed to access the dev server.",
"default": [],
"items": {
"type": "string"
}
},
"define": {
"description": "Defines global identifiers that will be replaced with a specified constant value when found in any JavaScript or TypeScript code including libraries. The value will be used directly. String values must be put in quotes. Identifiers within Angular metadata such as Component Decorators will not be replaced. _Note: this is only supported in Angular versions >= 21.0.0 and it's only applicable for the Vite-based development server._",
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"servePath": {
"type": "string",
"description": "The pathname where the app will be served."
},
"disableHostCheck": {
"type": "boolean",
"description": "Don't verify connected clients are part of allowed hosts.",
"default": false
},
"hmr": {
"type": "boolean",
"description": "Enable hot module replacement."
},
"watch": {
"type": "boolean",
"description": "Rebuild on change.",
"default": true
},
"poll": {
"type": "number",
"description": "Enable and define the file watching poll time period in milliseconds."
},
"forceEsbuild": {
"type": "boolean",
"description": "Force the development server to use the 'browser-esbuild' builder when building. This is a developer preview option for the esbuild-based build system.",
"default": false
},
"inspect": {
"default": false,
"description": "Activate debugging inspector. This option only has an effect when 'SSR' or 'SSG' are enabled.",
"oneOf": [
{
"type": "string",
"description": "Activate the inspector on host and port in the format of `[[host:]port]`. See the security warning in https://nodejs.org/docs/latest-v22.x/api/cli.html#warning-binding-inspector-to-a-public-ipport-combination-is-insecure regarding the host parameter usage."
},
{ "type": "boolean" }
]
},
"prebundle": {
"description": "Enable and control the Vite-based development server's prebundling capabilities. To enable prebundling, the Angular CLI cache must also be enabled. This option has no effect when using the 'browser' or other Webpack-based builders.",
"oneOf": [
{ "type": "boolean" },
{
"type": "object",
"properties": {
"exclude": {
"description": "List of package imports that should not be prebundled by the development server. The packages will be bundled into the application code itself.",
"type": "array",
"items": { "type": "string" }
}
},
"additionalProperties": false,
"required": ["exclude"]
}
]
},
"buildLibsFromSource": {
"type": "boolean",
"description": "Read buildable libraries from source instead of building them separately. If not set, it will take the value specified in the `buildTarget` options, or it will default to `true` if it's also not set in the `buildTarget` options.",
"x-priority": "important"
},
"esbuildMiddleware": {
"description": "A list of HTTP request middleware functions.",
"type": "array",
"items": {
"type": "string",
"description": "The path to the middleware function. Relative to the workspace root."
}
},
"watchDependencies": {
"type": "boolean",
"description": "Watch buildable dependencies and rebuild when they change.",
"default": true
}
},
"additionalProperties": false,
"required": ["buildTarget"]
}
@@ -0,0 +1,33 @@
import {
calculateProjectDependencies,
createTmpTsConfig,
DependentBuildableProjectNode,
} from '@nx/js/internal';
import { ProjectGraph, readCachedProjectGraph } from '@nx/devkit';
import { join } from 'path';
export function createTmpTsConfigForBuildableLibs(
tsConfigPath: string,
context: import('@angular-devkit/architect').BuilderContext,
options?: { projectGraph?: ProjectGraph; target?: string }
) {
let dependencies: DependentBuildableProjectNode[];
const result = calculateProjectDependencies(
options?.projectGraph ?? readCachedProjectGraph(),
context.workspaceRoot,
context.target.project,
options?.target ?? context.target.target,
context.target.configuration
);
dependencies = result.dependencies;
const tmpTsConfigPath = createTmpTsConfig(
join(context.workspaceRoot, tsConfigPath),
context.workspaceRoot,
result.target.data.root,
dependencies
);
process.env.NX_TSCONFIG_PATH = tmpTsConfigPath;
return { tsConfigPath: tmpTsConfigPath, dependencies };
}
@@ -0,0 +1,204 @@
import { logger, ProjectConfiguration } from '@nx/devkit';
import { getProjectSourceRoot, loadTsFile } from '@nx/js/internal';
import { existsSync, readFileSync } from 'fs';
import { join } from 'path';
export type DevRemoteDefinition =
| string
| { remoteName: string; configuration: string };
export function getDynamicRemotes(
project: ProjectConfiguration,
context: import('@angular-devkit/architect').BuilderContext,
workspaceProjects: Record<string, ProjectConfiguration>,
remotesToSkip: Set<string>,
pathToManifestFile: string | undefined
): string[] {
pathToManifestFile ??= getDynamicMfManifestFile(
project,
context.workspaceRoot
);
// check for dynamic remotes
// we should only check for dynamic based on what we generate
// and fallback to empty array
if (!pathToManifestFile || !existsSync(pathToManifestFile)) {
return [];
}
const moduleFederationManifestJson = readFileSync(
pathToManifestFile,
'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 allDynamicRemotes = Object.entries(parsedManifest)
.map(([remoteName]) => remoteName)
.filter((r) => !remotesToSkip.has(r));
const remotesNotInWorkspace: string[] = [];
const dynamicRemotes = allDynamicRemotes.filter((remote) => {
if (!workspaceProjects[remote]) {
remotesNotInWorkspace.push(remote);
return false;
}
return true;
});
if (remotesNotInWorkspace.length > 0) {
logger.warn(
`Skipping serving ${remotesNotInWorkspace.join(
', '
)} as they could not be found in the workspace. Ensure they are served correctly.`
);
}
return dynamicRemotes;
}
function getModuleFederationConfig(
tsconfigPath: string,
workspaceRoot: string,
projectRoot: string
) {
const moduleFederationConfigPathJS = join(
workspaceRoot,
projectRoot,
'module-federation.config.js'
);
const moduleFederationConfigPathTS = join(
workspaceRoot,
projectRoot,
'module-federation.config.ts'
);
const isTsConfig = existsSync(moduleFederationConfigPathTS);
const moduleFederationConfigPath = isTsConfig
? moduleFederationConfigPathTS
: moduleFederationConfigPathJS;
try {
const config = isTsConfig
? loadTsFile<any>(
moduleFederationConfigPath,
join(workspaceRoot, tsconfigPath)
)
: require(moduleFederationConfigPath);
return {
mfeConfig: config.default || config,
mfConfigPath: moduleFederationConfigPath,
};
} catch {
throw new Error(
`Could not load ${moduleFederationConfigPath}. Was this project generated with "@nx/angular:host"?`
);
}
}
export function getStaticRemotes(
project: ProjectConfiguration,
context: import('@angular-devkit/architect').BuilderContext,
workspaceProjects: Record<string, ProjectConfiguration>,
remotesToSkip: Set<string>
): string[] {
const { mfeConfig, mfConfigPath } = getModuleFederationConfig(
project.targets.build.options.tsConfig,
context.workspaceRoot,
project.root
);
const remotesConfig =
Array.isArray(mfeConfig.remotes) && mfeConfig.remotes.length > 0
? mfeConfig.remotes
: [];
const allStaticRemotes = remotesConfig
.map((remoteDefinition) =>
Array.isArray(remoteDefinition) ? remoteDefinition[0] : remoteDefinition
)
.filter((r) => !remotesToSkip.has(r));
const remotesNotInWorkspace: string[] = [];
const staticRemotes = allStaticRemotes.filter((remote) => {
if (!workspaceProjects[remote]) {
remotesNotInWorkspace.push(remote);
return false;
}
return true;
});
if (remotesNotInWorkspace.length > 0) {
logger.warn(
`Skipping serving ${remotesNotInWorkspace.join(
', '
)} as they could not be found in the workspace. Ensure they are served correctly.`
);
}
return staticRemotes;
}
export function validateDevRemotes(
options: {
devRemotes: DevRemoteDefinition[];
},
workspaceProjects: Record<string, ProjectConfiguration>
): void {
const invalidDevRemotes =
options.devRemotes.filter(
(remote) =>
!(typeof remote === 'string'
? workspaceProjects[remote]
: workspaceProjects[remote.remoteName])
) ?? [];
if (invalidDevRemotes.length) {
throw new Error(
invalidDevRemotes.length === 1
? `Invalid dev remote provided: ${invalidDevRemotes[0]}.`
: `Invalid dev remotes provided: ${invalidDevRemotes.join(', ')}.`
);
}
}
export function getDynamicMfManifestFile(
project: ProjectConfiguration,
workspaceRoot: string
): string | undefined {
// {sourceRoot}/assets/module-federation.manifest.json was the generated
// path for the manifest file in the past. We now generate the manifest
// file at {root}/public/module-federation.manifest.json. This check
// ensures that we can still support the old path for backwards
// compatibility since old projects may still have the manifest file
// at the old path.
return [
join(workspaceRoot, project.root, 'public/module-federation.manifest.json'),
join(
workspaceRoot,
getProjectSourceRoot(project),
'assets/module-federation.manifest.json'
),
].find((path) => existsSync(path));
}
@@ -0,0 +1,85 @@
import { merge } from 'webpack-merge';
import { loadTsFile } from '@nx/js/internal';
import { workspaceRoot } from '@nx/devkit';
import { join } from 'path';
import { existsSync, readFileSync } from 'fs';
export async function mergeCustomWebpackConfig(
baseWebpackConfig: any,
pathToWebpackConfig: string,
options: { tsConfig: string; [k: string]: any },
target: import('@angular-devkit/architect').Target
) {
const customWebpackConfiguration = resolveCustomWebpackConfig(
pathToWebpackConfig,
options.tsConfig.startsWith(workspaceRoot)
? options.tsConfig
: join(workspaceRoot, options.tsConfig)
);
// The extra Webpack configuration file can also export a Promise, for instance:
// `module.exports = new Promise(...)`. If it exports a single object, but not a Promise,
// then await will just resolve that object.
const config = await customWebpackConfiguration;
let newConfig: any;
if (typeof config === 'function') {
// The extra Webpack configuration file can export a synchronous or asynchronous function,
// for instance: `module.exports = async config => { ... }`.
newConfig = await config(baseWebpackConfig, options, target);
} else {
newConfig = merge(baseWebpackConfig, config);
}
// license-webpack-plugin will at times try to scan the monorepo's root package.json
// This will result in an error being thrown
// Ensure root package.json is excluded
const licensePlugin = newConfig.plugins.find(
(p) => p.constructor.name === 'LicenseWebpackPlugin'
);
if (licensePlugin) {
let rootPackageJsonName: string;
const pathToRootPackageJson = join(
newConfig.context.root ?? workspaceRoot,
'package.json'
);
if (existsSync(pathToRootPackageJson)) {
try {
const rootPackageJson = JSON.parse(
readFileSync(pathToRootPackageJson, 'utf-8')
);
rootPackageJsonName = rootPackageJson.name;
licensePlugin.pluginOptions.excludedPackageTest = (pkgName: string) => {
if (!rootPackageJsonName) {
return false;
}
return pkgName === rootPackageJsonName;
};
} catch {
// do nothing
}
}
}
return newConfig;
}
export function resolveCustomWebpackConfig(path: string, tsConfig: string) {
const customWebpackConfig = loadTsFile<any>(path, tsConfig);
// If the user provides a configuration in TS file
// then there are 2 cases for exporting an object. The first one is:
// `module.exports = { ... }`. And the second one is:
// `export default { ... }`. The ESM format is compiled into:
// `{ default: { ... } }`
return customWebpackConfig.default ?? customWebpackConfig;
}
export function resolveIndexHtmlTransformer(
path: string,
tsConfig: string,
target: import('@angular-devkit/architect').Target
) {
const indexTransformer = loadTsFile<any>(path, tsConfig);
const transform = indexTransformer.default ?? indexTransformer;
return (indexHtml) => transform(target, indexHtml);
}
@@ -0,0 +1,10 @@
import type { Schema } from '@angular-devkit/build-angular/src/builders/browser/schema';
export type BrowserBuilderSchema = Schema & {
customWebpackConfig?: {
path: string;
};
indexHtmlTransformer?: string;
buildLibsFromSource?: boolean;
watchDependencies?: boolean;
};
@@ -0,0 +1,603 @@
{
"version": 2,
"outputCapture": "direct-nodejs",
"$schema": "http://json-schema.org/draft-07/schema",
"title": "Schema for Webpack Browser",
"description": "Builds an Angular application using [webpack](https://webpack.js.org/).",
"examplesFile": "../../../docs/webpack-browser-examples.md",
"type": "object",
"presets": [
{
"name": "Custom Webpack Configuration",
"keys": [
"outputs",
"outputPath",
"index",
"main",
"polyfills",
"tsConfig",
"assets",
"styles",
"scripts",
"customWebpackConfig"
]
}
],
"properties": {
"assets": {
"type": "array",
"description": "List of static application assets.",
"default": [],
"items": {
"$ref": "#/definitions/assetPattern"
}
},
"main": {
"type": "string",
"description": "The full path for the main entry point to the app, relative to the current workspace."
},
"polyfills": {
"description": "Polyfills to be included in the build.",
"oneOf": [
{
"type": "array",
"description": "A list of polyfills to include in the build. Can be a full path for a file, relative to the current workspace or module specifier. Example: 'zone.js'.",
"items": {
"type": "string",
"uniqueItems": true
},
"default": []
},
{
"type": "string",
"description": "The full path for the polyfills file, relative to the current workspace or a module specifier. Example: 'zone.js'."
}
]
},
"tsConfig": {
"type": "string",
"description": "The full path for the TypeScript configuration file, relative to the current workspace."
},
"scripts": {
"description": "Global scripts to be included in the build.",
"type": "array",
"default": [],
"items": {
"oneOf": [
{
"type": "object",
"properties": {
"input": {
"type": "string",
"description": "The file to include.",
"pattern": "\\.[cm]?jsx?$"
},
"bundleName": {
"type": "string",
"pattern": "^[\\w\\-.]*$",
"description": "The bundle name for this extra entry point."
},
"inject": {
"type": "boolean",
"description": "If the bundle will be referenced in the HTML file.",
"default": true
}
},
"additionalProperties": false,
"required": ["input"]
},
{
"type": "string",
"description": "The file to include.",
"pattern": "\\.[cm]?jsx?$"
}
]
}
},
"styles": {
"description": "Global styles to be included in the build.",
"type": "array",
"default": [],
"items": {
"oneOf": [
{
"type": "object",
"properties": {
"input": {
"type": "string",
"description": "The file to include.",
"pattern": "\\.(?:css|scss|sass|less)$"
},
"bundleName": {
"type": "string",
"pattern": "^[\\w\\-.]*$",
"description": "The bundle name for this extra entry point."
},
"inject": {
"type": "boolean",
"description": "If the bundle will be referenced in the HTML file.",
"default": true
}
},
"additionalProperties": false,
"required": ["input"]
},
{
"type": "string",
"description": "The file to include.",
"pattern": "\\.(?:css|scss|sass|less)$"
}
]
},
"x-priority": "important"
},
"inlineStyleLanguage": {
"description": "The stylesheet language to use for the application's inline component styles.",
"type": "string",
"default": "css",
"enum": ["css", "less", "sass", "scss"]
},
"stylePreprocessorOptions": {
"description": "Options to pass to style preprocessors.",
"type": "object",
"properties": {
"includePaths": {
"description": "Paths to include. Paths will be resolved to project root.",
"type": "array",
"items": {
"type": "string"
},
"default": []
}
},
"additionalProperties": false
},
"optimization": {
"description": "Enables optimization of the build output. Including minification of scripts and styles, tree-shaking, dead-code elimination, inlining of critical CSS and fonts inlining. For more information, see https://angular.dev/reference/configs/workspace-config#optimization-configuration.",
"default": true,
"oneOf": [
{
"type": "object",
"properties": {
"scripts": {
"type": "boolean",
"description": "Enables optimization of the scripts output.",
"default": true
},
"styles": {
"description": "Enables optimization of the styles output.",
"default": true,
"oneOf": [
{
"type": "object",
"properties": {
"minify": {
"type": "boolean",
"description": "Minify CSS definitions by removing extraneous whitespace and comments, merging identifiers and minimizing values.",
"default": true
},
"inlineCritical": {
"type": "boolean",
"description": "Extract and inline critical CSS definitions to improve first paint time.",
"default": true
}
},
"additionalProperties": false
},
{
"type": "boolean"
}
]
},
"fonts": {
"description": "Enables optimization for fonts. This option requires internet access. `HTTPS_PROXY` environment variable can be used to specify a proxy server.",
"default": true,
"oneOf": [
{
"type": "object",
"properties": {
"inline": {
"type": "boolean",
"description": "Reduce render blocking requests by inlining external Google Fonts and Adobe Fonts CSS definitions in the application's HTML index file. This option requires internet access. `HTTPS_PROXY` environment variable can be used to specify a proxy server.",
"default": true
}
},
"additionalProperties": false
},
{
"type": "boolean"
}
]
}
},
"additionalProperties": false
},
{
"type": "boolean"
}
]
},
"fileReplacements": {
"description": "Replace compilation source files with other compilation source files in the build.",
"type": "array",
"items": {
"$ref": "#/definitions/fileReplacement"
},
"default": []
},
"outputPath": {
"type": "string",
"description": "The full path for the new output directory, relative to the current workspace."
},
"resourcesOutputPath": {
"type": "string",
"description": "The path where style resources will be placed, relative to outputPath.",
"default": ""
},
"aot": {
"type": "boolean",
"description": "Build using Ahead of Time compilation.",
"default": true
},
"sourceMap": {
"description": "Output source maps for scripts and styles. For more information, see https://angular.dev/reference/configs/workspace-config#source-map-configuration.",
"default": false,
"oneOf": [
{
"type": "object",
"properties": {
"scripts": {
"type": "boolean",
"description": "Output source maps for all scripts.",
"default": true
},
"styles": {
"type": "boolean",
"description": "Output source maps for all styles.",
"default": true
},
"hidden": {
"type": "boolean",
"description": "Output source maps used for error reporting tools.",
"default": false
},
"vendor": {
"type": "boolean",
"description": "Resolve vendor packages source maps.",
"default": false
}
},
"additionalProperties": false
},
{
"type": "boolean"
}
]
},
"vendorChunk": {
"type": "boolean",
"description": "Generate a separate bundle containing only vendor libraries. This option should only be used for development to reduce the incremental compilation time.",
"default": false
},
"commonChunk": {
"type": "boolean",
"description": "Generate a separate bundle containing code used across multiple bundles.",
"default": true
},
"baseHref": {
"type": "string",
"description": "Base url for the application being built."
},
"deployUrl": {
"type": "string",
"description": "Customize the base path for the URLs of resources in 'index.html' and component stylesheets. This option is only necessary for specific deployment scenarios, such as with Angular Elements or when utilizing different CDN locations."
},
"verbose": {
"type": "boolean",
"description": "Adds more details to output logging.",
"default": false
},
"progress": {
"type": "boolean",
"description": "Log progress to the console while building.",
"default": true
},
"i18nMissingTranslation": {
"type": "string",
"description": "How to handle missing translations for i18n.",
"enum": ["warning", "error", "ignore"],
"default": "warning"
},
"i18nDuplicateTranslation": {
"type": "string",
"description": "How to handle duplicate translations for i18n.",
"enum": ["warning", "error", "ignore"],
"default": "warning"
},
"localize": {
"description": "Translate the bundles in one or more locales.",
"oneOf": [
{
"type": "boolean",
"description": "Translate all locales."
},
{
"type": "array",
"description": "List of locales ID's to translate.",
"minItems": 1,
"items": {
"type": "string",
"pattern": "^[a-zA-Z]{2,3}(-[a-zA-Z]{4})?(-([a-zA-Z]{2}|[0-9]{3}))?(-[a-zA-Z]{5,8})?(-x(-[a-zA-Z0-9]{1,8})+)?$"
}
}
]
},
"watch": {
"type": "boolean",
"description": "Run build when files change.",
"default": false
},
"outputHashing": {
"type": "string",
"description": "Define the output filename cache-busting hashing mode.",
"default": "none",
"enum": ["none", "all", "media", "bundles"]
},
"poll": {
"type": "number",
"description": "Enable and define the file watching poll time period in milliseconds."
},
"deleteOutputPath": {
"type": "boolean",
"description": "Delete the output path before building.",
"default": true
},
"preserveSymlinks": {
"type": "boolean",
"description": "Do not use the real path when resolving modules. If unset then will default to `true` if NodeJS option --preserve-symlinks is set."
},
"extractLicenses": {
"type": "boolean",
"description": "Extract all licenses in a separate file.",
"default": true
},
"buildOptimizer": {
"type": "boolean",
"description": "Enables advanced build optimizations when using the 'aot' option.",
"default": true
},
"namedChunks": {
"type": "boolean",
"description": "Use file name for lazy loaded chunks.",
"default": false
},
"subresourceIntegrity": {
"type": "boolean",
"description": "Enables the use of subresource integrity validation.",
"default": false
},
"serviceWorker": {
"type": "boolean",
"description": "Generates a service worker config for production builds.",
"default": false
},
"ngswConfigPath": {
"type": "string",
"description": "Path to ngsw-config.json."
},
"index": {
"description": "Configures the generation of the application's HTML index.",
"oneOf": [
{
"type": "string",
"description": "The path of a file to use for the application's HTML index. The filename of the specified path will be used for the generated file and will be created in the root of the application's configured output path."
},
{
"type": "object",
"description": "",
"properties": {
"input": {
"type": "string",
"minLength": 1,
"description": "The path of a file to use for the application's generated HTML index."
},
"output": {
"type": "string",
"minLength": 1,
"default": "index.html",
"description": "The output path of the application's generated HTML index file. The full provided path will be used and will be considered relative to the application's configured output path."
}
},
"required": ["input"]
}
]
},
"statsJson": {
"type": "boolean",
"description": "Generates a 'stats.json' file which can be analyzed using tools such as 'webpack-bundle-analyzer'.",
"default": false
},
"budgets": {
"description": "Budget thresholds to ensure parts of your application stay within boundaries which you set.",
"type": "array",
"items": {
"$ref": "#/definitions/budget"
},
"default": []
},
"webWorkerTsConfig": {
"type": "string",
"description": "TypeScript configuration for Web Worker modules."
},
"crossOrigin": {
"type": "string",
"description": "Define the crossorigin attribute setting of elements that provide CORS support.",
"default": "none",
"enum": ["none", "anonymous", "use-credentials"]
},
"allowedCommonJsDependencies": {
"description": "A list of CommonJS or AMD packages that are allowed to be used without a build time warning. Use `'*'` to allow all.",
"type": "array",
"items": {
"type": "string"
},
"default": []
},
"customWebpackConfig": {
"description": "Options for additional webpack configurations.",
"type": "object",
"properties": {
"path": {
"description": "Path to additional webpack configuration, relative to the workspace root.",
"type": "string"
}
},
"x-priority": "important",
"additionalProperties": false
},
"indexHtmlTransformer": {
"description": "Path to a file containing a function to transform the index.html. The function should have the signature: `(target: Target, indexHtml: string) => string`, where `target` is the build target configuration and `indexHtml` is the original HTML content.",
"type": "string",
"alias": "indexFileTransformer"
},
"buildLibsFromSource": {
"type": "boolean",
"description": "Read buildable libraries from source instead of building them separately.",
"default": true
},
"watchDependencies": {
"type": "boolean",
"description": "Watch buildable dependencies and rebuild when they change.",
"default": true
}
},
"additionalProperties": false,
"required": ["outputPath", "index", "main", "tsConfig"],
"definitions": {
"assetPattern": {
"oneOf": [
{
"type": "object",
"properties": {
"followSymlinks": {
"type": "boolean",
"default": false,
"description": "Allow glob patterns to follow symlink directories. This allows subdirectories of the symlink to be searched."
},
"glob": {
"type": "string",
"description": "The pattern to match."
},
"input": {
"type": "string",
"description": "The input directory path in which to apply 'glob'. Defaults to the project root."
},
"ignore": {
"description": "An array of globs to ignore.",
"type": "array",
"items": {
"type": "string"
}
},
"output": {
"type": "string",
"default": "",
"description": "Absolute path within the output."
}
},
"additionalProperties": false,
"required": ["glob", "input"]
},
{
"type": "string"
}
]
},
"fileReplacement": {
"oneOf": [
{
"type": "object",
"properties": {
"src": {
"type": "string",
"pattern": "\\.(([cm]?[jt])sx?|json)$"
},
"replaceWith": {
"type": "string",
"pattern": "\\.(([cm]?[jt])sx?|json)$"
}
},
"additionalProperties": false,
"required": ["src", "replaceWith"]
},
{
"type": "object",
"properties": {
"replace": {
"type": "string",
"pattern": "\\.(([cm]?[jt])sx?|json)$"
},
"with": {
"type": "string",
"pattern": "\\.(([cm]?[jt])sx?|json)$"
}
},
"additionalProperties": false,
"required": ["replace", "with"]
}
]
},
"budget": {
"type": "object",
"properties": {
"type": {
"type": "string",
"description": "The type of budget.",
"enum": [
"all",
"allScript",
"any",
"anyScript",
"anyComponentStyle",
"bundle",
"initial"
]
},
"name": {
"type": "string",
"description": "The name of the bundle."
},
"baseline": {
"type": "string",
"description": "The baseline size for comparison."
},
"maximumWarning": {
"type": "string",
"description": "The maximum threshold for warning relative to the baseline."
},
"maximumError": {
"type": "string",
"description": "The maximum threshold for error relative to the baseline."
},
"minimumWarning": {
"type": "string",
"description": "The minimum threshold for warning relative to the baseline."
},
"minimumError": {
"type": "string",
"description": "The minimum threshold for error relative to the baseline."
},
"warning": {
"type": "string",
"description": "The threshold for warning relative to the baseline (min & max)."
},
"error": {
"type": "string",
"description": "The threshold for error relative to the baseline (min & max)."
}
},
"additionalProperties": false,
"required": ["type"]
}
}
}
@@ -0,0 +1,180 @@
import {
joinPathFragments,
normalizePath,
ProjectGraph,
readCachedProjectGraph,
targetToTargetString,
} from '@nx/devkit';
import type { DependentBuildableProjectNode } from '@nx/js/internal';
import { WebpackNxBuildCoordinationPlugin } from '@nx/webpack/internal';
import { existsSync } from 'fs';
import { isNpmProject } from 'nx/src/project-graph/operators';
import { getDependencyConfigs } from 'nx/src/tasks-runner/utils';
import { relative } from 'path';
import { from, Observable } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { assertBuilderPackageIsInstalled } from '../../executors/utilities/builder-package';
import { createTmpTsConfigForBuildableLibs } from '../utilities/buildable-libs';
import {
mergeCustomWebpackConfig,
resolveIndexHtmlTransformer,
} from '../utilities/webpack';
import type { BrowserBuilderSchema } from './schema';
// This is required to ensure that the webpack version used by the Module Federation is the same as the one used by the builders.
const Module = require('module');
const originalResolveFilename = Module._resolveFilename;
const patchedWebpackPath = require.resolve('webpack', {
paths: [require.resolve('@angular-devkit/build-angular')],
});
// Override the resolve function
Module._resolveFilename = function (request, parent, isMain, options) {
// Intercept webpack specifically
if (request === 'webpack') {
// Force webpack to resolve from your specific path
return patchedWebpackPath;
}
// For all other modules, use the original resolver
return originalResolveFilename.call(this, request, parent, isMain, options);
};
function shouldSkipInitialTargetRun(
projectGraph: ProjectGraph,
project: string,
target: string
): boolean {
const allTargetNames = new Set<string>();
for (const projectName in projectGraph.nodes) {
const project = projectGraph.nodes[projectName];
for (const targetName in project.data.targets ?? {}) {
allTargetNames.add(targetName);
}
}
const projectDependencyConfigs = getDependencyConfigs(
{ project, target },
{},
projectGraph,
Array.from(allTargetNames)
);
// if the task runner already ran the target, skip the initial run
return projectDependencyConfigs.some(
(d) => d.target === target && d.projects === 'dependencies'
);
}
export function executeWebpackBrowserBuilder(
options: BrowserBuilderSchema,
context: import('@angular-devkit/architect').BuilderContext
): Observable<import('@angular-devkit/architect').BuilderOutput> {
options.buildLibsFromSource ??= true;
options.watchDependencies ??= true;
const {
buildLibsFromSource,
customWebpackConfig,
indexHtmlTransformer,
watchDependencies,
...delegateBuilderOptions
} = options;
process.env.NX_BUILD_LIBS_FROM_SOURCE = `${buildLibsFromSource}`;
process.env.NX_BUILD_TARGET = targetToTargetString({ ...context.target });
const pathToWebpackConfig =
customWebpackConfig?.path &&
joinPathFragments(context.workspaceRoot, customWebpackConfig.path);
if (pathToWebpackConfig && !existsSync(pathToWebpackConfig)) {
throw new Error(
`Custom Webpack Config File Not Found!\nTo use a custom webpack config, please ensure the path to the custom webpack file is correct: \n${pathToWebpackConfig}`
);
}
const pathToIndexFileTransformer =
indexHtmlTransformer &&
joinPathFragments(context.workspaceRoot, indexHtmlTransformer);
if (pathToIndexFileTransformer && !existsSync(pathToIndexFileTransformer)) {
throw new Error(
`File containing Index File Transformer function Not Found!\n Please ensure the path to the file containing the function is correct: \n${pathToIndexFileTransformer}`
);
}
let dependencies: DependentBuildableProjectNode[];
let projectGraph: ProjectGraph;
if (!buildLibsFromSource) {
projectGraph = readCachedProjectGraph();
const { tsConfigPath, dependencies: foundDependencies } =
createTmpTsConfigForBuildableLibs(
delegateBuilderOptions.tsConfig,
context,
{ projectGraph }
);
dependencies = foundDependencies;
delegateBuilderOptions.tsConfig = normalizePath(
relative(context.workspaceRoot, tsConfigPath)
);
}
assertBuilderPackageIsInstalled('@angular-devkit/build-angular');
return from(import('@angular-devkit/build-angular')).pipe(
switchMap(({ executeBrowserBuilder }) =>
executeBrowserBuilder(delegateBuilderOptions, context as any, {
webpackConfiguration: (baseWebpackConfig) => {
if (!buildLibsFromSource && delegateBuilderOptions.watch) {
const workspaceDependencies = dependencies
.filter((dep) => !isNpmProject(dep.node))
.map((dep) => dep.node.name);
// default for `nx run-many` is --all projects
// by passing an empty string for --projects, run-many will default to
// run the target for all projects.
// This will occur when workspaceDependencies = []
if (workspaceDependencies.length > 0) {
const skipInitialRun = shouldSkipInitialTargetRun(
projectGraph,
context.target.project,
context.target.target
);
baseWebpackConfig.plugins.push(
// Cast away the angular/webpack plugin type difference (webpack versions).
new (WebpackNxBuildCoordinationPlugin as any)(
`nx run-many --target=${
context.target.target
} --projects=${workspaceDependencies.join(',')}`,
{ skipInitialRun, skipWatchingDeps: !watchDependencies }
)
);
}
}
if (!pathToWebpackConfig) {
return baseWebpackConfig;
}
return mergeCustomWebpackConfig(
baseWebpackConfig,
pathToWebpackConfig,
delegateBuilderOptions,
context.target
);
},
...(pathToIndexFileTransformer
? {
indexHtml: resolveIndexHtmlTransformer(
pathToIndexFileTransformer,
delegateBuilderOptions.tsConfig,
context.target
),
}
: {}),
})
)
);
}
export default require('@angular-devkit/architect').createBuilder(
executeWebpackBrowserBuilder
) as any;
@@ -0,0 +1,8 @@
import type { ServerBuilderOptions } from '@angular-devkit/build-angular';
export interface Schema extends ServerBuilderOptions {
customWebpackConfig?: {
path: string;
};
buildLibsFromSource?: boolean;
}
@@ -0,0 +1,320 @@
{
"version": 2,
"outputCapture": "direct-nodejs",
"$schema": "http://json-schema.org/draft-07/schema",
"title": "Schema for Webpack Server",
"description": "Builds a server Angular application using [webpack](https://webpack.js.org/). This executor is a drop-in replacement for the `@angular-devkit/build-angular:server` builder provided by the Angular CLI. It is usually used in tandem with the `@nx/angular:webpack-browser` executor when your Angular application uses a custom webpack configuration.",
"type": "object",
"properties": {
"assets": {
"type": "array",
"description": "List of static application assets.",
"default": [],
"items": {
"$ref": "#/definitions/assetPattern"
}
},
"main": {
"type": "string",
"description": "The full path for the main entry point to the server app, relative to the current workspace."
},
"tsConfig": {
"type": "string",
"default": "tsconfig.app.json",
"description": "The full path for the TypeScript configuration file, relative to the current workspace."
},
"inlineStyleLanguage": {
"description": "The stylesheet language to use for the application's inline component styles.",
"type": "string",
"default": "css",
"enum": ["css", "less", "sass", "scss"]
},
"stylePreprocessorOptions": {
"description": "Options to pass to style preprocessors",
"type": "object",
"properties": {
"includePaths": {
"description": "Paths to include. Paths will be resolved to project root.",
"type": "array",
"items": {
"type": "string"
},
"default": []
}
},
"additionalProperties": false
},
"optimization": {
"description": "Enables optimization of the build output. Including minification of scripts and styles, tree-shaking and dead-code elimination. For more information, see https://angular.dev/reference/configs/workspace-config#optimization-configuration.",
"default": true,
"oneOf": [
{
"type": "object",
"properties": {
"scripts": {
"type": "boolean",
"description": "Enables optimization of the scripts output.",
"default": true
},
"styles": {
"type": "boolean",
"description": "Enables optimization of the styles output.",
"default": true
}
},
"additionalProperties": false
},
{
"type": "boolean"
}
]
},
"fileReplacements": {
"description": "Replace compilation source files with other compilation source files in the build.",
"type": "array",
"items": {
"$ref": "#/definitions/fileReplacement"
},
"default": []
},
"outputPath": {
"type": "string",
"description": "The full path for the new output directory, relative to the current workspace.\n\nBy default, writes output to a folder named dist/ in the current project."
},
"resourcesOutputPath": {
"type": "string",
"description": "The path where style resources will be placed, relative to outputPath."
},
"sourceMap": {
"description": "Output source maps for scripts and styles. For more information, see https://angular.dev/reference/configs/workspace-config#source-map-configuration.",
"default": false,
"oneOf": [
{
"type": "object",
"properties": {
"scripts": {
"type": "boolean",
"description": "Output source maps for all scripts.",
"default": true
},
"styles": {
"type": "boolean",
"description": "Output source maps for all styles.",
"default": true
},
"hidden": {
"type": "boolean",
"description": "Output source maps used for error reporting tools.",
"default": false
},
"vendor": {
"type": "boolean",
"description": "Resolve vendor packages source maps.",
"default": false
}
},
"additionalProperties": false
},
{
"type": "boolean"
}
]
},
"deployUrl": {
"type": "string",
"description": "Customize the base path for the URLs of resources in 'index.html' and component stylesheets. This option is only necessary for specific deployment scenarios, such as with Angular Elements or when utilizing different CDN locations."
},
"vendorChunk": {
"type": "boolean",
"description": "Generate a separate bundle containing only vendor libraries. This option should only be used for development to reduce the incremental compilation time.",
"default": false
},
"verbose": {
"type": "boolean",
"description": "Adds more details to output logging.",
"default": false
},
"progress": {
"type": "boolean",
"description": "Log progress to the console while building.",
"default": true
},
"i18nMissingTranslation": {
"type": "string",
"description": "How to handle missing translations for i18n.",
"enum": ["warning", "error", "ignore"],
"default": "warning"
},
"i18nDuplicateTranslation": {
"type": "string",
"description": "How to handle duplicate translations for i18n.",
"enum": ["warning", "error", "ignore"],
"default": "warning"
},
"localize": {
"description": "Translate the bundles in one or more locales.",
"oneOf": [
{
"type": "boolean",
"description": "Translate all locales."
},
{
"type": "array",
"description": "List of locales ID's to translate.",
"minItems": 1,
"items": {
"type": "string",
"pattern": "^[a-zA-Z]{2,3}(-[a-zA-Z]{4})?(-([a-zA-Z]{2}|[0-9]{3}))?(-[a-zA-Z]{5,8})?(-x(-[a-zA-Z0-9]{1,8})+)?$"
}
}
]
},
"outputHashing": {
"type": "string",
"description": "Define the output filename cache-busting hashing mode.",
"default": "none",
"enum": ["none", "all", "media", "bundles"]
},
"deleteOutputPath": {
"type": "boolean",
"description": "Delete the output path before building.",
"default": true
},
"preserveSymlinks": {
"type": "boolean",
"description": "Do not use the real path when resolving modules. If unset then will default to `true` if NodeJS option --preserve-symlinks is set."
},
"extractLicenses": {
"type": "boolean",
"description": "Extract all licenses in a separate file, in the case of production builds only.",
"default": true
},
"buildOptimizer": {
"type": "boolean",
"description": "Enables advanced build optimizations.",
"default": true
},
"namedChunks": {
"type": "boolean",
"description": "Use file name for lazy loaded chunks.",
"default": false
},
"externalDependencies": {
"description": "Exclude the listed external dependencies from being bundled into the bundle. Instead, the created bundle relies on these dependencies to be available during runtime.",
"type": "array",
"items": {
"type": "string"
},
"default": []
},
"statsJson": {
"type": "boolean",
"description": "Generates a 'stats.json' file which can be analyzed using tools such as 'webpack-bundle-analyzer'.",
"default": false
},
"watch": {
"type": "boolean",
"description": "Run build when files change.",
"default": false
},
"poll": {
"type": "number",
"description": "Enable and define the file watching poll time period in milliseconds."
},
"customWebpackConfig": {
"description": "Options for additional webpack configurations.",
"type": "object",
"properties": {
"path": {
"description": "Path to additional webpack configuration, relative to the workspace root.",
"type": "string"
}
},
"x-priority": "important",
"additionalProperties": false
},
"buildLibsFromSource": {
"type": "boolean",
"description": "Read buildable libraries from source instead of building them separately.",
"default": true,
"x-priority": "important"
}
},
"additionalProperties": false,
"required": ["outputPath", "main", "tsConfig"],
"definitions": {
"assetPattern": {
"oneOf": [
{
"type": "object",
"properties": {
"followSymlinks": {
"type": "boolean",
"default": false,
"description": "Allow glob patterns to follow symlink directories. This allows subdirectories of the symlink to be searched."
},
"glob": {
"type": "string",
"description": "The pattern to match."
},
"input": {
"type": "string",
"description": "The input directory path in which to apply 'glob'. Defaults to the project root."
},
"ignore": {
"description": "An array of globs to ignore.",
"type": "array",
"items": {
"type": "string"
}
},
"output": {
"type": "string",
"default": "",
"description": "Absolute path within the output."
}
},
"additionalProperties": false,
"required": ["glob", "input"]
},
{
"type": "string"
}
]
},
"fileReplacement": {
"oneOf": [
{
"type": "object",
"properties": {
"src": {
"type": "string",
"pattern": "\\.(([cm]?[jt])sx?|json)$"
},
"replaceWith": {
"type": "string",
"pattern": "\\.(([cm]?[jt])sx?|json)$"
}
},
"additionalProperties": false,
"required": ["src", "replaceWith"]
},
{
"type": "object",
"properties": {
"replace": {
"type": "string",
"pattern": "\\.(([cm]?[jt])sx?|json)$"
},
"with": {
"type": "string",
"pattern": "\\.(([cm]?[jt])sx?|json)$"
}
},
"additionalProperties": false,
"required": ["replace", "with"]
}
]
}
}
}
@@ -0,0 +1,138 @@
import type { BuilderContext } from '@angular-devkit/architect';
import type { ServerBuilderOutput } from '@angular-devkit/build-angular';
import {
joinPathFragments,
normalizePath,
targetToTargetString,
} from '@nx/devkit';
import { existsSync } from 'fs';
import { relative } from 'path';
import { Observable, from } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { assertBuilderPackageIsInstalled } from '../../executors/utilities/builder-package';
import { createTmpTsConfigForBuildableLibs } from '../utilities/buildable-libs';
import { mergeCustomWebpackConfig } from '../utilities/webpack';
import { Schema } from './schema';
// This is required to ensure that the webpack version used by the Module Federation is the same as the one used by the builders.
const Module = require('module');
const originalResolveFilename = Module._resolveFilename;
const patchedWebpackPath = require.resolve('webpack', {
paths: [require.resolve('@angular-devkit/build-angular')],
});
// Override the resolve function
Module._resolveFilename = function (request, parent, isMain, options) {
// Intercept webpack specifically
if (request === 'webpack') {
// Force webpack to resolve from your specific path
return patchedWebpackPath;
}
// For all other modules, use the original resolver
return originalResolveFilename.call(this, request, parent, isMain, options);
};
function buildServerApp(
options: Schema,
context: BuilderContext
): Observable<ServerBuilderOutput> {
assertBuilderPackageIsInstalled('@angular-devkit/build-angular');
const { buildLibsFromSource, customWebpackConfig, ...delegateOptions } =
options;
// If there is a path to custom webpack config
// Invoke our own support for custom webpack config
if (customWebpackConfig && customWebpackConfig.path) {
const pathToWebpackConfig = joinPathFragments(
context.workspaceRoot,
customWebpackConfig.path
);
if (existsSync(pathToWebpackConfig)) {
return buildServerAppWithCustomWebpackConfiguration(
delegateOptions,
context,
pathToWebpackConfig
);
} else {
throw new Error(
`Custom Webpack Config File Not Found!\nTo use a custom webpack config, please ensure the path to the custom webpack file is correct: \n${pathToWebpackConfig}`
);
}
}
return from(import('@angular-devkit/build-angular')).pipe(
switchMap(({ executeServerBuilder }) =>
executeServerBuilder(delegateOptions, context)
)
);
}
function buildServerAppWithCustomWebpackConfiguration(
options: Schema,
context: BuilderContext,
pathToWebpackConfig: string
) {
return from(import('@angular-devkit/build-angular')).pipe(
switchMap(({ executeServerBuilder }) =>
executeServerBuilder(options, context as any, {
webpackConfiguration: async (baseWebpackConfig) => {
// Angular auto includes code from @angular/platform-server
// This includes the code outside the shared scope created by ModuleFederation
// This code will be included in the generated code from our generators,
// maintaining it within the shared scope.
// Therefore, if the build is an MF Server build, remove the auto-includes from
// the base webpack config from Angular
let mergedConfig = await mergeCustomWebpackConfig(
baseWebpackConfig,
pathToWebpackConfig,
options,
context.target
);
if (mergedConfig.target === 'async-node') {
mergedConfig.entry.main = mergedConfig.entry.main.filter(
(m) => !m.startsWith('@angular/platform-server/init')
);
mergedConfig.module.rules = mergedConfig.module.rules.filter((m) =>
!m.loader
? true
: !m.loader.endsWith(
'@angular-devkit/build-angular/src/builders/server/platform-server-exports-loader.js'
)
);
}
return mergedConfig;
},
})
)
);
}
export function executeWebpackServerBuilder(
options: Schema,
context: BuilderContext
): Observable<ServerBuilderOutput> {
options.buildLibsFromSource ??= true;
process.env.NX_BUILD_LIBS_FROM_SOURCE = `${options.buildLibsFromSource}`;
process.env.NX_BUILD_TARGET = targetToTargetString({ ...context.target });
if (!options.buildLibsFromSource) {
const { tsConfigPath } = createTmpTsConfigForBuildableLibs(
options.tsConfig,
context
);
options.tsConfig = normalizePath(
relative(context.workspaceRoot, tsConfigPath)
);
}
return buildServerApp(options, context);
}
export default require('@angular-devkit/architect').createBuilder(
executeWebpackServerBuilder
) as any;
@@ -0,0 +1,60 @@
import type { BuilderOutput } from '@angular-devkit/architect';
import type { ExecutorContext } from '@nx/devkit';
import type { DependentBuildableProjectNode } from '@nx/js/internal';
import { createBuilderContext } from 'nx/src/adapter/ngcli-adapter';
import { createTmpTsConfigForBuildableLibs } from '../utilities/buildable-libs';
import { assertBuilderPackageIsInstalled } from '../utilities/builder-package';
import {
loadIndexHtmlTransformer,
loadPlugins,
} from '../utilities/esbuild-extensions';
import type { ApplicationExecutorOptions } from './schema';
import { normalizeOptions } from './utils/normalize-options';
import { validateOptions } from './utils/validate-options';
export default async function* applicationExecutor(
options: ApplicationExecutorOptions,
context: ExecutorContext
): AsyncIterable<BuilderOutput> {
validateOptions(options);
const {
buildLibsFromSource = true,
plugins: pluginPaths,
indexHtmlTransformer: indexHtmlTransformerPath,
...delegateExecutorOptions
} = normalizeOptions(options);
let dependencies: DependentBuildableProjectNode[];
if (!buildLibsFromSource) {
const { tsConfigPath, dependencies: foundDependencies } =
createTmpTsConfigForBuildableLibs(
delegateExecutorOptions.tsConfig,
context
);
dependencies = foundDependencies;
delegateExecutorOptions.tsConfig = tsConfigPath;
}
const plugins = await loadPlugins(pluginPaths, options.tsConfig);
const indexHtmlTransformer = indexHtmlTransformerPath
? await loadIndexHtmlTransformer(indexHtmlTransformerPath, options.tsConfig)
: undefined;
const builderContext = await createBuilderContext(
{
builderName: '@nx/angular:application',
description: 'Build an application.',
optionSchema: require('./schema.json'),
},
context
);
assertBuilderPackageIsInstalled('@angular/build');
const { buildApplication } = await import('@angular/build');
return yield* buildApplication(delegateExecutorOptions, builderContext, {
codePlugins: plugins,
indexHtmlTransformer,
});
}
@@ -0,0 +1,8 @@
import type { ApplicationBuilderOptions } from '@angular/build';
import type { PluginSpec } from '../utilities/esbuild-extensions';
export interface ApplicationExecutorOptions extends ApplicationBuilderOptions {
buildLibsFromSource?: boolean;
indexHtmlTransformer?: string;
plugins?: string[] | PluginSpec[];
}
@@ -0,0 +1,782 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"title": "Schema for Nx Application Executor",
"description": "Builds an Angular application using [esbuild](https://esbuild.github.io/) with integrated SSR and prerendering capabilities.",
"examplesFile": "../../../docs/application-executor-examples.md",
"outputCapture": "direct-nodejs",
"type": "object",
"properties": {
"assets": {
"type": "array",
"description": "List of static application assets.",
"default": [],
"items": {
"$ref": "#/definitions/assetPattern"
}
},
"browser": {
"type": "string",
"description": "The full path for the browser entry point to the application, relative to the current workspace."
},
"server": {
"description": "The full path for the server entry point to the application, relative to the current workspace.",
"oneOf": [
{
"type": "string",
"description": "The full path for the server entry point to the application, relative to the current workspace."
},
{
"const": false,
"type": "boolean",
"description": "Indicates that a server entry point is not provided."
}
]
},
"polyfills": {
"description": "A list of polyfills to include in the build. Can be a full path for a file, relative to the current workspace or module specifier. Example: 'zone.js'.",
"type": "array",
"items": {
"type": "string",
"uniqueItems": true
},
"default": []
},
"tsConfig": {
"type": "string",
"description": "The full path for the TypeScript configuration file, relative to the current workspace."
},
"deployUrl": {
"type": "string",
"description": "Customize the base path for the URLs of resources in 'index.html' and component stylesheets. This option is only necessary for specific deployment scenarios, such as with Angular Elements or when utilizing different CDN locations."
},
"security": {
"description": "Security features to protect against XSS and other common attacks",
"type": "object",
"additionalProperties": false,
"properties": {
"allowedHosts": {
"description": "A list of hostnames that are allowed to access the server-side application. For more information, see https://angular.dev/best-practices/security#preventing-server-side-request-forgery-ssrf. _Note: this is only supported in Angular versions >= 21.2.0_.",
"type": "array",
"uniqueItems": true,
"items": {
"type": "string"
}
},
"autoCsp": {
"description": "Enables automatic generation of a hash-based Strict Content Security Policy (https://web.dev/articles/strict-csp#choose-hash) based on scripts in index.html. Will default to true once we are out of experimental/preview phases.",
"default": false,
"oneOf": [
{
"type": "object",
"properties": {
"unsafeEval": {
"type": "boolean",
"description": "Include the `unsafe-eval` directive (https://web.dev/articles/strict-csp#remove-eval) in the auto-CSP. Please only enable this if you are absolutely sure that you need to, as allowing calls to eval will weaken the XSS defenses provided by the auto-CSP.",
"default": false
}
},
"additionalProperties": false
},
{
"type": "boolean"
}
]
}
}
},
"scripts": {
"description": "Global scripts to be included in the build.",
"type": "array",
"default": [],
"items": {
"oneOf": [
{
"type": "object",
"properties": {
"input": {
"type": "string",
"description": "The file to include.",
"pattern": "\\.[cm]?jsx?$"
},
"bundleName": {
"type": "string",
"pattern": "^[\\w\\-.]*$",
"description": "The bundle name for this extra entry point."
},
"inject": {
"type": "boolean",
"description": "If the bundle will be referenced in the HTML file.",
"default": true
}
},
"additionalProperties": false,
"required": ["input"]
},
{
"type": "string",
"description": "The JavaScript/TypeScript file or package containing the file to include."
}
]
}
},
"styles": {
"description": "Global styles to be included in the build.",
"type": "array",
"default": [],
"items": {
"oneOf": [
{
"type": "object",
"properties": {
"input": {
"type": "string",
"description": "The file to include.",
"pattern": "\\.(?:css|scss|sass|less)$"
},
"bundleName": {
"type": "string",
"pattern": "^[\\w\\-.]*$",
"description": "The bundle name for this extra entry point."
},
"inject": {
"type": "boolean",
"description": "If the bundle will be referenced in the HTML file.",
"default": true
}
},
"additionalProperties": false,
"required": ["input"]
},
{
"type": "string",
"description": "The file to include.",
"pattern": "\\.(?:css|scss|sass|less)$"
}
]
}
},
"inlineStyleLanguage": {
"description": "The stylesheet language to use for the application's inline component styles.",
"type": "string",
"default": "css",
"enum": ["css", "less", "sass", "scss"]
},
"stylePreprocessorOptions": {
"description": "Options to pass to style preprocessors.",
"type": "object",
"properties": {
"includePaths": {
"description": "Paths to include. Paths will be resolved to workspace root.",
"type": "array",
"items": {
"type": "string"
},
"default": []
},
"sass": {
"description": "Options to pass to the sass preprocessor.",
"type": "object",
"properties": {
"fatalDeprecations": {
"description": "A set of deprecations to treat as fatal. If a deprecation warning of any provided type is encountered during compilation, the compiler will error instead. If a Version is provided, then all deprecations that were active in that compiler version will be treated as fatal.",
"type": "array",
"items": {
"type": "string"
}
},
"silenceDeprecations": {
"description": " A set of active deprecations to ignore. If a deprecation warning of any provided type is encountered during compilation, the compiler will ignore it instead.",
"type": "array",
"items": {
"type": "string"
}
},
"futureDeprecations": {
"description": "A set of future deprecations to opt into early. Future deprecations passed here will be treated as active by the compiler, emitting warnings as necessary.",
"type": "array",
"items": {
"type": "string"
}
}
},
"additionalProperties": false
}
},
"additionalProperties": false
},
"externalDependencies": {
"description": "Exclude the listed external dependencies from being bundled into the bundle. Instead, the created bundle relies on these dependencies to be available during runtime.",
"type": "array",
"items": {
"type": "string"
},
"default": []
},
"clearScreen": {
"type": "boolean",
"default": false,
"description": "Automatically clear the terminal screen during rebuilds."
},
"optimization": {
"description": "Enables optimization of the build output. Including minification of scripts and styles, tree-shaking, dead-code elimination, inlining of critical CSS and fonts inlining. For more information, see https://angular.dev/reference/configs/workspace-config#optimization-configuration.",
"default": true,
"oneOf": [
{
"type": "object",
"properties": {
"scripts": {
"type": "boolean",
"description": "Enables optimization of the scripts output.",
"default": true
},
"styles": {
"description": "Enables optimization of the styles output.",
"default": true,
"oneOf": [
{
"type": "object",
"properties": {
"minify": {
"type": "boolean",
"description": "Minify CSS definitions by removing extraneous whitespace and comments, merging identifiers and minimizing values.",
"default": true
},
"inlineCritical": {
"type": "boolean",
"description": "Extract and inline critical CSS definitions to improve first paint time.",
"default": true
},
"removeSpecialComments": {
"type": "boolean",
"description": "Remove comments in global CSS that contains '@license' or '@preserve' or that starts with '//!' or '/*!'.",
"default": true
}
},
"additionalProperties": false
},
{
"type": "boolean"
}
]
},
"fonts": {
"description": "Enables optimization for fonts. This option requires internet access. `HTTPS_PROXY` environment variable can be used to specify a proxy server.",
"default": true,
"oneOf": [
{
"type": "object",
"properties": {
"inline": {
"type": "boolean",
"description": "Reduce render blocking requests by inlining external Google Fonts and Adobe Fonts CSS definitions in the application's HTML index file. This option requires internet access. `HTTPS_PROXY` environment variable can be used to specify a proxy server.",
"default": true
}
},
"additionalProperties": false
},
{
"type": "boolean"
}
]
}
},
"additionalProperties": false
},
{
"type": "boolean"
}
]
},
"loader": {
"description": "Defines the type of loader to use with a specified file extension when used with a JavaScript `import`. `text` inlines the content as a string; `binary` inlines the content as a Uint8Array; `file` emits the file and provides the runtime location of the file; `dataurl` inlines the content as a data URL with best guess of MIME type; `base64` inlines the content as a Base64-encoded string; `empty` considers the content to be empty and not include it in bundles. _Note: `dataurl` and `base64` are only supported in Angular versions >= 20.1.0_.",
"type": "object",
"patternProperties": {
"^\\.\\S+$": {
"enum": ["text", "binary", "file", "dataurl", "base64", "empty"]
}
}
},
"define": {
"description": "Defines global identifiers that will be replaced with a specified constant value when found in any JavaScript or TypeScript code including libraries. The value will be used directly. String values must be put in quotes. Identifiers within Angular metadata such as Component Decorators will not be replaced.",
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"conditions": {
"description": "Custom package resolution conditions used to resolve conditional exports/imports. Defaults to ['module', 'development'/'production']. The following special conditions are always present if the requirements are satisfied: 'default', 'import', 'require', 'browser', 'node'.",
"type": "array",
"items": {
"type": "string"
}
},
"fileReplacements": {
"description": "Replace compilation source files with other compilation source files in the build.",
"type": "array",
"items": {
"$ref": "#/definitions/fileReplacement"
},
"default": []
},
"outputPath": {
"description": "Specify the output path relative to workspace root.",
"oneOf": [
{
"type": "object",
"properties": {
"base": {
"type": "string",
"description": "Specify the output path relative to workspace root."
},
"browser": {
"type": "string",
"pattern": "^[-\\w\\.]*$",
"default": "browser",
"description": "The output directory name of your browser build within the output path base. Defaults to 'browser'."
},
"server": {
"type": "string",
"pattern": "^[-\\w\\.]*$",
"default": "server",
"description": "The output directory name of your server build within the output path base. Defaults to 'server'."
},
"media": {
"type": "string",
"pattern": "^[-\\w\\.]+$",
"default": "media",
"description": "The output directory name of your media files within the output browser directory. Defaults to 'media'."
}
},
"required": ["base"],
"additionalProperties": false
},
{
"type": "string"
}
]
},
"aot": {
"type": "boolean",
"description": "Build using Ahead of Time compilation.",
"default": true
},
"sourceMap": {
"description": "Output source maps for scripts and styles. For more information, see https://angular.dev/reference/configs/workspace-config#source-map-configuration.",
"default": false,
"oneOf": [
{
"type": "object",
"properties": {
"scripts": {
"type": "boolean",
"description": "Output source maps for all scripts.",
"default": true
},
"styles": {
"type": "boolean",
"description": "Output source maps for all styles.",
"default": true
},
"hidden": {
"type": "boolean",
"description": "Output source maps used for error reporting tools.",
"default": false
},
"vendor": {
"type": "boolean",
"description": "Resolve vendor packages source maps.",
"default": false
},
"sourcesContent": {
"type": "boolean",
"description": "Output original source content for files within the source map.",
"default": true
}
},
"additionalProperties": false
},
{
"type": "boolean"
}
]
},
"baseHref": {
"type": "string",
"description": "Base url for the application being built."
},
"verbose": {
"type": "boolean",
"description": "Adds more details to output logging.",
"default": false
},
"progress": {
"type": "boolean",
"description": "Log progress to the console while building.",
"default": true
},
"i18nMissingTranslation": {
"type": "string",
"description": "How to handle missing translations for i18n.",
"enum": ["warning", "error", "ignore"],
"default": "warning"
},
"i18nDuplicateTranslation": {
"type": "string",
"description": "How to handle duplicate translations for i18n.",
"enum": ["warning", "error", "ignore"],
"default": "warning"
},
"localize": {
"description": "Translate the bundles in one or more locales.",
"oneOf": [
{
"type": "boolean",
"description": "Translate all locales."
},
{
"type": "array",
"description": "List of locales ID's to translate.",
"minItems": 1,
"items": {
"type": "string",
"pattern": "^[a-zA-Z]{2,3}(-[a-zA-Z]{4})?(-([a-zA-Z]{2}|[0-9]{3}))?(-[a-zA-Z]{5,8})?(-x(-[a-zA-Z0-9]{1,8})+)?$"
}
}
]
},
"watch": {
"type": "boolean",
"description": "Run build when files change.",
"default": false
},
"outputHashing": {
"type": "string",
"description": "Define the output filename cache-busting hashing mode.",
"default": "none",
"enum": ["none", "all", "media", "bundles"]
},
"poll": {
"type": "number",
"description": "Enable and define the file watching poll time period in milliseconds."
},
"deleteOutputPath": {
"type": "boolean",
"description": "Delete the output path before building.",
"default": true
},
"preserveSymlinks": {
"type": "boolean",
"description": "Do not use the real path when resolving modules. If unset then will default to `true` if NodeJS option --preserve-symlinks is set."
},
"extractLicenses": {
"type": "boolean",
"description": "Extract all licenses in a separate file.",
"default": true
},
"namedChunks": {
"type": "boolean",
"description": "Use file name for lazy loaded chunks.",
"default": false
},
"subresourceIntegrity": {
"type": "boolean",
"description": "Enables the use of subresource integrity validation.",
"default": false
},
"serviceWorker": {
"description": "Generates a service worker configuration.",
"default": false,
"oneOf": [
{
"type": "string",
"description": "Path to ngsw-config.json."
},
{
"const": false,
"type": "boolean",
"description": "Does not generate a service worker configuration."
}
]
},
"index": {
"description": "Configures the generation of the application's HTML index.",
"oneOf": [
{
"type": "string",
"description": "The path of a file to use for the application's HTML index. The filename of the specified path will be used for the generated file and will be created in the root of the application's configured output path."
},
{
"type": "object",
"description": "",
"properties": {
"input": {
"type": "string",
"minLength": 1,
"description": "The path of a file to use for the application's generated HTML index."
},
"output": {
"type": "string",
"minLength": 1,
"default": "index.html",
"description": "The output path of the application's generated HTML index file. The full provided path will be used and will be considered relative to the application's configured output path."
},
"preloadInitial": {
"type": "boolean",
"default": true,
"description": "Generates 'preload', 'modulepreload', and 'preconnect' link elements for initial application files and resources."
}
},
"required": ["input"]
},
{
"const": false,
"type": "boolean",
"description": "Does not generate an `index.html` file."
}
]
},
"statsJson": {
"type": "boolean",
"description": "Generates a 'stats.json' file which can be analyzed with https://esbuild.github.io/analyze/.",
"default": false
},
"budgets": {
"description": "Budget thresholds to ensure parts of your application stay within boundaries which you set.",
"type": "array",
"items": {
"$ref": "#/definitions/budget"
},
"default": []
},
"webWorkerTsConfig": {
"type": "string",
"description": "TypeScript configuration for Web Worker modules."
},
"crossOrigin": {
"type": "string",
"description": "Define the crossorigin attribute setting of elements that provide CORS support.",
"default": "none",
"enum": ["none", "anonymous", "use-credentials"]
},
"allowedCommonJsDependencies": {
"description": "A list of CommonJS or AMD packages that are allowed to be used without a build time warning. Use `'*'` to allow all.",
"type": "array",
"items": {
"type": "string"
},
"default": []
},
"prerender": {
"description": "Prerender (SSG) pages of your application during build time.",
"oneOf": [
{
"type": "boolean",
"description": "Enable prerending of pages of your application during build time."
},
{
"type": "object",
"properties": {
"routesFile": {
"type": "string",
"description": "The path to a file that contains a list of all routes to prerender, separated by newlines. This option is useful if you want to prerender routes with parameterized URLs."
},
"discoverRoutes": {
"type": "boolean",
"description": "Whether the builder should process the Angular Router configuration to find all unparameterized routes and prerender them.",
"default": true
}
},
"additionalProperties": false
}
]
},
"ssr": {
"description": "Server side render (SSR) pages of your application during runtime.",
"default": false,
"oneOf": [
{
"type": "boolean",
"description": "Enable the server bundles to be written to disk."
},
{
"type": "object",
"properties": {
"entry": {
"type": "string",
"description": "The server entry-point that when executed will spawn the web server."
},
"platform": {
"description": "Specifies the platform for which the server bundle is generated. This affects the APIs and modules available in the server-side code. \n\n- `node`: (Default) Generates a bundle optimized for Node.js environments. \n- `neutral`: Generates a platform-neutral bundle suitable for environments like edge workers, and other serverless platforms. This option avoids using Node.js-specific APIs, making the bundle more portable. \n\nPlease note that this feature does not provide polyfills for Node.js modules.",
"default": "node",
"enum": ["node", "neutral"]
},
"experimentalPlatform": {
"description": "Specifies the platform for which the server bundle is generated. This affects the APIs and modules available in the server-side code. \n\n- `node`: (Default) Generates a bundle optimized for Node.js environments. \n- `neutral`: Generates a platform-neutral bundle suitable for environments like edge workers, and other serverless platforms. This option avoids using Node.js-specific APIs, making the bundle more portable. \n\nPlease note that this feature does not provide polyfills for Node.js modules. Additionally, it is experimental, and the feature may undergo changes in future versions.",
"default": "node",
"enum": ["node", "neutral"],
"x-deprecated": "Use 'platform' instead."
}
},
"additionalProperties": false
}
]
},
"appShell": {
"type": "boolean",
"description": "Generates an application shell during build time."
},
"outputMode": {
"type": "string",
"description": "Defines the build output target. 'static': Generates a static site for deployment on any static hosting service. 'server': Produces an application designed for deployment on a server that supports server-side rendering (SSR).",
"enum": ["static", "server"]
},
"buildLibsFromSource": {
"type": "boolean",
"description": "Read buildable libraries from source instead of building them separately.",
"default": true
},
"plugins": {
"description": "A list of ESBuild plugins.",
"type": "array",
"items": {
"oneOf": [
{
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "The path to the plugin. Relative to the workspace root."
},
"options": {
"type": "object",
"description": "The options to provide to the plugin.",
"properties": {},
"additionalProperties": true
}
},
"additionalProperties": false,
"required": ["path"]
},
{
"type": "string",
"description": "The path to the plugin. Relative to the workspace root."
}
]
}
},
"indexHtmlTransformer": {
"description": "Path to a file exposing a default function to transform the `index.html` file.",
"type": "string"
}
},
"additionalProperties": false,
"required": ["outputPath", "tsConfig"],
"definitions": {
"assetPattern": {
"oneOf": [
{
"type": "object",
"properties": {
"followSymlinks": {
"type": "boolean",
"default": false,
"description": "Allow glob patterns to follow symlink directories. This allows subdirectories of the symlink to be searched."
},
"glob": {
"type": "string",
"description": "The pattern to match."
},
"input": {
"type": "string",
"description": "The input directory path in which to apply 'glob'. Defaults to the project root."
},
"ignore": {
"description": "An array of globs to ignore.",
"type": "array",
"items": {
"type": "string"
}
},
"output": {
"type": "string",
"default": "",
"description": "Absolute path within the output."
}
},
"additionalProperties": false,
"required": ["glob", "input"]
},
{
"type": "string"
}
]
},
"fileReplacement": {
"type": "object",
"properties": {
"replace": {
"type": "string",
"pattern": "\\.(([cm]?[jt])sx?|json)$"
},
"with": {
"type": "string",
"pattern": "\\.(([cm]?[jt])sx?|json)$"
}
},
"additionalProperties": false,
"required": ["replace", "with"]
},
"budget": {
"type": "object",
"properties": {
"type": {
"type": "string",
"description": "The type of budget.",
"enum": [
"all",
"allScript",
"any",
"anyScript",
"anyComponentStyle",
"bundle",
"initial"
]
},
"name": {
"type": "string",
"description": "The name of the bundle."
},
"baseline": {
"type": "string",
"description": "The baseline size for comparison."
},
"maximumWarning": {
"type": "string",
"description": "The maximum threshold for warning relative to the baseline."
},
"maximumError": {
"type": "string",
"description": "The maximum threshold for error relative to the baseline."
},
"minimumWarning": {
"type": "string",
"description": "The minimum threshold for warning relative to the baseline."
},
"minimumError": {
"type": "string",
"description": "The minimum threshold for error relative to the baseline."
},
"warning": {
"type": "string",
"description": "The threshold for warning relative to the baseline (min & max)."
},
"error": {
"type": "string",
"description": "The threshold for error relative to the baseline (min & max)."
}
},
"additionalProperties": false,
"required": ["type"]
}
}
}
@@ -0,0 +1,133 @@
import * as angularVersionUtils from '../../utilities/angular-version-utils';
import type { ApplicationExecutorOptions } from '../schema';
import { normalizeOptions } from './normalize-options';
// The builder's `ssr` type is version-specific (v22 only knows `platform`,
// earlier majors only `experimentalPlatform`), so the helper widens to read and
// return both, matching what `normalizeOptions` accepts and bridges.
type SsrPlatformInput =
| boolean
| {
entry?: string;
platform?: 'node' | 'neutral';
experimentalPlatform?: 'node' | 'neutral';
};
function normalizeSsr(ssr: SsrPlatformInput): SsrPlatformInput {
return normalizeOptions({ ssr } as ApplicationExecutorOptions)
.ssr as SsrPlatformInput;
}
describe('normalizeOptions', () => {
let getInstalledAngularVersionInfoSpy: jest.SpyInstance;
beforeEach(() => {
getInstalledAngularVersionInfoSpy = jest.spyOn(
angularVersionUtils,
'getInstalledAngularVersionInfo'
);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('should preserve other options untouched', () => {
getInstalledAngularVersionInfoSpy.mockReturnValue({
major: 22,
version: '22.0.0',
});
const result = normalizeOptions({
tsConfig: 'tsconfig.app.json',
ssr: { entry: 'server.ts', experimentalPlatform: 'neutral' },
} as ApplicationExecutorOptions);
expect(result.tsConfig).toBe('tsconfig.app.json');
expect(result.ssr).toEqual({ entry: 'server.ts', platform: 'neutral' });
});
it.each([undefined, false, true])(
'should leave non-object ssr (%s) unchanged',
(ssr) => {
getInstalledAngularVersionInfoSpy.mockReturnValue({
major: 22,
version: '22.0.0',
});
expect(normalizeSsr(ssr as ApplicationExecutorOptions['ssr'])).toBe(ssr);
}
);
it('should leave the ssr object unchanged when no platform is set', () => {
getInstalledAngularVersionInfoSpy.mockReturnValue({
major: 22,
version: '22.0.0',
});
const ssr = { entry: 'server.ts' };
expect(normalizeSsr(ssr)).toBe(ssr);
});
describe('when Angular version is >= 22', () => {
beforeEach(() => {
getInstalledAngularVersionInfoSpy.mockReturnValue({
major: 22,
version: '22.0.0',
});
});
it('should map experimentalPlatform to platform', () => {
expect(
normalizeSsr({ entry: 'server.ts', experimentalPlatform: 'neutral' })
).toEqual({ entry: 'server.ts', platform: 'neutral' });
});
it('should keep platform as-is', () => {
expect(normalizeSsr({ entry: 'server.ts', platform: 'neutral' })).toEqual(
{ entry: 'server.ts', platform: 'neutral' }
);
});
it('should prefer platform over experimentalPlatform when both are set', () => {
expect(
normalizeSsr({
entry: 'server.ts',
platform: 'neutral',
experimentalPlatform: 'node',
})
).toEqual({ entry: 'server.ts', platform: 'neutral' });
});
});
describe('when Angular version is < 22', () => {
beforeEach(() => {
getInstalledAngularVersionInfoSpy.mockReturnValue({
major: 21,
version: '21.2.0',
});
});
it('should map platform to experimentalPlatform', () => {
expect(normalizeSsr({ entry: 'server.ts', platform: 'neutral' })).toEqual(
{ entry: 'server.ts', experimentalPlatform: 'neutral' }
);
});
it('should keep experimentalPlatform as-is', () => {
expect(
normalizeSsr({ entry: 'server.ts', experimentalPlatform: 'neutral' })
).toEqual({ entry: 'server.ts', experimentalPlatform: 'neutral' });
});
it('should prefer platform over experimentalPlatform when both are set', () => {
expect(
normalizeSsr({
entry: 'server.ts',
platform: 'neutral',
experimentalPlatform: 'node',
})
).toEqual({ entry: 'server.ts', experimentalPlatform: 'neutral' });
});
});
});
@@ -0,0 +1,46 @@
import { getInstalledAngularVersionInfo } from '../../utilities/angular-version-utils';
import type { ApplicationExecutorOptions } from '../schema';
type SsrOption = ApplicationExecutorOptions['ssr'];
// The `platform`/`experimentalPlatform` keys are version-specific in the builder
// types (v22 only knows `platform`, earlier majors only `experimentalPlatform`),
// so the input is widened to read both and the result is cast back.
type SsrPlatformInput =
| boolean
| {
entry?: string;
platform?: 'node' | 'neutral';
experimentalPlatform?: 'node' | 'neutral';
};
export function normalizeOptions(
options: ApplicationExecutorOptions
): ApplicationExecutorOptions {
return { ...options, ssr: coerceSsrPlatform(options.ssr) };
}
// `platform` is the option name from Angular v22 onwards; earlier majors use
// `experimentalPlatform`. We accept both and forward the one the installed
// builder understands so a `neutral` value is never silently dropped.
function coerceSsrPlatform(ssr: SsrPlatformInput | undefined): SsrOption {
if (!ssr || typeof ssr !== 'object') {
return ssr as SsrOption;
}
const { platform, experimentalPlatform, ...rest } = ssr;
const resolvedPlatform = platform ?? experimentalPlatform;
if (resolvedPlatform === undefined) {
return ssr as SsrOption;
}
// `validateOptions` runs first and asserts Angular is installed, so the
// version info is always present here.
const { major: angularMajorVersion } = getInstalledAngularVersionInfo();
return (
angularMajorVersion >= 22
? { ...rest, platform: resolvedPlatform }
: { ...rest, experimentalPlatform: resolvedPlatform }
) as SsrOption;
}
@@ -0,0 +1,37 @@
import { lt } from 'semver';
import { getInstalledAngularVersionInfo } from '../../utilities/angular-version-utils';
import type { ApplicationExecutorOptions } from '../schema';
export function validateOptions(options: ApplicationExecutorOptions): void {
const { version: angularVersion } = getInstalledAngularVersionInfo();
if (lt(angularVersion, '21.2.0')) {
if (options.security?.allowedHosts) {
throw new Error(
`The "security.allowedHosts" option requires Angular version 21.2.0 or greater. You are currently using version ${angularVersion}.`
);
}
}
if (lt(angularVersion, '20.1.0')) {
if (options.loader) {
const invalidLoaders = Array.from(
new Set(
Object.values(options.loader).filter(
(l) => l === 'dataurl' || l === 'base64'
)
)
);
if (invalidLoaders.length) {
throw new Error(
`Using the ${invalidLoaders
.map((l) => `"${l}"`)
.join(' and ')} loader${
invalidLoaders.length > 1 ? 's' : ''
} requires Angular version 20.1.0 or greater. You are currently using version ${angularVersion}.`
);
}
}
}
}
@@ -0,0 +1,54 @@
import type { buildEsbuildBrowser as buildEsbuildBrowserFn } from '@angular-devkit/build-angular/src/builders/browser-esbuild';
import type { ExecutorContext } from '@nx/devkit';
import type { DependentBuildableProjectNode } from '@nx/js/internal';
import { createBuilderContext } from 'nx/src/adapter/ngcli-adapter';
import { createTmpTsConfigForBuildableLibs } from '../utilities/buildable-libs';
import { loadPlugins } from '../utilities/esbuild-extensions';
import type { EsBuildSchema } from './schema';
export default async function* esbuildExecutor(
options: EsBuildSchema,
context: ExecutorContext
): ReturnType<typeof buildEsbuildBrowserFn> {
options.buildLibsFromSource ??= true;
const {
buildLibsFromSource,
plugins: pluginPaths,
...delegateExecutorOptions
} = options;
let dependencies: DependentBuildableProjectNode[];
if (!buildLibsFromSource) {
const { tsConfigPath, dependencies: foundDependencies } =
createTmpTsConfigForBuildableLibs(
delegateExecutorOptions.tsConfig,
context
);
dependencies = foundDependencies;
delegateExecutorOptions.tsConfig = tsConfigPath;
}
const plugins = await loadPlugins(pluginPaths, options.tsConfig);
const { buildEsbuildBrowser } = <
typeof import('@angular-devkit/build-angular/src/builders/browser-esbuild')
>require('@angular-devkit/build-angular/src/builders/browser-esbuild');
const builderContext = await createBuilderContext(
{
builderName: '@nx/angular:browser-esbuild',
description: 'Build a browser application',
optionSchema: require('@angular-devkit/build-angular/src/builders/browser-esbuild/schema.json'),
},
context
);
return yield* buildEsbuildBrowser(
delegateExecutorOptions,
builderContext,
/* infrastructureSettings */ undefined,
plugins
);
}
@@ -0,0 +1,7 @@
import type { Schema } from '@angular-devkit/build-angular/src/builders/browser-esbuild/schema';
import type { PluginSpec } from '../utilities/esbuild-extensions';
export interface EsBuildSchema extends Schema {
buildLibsFromSource?: boolean;
plugins?: string[] | PluginSpec[];
}
@@ -0,0 +1,583 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"title": "Schema for Nx ESBuild Executor",
"description": "Builds an Angular application using [esbuild](https://esbuild.github.io/).",
"examplesFile": "../../../docs/browser-esbuild-examples.md",
"outputCapture": "direct-nodejs",
"type": "object",
"properties": {
"assets": {
"type": "array",
"description": "List of static application assets.",
"default": [],
"items": {
"$ref": "#/definitions/assetPattern"
}
},
"main": {
"type": "string",
"description": "The full path for the main entry point to the app, relative to the current workspace."
},
"polyfills": {
"description": "Polyfills to be included in the build.",
"oneOf": [
{
"type": "array",
"description": "A list of polyfills to include in the build. Can be a full path for a file, relative to the current workspace or module specifier. Example: 'zone.js'.",
"items": {
"type": "string",
"uniqueItems": true
},
"default": []
},
{
"type": "string",
"description": "The full path for the polyfills file, relative to the current workspace or a module specifier. Example: 'zone.js'."
}
]
},
"tsConfig": {
"type": "string",
"description": "The full path for the TypeScript configuration file, relative to the current workspace."
},
"scripts": {
"description": "Global scripts to be included in the build.",
"type": "array",
"default": [],
"items": {
"oneOf": [
{
"type": "object",
"properties": {
"input": {
"type": "string",
"description": "The file to include.",
"pattern": "\\.[cm]?jsx?$"
},
"bundleName": {
"type": "string",
"pattern": "^[\\w\\-.]*$",
"description": "The bundle name for this extra entry point."
},
"inject": {
"type": "boolean",
"description": "If the bundle will be referenced in the HTML file.",
"default": true
}
},
"additionalProperties": false,
"required": ["input"]
},
{
"type": "string",
"description": "The JavaScript/TypeScript file or package containing the file to include."
}
]
}
},
"styles": {
"description": "Global styles to be included in the build.",
"type": "array",
"default": [],
"items": {
"oneOf": [
{
"type": "object",
"properties": {
"input": {
"type": "string",
"description": "The file to include.",
"pattern": "\\.(?:css|scss|sass|less)$"
},
"bundleName": {
"type": "string",
"pattern": "^[\\w\\-.]*$",
"description": "The bundle name for this extra entry point."
},
"inject": {
"type": "boolean",
"description": "If the bundle will be referenced in the HTML file.",
"default": true
}
},
"additionalProperties": false,
"required": ["input"]
},
{
"type": "string",
"description": "The file to include.",
"pattern": "\\.(?:css|scss|sass|less)$"
}
]
}
},
"inlineStyleLanguage": {
"description": "The stylesheet language to use for the application's inline component styles.",
"type": "string",
"default": "css",
"enum": ["css", "less", "sass", "scss"]
},
"stylePreprocessorOptions": {
"description": "Options to pass to style preprocessors.",
"type": "object",
"properties": {
"includePaths": {
"description": "Paths to include. Paths will be resolved to workspace root.",
"type": "array",
"items": {
"type": "string"
},
"default": []
}
},
"additionalProperties": false
},
"externalDependencies": {
"description": "Exclude the listed external dependencies from being bundled into the bundle. Instead, the created bundle relies on these dependencies to be available during runtime.",
"type": "array",
"items": {
"type": "string"
},
"default": []
},
"optimization": {
"description": "Enables optimization of the build output. Including minification of scripts and styles, tree-shaking, dead-code elimination, inlining of critical CSS and fonts inlining. For more information, see https://angular.dev/reference/configs/workspace-config#optimization-configuration.",
"default": true,
"oneOf": [
{
"type": "object",
"properties": {
"scripts": {
"type": "boolean",
"description": "Enables optimization of the scripts output.",
"default": true
},
"styles": {
"description": "Enables optimization of the styles output.",
"default": true,
"oneOf": [
{
"type": "object",
"properties": {
"minify": {
"type": "boolean",
"description": "Minify CSS definitions by removing extraneous whitespace and comments, merging identifiers and minimizing values.",
"default": true
},
"inlineCritical": {
"type": "boolean",
"description": "Extract and inline critical CSS definitions to improve first paint time.",
"default": true
}
},
"additionalProperties": false
},
{
"type": "boolean"
}
]
},
"fonts": {
"description": "Enables optimization for fonts. This option requires internet access. `HTTPS_PROXY` environment variable can be used to specify a proxy server.",
"default": true,
"oneOf": [
{
"type": "object",
"properties": {
"inline": {
"type": "boolean",
"description": "Reduce render blocking requests by inlining external Google Fonts and Adobe Fonts CSS definitions in the application's HTML index file. This option requires internet access. `HTTPS_PROXY` environment variable can be used to specify a proxy server.",
"default": true
}
},
"additionalProperties": false
},
{
"type": "boolean"
}
]
}
},
"additionalProperties": false
},
{
"type": "boolean"
}
]
},
"fileReplacements": {
"description": "Replace compilation source files with other compilation source files in the build.",
"type": "array",
"items": {
"$ref": "#/definitions/fileReplacement"
},
"default": []
},
"outputPath": {
"type": "string",
"description": "The full path for the new output directory, relative to the current workspace."
},
"resourcesOutputPath": {
"type": "string",
"description": "The path where style resources will be placed, relative to outputPath."
},
"aot": {
"type": "boolean",
"description": "Build using Ahead of Time compilation.",
"default": true
},
"sourceMap": {
"description": "Output source maps for scripts and styles. For more information, see https://angular.dev/reference/configs/workspace-config#source-map-configuration.",
"default": false,
"oneOf": [
{
"type": "object",
"properties": {
"scripts": {
"type": "boolean",
"description": "Output source maps for all scripts.",
"default": true
},
"styles": {
"type": "boolean",
"description": "Output source maps for all styles.",
"default": true
},
"hidden": {
"type": "boolean",
"description": "Output source maps used for error reporting tools.",
"default": false
},
"vendor": {
"type": "boolean",
"description": "Resolve vendor packages source maps.",
"default": false
}
},
"additionalProperties": false
},
{
"type": "boolean"
}
]
},
"vendorChunk": {
"type": "boolean",
"description": "Generate a separate bundle containing only vendor libraries. This option should only be used for development to reduce the incremental compilation time.",
"default": false
},
"commonChunk": {
"type": "boolean",
"description": "Generate a separate bundle containing code used across multiple bundles.",
"default": true
},
"baseHref": {
"type": "string",
"description": "Base url for the application being built."
},
"deployUrl": {
"type": "string",
"description": "Customize the base path for the URLs of resources in 'index.html' and component stylesheets. This option is only necessary for specific deployment scenarios, such as with Angular Elements or when utilizing different CDN locations."
},
"verbose": {
"type": "boolean",
"description": "Adds more details to output logging.",
"default": false
},
"progress": {
"type": "boolean",
"description": "Log progress to the console while building.",
"default": true
},
"i18nMissingTranslation": {
"type": "string",
"description": "How to handle missing translations for i18n.",
"enum": ["warning", "error", "ignore"],
"default": "warning"
},
"i18nDuplicateTranslation": {
"type": "string",
"description": "How to handle duplicate translations for i18n.",
"enum": ["warning", "error", "ignore"],
"default": "warning"
},
"localize": {
"description": "Translate the bundles in one or more locales.",
"oneOf": [
{
"type": "boolean",
"description": "Translate all locales."
},
{
"type": "array",
"description": "List of locales ID's to translate.",
"minItems": 1,
"items": {
"type": "string",
"pattern": "^[a-zA-Z]{2,3}(-[a-zA-Z]{4})?(-([a-zA-Z]{2}|[0-9]{3}))?(-[a-zA-Z]{5,8})?(-x(-[a-zA-Z0-9]{1,8})+)?$"
}
}
]
},
"watch": {
"type": "boolean",
"description": "Run build when files change.",
"default": false
},
"outputHashing": {
"type": "string",
"description": "Define the output filename cache-busting hashing mode.",
"default": "none",
"enum": ["none", "all", "media", "bundles"]
},
"poll": {
"type": "number",
"description": "Enable and define the file watching poll time period in milliseconds."
},
"deleteOutputPath": {
"type": "boolean",
"description": "Delete the output path before building.",
"default": true
},
"preserveSymlinks": {
"type": "boolean",
"description": "Do not use the real path when resolving modules. If unset then will default to `true` if NodeJS option --preserve-symlinks is set."
},
"extractLicenses": {
"type": "boolean",
"description": "Extract all licenses in a separate file.",
"default": true
},
"buildOptimizer": {
"type": "boolean",
"description": "Enables advanced build optimizations when using the 'aot' option.",
"default": true
},
"namedChunks": {
"type": "boolean",
"description": "Use file name for lazy loaded chunks.",
"default": false
},
"subresourceIntegrity": {
"type": "boolean",
"description": "Enables the use of subresource integrity validation.",
"default": false
},
"serviceWorker": {
"type": "boolean",
"description": "Generates a service worker config for production builds.",
"default": false
},
"ngswConfigPath": {
"type": "string",
"description": "Path to ngsw-config.json."
},
"index": {
"description": "Configures the generation of the application's HTML index.",
"oneOf": [
{
"type": "string",
"description": "The path of a file to use for the application's HTML index. The filename of the specified path will be used for the generated file and will be created in the root of the application's configured output path."
},
{
"type": "object",
"description": "",
"properties": {
"input": {
"type": "string",
"minLength": 1,
"description": "The path of a file to use for the application's generated HTML index."
},
"output": {
"type": "string",
"minLength": 1,
"default": "index.html",
"description": "The output path of the application's generated HTML index file. The full provided path will be used and will be considered relative to the application's configured output path."
}
},
"required": ["input"]
},
{
"const": false,
"type": "boolean",
"description": "Does not generate an `index.html` file."
}
]
},
"statsJson": {
"type": "boolean",
"description": "Generates a 'stats.json' file which can be analyzed using tools such as 'webpack-bundle-analyzer'.",
"default": false
},
"budgets": {
"description": "Budget thresholds to ensure parts of your application stay within boundaries which you set.",
"type": "array",
"items": {
"$ref": "#/definitions/budget"
},
"default": []
},
"webWorkerTsConfig": {
"type": "string",
"description": "TypeScript configuration for Web Worker modules."
},
"crossOrigin": {
"type": "string",
"description": "Define the crossorigin attribute setting of elements that provide CORS support.",
"default": "none",
"enum": ["none", "anonymous", "use-credentials"]
},
"allowedCommonJsDependencies": {
"description": "A list of CommonJS or AMD packages that are allowed to be used without a build time warning. Use `'*'` to allow all.",
"type": "array",
"items": {
"type": "string"
},
"default": []
},
"buildLibsFromSource": {
"type": "boolean",
"description": "Read buildable libraries from source instead of building them separately.",
"default": true
},
"plugins": {
"description": "A list of ESBuild plugins.",
"type": "array",
"items": {
"oneOf": [
{
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "The path to the plugin. Relative to the workspace root."
},
"options": {
"type": "object",
"description": "The options to provide to the plugin.",
"properties": {},
"additionalProperties": true
}
},
"additionalProperties": false,
"required": ["path"]
},
{
"type": "string",
"description": "The path to the plugin. Relative to the workspace root."
}
]
}
}
},
"additionalProperties": false,
"required": ["outputPath", "index", "main", "tsConfig"],
"definitions": {
"assetPattern": {
"oneOf": [
{
"type": "object",
"properties": {
"followSymlinks": {
"type": "boolean",
"default": false,
"description": "Allow glob patterns to follow symlink directories. This allows subdirectories of the symlink to be searched."
},
"glob": {
"type": "string",
"description": "The pattern to match."
},
"input": {
"type": "string",
"description": "The input directory path in which to apply 'glob'. Defaults to the project root."
},
"ignore": {
"description": "An array of globs to ignore.",
"type": "array",
"items": {
"type": "string"
}
},
"output": {
"type": "string",
"default": "",
"description": "Absolute path within the output."
}
},
"additionalProperties": false,
"required": ["glob", "input"]
},
{
"type": "string"
}
]
},
"fileReplacement": {
"type": "object",
"properties": {
"replace": {
"type": "string",
"pattern": "\\.(([cm]?[jt])sx?|json)$"
},
"with": {
"type": "string",
"pattern": "\\.(([cm]?[jt])sx?|json)$"
}
},
"additionalProperties": false,
"required": ["replace", "with"]
},
"budget": {
"type": "object",
"properties": {
"type": {
"type": "string",
"description": "The type of budget.",
"enum": [
"all",
"allScript",
"any",
"anyScript",
"anyComponentStyle",
"bundle",
"initial"
]
},
"name": {
"type": "string",
"description": "The name of the bundle."
},
"baseline": {
"type": "string",
"description": "The baseline size for comparison."
},
"maximumWarning": {
"type": "string",
"description": "The maximum threshold for warning relative to the baseline."
},
"maximumError": {
"type": "string",
"description": "The maximum threshold for error relative to the baseline."
},
"minimumWarning": {
"type": "string",
"description": "The minimum threshold for warning relative to the baseline."
},
"minimumError": {
"type": "string",
"description": "The minimum threshold for error relative to the baseline."
},
"warning": {
"type": "string",
"description": "The threshold for warning relative to the baseline (min & max)."
},
"error": {
"type": "string",
"description": "The threshold for error relative to the baseline (min & max)."
}
},
"additionalProperties": false,
"required": ["type"]
}
}
}
@@ -0,0 +1,35 @@
import type { ExecutorContext } from '@nx/devkit';
import { joinPathFragments, parseTargetString, runExecutor } from '@nx/devkit';
import {
calculateProjectBuildableDependencies,
createTmpTsConfig,
} from '@nx/js/internal';
import type { DelegateBuildExecutorSchema } from './schema';
export async function* delegateBuildExecutor(
options: DelegateBuildExecutorSchema,
context: ExecutorContext
) {
const { target, dependencies } = calculateProjectBuildableDependencies(
context.taskGraph,
context.projectGraph,
context.root,
context.projectName,
context.targetName,
context.configurationName
);
options.tsConfig = createTmpTsConfig(
joinPathFragments(context.root, options.tsConfig),
context.root,
target.data.root,
dependencies
);
const { buildTarget, ...targetOptions } = options;
const delegateTarget = parseTargetString(buildTarget, context);
yield* await runExecutor(delegateTarget, targetOptions, context);
}
export default delegateBuildExecutor;
@@ -0,0 +1,6 @@
export interface DelegateBuildExecutorSchema {
buildTarget: string;
outputPath: string;
tsConfig: string;
watch?: boolean;
}
@@ -0,0 +1,34 @@
{
"version": 2,
"outputCapture": "direct-nodejs",
"$schema": "http://json-schema.org/draft-07/schema",
"title": "Schema for an executor which delegates a build.",
"description": "Delegates the build to a different target while supporting incremental builds.",
"cli": "nx",
"type": "object",
"properties": {
"buildTarget": {
"description": "Build target used for building the application after its dependencies have been built.",
"type": "string"
},
"outputPath": {
"type": "string",
"description": "The full path for the output directory, relative to the workspace root.",
"x-completion-type": "directory"
},
"tsConfig": {
"type": "string",
"description": "The full path for the TypeScript configuration file, relative to the workspace root.",
"x-completion-type": "file",
"x-completion-glob": "tsconfig.*.json"
},
"watch": {
"type": "boolean",
"description": "Whether to run a build when any file changes.",
"default": false
}
},
"additionalProperties": false,
"required": ["buildTarget", "outputPath", "tsConfig"],
"examplesFile": "../../../docs/delegate-build-examples.md"
}
@@ -0,0 +1,51 @@
import { parseTargetString, type ExecutorContext } from '@nx/devkit';
import { createBuilderContext } from 'nx/src/adapter/ngcli-adapter';
import { readCachedProjectConfiguration } from 'nx/src/project-graph/project-graph';
import { assertBuilderPackageIsInstalled } from '../utilities/builder-package';
import { patchBuilderContext } from '../utilities/patch-builder-context';
import type { ExtractI18nExecutorOptions } from './schema';
export default async function* extractI18nExecutor(
options: ExtractI18nExecutorOptions,
context: ExecutorContext
) {
const parsedBuildTarget = parseTargetString(options.buildTarget, context);
const buildTargetProjectConfiguration = readCachedProjectConfiguration(
parsedBuildTarget.project
);
const buildTarget =
buildTargetProjectConfiguration.targets[parsedBuildTarget.target];
const isUsingEsbuildBuilder = [
'@angular/build:application',
'@angular-devkit/build-angular:application',
'@angular-devkit/build-angular:browser-esbuild',
'@nx/angular:application',
'@nx/angular:browser-esbuild',
].includes(buildTarget.executor);
const builderContext = await createBuilderContext(
{
builderName: '@nx/angular:extract-i18n',
description: 'Extracts i18n messages from source code.',
optionSchema: require('./schema.json'),
},
context
);
/**
* The Angular CLI extract-i18n builder make some decisions based on the build
* target builder but it only considers `@angular-devkit/build-angular:*`
* builders. Since we are using a custom builder, we patch the context to
* handle `@nx/angular:*` executors.
*/
patchBuilderContext(builderContext, isUsingEsbuildBuilder, parsedBuildTarget);
assertBuilderPackageIsInstalled('@angular-devkit/build-angular');
const { executeExtractI18nBuilder } = await import(
'@angular-devkit/build-angular'
);
return await executeExtractI18nBuilder(options, builderContext);
}
@@ -0,0 +1,3 @@
import type { ExtractI18nBuilderOptions } from '@angular-devkit/build-angular';
export type ExtractI18nExecutorOptions = ExtractI18nBuilderOptions;
@@ -0,0 +1,50 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"title": "Schema for Nx extract-i18n Executor",
"description": "Extracts i18n messages from source code.",
"outputCapture": "direct-nodejs",
"type": "object",
"properties": {
"buildTarget": {
"type": "string",
"description": "A builder target to extract i18n messages in the format of `project:target[:configuration]`. You can also pass in more than one configuration name as a comma-separated list. Example: `project:target:production,staging`.",
"pattern": "^[^:\\s]+:[^:\\s]+(:[^\\s]+)?$"
},
"format": {
"type": "string",
"description": "Output format for the generated file.",
"default": "xlf",
"enum": [
"xmb",
"xlf",
"xlif",
"xliff",
"xlf2",
"xliff2",
"json",
"arb",
"legacy-migrate"
]
},
"progress": {
"type": "boolean",
"description": "Log progress to the console.",
"default": true
},
"outputPath": {
"type": "string",
"description": "Path where output will be placed."
},
"outFile": {
"type": "string",
"description": "Name of the file to output."
},
"i18nDuplicateTranslation": {
"type": "string",
"description": "How to handle duplicate translations. _Note: this is only available in Angular 20.0.0 and above._",
"enum": ["error", "warning", "ignore"]
}
},
"additionalProperties": false,
"required": ["buildTarget"]
}
@@ -0,0 +1,2 @@
export * from './normalize-options';
export * from './start-dev-remotes';
@@ -0,0 +1,22 @@
import type { NormalizedSchema, Schema } from '../schema';
import { join } from 'path';
import { workspaceRoot } from '@nx/devkit';
export function normalizeOptions(schema: Schema): NormalizedSchema {
schema.buildLibsFromSource ??= true;
process.env.NX_BUILD_LIBS_FROM_SOURCE = `${schema.buildLibsFromSource}`;
process.env.NX_BUILD_TARGET = `${schema.buildTarget}`;
return {
...schema,
devRemotes: schema.devRemotes ?? [],
host: schema.host ?? 'localhost',
port: schema.port ?? 4200,
liveReload: schema.liveReload ?? true,
open: schema.open ?? false,
ssl: schema.ssl ?? false,
verbose: schema.verbose ?? false,
sslCert: schema.sslCert ? join(workspaceRoot, schema.sslCert) : undefined,
sslKey: schema.sslKey ? join(workspaceRoot, schema.sslKey) : undefined,
};
}
@@ -0,0 +1,58 @@
import { type Schema } from '../schema';
import {
type ExecutorContext,
type ProjectConfiguration,
runExecutor,
} from '@nx/devkit';
export async function startRemotes(
remotes: string[],
workspaceProjects: Record<string, ProjectConfiguration>,
options: Pick<Schema, 'devRemotes' | 'verbose'>,
context: ExecutorContext,
target: 'serve' | 'serve-static' = 'serve'
) {
const remoteIters: AsyncIterable<{ success: boolean }>[] = [];
for (const app of remotes) {
if (!workspaceProjects[app].targets?.[target]) {
throw new Error(`Could not find "${target}" target in "${app}" project.`);
} else if (!workspaceProjects[app].targets?.[target].executor) {
throw new Error(
`Could not find executor for "${target}" target in "${app}" project.`
);
}
const [collection, executor] =
workspaceProjects[app].targets[target].executor.split(':');
const isUsingModuleFederationDevServerExecutor = executor.includes(
'module-federation-dev-server'
);
const configurationOverride = options.devRemotes.find(
(
r
): r is {
remoteName: string;
configuration: string;
} => typeof r !== 'string' && r.remoteName === app
)?.configuration;
remoteIters.push(
await runExecutor(
{
project: app,
target,
configuration: configurationOverride ?? context.configurationName,
},
{
...(target === 'serve' ? { verbose: options.verbose ?? false } : {}),
...(isUsingModuleFederationDevServerExecutor
? { isInitialHost: false }
: {}),
},
context
)
);
}
return remoteIters;
}
@@ -0,0 +1,179 @@
import {
eachValueFrom,
combineAsyncIterables,
createAsyncIterable,
mapAsyncIterable,
} from '@nx/devkit/internal';
import {
type ExecutorContext,
logger,
readProjectsConfigurationFromProjectGraph,
} from '@nx/devkit';
import { type Schema } from './schema';
import { normalizeOptions, startRemotes } from './lib';
import { startRemoteIterators } from '@nx/module-federation/internal';
import { waitForPortOpen, fileServerExecutor } from '@nx/web/internal';
import { createBuilderContext } from 'nx/src/adapter/ngcli-adapter';
import { executeDevServerBuilder } from '../../builders/dev-server/dev-server.impl';
import {
getDynamicMfManifestFile,
validateDevRemotes,
} from '../../builders/utilities/module-federation';
import { extname, join } from 'path';
import { existsSync } from 'fs';
import { warnAngularMfDevServerExecutorDeprecation } from '../../utils/module-federation-deprecation';
// This is required to ensure that the webpack version used by the Module Federation is the same as the one used by the builders.
const Module = require('module');
const originalResolveFilename = Module._resolveFilename;
const patchedWebpackPath = require.resolve('webpack', {
paths: [require.resolve('@angular-devkit/build-angular')],
});
// Override the resolve function
Module._resolveFilename = function (request, parent, isMain, options) {
// Intercept webpack specifically
if (request === 'webpack') {
// Force webpack to resolve from your specific path
return patchedWebpackPath;
}
// For all other modules, use the original resolver
return originalResolveFilename.call(this, request, parent, isMain, options);
};
export async function* moduleFederationDevServerExecutor(
schema: Schema,
context: ExecutorContext
) {
warnAngularMfDevServerExecutorDeprecation();
const options = normalizeOptions(schema);
const { projects: workspaceProjects } =
readProjectsConfigurationFromProjectGraph(context.projectGraph);
const project = workspaceProjects[context.projectName];
const currIter = options.static
? fileServerExecutor(
{
port: options.port,
host: options.host,
ssl: options.ssl,
buildTarget: options.buildTarget,
parallel: false,
spa: false,
withDeps: false,
cors: true,
cacheSeconds: -1,
},
context
)
: eachValueFrom(
executeDevServerBuilder(
options,
await createBuilderContext(
{
builderName: '@nx/angular:webpack-browser',
description: 'Build a browser application',
optionSchema: require('../../builders/webpack-browser/schema.json'),
},
context
)
)
);
if (options.isInitialHost === false) {
return yield* currIter;
}
let pathToManifestFile: string;
if (!options.pathToManifestFile) {
pathToManifestFile = getDynamicMfManifestFile(project, context.root);
} else {
const userPathToManifestFile = join(
context.root,
options.pathToManifestFile
);
if (!existsSync(userPathToManifestFile)) {
throw new Error(
`The provided Module Federation manifest file path does not exist. Please check the file exists at "${userPathToManifestFile}".`
);
} else if (extname(options.pathToManifestFile) !== '.json') {
throw new Error(
`The Module Federation manifest file must be a JSON. Please ensure the file at ${userPathToManifestFile} is a JSON.`
);
}
pathToManifestFile = userPathToManifestFile;
}
validateDevRemotes(options, workspaceProjects);
const { remotes, staticRemotesIter, devRemoteIters } =
await startRemoteIterators(
options,
context,
startRemotes,
pathToManifestFile,
'angular'
);
const removeBaseUrlEmission = (iter: AsyncIterable<unknown>) =>
mapAsyncIterable(iter, (v) => ({
...v,
baseUrl: undefined,
}));
return yield* combineAsyncIterables(
removeBaseUrlEmission(currIter),
...devRemoteIters.map(removeBaseUrlEmission),
...(staticRemotesIter ? [removeBaseUrlEmission(staticRemotesIter)] : []),
createAsyncIterable<{ success: true; baseUrl: string }>(
async ({ next, done }) => {
if (!options.isInitialHost) {
done();
return;
}
if (remotes.remotePorts.length === 0) {
logger.info(
`NX All remotes started, server ready at http://localhost:${options.port}`
);
next({ success: true, baseUrl: `http://localhost:${options.port}` });
done();
return;
}
try {
const portsToWaitFor = staticRemotesIter
? [options.staticRemotesPort, ...remotes.remotePorts]
: [...remotes.remotePorts];
await Promise.all(
portsToWaitFor.map((port) =>
waitForPortOpen(port, {
retries: 480,
retryDelay: 2500,
host: 'localhost',
})
)
);
logger.info(
`NX All remotes started, server ready at http://localhost:${options.port}`
);
next({ success: true, baseUrl: `http://localhost:${options.port}` });
} catch (err) {
throw new Error(
`Failed to start remotes. Check above for any errors.`,
{
cause: err,
}
);
} finally {
done();
}
}
)
);
}
export default moduleFederationDevServerExecutor;
@@ -0,0 +1,38 @@
import type { DevRemoteDefinition } from '../../builders/utilities/module-federation';
interface Schema {
buildTarget: string;
port?: number;
host?: string;
proxyConfig?: string;
ssl?: boolean;
sslKey?: string;
sslCert?: string;
headers?: Record<string, string>;
open?: boolean;
verbose?: boolean;
liveReload?: boolean;
publicHost?: string;
allowedHosts?: string[];
servePath?: string;
disableHostCheck?: boolean;
hmr?: boolean;
watch?: boolean;
poll?: number;
devRemotes?: DevRemoteDefinition[];
skipRemotes?: string[];
pathToManifestFile?: string;
static?: boolean;
isInitialHost?: boolean;
parallel?: number;
staticRemotesPort?: number;
buildLibsFromSource?: boolean;
}
export type NormalizedSchema = Schema & {
devRemotes: DevRemoteDefinition[];
liveReload: boolean;
open: boolean;
ssl: boolean;
verbose: boolean;
};
@@ -0,0 +1,171 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"title": "Schema for Module Federation Dev Server",
"x-deprecated": "The `@nx/angular:module-federation-dev-server` executor is deprecated. Angular Module Federation in Nx is no longer supported; use `@angular-architects/native-federation`. Removed in Nx v24.",
"continuous": true,
"outputCapture": "direct-nodejs",
"description": "Serves host [Module Federation](https://module-federation.io/) applications ([webpack](https://webpack.js.org/)-based) allowing to specify which remote applications should be served with the host.",
"type": "object",
"presets": [
{
"name": "Using a Different Port",
"keys": ["buildTarget", "port"]
}
],
"properties": {
"buildTarget": {
"type": "string",
"description": "A build builder target to serve in the format of `project:target[:configuration]`.",
"pattern": "^[^:\\s]+:[^:\\s]+(:[^\\s]+)?$"
},
"port": {
"type": "number",
"description": "Port to listen on.",
"default": 4200
},
"host": {
"type": "string",
"description": "Host to listen on.",
"default": "localhost"
},
"proxyConfig": {
"type": "string",
"description": "Proxy configuration file. For more information, see https://angular.dev/tools/cli/serve#proxying-to-a-backend-server."
},
"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."
},
"headers": {
"type": "object",
"description": "Custom HTTP headers to be added to all responses.",
"propertyNames": {
"pattern": "^[-_A-Za-z0-9]+$"
},
"additionalProperties": {
"type": "string"
}
},
"open": {
"type": "boolean",
"description": "Opens the url in default browser.",
"default": false,
"alias": "o"
},
"verbose": {
"type": "boolean",
"description": "Adds more details to output logging."
},
"liveReload": {
"type": "boolean",
"description": "Whether to reload the page on change, using live-reload.",
"default": true
},
"publicHost": {
"type": "string",
"description": "The URL that the browser client (or live-reload client, if enabled) should use to connect to the development server. Use for a complex dev server setup, such as one with reverse proxies."
},
"allowedHosts": {
"type": "array",
"description": "List of hosts that are allowed to access the dev server.",
"default": [],
"items": {
"type": "string"
}
},
"servePath": {
"type": "string",
"description": "The pathname where the app will be served."
},
"disableHostCheck": {
"type": "boolean",
"description": "Don't verify connected clients are part of allowed hosts.",
"default": false
},
"hmr": {
"type": "boolean",
"description": "Enable hot module replacement.",
"default": false
},
"watch": {
"type": "boolean",
"description": "Rebuild on change.",
"default": true
},
"poll": {
"type": "number",
"description": "Enable and define the file watching poll time period in milliseconds."
},
"devRemotes": {
"type": "array",
"items": {
"oneOf": [
{
"type": "string"
},
{
"type": "object",
"properties": {
"remoteName": {
"type": "string"
},
"configuration": {
"type": "string"
}
},
"required": ["remoteName"],
"additionalProperties": false
}
]
},
"description": "List of remote applications to run in development mode (i.e. using serve target).",
"x-priority": "important"
},
"skipRemotes": {
"type": "array",
"items": {
"type": "string"
},
"description": "List of remote applications to not automatically serve, either statically or in development mode. This will not remove the remotes from the `module-federation.config` file, and therefore the application may still try to fetch these remotes.\nThis option is useful if you have other means for serving the `remote` application(s).\n**NOTE:** Remotes that are not in the workspace will be skipped automatically."
},
"pathToManifestFile": {
"type": "string",
"description": "Path to a Module Federation manifest file (e.g. `my/path/to/module-federation.manifest.json`) containing the dynamic remote applications relative to the workspace root."
},
"static": {
"type": "boolean",
"description": "Whether to use a static file server instead of the webpack-dev-server. This should be used for remote applications that are also host applications."
},
"isInitialHost": {
"type": "boolean",
"description": "Whether the host that is running this executor is the first in the project tree to do so.",
"default": true,
"x-priority": "internal"
},
"parallel": {
"type": "number",
"description": "Max number of parallel processes for building static remotes"
},
"staticRemotesPort": {
"type": "number",
"description": "The port at which to serve the file-server for the static remotes."
},
"buildLibsFromSource": {
"type": "boolean",
"description": "Read buildable libraries from source instead of building them separately. If not set, it will take the value specified in the `buildTarget` options, or it will default to `true` if it's also not set in the `buildTarget` options.",
"x-priority": "important"
}
},
"additionalProperties": false,
"required": ["buildTarget"],
"examplesFile": "../../../docs/module-federation-dev-server-examples.md"
}
@@ -0,0 +1,20 @@
import { workspaceRoot } from '@nx/devkit';
import type { NormalizedSchema, Schema } from '../schema';
import { join } from 'path';
export function normalizeOptions(options: Schema): NormalizedSchema {
const devServeRemotes = !options.devRemotes
? []
: Array.isArray(options.devRemotes)
? options.devRemotes
: [options.devRemotes];
return {
...options,
devRemotes: devServeRemotes,
verbose: options.verbose ?? false,
ssl: options.ssl ?? false,
sslCert: options.sslCert ? join(workspaceRoot, options.sslCert) : undefined,
sslKey: options.sslKey ? join(workspaceRoot, options.sslKey) : undefined,
};
}
@@ -0,0 +1,58 @@
import { type Schema } from '../schema';
import {
type ExecutorContext,
type ProjectConfiguration,
runExecutor,
} from '@nx/devkit';
export async function startRemotes(
remotes: string[],
workspaceProjects: Record<string, ProjectConfiguration>,
options: Pick<Schema, 'devRemotes' | 'verbose'>,
context: ExecutorContext
) {
const target = 'serve-ssr';
const remoteIters: AsyncIterable<{ success: boolean }>[] = [];
for (const app of remotes) {
if (!workspaceProjects[app].targets?.[target]) {
throw new Error(`Could not find "${target}" target in "${app}" project.`);
} else if (!workspaceProjects[app].targets?.[target].executor) {
throw new Error(
`Could not find executor for "${target}" target in "${app}" project.`
);
}
const [_, executor] =
workspaceProjects[app].targets[target].executor.split(':');
const isUsingModuleFederationSsrDevServerExecutor = executor.includes(
'module-federation-dev-ssr'
);
const configurationOverride = options.devRemotes.find(
(
r
): r is {
remoteName: string;
configuration: string;
} => typeof r !== 'string' && r.remoteName === app
)?.configuration;
remoteIters.push(
await runExecutor(
{
project: app,
target,
configuration: configurationOverride ?? context.configurationName,
},
{
...{ verbose: options.verbose ?? false },
...(isUsingModuleFederationSsrDevServerExecutor
? { isInitialHost: false }
: {}),
},
context
)
);
}
return remoteIters;
}
@@ -0,0 +1,173 @@
import { type ExecutorContext, logger } from '@nx/devkit';
import {
combineAsyncIterables,
createAsyncIterable,
mapAsyncIterable,
eachValueFrom,
} from '@nx/devkit/internal';
import { startRemoteIterators } from '@nx/module-federation/internal';
import { waitForPortOpen } from '@nx/web/internal';
import { existsSync } from 'fs';
import { createBuilderContext } from 'nx/src/adapter/ngcli-adapter';
import { readProjectsConfigurationFromProjectGraph } from 'nx/src/project-graph/project-graph';
import { extname, join } from 'path';
import {
getDynamicMfManifestFile,
validateDevRemotes,
} from '../../builders/utilities/module-federation';
import { assertBuilderPackageIsInstalled } from '../utilities/builder-package';
import { normalizeOptions } from './lib/normalize-options';
import { startRemotes } from './lib/start-dev-remotes';
import type { Schema } from './schema';
import { warnAngularMfDevSsrExecutorDeprecation } from '../../utils/module-federation-deprecation';
// This is required to ensure that the webpack version used by the Module Federation is the same as the one used by the builders.
const Module = require('module');
const originalResolveFilename = Module._resolveFilename;
const patchedWebpackPath = require.resolve('webpack', {
paths: [require.resolve('@angular-devkit/build-angular')],
});
// Override the resolve function
Module._resolveFilename = function (request, parent, isMain, options) {
// Intercept webpack specifically
if (request === 'webpack') {
// Force webpack to resolve from your specific path
return patchedWebpackPath;
}
// For all other modules, use the original resolver
return originalResolveFilename.call(this, request, parent, isMain, options);
};
export async function* moduleFederationSsrDevServerExecutor(
schema: Schema,
context: ExecutorContext
) {
warnAngularMfDevSsrExecutorDeprecation();
const options = normalizeOptions(schema);
assertBuilderPackageIsInstalled('@angular-devkit/build-angular');
const { executeSSRDevServerBuilder } = await import(
'@angular-devkit/build-angular'
);
const currIter = eachValueFrom(
executeSSRDevServerBuilder(
options,
await createBuilderContext(
{
builderName: '@nx/angular:webpack-server',
description: 'Build a ssr application',
optionSchema: require('../../builders/webpack-server/schema.json'),
},
context
)
)
);
if (options.isInitialHost === false) {
return yield* currIter;
}
const { projects: workspaceProjects } =
readProjectsConfigurationFromProjectGraph(context.projectGraph);
const project = workspaceProjects[context.projectName];
let pathToManifestFile: string;
if (options.pathToManifestFile) {
const userPathToManifestFile = join(
context.root,
options.pathToManifestFile
);
if (!existsSync(userPathToManifestFile)) {
throw new Error(
`The provided Module Federation manifest file path does not exist. Please check the file exists at "${userPathToManifestFile}".`
);
} else if (extname(options.pathToManifestFile) !== '.json') {
throw new Error(
`The Module Federation manifest file must be a JSON. Please ensure the file at ${userPathToManifestFile} is a JSON.`
);
}
pathToManifestFile = userPathToManifestFile;
} else {
pathToManifestFile = getDynamicMfManifestFile(project, context.root);
}
validateDevRemotes({ devRemotes: options.devRemotes }, workspaceProjects);
const { remotes, staticRemotesIter, devRemoteIters } =
await startRemoteIterators(
options,
context,
startRemotes,
pathToManifestFile,
'angular',
true
);
const removeBaseUrlEmission = (iter: AsyncIterable<unknown>) =>
mapAsyncIterable(iter, (v) => ({
...v,
baseUrl: undefined,
}));
const combined = combineAsyncIterables(
removeBaseUrlEmission(staticRemotesIter),
...(devRemoteIters ? devRemoteIters.map(removeBaseUrlEmission) : []),
createAsyncIterable<{ success: true; baseUrl: string }>(
async ({ next, done }) => {
if (!options.isInitialHost) {
done();
return;
}
if (remotes.remotePorts.length) {
logger.info(
`Nx All remotes started, server ready at http://localhost:${options.port}`
);
next({ success: true, baseUrl: `http://localhost:${options.port}` });
done();
return;
}
try {
const portsToWaitFor =
staticRemotesIter && options.staticRemotesPort
? [options.staticRemotesPort, ...remotes.remotePorts]
: [...remotes.remotePorts];
await Promise.all(
portsToWaitFor.map((port) =>
waitForPortOpen(port, {
retries: 480,
retryDelay: 2500,
host: 'localhost',
})
)
);
next({ success: true, baseUrl: `http://localhost:${options.port}` });
} catch (error) {
throw new Error(
`Failed to start remotes. Check above for any errors.`,
{
cause: error,
}
);
} finally {
done();
}
}
)
);
let refs = 2 + (devRemoteIters?.length ?? 0);
for await (const result of combined) {
if (result.success === false) throw new Error('Remotes failed to start');
if (result.success) refs--;
if (refs === 0) break;
}
return yield* currIter;
}
export default moduleFederationSsrDevServerExecutor;
@@ -0,0 +1,17 @@
import { type DevRemoteDefinition } from '../../builders/utilities/module-federation';
import type { SSRDevServerBuilderOptions } from '@angular-devkit/build-angular';
export interface Schema extends SSRDevServerBuilderOptions {
devRemotes?: DevRemoteDefinition[];
skipRemotes?: string[];
pathToManifestFile?: string;
parallel?: number;
staticRemotesPort?: number;
isInitialHost?: boolean;
}
export interface NormalizedSchema extends Schema {
devRemotes: DevRemoteDefinition[];
ssl: boolean;
verbose: boolean;
}
@@ -0,0 +1,124 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"title": "Module Federation SSR Dev Server Target",
"x-deprecated": "The `@nx/angular:module-federation-ssr-dev-server` executor is deprecated. Angular Module Federation in Nx is no longer supported; use `@angular-architects/native-federation`. Removed in Nx v24.",
"continuous": true,
"outputCapture": "direct-nodejs",
"description": "The module-federation-ssr-dev-server executor is reserved exclusively for use with host SSR Module Federation applications. It allows the user to specify which remote applications should be served with the host.",
"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": {
"oneOf": [
{
"type": "string"
},
{
"type": "object",
"properties": {
"remoteName": {
"type": "string"
},
"configuration": {
"type": "string"
}
},
"required": ["remoteName"],
"additionalProperties": false
}
]
},
"description": "List of remote applications to run in development mode (i.e. using serve target).",
"x-priority": "important"
},
"skipRemotes": {
"type": "array",
"items": {
"type": "string"
},
"description": "List of remote applications to not automatically serve, either statically or in development mode."
},
"verbose": {
"type": "boolean",
"description": "Adds more details to output logging.",
"default": false
},
"pathToManifestFile": {
"type": "string",
"description": "Path to a Module Federation manifest file (e.g. `my/path/to/module-federation.manifest.json`) containing the dynamic remote applications relative to the workspace root."
},
"isInitialHost": {
"type": "boolean",
"description": "Whether the host that is running this executor is the first in the project tree to do so.",
"default": true,
"x-priority": "internal"
},
"parallel": {
"type": "number",
"description": "Max number of parallel processes for building static remotes"
},
"staticRemotesPort": {
"type": "number",
"description": "The port at which to serve the file-server for the static remotes."
}
},
"additionalProperties": false,
"required": ["browserTarget", "serverTarget"]
}
@@ -0,0 +1,82 @@
import {
NgEntryPoint as NgEntryPointBase,
type DestinationFiles,
} from 'ng-packagr/src/lib/ng-package/entry-point/entry-point';
import type { NgPackageConfig } from 'ng-packagr/src/ng-package.schema';
import { ensureUnixPath } from 'ng-packagr/src/lib/utils/path';
import { join, relative } from 'node:path';
export type NgEntryPointType = NgEntryPointBase & {
primaryDestinationPath?: string;
};
export function createNgEntryPoint(
packageJson: Record<string, any>,
ngPackageJson: NgPackageConfig,
basePath: string,
secondaryData?: Record<string, any>
): NgEntryPointType {
class NgEntryPoint extends NgEntryPointBase {
constructor(
public readonly packageJson: Record<string, any>,
public readonly ngPackageJson: NgPackageConfig,
public readonly basePath: string,
private readonly _secondaryData?: Record<string, any>
) {
super(packageJson, ngPackageJson, basePath, _secondaryData);
}
public get primaryDestinationPath(): string {
return (
this._secondaryData?.primaryDestinationPath ?? this.destinationPath
);
}
public get destinationFiles(): DestinationFiles {
let primaryDestPath = this.destinationPath;
let secondaryDir = '';
if (this._secondaryData) {
primaryDestPath = this._secondaryData.primaryDestinationPath;
secondaryDir = relative(
primaryDestPath,
this._secondaryData.destinationPath
);
}
const flatModuleFile = this.flatModuleFile;
const pathJoinWithDest = (...paths: string[]) =>
join(primaryDestPath, ...paths);
return {
directory: ensureUnixPath(secondaryDir),
declarations: pathJoinWithDest(
'tmp-typings',
secondaryDir,
`${flatModuleFile}.d.ts`
),
// changed to use esm2022
declarationsBundled: pathJoinWithDest(
secondaryDir,
`${flatModuleFile}.d.ts`
),
declarationsDir: pathJoinWithDest(secondaryDir),
esm2022: pathJoinWithDest(
'tmp-esm2022',
secondaryDir,
`${flatModuleFile}.js`
),
// changed to use esm2022
fesm2022: pathJoinWithDest(
'esm2022',
secondaryDir,
`${flatModuleFile}.js`
),
// changed to use esm2022
fesm2022Dir: pathJoinWithDest('esm2022'),
};
}
}
return new NgEntryPoint(packageJson, ngPackageJson, basePath, secondaryData);
}
@@ -0,0 +1,15 @@
import {
provideTransform,
type TransformProvider,
} from 'ng-packagr/src/lib/graph/transform.di';
import { WRITE_BUNDLES_TRANSFORM_TOKEN } from 'ng-packagr/src/lib/ng-package/entry-point/write-bundles.di';
import { OPTIONS_TOKEN } from 'ng-packagr/src/lib/ng-package/options.di';
import { writeBundlesTransform } from './write-bundles.transform';
export function getWriteBundlesTransformProvider(): TransformProvider {
return provideTransform({
provide: WRITE_BUNDLES_TRANSFORM_TOKEN,
useFactory: writeBundlesTransform,
deps: [OPTIONS_TOKEN],
});
}
@@ -0,0 +1,117 @@
/**
* Adapted from the original ng-packagr.
*
* Changes made:
* - Removed bundling altogether.
* - Write the ESM2022 outputs to the file system.
* - Fake the FESM2022 outputs pointing them to the ESM2022 outputs.
*/
import { transformFromPromise } from 'ng-packagr/src/lib/graph/transform';
import type { NgEntryPoint } from 'ng-packagr/src/lib/ng-package/entry-point/entry-point';
import {
isEntryPointInProgress,
isPackage,
} from 'ng-packagr/src/lib/ng-package/nodes';
import type { NgPackagrOptions } from 'ng-packagr/src/lib/ng-package/options.di';
import { NgPackage } from 'ng-packagr/src/lib/ng-package/package';
import { mkdir, writeFile, readFile } from 'node:fs/promises';
import { dirname, join, normalize } from 'node:path';
import { createNgEntryPoint, type NgEntryPointType } from './entry-point';
async function shouldWriteFile(
filePath: string,
newContent: string
): Promise<boolean> {
try {
const existingContent = await readFile(filePath, 'utf-8');
return existingContent !== newContent;
} catch (error) {
// If we can't read the existing file (including if it doesn't exist), write the new one
return true;
}
}
export const writeBundlesTransform = (_options: NgPackagrOptions) => {
return transformFromPromise(async (graph) => {
const entryPointNode = graph.find(isEntryPointInProgress());
if (!entryPointNode) {
return;
}
const entryPoint = toCustomNgEntryPoint(entryPointNode.data.entryPoint);
entryPointNode.data.entryPoint = entryPoint;
entryPointNode.data.destinationFiles = entryPoint.destinationFiles;
for (const [
path,
outputCache,
] of entryPointNode.cache.outputCache.entries()) {
const normalizedPath = normalizeEsm2022Path(path, entryPoint);
// Only write if content has changed
if (await shouldWriteFile(normalizedPath, outputCache.content)) {
await mkdir(dirname(normalizedPath), { recursive: true });
await writeFile(normalizedPath, outputCache.content);
}
}
if (
!entryPointNode.cache.outputCache.size &&
entryPoint.isSecondaryEntryPoint
) {
await mkdir(entryPoint.destinationPath, { recursive: true });
}
// Update package node only when processing the primary entry point
if (!entryPoint.isSecondaryEntryPoint) {
const packageNode = graph.find(isPackage);
if (packageNode) {
packageNode.data = new NgPackage(
packageNode.data.src,
toCustomNgEntryPoint(packageNode.data.primary),
packageNode.data.secondaries.map((secondary) =>
toCustomNgEntryPoint(secondary)
)
);
}
}
});
};
function normalizeEsm2022Path(
path: string,
entryPoint: NgEntryPointType
): string {
const normalizedPath = normalize(path);
if (!entryPoint.primaryDestinationPath) {
return normalizedPath;
}
if (
normalizedPath.startsWith(
join(entryPoint.primaryDestinationPath, 'tmp-esm2022')
)
) {
return normalizedPath.replace('tmp-esm2022', 'esm2022');
}
if (
normalizedPath.startsWith(
join(entryPoint.primaryDestinationPath, 'tmp-typings')
)
) {
return normalizedPath.replace('tmp-typings', '');
}
return normalizedPath;
}
function toCustomNgEntryPoint(entryPoint: NgEntryPoint): NgEntryPointType {
return createNgEntryPoint(
entryPoint.packageJson,
entryPoint.ngPackageJson,
entryPoint.basePath,
// @ts-expect-error this is a TS private property, but it can be accessed at runtime
entryPoint.secondaryData
);
}
@@ -0,0 +1,18 @@
import { type NgPackagr, ngPackagr } from 'ng-packagr';
export async function getNgPackagrInstance(): Promise<NgPackagr> {
const { getWriteBundlesTransformProvider } = await import(
'./ng-package/entry-point/write-bundles.di.js'
);
const { getStylesheetProcessorFactoryProvider } = await import(
'../../utilities/ng-packagr/stylesheet-processor.di.js'
);
const packagr = ngPackagr();
packagr.withProviders([
getWriteBundlesTransformProvider(),
getStylesheetProcessorFactoryProvider(),
]);
return packagr;
}
@@ -0,0 +1,43 @@
import type { ExecutorContext } from '@nx/devkit';
import {
createTmpTsConfig,
DependentBuildableProjectNode,
} from '@nx/js/internal';
import { NgPackagr } from 'ng-packagr';
import { join, resolve } from 'path';
import { createLibraryExecutor } from '../package/package.impl';
import type { BuildAngularLibraryExecutorOptions } from '../package/schema';
import { parseRemappedTsConfigAndMergeDefaults } from '../utilities/typescript';
import { getNgPackagrInstance } from './ng-packagr-adjustments/ng-packagr';
async function initializeNgPackgrLite(
options: BuildAngularLibraryExecutorOptions,
context: ExecutorContext,
projectDependencies: DependentBuildableProjectNode[]
): Promise<NgPackagr> {
const ngPackagr = await getNgPackagrInstance();
ngPackagr.forProject(resolve(context.root, options.project));
if (options.tsConfig) {
const remappedTsConfigFilePath = createTmpTsConfig(
join(context.root, options.tsConfig),
context.root,
context.projectsConfigurations.projects[context.projectName].root,
projectDependencies
);
const tsConfig = await parseRemappedTsConfigAndMergeDefaults(
context.root,
options.tsConfig,
remappedTsConfigFilePath
);
ngPackagr.withTsConfig(tsConfig);
}
return ngPackagr;
}
export const ngPackagrLiteExecutor = createLibraryExecutor(
initializeNgPackgrLite
);
export default ngPackagrLiteExecutor;
@@ -0,0 +1,38 @@
{
"version": 2,
"outputCapture": "direct-nodejs",
"$schema": "https://json-schema.org/schema",
"title": "ng-packagr Target",
"description": "Builds an Angular library with support for incremental builds.\n\nThis executor is meant to be used with buildable libraries in an incremental build scenario. It is similar to the `@nx/angular:package` executor but it only produces ESM2022 bundles.",
"cli": "nx",
"type": "object",
"presets": [
{
"name": "Updating Project Dependencies for Buildable Library",
"keys": ["project"]
}
],
"properties": {
"project": {
"type": "string",
"description": "The file path for the ng-packagr configuration file, relative to the workspace root."
},
"tsConfig": {
"type": "string",
"description": "The full path for the TypeScript configuration file, relative to the workspace root.",
"x-completion-type": "file",
"x-completion-glob": "tsconfig.*.json",
"x-priority": "important"
},
"watch": {
"type": "boolean",
"description": "Whether to run a build when any file changes.",
"default": false
},
"poll": {
"type": "number",
"description": "Enable and define the file watching poll time period in milliseconds."
}
},
"additionalProperties": false
}
@@ -0,0 +1,11 @@
import { type NgPackagr, ngPackagr } from 'ng-packagr';
export async function getNgPackagrInstance(): Promise<NgPackagr> {
const { getStylesheetProcessorFactoryProvider } = await import(
'../../utilities/ng-packagr/stylesheet-processor.di.js'
);
const packagr = ngPackagr();
packagr.withProviders([getStylesheetProcessorFactoryProvider()]);
return packagr;
}
@@ -0,0 +1,92 @@
import type { ExecutorContext } from '@nx/devkit';
import { eachValueFrom } from '@nx/devkit/internal';
import {
calculateProjectBuildableDependencies,
createTmpTsConfig,
type DependentBuildableProjectNode,
} from '@nx/js/internal';
import type { NgPackagr } from 'ng-packagr';
import { join, resolve } from 'path';
import { from } from 'rxjs';
import { mapTo, switchMap } from 'rxjs/operators';
import { parseRemappedTsConfigAndMergeDefaults } from '../utilities/typescript';
import { getNgPackagrInstance } from './ng-packagr-adjustments/ng-packagr';
import type { BuildAngularLibraryExecutorOptions } from './schema';
async function initializeNgPackagr(
options: BuildAngularLibraryExecutorOptions,
context: ExecutorContext,
projectDependencies: DependentBuildableProjectNode[]
): Promise<NgPackagr> {
const ngPackagr = await getNgPackagrInstance();
ngPackagr.forProject(resolve(context.root, options.project));
if (options.tsConfig) {
const remappedTsConfigFilePath = createTmpTsConfig(
join(context.root, options.tsConfig),
context.root,
context.projectsConfigurations.projects[context.projectName].root,
projectDependencies
);
const tsConfig = await parseRemappedTsConfigAndMergeDefaults(
context.root,
options.tsConfig,
remappedTsConfigFilePath
);
ngPackagr.withTsConfig(tsConfig);
}
return ngPackagr;
}
/**
* Creates an executor function that executes the library build of an Angular
* package using ng-packagr.
* @param initializeNgPackagr function that returns an ngPackagr instance to use for the build.
*/
export function createLibraryExecutor(
initializeNgPackagr: (
options: BuildAngularLibraryExecutorOptions,
context: ExecutorContext,
projectDependencies: DependentBuildableProjectNode[]
) => Promise<NgPackagr>
) {
return async function* (
options: BuildAngularLibraryExecutorOptions,
context: ExecutorContext
) {
options.project ??= join(
context.projectsConfigurations.projects[context.projectName].root,
'ng-package.json'
);
const { dependencies } = calculateProjectBuildableDependencies(
context.taskGraph,
context.projectGraph,
context.root,
context.projectName,
context.targetName,
context.configurationName
);
if (options.watch) {
return yield* eachValueFrom(
from(initializeNgPackagr(options, context, dependencies)).pipe(
switchMap((packagr) => packagr.watch()),
mapTo({ success: true })
)
);
}
return from(initializeNgPackagr(options, context, dependencies))
.pipe(
switchMap((packagr) => packagr.build()),
mapTo({ success: true })
)
.toPromise();
};
}
export const packageExecutor = createLibraryExecutor(initializeNgPackagr);
export default packageExecutor;
+6
View File
@@ -0,0 +1,6 @@
import type { NgPackagrBuilderOptions } from '@angular-devkit/build-angular';
export interface BuildAngularLibraryExecutorOptions
extends NgPackagrBuilderOptions {
project?: string;
}
@@ -0,0 +1,38 @@
{
"version": 2,
"outputCapture": "direct-nodejs",
"$schema": "https://json-schema.org/schema",
"title": "ng-packagr Target",
"description": "Builds and packages an Angular library producing an output following the Angular Package Format (APF) to be distributed as an NPM package.\n\nThis executor is a drop-in replacement for the `@angular-devkit/build-angular:ng-packagr` and `@angular/build:ng-packagr` builders, with additional support for incremental builds.",
"cli": "nx",
"type": "object",
"presets": [
{
"name": "Updating Project Dependencies for Publishable Library",
"keys": ["project"]
}
],
"properties": {
"project": {
"type": "string",
"description": "The file path for the ng-packagr configuration file, relative to the workspace root."
},
"tsConfig": {
"type": "string",
"description": "The full path for the TypeScript configuration file, relative to the workspace root.",
"x-completion-type": "file",
"x-completion-glob": "tsconfig.*.json",
"x-priority": "important"
},
"watch": {
"type": "boolean",
"description": "Whether to run a build when any file changes.",
"default": false
},
"poll": {
"type": "number",
"description": "Enable and define the file watching poll time period in milliseconds."
}
},
"additionalProperties": false
}
+7
View File
@@ -0,0 +1,7 @@
import type { UnitTestBuilderOptions } from '@angular/build';
import type { PluginSpec } from '../utilities/esbuild-extensions';
export interface UnitTestExecutorOptions extends UnitTestBuilderOptions {
indexHtmlTransformer?: string;
plugins?: string[] | PluginSpec[];
}
@@ -0,0 +1,331 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"title": "Schema for Nx Unit Test Executor",
"description": "Run application unit tests. _Note: this is only supported in Angular versions >= 21.0.0_.",
"outputCapture": "direct-nodejs",
"type": "object",
"properties": {
"buildTarget": {
"type": "string",
"description": "Specifies the build target to use for the unit test build in the format `project:target[:configuration]`. This defaults to the `build` target of the current project with the `development` configuration. You can also pass a comma-separated list of configurations. Example: `project:target:production,staging`.",
"pattern": "^[^:\\s]*:[^:\\s]*(:[^\\s]+)?$"
},
"tsConfig": {
"type": "string",
"description": "The path to the TypeScript configuration file, relative to the workspace root. Defaults to `tsconfig.spec.json` in the project root if it exists. If not specified and the default does not exist, the `tsConfig` from the specified `buildTarget` will be used."
},
"runner": {
"type": "string",
"description": "Specifies the test runner to use for test execution.",
"default": "vitest",
"enum": ["karma", "vitest"]
},
"runnerConfig": {
"type": ["string", "boolean"],
"description": "Specifies the configuration file for the selected test runner. If a string is provided, it will be used as the path to the configuration file. If `true`, the builder will search for a default configuration file (e.g., `vitest.config.ts` or `karma.conf.js`). If `false`, no external configuration file will be used.\\nFor Vitest, this enables advanced options and the use of custom plugins. Please note that while the file is loaded, the Angular team does not provide direct support for its specific contents or any third-party plugins used within it.",
"default": false
},
"browsers": {
"description": "Specifies the browsers to use for test execution. When not specified, tests are run in a Node.js environment using jsdom. For both Vitest and Karma, browser names ending with 'Headless' (e.g., 'ChromeHeadless') will enable headless mode.",
"type": "array",
"items": {
"type": "string"
},
"minItems": 1
},
"browserViewport": {
"description": "Specifies the browser viewport dimensions for browser-based tests in the format `widthxheight`.",
"type": "string",
"pattern": "^\\d+x\\d+$"
},
"headless": {
"type": "boolean",
"description": "Forces all configured browsers to run in headless mode. When using the Vitest runner, this option is ignored if no browsers are configured. The Karma runner does not support this option. _Note: this is only supported in Angular versions >= 21.2.0_."
},
"isolate": {
"type": "boolean",
"description": "Enables isolation for test execution. When true, Vitest runs tests in separate threads or processes. This option is only available for the Vitest runner. Defaults to false to align with the Karma/Jasmine experience. _Note: this is only supported in Angular versions >= 22.0.0_."
},
"quiet": {
"type": "boolean",
"description": "Suppresses the verbose build summary and stats table on each rebuild. Defaults to `true` locally and `false` in CI environments. _Note: this is only supported in Angular versions >= 22.0.0_."
},
"include": {
"type": "array",
"items": {
"type": "string"
},
"default": ["**/*.spec.ts", "**/*.test.ts"],
"description": "Specifies glob patterns of files to include for testing, relative to the project root. This option also has special handling for directory paths (includes all test files within) and file paths (includes the corresponding test file if one exists)."
},
"exclude": {
"type": "array",
"items": {
"type": "string"
},
"description": "Specifies glob patterns of files to exclude from testing, relative to the project root."
},
"filter": {
"type": "string",
"description": "Specifies a regular expression pattern to match against test suite and test names. Only tests with a name matching the pattern will be executed. For example, `^App` will run only tests in suites beginning with 'App'."
},
"watch": {
"type": "boolean",
"description": "Enables watch mode, which re-runs tests when source files change. Defaults to `true` in TTY environments and `false` otherwise."
},
"debug": {
"type": "boolean",
"description": "Enables debugging mode for tests, allowing the use of the Node Inspector.",
"default": false
},
"ui": {
"type": "boolean",
"description": "Enables the Vitest UI for interactive test execution. This option is only available for the Vitest runner."
},
"coverage": {
"type": "boolean",
"description": "Enables coverage reporting for tests.",
"default": false
},
"coverageInclude": {
"type": "array",
"description": "Specifies glob patterns of files to include in the coverage report.",
"items": {
"type": "string"
}
},
"coverageExclude": {
"type": "array",
"description": "Specifies glob patterns of files to exclude from the coverage report.",
"items": {
"type": "string"
}
},
"coverageReporters": {
"type": "array",
"description": "Specifies the reporters to use for coverage results. Each reporter can be a string representing its name, or a tuple containing the name and an options object. Built-in reporters include 'html', 'lcov', 'lcovonly', 'text', 'text-summary', 'cobertura', 'json', and 'json-summary'.",
"items": {
"oneOf": [
{
"enum": [
"html",
"lcov",
"lcovonly",
"text",
"text-summary",
"cobertura",
"json",
"json-summary"
]
},
{
"type": "array",
"minItems": 1,
"maxItems": 2,
"items": [
{
"enum": [
"html",
"lcov",
"lcovonly",
"text",
"text-summary",
"cobertura",
"json",
"json-summary"
]
},
{
"type": "object"
}
]
}
]
}
},
"coverageThresholds": {
"type": "object",
"description": "Specifies minimum coverage thresholds that must be met. If thresholds are not met, the builder will exit with an error.",
"properties": {
"perFile": {
"type": "boolean",
"description": "When true, thresholds are enforced for each file individually."
},
"statements": {
"type": "number",
"description": "Minimum percentage of statements covered."
},
"branches": {
"type": "number",
"description": "Minimum percentage of branches covered."
},
"functions": {
"type": "number",
"description": "Minimum percentage of functions covered."
},
"lines": {
"type": "number",
"description": "Minimum percentage of lines covered."
}
},
"additionalProperties": false
},
"coverageWatermarks": {
"type": "object",
"description": "Specifies coverage watermarks for the HTML reporter. These determine the color coding for high, medium, and low coverage.",
"properties": {
"statements": {
"type": "array",
"description": "The high and low watermarks for statements coverage. `[low, high]`",
"items": { "type": "number" },
"minItems": 2,
"maxItems": 2
},
"branches": {
"type": "array",
"description": "The high and low watermarks for branches coverage. `[low, high]`",
"items": { "type": "number" },
"minItems": 2,
"maxItems": 2
},
"functions": {
"type": "array",
"description": "The high and low watermarks for functions coverage. `[low, high]`",
"items": { "type": "number" },
"minItems": 2,
"maxItems": 2
},
"lines": {
"type": "array",
"description": "The high and low watermarks for lines coverage. `[low, high]`",
"items": { "type": "number" },
"minItems": 2,
"maxItems": 2
}
},
"additionalProperties": false
},
"reporters": {
"type": "array",
"description": "Specifies the reporters to use during test execution. Each reporter can be a string representing its name, or a tuple containing the name and an options object. Built-in reporters include 'default', 'verbose', 'dots', 'json', 'junit', 'tap', 'tap-flat', and 'html'. You can also provide a path to a custom reporter.",
"items": {
"oneOf": [
{
"anyOf": [
{
"type": "string"
},
{
"enum": [
"default",
"verbose",
"dots",
"json",
"junit",
"tap",
"tap-flat",
"html"
]
}
]
},
{
"type": "array",
"minItems": 1,
"maxItems": 2,
"items": [
{
"anyOf": [
{
"type": "string"
},
{
"enum": [
"default",
"verbose",
"dots",
"json",
"junit",
"tap",
"tap-flat",
"html"
]
}
]
},
{
"type": "object"
}
]
}
]
}
},
"outputFile": {
"type": "string",
"description": "Specifies a file path for the test report, applying only to the first reporter. To configure output files for multiple reporters, use the tuple format `['reporter-name', { outputFile: '...' }]` within the `reporters` option. When not provided, output is written to the console."
},
"providersFile": {
"type": "string",
"description": "Specifies the path to a TypeScript file that provides an array of Angular providers for the test environment. The file must contain a default export of the provider array.",
"minLength": 1
},
"setupFiles": {
"type": "array",
"items": {
"type": "string"
},
"description": "A list of paths to global setup files that are executed before the test files. The application's polyfills and the Angular TestBed are always initialized before these files."
},
"progress": {
"type": "boolean",
"description": "Shows build progress information in the console. Defaults to the `progress` setting of the specified `buildTarget`."
},
"listTests": {
"type": "boolean",
"description": "Lists all discovered test files and exits the process without building or executing the tests.",
"default": false
},
"dumpVirtualFiles": {
"type": "boolean",
"description": "Dumps build output files to the `.angular/cache` directory for debugging purposes.",
"default": false,
"visible": false
},
"plugins": {
"description": "A list of ESBuild plugins.",
"type": "array",
"items": {
"oneOf": [
{
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "The path to the plugin. Relative to the workspace root."
},
"options": {
"type": "object",
"description": "The options to provide to the plugin.",
"properties": {},
"additionalProperties": true
}
},
"additionalProperties": false,
"required": ["path"]
},
{
"type": "string",
"description": "The path to the plugin. Relative to the workspace root."
}
]
}
},
"indexHtmlTransformer": {
"description": "Path to a file exposing a default function to transform the `index.html` file.",
"type": "string"
}
},
"additionalProperties": false,
"required": []
}
@@ -0,0 +1,106 @@
import * as angularVersionUtils from '../utilities/angular-version-utils';
// The executor pulls in the Angular CLI adapter and esbuild plugin loaders at
// module scope; option validation runs before any of them, so stub them out to
// keep the spec a pure unit test of the version gates.
jest.mock('nx/src/adapter/ngcli-adapter', () => ({
createBuilderContext: jest.fn().mockResolvedValue({
getBuilderNameForTarget: jest.fn(),
getTargetOptions: jest.fn(),
}),
}));
jest.mock('../utilities/esbuild-extensions', () => ({
loadPlugins: jest.fn().mockResolvedValue([]),
loadIndexHtmlTransformer: jest.fn(),
}));
jest.mock('../utilities/builder-package', () => ({
assertBuilderPackageIsInstalled: jest.fn(),
}));
jest.mock('@angular/build', () => ({
executeUnitTestBuilder: jest.fn(async function* () {
yield { success: true };
}),
}));
import unitTestExecutor from './unit-test.impl';
import type { UnitTestExecutorOptions } from './schema';
describe('unitTestExecutor option validation', () => {
let getInstalledAngularVersionInfoSpy: jest.SpyInstance;
// validateOptions runs synchronously at the top of the generator body, so the
// first `.next()` surfaces any gate error as a rejected promise.
const run = (options: Partial<UnitTestExecutorOptions>) =>
unitTestExecutor(
options as UnitTestExecutorOptions,
{
projectName: 'app',
} as any
).next();
beforeEach(() => {
getInstalledAngularVersionInfoSpy = jest.spyOn(
angularVersionUtils,
'getInstalledAngularVersionInfo'
);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('throws when the Angular version is < 21', async () => {
getInstalledAngularVersionInfoSpy.mockReturnValue({
major: 20,
version: '20.1.0',
});
await expect(run({})).rejects.toThrow(
'The "unit-test" executor is only available for Angular versions >= 21.0.0. You are currently using version 20.1.0.'
);
});
it('throws when "headless" is set on Angular < 21.2.0', async () => {
getInstalledAngularVersionInfoSpy.mockReturnValue({
major: 21,
version: '21.1.0',
});
await expect(run({ headless: true })).rejects.toThrow(
'The "headless" option requires Angular version 21.2.0 or greater. You are currently using version 21.1.0.'
);
});
it('throws when "isolate" is set on Angular < 22', async () => {
getInstalledAngularVersionInfoSpy.mockReturnValue({
major: 21,
version: '21.2.0',
});
await expect(run({ isolate: true })).rejects.toThrow(
'The "isolate" option requires Angular version 22.0.0 or greater. You are currently using version 21.2.0.'
);
});
it('throws when "quiet" is set on Angular < 22', async () => {
getInstalledAngularVersionInfoSpy.mockReturnValue({
major: 21,
version: '21.2.0',
});
await expect(run({ quiet: true })).rejects.toThrow(
'The "quiet" option requires Angular version 22.0.0 or greater. You are currently using version 21.2.0.'
);
});
it('allows "isolate" and "quiet" on Angular >= 22', async () => {
getInstalledAngularVersionInfoSpy.mockReturnValue({
major: 22,
version: '22.0.0',
});
const { value } = await run({ isolate: true, quiet: true });
expect(value).toEqual({ success: true });
});
});
@@ -0,0 +1,154 @@
import type { BuilderContext } from '@angular-devkit/architect';
import type {
ApplicationBuilderOptions,
NgPackagrBuilderOptions,
} from '@angular/build';
import type { ExecutorContext, Target } from '@nx/devkit';
import { createBuilderContext } from 'nx/src/adapter/ngcli-adapter';
import { targetFromTargetString } from '../../utils/targets';
import type { ApplicationExecutorOptions } from '../application/schema';
import type { BuildAngularLibraryExecutorOptions } from '../package/schema';
import { lt } from 'semver';
import { getInstalledAngularVersionInfo } from '../utilities/angular-version-utils';
import { assertBuilderPackageIsInstalled } from '../utilities/builder-package';
import {
loadIndexHtmlTransformer,
loadPlugins,
} from '../utilities/esbuild-extensions';
import type { UnitTestExecutorOptions } from './schema';
export default async function* unitTestExecutor(
options: UnitTestExecutorOptions,
context: ExecutorContext
) {
validateOptions(options);
const {
plugins: pluginPaths,
indexHtmlTransformer: indexHtmlTransformerPath,
...delegateExecutorOptions
} = options;
const plugins = await loadPlugins(pluginPaths, options.tsConfig);
const indexHtmlTransformer = indexHtmlTransformerPath
? await loadIndexHtmlTransformer(indexHtmlTransformerPath, options.tsConfig)
: undefined;
const builderContext = await createBuilderContext(
{
builderName: '@nx/angular:unit-test',
description: 'Run application unit tests.',
optionSchema: require('./schema.json'),
},
context
);
const buildTargetSpecifier = options.buildTarget ?? `::development`;
const buildTarget = targetFromTargetString(
buildTargetSpecifier,
context.projectName,
'build'
);
patchBuilderContext(builderContext, buildTarget);
assertBuilderPackageIsInstalled('@angular/build');
const { executeUnitTestBuilder } = await import('@angular/build');
return yield* executeUnitTestBuilder(
delegateExecutorOptions,
builderContext,
{
codePlugins: plugins,
indexHtmlTransformer,
}
);
}
function validateOptions(options: UnitTestExecutorOptions): void {
const { version: angularVersion, major: angularMajorVersion } =
getInstalledAngularVersionInfo();
if (angularMajorVersion < 21) {
throw new Error(
`The "unit-test" executor is only available for Angular versions >= 21.0.0. You are currently using version ${angularVersion}.`
);
}
if (lt(angularVersion, '21.2.0')) {
if (options.headless !== undefined) {
throw new Error(
`The "headless" option requires Angular version 21.2.0 or greater. You are currently using version ${angularVersion}.`
);
}
}
if (lt(angularVersion, '22.0.0')) {
if (options.isolate !== undefined) {
throw new Error(
`The "isolate" option requires Angular version 22.0.0 or greater. You are currently using version ${angularVersion}.`
);
}
if (options.quiet !== undefined) {
throw new Error(
`The "quiet" option requires Angular version 22.0.0 or greater. You are currently using version ${angularVersion}.`
);
}
}
}
/**
* The Angular CLI unit-test builder only accepts the `@angular/build:application`
* and `@angular/build:ng-packagr` builders. We need to patch the builder context
* so that it accepts the `@nx/angular:*` executors.
*
* https://github.com/angular/angular-cli/blob/f9de11d67d3e0e0524372819583bc77756596d4f/packages/angular/build/src/builders/unit-test/builder.ts#L246-L262
*/
function patchBuilderContext(context: BuilderContext, buildTarget: Target) {
const executorToBuilderMap = new Map<string, string>([
['@nx/angular:application', '@angular/build:application'],
['@nx/angular:ng-packagr-lite', '@angular/build:ng-packagr'],
['@nx/angular:package', '@angular/build:ng-packagr'],
]);
const originalGetBuilderNameForTarget = context.getBuilderNameForTarget;
context.getBuilderNameForTarget = async (target) => {
const builderName = await originalGetBuilderNameForTarget(target);
if (executorToBuilderMap.has(builderName)) {
return executorToBuilderMap.get(builderName)!;
}
return builderName;
};
const originalGetTargetOptions = context.getTargetOptions;
context.getTargetOptions = async (target) => {
const options = await originalGetTargetOptions(target);
if (
target.project === buildTarget.project &&
target.target === buildTarget.target &&
target.configuration === buildTarget.configuration
) {
cleanBuildTargetOptions(options);
}
return options;
};
}
function cleanBuildTargetOptions(
options: ApplicationExecutorOptions | BuildAngularLibraryExecutorOptions
): ApplicationBuilderOptions | NgPackagrBuilderOptions {
if (
'buildLibsFromSource' in options ||
'indexHtmlTransformer' in options ||
'plugins' in options
) {
delete options.buildLibsFromSource;
delete options.indexHtmlTransformer;
delete options.plugins;
}
return options;
}
@@ -0,0 +1,15 @@
import { getInstalledPackageVersion } from '@nx/devkit/internal';
import { major } from 'semver';
export type VersionInfo = { major: number; version: string };
export function getInstalledAngularVersionInfo(): VersionInfo | null {
return getInstalledPackageVersionInfo('@angular/core');
}
export function getInstalledPackageVersionInfo(
pkgName: string
): VersionInfo | null {
const version = getInstalledPackageVersion(pkgName);
return version ? { major: major(version), version } : null;
}
@@ -0,0 +1,37 @@
import { readCachedProjectGraph, type ExecutorContext } from '@nx/devkit';
import {
calculateProjectDependencies,
createTmpTsConfig,
type DependentBuildableProjectNode,
} from '@nx/js/internal';
import { join } from 'path';
export function createTmpTsConfigForBuildableLibs(
tsConfigPath: string,
context: ExecutorContext
) {
let dependencies: DependentBuildableProjectNode[];
const result = calculateProjectDependencies(
context.projectGraph ?? readCachedProjectGraph(),
context.root,
context.projectName,
context.targetName,
context.configurationName
);
dependencies = result.dependencies;
const tmpTsConfigPath = createTmpTsConfig(
join(context.root, tsConfigPath),
context.root,
result.target.data.root,
dependencies
);
process.env.NX_TSCONFIG_PATH = tmpTsConfigPath;
const tmpTsConfigPathWithoutWorkspaceRoot = tmpTsConfigPath.replace(
context.root,
''
);
return { tsConfigPath: tmpTsConfigPathWithoutWorkspaceRoot, dependencies };
}
@@ -0,0 +1,9 @@
export function assertBuilderPackageIsInstalled(packageName: string): void {
try {
require.resolve(packageName);
} catch {
throw new Error(
`This executor requires the package ${packageName} to be installed. Please make sure it is installed and try again.`
);
}
}
@@ -0,0 +1,78 @@
import type { buildApplication } from '@angular/build';
import { workspaceRoot } from '@nx/devkit';
import { existsSync } from 'node:fs';
import { isAbsolute, join } from 'node:path';
import { loadModule } from './module-loader';
// This is a workaround to make sure we use the same esbuild version as the
// Angular DevKit uses. This is only used internally to load the plugins and
// forward them to the Angular DevKit builders.
type Plugin = Parameters<typeof buildApplication>[2]['codePlugins'][number];
export type PluginSpec = {
path: string;
options: any;
};
// Nx strips {workspaceRoot}/ and expands {projectRoot} in option paths to a
// path relative to the workspace root, but require() would resolve that against
// this file's directory. Anchor it to the workspace root, while leaving bare
// package specifiers (e.g. an esbuild plugin shipped as a package) untouched.
function resolveModulePath(path: string): string {
if (isAbsolute(path)) {
return path;
}
const candidate = join(workspaceRoot, path);
return existsSync(candidate) ? candidate : path;
}
export async function loadPlugins(
plugins: string[] | PluginSpec[] | undefined,
tsConfig: string
): Promise<Plugin[]> {
if (!plugins?.length) {
return [];
}
return Promise.all(
plugins.map((plugin: string | PluginSpec) => loadPlugin(plugin, tsConfig))
);
}
async function loadPlugin(
pluginSpec: string | PluginSpec,
tsConfig: string
): Promise<Plugin> {
const pluginPath =
typeof pluginSpec === 'string' ? pluginSpec : pluginSpec.path;
let plugin = await loadModule(resolveModulePath(pluginPath), tsConfig);
if (typeof plugin === 'function') {
plugin =
typeof pluginSpec === 'object' ? plugin(pluginSpec.options) : plugin();
}
return plugin;
}
export async function loadMiddleware(
middlewareFns: string[] | undefined,
tsConfig: string
): Promise<any[]> {
if (!middlewareFns?.length) {
return [];
}
return Promise.all(
middlewareFns.map((fnPath) =>
loadModule(resolveModulePath(fnPath), tsConfig)
)
);
}
export async function loadIndexHtmlTransformer(
indexHtmlTransformerPath: string,
tsConfig: string
): Promise<any> {
return loadModule(resolveModulePath(indexHtmlTransformerPath), tsConfig);
}
@@ -0,0 +1,65 @@
import { extname } from 'path';
import { pathToFileURL } from 'node:url';
import { loadTsFile, requireWithTsconfigFallback } from '@nx/js/internal';
export async function loadModule<T = any>(
path: string,
tsConfig?: string
): Promise<T> {
const ext = extname(path);
if (ext === '.mjs') {
const result = await loadEsmModule(pathToFileURL(path));
return (result as { default: T }).default ?? (result as T);
}
if (ext === '.cjs') {
const result = requireWithTsconfigFallback<T>(path, tsConfig);
return (result as { default?: T }).default ?? result;
}
const isTs = ext === '.ts' || ext === '.cts' || ext === '.mts';
try {
const result =
isTs && tsConfig
? loadTsFile<any>(path, tsConfig)
: requireWithTsconfigFallback<any>(path, tsConfig);
return result.default ?? result;
} catch (e: any) {
// ERR_REQUIRE_ESM (legacy) and ERR_REQUIRE_ASYNC_MODULE (Node 22.12+,
// ESM with top-level await) both indicate the module must be loaded via
// dynamic import.
if (e.code === 'ERR_REQUIRE_ESM' || e.code === 'ERR_REQUIRE_ASYNC_MODULE') {
const result = await loadEsmModule(pathToFileURL(path));
return (result as { default: T }).default ?? (result as T);
}
throw e;
}
}
/**
* Lazily compiled dynamic import loader function.
*/
let load: (<T>(modulePath: string | URL) => Promise<T>) | undefined;
/**
* This uses a dynamic import to load a module which may be ESM.
* CommonJS code can load ESM code via a dynamic import. Unfortunately, TypeScript
* will currently, unconditionally downlevel dynamic import into a require call.
* require calls cannot load ESM code and will result in a runtime error. To workaround
* this, a Function constructor is used to prevent TypeScript from changing the dynamic import.
* Once TypeScript provides support for keeping the dynamic import this workaround can
* be dropped.
*
* @param modulePath The path of the module to load.
* @returns A Promise that resolves to the dynamically imported module.
*/
export function loadEsmModule<T = any>(modulePath: string | URL): Promise<T> {
load ??= new Function('modulePath', `return import(modulePath);`) as Exclude<
typeof load,
undefined
>;
return load(modulePath);
}

Some files were not shown because too many files have changed in this diff Show More