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
+67
View File
@@ -0,0 +1,67 @@
import { baseConfig } from '../../eslint.config.mjs';
import * as jsoncEslintParser from 'jsonc-eslint-parser';
export default [
...baseConfig,
{
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: ['./package.json'],
rules: {
'@nx/nx-plugin-checks': 'error',
'@nx/dependency-checks': [
'error',
{
buildTargets: ['build-base'],
ignoredFiles: [
'{projectRoot}/eslint.config.mjs',
'{projectRoot}/vite.config.{js,ts,mjs,mts}',
],
ignoredDependencies: [
'@rspack/core',
'@angular/core',
'jsonc-eslint-parser',
'vitest',
'memfs',
'sass',
'tailwindcss',
'@babel/core',
'autoprefixer',
'css-loader',
'jsonc-parser',
'less-loader',
'loader-utils',
'open',
'ora',
'parse5-html-rewriting-stream',
'piscina',
'postcss',
'postcss-loader',
'rxjs',
'sass-loader',
'sass-embedded',
'webpack-merge',
'ws',
],
},
],
},
languageOptions: {
parser: jsoncEslintParser,
},
},
{
ignores: ['dist'],
},
];
+139
View File
@@ -0,0 +1,139 @@
{
"name": "@nx/angular-rspack",
"version": "0.0.1",
"private": false,
"publishConfig": {
"access": "public"
},
"description": "Rspack Plugin and Loaders for building Angular.",
"author": "Victor Savkin",
"license": "MIT",
"homepage": "https://nx.dev",
"bugs": {
"url": "https://github.com/nrwl/nx/issues"
},
"repository": {
"type": "git",
"url": "https://github.com/nrwl/nx.git",
"directory": "packages/angular-rspack"
},
"keywords": [
"Monorepo",
"Angular",
"Rspack"
],
"scripts": {},
"type": "commonjs",
"main": "./dist/lib/index.js",
"types": "./dist/lib/index.d.ts",
"typings": "./dist/lib/index.d.ts",
"files": [
"dist",
"!dist/tsconfig.lib.tsbuildinfo"
],
"exports": {
".": {
"@nx/nx-source": "./src/lib/index.ts",
"types": "./dist/lib/index.d.ts",
"default": "./dist/lib/index.js"
},
"./loaders/angular-partial-transform-loader": {
"@nx/nx-source": "./src/lib/plugins/loaders/angular-partial-transform.loader.ts",
"types": "./dist/lib/plugins/loaders/angular-partial-transform.loader.d.ts",
"default": "./dist/lib/plugins/loaders/angular-partial-transform.loader.js"
},
"./loaders/angular-loader": {
"@nx/nx-source": "./src/lib/plugins/loaders/angular-transform.loader.ts",
"types": "./dist/lib/plugins/loaders/angular-transform.loader.d.ts",
"default": "./dist/lib/plugins/loaders/angular-transform.loader.js"
},
"./loaders/platform-server-exports-loader": {
"@nx/nx-source": "./src/lib/plugins/loaders/platform-server-exports.loader.ts",
"types": "./dist/lib/plugins/loaders/platform-server-exports.loader.d.ts",
"default": "./dist/lib/plugins/loaders/platform-server-exports.loader.js"
}
},
"dependencies": {
"@ampproject/remapping": "2.3.0",
"@babel/core": "catalog:",
"@discoveryjs/json-ext": "0.6.3",
"@nx/angular-rspack-compiler": "workspace:*",
"@nx/devkit": "workspace:*",
"ansi-colors": "4.1.3",
"autoprefixer": "catalog:css",
"deepmerge": "^4.3.1",
"css-loader": "^7.1.2",
"jsonc-parser": "catalog:",
"less-loader": "catalog:css",
"license-webpack-plugin": "^4.0.2",
"loader-utils": "3.3.1",
"open": "10.1.0",
"ora": "catalog:",
"parse5-html-rewriting-stream": "7.0.0",
"piscina": "5.0.0",
"postcss": "catalog:css",
"postcss-loader": "catalog:css",
"resolve-url-loader": "^5.0.0",
"sass": "catalog:css",
"sass-embedded": "catalog:css",
"sass-loader": "catalog:css",
"source-map-loader": "^5.0.0",
"tslib": "catalog:typescript",
"webpack-merge": "^6.0.1",
"ws": "^8.18.0"
},
"devDependencies": {
"jsonc-eslint-parser": "^2.4.0"
},
"peerDependencies": {
"@angular/build": "catalog:angular-supported-versions",
"@angular/localize": "catalog:angular-supported-versions",
"@angular/platform-server": "catalog:angular-supported-versions",
"@angular/ssr": "catalog:angular-supported-versions",
"@rspack/core": "^2.0.0 || >=1.3.5 <1.7.0",
"tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0",
"zone.js": "catalog:angular-supported-versions"
},
"peerDependenciesMeta": {
"tailwindcss": {
"optional": true
},
"@angular/localize": {
"optional": true
},
"@angular/platform-server": {
"optional": true
},
"zone.js": {
"optional": true
}
},
"nx": {
"name": "angular-rspack",
"sourceRoot": "packages/angular-rspack/src",
"projectType": "library",
"targets": {
"build": {
"command": "node ./scripts/copy-readme.js angular-rspack packages/angular-rspack/readme-template.md packages/angular-rspack/README.md",
"inputs": [
"copyReadme"
],
"outputs": [
"{projectRoot}/README.md"
]
},
"test": {
"dependsOn": [
"build",
"^build-native",
"angular-rspack-compiler:build"
],
"options": {
"args": [
"--passWithNoTests"
]
}
}
}
}
}
@@ -0,0 +1,48 @@
<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.
## Build Angular with Rspack
The goal of `@nx/angular-rspack` is to make easy and straightforward to build Angular applications with [rspack](https://rspack.dev).
### Getting Started
#### Step 1: Create a new Nx Workspace with Angular Rspack Application
```bash
npx create-nx-workspace myorg
NX Let's create a new workspace [[https://nx.dev/getting-started/intro](https://nx.dev/getting-started/intro)]
✔ Which stack do you want to use? · angular
✔ Integrated monorepo, or standalone project? · integrated
✔ Application name · myorg
✔ Which bundler would you like to use? · rspack
✔ Default stylesheet format · css
✔ Do you want to enable Server-Side Rendering (SSR)? · No
✔ Which unit test runner would you like to use? · vitest
✔ Test runner to use for end to end (E2E) tests · playwright
✔ Which CI provider would you like to use? · skip
✔ Would you like remote caching to make your build faster? · skip
NX Creating your v20.8.0 workspace.
```
#### Step 2: Run Build and Serve commands
```bash
npx nx build myorg
npx nx serve myorg
```
{{content}}
@@ -0,0 +1,115 @@
import { TS_ALL_EXT_REGEX } from '@nx/angular-rspack-compiler';
import type { Configuration } from '@rspack/core';
import { isRspackV2 } from '../../utils/rspack-version';
import { isServeMode } from '../../utils/rspack-serve-env';
import type {
HashFormat,
I18nOptions,
NormalizedAngularRspackPluginOptions,
} from '../../models';
import { NgRspackPlugin } from '../../plugins/ng-rspack';
import { getDevServerConfig } from './dev-server-config-utils';
import { getPolyfillsEntry, toRspackEntries } from './entry-points';
import { getOptimization } from './optimization-config';
import { resolve } from 'path';
import { HmrLoader } from '../../plugins/loaders/hmr-accept-loader';
export async function getBrowserConfig(
normalizedOptions: NormalizedAngularRspackPluginOptions,
i18n: I18nOptions,
hashFormat: HashFormat,
defaultConfig: Configuration
): Promise<Configuration> {
const isDevServer = isServeMode();
const isProduction = process.env['NODE_ENV'] === 'production';
const { root } = normalizedOptions;
return {
...defaultConfig,
name: 'browser',
target: ['web', 'es2015'],
entry: {
main: {
import: [
...(i18n.shouldInline ? ['@angular/localize/init'] : []),
normalizedOptions.browser,
],
},
...getPolyfillsEntry(normalizedOptions.polyfills, normalizedOptions.aot),
...toRspackEntries(
normalizedOptions.globalStyles,
normalizedOptions.root,
'ngGlobalStyles'
),
...toRspackEntries(
normalizedOptions.globalScripts,
normalizedOptions.root
),
},
devServer: await getDevServerConfig(normalizedOptions, 'browser'),
output: {
...defaultConfig.output,
hashFunction: isProduction ? 'xxhash64' : undefined,
path: normalizedOptions.outputPath.browser,
cssFilename: `[name]${hashFormat.file}.css`,
filename: `[name]${hashFormat.chunk}.js`,
chunkFilename: `[name]${hashFormat.chunk}.js`,
...(isDevServer
? {}
: {
scriptType: 'module',
module: true,
chunkFormat: 'module',
chunkLoading: 'import',
workerChunkLoading: 'import',
}),
},
// v2 folded experiments.outputModule into top-level output.module.
experiments:
isDevServer || isRspackV2()
? {}
: ({ outputModule: true } as unknown as Configuration['experiments']),
resolve: {
...defaultConfig.resolve,
mainFields: ['es2020', 'es2015', 'browser', 'module', 'main'],
},
optimization: getOptimization(normalizedOptions, 'browser'),
module: {
...defaultConfig.module,
rules: [
{
test: TS_ALL_EXT_REGEX,
use: [
{
loader: 'builtin:swc-loader',
options: {
jsc: {
parser: {
syntax: 'typescript',
},
target: 'es2022',
},
},
},
],
},
...(normalizedOptions.devServer?.hmr
? [
{
loader: HmrLoader,
include: [resolve(root, normalizedOptions.browser)],
},
]
: []),
...(defaultConfig.module?.rules ?? []),
],
},
plugins: [
...(defaultConfig.plugins ?? []),
new NgRspackPlugin(normalizedOptions, {
i18nOptions: i18n,
platform: 'browser',
}),
],
};
}
@@ -0,0 +1,207 @@
import type { Compiler, Configuration } from '@rspack/core';
import { join, resolve } from 'node:path';
import {
JS_ALL_EXT_REGEX,
TS_ALL_EXT_REGEX,
} from '@nx/angular-rspack-compiler';
import { workspaceRoot } from '@nx/devkit';
import {
HashFormat,
I18nOptions,
NormalizedAngularRspackPluginOptions,
} from '../../models';
import { getStylesConfig } from './style-config-utils';
import { getCrossOriginLoading } from './helpers';
import { configureSourceMap } from './sourcemap-utils';
import { isServeMode } from '../../utils/rspack-serve-env';
import { StatsJsonPlugin } from '../../plugins/stats-json-plugin';
import { WatchFilesLogsPlugin } from '../../plugins/watch-file-logs-plugin';
import { getIndexInputFile } from '../../utils/index-file/get-index-input-file';
import { isRspackV2 } from '../../utils/rspack-version';
export async function getCommonConfig(
normalizedOptions: NormalizedAngularRspackPluginOptions,
i18n: I18nOptions,
i18nHash: string | (() => void),
hashFormat: HashFormat
) {
const isDevServer = isServeMode();
const crossOriginLoading = getCrossOriginLoading(normalizedOptions);
const sourceMapOptions = configureSourceMap(normalizedOptions.sourceMap);
const stylesConfig = await getStylesConfig(
normalizedOptions,
hashFormat,
normalizedOptions.hasServer ? 'server' : 'browser'
);
const indexInputFile = join(
normalizedOptions.root,
getIndexInputFile(normalizedOptions.index)
);
const indexInputWatchPlugin = {
apply: (compiler: Compiler) => {
compiler.hooks.thisCompilation.tap('build-angular', (compilation) => {
compilation.fileDependencies.add(indexInputFile);
});
},
};
const defaultConfig: Configuration = {
context: normalizedOptions.root,
mode:
normalizedOptions.optimization.scripts ||
normalizedOptions.optimization.styles.minify
? 'production'
: 'development',
devtool: normalizedOptions.sourceMap.scripts ? 'source-map' : false,
infrastructureLogging: {
appendOnly: false,
debug: normalizedOptions.verbose,
level: normalizedOptions.verbose ? 'verbose' : 'none',
},
performance: {
hints: false,
},
stats: 'none', // This is handled in the AngularRspackPlugin by the rspackStatsLogger
output: {
uniqueName: normalizedOptions.projectName ?? 'rspack-angular',
publicPath: normalizedOptions.deployUrl ?? '',
clean: false, // already taken care for by AngularRspackPlugin
crossOriginLoading,
trustedTypes: { policyName: 'angular#bundler' },
sourceMapFilename: normalizedOptions.sourceMap.scripts
? '[file].map'
: undefined,
...(isDevServer ? {} : { scriptType: 'module' }),
},
resolve: {
extensions: ['.ts', '.tsx', '.mjs', '.js'],
symlinks: !normalizedOptions.preserveSymlinks,
// Make sure we add the root node_modules directory to the node resolver
modules: ['node_modules', join(workspaceRoot, 'node_modules')],
conditionNames: ['es2020', 'es2015', '...'],
tsConfig: {
configFile: normalizedOptions.tsConfig,
},
alias: {
...(i18n.shouldInline && normalizedOptions.aot
? { '@angular/localize/init': false }
: {}),
...(normalizedOptions.fileReplacements?.reduce(
(aliases, replacement) => ({
...aliases,
[replacement.replace]: replacement.with,
}),
{}
) ?? {}),
},
},
resolveLoader: {
symlinks: !normalizedOptions.preserveSymlinks,
},
watch: normalizedOptions.watch,
watchOptions: {
// Default aggregateTimeout to batch rapid filesystem events (e.g., editor backup files)
aggregateTimeout: 50,
poll: normalizedOptions.poll,
followSymlinks: normalizedOptions.preserveSymlinks,
ignored:
normalizedOptions.poll === undefined ? undefined : '**/node_modules/**',
// User-provided watchOptions take precedence
...normalizedOptions.watchOptions,
},
ignoreWarnings: [
// https://github.com/webpack-contrib/source-map-loader/blob/b2de4249c7431dd8432da607e08f0f65e9d64219/src/index.js#L83
/Failed to parse source map from/,
// https://github.com/webpack-contrib/postcss-loader/blob/bd261875fdf9c596af4ffb3a1a73fe3c549befda/src/index.js#L153-L158
/Add postcss as project dependency/,
// esbuild will issue a warning, while still hoists the @charset at the very top.
// This is caused by a bug in css-loader https://github.com/webpack-contrib/css-loader/issues/1212
/"@charset" must be the first rule in the file/,
],
module: {
parser: {
javascript: {
url: false,
},
},
rules: [
{
test: /\.?(svg|html)$/,
// Only process HTML and SVG which are known Angular component resources.
resourceQuery: /\?ngResource/,
type: 'asset/source',
},
...stylesConfig.loaderRules,
...sourceMapOptions.sourceMapRules,
{ test: /[/\\]rxjs[/\\]add[/\\].+\.js$/, sideEffects: true },
{
test: TS_ALL_EXT_REGEX,
use: [
{
// eslint-disable-next-line @nx/enforce-module-boundaries
loader: require.resolve(
'@nx/angular-rspack/loaders/angular-loader'
),
},
],
},
{
test: JS_ALL_EXT_REGEX,
use: [
{
// eslint-disable-next-line @nx/enforce-module-boundaries
loader: require.resolve(
'@nx/angular-rspack/loaders/angular-partial-transform-loader'
),
},
],
},
],
},
plugins: [
...sourceMapOptions.sourceMapPlugins,
...(normalizedOptions.verbose ? [new WatchFilesLogsPlugin()] : []),
...(normalizedOptions.watch ? [indexInputWatchPlugin] : []),
...(normalizedOptions.statsJson
? [
new StatsJsonPlugin(
resolve(
normalizedOptions.root,
normalizedOptions.outputPath.base,
'stats.json'
)
),
]
: []),
...(i18n.shouldInline
? [
{
apply(compiler) {
compiler.hooks.compilation.tap(
'AngularRspackPlugin',
(compilation) => {
// Resolve from the live compiler: the statically imported
// copy can be a different @rspack/core major than the one
// running the build, and getCompilationHooks rejects
// foreign Compilation instances.
compiler.rspack.javascript.JavascriptModulesPlugin.getCompilationHooks(
compilation
).chunkHash.tap('AngularRspackPlugin', (_, hash) => {
hash.update(Buffer.from('$localize' + i18nHash));
});
}
);
},
},
]
: []),
...stylesConfig.plugins,
],
};
// TODO(v24): drop once @rspack/core v1 is out of the support window.
// v2 removed top-level `profile`; Rsdoctor replaces it.
if (normalizedOptions.statsJson && !isRspackV2()) {
(defaultConfig as { profile?: boolean }).profile = true;
}
return defaultConfig;
}
@@ -0,0 +1,360 @@
import type { DevServer } from '@rspack/core';
import assert from 'node:assert';
import { existsSync, promises as fsPromises } from 'node:fs';
import { extname, posix, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import type { NormalizedAngularRspackPluginOptions } from '../../models';
import { getIndexOutputFile } from '../../utils/index-file/get-index-output-file';
import { loadEsmModule } from '../../utils/misc-helpers';
export async function getDevServerConfig(
options: NormalizedAngularRspackPluginOptions,
platform: 'browser' | 'server'
): Promise<DevServer> {
const { root } = options;
const servePath = buildServePath(options);
return {
host: options.devServer.host,
port: options.devServer.port,
headers: {
'Access-Control-Allow-Origin': '*',
...options.devServer.headers,
},
historyApiFallback: {
index: posix.join(servePath, getIndexOutputFile(options.index)),
disableDotRule: true,
htmlAcceptHeaders: ['text/html', 'application/xhtml+xml'],
rewrites: [
{
from: new RegExp(`^(?!${servePath})/.*`),
to: (context) => context.parsedUrl.href,
},
],
},
compress: false,
static: false,
server: getServerConfig(options),
allowedHosts: getAllowedHostsConfig(
options.devServer.allowedHosts,
options.devServer.disableHostCheck
),
devMiddleware: {
publicPath: servePath,
writeToDisk:
platform === 'browser' && options.hasServer
? (file) => !file.includes('.hot-update.')
: undefined,
},
liveReload: options.devServer.liveReload,
hot:
options.devServer.hmr && !options.devServer.liveReload
? 'only'
: options.devServer.hmr,
proxy: await getProxyConfig(root, options.devServer.proxyConfig),
...getWebSocketSettings(options, servePath),
watchFiles: ['./src/**/*.*', './public/**/*.*'],
onListening:
platform === 'browser'
? (devServer) => {
if (!devServer) {
throw new Error('@rspack/dev-server is not defined');
}
const port =
(devServer.server?.address() as { port: number })?.port ??
options.devServer.port;
console.log('Listening on port:', port);
}
: undefined,
};
}
/**
* Resolve and build a URL _path_ that will be the root of the server. This resolves base href and
* deploy URL from the browser options and returns a path from the root.
*/
function buildServePath(options: NormalizedAngularRspackPluginOptions): string {
let servePath = options.devServer.servePath;
if (servePath === undefined) {
const defaultPath = findDefaultServePath(
options.baseHref,
options.deployUrl
);
if (defaultPath == null) {
console.warn(
`Warning: --deploy-url and/or --base-href contain unsupported values for ng serve. Default serve path of '/' used. Use --serve-path to override.`
);
}
servePath = defaultPath || '';
}
if (servePath.endsWith('/')) {
servePath = servePath.slice(0, -1);
}
if (!servePath.startsWith('/')) {
servePath = `/${servePath}`;
}
return servePath;
}
/**
* Find the default server path. We don't want to expose baseHref and deployUrl as arguments, only
* the browser options where needed.
*/
function findDefaultServePath(
baseHref?: string,
deployUrl?: string
): string | null {
if (!baseHref && !deployUrl) {
return '';
}
if (
/^(\w+:)?\/\//.test(baseHref || '') ||
/^(\w+:)?\/\//.test(deployUrl || '')
) {
// If baseHref or deployUrl is absolute, unsupported by ng serve
return null;
}
// normalize baseHref
// for ng serve the starting base is always `/` so a relative
// and root relative value are identical
const baseHrefParts = (baseHref || '')
.split('/')
.filter((part) => part !== '');
if (baseHref && !baseHref.endsWith('/')) {
baseHrefParts.pop();
}
const normalizedBaseHref =
baseHrefParts.length === 0 ? '/' : `/${baseHrefParts.join('/')}/`;
if (deployUrl && deployUrl[0] === '/') {
if (baseHref && baseHref[0] === '/' && normalizedBaseHref !== deployUrl) {
// If baseHref and deployUrl are root relative and not equivalent, unsupported by ng serve
return null;
}
return deployUrl;
}
// Join together baseHref and deployUrl
return `${normalizedBaseHref}${deployUrl || ''}`;
}
function getServerConfig(
options: NormalizedAngularRspackPluginOptions
): DevServer['server'] {
const {
root,
devServer: { ssl, sslCert, sslKey },
} = options;
if (!ssl) {
return 'http';
}
return {
type: 'https',
options:
sslCert && sslKey
? {
key: resolve(root, sslKey),
cert: resolve(root, sslCert),
}
: undefined,
};
}
function getAllowedHostsConfig(
allowedHosts: string[] | boolean | undefined,
disableHostCheck: boolean | undefined
) {
if (disableHostCheck || allowedHosts === true) {
return 'all';
}
if (Array.isArray(allowedHosts) && allowedHosts.length > 0) {
return allowedHosts;
}
return undefined;
}
async function getProxyConfig(
root: string,
proxyConfig: string | undefined
): Promise<DevServer['proxy'] | undefined> {
if (!proxyConfig) {
return undefined;
}
const proxyPath = resolve(root, proxyConfig);
if (!existsSync(proxyPath)) {
throw new Error(`Proxy configuration file ${proxyPath} does not exist.`);
}
let proxyConfiguration: Record<string, object> | object[];
switch (extname(proxyPath)) {
case '.json': {
const content = await fsPromises.readFile(proxyPath, 'utf-8');
const { parse, printParseErrorCode } = await import('jsonc-parser');
const parseErrors: import('jsonc-parser').ParseError[] = [];
proxyConfiguration = parse(content, parseErrors, {
allowTrailingComma: true,
});
if (parseErrors.length > 0) {
let errorMessage = `Proxy configuration file ${proxyPath} contains parse errors:`;
for (const parseError of parseErrors) {
const { line, column } = getJsonErrorLineColumn(
parseError.offset,
content
);
errorMessage += `\n[${line}, ${column}] ${printParseErrorCode(
parseError.error
)}`;
}
throw new Error(errorMessage);
}
break;
}
case '.mjs':
// Load the ESM configuration file using the TypeScript dynamic import workaround.
// Once TypeScript provides support for keeping the dynamic import this workaround can be
// changed to a direct dynamic import.
proxyConfiguration = (
await loadEsmModule<{ default: Record<string, object> | object[] }>(
pathToFileURL(proxyPath)
)
).default;
break;
case '.cjs':
proxyConfiguration = require(proxyPath);
break;
default:
// The file could be either CommonJS or ESM.
// CommonJS is tried first then ESM if loading fails.
try {
proxyConfiguration = require(proxyPath);
} catch (e) {
assertIsError(e);
if (
e.code !== 'ERR_REQUIRE_ESM' &&
e.code !== 'ERR_REQUIRE_ASYNC_MODULE'
) {
throw e;
}
// Load the ESM configuration file using the TypeScript dynamic import workaround.
// Once TypeScript provides support for keeping the dynamic import this workaround can be
// changed to a direct dynamic import.
proxyConfiguration = (
await loadEsmModule<{ default: Record<string, object> | object[] }>(
pathToFileURL(proxyPath)
)
).default;
}
}
return normalizeProxyConfiguration(proxyConfiguration);
}
function getWebSocketSettings(
options: NormalizedAngularRspackPluginOptions,
servePath: string
): {
webSocketServer?: DevServer['webSocketServer'];
client?: DevServer['client'];
} {
const { hmr, liveReload } = options.devServer;
if (!hmr && !liveReload) {
return { client: undefined, webSocketServer: false };
}
const webSocketPath = posix.join(servePath, 'ng-cli-ws');
return {
webSocketServer: {
options: { path: webSocketPath },
},
client: {
logging: 'info',
webSocketURL: getPublicHostOptions(options, webSocketPath),
overlay: {
errors: true,
warnings: false,
runtimeErrors: false,
},
},
};
}
function getPublicHostOptions(
options: NormalizedAngularRspackPluginOptions,
webSocketPath: string
): string {
let publicHost = options.devServer.publicHost;
if (publicHost) {
const hostWithProtocol = !/^\w+:\/\//.test(publicHost)
? `https://${publicHost}`
: publicHost;
publicHost = new URL(hostWithProtocol).host;
}
return `auto://${publicHost || '0.0.0.0:0'}${webSocketPath}`;
}
function getJsonErrorLineColumn(offset: number, content: string) {
if (offset === 0) {
return { line: 1, column: 1 };
}
let line = 0;
let position = 0;
while (true) {
++line;
const nextNewline = content.indexOf('\n', position);
if (nextNewline === -1 || nextNewline > offset) {
break;
}
position = nextNewline + 1;
}
return { line, column: offset - position + 1 };
}
function normalizeProxyConfiguration(
proxy: Record<string, object> | object[]
): DevServer['proxy'] {
return Array.isArray(proxy)
? proxy
: Object.entries(proxy).map(([context, value]) => ({
context: [context],
...value,
}));
}
function assertIsError(
value: unknown
): asserts value is Error & { code?: string } {
const isError =
value instanceof Error ||
// The following is needing to identify errors coming from RxJs.
(typeof value === 'object' &&
value &&
'name' in value &&
'message' in value);
assert(isError, 'catch clause variable is not an Error instance');
}
@@ -0,0 +1,59 @@
import { join } from 'node:path';
import type { GlobalEntry } from '../../models';
export function getEntryPoints(
globalStyles: GlobalEntry[],
globalScripts: GlobalEntry[],
isHMREnabled?: boolean
): [name: string, isModule: boolean][] {
return [
['runtime', !isHMREnabled],
['polyfills', true],
...(globalStyles.filter((s) => s.initial).map((s) => [s.name, false]) as [
string,
boolean,
][]),
...(globalScripts.filter((s) => s.initial).map((s) => [s.name, false]) as [
string,
boolean,
][]),
['vendor', true],
['main', true],
];
}
export function getPolyfillsEntry(
polyfills: string[],
aot: boolean
): { polyfills?: string[] } {
const normalizedPolyfills = [...polyfills];
if (!aot) {
normalizedPolyfills.push('@angular/compiler');
}
if (normalizedPolyfills.length) {
return { polyfills: normalizedPolyfills };
}
return {};
}
export function toRspackEntries(
entries: GlobalEntry[],
root: string,
queryString?: string
) {
const result: Record<string, { import: string[] }> = {};
for (const { files, name } of entries) {
result[name] ??= { import: [] };
for (const file of files) {
result[name].import.push(
join(root, `${file}${queryString ? `?${queryString}` : ''}`)
);
}
}
return result;
}
@@ -0,0 +1,41 @@
import { type StatsOptions } from '@rspack/core';
export function getStatsOptions(verbose = false): StatsOptions {
const rspackStatsOptions: StatsOptions = {
all: false, // Fallback value for stats options when an option is not defined. It has precedence over local webpack defaults.
colors: true,
hash: true, // required by custom stat output
timings: true, // required by custom stat output
chunks: true, // required by custom stat output
builtAt: true, // required by custom stat output
warnings: true,
errors: true,
assets: true, // required by custom stat output
cachedAssets: true, // required for bundle size calculators
// Needed for markAsyncChunksNonInitial.
ids: true,
entrypoints: true,
};
const verboseStatsOutputOptions: StatsOptions = {
// The verbose output will most likely be piped to a file, so colors just mess it up.
colors: false,
usedExports: true,
optimizationBailout: true,
reasons: true,
children: true,
assets: true,
version: true,
chunkModules: true,
errorDetails: true,
errorStack: true,
moduleTrace: true,
logging: 'verbose',
modulesSpace: Infinity,
};
return verbose
? { ...rspackStatsOptions, ...verboseStatsOutputOptions }
: rspackStatsOptions;
}
@@ -0,0 +1,142 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import { readdir, rm } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import {
AngularRspackPluginOptions,
HashFormat,
NormalizedAngularRspackPluginOptions,
normalizeOptions,
OutputHashing,
} from '../../models';
import { configureI18n } from '../i18n/create-i18n-options';
import type { Configuration } from '@rspack/core';
/**
* Delete an output directory, but error out if it's the root of the project.
*/
export async function deleteOutputDir(
root: string,
outputPath: string,
emptyOnlyDirectories?: string[]
): Promise<void> {
const resolvedOutputPath = resolve(root, outputPath);
if (resolvedOutputPath === root) {
throw new Error('Output path MUST not be project root directory!');
}
const directoriesToEmpty = emptyOnlyDirectories
? new Set(
emptyOnlyDirectories.map((directory) =>
join(resolvedOutputPath, directory)
)
)
: undefined;
// Avoid removing the actual directory to avoid errors in cases where the output
// directory is mounted or symlinked. Instead the contents are removed.
let entries;
try {
entries = await readdir(resolvedOutputPath);
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
return;
}
throw error;
}
for (const entry of entries) {
const fullEntry = join(resolvedOutputPath, entry);
// Leave requested directories. This allows symlinks to continue to function.
if (directoriesToEmpty?.has(fullEntry)) {
await deleteOutputDir(resolvedOutputPath, fullEntry);
continue;
}
await rm(fullEntry, { force: true, recursive: true, maxRetries: 3 });
}
}
export function getOutputHashFormat(
outputHashing: OutputHashing = 'none',
length = 8
): HashFormat {
const hashTemplate = `.[contenthash:${length}]`;
switch (outputHashing) {
case 'media':
return {
chunk: '',
extract: '',
file: hashTemplate,
script: '',
};
case 'bundles':
return {
chunk: hashTemplate,
extract: hashTemplate,
file: '',
script: hashTemplate,
};
case 'all':
return {
chunk: hashTemplate,
extract: hashTemplate,
file: hashTemplate,
script: hashTemplate,
};
case 'none':
default:
return {
chunk: '',
extract: '',
file: '',
script: '',
};
}
}
export async function normalizeOptionWithI18n(
options: AngularRspackPluginOptions
) {
const { options: _options, i18n } = await configureI18n(
options.root ?? process.cwd(),
options
);
// Update file hashes to include translation file content
const i18nHash = i18n.shouldInline
? Object.values(i18n.locales).reduce(
(data, locale) =>
data + locale.files.map((file) => file.integrity || '').join('|'),
''
)
: () => {
// no-op as i18n is not inlined
};
const normalizedOptions = await normalizeOptions(_options);
return { i18n, i18nHash, normalizedOptions };
}
export function getCrossOriginLoading(
normalizedOptions: NormalizedAngularRspackPluginOptions
) {
let crossOriginLoading: NonNullable<
Configuration['output']
>['crossOriginLoading'] = false;
if (
normalizedOptions.subresourceIntegrity &&
normalizedOptions.crossOrigin === 'none'
) {
crossOriginLoading = 'anonymous';
} else if (normalizedOptions.crossOrigin !== 'none') {
crossOriginLoading = normalizedOptions.crossOrigin;
}
return crossOriginLoading;
}
@@ -0,0 +1,75 @@
import {
LightningCssMinimizerRspackPlugin,
SwcJsMinimizerRspackPlugin,
Configuration,
} from '@rspack/core';
import { NormalizedAngularRspackPluginOptions } from '../../models';
import { TransferSizePlugin } from '../../plugins/transfer-size-plugin';
export const VENDORS_TEST = /[\\/]node_modules[\\/]/;
export function getOptimization(
normalizedOptions: NormalizedAngularRspackPluginOptions,
platform: 'browser' | 'server'
): Configuration['optimization'] {
return {
chunkIds: normalizedOptions.namedChunks ? 'named' : 'deterministic',
emitOnErrors: false,
moduleIds: 'deterministic',
runtimeChunk: platform === 'browser' ? 'single' : false,
minimizer: normalizedOptions.optimization.scripts
? [
new SwcJsMinimizerRspackPlugin({
minimizerOptions: {
minify: true,
...(platform === 'server'
? {
mangle: {
reserved: [
'renderApplication',
'renderModule',
'ɵSERVER_CONTEXT',
'ɵgetRoutesFromAngularRouterConfig',
],
},
}
: {}),
format: {
comments: false,
},
},
}),
new LightningCssMinimizerRspackPlugin(),
...(platform === 'browser' ? [new TransferSizePlugin()] : []),
]
: [],
splitChunks: {
chunks: platform === 'browser' ? 'all' : 'async',
minChunks: 1,
minSize: 20000,
maxAsyncRequests: 30,
maxInitialRequests: 30,
cacheGroups: {
default: normalizedOptions.commonChunk && {
chunks: 'async',
minChunks: 2,
priority: 10,
},
common: normalizedOptions.commonChunk && {
name: 'common',
chunks: 'async',
minChunks: 2,
enforce: true,
priority: 5,
},
vendors: false,
defaultVendors: normalizedOptions.vendorChunk && {
name: 'vendor',
chunks: (chunk) => chunk.name === 'main',
enforce: true,
test: VENDORS_TEST,
},
},
},
};
}
@@ -0,0 +1,104 @@
import { TS_ALL_EXT_REGEX } from '@nx/angular-rspack-compiler';
import { type Configuration, ContextReplacementPlugin } from '@rspack/core';
import { resolve } from 'path';
import type {
I18nOptions,
NormalizedAngularRspackPluginOptions,
} from '../../models';
import { NgRspackPlugin } from '../../plugins/ng-rspack';
import { PrerenderPlugin } from '../../plugins/prerender-plugin';
import { isPackageInstalled } from '../../utils/misc-helpers';
import { getDevServerConfig } from './dev-server-config-utils';
import { getOptimization } from './optimization-config';
import { isServeMode } from '../../utils/rspack-serve-env';
export async function getServerConfig(
normalizedOptions: NormalizedAngularRspackPluginOptions,
i18n: I18nOptions,
defaultConfig: Configuration
): Promise<Configuration> {
const isDevServer = isServeMode();
const { root } = normalizedOptions;
return {
...defaultConfig,
dependencies: ['browser'],
name: 'server',
target: ['node', 'es2015'],
entry: {
server: {
import: [
...(isPackageInstalled(root, '@angular/platform-server')
? // This import must come before any imports (direct or transitive) that rely on DOM built-ins being
// available, such as `@angular/elements`.
['@angular/platform-server/init']
: []),
...(i18n.shouldInline ? ['@angular/localize/init'] : []),
(normalizedOptions.ssr as { entry: string }).entry,
],
},
},
output: {
...defaultConfig.output,
path: normalizedOptions.outputPath.server,
filename: '[name].js',
chunkFilename: '[name].js',
library: { type: 'commonjs' },
},
resolve: {
...defaultConfig.resolve,
mainFields: ['es2020', 'es2015', 'module', 'main'],
},
devServer: await getDevServerConfig(normalizedOptions, 'server'),
externals: normalizedOptions.externalDependencies,
optimization: getOptimization(normalizedOptions, 'server'),
module: {
...defaultConfig.module,
rules: [
{
test: TS_ALL_EXT_REGEX,
use: [
{
loader: 'builtin:swc-loader',
options: {
jsc: {
parser: {
syntax: 'typescript',
},
target: 'es2022',
},
},
},
],
},
{
// eslint-disable-next-line @nx/enforce-module-boundaries
loader: require.resolve(
'@nx/angular-rspack/loaders/platform-server-exports-loader'
),
include: [
resolve(root, (normalizedOptions.ssr as { entry: string }).entry),
],
options: {
angularSSRInstalled: isPackageInstalled(root, '@angular/ssr'),
isZoneJsInstalled: isPackageInstalled(root, 'zone.js'),
},
},
...(defaultConfig.module?.rules ?? []),
],
},
plugins: [
...(defaultConfig.plugins ?? []),
// Fixes Critical dependency: the request of a dependency is an expression
new ContextReplacementPlugin(/@?hapi|express[\\/]/),
new NgRspackPlugin(normalizedOptions, {
i18nOptions: i18n,
platform: 'server',
}),
...(normalizedOptions.prerender ||
(normalizedOptions.appShell && !isDevServer)
? [new PrerenderPlugin(normalizedOptions, i18n)]
: []),
],
};
}
@@ -0,0 +1,61 @@
import {
RspackPluginInstance,
RuleSetRule,
SourceMapDevToolPlugin,
} from '@rspack/core';
import type { SourceMap } from '../../models';
import { DevToolsIgnorePlugin } from '../../plugins/tools/dev-tools-ignore-plugin';
export function configureSourceMap(sourceMap: SourceMap) {
const { scripts, styles, hidden, vendor } = sourceMap;
const sourceMapRules: RuleSetRule[] = [];
const sourceMapPlugins: RspackPluginInstance[] = [];
if (scripts || styles) {
const include: RegExp[] = [];
if (scripts) {
include.push(/js$/);
}
if (styles) {
include.push(/css$/);
}
sourceMapPlugins.push(new DevToolsIgnorePlugin());
sourceMapPlugins.push(
new SourceMapDevToolPlugin({
filename: '[file].map',
include,
// We want to set sourceRoot to `webpack:///` for non
// inline sourcemaps as otherwise paths to sourcemaps will be broken in browser
// `webpack:///` is needed for Visual Studio breakpoints to work properly as currently
// there is no way to set the 'webRoot'
sourceRoot: 'webpack:///',
moduleFilenameTemplate: '[resource-path]',
append: hidden ? false : undefined,
})
);
sourceMapRules.push({
test: /\.[cm]?jsx?$/,
enforce: 'pre',
loader: require.resolve('source-map-loader'),
options: {
filterSourceMappingUrl: (_mapUri: string, resourcePath: string) => {
if (vendor) {
// Consume all sourcemaps when vendor option is enabled.
return true;
}
// Don't consume sourcemaps in node_modules when vendor is disabled.
// But, do consume local libraries sourcemaps.
return !resourcePath.includes('node_modules');
},
},
});
}
return { sourceMapRules, sourceMapPlugins };
}
@@ -0,0 +1,486 @@
import { Sass } from '@nx/angular-rspack-compiler';
import { workspaceRoot } from '@nx/devkit';
import {
CssExtractRspackPlugin,
type LoaderContext,
type Plugins,
type RuleSetRules,
type RuleSetUseItem,
} from '@rspack/core';
import { createRequire } from 'node:module';
import { basename, dirname, relative, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import type { FileImporter } from 'sass';
import { isServeMode } from '../../utils/rspack-serve-env';
import type {
HashFormat,
NormalizedAngularRspackPluginOptions,
} from '../../models';
import PostcssCliResources from '../../utils/postcss-cli-resources';
import {
findTailwindConfiguration,
generateSearchDirectories,
loadPostcssConfiguration,
type SearchDirectory,
} from '../../utils/postcss-configuration';
import { AnyComponentStyleBudgetChecker } from '../../plugins/any-component-style-budget-checker-plugin';
import { SuppressJsForCssOnlyEntryPlugin } from '../../plugins/suppress-js-for-css-chunks-plugin';
export async function getStylesConfig(
buildOptions: NormalizedAngularRspackPluginOptions,
hashFormat: HashFormat,
platform: 'browser' | 'server'
): Promise<{
loaderRules: RuleSetRules;
plugins: Plugins;
}> {
const isDevServer = isServeMode();
const extraPlugins: Plugins = [];
extraPlugins.push(new AnyComponentStyleBudgetChecker(buildOptions.budgets));
if (!isDevServer || buildOptions.devServer?.hmr) {
extraPlugins.push(new SuppressJsForCssOnlyEntryPlugin());
}
const cssSourceMap = buildOptions.sourceMap.styles;
// use includePaths from appConfig
const includePaths =
buildOptions.stylePreprocessorOptions?.includePaths?.map((p) =>
resolve(buildOptions.root, p)
) ?? [];
const assetNameTemplate = assetNameTemplateFactory(hashFormat);
const extraPostcssPlugins: import('postcss').Plugin[] = [];
// A configuration file can exist in the project or workspace root
const searchDirectories = await generateSearchDirectories([
buildOptions.root,
workspaceRoot,
]);
const postcssConfiguration =
await loadPostcssConfiguration(searchDirectories);
if (postcssConfiguration) {
const postCssPluginRequire = createRequire(
dirname(postcssConfiguration.configPath) + '/'
);
for (const [pluginName, pluginOptions] of postcssConfiguration.config
.plugins) {
const pluginModule = postCssPluginRequire(pluginName);
const plugin = pluginModule.__esModule
? pluginModule['default']
: pluginModule;
if (typeof plugin !== 'function' || plugin.postcss !== true) {
throw new Error(
`Attempted to load invalid Postcss plugin: "${pluginName}"`
);
}
extraPostcssPlugins.push(plugin(pluginOptions));
}
} else {
const tailwindConfig = await getTailwindConfig(
searchDirectories,
workspaceRoot
);
if (tailwindConfig) {
const tailwind = await import(tailwindConfig.package);
extraPostcssPlugins.push(
tailwind.default({ config: tailwindConfig.file })
);
}
}
const autoprefixer: typeof import('autoprefixer') = require('autoprefixer');
const postcssOptionsCreator = (
inlineSourcemaps: boolean,
extracted: boolean
) => {
const optionGenerator = (loader: LoaderContext<unknown>) => ({
map: inlineSourcemaps
? {
inline: true,
annotation: false,
}
: undefined,
plugins: [
PostcssCliResources({
baseHref: buildOptions.baseHref,
deployUrl: buildOptions.deployUrl,
resourcesOutputPath: relative(
buildOptions.outputPath.browser,
buildOptions.outputPath.media
),
loader,
filename: assetNameTemplate,
emitFile: platform !== 'server',
extracted,
}),
...extraPostcssPlugins,
autoprefixer({
ignoreUnknownVersions: true,
overrideBrowserslist: buildOptions.supportedBrowsers,
}),
],
});
// postcss-loader fails when trying to determine configuration files for data URIs
optionGenerator.config = false;
return optionGenerator;
};
let componentsSourceMap = !!cssSourceMap;
if (cssSourceMap) {
if (buildOptions.optimization.styles.minify) {
// Never use component css sourcemap when style optimizations are on.
// It will just increase bundle size without offering good debug experience.
console.warn(
'Components styles sourcemaps are not generated when styles optimization is enabled.'
);
componentsSourceMap = false;
} else if (buildOptions.sourceMap.hidden) {
// Inline all sourcemap types except hidden ones, which are the same as no sourcemaps
// for component css.
console.warn(
'Components styles sourcemaps are not generated when sourcemaps are hidden.'
);
componentsSourceMap = false;
}
}
// extract global css from js files into own css file.
extraPlugins.push(
new CssExtractRspackPlugin({ filename: `[name]${hashFormat.extract}.css` })
);
const postCss = require('postcss');
const postCssLoaderPath = require.resolve('postcss-loader');
const componentStyleLoaders: RuleSetUseItem[] = [
{
loader: require.resolve('css-loader'),
options: {
url: false,
sourceMap: componentsSourceMap,
importLoaders: 1,
exportType: 'string',
esModule: false,
},
},
{
loader: postCssLoaderPath,
options: {
implementation: postCss,
postcssOptions: postcssOptionsCreator(componentsSourceMap, false),
},
},
];
const globalStyleLoaders: RuleSetUseItem[] = [
{
loader: CssExtractRspackPlugin.loader,
},
{
loader: require.resolve('css-loader'),
options: {
url: false,
sourceMap: !!cssSourceMap,
importLoaders: 1,
},
},
{
loader: postCssLoaderPath,
options: {
implementation: postCss,
postcssOptions: postcssOptionsCreator(false, true),
sourceMap: !!cssSourceMap,
},
},
];
const styleLanguages: {
extensions: string[];
use: RuleSetUseItem[];
}[] = [
{
extensions: ['css'],
use: [],
},
{
extensions: ['scss'],
use: [
{
loader: require.resolve('resolve-url-loader'),
options: {
sourceMap: cssSourceMap,
},
},
{
loader: require.resolve('sass-loader'),
options: getSassLoaderOptions(
workspaceRoot,
includePaths,
false,
!!buildOptions.verbose,
!!buildOptions.preserveSymlinks,
buildOptions.stylePreprocessorOptions?.sass
),
},
],
},
{
extensions: ['sass'],
use: [
{
loader: require.resolve('resolve-url-loader'),
options: {
sourceMap: cssSourceMap,
},
},
{
loader: require.resolve('sass-loader'),
options: getSassLoaderOptions(
workspaceRoot,
includePaths,
true,
!!buildOptions.verbose,
!!buildOptions.preserveSymlinks,
buildOptions.stylePreprocessorOptions?.sass
),
},
],
},
{
extensions: ['less'],
use: [
{
loader: require.resolve('less-loader'),
options: {
sourceMap: cssSourceMap,
lessOptions: {
javascriptEnabled: true,
paths: includePaths,
},
},
},
],
},
];
return {
loaderRules: styleLanguages.map(({ extensions, use }) => ({
test: new RegExp(`\\.(?:${extensions.join('|')})$`, 'i'),
oneOf: [
// Global styles are only defined global styles
{
resourceQuery: /\?ngGlobalStyle/,
use: [...globalStyleLoaders, ...use],
},
// Component styles are all styles except defined global styles
{
resourceQuery: /\?ngResource/,
use: [...componentStyleLoaders, ...use],
},
// Fallthrough for anything not query-tagged
{ use },
],
})),
plugins: extraPlugins,
};
}
function assetNameTemplateFactory(
hashFormat: HashFormat
): (resourcePath: string) => string {
const visitedFiles = new Map<string, string>();
return (resourcePath: string) => {
if (hashFormat.file) {
// File names are hashed therefore we don't need to handle files with the same file name.
return `[name]${hashFormat.file}.[ext]`;
}
const filename = basename(resourcePath);
// Check if the file with the same name has already been processed.
const visited = visitedFiles.get(filename);
if (!visited) {
// Not visited.
visitedFiles.set(filename, resourcePath);
return filename;
} else if (visited === resourcePath) {
// Same file.
return filename;
}
// File has the same name but it's in a different location.
return '[path][name].[ext]';
};
}
function getSassLoaderOptions(
root: string,
includePaths: string[],
indentedSyntax: boolean,
verbose: boolean,
preserveSymlinks: boolean,
deprecationOptions: Sass | undefined
): Record<string, unknown> {
return {
api: 'modern-compiler',
sourceMap: true,
implementation: require.resolve('sass-embedded'),
// Webpack importer is only implemented in the legacy API and we have our own custom Webpack importer.
// See: https://github.com/webpack-contrib/sass-loader/blob/997f3eb41d86dd00d5fa49c395a1aeb41573108c/src/utils.js#L642-L651
webpackImporter: false,
sassOptions: (loaderContext: LoaderContext<unknown>) => ({
importers: [
getSassResolutionImporter(loaderContext, root, preserveSymlinks),
],
loadPaths: includePaths,
// Use expanded as otherwise sass will remove comments that are needed for autoprefixer
// Ex: /* autoprefixer grid: autoplace */
// See: https://github.com/webpack-contrib/sass-loader/blob/45ad0be17264ceada5f0b4fb87e9357abe85c4ff/src/getSassOptions.js#L68-L70
style: 'expanded',
// Silences compiler warnings from 3rd party stylesheets
quietDeps: !verbose,
verbose,
syntax: indentedSyntax ? 'indented' : 'scss',
sourceMapIncludeSources: true,
...(deprecationOptions || {}),
}),
};
}
function getSassResolutionImporter(
loaderContext: LoaderContext<unknown>,
root: string,
preserveSymlinks: boolean
): FileImporter<'async'> {
const commonResolverOptions: Parameters<
(typeof loaderContext)['getResolve']
>[0] = {
conditionNames: ['sass', 'style'],
mainFields: ['sass', 'style', 'main', '...'],
extensions: ['.scss', '.sass', '.css'],
restrictions: [/\.((sa|sc|c)ss)$/i] as any,
preferRelative: true,
symlinks: !preserveSymlinks,
};
// Sass also supports import-only files. If you name a file <name>.import.scss, it will only be loaded for imports, not for @uses.
// See: https://sass-lang.com/documentation/at-rules/import#import-only-files
const resolveImport = loaderContext.getResolve({
byDependency: {
'sass-import': {
...commonResolverOptions,
mainFiles: ['_index.import', '_index', 'index.import', 'index', '...'],
},
},
});
const resolveModule = loaderContext.getResolve({
byDependency: {
'sass-module': {
...commonResolverOptions,
mainFiles: ['_index', 'index', '...'],
},
},
});
return {
findFileUrl: async (
url: string,
{ fromImport, containingUrl }
): Promise<URL | null> => {
if (url.charAt(0) === '.') {
// Let Sass handle relative imports.
return null;
}
let resolveDir = root;
if (containingUrl) {
resolveDir = dirname(fileURLToPath(containingUrl));
}
const resolve = fromImport ? resolveImport : resolveModule;
// Try to resolve from root of workspace
const result = await tryResolve(resolve, resolveDir, url);
return result ? pathToFileURL(result) : null;
},
};
}
async function tryResolve(
resolve: ReturnType<LoaderContext<unknown>['getResolve']>,
root: string,
url: string
): Promise<string | false | undefined> {
const promisifiedResolve = (root: string, url: string) =>
new Promise<string | false | undefined>((res, rej) => {
resolve(root, url, (err, result) => {
if (err) {
rej();
} else {
res(result);
}
});
});
try {
return await promisifiedResolve(root, url);
} catch {
// Try to resolve a partial file
// @use '@material/button/button' as mdc-button;
// `@material/button/button` -> `@material/button/_button`
const lastSlashIndex = url.lastIndexOf('/');
const underscoreIndex = lastSlashIndex + 1;
if (underscoreIndex > 0 && url.charAt(underscoreIndex) !== '_') {
const partialFileUrl = `${url.slice(0, underscoreIndex)}_${url.slice(
underscoreIndex
)}`;
return promisifiedResolve(root, partialFileUrl).catch(() => undefined);
}
}
return undefined;
}
async function getTailwindConfig(
searchDirectories: SearchDirectory[],
workspaceRoot: string
): Promise<{ file: string; package: string } | undefined> {
const tailwindConfigurationPath =
findTailwindConfiguration(searchDirectories);
if (!tailwindConfigurationPath) {
return undefined;
}
// Create a node resolver from the configuration file
const resolver = createRequire(tailwindConfigurationPath);
try {
return {
file: tailwindConfigurationPath,
package: resolver.resolve('tailwindcss'),
};
} catch {
const relativeTailwindConfigPath = relative(
workspaceRoot,
tailwindConfigurationPath
);
console.warn(
`Tailwind CSS configuration file found (${relativeTailwindConfigPath})` +
` but the 'tailwindcss' package is not installed.` +
` To enable Tailwind CSS, please install the 'tailwindcss' package.`
);
}
return undefined;
}
@@ -0,0 +1,41 @@
import type { Configuration } from '@rspack/core';
import { merge as rspackMerge } from 'webpack-merge';
import deepMerge from 'deepmerge';
import type { AngularRspackPluginOptions } from '../../models';
export function handleConfigurations(
defaultOptions: {
options: AngularRspackPluginOptions;
rspackConfigOverrides?: Partial<Configuration>;
},
configurations: Record<
string,
{
options: Partial<AngularRspackPluginOptions>;
rspackConfigOverrides?: Partial<Configuration>;
}
>,
configurationModes: string[]
) {
let mergedConfigurationBuildOptions = { ...defaultOptions.options };
let mergedRspackConfigOverrides = defaultOptions.rspackConfigOverrides ?? {};
for (const configurationName of configurationModes) {
if (configurationName in configurations) {
mergedConfigurationBuildOptions = deepMerge(
mergedConfigurationBuildOptions,
configurations[configurationName].options ?? {}
);
if (configurations[configurationName].rspackConfigOverrides) {
mergedRspackConfigOverrides = rspackMerge(
mergedRspackConfigOverrides,
configurations[configurationName].rspackConfigOverrides
);
}
}
}
return { mergedConfigurationBuildOptions, mergedRspackConfigOverrides };
}
export function parseConfigurationMode(configurationMode: string) {
return configurationMode.split(',').map((m) => m.trim());
}
@@ -0,0 +1,456 @@
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AngularRspackPluginOptions } from '../models';
import * as postcssConfiguration from '../utils/postcss-configuration';
import { handleConfigurations } from './config-utils/user-defined-config-helpers';
import { _createConfig, createConfig } from './create-config';
describe('createConfig', () => {
const configBase: AngularRspackPluginOptions = {
browser: './src/main.ts',
index: './src/index.html',
tsConfig: './tsconfig.base.json',
outputPath: './dist',
inlineStyleLanguage: 'css',
polyfills: [],
styles: [],
assets: [],
fileReplacements: [],
optimization: true,
outputHashing: 'all',
scripts: [],
aot: true,
skipTypeChecking: false,
deleteOutputPath: false,
};
beforeEach(() => {
vi.stubEnv('NODE_ENV', '');
vi.stubEnv('NGRS_CONFIG', '');
});
afterEach(() => {
vi.clearAllMocks();
});
it('should create config for mode "production" when optimization=true', async () => {
await expect(_createConfig(configBase)).resolves.toStrictEqual([
expect.objectContaining({ mode: 'production' }),
]);
}, 10000);
it('should create config for mode "development" when optimization=false', async () => {
await expect(
_createConfig({ ...configBase, optimization: false })
).resolves.toStrictEqual([
expect.objectContaining({ mode: 'development' }),
]);
});
describe('createConfig', () => {
const runCreateConfig = () => {
return createConfig(
{ options: configBase },
{
development: {
options: {
browser: './src/dev.main.ts',
skipTypeChecking: true,
},
},
production: {
options: {
browser: './src/prod.main.ts',
skipTypeChecking: false,
},
},
}
);
};
it('should create config from options', async () => {
const { scripts, styles, ...rest } = configBase;
await expect(
createConfig({ options: configBase })
).resolves.toMatchObject([
expect.objectContaining({
mode: 'production',
devServer: expect.objectContaining({
port: 4200,
}),
plugins: expect.arrayContaining([
{
i18n: expect.objectContaining({}),
isPlatformServer: false,
pluginOptions: expect.objectContaining({
...rest,
outputPath: {
base: join(process.cwd(), 'dist'),
browser: join(process.cwd(), 'dist', 'browser'),
server: join(process.cwd(), 'dist', 'server'),
media: join(process.cwd(), 'dist', 'browser', 'media'),
},
index: expect.objectContaining({
input: join(process.cwd(), 'src/index.html'),
output: 'index.html',
}),
tsConfig: join(process.cwd(), 'tsconfig.base.json'),
sourceMap: {
scripts: false,
styles: false,
hidden: false,
vendor: false,
},
optimization: {
fonts: {
inline: true,
},
styles: {
minify: true,
inlineCritical: true,
},
scripts: true,
},
advancedOptimizations: true,
useTsProjectReferences: false,
polyfills: [],
globalScripts: scripts,
globalStyles: styles,
devServer: expect.objectContaining({
host: 'localhost',
port: 4200,
}),
}),
},
]),
}),
]);
});
it('should create i18n options correctly', async () => {
await expect(
createConfig({
options: {
...configBase,
i18nMetadata: {
locales: {
fr: {
translation: 'src/locale/messages.fr.xlf',
},
},
sourceLocale: 'en-GB',
},
},
})
).resolves.toStrictEqual([
expect.objectContaining({
mode: 'production',
devServer: expect.objectContaining({
port: 4200,
}),
plugins: expect.arrayContaining([
expect.objectContaining({
i18n: expect.objectContaining({
hasDefinedSourceLocale: true,
locales: {
'en-GB': expect.objectContaining({
subPath: 'en-GB',
dataPath: expect.stringContaining(
'@angular/common/locales/global/en-GB.js'
),
}),
fr: expect.objectContaining({
subPath: 'fr',
files: [
expect.objectContaining({
path: 'src/locale/messages.fr.xlf',
}),
],
}),
},
}),
pluginOptions: expect.objectContaining({}),
}),
]),
}),
]);
});
it('should create config from options with a custom root', async () => {
const customRoot = join(process.cwd(), 'custom-root');
vi.spyOn(
postcssConfiguration,
'generateSearchDirectories'
).mockResolvedValue([]);
await expect(
createConfig({
options: { ...configBase, root: customRoot },
})
).resolves.toMatchObject([
expect.objectContaining({
mode: 'production',
devServer: expect.objectContaining({
port: 4200,
}),
plugins: expect.arrayContaining([
{
i18n: expect.objectContaining({}),
isPlatformServer: false,
pluginOptions: expect.objectContaining({
outputPath: {
base: join(customRoot, 'dist'),
browser: join(customRoot, 'dist', 'browser'),
server: join(customRoot, 'dist', 'server'),
media: join(customRoot, 'dist', 'browser', 'media'),
},
index: expect.objectContaining({
input: join(customRoot, 'src/index.html'),
output: 'index.html',
}),
tsConfig: join(customRoot, 'tsconfig.base.json'),
}),
},
]),
}),
]);
});
it('should allow turning off optimizations', async () => {
const { scripts, styles, ...rest } = configBase;
await expect(
createConfig({ options: { ...configBase, optimization: false } })
).resolves.toMatchObject([
expect.objectContaining({
mode: 'development',
devServer: expect.objectContaining({
port: 4200,
}),
plugins: expect.arrayContaining([
{
i18n: expect.objectContaining({}),
isPlatformServer: false,
pluginOptions: expect.objectContaining({
...rest,
outputPath: {
base: join(process.cwd(), 'dist'),
browser: join(process.cwd(), 'dist', 'browser'),
server: join(process.cwd(), 'dist', 'server'),
media: join(process.cwd(), 'dist', 'browser', 'media'),
},
index: expect.objectContaining({
input: join(process.cwd(), 'src/index.html'),
output: 'index.html',
}),
tsConfig: join(process.cwd(), 'tsconfig.base.json'),
sourceMap: {
scripts: false,
styles: false,
hidden: false,
vendor: false,
},
polyfills: [],
optimization: {
fonts: {
inline: false,
},
styles: {
minify: false,
inlineCritical: false,
},
scripts: false,
},
advancedOptimizations: false,
}),
},
]),
}),
]);
});
it('should allow changing the devServer port', async () => {
await expect(
createConfig({
options: {
...configBase,
devServer: { port: 8080 },
},
})
).resolves.toStrictEqual([
expect.objectContaining({
devServer: expect.objectContaining({
port: 8080,
}),
}),
]);
});
it('should allow changing the devServer host', async () => {
await expect(
createConfig({
options: {
...configBase,
devServer: { host: '0.0.0.0' },
},
})
).resolves.toStrictEqual([
expect.objectContaining({
devServer: expect.objectContaining({
host: '0.0.0.0',
client: expect.objectContaining({
webSocketURL: 'auto://0.0.0.0:0/ng-cli-ws',
}),
}),
}),
]);
});
it('should set default watchOptions with aggregateTimeout of 50ms', async () => {
await expect(
createConfig({ options: configBase })
).resolves.toStrictEqual([
expect.objectContaining({
watchOptions: expect.objectContaining({
aggregateTimeout: 50,
}),
}),
]);
});
it('should allow overriding watchOptions.aggregateTimeout', async () => {
await expect(
createConfig({
options: {
...configBase,
watchOptions: { aggregateTimeout: 200 },
},
})
).resolves.toStrictEqual([
expect.objectContaining({
watchOptions: expect.objectContaining({
aggregateTimeout: 200,
}),
}),
]);
});
it('should merge watchOptions with poll option', async () => {
await expect(
createConfig({
options: {
...configBase,
poll: 1000,
watchOptions: { aggregateTimeout: 100 },
},
})
).resolves.toStrictEqual([
expect.objectContaining({
watchOptions: expect.objectContaining({
aggregateTimeout: 100,
poll: 1000,
ignored: '**/node_modules/**',
}),
}),
]);
});
it.each([
['development', 'dev', true],
['production', 'prod', false],
])(
'should create config for mode "development" if env variable NGRS_CONFIG is "%s"',
async (configuration, fileNameSegment, skipTypeChecking) => {
vi.stubEnv('NGRS_CONFIG', configuration);
const config = await runCreateConfig();
const plugins = config[0].plugins;
const NgRspackPlugin = plugins?.find(
(plugin) => plugin?.constructor.name === 'NgRspackPlugin'
);
expect(NgRspackPlugin).toBeDefined();
expect(
NgRspackPlugin['pluginOptions'] as AngularRspackPluginOptions
).toEqual(
expect.objectContaining({
browser: `./src/${fileNameSegment}.main.ts`,
skipTypeChecking,
})
);
}
);
it('should merge user stats options via rspackConfigOverrides', async () => {
const config = await createConfig({
options: configBase,
rspackConfigOverrides: {
stats: { all: false, chunks: false },
},
});
expect(config[0].stats).toEqual(
expect.objectContaining({ all: false, chunks: false })
);
});
it('should allow user to fully override stats options', async () => {
const customStats = {
all: false,
assets: true,
errors: true,
warnings: true,
};
const config = await createConfig({
options: configBase,
rspackConfigOverrides: {
stats: customStats,
},
});
expect(config[0].stats).toEqual(expect.objectContaining(customStats));
});
it('should successfully merge multiple configurations', () => {
const config = handleConfigurations(
{
options: {
...configBase,
i18nMetadata: {
locales: {
fr: {
translation: 'src/locale/messages.fr.xlf',
},
de: {
translation: 'src/locale/messages.de.xlf',
},
},
sourceLocale: 'en-GB',
},
},
},
{
fr: {
options: {
localize: ['fr'],
},
},
de: {
options: {
localize: ['de'],
},
},
},
['fr', 'de']
);
expect(config).toStrictEqual(
expect.objectContaining({
mergedConfigurationBuildOptions: expect.objectContaining({
localize: ['fr', 'de'],
}),
})
);
});
});
});
@@ -0,0 +1,90 @@
import { type Configuration } from '@rspack/core';
import { merge as rspackMerge } from 'webpack-merge';
import { type AngularRspackPluginOptions } from '../models';
import {
deleteOutputDir,
getOutputHashFormat,
normalizeOptionWithI18n,
} from './config-utils/helpers';
import { getCommonConfig } from './config-utils/common-config';
import { getServerConfig } from './config-utils/server-config';
import { getBrowserConfig } from './config-utils/browser-config';
import {
handleConfigurations,
parseConfigurationMode,
} from './config-utils/user-defined-config-helpers';
import { assertSupportedRspackCoreVersion } from '../utils/assert-supported-rspack-version';
import { isServeMode } from '../utils/rspack-serve-env';
export async function createConfig(
defaultOptions: {
options: AngularRspackPluginOptions;
rspackConfigOverrides?: Partial<Configuration>;
},
configurations: Record<
string,
{
options: Partial<AngularRspackPluginOptions>;
rspackConfigOverrides?: Partial<Configuration>;
}
> = {},
configEnvVar = 'NGRS_CONFIG'
): Promise<Configuration[]> {
const configurationMode =
process.env[configEnvVar] ?? (isServeMode() ? 'development' : 'production');
const configurationModes = parseConfigurationMode(configurationMode);
const { mergedConfigurationBuildOptions, mergedRspackConfigOverrides } =
handleConfigurations(defaultOptions, configurations, configurationModes);
return _createConfig(
mergedConfigurationBuildOptions,
mergedRspackConfigOverrides
);
}
export async function _createConfig(
options: AngularRspackPluginOptions,
rspackConfigOverrides?: Partial<Configuration>
): Promise<Configuration[]> {
assertSupportedRspackCoreVersion();
const { i18n, i18nHash, normalizedOptions } =
await normalizeOptionWithI18n(options);
const hashFormat = getOutputHashFormat(normalizedOptions.outputHashing);
if (normalizedOptions.deleteOutputPath) {
await deleteOutputDir(
normalizedOptions.root,
normalizedOptions.outputPath.base
);
}
const defaultConfig = await getCommonConfig(
normalizedOptions,
i18n,
i18nHash,
hashFormat
);
const configs: Configuration[] = [];
if (normalizedOptions.hasServer) {
const serverConfig: Configuration = await getServerConfig(
normalizedOptions,
i18n,
defaultConfig
);
const mergedConfig = rspackMerge(serverConfig, rspackConfigOverrides ?? {});
configs.push(mergedConfig);
}
const browserConfig: Configuration = await getBrowserConfig(
normalizedOptions,
i18n,
hashFormat,
defaultConfig
);
const mergedConfig = rspackMerge(browserConfig, rspackConfigOverrides ?? {});
configs.unshift(mergedConfig);
return configs;
}
@@ -0,0 +1,174 @@
import {
createI18nOptions as _createI18nOptions,
createTranslationLoader,
loadTranslations,
} from '@angular/build/private';
import { logger, workspaceRoot } from '@nx/devkit';
import { createRequire } from 'node:module';
import { join, relative } from 'node:path';
import type { AngularRspackPluginOptions } from '../../models';
import {
createProjectRootMappings,
findProjectForPath,
} from '../../utils/find-project-for-path';
import { retrieveOrCreateProjectGraph } from '../../utils/graph';
/**
* The base module location used to search for locale specific data.
*/
const LOCALE_DATA_BASE_MODULE = '@angular/common/locales/global';
function findLocaleDataPath(
locale: string,
resolver: (locale: string) => string
): string | null {
// Remove private use subtags
const scrubbedLocale = locale.replace(/-x(-[a-zA-Z0-9]{1,8})+$/, '');
try {
return resolver(scrubbedLocale);
} catch {
// fallback to known existing en-US locale data as of 14.0
return scrubbedLocale === 'en-US'
? findLocaleDataPath('en', resolver)
: null;
}
}
async function getI18nMetadata(
projectRoot: string,
i18nMetadata: AngularRspackPluginOptions['i18nMetadata']
) {
if (i18nMetadata === undefined) {
return await tryGetI18nMetadataFromProject(projectRoot);
}
return i18nMetadata;
}
async function tryGetI18nMetadataFromProject(projectRoot: string) {
try {
const graph = await retrieveOrCreateProjectGraph();
if (!graph) {
return undefined;
}
const projectRootMappings = createProjectRootMappings(graph.nodes);
const root = projectRoot.startsWith(workspaceRoot)
? relative(workspaceRoot, projectRoot)
: projectRoot;
const projectName = findProjectForPath(root, projectRootMappings);
if (!projectName) {
// Could not find a project the given root
return undefined;
}
const project = graph.nodes[projectName];
// Need to cast to any as Nx is not aware of the i18n metadata that exists in angular projects
const projectData = project.data as any;
if (projectData.i18n && typeof projectData.i18n === 'object') {
return projectData.i18n;
}
} catch {
// Issue attempting to use the Nx project graph to determine the project to get the i18n metadata.
return undefined;
}
}
async function createI18nOptions(
projectRoot: string,
options: AngularRspackPluginOptions,
inline?: boolean | string[],
ssrEnabled?: boolean
) {
const i18nMetadata = await getI18nMetadata(projectRoot, options.i18nMetadata);
return _createI18nOptions({ i18n: i18nMetadata }, inline, logger, ssrEnabled);
}
export async function configureI18n(
projectRoot: string,
options: AngularRspackPluginOptions
) {
if (global.NX_GRAPH_CREATION) {
return {
options,
i18n: {
inlineLocales: new Set<string>(),
// en-US is the default locale added to Angular applications (https://angular.dev/guide/i18n/format-data-locale)
sourceLocale: 'en-US',
locales: {},
get shouldInline() {
return this.inlineLocales.size > 0;
},
},
};
}
const i18n = await createI18nOptions(projectRoot, options, options.localize);
// No additional processing needed if no inlining requested and no source locale defined.
if (!i18n.shouldInline && !i18n.hasDefinedSourceLocale) {
return { options, i18n };
}
// The trailing slash is required to signal that the path is a directory and not a file.
const projectRequire = createRequire(projectRoot + '/');
const localeResolver = (locale: string) =>
projectRequire.resolve(join(LOCALE_DATA_BASE_MODULE, locale));
// Load locale data and translations (if present)
let loader;
const usedFormats = new Set<string>();
for (const [locale, desc] of Object.entries(i18n.locales)) {
if (!i18n.inlineLocales.has(locale) && locale !== i18n.sourceLocale) {
continue;
}
let localeDataPath = findLocaleDataPath(locale, localeResolver);
if (!localeDataPath) {
const [first] = locale.split('-');
if (first) {
localeDataPath = findLocaleDataPath(
first.toLowerCase(),
localeResolver
);
if (localeDataPath) {
logger.warn(
`Locale data for '${locale}' cannot be found. Using locale data for '${first}'.`
);
}
}
}
if (!localeDataPath) {
logger.warn(
`Locale data for '${locale}' cannot be found. No locale data will be included for this locale.`
);
} else {
desc.dataPath = localeDataPath;
}
if (!desc.files.length) {
continue;
}
loader ??= await createTranslationLoader();
loadTranslations(
locale,
desc,
workspaceRoot,
loader,
{
warn(message) {
logger.warn(message);
},
error(message) {
throw new Error(message);
},
},
usedFormats,
options.i18nDuplicateTranslation
);
}
return { options, i18n };
}
+6
View File
@@ -0,0 +1,6 @@
export * from './config/create-config';
export * from './plugins/ng-rspack';
export { configureSourceMap } from './config/config-utils/sourcemap-utils';
export { getCrossOriginLoading } from './config/config-utils/helpers';
export { getOptimization } from './config/config-utils/optimization-config';
export { VENDORS_TEST } from './config/config-utils/optimization-config';
@@ -0,0 +1,396 @@
import type {
FileReplacement,
InlineStyleLanguage,
StylePreprocessorOptions,
} from '@nx/angular-rspack-compiler';
import type { BudgetEntry } from '@angular/build/private';
import type { WatchOptions } from '@rspack/core';
import { I18nProjectMetadata } from './i18n';
export interface DevServerOptions {
/**
* The hosts that the development server will respond to.
*/
allowedHosts?: string[] | boolean;
/**
* Don't verify connected clients are part of allowed hosts.
*/
disableHostCheck?: boolean;
/**
* Custom HTTP headers to be added to all responses.
*/
headers?: Record<string, string>;
/**
* Enable hot module replacement.
*/
hmr?: boolean;
/**
* Host to listen on.
*/
host?: string;
/**
* @deprecated This is a no-op and can be safely removed.
* The `inspect` option is no longer supported.
*/
inspect?: string | boolean;
/**
* Whether to reload the page on change, using live-reload.
* @default true
*/
liveReload?: boolean;
/**
* Opens the url in default browser.
*/
open?: boolean;
/**
* Port to listen on.
*/
port?: number;
/**
* @deprecated This is a no-op and can be safely removed.
* The `prebundle` option is no longer supported.
*/
prebundle?:
| boolean
| {
exclude: string[];
};
/**
* Proxy configuration file. For more information, see https://angular.dev/tools/cli/serve#proxying-to-a-backend-server.
*/
proxyConfig?: string;
/**
* 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.
*/
publicHost?: string;
/**
* The pathname where the application will be served.
*/
servePath?: string;
/**
* Serve using HTTPS.
*/
ssl?: boolean;
/**
* SSL certificate to use for serving HTTPS.
*/
sslCert?: string;
/**
* SSL key to use for serving HTTPS.
*/
sslKey?: string;
}
export interface NormalizedDevServerOptions extends DevServerOptions {
allowedHosts: string[] | boolean;
host: string;
liveReload: boolean;
port: number;
open: boolean;
}
export interface OptimizationOptions {
fonts?:
| boolean
| {
inline?: boolean;
};
scripts?: boolean;
styles?:
| boolean
| {
minify?: boolean;
inlineCritical?: boolean;
};
}
export interface NormalizedOptimizationOptions {
fonts: {
inline: boolean;
};
scripts: boolean;
styles: {
minify: boolean;
inlineCritical: boolean;
};
}
export type OutputHashing = 'none' | 'all' | 'media' | 'bundles';
export type HashFormat = {
chunk: string;
extract: string;
file: string;
script: string;
};
export interface OutputPath {
base: string;
browser: string;
media: string;
server: string;
}
export type AssetExpandedDefinition = {
glob: string;
input: string;
ignore?: string[];
output?: string;
};
export type AssetElement = AssetExpandedDefinition | string;
export type NormalizedAssetElement = AssetExpandedDefinition & {
output: string;
};
export type ScriptOrStyleEntry =
| string
| {
input: string;
bundleName?: string;
inject?: boolean;
};
export type GlobalEntry = {
files: string[];
initial: boolean;
name: string;
};
export type IndexExpandedDefinition = {
input: string;
output?: string;
preloadInitial?: boolean;
transformer?: IndexHtmlTransform;
};
export type IndexElement = IndexExpandedDefinition | string | false;
export type IndexHtmlTransform = (content: string) => Promise<string>;
export type NormalizedIndexElement = IndexExpandedDefinition;
export type NormalizedEntryPoint = Required<
Exclude<ScriptOrStyleEntry, string>
>;
export interface SourceMap {
scripts: boolean;
styles: boolean;
hidden: boolean;
vendor: boolean;
}
type PatchedBudgetType =
| 'all'
| 'allScript'
| 'any'
| 'anyScript'
| 'anyComponentStyle'
| 'bundle'
| 'initial';
type PatchedBudgetEntry = Omit<BudgetEntry, 'type'> & {
type: PatchedBudgetType;
};
export interface AngularRspackPluginOptions {
/**
* @deprecated This is a no-op and can be safely removed.
* A list of CommonJS or AMD packages that are allowed to be used without a build time warning. Use `'*'` to allow all.
*/
allowedCommonJsDependencies?: string[];
/**
*
*/
appShell?: boolean;
aot?: boolean;
assets?: AssetElement[];
/**
* Base url for the application being built.
*/
baseHref?: string;
browser?: string;
/**
* Budget thresholds to ensure parts of your application stay within boundaries which you set.
*/
budgets?: PatchedBudgetEntry[];
commonChunk?: boolean;
/**
* Define the `crossorigin` attribute setting of elements that provide CORS
* support.
* @default 'none'
*/
crossOrigin?: 'none' | 'anonymous' | 'use-credentials';
/**
* 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.
*/
define?: Record<string, string>;
/**
* Delete the output path before building.
*/
deleteOutputPath?: boolean;
/**
* 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.
*/
deployUrl?: string;
devServer?: DevServerOptions;
/**
* Exclude the listed external dependencies from being bundled into the bundle. Instead, the created bundle relies on these dependencies to be available during runtime.
*/
externalDependencies?: string[];
extractLicenses?: boolean;
fileReplacements?: FileReplacement[];
index?: IndexElement;
inlineStyleLanguage?: InlineStyleLanguage;
/**
* Project metadata for i18n.
*/
i18nMetadata?: I18nProjectMetadata;
/**
* How to handle missing translations for i18n.
*/
i18nMissingTranslation?: 'warning' | 'error' | 'ignore';
/**
* How to handle duplicate translations for i18n.
*/
i18nDuplicateTranslation?: 'warning' | 'error' | 'ignore';
/**
* Translate the bundles in one or more locales.
*/
localize?: boolean | string[];
namedChunks?: boolean;
/**
* Path to ngsw-config.json.
*/
ngswConfigPath?: string;
optimization?: boolean | OptimizationOptions;
outputHashing?: OutputHashing;
outputPath?:
| string
| (Required<Pick<OutputPath, 'base'>> & Partial<OutputPath>);
/**
* Enable and define the file watching poll time period in milliseconds.
*/
poll?: number;
polyfills?: string[];
/**
* Prerender (SSG) pages of your application during build time.
*/
prerender?:
| boolean
| {
/**
* The routes to render.
*/
routes?: string[];
/**
* 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.
*/
routesFile?: string;
/**
* Whether the builder should process the Angular Router configuration to find all unparameterized routes and prerender them.
*/
discoverRoutes?: boolean;
};
/**
* Do not use the real path when resolving modules. If unset then will default to `true` if NodeJS option --preserve-symlinks is set.
*/
preserveSymlinks?: boolean;
/**
* Log progress to the console while building.
*/
progress?: boolean;
root?: string;
scripts?: ScriptOrStyleEntry[];
server?: string;
/**
* Generates a service worker config for production builds.
*/
serviceWorker?: boolean;
skipTypeChecking?: boolean;
sourceMap?: boolean | Partial<SourceMap>;
ssr?:
| boolean
| {
entry: string;
platform?: 'node' | 'neutral';
/** @deprecated Use `platform` instead. */
experimentalPlatform?: 'node' | 'neutral';
};
/**
* Generates a 'stats.json' file which can be analyzed using tools such as 'webpack-bundle-analyzer'.
*/
statsJson?: boolean;
stylePreprocessorOptions?: StylePreprocessorOptions;
styles?: ScriptOrStyleEntry[];
/**
* Enables the use of subresource integrity validation.
* @default false
*/
subresourceIntegrity?: boolean;
tsConfig?: string;
useTsProjectReferences?: boolean;
vendorChunk?: boolean;
/**
* Adds more details to output logging.
*/
verbose?: boolean;
/**
* Run build when files change.
*/
watch?: boolean;
/**
* Options for the file watcher. Can be used to configure aggregateTimeout,
* ignored patterns, and other watcher behavior.
*/
watchOptions?: WatchOptions;
/**
* @deprecated This is a no-op and can be safely removed.
* The tsconfig file for web workers.
*/
webWorkerTsConfig?: string;
}
export interface NormalizedAngularRspackPluginOptions
extends Omit<AngularRspackPluginOptions, 'index' | 'scripts' | 'styles'> {
appShell: boolean;
advancedOptimizations: boolean;
aot: boolean;
assets: NormalizedAssetElement[];
browser: string;
budgets: BudgetEntry[];
commonChunk: boolean;
crossOrigin: 'none' | 'anonymous' | 'use-credentials';
deleteOutputPath: boolean;
devServer: NormalizedDevServerOptions;
externalDependencies: string[];
extractLicenses: boolean;
fileReplacements: FileReplacement[];
globalScripts: GlobalEntry[];
globalStyles: GlobalEntry[];
hasServer: boolean;
index: NormalizedIndexElement;
inlineStyleLanguage: InlineStyleLanguage;
namedChunks: boolean;
optimization: NormalizedOptimizationOptions;
outputHashing: OutputHashing;
outputPath: OutputPath;
polyfills: string[];
projectName: string | undefined;
progress: boolean;
root: string;
scripts: ScriptOrStyleEntry[];
styles: ScriptOrStyleEntry[];
sourceMap: SourceMap;
subresourceIntegrity: boolean;
supportedBrowsers: string[];
tsConfig: string;
vendorChunk: boolean;
/**
* Adds more details to output logging.
*/
verbose?: boolean;
watch: boolean;
watchOptions?: WatchOptions;
zoneless: boolean;
}
@@ -0,0 +1,20 @@
import type { Compilation } from '@rspack/core';
import type {
JavaScriptTransformer,
SourceFileCache,
} from '@nx/angular-rspack-compiler';
import { I18nOptions } from './i18n';
export const NG_RSPACK_SYMBOL_NAME = 'NG_RSPACK_BUILD';
export type NG_RSPACK_COMPILATION_STATE = {
javascriptTransformer: JavaScriptTransformer;
typescriptFileCache: SourceFileCache['typeScriptFileCache'];
// True when the Angular compilation failed to initialize or emit, meaning
// the typescript file cache cannot be relied on for this build.
angularCompilationFailed: boolean;
i18n?: I18nOptions;
};
export type NgRspackCompilation = Compilation & {
[NG_RSPACK_SYMBOL_NAME]: () => NG_RSPACK_COMPILATION_STATE;
};
@@ -0,0 +1,61 @@
import { I18nOptions as _I18nOptions } from '@angular/build/private';
export type I18nOptions = _I18nOptions;
export interface SourceLocale {
code?: string;
baseHref?: string;
subPath?: string;
}
export interface LocaleMetadata {
translation?: string | string[];
baseHref?: string;
subPath?: string;
}
export interface I18nProjectMetadata {
sourceLocale?: string | SourceLocale;
locales?: Record<string, string | string[] | LocaleMetadata>;
}
/**
* How to handle potential diagnostics.
*/
export type DiagnosticHandlingStrategy = 'error' | 'warning' | 'ignore';
export class Diagnostics {
readonly messages: { type: 'warning' | 'error'; message: string }[] = [];
get hasErrors() {
return this.messages.some((m) => m.type === 'error');
}
add(type: DiagnosticHandlingStrategy, message: string) {
if (type !== 'ignore') {
this.messages.push({ type, message });
}
}
warn(message: string) {
this.messages.push({ type: 'warning', message });
}
error(message: string) {
this.messages.push({ type: 'error', message });
}
merge(other: Diagnostics) {
this.messages.push(...other.messages);
}
formatDiagnostics(message: string): string {
const errors = this.messages
.filter((d) => d.type === 'error')
.map((d) => ' - ' + d.message);
const warnings = this.messages
.filter((d) => d.type === 'warning')
.map((d) => ' - ' + d.message);
if (errors.length) {
message += '\nERRORS:\n' + errors.join('\n');
}
if (warnings.length) {
message += '\nWARNINGS:\n' + warnings.join('\n');
}
return message;
}
}
@@ -0,0 +1,4 @@
export * from './angular-rspack-plugin-options';
export * from './augmented-compilation';
export * from './i18n';
export * from './normalize-options';
@@ -0,0 +1,586 @@
import { BudgetEntry, getSupportedBrowsers } from '@angular/build/private';
import type { FileReplacement } from '@nx/angular-rspack-compiler';
import { workspaceRoot, type ProjectGraphProjectNode } from '@nx/devkit';
import assert from 'node:assert';
import { existsSync, statSync } from 'node:fs';
import {
basename,
dirname,
extname,
join,
normalize,
posix,
relative,
resolve,
} from 'node:path';
import {
findProjectForPath,
normalizeProjectRoot,
} from '../utils/find-project-for-path';
import { retrieveOrCreateProjectGraph } from '../utils/graph';
import type {
AngularRspackPluginOptions,
AssetElement,
DevServerOptions,
GlobalEntry,
NormalizedAngularRspackPluginOptions,
NormalizedAssetElement,
NormalizedDevServerOptions,
NormalizedIndexElement,
NormalizedOptimizationOptions,
OutputPath,
ScriptOrStyleEntry,
SourceMap,
} from './angular-rspack-plugin-options';
export const INDEX_HTML_CSR = 'index.csr.html';
/**
* Resolves file replacement paths to absolute paths based on the provided root directory.
*
* @param fileReplacements - Array of file replacements with relative paths.
* @param root - The root directory to resolve the paths against.
* @returns Array of file replacements resolved against the root.
*/
export function resolveFileReplacements(
fileReplacements: FileReplacement[],
root: string
): FileReplacement[] {
return fileReplacements.map((fileReplacement) => ({
replace: resolve(root, fileReplacement.replace),
with: resolve(root, fileReplacement.with),
}));
}
export function getHasServer(
root: string,
server: string | undefined,
ssr: AngularRspackPluginOptions['ssr']
): boolean {
return !!(
server &&
ssr &&
(ssr as { entry: string }).entry &&
existsSync(join(root, server)) &&
existsSync(join(root, (ssr as { entry: string }).entry))
);
}
export function validateSsr(ssr: AngularRspackPluginOptions['ssr']) {
if (!ssr) {
return;
}
if (ssr === true) {
throw new Error(
'The "ssr" option should be an object or false. Please check the documentation.'
);
}
if (typeof ssr === 'object')
if (!ssr.entry) {
throw new Error(
'The "ssr" option should have an "entry" property. Please check the documentation.'
);
} else if ((ssr.platform ?? ssr.experimentalPlatform) === 'neutral') {
console.warn(
'The "neutral" value for the "ssr.platform" option is not currently supported. Node will be used as the platform.'
);
}
}
export async function normalizeOptions(
options: AngularRspackPluginOptions
): Promise<NormalizedAngularRspackPluginOptions> {
const { fileReplacements = [], server, ssr, optimization } = options;
validateSsr(ssr);
const normalizedSsr = !ssr
? false
: typeof ssr === 'object'
? {
entry: ssr.entry,
platform: 'node' as const, // @TODO: Add support for neutral platform
}
: ssr;
const normalizedOptimization = normalizeOptimization(optimization);
const root = options.root ?? process.cwd();
const tsConfig = options.tsConfig
? resolve(root, options.tsConfig)
: join(root, 'tsconfig.app.json');
const aot = options.aot ?? true;
const advancedOptimizations = aot && normalizedOptimization.scripts;
const project = await getProject(root);
const assets =
project && options.assets?.length
? normalizeAssetPatterns(
options.assets,
root,
project.data.root,
project.data.sourceRoot
)
: [];
const globalStyles = normalizeGlobalEntries(options.styles, 'styles');
const globalScripts = normalizeGlobalEntries(options.scripts, 'scripts');
if (options.index === false) {
console.warn(
'Disabling the "index" option is not yet supported. Defaulting to "src/index.html".'
);
options.index = join(root, 'src/index.html');
} else if (!options.index) {
options.index = join(root, 'src/index.html');
}
// index can never have a value of `true` but in the schema it's of type `boolean`.
let indexOutput: string;
// The output file will be created within the configured output path
if (typeof options.index === 'string') {
indexOutput = options.index;
} else {
if (options.index.preloadInitial) {
console.warn(`The "index.preloadInitial" option is not yet supported.`);
}
if (options.index.output) {
console.warn(`The "index.output" option is not yet supported.`);
}
indexOutput = options.index.output || 'index.html';
}
/**
* If SSR is activated, create a distinct entry file for the `index.html`.
* This is necessary because numerous server/cloud providers automatically serve the `index.html` as a static file
* if it exists (handling SSG).
*
* For instance, accessing `foo.com/` would lead to `foo.com/index.html` being served instead of hitting the server.
*
* This approach can also be applied to service workers, where the `index.csr.html` is served instead of the prerendered `index.html`.
*/
const indexBaseName = basename(indexOutput);
// @TODO: use this once we properly support SSR/SSG options
// (normalizedSsr || prerenderOptions) && indexBaseName === 'index.html'
// ? INDEX_HTML_CSR
// : indexBaseName;
indexOutput = indexBaseName;
const entryPoints: [name: string, isEsm: boolean][] = [
['runtime', !options.devServer?.hmr],
['polyfills', true],
...(globalStyles.filter((s) => s.initial).map((s) => [s.name, false]) as [
string,
boolean,
][]),
...(globalScripts.filter((s) => s.initial).map((s) => [s.name, false]) as [
string,
boolean,
][]),
['vendor', true],
['main', true],
];
const duplicates = entryPoints.filter(
([name]) =>
entryPoints[0].indexOf(name) !== entryPoints[0].lastIndexOf(name)
);
if (duplicates.length > 0) {
throw new Error(
`Multiple bundles have been named the same: '${duplicates.join(`', '`)}'.`
);
}
const index: NormalizedIndexElement = {
input: resolve(
root,
typeof options.index === 'string' ? options.index : options.index.input
),
output: indexOutput,
transformer:
typeof options.index === 'object' ? options.index.transformer : undefined,
// Preload initial defaults to true
preloadInitial:
typeof options.index !== 'object' ||
(options.index.preloadInitial ?? true),
};
const budgets: BudgetEntry[] = !options.budgets
? []
: options.budgets.map((budget) => ({
...budget,
type: budget.type as any,
}));
const zoneless = options.polyfills
? !options.polyfills.includes('zone.js')
: true;
const stylePreprocessorOptions = options.stylePreprocessorOptions ?? {};
if (stylePreprocessorOptions.includePaths?.length) {
stylePreprocessorOptions.includePaths = [
...stylePreprocessorOptions.includePaths,
'node_modules',
];
}
return {
advancedOptimizations,
appShell: options.appShell ?? false,
assets,
aot,
baseHref: options.baseHref,
browser: options.browser ?? './src/main.ts',
budgets,
commonChunk: options.commonChunk ?? true,
crossOrigin: options.crossOrigin ?? 'none',
define: options.define ?? {},
deleteOutputPath: options.deleteOutputPath ?? true,
deployUrl: options.deployUrl,
devServer: normalizeDevServer(options.devServer),
externalDependencies: options.externalDependencies ?? [],
extractLicenses: options.extractLicenses ?? true,
fileReplacements: resolveFileReplacements(fileReplacements, root),
globalStyles,
globalScripts,
hasServer: getHasServer(root, server, normalizedSsr),
index,
inlineStyleLanguage: options.inlineStyleLanguage ?? 'css',
namedChunks: options.namedChunks ?? false,
ngswConfigPath: options.ngswConfigPath,
optimization: normalizedOptimization,
outputHashing: options.outputHashing ?? 'none',
outputPath: normalizeOutputPath(root, options.outputPath),
poll: options.poll ?? undefined,
polyfills: options.polyfills ?? [],
prerender: options.prerender ?? false,
projectName: project?.name ?? undefined,
progress: options.progress ?? true,
root,
scripts: options.scripts ?? [],
serviceWorker: options.serviceWorker,
server,
skipTypeChecking: options.skipTypeChecking ?? false,
sourceMap: normalizeSourceMap(options.sourceMap),
ssr: normalizedSsr,
statsJson: options.statsJson ?? false,
styles: options.styles ?? [],
stylePreprocessorOptions,
subresourceIntegrity: options.subresourceIntegrity ?? false,
supportedBrowsers: getSupportedBrowsers(root, { warn: console.warn }),
tsConfig,
useTsProjectReferences: options.useTsProjectReferences ?? false,
vendorChunk: options.vendorChunk ?? false,
verbose: options.verbose ?? false,
watch: options.watch ?? false,
watchOptions: options.watchOptions,
zoneless,
};
}
export function normalizeOptimization(
optimization: AngularRspackPluginOptions['optimization']
): NormalizedOptimizationOptions {
if (typeof optimization === 'boolean') {
return {
fonts: {
inline: optimization,
},
scripts: optimization,
styles: {
minify: optimization,
inlineCritical: optimization,
},
};
} else if (optimization === undefined) {
return {
fonts: {
inline: true,
},
scripts: true,
styles: {
minify: true,
inlineCritical: true,
},
};
}
return {
fonts: {
inline:
optimization.fonts === undefined
? true
: typeof optimization.fonts === 'boolean'
? optimization.fonts
: (optimization.fonts.inline ?? true),
},
scripts: optimization.scripts ?? true,
styles: {
minify:
optimization.styles === undefined
? true
: typeof optimization.styles === 'boolean'
? optimization.styles
: (optimization.styles.minify ?? true),
inlineCritical:
optimization.styles === undefined
? true
: typeof optimization.styles === 'boolean'
? optimization.styles
: (optimization.styles.inlineCritical ?? true),
},
};
}
function normalizeSourceMap(
sourceMap: boolean | Partial<SourceMap> | undefined
): SourceMap {
if (sourceMap === undefined) {
return {
scripts: false,
styles: false,
hidden: false,
vendor: false,
};
}
if (typeof sourceMap === 'boolean') {
return {
scripts: sourceMap,
styles: sourceMap,
hidden: sourceMap,
vendor: sourceMap,
};
}
return {
scripts: sourceMap.scripts ?? true,
styles: sourceMap.styles ?? true,
hidden: sourceMap.hidden ?? false,
vendor: sourceMap.vendor ?? false,
};
}
function normalizeDevServer(
devServer: DevServerOptions | undefined
): NormalizedDevServerOptions {
const defaultHost = 'localhost';
const defaultPort = 4200;
if (!devServer) {
return {
allowedHosts: [],
host: defaultHost,
liveReload: true,
port: defaultPort,
open: false,
};
}
return {
...devServer,
open: devServer.open ?? false,
allowedHosts: devServer.allowedHosts ?? [],
host: devServer.host ?? defaultHost,
liveReload: devServer.liveReload ?? true,
port: devServer.port ?? defaultPort,
};
}
function normalizeOutputPath(
root: string,
outputPath:
| string
| (Required<Pick<OutputPath, 'base'>> & Partial<OutputPath>)
| undefined
): OutputPath {
let base =
typeof outputPath === 'string' ? outputPath : (outputPath?.base ?? 'dist');
if (!base.startsWith(root)) {
base = join(root, base);
}
const browser =
typeof outputPath === 'string' || !outputPath?.browser
? join(base, 'browser')
: join(base, outputPath.browser);
const server =
typeof outputPath === 'string' || !outputPath?.server
? join(base, 'server')
: join(base, outputPath.server);
const media =
typeof outputPath === 'string' || !outputPath?.media
? join(browser, 'media')
: join(browser, outputPath.media);
return { base, browser, server, media };
}
function normalizeAssetPatterns(
assets: AssetElement[],
root: string,
projectRoot: string,
projectSourceRoot: string | undefined
): NormalizedAssetElement[] {
if (assets.length === 0) {
return [];
}
// When sourceRoot is not available, we default to ${projectRoot}/src.
const resolvedSourceRoot = projectSourceRoot
? join(workspaceRoot, projectSourceRoot)
: join(workspaceRoot, projectRoot, 'src');
return assets.map((assetPattern) => {
// Normalize string asset patterns to objects.
if (typeof assetPattern === 'string') {
const assetPath = normalize(assetPattern);
const resolvedAssetPath = resolve(root, assetPath);
// Check if the string asset is within sourceRoot.
if (!resolvedAssetPath.startsWith(resolvedSourceRoot)) {
throw new Error(
`The ${assetPattern} asset path must start with the project source root.`
);
}
let glob: string, input: string;
let isDirectory = false;
try {
isDirectory = statSync(resolvedAssetPath).isDirectory();
} catch {
isDirectory = true;
}
if (isDirectory) {
// Folders get a recursive star glob.
glob = '**/*';
// Input directory is their original path.
input = assetPath;
} else {
// Files are their own glob.
glob = basename(assetPath);
// Input directory is their original dirname.
input = dirname(assetPath);
}
// Output directory for both is the relative path from source root to input.
const output = relative(resolvedSourceRoot, resolve(root, input));
assetPattern = { glob, input, output };
} else {
assetPattern.output = join('.', assetPattern.output ?? '');
}
assert(assetPattern.output !== undefined);
if (assetPattern.output.startsWith('..')) {
throw new Error(
'An asset cannot be written to a location outside of the output path.'
);
}
return assetPattern as NormalizedAssetElement;
});
}
function normalizeGlobalEntries(
rawEntries: ScriptOrStyleEntry[] | undefined,
defaultName: string
): GlobalEntry[] {
if (!rawEntries?.length) {
return [];
}
const bundles = new Map<string, GlobalEntry>();
for (const rawEntry of rawEntries) {
let entry: ScriptOrStyleEntry;
if (typeof rawEntry === 'string') {
// string entries use default bundle name and inject values
entry = { input: rawEntry };
} else {
entry = rawEntry;
}
const { bundleName, input, inject = true } = entry;
// Non-injected entries default to the file name
const name =
bundleName || (inject ? defaultName : basename(input, extname(input)));
const existing = bundles.get(name);
if (!existing) {
bundles.set(name, { name, files: [input], initial: inject });
continue;
}
if (existing.initial !== inject) {
throw new Error(
`The "${name}" bundle is mixing injected and non-injected entries. ` +
'Verify that the project options are correct.'
);
}
existing.files.push(input);
}
return [...bundles.values()];
}
async function getProject(root: string): Promise<
| {
name?: string;
data: {
name?: string;
root: string;
sourceRoot?: string;
};
}
| undefined
> {
if (global.NX_GRAPH_CREATION) {
return undefined;
}
const projectGraph = await retrieveOrCreateProjectGraph();
let projectName = process.env.NX_TASK_TARGET_PROJECT;
if (projectGraph) {
if (!projectName) {
const projectRootMappings = createProjectRootMappings(projectGraph.nodes);
projectName =
findProjectForPath(
posix.relative(workspaceRoot, root),
projectRootMappings
) ?? undefined;
}
if (projectName) {
return projectGraph.nodes[projectName];
}
}
return {
name: undefined,
data: {
name: undefined,
root: root,
sourceRoot: undefined,
},
};
}
function createProjectRootMappings(
nodes: Record<string, ProjectGraphProjectNode>
): Map<string, string> {
const projectRootMappings = new Map<string, string>();
for (const projectName of Object.keys(nodes)) {
const root = nodes[projectName].data.root;
projectRootMappings.set(normalizeProjectRoot(root), projectName);
}
return projectRootMappings;
}
@@ -0,0 +1,285 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { NormalizedAngularRspackPluginOptions } from '../models';
import { NG_RSPACK_SYMBOL_NAME, type NgRspackCompilation } from '../models';
import { AngularRspackPlugin } from './angular-rspack-plugin';
const {
buildAndAnalyzeMock,
setupCompilationMock,
disposeComponentStylesheetBundlerMock,
transformerCloseMock,
} = vi.hoisted(() => ({
buildAndAnalyzeMock: vi.fn(),
setupCompilationMock: vi.fn(),
disposeComponentStylesheetBundlerMock: vi.fn(),
transformerCloseMock: vi.fn(),
}));
vi.mock('@nx/angular-rspack-compiler', () => ({
buildAndAnalyze: buildAndAnalyzeMock,
DiagnosticModes: { All: 7 },
disposeComponentStylesheetBundler: disposeComponentStylesheetBundlerMock,
JavaScriptTransformer: class {
close = transformerCloseMock;
},
SourceFileCache: class {
typeScriptFileCache = new Map<string, string | Uint8Array>();
referencedFiles: string[] = [];
invalidate = vi.fn();
},
maxWorkers: () => 1,
setupCompilationWithAngularCompilation: setupCompilationMock,
AngularCompilation: class {},
}));
type Tap = { name: string; fn: (...args: unknown[]) => unknown };
function createHook() {
const taps: Tap[] = [];
return {
taps,
tap: (name: string, fn: Tap['fn']) => taps.push({ name, fn }),
tapAsync: (name: string, fn: Tap['fn']) => taps.push({ name, fn }),
};
}
class FakeWebpackError extends Error {}
function createFakeCompiler() {
const compiler = {
hooks: {
beforeRun: createHook(),
watchRun: createHook(),
beforeCompile: createHook(),
compilation: createHook(),
thisCompilation: createHook(),
emit: createHook(),
afterEmit: createHook(),
done: createHook(),
afterDone: createHook(),
shutdown: createHook(),
normalModuleFactory: createHook(),
},
options: {
resolve: { tsConfig: '/root/tsconfig.json' },
stats: undefined,
target: 'web',
},
rspack: {
Compilation: { PROCESS_ASSETS_STAGE_ADDITIONS: 100 },
WebpackError: FakeWebpackError,
sources: {},
},
watching: undefined,
};
return compiler;
}
function createFakeCompilation(
compiler: ReturnType<typeof createFakeCompiler>
) {
return {
errors: [] as Error[],
warnings: [] as Error[],
hooks: { afterSeal: createHook(), processAssets: createHook() },
fileDependencies: { add: vi.fn() },
compiler,
};
}
async function fireAsyncTaps(
hook: ReturnType<typeof createHook>,
...args: unknown[]
) {
for (const tap of [...hook.taps]) {
await new Promise<void>((resolve, reject) => {
try {
const result = tap.fn(...args, resolve);
// rspack ignores promises returned from tapAsync functions, so a
// rejection means the tap leaked an error instead of handling it -
// fail the test in that case.
if (result instanceof Promise) {
result.catch(reject);
}
} catch (e) {
reject(e);
}
});
}
}
function fireSyncTaps(hook: ReturnType<typeof createHook>, ...args: unknown[]) {
for (const tap of [...hook.taps]) {
tap.fn(...args);
}
}
describe('AngularRspackPlugin', () => {
const options = {
root: '/root',
aot: true,
tsConfig: '/root/tsconfig.json',
inlineStyleLanguage: 'css',
fileReplacements: [],
hasServer: false,
skipTypeChecking: false,
sourceMap: { scripts: true, styles: true, hidden: false, vendor: false },
advancedOptimizations: false,
budgets: [],
verbose: false,
outputPath: { browser: '/root/dist/browser' },
} as unknown as NormalizedAngularRspackPluginOptions;
beforeEach(() => {
vi.clearAllMocks();
transformerCloseMock.mockResolvedValue(undefined);
disposeComponentStylesheetBundlerMock.mockResolvedValue(undefined);
buildAndAnalyzeMock.mockResolvedValue(undefined);
setupCompilationMock.mockResolvedValue({
angularCompilation: { diagnoseFiles: vi.fn().mockResolvedValue({}) },
collectedStylesheetAssets: [],
});
});
function applyPlugin() {
const compiler = createFakeCompiler();
const plugin = new AngularRspackPlugin(options);
plugin.apply(compiler as never);
return compiler;
}
async function runBuildStart(
compiler: ReturnType<typeof createFakeCompiler>
) {
// beforeRun registers the beforeCompile tap that runs build and analyze
await fireAsyncTaps(compiler.hooks.beforeRun, compiler);
await fireAsyncTaps(compiler.hooks.beforeCompile, {});
}
it('should report an initialization failure as a compilation error and skip further compilation steps', async () => {
setupCompilationMock.mockRejectedValue(new Error('NgtscProgram failed'));
const compiler = applyPlugin();
await runBuildStart(compiler);
expect(buildAndAnalyzeMock).not.toHaveBeenCalled();
const compilation = createFakeCompilation(compiler);
fireSyncTaps(compiler.hooks.thisCompilation, compilation);
expect(compilation.errors).toHaveLength(1);
expect(compilation.errors[0].message).toContain(
'Angular compilation initialization failed.'
);
expect(compilation.errors[0].message).toContain('NgtscProgram failed');
// the loaders are signaled to short-circuit
fireSyncTaps(compiler.hooks.compilation, compilation);
const state = (compilation as unknown as NgRspackCompilation)[
NG_RSPACK_SYMBOL_NAME
]();
expect(state.angularCompilationFailed).toBe(true);
// emit skips diagnostics (no angular compilation to diagnose) and does not throw
await fireAsyncTaps(compiler.hooks.emit, compilation);
expect(compilation.errors).toHaveLength(1);
});
it('should report an emit failure as a compilation error and still run diagnostics', async () => {
const diagnoseFiles = vi.fn().mockResolvedValue({});
setupCompilationMock.mockResolvedValue({
angularCompilation: { diagnoseFiles },
collectedStylesheetAssets: [],
});
buildAndAnalyzeMock.mockRejectedValue(new Error('worker crashed'));
const compiler = applyPlugin();
await runBuildStart(compiler);
const compilation = createFakeCompilation(compiler);
fireSyncTaps(compiler.hooks.thisCompilation, compilation);
expect(compilation.errors).toHaveLength(1);
expect(compilation.errors[0].message).toContain(
'Angular compilation emit failed.'
);
expect(compilation.errors[0].message).toContain('worker crashed');
// the loaders are signaled to short-circuit
fireSyncTaps(compiler.hooks.compilation, compilation);
const state = (compilation as unknown as NgRspackCompilation)[
NG_RSPACK_SYMBOL_NAME
]();
expect(state.angularCompilationFailed).toBe(true);
// unlike an initialization failure, diagnostics still run - they
// usually carry the root cause of the emit failure
await fireAsyncTaps(compiler.hooks.emit, compilation);
expect(diagnoseFiles).toHaveBeenCalled();
});
it('should surface a diagnostics failure as a build error instead of hanging the build', async () => {
const diagnoseFiles = vi
.fn()
.mockRejectedValue(new Error('diagnostics crashed'));
setupCompilationMock.mockResolvedValue({
angularCompilation: { diagnoseFiles },
collectedStylesheetAssets: [],
});
const compiler = applyPlugin();
await runBuildStart(compiler);
const compilation = createFakeCompilation(compiler);
// emit must resolve (the callback fires) rather than leak the rejection
await fireAsyncTaps(compiler.hooks.emit, compilation);
expect(transformerCloseMock).toHaveBeenCalled();
expect(
compilation.errors.some((error) =>
error.message.includes('Angular diagnostics failed.')
)
).toBe(true);
});
it('should clear the error and recover on a rebuild that initializes successfully', async () => {
setupCompilationMock.mockRejectedValueOnce(new Error('transient failure'));
const compiler = applyPlugin();
// first watch build fails to initialize
await fireAsyncTaps(compiler.hooks.watchRun, compiler);
await fireAsyncTaps(compiler.hooks.beforeCompile, {});
const failedCompilation = createFakeCompilation(compiler);
fireSyncTaps(compiler.hooks.thisCompilation, failedCompilation);
expect(failedCompilation.errors).toHaveLength(1);
expect(buildAndAnalyzeMock).not.toHaveBeenCalled();
// next rebuild initializes successfully and recovers
await fireAsyncTaps(compiler.hooks.watchRun, compiler);
await fireAsyncTaps(compiler.hooks.beforeCompile, {});
const compilation = createFakeCompilation(compiler);
fireSyncTaps(compiler.hooks.thisCompilation, compilation);
expect(compilation.errors).toHaveLength(0);
expect(buildAndAnalyzeMock).toHaveBeenCalledTimes(1);
fireSyncTaps(compiler.hooks.compilation, compilation);
const state = (compilation as unknown as NgRspackCompilation)[
NG_RSPACK_SYMBOL_NAME
]();
expect(state.angularCompilationFailed).toBe(false);
});
it('should not throw from afterDone when the run failed before producing stats', async () => {
const compiler = applyPlugin();
expect(() =>
fireSyncTaps(compiler.hooks.afterDone, undefined)
).not.toThrow();
});
it('should release the worker pool and the stylesheet bundler on shutdown', async () => {
const compiler = applyPlugin();
await fireAsyncTaps(compiler.hooks.shutdown);
expect(transformerCloseMock).toHaveBeenCalled();
expect(disposeComponentStylesheetBundlerMock).toHaveBeenCalled();
});
});
@@ -0,0 +1,546 @@
import {
augmentAppWithServiceWorker,
type BudgetCalculatorResult,
checkBudgets,
ThresholdSeverity,
} from '@angular/build/private';
import {
buildAndAnalyze,
DiagnosticModes,
disposeComponentStylesheetBundler,
JavaScriptTransformer,
SourceFileCache,
maxWorkers,
setupCompilationWithAngularCompilation,
AngularCompilation,
} from '@nx/angular-rspack-compiler';
import { workspaceRoot } from '@nx/devkit';
import {
type Compilation,
type Compiler,
type RspackOptionsNormalized,
type RspackPluginInstance,
sources,
} from '@rspack/core';
import { dirname, join, normalize, resolve } from 'node:path';
import {
type I18nOptions,
NG_RSPACK_SYMBOL_NAME,
type NgRspackCompilation,
type NormalizedAngularRspackPluginOptions,
} from '../models';
import { getLocaleBaseHref } from '../utils/get-locale-base-href';
import { addError, addWarning } from '../utils/rspack-diagnostics';
import { assertNever } from '../utils/misc-helpers';
import { rspackStatsLogger, statsErrorsToString } from '../utils/stats';
import { getStatsOptions } from '../config/config-utils/get-stats-options';
const PLUGIN_NAME = 'AngularRspackPlugin';
type ResolvedJavascriptTransformer = Parameters<typeof buildAndAnalyze>[2];
export class AngularRspackPlugin implements RspackPluginInstance {
#_options: NormalizedAngularRspackPluginOptions;
#i18n: I18nOptions | undefined;
#sourceFileCache: SourceFileCache;
#javascriptTransformer: ResolvedJavascriptTransformer;
// This will be defined in the apply method correctly
#angularCompilation: AngularCompilation;
#collectedStylesheetAssets: Array<{ path: string; text: string }> = [];
#initializationError: string | undefined;
#emitError: string | undefined;
constructor(
options: NormalizedAngularRspackPluginOptions,
i18nOptions?: I18nOptions
) {
this.#_options = options;
this.#i18n = i18nOptions;
this.#sourceFileCache = new SourceFileCache();
this.#javascriptTransformer = new JavaScriptTransformer(
{
/**
* Matches https://github.com/angular/angular-cli/blob/33ed6e875e509ebbaa0cbdb57be9e932f9915dff/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts#L89
* where pluginOptions.sourcemap is set https://github.com/angular/angular-cli/blob/61d98fde122468978de9b17bd79761befdbf2fac/packages/angular/build/src/tools/esbuild/compiler-plugin-options.ts#L34
*/
sourcemap: !!(
this.#_options.sourceMap.scripts &&
(this.#_options.sourceMap.hidden ? 'external' : true)
),
thirdPartySourcemaps: this.#_options.sourceMap.vendor,
advancedOptimizations: this.#_options.advancedOptimizations,
jit: !this.#_options.aot,
},
maxWorkers()
) as unknown as ResolvedJavascriptTransformer;
}
apply(compiler: Compiler) {
const root = this.#_options.root;
// Both of these are exclusive to each other - only one of them can be used at a time
// But they will happen before the compiler is created - so we can use them to set up the parallel compilation once
compiler.hooks.beforeRun.tapAsync(
PLUGIN_NAME,
async (compiler, callback) => {
await this.setupCompilation(root, compiler.options.resolve.tsConfig);
compiler.hooks.beforeCompile.tapAsync(
PLUGIN_NAME,
async (params, callback) => {
await this.buildAndAnalyze();
callback();
}
);
callback();
}
);
let currentWatchingModifiedFiles = new Set<string>();
let watchRunInitialized = false;
// Register compilation hook once - adds modified files to dependencies
compiler.hooks.compilation.tap(PLUGIN_NAME + '_fileDeps', (compilation) => {
currentWatchingModifiedFiles.forEach((file) => {
compilation.fileDependencies.add(file);
});
});
compiler.hooks.watchRun.tapAsync(
PLUGIN_NAME,
async (compiler, callback) => {
if (!watchRunInitialized) {
watchRunInitialized = true;
compiler.hooks.beforeCompile.tapAsync(
PLUGIN_NAME,
async (params, callback) => {
const watchingModifiedFiles = compiler.watching?.compiler
?.modifiedFiles
? new Set(compiler.watching.compiler.modifiedFiles)
: new Set<string>();
if (this.#angularCompilation) {
this.#sourceFileCache.invalidate(watchingModifiedFiles);
}
await this.setupCompilation(
root,
compiler.options.resolve.tsConfig,
watchingModifiedFiles.size > 0
? watchingModifiedFiles
: undefined
);
await this.buildAndAnalyze();
// Update shared state for compilation hook
currentWatchingModifiedFiles = watchingModifiedFiles;
callback();
}
);
}
callback();
}
);
compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => {
// Surface a failed Angular compilation as a build error (like
// @angular/build) instead of an exception that stalls the hooks chain.
// Watch builds recover on the next rebuild's re-init.
const angularCompilationError =
this.#initializationError ?? this.#emitError;
if (angularCompilationError) {
addError(compilation, angularCompilationError);
}
// Handle errors thrown by loaders that prevent sealing (but ignore for watch mode)
compilation.hooks.afterSeal.tapAsync(PLUGIN_NAME, (callback) => {
if (!watchRunInitialized && compilation.errors.length > 0) {
const stats = compilation.getStats();
const compilationError = statsErrorsToString(
stats.toJson(),
getStatsOptions(this.#_options.verbose)
);
callback(new Error(compilationError));
} else {
callback();
}
});
compilation.hooks.processAssets.tap(
{
name: PLUGIN_NAME,
stage: compiler.rspack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONS,
},
(assets) => {
// Emit collected stylesheet assets
if (this.#collectedStylesheetAssets.length > 0) {
const { RawSource } = compiler.rspack.sources;
for (const asset of this.#collectedStylesheetAssets) {
// Normalize the asset path to ensure correct output location
let assetPath = asset.path;
// Extract just the media/filename part from the path
const mediaMatch = assetPath.match(/media\/[^/]+$/);
if (mediaMatch) {
assetPath = mediaMatch[0];
} else {
// If not a media path, try to make it relative to the project root
assetPath = assetPath.replace(/^\/+/, '');
// Remove absolute path prefix if present
const projectRootIndex = assetPath.lastIndexOf(
this.#_options.root
);
if (projectRootIndex >= 0) {
assetPath = assetPath.substring(
projectRootIndex + this.#_options.root.length + 1
);
}
}
// Create the asset source
const source = new RawSource(asset.text);
// Emit the asset
compilation.emitAsset(assetPath, source);
}
// Clear the collected assets after emission
this.#collectedStylesheetAssets.length = 0;
}
for (const assetName in assets) {
const asset = compilation.getAsset(assetName);
if (!asset) {
continue;
}
const assetHash = asset.info?.contenthash?.[0] ?? '';
const assetNameWithoutHash = assetName.replace(`.${assetHash}`, '');
if (assetNameWithoutHash !== 'main.js') {
continue;
}
const originalSource = asset.source;
let setLocaleContent = '';
if (this.#i18n?.shouldInline) {
// When inlining, a placeholder is used to allow the post-processing step to inject the $localize locale identifier.
setLocaleContent +=
'(globalThis.$localize ??= {}).locale = "___NG_LOCALE_INSERT___";\n';
} else if (this.#i18n?.hasDefinedSourceLocale) {
// If not inlining translations and source locale is defined, inject the locale specifier.
setLocaleContent += `(globalThis.$localize ??= {}).locale = "${
this.#i18n.sourceLocale
}";\n`;
}
const concatLocaleSource = new sources.ConcatSource(
setLocaleContent,
originalSource
);
compilation.updateAsset(
assetName,
concatLocaleSource,
(assetInfo) => assetInfo
);
}
}
);
});
compiler.hooks.emit.tapAsync(PLUGIN_NAME, async (compilation, callback) => {
// Skip diagnostics when the Angular compilation failed to initialize:
// there is no (or only stale) compilation state to diagnose and the
// failure is already reported as a compilation error. After an emit
// failure diagnostics still run since they usually carry the root
// cause, matching @angular/build's application builder.
try {
if (!this.#_options.skipTypeChecking && !this.#initializationError) {
const { errors, warnings } =
await this.#angularCompilation.diagnoseFiles(DiagnosticModes.All);
for (const error of errors ?? []) {
compilation.errors.push({
name: PLUGIN_NAME,
message: error.text || '',
file: error.location?.file,
stack: error.text,
});
}
for (const warning of warnings ?? []) {
compilation.warnings.push({
name: PLUGIN_NAME,
message: warning.text || '',
file: warning.location?.file,
stack: warning.text,
});
}
}
} catch (error) {
// A diagnostics failure must not leave the loader callback pending
// and hang the build. Surface it as a build error instead.
addError(
compilation,
`Angular diagnostics failed.\n${formatError(error)}`
);
}
try {
await this.#javascriptTransformer.close();
} catch {
// Best-effort cleanup that must never hang the build.
}
callback();
});
// Store compilation reference for budget checking in done hook
let currentEmitCompilation: Compilation | undefined;
compiler.hooks.afterEmit.tap(PLUGIN_NAME, (compilation) => {
currentEmitCompilation = compilation;
});
// Register done hook once - checks budgets using stored compilation
compiler.hooks.done.tap(PLUGIN_NAME + '_budgets', (statsValue) => {
if (!currentEmitCompilation) return;
const budgets = this.#_options.budgets;
const isPlatformServer = Array.isArray(compiler.options.target)
? compiler.options.target.some(
(target) => target === 'node' || target == 'async-node'
)
: compiler.options.target === 'node' ||
compiler.options.target === 'async-node';
// Early exit - skip expensive toJson() when budgets not needed
if (!budgets?.length || isPlatformServer) {
return;
}
// Only serialize what's needed for budget checking
const stats = statsValue.toJson({
all: false,
assets: true,
chunks: true,
});
const budgetFailures = [...checkBudgets(budgets, stats)];
for (const { severity, message } of budgetFailures) {
switch (severity) {
case ThresholdSeverity.Warning:
addWarning(currentEmitCompilation, {
message,
name: PLUGIN_NAME,
hideStack: true,
});
break;
case ThresholdSeverity.Error:
addError(currentEmitCompilation, {
message,
name: PLUGIN_NAME,
hideStack: true,
});
break;
default:
assertNever(severity);
}
}
});
compiler.hooks.afterDone.tap(PLUGIN_NAME, (stats) => {
// Despite the typings, stats is undefined when the run fails before
// producing stats (e.g. the afterSeal hook above fails the seal for a
// build with errors). Rspack reports that failure itself.
if (!stats) {
return;
}
// Get stats options - merge defaults with user's config if provided
const configStats = compiler.options.stats;
const defaultStatsOptions = getStatsOptions(this.#_options.verbose);
const statsOptions =
typeof configStats === 'object' && configStats !== null
? { ...defaultStatsOptions, ...configStats }
: defaultStatsOptions;
rspackStatsLogger(stats, statsOptions);
// Don't forcibly exit the process with errors when in watch mode
if (!watchRunInitialized && stats.hasErrors()) {
process.exit(1);
}
});
// Dispose the shared component stylesheet bundler after a one-shot build so
// its esbuild service (a child process and sockets, with @angular/build >=
// 21.2.14) is released and `rspack build` can exit. Safe per-compiler: an
// SSR build runs the server compiler after the browser one
// (`dependencies: ['browser']`), never concurrently, and `setupCompilation`
// lazily recreates the singleton if a later compiler needs it again. Watch
// builds keep the bundler for incremental rebuilds and tear it down on
// process shutdown instead.
compiler.hooks.done.tapAsync(
`${PLUGIN_NAME}_cleanup`,
async (_stats, callback) => {
if (!watchRunInitialized) {
try {
await disposeComponentStylesheetBundler();
} catch {
// Best-effort cleanup - never fail an otherwise successful build.
}
}
callback();
}
);
// Failed builds skip the done hook (afterSeal fails the seal), so release
// the esbuild service and worker pool here too. Runs on close for both
// one-shot and watch; both teardowns are idempotent.
compiler.hooks.shutdown.tapAsync(
`${PLUGIN_NAME}_cleanup`,
async (callback) => {
try {
await this.#javascriptTransformer.close();
await disposeComponentStylesheetBundler();
} catch {
// Best-effort cleanup that must never fail the compiler teardown.
}
callback();
}
);
compiler.hooks.normalModuleFactory.tap(
PLUGIN_NAME,
(normalModuleFactory) => {
normalModuleFactory.hooks.beforeResolve.tap(PLUGIN_NAME, (data) => {
if (data.request.startsWith('angular:jit:')) {
const path = data.request.split(';')[1];
data.request = `${normalize(
resolve(dirname(data.contextInfo.issuer), path)
)}?raw`;
}
});
}
);
compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation) => {
(compilation as NgRspackCompilation)[NG_RSPACK_SYMBOL_NAME] = () => ({
javascriptTransformer: this
.#javascriptTransformer as unknown as JavaScriptTransformer,
typescriptFileCache: this.#sourceFileCache.typeScriptFileCache,
angularCompilationFailed:
this.#initializationError !== undefined ||
this.#emitError !== undefined,
i18n: this.#i18n,
});
});
if (this.#_options.serviceWorker) {
compiler.hooks.done.tapAsync(
PLUGIN_NAME,
async (compilation, callback) => {
let providedLocales = this.#_options.localize;
if (!providedLocales) {
await augmentAppWithServiceWorker(
this.#_options.root,
workspaceRoot,
this.#_options.outputPath.browser,
this.#_options.baseHref ?? '/',
this.#_options.ngswConfigPath
);
} else if (providedLocales && this.#i18n) {
if (typeof providedLocales === 'string') {
providedLocales = [providedLocales];
} else if (typeof providedLocales === 'boolean') {
providedLocales = Array.from(this.#i18n.inlineLocales);
}
for (const locale of providedLocales) {
await augmentAppWithServiceWorker(
this.#_options.root,
workspaceRoot,
join(
this.#_options.outputPath.browser,
this.#i18n.locales[locale]?.subPath ?? locale
),
getLocaleBaseHref(
this.#i18n,
locale,
this.#_options.baseHref
) ??
this.#_options.baseHref ??
'/',
this.#_options.ngswConfigPath
);
}
}
callback();
}
);
}
}
private async setupCompilation(
root: string,
tsConfig: RspackOptionsNormalized['resolve']['tsConfig'],
modifiedFiles?: Set<string>
) {
this.#initializationError = undefined;
this.#emitError = undefined;
const tsconfigPath = tsConfig
? typeof tsConfig === 'string'
? tsConfig
: tsConfig.configFile
: this.#_options.tsConfig;
try {
const result = await setupCompilationWithAngularCompilation(
{
source: {
tsconfigPath: tsconfigPath,
},
},
{
root,
aot: this.#_options.aot,
tsConfig: tsconfigPath,
inlineStyleLanguage: this.#_options.inlineStyleLanguage,
fileReplacements: this.#_options.fileReplacements,
useTsProjectReferences: this.#_options.useTsProjectReferences,
hasServer: this.#_options.hasServer,
includePaths: this.#_options.stylePreprocessorOptions?.includePaths,
sass: this.#_options.stylePreprocessorOptions?.sass,
},
this.#sourceFileCache,
this.#angularCompilation,
modifiedFiles
);
this.#angularCompilation = result.angularCompilation;
this.#collectedStylesheetAssets = result.collectedStylesheetAssets;
} catch (error) {
this.#initializationError = `Angular compilation initialization failed.\n${formatError(
error
)}`;
}
}
private async buildAndAnalyze() {
if (this.#initializationError) {
return;
}
try {
await buildAndAnalyze(
this.#angularCompilation,
this.#sourceFileCache.typeScriptFileCache,
this.#javascriptTransformer
);
} catch (error) {
this.#emitError = `Angular compilation emit failed.\n${formatError(
error
)}`;
}
}
}
function formatError(error: unknown): string {
return error instanceof Error
? (error.stack ?? error.message)
: String(error);
}
@@ -0,0 +1,88 @@
import {
Compilation,
Compiler,
RspackPluginInstance,
sources,
} from '@rspack/core';
import { ChildProcess, fork } from 'child_process';
import { SsrReloadServer } from './server/ssr-reload-server';
import { NormalizedAngularRspackPluginOptions } from '../models';
import { join } from 'node:path';
import { existsSync, readFileSync } from 'node:fs';
import { getIndexOutputFile } from '../utils/index-file/get-index-output-file';
import { addBodyScript } from '../utils/index-file/add-body-script';
const PLUGIN_NAME = 'AngularSsrDevServer';
export class AngularSsrDevServer implements RspackPluginInstance {
#devServerProcess: ChildProcess | undefined;
#wsServer: SsrReloadServer;
#options: NormalizedAngularRspackPluginOptions;
constructor(options: NormalizedAngularRspackPluginOptions) {
this.#wsServer = new SsrReloadServer();
this.#options = options;
}
apply(compiler: Compiler) {
compiler.hooks.entryOption.tap(PLUGIN_NAME, (context, entry) => {
const keys = Object.keys(entry);
for (const key of keys) {
const entryValue = entry[key];
entryValue.import = [...entryValue.import];
}
});
compiler.hooks.watchRun.tapAsync(
PLUGIN_NAME,
async (compiler, callback) => {
compiler.hooks.afterEmit.tapAsync(
PLUGIN_NAME,
async (compilation, callback) => {
await this.#attachSSRReloadClient(compilation);
const serverPath = join(
this.#options.outputPath.server,
'server.js'
);
if (this.#devServerProcess) {
this.#devServerProcess.kill();
this.#devServerProcess = undefined;
await new Promise<void>((res) => setTimeout(res, 50));
}
if (!existsSync(serverPath)) {
await new Promise<void>((res) => setTimeout(res, 50));
}
this.#devServerProcess = fork(serverPath);
this.#devServerProcess.on('spawn', () => {
this.#wsServer.sendReload();
});
callback();
}
);
callback();
}
);
}
async #attachSSRReloadClient(compilation: Compilation) {
const clientPath = require.resolve(
`${__dirname}/client/ssr-reload-client.js`
);
const pathToIndex = join(
this.#options.outputPath.browser,
getIndexOutputFile(this.#options.index)
);
const html = readFileSync(pathToIndex, 'utf-8');
const updatedHtml = await addBodyScript(
html,
readFileSync(clientPath, 'utf-8')
);
const source = new sources.RawSource(updatedHtml);
const relativePathToIndex = pathToIndex.replace(
this.#options.outputPath.browser,
''
);
compilation.emitAsset(relativePathToIndex, source);
}
}
@@ -0,0 +1,84 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {
BudgetAsset,
BudgetEntry,
BudgetType,
ThresholdSeverity,
checkBudgets,
} from '@angular/build/private';
import * as path from 'node:path';
import { Compilation, type Compiler, RspackPluginInstance } from '@rspack/core';
import { addError, addWarning } from '../utils/rspack-diagnostics';
import { assertNever } from '../utils/misc-helpers';
const PLUGIN_NAME = 'AnyComponentStyleBudgetChecker';
/**
* Check budget sizes for component styles by emitting a warning or error if a
* budget is exceeded by a particular component's styles.
*/
export class AnyComponentStyleBudgetChecker implements RspackPluginInstance {
private readonly budgets: BudgetEntry[];
constructor(budgets: BudgetEntry[]) {
this.budgets = budgets.filter(
(budget) => budget.type === BudgetType.AnyComponentStyle
);
}
apply(compiler: Compiler) {
compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation) => {
compilation.hooks.processAssets.tap(
{
name: PLUGIN_NAME,
stage: Compilation.PROCESS_ASSETS_STAGE_ANALYSE,
},
() => {
// No budgets.
if (this.budgets.length === 0) {
return;
}
// In AOT compilations component styles get processed in child compilations.
if (!compilation.compiler.parentCompilation) {
return;
}
const cssExtensions = ['.css', '.scss', '.less', '.sass'];
const componentStyles: BudgetAsset[] = Object.keys(compilation.assets)
.filter((name) => cssExtensions.includes(path.extname(name)))
.map((name) => ({
name,
size: compilation.assets[name].size(),
componentStyle: true,
}));
for (const { severity, message } of checkBudgets(
this.budgets,
{ chunks: [], assets: componentStyles },
true
)) {
switch (severity) {
case ThresholdSeverity.Warning:
addWarning(compilation, message);
break;
case ThresholdSeverity.Error:
addError(compilation, message);
break;
default:
assertNever(severity);
}
}
}
);
});
}
}
@@ -0,0 +1,18 @@
// casting is needed to force the type to be the global WebSocket and avoid type errors
const socket: WebSocket = new WebSocket(`ws://localhost:60000`);
socket.addEventListener('message', (event) => {
if (event.data === 'ssr-reload') {
console.log('[AngularSSRDevServer] Reloading the page...');
// @ts-expect-error: This is a client-side file
window.location.reload();
}
});
socket.addEventListener('open', () => {
console.log('[AngularSSRDevServer] WebSocket connection established.');
});
socket.addEventListener('close', () => {
console.warn('[AngularSSRDevServer] WebSocket connection closed.');
});
@@ -0,0 +1,294 @@
import remapping from '@ampproject/remapping';
import {
type PluginObj,
parseSync,
transformFromAstAsync,
types,
} from '@babel/core';
import {
type Compiler,
type RspackPluginInstance,
sources,
} from '@rspack/core';
import assert from 'node:assert';
import type {
I18nOptions,
NormalizedAngularRspackPluginOptions,
} from '../models';
import { getLocaleOutputPaths } from '../utils/i18n';
import { loadEsmModule } from '../utils/misc-helpers';
/**
* A Type representing the localize tools module.
*/
type LocalizeUtilityModule = typeof import('@angular/localize/tools');
const PLUGIN_NAME = 'I18nInlinePlugin';
export class I18nInlinePlugin implements RspackPluginInstance {
#pluginOptions: NormalizedAngularRspackPluginOptions;
#i18n: I18nOptions;
/**
* Cached instance of the `@angular/localize/tools` module.
* This is used to remove the need to repeatedly import the module per file translation.
*/
#localizeToolsModule: LocalizeUtilityModule | undefined;
#outputPaths: Set<string>;
constructor(
pluginOptions: NormalizedAngularRspackPluginOptions,
i18nOptions: I18nOptions
) {
this.#pluginOptions = pluginOptions;
this.#i18n = i18nOptions;
this.#outputPaths = new Set(getLocaleOutputPaths(i18nOptions).values());
}
apply(compiler: Compiler) {
compiler.hooks.emit.tapAsync(PLUGIN_NAME, async (compilation, callback) => {
const filesToInline = new Map<
string,
{ text: string; map: sources.RawSourceMap | null }
>();
const additionalFiles = new Map<string, sources.Source>();
for (const [filename, source] of Object.entries(compilation.assets)) {
let contents = source.source();
if (typeof contents !== 'string') {
contents = contents.toString();
}
if (!filename.endsWith('.map') && contents.includes('$localize')) {
filesToInline.set(filename, { text: contents, map: source.map() });
} else {
additionalFiles.set(filename, source);
}
}
// Map of locales to Map of files to output
const filesToOutput = new Map<
string,
Map<string, { text: string | Buffer; map: sources.RawSourceMap | null }>
>();
for (const localeKey of this.#i18n.inlineLocales) {
const locale = this.#i18n.locales[localeKey];
const localeFiles = new Map<
string,
{ text: string | Buffer; map: sources.RawSourceMap | null }
>();
for (const [filename, { text, map }] of filesToInline.entries()) {
if (this.#shouldSkipPerLocaleEmit(filename)) {
continue;
}
const result = await this.#transformWithBabel(
text,
map,
filename,
localeKey,
locale.translation,
this.#pluginOptions.advancedOptimizations
);
const sourceMap = result.map
? new sources.SourceMapSource(result.code, filename, result.map)
: undefined;
localeFiles.set(filename, {
text: result.code,
map: sourceMap ? sourceMap.map() : undefined,
});
// TODO: Add support for diagnostics
}
for (const [filename, source] of additionalFiles.entries()) {
if (this.#shouldSkipPerLocaleEmit(filename)) {
continue;
}
localeFiles.set(filename, {
text: source.source(),
map: source.map(),
});
}
filesToOutput.set(locale.subPath, localeFiles);
}
for (const [localeSubPath, files] of filesToOutput.entries()) {
for (const [filename, { text, map }] of files.entries()) {
const localeFileName = `${localeSubPath}/${filename}`;
const asset = compilation.getAsset(filename);
const assetInfo = asset ? asset.info : undefined;
if (map) {
compilation.emitAsset(
localeFileName,
new sources.SourceMapSource(text, localeFileName, map),
(newAssetInfo) => ({ ...assetInfo, ...newAssetInfo })
);
}
compilation.emitAsset(
localeFileName,
new sources.RawSource(text),
(newAssetInfo) => ({ ...assetInfo, ...newAssetInfo })
);
if (asset) {
compilation.deleteAsset(filename);
}
}
}
callback();
});
}
// Returns true when an asset must not be duplicated per locale.
// - First segment matches a locale subPath: the asset is already inside a
// per-locale dir (re-entrancy guard).
// - Path contains a `..` segment: the asset is globally scoped and emits
// outside the per-locale dir (e.g. `LicenseWebpackPlugin`'s
// `../3rdpartylicenses.txt`, which lands at `outputPath.base`). This
// mirrors how Angular's application builder excludes
// `BuildOutputFileType.Root` files from i18n inlining; without the
// guard, every locale's emit would resolve to the same physical path
// and rspack's `compareBeforeEmit` would surface them as undeclared
// reads.
#shouldSkipPerLocaleEmit(filename: string) {
const segments = filename.split('/');
return this.#outputPaths.has(segments[0]) || segments.includes('..');
}
/**
* Attempts to load the `@angular/localize/tools` module containing the functionality to
* perform the file translations.
* This module must be dynamically loaded as it is an ESM module and this file is CommonJS.
*/
async #loadLocalizeTools() {
// Load ESM `@angular/localize/tools` using the TypeScript dynamic import workaround.
// Once TypeScript provides support for keeping the dynamic import this workaround can be
// changed to a direct dynamic import.
this.#localizeToolsModule ??= await loadEsmModule<LocalizeUtilityModule>(
'@angular/localize/tools'
);
}
/**
* Creates the needed Babel plugins to inline a given locale and translation for a JavaScript file.
* @param locale A string containing the locale specifier to use.
* @param translation A object record containing locale specific messages to use.
* @returns An array of Babel plugins.
*/
async #createI18nPlugins(
locale: string,
translation: Record<string, unknown> | undefined
) {
await this.#loadLocalizeTools();
const { Diagnostics, makeEs2015TranslatePlugin } =
this.#localizeToolsModule!;
const plugins: PluginObj[] = [];
const diagnostics = new Diagnostics();
plugins.push(
makeEs2015TranslatePlugin(diagnostics, (translation || {}) as any, {
missingTranslation:
translation === undefined
? 'ignore'
: this.#pluginOptions.i18nMissingTranslation,
})
);
// Create a plugin to replace the locale specifier constant inject by the build system with the actual specifier
plugins.push({
visitor: {
StringLiteral(path) {
if (path.node.value === '___NG_LOCALE_INSERT___') {
path.replaceWith(types.stringLiteral(locale));
}
},
},
});
return { diagnostics, plugins };
}
#assertIsError(value: unknown): asserts value is Error & { code?: string } {
const isError =
value instanceof Error ||
// The following is needing to identify errors coming from RxJs.
(typeof value === 'object' &&
value &&
'name' in value &&
'message' in value);
assert(isError, 'catch clause variable is not an Error instance');
}
/**
* Transforms a JavaScript file using Babel to inline the request locale and translation.
* @param code A string containing the JavaScript code to transform.
* @param map A sourcemap object for the provided JavaScript code.
* @param filename The filename of the JavaScript file to transform.
* @param locale The locale to inline.
* @param translation The translation to inline.
* @param shouldOptimize Whether to optimize the transformed code.
* @returns An object containing the code, map, and diagnostics from the transformation.
*/
async #transformWithBabel(
code: string,
map: sources.RawSourceMap | null,
filename: string,
locale: string,
translation: Record<string, unknown> | undefined,
shouldOptimize: boolean
) {
let ast;
try {
ast = parseSync(code, {
babelrc: false,
configFile: false,
sourceType: 'unambiguous',
filename,
});
} catch (error) {
this.#assertIsError(error);
// Make the error more readable.
// Same errors will contain the full content of the file as the error message
// Which makes it hard to find the actual error message.
const index = error.message.indexOf(')\n');
const msg =
index !== -1 ? error.message.slice(0, index + 1) : error.message;
throw new Error(`${msg}\nAn error occurred inlining file "${filename}"`);
}
if (!ast) {
throw new Error(`Unknown error occurred inlining file "${filename}"`);
}
const { diagnostics, plugins } = await this.#createI18nPlugins(
locale,
translation
);
const transformResult = await transformFromAstAsync(ast, code, {
filename,
// false is a valid value but not included in the type definition
inputSourceMap: false as unknown as undefined,
sourceMaps: !!map,
compact: shouldOptimize,
configFile: false,
babelrc: false,
browserslistConfigFile: false,
plugins,
});
if (!transformResult || !transformResult.code) {
throw new Error(
`Unknown error occurred processing bundle for "${filename}".`
);
}
let outputMap;
if (map && transformResult.map) {
outputMap = remapping([transformResult.map, map], () => null);
}
return {
code: transformResult.code,
map: outputMap && JSON.stringify(outputMap),
diagnostics,
};
}
}
@@ -0,0 +1,209 @@
import {
IndexHtmlGenerator,
type FileInfo,
type IndexHtmlGeneratorOptions,
} from '@angular/build/private';
import { VERSION } from '@angular/core';
import { Compilation, RspackPluginInstance, type Compiler } from '@rspack/core';
import { createHash } from 'node:crypto';
import { basename, extname, join } from 'node:path';
import type { I18nOptions, IndexExpandedDefinition } from '../models';
import { addEventDispatchContract } from '../utils/index-file/add-event-dispatch-contract';
import { assertIsError } from '../utils/misc-helpers';
import { ensureOutputPaths } from '../utils/i18n';
import { addError, addWarning } from '../utils/rspack-diagnostics';
import {
addTrailingSlash,
joinUrlParts,
stripLeadingSlash,
} from '../utils/url';
import { getIndexOutputFile } from '../utils/index-file/get-index-output-file';
export interface IndexHtmlPluginOptions extends IndexHtmlGeneratorOptions {
baseHref: string | undefined;
i18n: I18nOptions;
index: IndexExpandedDefinition;
isSsr: boolean;
outputPath: string;
}
const PLUGIN_NAME = 'IndexHtmlPlugin';
export class IndexHtmlPlugin
extends IndexHtmlGenerator
implements RspackPluginInstance
{
private _compilation: Compilation | undefined;
get compilation(): Compilation {
if (this._compilation) {
return this._compilation;
}
throw new Error('compilation is undefined.');
}
constructor(override readonly options: IndexHtmlPluginOptions) {
super(options);
}
apply(compiler: Compiler) {
compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => {
this._compilation = compilation;
compilation.hooks.processAssets.tapPromise(
{
name: PLUGIN_NAME,
stage: Compilation.PROCESS_ASSETS_STAGE_SUMMARIZE,
},
async () => {
const files: FileInfo[] = [];
try {
for (const chunk of compilation.chunks) {
for (const file of chunk.files) {
// https://github.com/web-infra-dev/rspack/blob/a2e1e21c7e1ed0f34e476ec270e3c5460c4a1a36/packages/rspack/src/config/defaults.ts#L606
if (
file.endsWith('.hot-update.js') ||
file.endsWith('.hot-update.mjs')
) {
continue;
}
files.push({
name: chunk.name,
file,
extension: extname(file),
});
}
}
// v22+ emits an importmap with per-lazy-chunk SRI. Fill the map the
// augment plugin captured at construction (allocated in ng-rspack on
// SRI builds). Eager chunks already carry an integrity attribute on
// their <script> tag; a chunk is initial when any of its groups is
// (entrypoint.getFiles() is deep and would wrongly include lazy files).
const chunksIntegrity = this.options.chunksIntegrity as
| Map<string, string>
| undefined;
if (chunksIntegrity && +VERSION.major >= 22) {
chunksIntegrity.clear();
const initialFiles = new Set<string>();
for (const chunk of compilation.chunks) {
let isInitial = false;
for (const group of chunk.groupsIterable) {
if (group.isInitial()) {
isInitial = true;
break;
}
}
if (isInitial) {
for (const file of chunk.files) {
initialFiles.add(file);
}
}
}
for (const { file, extension } of files) {
if (extension !== '.js' || initialFiles.has(file)) {
continue;
}
const hash = createHash('sha384')
.update(compilation.assets[file].buffer())
.digest('base64');
chunksIntegrity.set(file, `sha384-${hash}`);
}
}
const outputPaths = ensureOutputPaths(
this.options.outputPath,
this.options.i18n
);
for (const [locale, outputPath] of outputPaths.entries()) {
const { csrContent, warnings, errors } = await this.process({
files,
outputPath,
baseHref:
this.getLocaleBaseHref(locale) ?? this.options.baseHref,
lang: locale || undefined,
});
let html = csrContent;
if (this.options.isSsr) {
html = await addEventDispatchContract(csrContent);
}
const { RawSource } = compiler.rspack.sources;
compilation.emitAsset(
join(outputPath, getIndexOutputFile(this.options.index)),
new RawSource(html)
);
warnings.forEach((msg) => addWarning(compilation, msg));
errors.forEach((msg) => addError(compilation, msg));
}
} catch (error) {
assertIsError(error);
addError(compilation, error.message);
}
}
);
});
}
override async readAsset(path: string): Promise<string> {
const data = this.compilation.assets[basename(path)].source();
return typeof data === 'string' ? data : data.toString();
}
protected override async readIndex(path: string): Promise<string> {
return new Promise<string>((resolve, reject) => {
if (!this.compilation.inputFileSystem) {
super.readIndex(path).then(resolve).catch(reject);
return;
}
this.compilation.inputFileSystem.readFile(
path,
(err?: Error | null, data?: string | Buffer) => {
if (err) {
reject(err);
return;
}
this.compilation.fileDependencies.add(path);
resolve(data?.toString() ?? '');
}
);
});
}
getLocaleBaseHref(locale: string): string | undefined {
if (this.options.i18n.flatOutput) {
return undefined;
}
const localeData = this.options.i18n.locales[locale];
if (!localeData) {
return undefined;
}
const baseHrefSuffix = localeData.baseHref ?? localeData.subPath + '/';
let joinedBaseHref: string | undefined;
if (baseHrefSuffix !== '') {
joinedBaseHref = addTrailingSlash(
joinUrlParts(this.options.baseHref || '', baseHrefSuffix)
);
if (this.options.baseHref && this.options.baseHref[0] !== '/') {
joinedBaseHref = stripLeadingSlash(joinedBaseHref);
}
}
return joinedBaseHref;
}
}
@@ -0,0 +1,81 @@
import type { LoaderContext } from '@rspack/core';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { NG_RSPACK_SYMBOL_NAME, type NgRspackCompilation } from '../../models';
import { default as angularPartialTransformLoader } from './angular-partial-transform.loader';
describe('angular-partial-transform.loader', () => {
const callback = vi.fn();
const typescriptFileCache = new Map<string, string | Uint8Array>();
const transformFile = vi.fn();
const makeCompilation = (angularCompilationFailed = false) =>
({
[NG_RSPACK_SYMBOL_NAME]: () => ({
javascriptTransformer: { transformFile },
typescriptFileCache,
angularCompilationFailed,
}),
}) as unknown as NgRspackCompilation;
const thisValue = {
async: vi.fn(() => callback),
_compilation: {},
} as unknown as LoaderContext<unknown>;
beforeEach(() => {
vi.clearAllMocks();
typescriptFileCache.clear();
});
it('should return content when NG_RSPACK_SYMBOL_NAME is undefined', () => {
angularPartialTransformLoader.call(thisValue, '@angular content');
expect(callback).toHaveBeenCalledWith(null, '@angular content');
});
it('should pass content through when the angular compilation failed', () => {
angularPartialTransformLoader.call(
{
...thisValue,
_compilation: makeCompilation(true),
resourcePath: '/path/to/file.js',
},
'@angular content'
);
expect(callback).toHaveBeenCalledWith(null, '@angular content');
expect(transformFile).not.toHaveBeenCalled();
});
it('should transform the file and cache the result', async () => {
transformFile.mockResolvedValue(Buffer.from('transformed'));
angularPartialTransformLoader.call(
{
...thisValue,
_compilation: makeCompilation(),
resourcePath: '/path/to/file.js',
},
'@angular content'
);
await vi.waitFor(() =>
expect(callback).toHaveBeenCalledWith(null, 'transformed')
);
expect(typescriptFileCache.get('/path/to/file.js')).toBe('transformed');
});
it('should fail the module when the transform rejects', async () => {
const error = new Error('worker crashed');
transformFile.mockRejectedValue(error);
angularPartialTransformLoader.call(
{
...thisValue,
_compilation: makeCompilation(),
resourcePath: '/path/to/file.js',
},
'@angular content'
);
await vi.waitFor(() => expect(callback).toHaveBeenCalledWith(error));
});
});
@@ -0,0 +1,61 @@
import { LoaderContext } from '@rspack/core';
import { NG_RSPACK_SYMBOL_NAME, NgRspackCompilation } from '../../models';
export default function loader(this: LoaderContext<unknown>, content: string) {
const callback = this.async();
if (
(this._compilation as NgRspackCompilation)[NG_RSPACK_SYMBOL_NAME] ===
undefined
) {
callback(null, content);
} else {
const {
javascriptTransformer,
typescriptFileCache,
angularCompilationFailed,
} = (this._compilation as NgRspackCompilation)[NG_RSPACK_SYMBOL_NAME]();
if (angularCompilationFailed) {
// The build is already failing with the Angular compilation error,
// so skip transforming and pass the original content through.
callback(null, content);
return;
}
const request = this.resourcePath;
if (
request.startsWith('data:text/javascript') &&
request.includes('__module_federation_bundler_runtime__')
) {
callback(null, content);
return;
}
if (!content.includes('@angular')) {
callback(null, content);
return;
}
const existingTransform = typescriptFileCache.get(request);
if (existingTransform) {
const existingContents =
typeof existingTransform === 'string'
? existingTransform
: Buffer.from(existingTransform).toString('utf8');
callback(null, existingContents);
return;
}
javascriptTransformer.transformFile(request, false, false).then(
(contents) => {
const transformedCode = Buffer.from(contents).toString('utf8');
typescriptFileCache.set(request, transformedCode);
callback(null, transformedCode);
},
(error) => {
// Fail the module instead of leaving the loader callback pending,
// which would hang the build.
callback(error instanceof Error ? error : new Error(String(error)));
}
);
}
}
@@ -0,0 +1,143 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { default as angularTransformLoader } from './angular-transform.loader';
import { NG_RSPACK_SYMBOL_NAME, type NgRspackCompilation } from '../../models';
import type { LoaderContext } from '@rspack/core';
class StyleUrlsResolverMock {
constructor(private cb: (content: string) => string) {}
resolve(content: string, _: string): string[] {
return [this.cb(content)];
}
}
class TemplateUrlsResolverMock {
constructor(private cb: (content: string) => string) {}
resolve(content: string, _: string): string[] {
return [this.cb(content)];
}
}
describe('angular-transform.loader', () => {
const callback = vi.fn();
const addDependency = vi.fn();
const typescriptFileCache = new Map<string, string | Buffer>();
const _compilation = {
[NG_RSPACK_SYMBOL_NAME]: () => ({
typescriptFileCache,
}),
} as unknown as NgRspackCompilation;
const thisValue = {
async: vi.fn(() => callback),
_compilation: {},
} as unknown as LoaderContext<unknown>;
beforeEach(() => {
vi.clearAllMocks();
});
it('should return content when NG_RSPACK_SYMBOL_NAME is undefined', () => {
angularTransformLoader.call(thisValue, 'content');
expect(callback).toHaveBeenCalledWith(null, 'content');
});
it('should add dependencies for resolved template and style URLs', () => {
angularTransformLoader.call(
{
...thisValue,
_compilation,
resourcePath: '/home/projects/analog/src/app/app.component.ts',
addDependency,
},
'@Component()',
{
templateUrlsResolver: new TemplateUrlsResolverMock(
() =>
'./app.component.html|/home/projects/analog/src/app/app.component.html'
),
styleUrlsResolver: new StyleUrlsResolverMock(
() =>
'./app.component.css|/home/projects/analog/src/app/app.component.css'
),
} as any
);
expect(addDependency).toHaveBeenCalledTimes(2);
expect(addDependency).toHaveBeenNthCalledWith(
1,
'/home/projects/analog/src/app/app.component.html'
);
expect(addDependency).toHaveBeenNthCalledWith(
2,
'/home/projects/analog/src/app/app.component.css'
);
});
it('should emit an empty module when the angular compilation failed', () => {
angularTransformLoader.call(
{
...thisValue,
_compilation: {
[NG_RSPACK_SYMBOL_NAME]: () => ({
typescriptFileCache,
angularCompilationFailed: true,
}),
} as unknown as NgRspackCompilation,
resourcePath: '/path/to/file.ts',
},
'content'
);
expect(callback).toHaveBeenCalledWith(null, '');
});
it('should return content when typescriptFileCache does not contain normalizedRequest', () => {
angularTransformLoader.call(
{
...thisValue,
_compilation,
resourcePath: '/path/to/file.ts',
},
'content'
);
expect(callback).toHaveBeenCalledWith(null, 'content');
});
it('should return content when typescriptFileCache does contain string', () => {
typescriptFileCache.set(
'/home/projects/analog/src/app/app.component.ts',
'cached content'
);
angularTransformLoader.call(
{
...thisValue,
_compilation,
resourcePath: '/home/projects/analog/src/app/app.component.ts',
},
'@Component()'
);
expect(callback).toHaveBeenCalledWith(null, 'cached content');
});
it('should return content when typescriptFileCache does contain Buffer', () => {
typescriptFileCache.set(
'/home/projects/analog/src/app/app.component.ts',
Buffer.from('cached content')
);
angularTransformLoader.call(
{
...thisValue,
_compilation,
resourcePath: '/home/projects/analog/src/app/app.component.ts',
},
'@Component()'
);
expect(callback).toHaveBeenCalledWith(null, Buffer.from('cached content'));
});
});
@@ -0,0 +1,68 @@
import type { LoaderContext } from '@rspack/core';
import { normalize } from 'path';
import { NG_RSPACK_SYMBOL_NAME, NgRspackCompilation } from '../../models';
import {
StyleUrlsResolver,
TemplateUrlsResolver,
} from '@nx/angular-rspack-compiler';
const _styleUrlsResolver = new StyleUrlsResolver();
const _templateUrlsResolver = new TemplateUrlsResolver();
export default function loader(
this: LoaderContext<unknown>,
content: string,
opt?: {
styleUrlsResolver: StyleUrlsResolver;
templateUrlsResolver: TemplateUrlsResolver;
}
) {
const {
styleUrlsResolver = _styleUrlsResolver,
templateUrlsResolver = _templateUrlsResolver,
} = opt ?? {};
const callback = this.async();
if (
(this._compilation as NgRspackCompilation)[NG_RSPACK_SYMBOL_NAME] ===
undefined
) {
callback(null, content);
} else {
const { typescriptFileCache, angularCompilationFailed } = (
this._compilation as NgRspackCompilation
)[NG_RSPACK_SYMBOL_NAME]();
if (angularCompilationFailed) {
// The Angular compilation failure is already reported as a compilation
// error. Emit empty modules instead of raw TS sources that would only
// bury it under parser errors.
callback(null, '');
return;
}
const request = this.resourcePath.replace(/^[A-Z]:/, '');
const normalizedRequest = normalize(request);
const templateUrls = templateUrlsResolver.resolve(
content,
normalizedRequest
);
const styleUrls = styleUrlsResolver.resolve(content, normalizedRequest);
for (const urlSet of [...templateUrls, ...styleUrls]) {
// `urlSet` is a string where a relative path is joined with an
// absolute path using the `|` symbol.
// For example: `./app.component.html|/home/projects/analog/src/app/app.component.html`.
const [, absoluteFileUrl] = urlSet.split('|');
this.addDependency(absoluteFileUrl);
}
const contents = typescriptFileCache.get(normalizedRequest);
if (contents === undefined) {
callback(null, content);
} else if (typeof contents === 'string') {
callback(null, contents);
} else {
callback(null, Buffer.from(contents));
}
}
}
@@ -0,0 +1,30 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import { join } from 'node:path';
import { LoaderContext, LoaderDefinitionFunction } from '@rspack/core';
export const HmrLoader = __filename;
const hmrAcceptPath = join(__dirname, './hmr-accept.js').replace(/\\/g, '/');
export default function localizeExtractLoader(
this: LoaderContext,
content: string,
map: Parameters<LoaderDefinitionFunction>[1]
) {
const source = `${content}
// HMR Accept Code
import ngHmrAccept from '${hmrAcceptPath}';
ngHmrAccept(module);
`;
this.callback(null, source, map);
return;
}
@@ -0,0 +1,245 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {
ApplicationRef,
PlatformRef,
Type,
isDevMode,
ɵresetCompiledComponents,
} from '@angular/core';
import { filter, take } from 'rxjs';
// For the time being we cannot use the DOM lib because it conflicts with @types/node,
// In future when we remove `yarn admin build` we should have this as a seperate compilation unit
// which includes DOM lib.
declare const ng: any;
declare const document: any;
declare const MutationObserver: any;
declare const KeyboardEvent: any;
declare const Event: any;
export default function (mod: any): void {
if (!mod['hot']) {
return;
}
if (!isDevMode()) {
console.error(
`[NG HMR] Cannot use HMR when Angular is running in production mode. To prevent production mode, do not call 'enableProdMode()'.`
);
return;
}
mod['hot'].accept();
mod['hot'].dispose(() => {
if (typeof ng === 'undefined') {
console.warn(
`[NG HMR] Cannot find global 'ng'. Likely this is caused because scripts optimization is enabled.`
);
return;
}
if (!ng.getInjector) {
// View Engine
return;
}
// Reset JIT compiled components cache
ɵresetCompiledComponents();
const appRoot = getAppRoot();
if (!appRoot) {
return;
}
const appRef = getApplicationRef(appRoot);
if (!appRef) {
return;
}
// Inputs that are hidden should be ignored
const oldInputs = document.querySelectorAll(
'input:not([type="hidden"]), textarea'
);
const oldOptions = document.querySelectorAll('option');
// Create new application
appRef.components.forEach((cp) => {
const element = cp.location.nativeElement;
const parentNode = element.parentNode;
parentNode.insertBefore(document.createElement(element.tagName), element);
parentNode.removeChild(element);
});
// Destroy old application, injectors, <style..., etc..
const platformRef = getPlatformRef(appRoot);
if (platformRef) {
platformRef.destroy();
}
// Restore all inputs and options
const bodyElement = document.body;
if (oldInputs.length + oldOptions.length === 0 || !bodyElement) {
return;
}
// Use a `MutationObserver` to wait until the app-root element has been bootstrapped.
// ie: when the ng-version attribute is added.
new MutationObserver((_mutationsList: any, observer: any) => {
observer.disconnect();
const newAppRoot = getAppRoot();
if (!newAppRoot) {
return;
}
const newAppRef = getApplicationRef(newAppRoot);
if (!newAppRef) {
return;
}
// Wait until the application isStable to restore the form values
newAppRef.isStable
.pipe(
filter((isStable) => !!isStable),
take(1)
)
.subscribe(() => restoreFormValues(oldInputs, oldOptions));
}).observe(bodyElement, {
attributes: true,
subtree: true,
attributeFilter: ['ng-version'],
});
});
}
function getAppRoot(): any {
const appRoot = document.querySelector('[ng-version]');
if (!appRoot) {
console.warn('[NG HMR] Cannot find the application root component.');
return undefined;
}
return appRoot;
}
function getToken<T>(appRoot: any, token: Type<T>): T | undefined {
return (
(typeof ng === 'object' && ng.getInjector(appRoot).get(token)) || undefined
);
}
function getApplicationRef(appRoot: any): ApplicationRef | undefined {
const appRef = getToken(appRoot, ApplicationRef);
if (!appRef) {
console.warn(`[NG HMR] Cannot get 'ApplicationRef'.`);
return undefined;
}
return appRef;
}
function getPlatformRef(appRoot: any): PlatformRef | undefined {
const platformRef = getToken(appRoot, PlatformRef);
if (!platformRef) {
console.warn(`[NG HMR] Cannot get 'PlatformRef'.`);
return undefined;
}
return platformRef;
}
function dispatchEvents(element: any): void {
element.dispatchEvent(
new Event('input', {
bubbles: true,
cancelable: true,
})
);
element.blur();
element.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter' }));
}
function restoreFormValues(oldInputs: any[], oldOptions: any[]): void {
// Restore input that are not hidden
const newInputs = document.querySelectorAll(
'input:not([type="hidden"]), textarea'
);
if (newInputs.length && newInputs.length === oldInputs.length) {
console.log('[NG HMR] Restoring input/textarea values.');
for (let index = 0; index < newInputs.length; index++) {
const newElement = newInputs[index];
const oldElement = oldInputs[index];
switch (oldElement.type) {
case 'button':
case 'image':
case 'submit':
case 'reset':
// These types don't need any value change.
continue;
case 'radio':
case 'checkbox':
newElement.checked = oldElement.checked;
break;
case 'color':
case 'date':
case 'datetime-local':
case 'email':
case 'hidden':
case 'month':
case 'number':
case 'password':
case 'range':
case 'search':
case 'tel':
case 'text':
case 'textarea':
case 'time':
case 'url':
case 'week':
newElement.value = oldElement.value;
break;
case 'file':
// Ignored due: Uncaught DOMException: Failed to set the 'value' property on 'HTMLInputElement':
// This input element accepts a filename, which may only be programmatically set to the empty string.
break;
default:
console.warn('[NG HMR] Unknown input type ' + oldElement.type + '.');
continue;
}
dispatchEvents(newElement);
}
} else if (oldInputs.length) {
console.warn('[NG HMR] Cannot restore input/textarea values.');
}
// Restore option
const newOptions = document.querySelectorAll('option');
if (newOptions.length && newOptions.length === oldOptions.length) {
console.log('[NG HMR] Restoring selected options.');
for (let index = 0; index < newOptions.length; index++) {
const newElement = newOptions[index];
newElement.selected = oldOptions[index].selected;
dispatchEvents(newElement);
}
} else if (oldOptions.length) {
console.warn('[NG HMR] Cannot restore selected options.');
}
}
@@ -0,0 +1,36 @@
import {
type LoaderContext,
type LoaderDefinitionFunction,
} from '@rspack/core';
export default function loader(
this: LoaderContext<{
angularSSRInstalled: boolean;
isZoneJsInstalled: boolean;
}>,
content: string,
map: Parameters<LoaderDefinitionFunction>[1]
) {
const { angularSSRInstalled, isZoneJsInstalled } = this.getOptions();
let source = `${content}
// EXPORTS added by @nx/angular-rspack
export { renderApplication, renderModule, ɵSERVER_CONTEXT } from '@angular/platform-server';
`;
if (angularSSRInstalled) {
source += `
export { ɵgetRoutesFromAngularRouterConfig } from '@angular/ssr';
`;
}
if (isZoneJsInstalled) {
source = `import 'zone.js/node';
${source}`;
}
this.callback(null, source, map);
return;
}
@@ -0,0 +1,196 @@
import { VERSION } from '@angular/core';
import {
Compiler,
CopyRspackPlugin,
DefinePlugin,
RspackPluginInstance,
} from '@rspack/core';
import { posix, relative, resolve } from 'node:path';
import { getEntryPoints } from '../config/config-utils/entry-points';
import type {
I18nOptions,
NormalizedAngularRspackPluginOptions,
} from '../models';
import { loadEsmModule } from '../utils/misc-helpers';
import { isServeMode } from '../utils/rspack-serve-env';
import { AngularRspackPlugin } from './angular-rspack-plugin';
import { AngularSsrDevServer } from './angular-ssr-dev-server';
import { I18nInlinePlugin } from './i18n-inline-plugin';
import { IndexHtmlPlugin } from './index-html-plugin';
import { ProgressPlugin } from './progress-plugin';
import { RxjsEsmResolutionPlugin } from './rxjs-esm-resolution';
export class NgRspackPlugin implements RspackPluginInstance {
readonly pluginOptions: NormalizedAngularRspackPluginOptions;
readonly isPlatformServer: boolean;
readonly i18n: I18nOptions;
constructor(
pluginOptions: NormalizedAngularRspackPluginOptions,
extraOptions: {
platform: 'browser' | 'server';
i18nOptions: I18nOptions;
}
) {
this.pluginOptions = pluginOptions;
this.i18n = extraOptions.i18nOptions;
this.isPlatformServer = extraOptions.platform === 'server';
}
apply(compiler: Compiler) {
const root = this.pluginOptions.root;
const isDevServer = isServeMode();
if (this.isPlatformServer && isDevServer) {
if (
this.pluginOptions.ssr &&
typeof this.pluginOptions.ssr === 'object' &&
this.pluginOptions.ssr.entry !== undefined
) {
new AngularSsrDevServer(this.pluginOptions).apply(compiler);
}
}
if (!isDevServer && this.pluginOptions.progress) {
new ProgressPlugin(this.isPlatformServer ? 'server' : 'browser').apply(
compiler
);
}
new DefinePlugin({
...(this.pluginOptions.optimization.scripts
? { ngDevMode: 'false' }
: {}),
ngJitMode: this.pluginOptions.aot
? +VERSION.major >= 21
? 'false'
: undefined
: 'true',
ngServerMode: this.isPlatformServer,
ngHmrMode:
this.pluginOptions.devServer?.hmr && isDevServer ? 'true' : 'false',
...(this.pluginOptions.define ?? {}),
}).apply(compiler);
if (this.pluginOptions.assets && !this.isPlatformServer) {
new CopyRspackPlugin({
patterns: (this.pluginOptions.assets ?? []).map((asset) => {
let { input, output = '' } = asset;
input = resolve(root, input).replace(/\\/g, '/');
input = input.endsWith('/') ? input : input + '/';
output = output.endsWith('/') ? output : output + '/';
if (output.startsWith('..')) {
throw new Error(
'An asset cannot be written to a location outside of the output path.'
);
}
return {
context: input,
to: output.replace(/^\//, ''),
from: asset.glob,
noErrorOnMissing: true,
force: true,
globOptions: {
dot: true,
ignore: [
'**/.gitkeep',
'**/.DS_Store',
'**/Thumbs.db',
...(asset.ignore ?? []),
],
},
};
}),
}).apply(compiler);
}
if (this.pluginOptions.extractLicenses) {
const { LicenseWebpackPlugin } = require('license-webpack-plugin');
new LicenseWebpackPlugin({
stats: {
warnings: false,
errors: false,
},
perChunkOutput: false,
outputFilename: posix.join(
relative(
this.pluginOptions.outputPath.browser,
this.pluginOptions.outputPath.base
),
'3rdpartylicenses.txt'
),
skipChildCompilers: true,
}).apply(compiler as any);
}
if (this.i18n.shouldInline) {
new I18nInlinePlugin(this.pluginOptions, this.i18n).apply(compiler);
}
new RxjsEsmResolutionPlugin().apply(compiler);
new AngularRspackPlugin(this.pluginOptions, this.i18n).apply(compiler);
if (!this.isPlatformServer && this.pluginOptions.index) {
new IndexHtmlPlugin({
indexPath: this.pluginOptions.index.input,
index: this.pluginOptions.index,
baseHref: this.pluginOptions.baseHref,
outputPath: this.pluginOptions.outputPath.browser,
entrypoints: getEntryPoints(
this.pluginOptions.globalStyles,
this.pluginOptions.globalScripts,
this.pluginOptions.devServer?.hmr
),
i18n: this.i18n,
optimization: this.pluginOptions.optimization,
postTransform: this.pluginOptions.index.transformer,
isSsr: !!(
this.pluginOptions.ssr ||
this.pluginOptions.prerender ||
this.pluginOptions.appShell
),
deployUrl: this.pluginOptions.deployUrl,
crossOrigin: this.pluginOptions.crossOrigin,
sri: this.pluginOptions.subresourceIntegrity,
// The augment plugin captures this map at construction; IndexHtmlPlugin
// fills it during the build so SRI builds emit a lazy-chunk integrity
// importmap (on the Angular versions that support it).
chunksIntegrity: this.pluginOptions.subresourceIntegrity
? new Map()
: undefined,
}).apply(compiler);
}
if (
this.pluginOptions.devServer.open &&
isDevServer &&
!this.isPlatformServer
) {
compiler.hooks.afterEmit.tapAsync(
'AngularRspackPlugin',
async (_, callback) => {
const protocol = this.pluginOptions.devServer.ssl ? 'https' : 'http';
const hostname =
this.pluginOptions.devServer.host === '0.0.0.0'
? 'localhost'
: this.pluginOptions.devServer.host;
const port = this.pluginOptions.devServer.port;
const devServerOpts = compiler.options.devServer;
const publicPath =
typeof devServerOpts === 'object'
? devServerOpts.devMiddleware?.publicPath
: undefined;
const pathname =
typeof publicPath === 'string' ? publicPath : undefined;
const serverAddress = new URL(`${protocol}://${hostname}:${port}`);
if (pathname) {
// Ensure pathname starts with a slash
serverAddress.pathname = pathname.startsWith('/')
? pathname
: `/${pathname}`;
}
const open = (await loadEsmModule<typeof import('open')>('open'))
.default;
await open(serverAddress.toString());
callback();
}
);
}
}
}
@@ -0,0 +1,303 @@
import { Compilation, type Compiler, RspackPluginInstance } from '@rspack/core';
import { augmentAppWithServiceWorker } from '@angular/build/private';
import { workspaceRoot } from '@nx/devkit';
import { existsSync } from 'fs';
import { readFile } from 'fs/promises';
import { join } from 'path';
import assert from 'assert';
import {
type I18nOptions,
IndexExpandedDefinition,
NormalizedAngularRspackPluginOptions,
} from '../models';
import { getIndexOutputFile } from '../utils/index-file/get-index-output-file';
import { WorkerPool } from './tools/worker-pool';
import { maxWorkers } from '../utils/max-workers';
import { ensureOutputPaths, getLocaleOutputPaths } from '../utils/i18n';
import { RenderOptions, RenderResult } from './tools/render-worker';
import { addError, addWarning } from '../utils/rspack-diagnostics';
import { assertIsError } from '../utils/misc-helpers';
class RoutesSet extends Set<string> {
override add(value: string): this {
return super.add(value.charAt(0) === '/' ? value.slice(1) : value);
}
}
export class PrerenderPlugin implements RspackPluginInstance {
#_options: NormalizedAngularRspackPluginOptions;
#i18n: I18nOptions | undefined;
constructor(
options: NormalizedAngularRspackPluginOptions,
i18nOptions?: I18nOptions
) {
this.#_options = options;
this.#i18n = i18nOptions;
}
apply(compiler: Compiler) {
compiler.hooks.afterEmit.tapAsync(
'Angular Rspack',
async (compilation, callback) => {
if (this.#_options.appShell) {
await this.#prerenderAppShell(compilation);
}
if (this.#_options.prerender) {
await this.#prerenderSSGUniversal(compilation);
}
callback();
}
);
}
async #prerenderAppShell(compilation: Compilation) {
// Users can specify a different base html file e.g. "src/home.html"
const indexFile = getIndexOutputFile(
this.#_options.index as IndexExpandedDefinition
);
const worker = new WorkerPool({
filename: require.resolve('./tools/render-worker'),
maxThreads: maxWorkers(),
workerData: {
zonePackage: this.#resolveZonePackage(workspaceRoot),
},
recordTiming: false,
});
try {
const outputPaths = this.#i18n
? ensureOutputPaths(this.#_options.outputPath.browser, this.#i18n)
: new Map([['', this.#_options.outputPath.browser]]);
const localeOutputPaths = this.#i18n
? getLocaleOutputPaths(this.#i18n)
: new Map();
for (const [locale, outputPath] of outputPaths.entries()) {
const normalizedOutputPath = join(
this.#_options.outputPath.browser,
outputPath
);
const serverBundlePath = locale
? join(
this.#_options.outputPath.server,
localeOutputPaths.get(locale),
'server.js'
)
: join(this.#_options.outputPath.server, 'server.js');
if (!existsSync(serverBundlePath)) {
throw new Error(
`Could not find the main bundle: ${serverBundlePath}`
);
}
try {
const options: RenderOptions = {
indexFile,
deployUrl: this.#_options.deployUrl || '',
inlineCriticalCss:
!!this.#_options.optimization.styles.inlineCritical,
minifyCss: !!this.#_options.optimization.styles.minify,
outputPath: normalizedOutputPath,
route: '/',
serverBundlePath,
};
const { errors, warnings } = await worker.run(options);
errors?.forEach((e) => addError(compilation, e));
warnings?.forEach((e) => addWarning(compilation, e));
} catch (error) {
assertIsError(error);
addError(compilation, error.message);
}
if (this.#_options.serviceWorker && this.#_options.ngswConfigPath) {
try {
await augmentAppWithServiceWorker(
this.#_options.root,
workspaceRoot,
outputPath,
this.#_options.baseHref || '/',
this.#_options.ngswConfigPath
);
} catch (error) {
assertIsError(error);
addError(compilation, error.message);
}
}
}
} catch (error) {
assertIsError(error);
addError(compilation, error.message);
} finally {
void worker.destroy();
}
}
async #prerenderSSGUniversal(compilation: Compilation) {
// Users can specify a different base html file e.g. "src/home.html"
const indexFile = getIndexOutputFile(
this.#_options.index as IndexExpandedDefinition
);
const worker = new WorkerPool({
filename: require.resolve('./tools/render-worker'),
maxThreads: maxWorkers(),
workerData: {
zonePackage: this.#resolveZonePackage(workspaceRoot),
},
recordTiming: false,
});
let routes: string[] | undefined;
try {
const outputPaths = this.#i18n
? ensureOutputPaths(this.#_options.outputPath.browser, this.#i18n)
: new Map([['', this.#_options.outputPath.browser]]);
const localeOutputPaths = this.#i18n
? getLocaleOutputPaths(this.#i18n)
: new Map();
for (const [locale, outputPath] of outputPaths.entries()) {
const normalizedOutputPath = join(
this.#_options.outputPath.browser,
outputPath
);
const serverBundlePath = locale
? join(
this.#_options.outputPath.server,
localeOutputPaths.get(locale),
'server.js'
)
: join(this.#_options.outputPath.server, 'server.js');
if (!existsSync(serverBundlePath)) {
throw new Error(
`Could not find the main bundle: ${serverBundlePath}`
);
}
routes ??= await this.#getRoutes(
indexFile,
normalizedOutputPath,
serverBundlePath,
workspaceRoot
);
try {
const results = (await Promise.all(
routes.map((route) => {
const options: RenderOptions = {
indexFile,
deployUrl: this.#_options.deployUrl || '',
inlineCriticalCss:
!!this.#_options.optimization.styles.inlineCritical,
minifyCss: !!this.#_options.optimization.styles.minify,
outputPath: normalizedOutputPath,
route,
serverBundlePath,
};
return worker.run(options);
})
)) as RenderResult[];
for (const { errors, warnings } of results) {
errors?.forEach((e) => addError(compilation, e));
warnings?.forEach((e) => addWarning(compilation, e));
}
} catch (error) {
assertIsError(error);
addError(compilation, error.message);
}
if (this.#_options.serviceWorker && this.#_options.ngswConfigPath) {
try {
await augmentAppWithServiceWorker(
this.#_options.root,
workspaceRoot,
outputPath,
this.#_options.baseHref || '/',
this.#_options.ngswConfigPath
);
} catch (error) {
assertIsError(error);
addError(compilation, error.message);
}
}
}
} finally {
void worker.destroy();
}
}
async #getRoutes(
indexFile: string,
outputPath: string,
serverBundlePath: string,
workspaceRoot: string
): Promise<string[]> {
const {
routes: extraRoutes = [],
routesFile,
discoverRoutes,
} = this.#normalizePrerenderOptions();
const routes = new RoutesSet(extraRoutes);
if (routesFile) {
const routesFromFile = (
await readFile(join(workspaceRoot, routesFile), 'utf8')
).split(/\r?\n/);
for (const route of routesFromFile) {
routes.add(route);
}
}
if (discoverRoutes) {
const renderWorker = new WorkerPool({
filename: require.resolve('./tools/routes-extractor-worker'),
maxThreads: maxWorkers(),
workerData: {
indexFile,
outputPath,
serverBundlePath,
zonePackage: this.#resolveZonePackage(workspaceRoot),
},
recordTiming: false,
});
const extractedRoutes: string[] = await renderWorker
.run({})
.finally(() => void renderWorker.destroy());
for (const route of extractedRoutes) {
routes.add(route);
}
}
if (routes.size === 0) {
throw new Error('Could not find any routes to prerender.');
}
return [...routes];
}
#normalizePrerenderOptions() {
assert(this.#_options.prerender, 'Prerendering is not enabled.');
if (typeof this.#_options.prerender === 'boolean') {
return {
routes: [],
routesFile: undefined,
discoverRoutes: true,
};
}
return this.#_options.prerender;
}
#resolveZonePackage(workspaceRoot: string): string | false {
if (this.#_options.zoneless) return false;
return require.resolve('zone.js', { paths: [workspaceRoot] });
}
}
@@ -0,0 +1,49 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import type { Compiler, RspackPluginInstance } from '@rspack/core';
import { Spinner } from '../utils/spinner';
// Driven via compiler hooks instead of extending @rspack/core's ProgressPlugin:
// the latter's wrapper does `progress(p, m, ...items)` and crashes when the
// native binding passes undefined for items (fixed in rspack 2.x, not 1.x).
export class ProgressPlugin implements RspackPluginInstance {
constructor(private readonly platform: 'server' | 'browser') {}
apply(compiler: Compiler): void {
const platformCapitalFirst = this.platform.replace(/^\w/, (s) =>
s.toUpperCase()
);
const spinner = new Spinner();
spinner.start(
`Generating ${this.platform} application bundles (phase: setup)...`
);
compiler.hooks.compile.tap('AngularRspackProgressPlugin', () => {
if (!spinner.isSpinning) {
spinner.start();
}
spinner.text = `Generating ${this.platform} application bundles...`;
});
compiler.hooks.invalid.tap('AngularRspackProgressPlugin', () => {
if (!spinner.isSpinning) {
spinner.start();
}
spinner.text = `Regenerating ${this.platform} application bundles...`;
});
compiler.hooks.done.tap('AngularRspackProgressPlugin', () => {
if (spinner.isSpinning) {
spinner.succeed(
`${platformCapitalFirst} application bundle generation complete.`
);
}
});
}
}
@@ -0,0 +1,19 @@
import { Compiler, RspackPluginInstance } from '@rspack/core';
export class RxjsEsmResolutionPlugin implements RspackPluginInstance {
apply(compiler: Compiler) {
compiler.hooks.normalModuleFactory.tap(
'RxJSEsmResolution',
(normalModuleFactory) => {
normalModuleFactory.hooks.resolve.tap('RxJSEsmResolution', (data) => {
if (data.request.startsWith('rxjs')) {
data.request = data.request.replace(
/([\\/]dist[\\/])cjs([\\/])/,
'$1esm$2'
);
}
});
}
);
}
}
@@ -0,0 +1,17 @@
import { WebSocket, Server } from 'ws';
export class SsrReloadServer {
#server: Server;
constructor() {
this.#server = new WebSocket.Server({ port: 60_000 });
process.on('SIGTERM', () => this.#server.close());
process.on('exit', () => this.#server.close());
}
sendReload() {
this.#server.clients.forEach((client) => {
client.send('ssr-reload');
});
}
}
@@ -0,0 +1,43 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import { createWriteStream } from 'node:fs';
import { mkdir } from 'node:fs/promises';
import { dirname } from 'node:path';
import { pipeline } from 'node:stream/promises';
import type { RspackPluginInstance, Compiler } from '@rspack/core';
import { assertIsError } from '../utils/misc-helpers';
import { addError } from '../utils/rspack-diagnostics';
export class StatsJsonPlugin implements RspackPluginInstance {
constructor(private readonly statsOutputPath: string) {}
apply(compiler: Compiler) {
compiler.hooks.done.tapPromise(
'AngularRspackStatsJsonPlugin',
async (stats) => {
const { stringifyChunked } = await import('@discoveryjs/json-ext');
const data = stats.toJson('verbose');
try {
await mkdir(dirname(this.statsOutputPath), { recursive: true });
await pipeline(
stringifyChunked(data),
createWriteStream(this.statsOutputPath)
);
} catch (error) {
assertIsError(error);
addError(
stats.compilation,
`Unable to write stats file: ${error.message || 'unknown error'}`
);
}
}
);
}
}
@@ -0,0 +1,49 @@
import { Compiler, NormalModule, RspackPluginInstance } from '@rspack/core';
export class SuppressJsForCssOnlyEntryPlugin implements RspackPluginInstance {
apply(compiler: Compiler) {
compiler.hooks.thisCompilation.tap(
'SuppressJsForCssOnlyEntryPlugin',
(compilation) => {
compilation.hooks.processAssets.tap(
{
name: 'SuppressJsForCssOnlyEntryPlugin',
stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_SUMMARIZE,
},
(_) => {
for (const chunk of compilation.chunks) {
const cssFiles = [...chunk.files].filter((f) =>
f.endsWith('.css')
);
const jsFiles = [...chunk.files].filter((f) => f.endsWith('.js'));
// Only act if chunk has CSS and a single JS file
if (cssFiles.length > 0 && jsFiles.length === 1) {
const jsAssetName = jsFiles[0];
const asset = compilation.getAsset(jsAssetName);
if (!asset) continue;
const entryModules =
compilation.chunkGraph.getChunkEntryModulesIterable(chunk);
const cssOnly = Array.from(entryModules).every((module) => {
const extensions = ['.css', '.scss', '.sass', '.less'];
return extensions.some((ext) =>
(
module as NormalModule
).resourceResolveData?.path?.endsWith(ext)
);
});
if (cssOnly) {
compilation.deleteAsset(jsAssetName);
}
}
}
}
);
}
);
}
}
@@ -0,0 +1,76 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import { Compilation, Compiler, RspackPluginInstance } from '@rspack/core';
// Following the naming conventions from
// https://sourcemaps.info/spec.html#h.ghqpj1ytqjbm
const IGNORE_LIST = 'x_google_ignoreList';
const PLUGIN_NAME = 'devtools-ignore-plugin';
interface SourceMap {
sources: string[];
[IGNORE_LIST]: number[];
}
/**
* This plugin adds a field to source maps that identifies which sources are
* vendored or runtime-injected (aka third-party) sources. These are consumed by
* Chrome DevTools to automatically ignore-list sources.
*/
export class DevToolsIgnorePlugin implements RspackPluginInstance {
apply(compiler: Compiler) {
const { RawSource } = compiler.rspack.sources;
compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation) => {
compilation.hooks.processAssets.tap(
{
name: PLUGIN_NAME,
stage: Compilation.PROCESS_ASSETS_STAGE_DEV_TOOLING,
},
(assets) => {
for (const [name, asset] of Object.entries(assets)) {
// Instead of using `asset.map()` to fetch the source maps from
// SourceMapSource assets, process them directly as a RawSource.
// This is because `.map()` is slow and can take several seconds.
if (!name.endsWith('.map')) {
// Ignore non source map files.
continue;
}
const mapContent = asset.source().toString();
if (!mapContent) {
continue;
}
const map = JSON.parse(mapContent) as SourceMap;
const ignoreList: number[] = [];
for (const [index, path] of map.sources.entries()) {
if (
path.includes('/node_modules/') ||
path.startsWith('webpack/') ||
path.startsWith('rspack/')
) {
ignoreList.push(index);
}
}
map[IGNORE_LIST] = ignoreList;
compilation.updateAsset(
name,
new RawSource(JSON.stringify(map)),
(assetInfo) => assetInfo
);
}
}
);
});
}
}
@@ -0,0 +1,199 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import type { ApplicationRef, StaticProvider, Type } from '@angular/core';
import type {
renderApplication,
renderModule,
ɵSERVER_CONTEXT,
} from '@angular/platform-server';
import assert from 'node:assert';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { workerData } from 'node:worker_threads';
export interface RenderOptions {
indexFile: string;
deployUrl: string;
inlineCriticalCss: boolean;
minifyCss: boolean;
outputPath: string;
serverBundlePath: string;
route: string;
}
export interface RenderResult {
errors?: string[];
warnings?: string[];
}
interface ServerBundleExports {
/** An internal token that allows providing extra information about the server context. */
ɵSERVER_CONTEXT?: typeof ɵSERVER_CONTEXT;
/** Render an NgModule application. */
renderModule?: typeof renderModule;
/** NgModule to render. */
AppServerModule?: Type<unknown>;
/** Method to render a standalone application. */
renderApplication?: typeof renderApplication;
/** Standalone application bootstrapping function. */
default?: (() => Promise<ApplicationRef>) | Type<unknown>;
}
/**
* The fully resolved path to the zone.js package that will be loaded during worker initialization.
* This is passed as workerData when setting up the worker via the `piscina` package.
*/
const { zonePackage } = workerData as {
zonePackage: string | false;
};
/**
* Renders each route in routes and writes them to <outputPath>/<route>/index.html.
*/
async function render({
indexFile,
deployUrl,
minifyCss,
outputPath,
serverBundlePath,
route,
inlineCriticalCss,
}: RenderOptions): Promise<RenderResult> {
const result = {} as RenderResult;
const browserIndexOutputPath = path.join(outputPath, indexFile);
const outputFolderPath = path.join(outputPath, route);
const outputIndexPath = path.join(outputFolderPath, 'index.html');
// rspack emits a CommonJS server bundle; load with `require` so its named
// exports resolve (a nodenext `import()` would leave them undefined).
const {
ɵSERVER_CONTEXT,
AppServerModule,
renderModule,
renderApplication,
default: bootstrapAppFn,
} = require(serverBundlePath) as ServerBundleExports;
assert(
ɵSERVER_CONTEXT,
`ɵSERVER_CONTEXT was not exported from: ${serverBundlePath}.`
);
const indexBaseName = fs.existsSync(
path.join(outputPath, 'index.original.html')
)
? 'index.original.html'
: indexFile;
const browserIndexInputPath = path.join(outputPath, indexBaseName);
const document = await fs.promises.readFile(browserIndexInputPath, 'utf8');
const platformProviders: StaticProvider[] = [
{
provide: ɵSERVER_CONTEXT,
useValue: 'ssg',
},
];
let html: string;
// Render platform server module
if (isBootstrapFn(bootstrapAppFn)) {
assert(
renderApplication,
`renderApplication was not exported from: ${serverBundlePath}.`
);
html = await renderApplication(bootstrapAppFn, {
document,
url: route,
platformProviders,
});
} else {
assert(
renderModule,
`renderModule was not exported from: ${serverBundlePath}.`
);
const moduleClass = bootstrapAppFn || AppServerModule;
assert(
moduleClass,
`Neither an AppServerModule nor a bootstrapping function was exported from: ${serverBundlePath}.`
);
html = await renderModule(moduleClass, {
document,
url: route,
extraProviders: platformProviders,
});
}
if (inlineCriticalCss) {
const { InlineCriticalCssProcessor } = await import(
'@angular/build/private'
);
const inlineCriticalCssProcessor = new InlineCriticalCssProcessor({
deployUrl: deployUrl,
minify: minifyCss,
});
const { content, warnings, errors } =
await inlineCriticalCssProcessor.process(html, {
outputPath,
});
result.errors = errors;
result.warnings = warnings;
html = content;
}
// This case happens when we are prerendering "/".
if (browserIndexOutputPath === outputIndexPath) {
const browserIndexOutputPathOriginal = path.join(
outputPath,
'index.original.html'
);
fs.renameSync(browserIndexOutputPath, browserIndexOutputPathOriginal);
}
fs.mkdirSync(outputFolderPath, { recursive: true });
fs.writeFileSync(outputIndexPath, html);
return result;
}
function isBootstrapFn(value: unknown): value is () => Promise<ApplicationRef> {
// We can differentiate between a module and a bootstrap function by reading compiler-generated `ɵmod` static property:
return typeof value === 'function' && !('ɵmod' in value);
}
/**
* Initializes the worker when it is first created by loading the Zone.js package
* into the worker instance.
*
* @returns A promise resolving to the render function of the worker.
*/
async function initialize() {
// Setup Zone.js
if (zonePackage) {
await import(zonePackage);
}
// Return the render function for use
return render;
}
/**
* The default export will be the promise returned by the initialize function.
* This is awaited by piscina prior to using the Worker.
*/
export default initialize();
@@ -0,0 +1,90 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import type { ApplicationRef, Type } from '@angular/core';
import type { ɵgetRoutesFromAngularRouterConfig } from '@angular/ssr';
import assert from 'node:assert';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { workerData } from 'node:worker_threads';
export interface RoutesExtractorWorkerData {
zonePackage: string | false;
indexFile: string;
outputPath: string;
serverBundlePath: string;
}
interface ServerBundleExports {
/** NgModule to render. */
AppServerModule?: Type<unknown>;
/** Standalone application bootstrapping function. */
default?: (() => Promise<ApplicationRef>) | Type<unknown>;
/** Method to extract routes from the router config. */
ɵgetRoutesFromAngularRouterConfig: typeof ɵgetRoutesFromAngularRouterConfig;
}
const { zonePackage, serverBundlePath, outputPath, indexFile } =
workerData as RoutesExtractorWorkerData;
async function extract(): Promise<string[]> {
// rspack emits a CommonJS server bundle; load with `require` so its named
// exports resolve (a nodenext `import()` would leave them undefined).
const {
AppServerModule,
ɵgetRoutesFromAngularRouterConfig: getRoutesFromAngularRouterConfig,
default: bootstrapAppFn,
} = require(serverBundlePath) as ServerBundleExports;
const browserIndexInputPath = path.join(outputPath, indexFile);
const document = await fs.promises.readFile(browserIndexInputPath, 'utf8');
const bootstrapAppFnOrModule = bootstrapAppFn || AppServerModule;
assert(
bootstrapAppFnOrModule,
`The file "${serverBundlePath}" does not have a default export for an AppServerModule or a bootstrapping function.`
);
const routes: string[] = [];
const { routes: extractRoutes } = await getRoutesFromAngularRouterConfig(
bootstrapAppFnOrModule,
document,
new URL('http://localhost')
);
for (const { route, redirectTo } of extractRoutes) {
if (redirectTo === undefined && !/[:*]/.test(route)) {
routes.push(route);
}
}
return routes;
}
/**
* Initializes the worker when it is first created by loading the Zone.js package
* into the worker instance.
*
* @returns A promise resolving to the extract function of the worker.
*/
async function initialize() {
// Setup Zone.js
if (zonePackage) {
await import(zonePackage);
}
return extract;
}
/**
* The default export will be the promise returned by the initialize function.
* This is awaited by piscina prior to using the Worker.
*/
export default initialize();
@@ -0,0 +1,24 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import { Piscina } from 'piscina';
export type WorkerPoolOptions = ConstructorParameters<typeof Piscina>[0];
export class WorkerPool extends Piscina {
constructor(options: WorkerPoolOptions) {
const piscinaOptions: WorkerPoolOptions = {
minThreads: 1,
idleTimeout: 1000,
recordTiming: false,
...options,
};
super(piscinaOptions);
}
}
@@ -0,0 +1,65 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import { promisify } from 'node:util';
import { brotliCompress } from 'node:zlib';
import { Compiler, RspackPluginInstance } from '@rspack/core';
import { addWarning } from '../utils/rspack-diagnostics';
const brotliCompressAsync = promisify(brotliCompress);
const PLUGIN_NAME = 'angular-transfer-size-estimator';
export class TransferSizePlugin implements RspackPluginInstance {
apply(compiler: Compiler) {
compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => {
compilation.hooks.processAssets.tapAsync(
{
name: PLUGIN_NAME,
stage: compiler.rspack.Compilation.PROCESS_ASSETS_STAGE_ANALYSE,
},
async (compilationAssets, callback) => {
const actions: unknown[] = [];
for (const assetName of Object.keys(compilationAssets)) {
if (!assetName.endsWith('.js') && !assetName.endsWith('.css')) {
continue;
}
const scriptAsset = compilation.getAsset(assetName);
if (!scriptAsset || scriptAsset.source.size() <= 0) {
continue;
}
actions.push(
brotliCompressAsync(scriptAsset.source.source())
.then((result) => {
compilation.updateAsset(
assetName,
(s) => s,
(assetInfo) => {
assetInfo.estimatedTransferSize = result.length;
return assetInfo;
}
);
})
.catch((error) => {
addWarning(
compilation,
`Unable to calculate estimated transfer size for '${assetName}'. Reason: ${error.message}`
);
})
);
}
await Promise.all(actions);
callback();
}
);
});
}
}
@@ -0,0 +1,41 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import type { Compiler, RspackPluginInstance } from '@rspack/core';
const PLUGIN_NAME = 'AngularRspackWatchFilesLogsPlugin';
export class WatchFilesLogsPlugin implements RspackPluginInstance {
apply(compiler: Compiler) {
let currentModifiedFiles: ReadonlySet<string> | undefined;
let currentRemovedFiles: ReadonlySet<string> | undefined;
// Register compilation hook once - logs modified/removed files
compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation) => {
const logger = compilation.getLogger(PLUGIN_NAME);
if (currentModifiedFiles?.size) {
logger.log(
`Modified files:\n${[...currentModifiedFiles].join('\n')}\n`
);
}
if (currentRemovedFiles?.size) {
logger.log(`Removed files:\n${[...currentRemovedFiles].join('\n')}\n`);
}
});
// Update shared state on each watch cycle
compiler.hooks.watchRun.tap(
PLUGIN_NAME,
({ modifiedFiles, removedFiles }) => {
currentModifiedFiles = modifiedFiles;
currentRemovedFiles = removedFiles;
}
);
}
}
@@ -0,0 +1,9 @@
import { assertSupportedInstalledPackageVersion } from '@nx/devkit/internal';
import { minSupportedRspackCoreVersion } from './versions';
export function assertSupportedRspackCoreVersion(): void {
assertSupportedInstalledPackageVersion(
'@rspack/core',
minSupportedRspackCoreVersion
);
}
@@ -0,0 +1,58 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import { StatsChunk, StatsCompilation } from '@rspack/core';
import { NormalizedEntryPoint } from '../models/angular-rspack-plugin-options';
/**
* Webpack stats may incorrectly mark extra entry points `initial` chunks, when
* they are actually loaded asynchronously and thus not in the main bundle. This
* function finds extra entry points in Webpack stats and corrects this value
* whereever necessary. Does not modify {@param webpackStats}.
*/
export function markAsyncChunksNonInitial(
webpackStats: StatsCompilation,
extraEntryPoints: NormalizedEntryPoint[]
): StatsChunk[] {
const { chunks = [], entrypoints: entryPoints = {} } = webpackStats;
// Find all Webpack chunk IDs not injected into the main bundle. We don't have
// to worry about transitive dependencies because extra entry points cannot be
// depended upon in Webpack, thus any extra entry point with `inject: false`,
// **cannot** be loaded in main bundle.
const asyncChunkIds = extraEntryPoints
.filter(
(entryPoint) => !entryPoint.inject && entryPoints[entryPoint.bundleName]
)
.flatMap((entryPoint) =>
entryPoints[entryPoint.bundleName].chunks?.filter((n) => n !== 'runtime')
);
// Find chunks for each ID.
const asyncChunks = asyncChunkIds.map((chunkId) => {
const chunk = chunks.find((chunk) => chunk.id === chunkId);
if (!chunk) {
throw new Error(
`Failed to find chunk (${chunkId}) in set:\n${JSON.stringify(chunks)}`
);
}
return chunk;
});
// A chunk is considered `initial` only if Webpack already belives it to be initial
// and the application developer did not mark it async via an extra entry point.
return chunks.map((chunk) => {
return asyncChunks.find((asyncChunk) => asyncChunk === chunk)
? {
...chunk,
initial: false,
}
: chunk;
});
}
@@ -0,0 +1,43 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import * as ansiColors from 'ansi-colors';
import { WriteStream } from 'node:tty';
function supportColor(): boolean {
if (process.env.FORCE_COLOR !== undefined) {
// 2 colors: FORCE_COLOR = 0 (Disables colors), depth 1
// 16 colors: FORCE_COLOR = 1, depth 4
// 256 colors: FORCE_COLOR = 2, depth 8
// 16,777,216 colors: FORCE_COLOR = 3, depth 16
// See: https://nodejs.org/dist/latest-v12.x/docs/api/tty.html#tty_writestream_getcolordepth_env
// and https://github.com/nodejs/node/blob/b9f36062d7b5c5039498e98d2f2c180dca2a7065/lib/internal/tty.js#L106;
switch (process.env.FORCE_COLOR) {
case '':
case 'true':
case '1':
case '2':
case '3':
return true;
default:
return false;
}
}
if (process.stdout instanceof WriteStream) {
return process.stdout.hasColors();
}
return false;
}
// Create a separate instance to prevent unintended global changes to the color configuration
const colors = ansiColors.create();
colors.enabled = supportColor();
export { colors };
@@ -0,0 +1,54 @@
import { beforeEach, describe, expect, it } from 'vitest';
import {
createProjectRootMappings,
findProjectForPath,
} from './find-project-for-path';
describe('findProjectForPath', () => {
const nodes = {
'demo-app': {
name: 'demo-app',
type: 'app' as const,
data: {
root: 'apps/demo-app',
},
},
ui: {
name: 'ui',
type: 'lib' as const,
data: {
root: 'libs/ui',
},
},
};
let projectRootMappings: Map<string, string>;
beforeEach(() => {
projectRootMappings = createProjectRootMappings(nodes as any);
});
it('should find the project given a POSIX-style path', () => {
expect(
findProjectForPath('apps/demo-app/src/main.ts', projectRootMappings)
).toEqual('demo-app');
});
it('should find the project given a Windows-style backslash path', () => {
expect(
findProjectForPath('apps\\demo-app\\src\\main.ts', projectRootMappings)
).toEqual('demo-app');
});
it('should find the project given a mixed separator path', () => {
expect(
findProjectForPath('apps\\demo-app/src\\main.ts', projectRootMappings)
).toEqual('demo-app');
});
it('should return undefined for a path not in any project', () => {
expect(
findProjectForPath('other\\path\\file.ts', projectRootMappings)
).toBeUndefined();
});
});
@@ -0,0 +1,54 @@
// TODO: Remove this once migrated to Nx
import type { ProjectGraphProjectNode } from '@nx/devkit';
import { posix } from 'node:path';
type ProjectRootMappings = Map<string, string>;
/**
* This creates a map of project roots to project names to easily look up the project of a file
* @param nodes This is the nodes from the project graph
*/
export function createProjectRootMappings(
nodes: Record<string, ProjectGraphProjectNode>
): ProjectRootMappings {
const projectRootMappings = new Map<string, string>();
for (const projectName of Object.keys(nodes)) {
const root = nodes[projectName].data.root;
projectRootMappings.set(normalizeProjectRoot(root), projectName);
}
return projectRootMappings;
}
/**
* Locates a project in projectRootMap based on a file within it
* @param filePath path that is inside of projectName. This should be relative from the workspace root
* @param projectRootMap Map<projectRoot, projectName> Use {@link createProjectRootMappings} to create this
*/
export function findProjectForPath(
filePath: string,
projectRootMap: ProjectRootMappings
): string | undefined {
/**
* Project Mappings are in UNIX-style file paths
* Windows may pass Win-style file paths
* Ensure filePath is in UNIX-style
*/
let currentPath = posix.normalize(filePath.replaceAll('\\', '/'));
for (
;
currentPath != posix.dirname(currentPath);
currentPath = posix.dirname(currentPath)
) {
const p = projectRootMap.get(currentPath);
if (p) {
return p;
}
}
return projectRootMap.get(currentPath);
}
export function normalizeProjectRoot(root: string) {
root = root === '' ? '.' : root;
return root && root.endsWith('/') ? root.substring(0, root.length - 1) : root;
}
@@ -0,0 +1,32 @@
import { I18nOptions } from '../models';
import { addTrailingSlash, joinUrlParts, stripLeadingSlash } from './url';
export function getLocaleBaseHref(
i18n: I18nOptions,
locale: string,
baseHref?: string
): string | undefined {
if (i18n.flatOutput) {
return undefined;
}
const localeData = i18n.locales[locale];
if (!localeData) {
return undefined;
}
const baseHrefSuffix = localeData.baseHref ?? localeData.subPath + '/';
let joinedBaseHref: string | undefined;
if (baseHrefSuffix !== '') {
joinedBaseHref = addTrailingSlash(
joinUrlParts(baseHref || '', baseHrefSuffix)
);
if (baseHref && baseHref[0] !== '/') {
joinedBaseHref = stripLeadingSlash(joinedBaseHref);
}
}
return joinedBaseHref;
}
@@ -0,0 +1,27 @@
import {
createProjectGraphAsync,
readCachedProjectGraph,
type ProjectGraph,
} from '@nx/devkit';
export async function retrieveOrCreateProjectGraph(): Promise<ProjectGraph | null> {
let projectGraph: ProjectGraph | null = null;
try {
projectGraph = readCachedProjectGraph();
} catch {
// ignore
}
try {
if (!projectGraph) {
projectGraph = await createProjectGraphAsync({
exitOnError: false,
resetDaemonClient: true,
});
}
} catch {
// ignore
}
return projectGraph;
}
@@ -0,0 +1,30 @@
import { existsSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import type { I18nOptions } from '../models';
export function ensureOutputPaths(
baseOutputPath: string,
i18n: I18nOptions
): Map<string, string> {
const outputPaths = getLocaleOutputPaths(i18n);
for (const [, outputPath] of outputPaths) {
const fullPath = join(baseOutputPath, outputPath);
if (!existsSync(fullPath)) {
mkdirSync(fullPath, { recursive: true });
}
}
return outputPaths;
}
export function getLocaleOutputPaths(i18n: I18nOptions): Map<string, string> {
const outputPaths: [string, string][] = i18n.shouldInline
? [...i18n.inlineLocales].map((l) => [
l,
i18n.flatOutput ? '' : i18n.locales[l].subPath,
])
: [['', '']];
return new Map(outputPaths);
}
@@ -0,0 +1,20 @@
import { htmlRewritingStream } from './html-rewriting-stream';
export async function addBodyScript(
html: string,
bodyScriptContents: string
): Promise<string> {
const { rewriter, transformedContent } = await htmlRewritingStream(html);
const bodyScript = `<script type="text/javascript">${bodyScriptContents}</script>`;
rewriter.on('startTag', (tag) => {
rewriter.emitStartTag(tag);
if (tag.tagName === 'body') {
rewriter.emitRaw(bodyScript);
}
});
return transformedContent();
}
@@ -0,0 +1,34 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import { readFile } from 'node:fs/promises';
import { htmlRewritingStream } from './html-rewriting-stream';
let jsActionContractScript: string;
export async function addEventDispatchContract(html: string): Promise<string> {
const { rewriter, transformedContent } = await htmlRewritingStream(html);
jsActionContractScript ??=
'<script type="text/javascript" id="ng-event-dispatch-contract">' +
(await readFile(
require.resolve('@angular/core/event-dispatch-contract.min.js'),
'utf-8'
)) +
'</script>';
rewriter.on('startTag', (tag) => {
rewriter.emitStartTag(tag);
if (tag.tagName === 'body') {
rewriter.emitRaw(jsActionContractScript);
}
});
return transformedContent();
}
@@ -0,0 +1,9 @@
import type { IndexExpandedDefinition } from '../../models';
export function getIndexInputFile(index: IndexExpandedDefinition): string {
if (typeof index === 'string') {
return index;
}
return index.input;
}
@@ -0,0 +1,10 @@
import type { IndexExpandedDefinition } from '../../models';
import { basename } from 'node:path';
export function getIndexOutputFile(index: IndexExpandedDefinition): string {
if (typeof index === 'string') {
return basename(index);
}
return index.output || 'index.html';
}
@@ -0,0 +1,40 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import type { RewritingStream } from 'parse5-html-rewriting-stream';
import { loadEsmModule } from '../misc-helpers';
// Export helper types for the rewriter
export type StartTag = Parameters<RewritingStream['emitStartTag']>[0];
export type EndTag = Parameters<RewritingStream['emitEndTag']>[0];
export type { RewritingStream };
export async function htmlRewritingStream(content: string): Promise<{
rewriter: RewritingStream;
transformedContent: () => Promise<string>;
}> {
const { RewritingStream } = await loadEsmModule<
typeof import('parse5-html-rewriting-stream')
>('parse5-html-rewriting-stream');
const rewriter = new RewritingStream();
return {
rewriter,
transformedContent: () =>
pipeline(Readable.from(content), rewriter, async function (source) {
const chunks: Buffer[] = [];
for await (const chunk of source) {
chunks.push(Buffer.from(chunk));
}
return Buffer.concat(chunks).toString('utf-8');
}),
};
}
@@ -0,0 +1,20 @@
import { availableParallelism } from 'node:os';
const ENV_NG_BUILD_MAX_WORKERS = 'NG_BUILD_MAX_WORKERS';
function isPresent(variable: string | undefined): variable is string {
return typeof variable === 'string' && variable.trim() !== '';
}
function parseMaxWorkers(value: string | undefined): number | null {
if (!isPresent(value)) return null;
const parsed = Number(value);
return Number.isNaN(parsed) || parsed <= 0 ? null : parsed; // Ensure valid positive number
}
export const maxWorkers = () => {
const parsedWorkers = parseMaxWorkers(process.env[ENV_NG_BUILD_MAX_WORKERS]);
return parsedWorkers !== null
? parsedWorkers
: Math.min(4, Math.max(availableParallelism() - 1, 1));
};
@@ -0,0 +1,67 @@
import assert from 'node:assert';
import { isAbsolute } from 'node:path';
import { pathToFileURL } from 'node:url';
let load: (<T>(modulePath: string | URL) => Promise<T>) | undefined;
export function loadEsmModule<T>(modulePath: string | URL): Promise<T> {
load ??= new Function('modulePath', `return import(modulePath);`) as Exclude<
typeof load,
undefined
>;
// The dynamic import lives inside `new Function(...)` to stop TypeScript from
// downleveling it to require(). A side effect is that bare specifiers are
// resolved against the location of the Function rather than this module, so
// under Node >= 26's stricter ESM resolver they fail with ERR_MODULE_NOT_FOUND
// even for declared dependencies. Resolve bare specifiers here, where this
// package's dependencies are reachable, and hand off an absolute file URL.
if (typeof modulePath === 'string' && isBareSpecifier(modulePath)) {
modulePath = pathToFileURL(require.resolve(modulePath)).href;
}
return load(modulePath);
}
function isBareSpecifier(specifier: string): boolean {
return (
!specifier.startsWith('.') &&
!isAbsolute(specifier) &&
!specifier.includes('://') &&
!specifier.startsWith('node:') &&
!specifier.startsWith('data:')
);
}
export function assertIsError(
value: unknown
): asserts value is Error & { code?: string } {
const isError =
value instanceof Error ||
// The following is needed to identify errors coming from RxJs
(typeof value === 'object' &&
value &&
'name' in value &&
'message' in value);
assert(isError, 'catch clause variable is not an Error instance');
}
export function isPackageInstalled(root: string, name: string): boolean {
try {
require.resolve(name, { paths: [root] });
return true;
} catch {
return false;
}
}
export function assertNever(input: never): never {
throw new Error(
`Unexpected call to assertNever() with input: ${JSON.stringify(
input,
null /* replacer */,
4 /* tabSize */
)}`
);
}
@@ -0,0 +1,239 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import { interpolateName } from 'loader-utils';
import * as path from 'node:path';
import { pathToFileURL } from 'node:url';
import type { Declaration, Plugin } from 'postcss';
import { assertIsError } from './misc-helpers';
function wrapUrl(url: string): string {
let wrappedUrl;
const hasSingleQuotes = url.indexOf("'") >= 0;
if (hasSingleQuotes) {
wrappedUrl = `"${url}"`;
} else {
wrappedUrl = `'${url}'`;
}
return `url(${wrappedUrl})`;
}
function resolveUrl(from: string, to: string) {
const resolvedUrl = new URL(to, new URL(from, 'resolve://'));
if (resolvedUrl.protocol === 'resolve:') {
// `from` is a relative URL.
const { pathname, search, hash } = resolvedUrl;
return pathname + search + hash;
}
return resolvedUrl.toString();
}
export interface PostcssCliResourcesOptions {
baseHref?: string;
deployUrl?: string;
resourcesOutputPath?: string;
rebaseRootRelative?: boolean;
/** CSS is extracted to a `.css` or is embedded in a `.js` file. */
extracted?: boolean;
filename: (resourcePath: string) => string;
loader: import('@rspack/core').LoaderContext<unknown>;
emitFile: boolean;
}
async function resolve(
file: string,
base: string,
resolver: (file: string, base: string) => Promise<string>
): Promise<string> {
try {
return await resolver('./' + file, base);
} catch {
return resolver(file, base);
}
}
export const postcss = true;
export default function (options?: PostcssCliResourcesOptions): Plugin {
if (!options) {
throw new Error('No options were specified to "postcss-cli-resources".');
}
const {
deployUrl = '',
resourcesOutputPath = '',
filename,
loader,
emitFile,
extracted,
} = options;
const process = async (
inputUrl: string,
context: string,
resourceCache: Map<string, string>
) => {
// If root-relative, absolute or protocol relative url, leave as is
if (/^((?:\w+:)?\/\/|data:|chrome:|#)/.test(inputUrl)) {
return inputUrl;
}
if (/^\//.test(inputUrl)) {
return inputUrl;
}
// If starts with a caret, remove and return remainder
// this supports bypassing asset processing
if (inputUrl.startsWith('^')) {
return inputUrl.slice(1);
}
const cacheKey = path.resolve(context, inputUrl);
const cachedUrl = resourceCache.get(cacheKey);
if (cachedUrl) {
return cachedUrl;
}
if (inputUrl.startsWith('~')) {
inputUrl = inputUrl.slice(1);
}
// Separate URL query/hash from the file path before resolving
const [, filePath, urlSuffix] = inputUrl.match(/^([^?#]*)(.*)$/)!;
const resolvedPath = path.resolve(context, filePath.replace(/\\/g, '/'));
const { pathname } = pathToFileURL(resolvedPath);
let hash = '';
let search = '';
if (urlSuffix) {
({ hash, search } = new URL(`file:///dummy${urlSuffix}`));
}
const resolver = (file: string, base: string) =>
new Promise<string>((resolve, reject) => {
loader.resolve(base, decodeURI(file), (err, result) => {
if (err) {
reject(err);
return;
}
resolve(result as string);
});
});
const result = await resolve(pathname as string, context, resolver);
return new Promise<string>((resolve, reject) => {
loader.fs.readFile(result, (err, content) => {
if (err) {
reject(err);
return;
}
let outputPath = interpolateName(
// TODO: Revisit. Previously due to lack of type safety, this object
// was fine, but in practice it doesn't match the type of the loader context.
{ resourcePath: result } as Parameters<typeof interpolateName>[0],
filename(result),
{
content,
context: loader.context || loader.rootContext,
}
).replace(/\\|\//g, '-');
if (resourcesOutputPath) {
outputPath = path.posix.join(resourcesOutputPath, outputPath);
}
loader.addDependency(result);
if (emitFile) {
loader.emitFile(outputPath, content!, undefined, {
sourceFilename: result,
});
}
let outputUrl = outputPath.replace(/\\/g, '/');
if (hash || search) {
outputUrl = outputUrl + (search || '') + (hash || '');
}
if (deployUrl && !extracted) {
outputUrl = resolveUrl(deployUrl, outputUrl);
}
resourceCache.set(cacheKey, outputUrl);
resolve(outputUrl);
});
});
};
const resourceCache = new Map<string, string>();
const processed = Symbol('postcss-cli-resources');
return {
postcssPlugin: 'postcss-cli-resources',
async Declaration(decl) {
if (!decl.value.includes('url') || processed in decl) {
return;
}
const value = decl.value;
const urlRegex = /url(?:\(\s*(['"]?))(.*?)(?:\1\s*\))/g;
const segments: string[] = [];
let match;
let lastIndex = 0;
let modified = false;
// We want to load it relative to the file that imports
const inputFile = decl.source && decl.source.input.file;
const context = (inputFile && path.dirname(inputFile)) || loader.context;
while ((match = urlRegex.exec(value))) {
const originalUrl = match[2];
let processedUrl;
try {
processedUrl = await process(
originalUrl,
context ?? '',
resourceCache
);
} catch (err) {
assertIsError(err);
loader.emitError(decl.error(err.message, { word: originalUrl }));
continue;
}
if (lastIndex < match.index) {
segments.push(value.slice(lastIndex, match.index));
}
if (!processedUrl || originalUrl === processedUrl) {
segments.push(match[0]);
} else {
segments.push(wrapUrl(processedUrl));
modified = true;
}
lastIndex = match.index + match[0].length;
}
if (lastIndex < value.length) {
segments.push(value.slice(lastIndex));
}
if (modified) {
decl.value = segments.join('');
}
(decl as Declaration & { [processed]: boolean })[processed] = true;
},
};
}
@@ -0,0 +1,144 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import { readFile, readdir } from 'node:fs/promises';
import { join } from 'node:path';
export interface PostcssConfiguration {
plugins: [name: string, options?: object | string][];
}
interface RawPostcssConfiguration {
plugins?:
| Record<string, object | boolean | string>
| (string | [string, object])[];
}
const postcssConfigurationFiles: string[] = [
'postcss.config.json',
'.postcssrc.json',
'postcss.config.js',
];
const tailwindConfigFiles: string[] = [
'tailwind.config.js',
'tailwind.config.cjs',
'tailwind.config.mjs',
'tailwind.config.ts',
];
export interface SearchDirectory {
root: string;
files: Set<string>;
}
export async function generateSearchDirectories(
roots: string[]
): Promise<SearchDirectory[]> {
return await Promise.all(
roots.map((root) =>
readdir(root, { withFileTypes: true }).then((entries) => ({
root,
files: new Set(
entries.filter((entry) => entry.isFile()).map((entry) => entry.name)
),
}))
)
);
}
function findFile(
searchDirectories: SearchDirectory[],
potentialFiles: string[]
): string | undefined {
for (const { root, files } of searchDirectories) {
for (const potential of potentialFiles) {
if (files.has(potential)) {
return join(root, potential);
}
}
}
return undefined;
}
export function findTailwindConfiguration(
searchDirectories: SearchDirectory[]
): string | undefined {
return findFile(searchDirectories, tailwindConfigFiles);
}
async function readPostcssConfiguration(
configurationFile: string
): Promise<RawPostcssConfiguration> {
const data = await readFile(configurationFile, 'utf-8');
const config = JSON.parse(data) as RawPostcssConfiguration;
return config;
}
export async function loadPostcssConfiguration(
searchDirectories: SearchDirectory[]
): Promise<
| {
configPath: string;
config: PostcssConfiguration;
}
| undefined
> {
const configPath = findFile(searchDirectories, postcssConfigurationFiles);
if (!configPath) {
return undefined;
}
const raw = configPath.endsWith('.js')
? require(configPath)
: await readPostcssConfiguration(configPath);
// If no plugins are defined, consider it equivalent to no configuration
if (!raw.plugins || typeof raw.plugins !== 'object') {
return undefined;
}
// Normalize plugin array form
if (Array.isArray(raw.plugins)) {
if (raw.plugins.length < 1) {
return undefined;
}
const config: PostcssConfiguration = { plugins: [] };
for (const element of raw.plugins) {
if (typeof element === 'string') {
config.plugins.push([element]);
} else {
config.plugins.push(element);
}
}
return { configPath, config };
}
// Normalize plugin object map form
const entries = Object.entries(raw.plugins);
if (entries.length < 1) {
return undefined;
}
const config: PostcssConfiguration = { plugins: [] };
for (const [name, options] of entries) {
if (
!options ||
(typeof options !== 'object' && typeof options !== 'string')
) {
continue;
}
config.plugins.push([name, options]);
}
return { configPath, config };
}
@@ -0,0 +1,36 @@
import type { Compilation } from '@rspack/core';
// `RspackError` is not publicly exported from `@rspack/core` until v1.4.0,
// but the supported peer range starts at v1.3.5. Derive the type from the
// publicly-exported `Compilation` class, which exposes it via `errors[]`.
type RspackError = Compilation['errors'][number];
export function addError(
compilation: Compilation,
messageOrError: RspackError | string
): void {
const error = new compilation.compiler.rspack.WebpackError(
typeof messageOrError === 'string' ? messageOrError : messageOrError.message
);
if (typeof messageOrError !== 'string') {
if (messageOrError.hideStack) {
(error as RspackError).hideStack = true;
}
}
compilation.errors.push(error);
}
export function addWarning(
compilation: Compilation,
messageOrError: RspackError | string
): void {
const warning = new compilation.compiler.rspack.WebpackError(
typeof messageOrError === 'string' ? messageOrError : messageOrError.message
);
if (typeof messageOrError !== 'string') {
if (messageOrError.hideStack) {
(warning as RspackError).hideStack = true;
}
}
compilation.warnings.push(warning);
}
@@ -0,0 +1,5 @@
// v1 dev-server sets WEBPACK_SERVE on module load; v2 sets RSPACK_SERVE.
// rspack-cli imports the dev-server before loading the user config.
export function isServeMode(): boolean {
return !!process.env['WEBPACK_SERVE'] || !!process.env['RSPACK_SERVE'];
}
@@ -0,0 +1,7 @@
export function isRspackV2(): boolean {
// Lazy-require avoids loading @rspack/core (pure ESM in v2) at module load
// time; these call sites run at config-build time, before a compiler exists.
const { rspackVersion } =
require('@rspack/core') as typeof import('@rspack/core');
return parseInt(rspackVersion ?? '1', 10) >= 2;
}
@@ -0,0 +1,58 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import ora from 'ora';
import { colors } from './color';
import { isTTY } from './tty';
export class Spinner {
private readonly spinner: ora.Ora;
/** When false, only fail messages will be displayed. */
enabled = true;
readonly #isTTY = isTTY();
constructor(text?: string) {
this.spinner = ora({
text: text === undefined ? undefined : text + '\n',
// The below 2 options are needed because otherwise CTRL+C will be delayed
// when the underlying process is sync.
hideCursor: false,
discardStdin: false,
isEnabled: this.#isTTY,
});
}
set text(text: string) {
this.spinner.text = text;
}
get isSpinning(): boolean {
return this.spinner.isSpinning || !this.#isTTY;
}
succeed(text?: string): void {
if (this.enabled) {
this.spinner.succeed(text);
}
}
fail(text?: string): void {
this.spinner.fail(text && colors.redBright(text));
}
stop(): void {
this.spinner.stop();
}
start(text?: string): void {
if (this.enabled) {
this.spinner.start(text);
}
}
}
@@ -0,0 +1,502 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {
BudgetCalculatorResult,
BundleStats,
generateBuildStatsTable,
} from '@angular/build/private';
import assert from 'node:assert';
import * as path from 'node:path';
import {
Configuration,
StatsCompilation,
Stats,
StatsOptions,
} from '@rspack/core';
import {
NormalizedAngularRspackPluginOptions,
NormalizedEntryPoint,
ScriptOrStyleEntry,
} from '../models/angular-rspack-plugin-options';
import { normalizeOptimization } from '../models/normalize-options';
import { colors as ansiColors } from './color';
import { markAsyncChunksNonInitial } from './async-chunks';
import { getStatsOptions } from '../config/config-utils/get-stats-options';
export interface RspackLoggingCallback {
(stats: Stats, config: Configuration): void;
}
export type RspackStatsOptions = Exclude<
Configuration['stats'],
string | boolean | undefined
>;
export function normalizeExtraEntryPoints(
extraEntryPoints: ScriptOrStyleEntry[],
defaultBundleName: string
): NormalizedEntryPoint[] {
return extraEntryPoints.map((entry) => {
if (typeof entry === 'string') {
return { input: entry, inject: true, bundleName: defaultBundleName };
}
const { inject = true, ...newEntry } = entry;
let bundleName;
if (entry.bundleName) {
bundleName = entry.bundleName;
} else if (!inject) {
// Lazy entry points use the file name as bundle name.
bundleName = path.parse(entry.input).name;
} else {
bundleName = defaultBundleName;
}
return { ...newEntry, inject, bundleName };
});
}
function getBuildDuration(rspackStats: StatsCompilation): number {
assert(rspackStats.builtAt, 'buildAt cannot be undefined');
assert(rspackStats.time, 'time cannot be undefined');
return Date.now() - rspackStats.builtAt + rspackStats.time;
}
function generateBundleStats(info: {
rawSize?: number;
estimatedTransferSize?: number;
files?: string[];
names?: string[];
initial?: boolean;
rendered?: boolean;
}): BundleStats {
const rawSize = typeof info.rawSize === 'number' ? info.rawSize : '-';
const estimatedTransferSize =
typeof info.estimatedTransferSize === 'number'
? info.estimatedTransferSize
: '-';
const files =
info.files
?.filter((f) => !f.endsWith('.map'))
.map((f) => path.basename(f))
.join(', ') ?? '';
const names = info.names?.length ? info.names.join(', ') : '-';
const initial = !!info.initial;
return {
initial,
stats: [files, names, rawSize, estimatedTransferSize],
};
}
// We use this cache because we can have multiple builders running in the same process,
// where each builder has different output path.
// Ideally, we should create the logging callback as a factory, but that would need a refactoring.
const runsCache = new Set<string>();
const chunkHashCache = new Map<string, Map<string | number, string>>();
function statsToString(
stats: Stats,
json: StatsCompilation,
statsConfig: StatsOptions,
budgetFailures?: BudgetCalculatorResult[]
): string {
if (!json.chunks?.length) {
return '';
}
const colors =
typeof statsConfig.colors === 'boolean'
? statsConfig.colors
: !!statsConfig.colors;
const rs = (x: string) => (colors ? ansiColors.reset(x) : x);
const w = (x: string) => (colors ? ansiColors.bold.white(x) : x);
const changedChunksStats: BundleStats[] = [];
let unchangedChunkNumber = 0;
let hasEstimatedTransferSizes = false;
const isFirstRun = !runsCache.has(json.outputPath || '');
const prevHashes =
chunkHashCache.get(json.outputPath || '') ??
new Map<string | number, string>();
const nextHashes = new Map<string | number, string>();
for (const chunk of json.chunks) {
const key = chunk.id ?? chunk.names?.[0] ?? '';
const hash = chunk.hash ?? '';
nextHashes.set(key, hash);
// During first build we want to display all chunks.
// On rebuilds, skip chunks whose hash hasn't changed.
if (!isFirstRun && prevHashes.get(key) === hash) {
unchangedChunkNumber++;
continue;
}
const assets = json.assets?.filter((asset) => {
// First try exact match with files array (for non-i18n builds)
if (chunk.files?.includes(asset.name)) {
return true;
}
// For i18n builds, chunk.files is often empty but we can match by chunk names
// Assets have locale prefixes (e.g., "fr/main.abc123.js")
if (!chunk.files?.length && chunk.names?.length) {
// Remove locale prefix and hash from asset name to get base name
const assetWithoutLocale = asset.name
.replace(/^[a-z]{2}(-[A-Z]{2})?\//, '') // Remove locale prefix
.replace(/\.[a-f0-9]{8,}\./, '.'); // Remove hash (e.g., main.abc123.js -> main.js)
// Check if any chunk name matches the base name of the asset
return chunk.names.some((chunkName) => {
// Match chunk name to asset base name (e.g., "main" matches "main.js")
return (
assetWithoutLocale.startsWith(chunkName + '.') ||
assetWithoutLocale === chunkName
);
});
}
// For i18n builds where chunk.files has values but without locale prefix
if (chunk.files?.length) {
// Check if any chunk file matches the asset name without locale prefix
return chunk.files.some((chunkFile) => {
// Remove potential locale prefix from asset name
const assetWithoutLocale = asset.name.replace(
/^[a-z]{2}(-[A-Z]{2})?\//,
''
);
return chunkFile === assetWithoutLocale;
});
}
return false;
});
let rawSize = 0;
let estimatedTransferSize;
if (assets) {
for (const asset of assets) {
if (asset.name.endsWith('.map')) {
continue;
}
rawSize += asset.size;
const compilationAsset = stats.compilation.getAsset(asset.name);
const assetInfo = compilationAsset ? compilationAsset.info : undefined;
if (assetInfo) {
if (typeof assetInfo.estimatedTransferSize === 'number') {
if (estimatedTransferSize === undefined) {
estimatedTransferSize = 0;
hasEstimatedTransferSizes = true;
}
estimatedTransferSize += assetInfo.estimatedTransferSize;
}
}
}
}
changedChunksStats.push(
generateBundleStats({ ...chunk, rawSize, estimatedTransferSize })
);
}
runsCache.add(json.outputPath || '');
chunkHashCache.set(json.outputPath || '', nextHashes);
const statsTable = generateBuildStatsTable(
changedChunksStats,
colors,
unchangedChunkNumber === 0,
hasEstimatedTransferSizes,
budgetFailures
);
// In some cases we do things outside of rspack context
// Such us index generation, service worker augmentation etc...
// This will correct the time and include these.
const time = getBuildDuration(json);
return rs(
`\n${statsTable}\n\n` +
(unchangedChunkNumber > 0
? `${unchangedChunkNumber} unchanged chunks\n\n`
: '') +
`Build at: ${w(new Date().toISOString())} - Hash: ${w(
json.hash || ''
)} - Time: ${w('' + time)}ms`
);
}
export function statsWarningsToString(
json: StatsCompilation,
statsConfig: RspackStatsOptions
): string {
const colors =
typeof statsConfig.colors === 'boolean'
? statsConfig.colors
: !!statsConfig.colors;
const c = (x: string) => (colors ? ansiColors.reset.cyan(x) : x);
const y = (x: string) => (colors ? ansiColors.reset.yellow(x) : x);
const yb = (x: string) => (colors ? ansiColors.reset.yellowBright(x) : x);
const warnings = json.warnings ? [...json.warnings] : [];
if (json.children) {
warnings.push(
...json.children
.map((c) => c.warnings ?? [])
.reduce((a, b) => [...a, ...b], [])
);
}
let output = '';
for (const warning of warnings) {
if (typeof warning === 'string') {
output += yb(`Warning: ${warning}\n\n`);
} else {
let file = warning.file || warning.moduleName;
// Clean up warning paths
// Ex: ./src/app/styles.scss.rspack[javascript/auto]!=!./node_modules/css-loader/dist/cjs.js....
// to ./src/app/styles.scss.rspack
if (file && !statsConfig.errorDetails) {
const rpsackPathIndex = file.indexOf('.rspack[');
if (rpsackPathIndex !== -1) {
file = file.substring(0, rpsackPathIndex);
}
const rspackLoadersIndex = file.indexOf('!=!');
if (rspackLoadersIndex !== -1) {
file = file.substring(0, rspackLoadersIndex);
}
}
if (file) {
output += c(file);
if (warning.loc) {
output += ':' + yb(warning.loc);
}
output += ' - ';
}
if (!/^error/i.test(warning.message)) {
const errorIndex = warning.message.indexOf('Error: ');
warning.message = warning.message.substring(errorIndex + 7);
}
if (!/^warning/i.test(warning.message)) {
output += y('Warning: ');
}
output += `${warning.message}\n\n`;
}
}
return output ? '\n' + output : output;
}
export function statsErrorsToString(
json: StatsCompilation,
statsConfig: RspackStatsOptions
): string {
const colors =
typeof statsConfig.colors === 'boolean'
? statsConfig.colors
: !!statsConfig.colors;
const c = (x: string) => (colors ? ansiColors.reset.cyan(x) : x);
const yb = (x: string) => (colors ? ansiColors.reset.yellowBright(x) : x);
const r = (x: string) => (colors ? ansiColors.reset.redBright(x) : x);
const errors = json.errors ? [...json.errors] : [];
if (json.children) {
errors.push(
...json.children
.map((c) => c?.errors || [])
.reduce((a, b) => [...a, ...b], [])
);
}
let output = '';
for (const error of errors) {
if (typeof error === 'string') {
output += r(`Error: ${error}\n\n`);
} else {
let file = error.file || error.moduleName;
// Clean up error paths
// Ex: ./src/app/styles.scss.rspack[javascript/auto]!=!./node_modules/css-loader/dist/cjs.js....
// to ./src/app/styles.scss.rspack
if (file && !statsConfig.errorDetails) {
const rspackPathIndex = file.indexOf('.rspack[');
if (rspackPathIndex !== -1) {
file = file.substring(0, rspackPathIndex);
}
const rspackLoadersIndex = file.indexOf('!=!');
if (rspackLoadersIndex !== -1) {
file = file.substring(0, rspackLoadersIndex);
}
}
if (file) {
output += c(file);
if (error.loc) {
output += ':' + yb(error.loc);
}
output += ' - ';
}
// In most cases rspack will add stack traces to error messages.
// This below cleans up the error from stacks.
// See: https://github.com/webpack/webpack/issues/15980
const index = error.message.search(/[\n\s]+at /);
const message =
statsConfig.errorStack || index === -1
? error.message
: error.message.substring(0, index);
if (!/^error/i.test(message)) {
output += r('Error: ');
}
output += `${message}\n\n`;
}
}
return output ? '\n' + output : output;
}
export function statsHasErrors(json: StatsCompilation): boolean {
return !!(
json.errors?.length || json.children?.some((c) => c.errors?.length)
);
}
export function statsHasWarnings(json: StatsCompilation): boolean {
return !!(
json.warnings?.length || json.children?.some((c) => c.warnings?.length)
);
}
export function createRspackLoggingCallback(
options: NormalizedAngularRspackPluginOptions
) {
const { verbose = false, scripts = [], styles = [] } = options;
const extraEntryPoints = [
...normalizeExtraEntryPoints(styles, 'styles'),
...normalizeExtraEntryPoints(scripts, 'scripts'),
];
return (stats, config) => {
if (verbose && config.stats !== false) {
const statsOptions = config.stats === true ? undefined : config.stats;
console.info(stats.toString(statsOptions));
}
const rawStats = stats.toJson(getStatsOptions(false));
const rspackStats = {
...rawStats,
chunks: markAsyncChunksNonInitial(rawStats, extraEntryPoints),
};
rspackStatsLogger(rspackStats, config);
};
}
export interface BuildEventStats {
aot: boolean;
optimization: boolean;
allChunksCount: number;
lazyChunksCount: number;
initialChunksCount: number;
changedChunksCount?: number;
durationInMs: number;
cssSizeInBytes: number;
jsSizeInBytes: number;
ngComponentCount: number;
}
export function generateBuildEventStats(
rspackStats: StatsCompilation,
options: NormalizedAngularRspackPluginOptions
): BuildEventStats {
const { chunks = [], assets = [] } = rspackStats;
let jsSizeInBytes = 0;
let cssSizeInBytes = 0;
let initialChunksCount = 0;
let ngComponentCount = 0;
let changedChunksCount = 0;
const allChunksCount = chunks.length;
const isFirstRun = !runsCache.has(rspackStats.outputPath || '');
const prevHashes =
chunkHashCache.get(rspackStats.outputPath || '') ??
new Map<string | number, string>();
const chunkFiles = new Set<string>();
for (const chunk of chunks) {
if (!isFirstRun) {
const key = chunk.id ?? chunk.names?.[0] ?? '';
const hash = chunk.hash ?? '';
if (prevHashes.get(key) !== hash) {
changedChunksCount++;
}
}
if (chunk.initial) {
initialChunksCount++;
}
for (const file of chunk.files ?? []) {
chunkFiles.add(file);
}
}
for (const asset of assets) {
if (asset.name.endsWith('.map') || !chunkFiles.has(asset.name)) {
continue;
}
if (asset.name.endsWith('.js')) {
jsSizeInBytes += asset.size;
ngComponentCount += asset.info.ngComponentCount ?? 0;
} else if (asset.name.endsWith('.css')) {
cssSizeInBytes += asset.size;
}
}
return {
optimization: normalizeOptimization(options.optimization).scripts,
aot: options.aot,
allChunksCount,
lazyChunksCount: allChunksCount - initialChunksCount,
initialChunksCount,
changedChunksCount,
durationInMs: getBuildDuration(rspackStats),
cssSizeInBytes,
jsSizeInBytes,
ngComponentCount,
};
}
export function rspackStatsLogger(
stats: Stats,
statOptions: StatsOptions,
budgetFailures?: BudgetCalculatorResult[]
): void {
const json = stats.toJson(statOptions);
console.log(statsToString(stats, json, statOptions, budgetFailures));
if (typeof stats !== 'object') {
throw new Error('Invalid Rspack stats configuration.');
}
if (statsHasWarnings(json)) {
console.warn(statsWarningsToString(json, statOptions));
}
if (statsHasErrors(json)) {
console.log('Stats has errors');
console.error(statsErrorsToString(json, statOptions));
}
}
@@ -0,0 +1,24 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
function _isTruthy(value: undefined | string): boolean {
// Returns true if value is a string that is anything but 0 or false.
return (
value !== undefined && value !== '0' && value.toUpperCase() !== 'FALSE'
);
}
export function isTTY(): boolean {
// If we force TTY, we always return true.
const force = process.env['NG_FORCE_TTY'];
if (force !== undefined) {
return _isTruthy(force);
}
return !!process.stdout.isTTY && !_isTruthy(process.env['CI']);
}
@@ -0,0 +1,104 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
/**
* Removes the leading slash from a URL if it exists.
*
* @param url - The URL string from which to remove the leading slash.
* @returns The URL string without a leading slash.
*
* @example
* ```js
* stripLeadingSlash('/path'); // 'path'
* stripLeadingSlash('/path/'); // 'path/'
* stripLeadingSlash('/'); // '/'
* stripLeadingSlash(''); // ''
* ```
*/
export function stripLeadingSlash(url: string): string {
// Check if the first character of the URL is a slash
return url.length > 1 && url[0] === '/' ? url.slice(1) : url;
}
/**
* Adds a leading slash to a URL if it does not already have one.
*
* @param url - The URL string to which the leading slash will be added.
* @returns The URL string with a leading slash.
*
* @example
* ```js
* addLeadingSlash('path'); // '/path'
* addLeadingSlash('/path'); // '/path'
* ```
*/
export function addLeadingSlash(url: string): string {
// Check if the URL already starts with a slash
return url[0] === '/' ? url : `/${url}`;
}
/**
* Adds a trailing slash to a URL if it does not already have one.
*
* @param url - The URL string to which the trailing slash will be added.
* @returns The URL string with a trailing slash.
*
* @example
* ```js
* addTrailingSlash('path'); // 'path/'
* addTrailingSlash('path/'); // 'path/'
* ```
*/
export function addTrailingSlash(url: string): string {
// Check if the URL already end with a slash
return url.at(-1) === '/' ? url : `${url}/`;
}
/**
* Joins URL parts into a single URL string.
*
* This function takes multiple URL segments, normalizes them by removing leading
* and trailing slashes where appropriate, and then joins them into a single URL.
*
* @param parts - The parts of the URL to join. Each part can be a string with or without slashes.
* @returns The joined URL string, with normalized slashes.
*
* @example
* ```js
* joinUrlParts('path/', '/to/resource'); // '/path/to/resource'
* joinUrlParts('/path/', 'to/resource'); // '/path/to/resource'
* joinUrlParts('http://localhost/path/', 'to/resource'); // 'http://localhost/path/to/resource'
* joinUrlParts('', ''); // '/'
* ```
*/
export function joinUrlParts(...parts: string[]): string {
const normalizeParts: string[] = [];
for (const part of parts) {
if (part === '') {
// Skip any empty parts
continue;
}
let normalizedPart = part;
if (part[0] === '/') {
normalizedPart = normalizedPart.slice(1);
}
if (part.at(-1) === '/') {
normalizedPart = normalizedPart.slice(0, -1);
}
if (normalizedPart !== '') {
normalizeParts.push(normalizedPart);
}
}
const protocolMatch =
normalizeParts.length && /^https?:\/\//.test(normalizeParts[0]);
const joinedParts = normalizeParts.join('/');
return protocolMatch ? joinedParts : addLeadingSlash(joinedParts);
}
@@ -0,0 +1 @@
export const minSupportedRspackCoreVersion = '1.3.5';
@@ -0,0 +1,16 @@
// Mock for `@nx/devkit/internal`. Mirrors the `@nx/devkit` mock pattern: only
// the symbols the plugin code imports are provided, and behavior is the
// minimum each test path needs.
//
// `assertSupportedInstalledPackageVersion` is a no-op here because no current
// test exercises the runtime floor guard. In production, the helper would
// read the installed version via `getInstalledPackageVersion` and throw when
// below floor; making the mock a no-op lets tests transitively load the
// guard without depending on a built devkit/nx dist.
export function assertSupportedInstalledPackageVersion(
_packageName: string,
_minSupportedVersion: string
): void {
/* no-op */
}
@@ -0,0 +1,47 @@
import * as path from 'path';
import { PathLike, statSync } from 'node:fs';
export function fileExists(path: PathLike): boolean {
try {
return statSync(path).isFile();
} catch {
return false;
}
}
/**
* The root of the workspace
*/
export let workspaceRoot = workspaceRootInner(process.cwd(), process.cwd());
// Required for integration tests in projects which depend on Nx at runtime, such as lerna and angular-eslint
export function setWorkspaceRoot(root: string): void {
workspaceRoot = root;
}
export function workspaceRootInner(
dir: string,
candidateRoot: string | null
): string {
if (process.env.NX_WORKSPACE_ROOT_PATH)
return process.env.NX_WORKSPACE_ROOT_PATH;
if (path.dirname(dir) === dir) return candidateRoot;
const matches = [
path.join(dir, 'nx.json'),
path.join(dir, 'nx'),
path.join(dir, 'nx.bat'),
];
if (matches.some((x) => fileExists(x))) {
return dir;
// This handles the case where we have a workspace which uses npm / yarn / pnpm
// workspaces, and has a project which contains Nx in its dependency tree.
// e.g. packages/my-lib/package.json contains @nx/devkit, which references Nx and is
// thus located in //packages/my-lib/node_modules/nx/package.json
} else if (fileExists(path.join(dir, 'node_modules', 'nx', 'package.json'))) {
return workspaceRootInner(path.dirname(dir), dir);
} else {
return workspaceRootInner(path.dirname(dir), candidateRoot);
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
+30
View File
@@ -0,0 +1,30 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo",
"emitDeclarationOnly": false,
"types": ["node"],
"composite": true,
"noImplicitAny": false,
"noUnusedLocals": false,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true
},
"include": [
"src/**/*.ts",
"src/lib/plugins/loaders/hmr-accept.ts",
"src/lib/plugins/loaders/hmr-accept-loader.ts",
"src/lib/plugins/tools/*.ts"
],
"exclude": ["vitest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"],
"references": [
{
"path": "../devkit/tsconfig.lib.json"
},
{
"path": "../angular-rspack-compiler/tsconfig.lib.json"
}
]
}
@@ -0,0 +1,26 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "out-tsc/spec",
"types": ["vitest/globals", "vitest/importMeta", "node", "vitest"],
"isolatedModules": true
},
"references": [
{
"path": "tsconfig.lib.json"
}
],
"include": [
"vite.config.ts",
"vitest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/**/*.d.ts"
]
}
+51
View File
@@ -0,0 +1,51 @@
import { defineConfig } from 'vitest/config';
import { resolve } from 'path';
export default defineConfig({
cacheDir: '../../node_modules/.vite/angular-rspack/unit',
root: __dirname,
resolve: {
// Use regex aliases so each subpath is intercepted independently. Plain
// string aliases (e.g., `'@nx/devkit'`) do prefix matching in vite, which
// would rewrite `@nx/devkit/internal` to `<mock-file-path>/internal` and
// fail to resolve.
alias: [
{
find: /^@nx\/devkit$/,
replacement: resolve('./test-utils/nx-devkit-mock.ts'),
},
{
find: /^@nx\/devkit\/internal$/,
replacement: resolve('./test-utils/nx-devkit-internal-mock.ts'),
},
// Load the sibling workspace package from source so its imports
// (e.g. `@nx/devkit/internal`) go through the aliases above instead of
// resolving via the built dist's CJS `require`.
{
find: /^@nx\/angular-rspack-compiler$/,
replacement: resolve('../angular-rspack-compiler/src/index.ts'),
},
],
},
test: {
watch: false,
environment: 'node',
include: ['src/**/*.spec.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
coverage: {
provider: 'v8',
reporter: ['text', 'lcov'],
reportsDirectory: './coverage/angular-rspack/unit',
exclude: [
'mocks/**',
'**/types.ts',
'**/*.d.ts',
'__snapshots__/**',
'**/__tests__/**',
'**/.eslintrc.json',
'**/vitest*.config.mts',
],
},
reporters: ['default'],
passWithNoTests: true,
},
});