chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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'],
|
||||
},
|
||||
];
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+81
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user