Some tricks for customizing the Angular build

Webpack required, batteries not included

Jose I Santa Cruz G
ITNEXT

--

Nightcafe prompt: Lego blocks inside a computer monitor
How building apps should be like

Most Angular applications will work when building them out of the box, npx ng build et voila!
What about if we require some extra modifications in the code that depend on, for eg, when the build is done? And here come the magic…

Note from the author: I've never believed in short stories (and my wife hates that), the story is always long and it's supposed to explain the whole process, otherwise there will be NO understanding on why things happen (or are done) the way they do.
If you want to skip the long story, ctrl+F here's the catch

First, let's see the context

Let's say we have an enterprise application, with some particular deployment requirements, if we skip them it won't do "any harm" (quoted, as we all know this is not completely true), but having them is desirable.

Most of these desired requirements are replacements inside the code, or some special file generation:

  • Both index.html and environment.ts files require replacing a placeholder for the deployed version or something that may help identifying what release was deployed, such as the last commit hash.
  • The deployed project requires an specific identifier for an error reporting app. Furthermore, all projects live in a monorepo, so the error reporting operations are in an internal library so it can be used in every different project.
  • The application's frontend is deployed in a CDN (such as Cloudflare), and to take advantage of caching some rarely updated CSS style files, a hashless-filename copy of each CSS is uploaded to a VPS used as CDN for images and other static files that don't usually change. This should be done only for production deployments.
  • The organization detected some third party projects on Github that were scrapping the public site in order to download certain files and information without the proper authorization (I have a not-so-pretty-nor-friendly term for this). In order to make this operations harder (or hard enough to discourage future work on adjusting the scrapping strategy) there're some random generated CSS strings and HTML blocks which are meant to be inserted in some internal components. (Yes, I know this is not a real security measure, but when the effort of solving a simple challenge increases far over expectations truth is we are most likely to give up).
  • .map files are required for error tracking and debugging (in a third party site that offers the service), but when deployed the code must be somewhat protected. In order to achieve this after uploading map files to this service vendor site, we require to generate a fake empty map file. (Yes, I also know that only a few things will stop curious developers from inspecting/trying to inspect the code, but you know, time is money, and if we are wasting more time that we're paid for trying to solve something that's supposed to be "simple" it's good to ask ourselves again if it's worth the extra effort).
  • Non minimized Javascript files in the /js folder should be deleted.

How to do all this?

Before this we should identify which operations should be done before building the app, and which should be done after, and also try to place each task in some execution order.

Before the build:

  1. Commit hash replacement in index.html and environment.ts files.
  2. Project identifier placeholder replacement in internal library for error reporting.
  3. Anti-scrapper replacements in components.

After the build:

  1. Copy and upload hashless CSS
  2. Fake mapfile generation
  3. Delete not minimized JS from /js folder

We could do this using plain shell scripts, but it's no mystery there are many developers who are scared of the command line and toxi-commands like:

find . -name '*.*.css' -type f | awk -F. '{ print $2 }' | xargs -I{} bash -c 'cp .{}*.css .{}.css' 2> /dev/null || :

which is explained as: find all hashed css files and make an unhashed copy, if the command fails keep running.
Guess ChatGPT would make a better one-line command for this prompt 😅

So, we are solving this using webpack.

Why & how webpack?

Webpack is the module bundler tool used by Angular to generate the distributable build files. There are faster, simpler and better tools for the same, but webpack is the one that we all know already work with Angular. As in most cases we don't really require to make strange things during the app build process we can use it directly.

Strange Angular build

But as we ARE going to do strange things, we'll have to enrich the current webpack config used by Angular with some extra code that solves our requirements. And in most cases this can be done directly using one of the many webpacks plugins available, just be sure to choose the right one according to webpack's version, and hopefully, a maintained library.

There are actually 2 reliable ways of doing these operations:

  1. If your project is using the nx-cli as a wrapper/replacement for the ng-cli , you can use the Nx Webpack plugin.
  2. If you're not using Nx (even if you are) you can use the package @angular-builders

The "how?"

With Nx it's fairly simple:

// shamelessly copied & pasted from https://nx.dev/packages/webpack/documents/webpack-config-setup
"my-app": {
"targets": {
//...
"build": {
"executor": "@nx/webpack:webpack",
//...
"options": {
//...
"webpackConfig": "apps/my-app/webpack.config.js"
},
"configurations": {
...
}
},
}
}

just change the build executor for the project (in this case called my-app), inside the project.json file.

Using the @angular-builder/custom-webpack package, is as simple:

// another shameless copy & pasted, this time from https://github.com/just-jeb/angular-builders/tree/master/packages/custom-webpack
"example-app": {
...
"architect": {
...
"build": {
// the example uses the builder attribute, so this may change
"executor": "@angular-builders/custom-webpack:browser",
"options": {
...
"customWebpackConfig": {
"path": "apps/example-app/webpack.config.js",
...
},
}
}
}

Be sure to download the correct package version, as it has slight changes depending on the Angular version your project is using. And setting this up is quite the same as the Nx example.

My personal preferences favor the @angular-builders package, and this is because of the support for some extra options. I'll get to this when explaining how to solve our particular requirements.

Angular & Webpack build story

If we go on a first run and try to solve all the strange requirements we can actually build a webpack.config file that would probably work… on a non Angular project. This is because Angular since version 8 did more than a couple of changes in the building process (learned this by digging in the @angular-builders docs and issues in their Github repo, and by trial and error).

So let's start coding:

1. Commit hash replacement in index.html and environment.ts files

For this we are going to use git-revision-webpack-plugin
And the required code would be something like:

const webpack                 = require('webpack');
const { GitRevisionPlugin } = require('git-revision-webpack-plugin');
const StringReplacePlugin = require("string-replace-webpack-plugin");

const gitRevisionPlugin = new GitRevisionPlugin();
let currentCommit = JSON.stringify(gitRevisionPlugin.commithash());
// Next line is part of the no-likes from this library
currentCommit = currentCommit.replace(/["']+/g, '');

module.exports = (config, _options) => {
let plugins = [
gitRevisionPlugin,
new StringReplacePlugin(),
];

config.module.rules.unshift(
{
test: /\.(html|ts)$/,
use: [
{
loader: StringReplacePlugin.replace({
replacements: [{
pattern: /__COMMIT__/g,
replacement: function (_match, _p1, _offset, _string) {
return currentCommit;
}
}]
})
}
);

config.plugins.push(
...plugins
);

return config;
};

We'll also be using the string-replace-webpack-plugin to replace the __COMMIT__ placeholder. There are many other string replacer webpack plugins, but even though this one is kind of old, it worked pretty well.

What I don't like from the GitRevisionPlugin is that as all the commit info is in a JSON Object, it has to be stringified to be retrieved, and the resulting string is wrapped between quotes. That's the reason of the currentCommit.replace(/["']+/g, ''); line, so we can get a "clean unquoted string".

Note that the replacement rule in the webpack file is unshifted, meaning that it's going to be placed as the first rule.
And the plugins are pushed in the plugin array, in order to be included last.

As arrays are aware of the position assigned to every object, we can at least think that where an array is used it is because positions matter (what's first, what's last).
So first rule is to replace the placeholder.

Theoretically, so far so good.

2. Project identifier placeholder replacement in internal library for error reporting

Let's say we are using Sentry as our error reporting external service.
From the documentation, to know which app is sending log records, Sentry needs a release name, so we'll use a placeholder in our code, let's call it __SENTRY_RELEASE__, and replace it, the same way we did with our __COMMIT__ placeholder.

(hope you're not expecting me to copy the above code from the commit replacement and replace __COMMIT__ with __SENTRY_RELEASE__)

Theoretically, still so far so good.

3. Anti-scrapper replacements in components

Certain components include a random generated string CSS, so scrappers that rely on CSS class matching stop working. As I mentioned again, this is not a real security measure, but it will make any ill attempts of scrapping the site a little more difficult.

The placeholder for this random CSS is zcrappingNoMoor (creative enough to avoid someone else overwriting these styles).

So a simple SCSS like this one:

:host {
.product-card {
// many other styles here
}
}

will become:

:host {
.product-zcrappingNoMoor-card {
// many other styles here
}
}

and when replaced, every compilation would have a different class, such as product-e6faaa67-card messing the logic of some scrappers.

How you generate this random string is up to your own likings. What I did is a little overkill but works.

const { createHash }          = require('node:crypto');

// Random string based on the currrent commit hash
// Random size between 1 and 9 chars
const cssRndIdx = Math.floor(Math.random() * 8) + 1;
let cssRndStr = createHash('md5').update(currentCommit).digest('hex');
cssRndStr = cssRndStr.substring(0, cssRndIdx);

And then another string replacement block.
Theoretically, still so far so good.

4. Copy and upload hashless CSS (after the build)

If your styles don't change "that much" maybe (just maybe) it's a good idea to have a hashless styles.css file uploaded on a CDN. In that way you'll skip the cache buster, and use the browser's or CDN cache.

There are 2 points here:

  1. Filtering all generated CSS files in order to remove the hash from the file name and copying the file with this hashless name.
  2. Executing the task after the build.

For the first point, even ChatGPT can generate a nice NodeJS function for retrieving the built files, just be aware you'll have to test to make sure you do get what you need. And filtering can also be done in many ways.
The second point has its drawbacks, as the task has to be executed AFTER the build is finished.

To take control on when a webpack task is executed we will use the Webpack Shell Plugin Next. What I really like from this plugin is that you can execute both shell commands or javascript functions.

To upload CSS files to the CDN server, we will use the scp command. I looked for a 100% NodeJS implementation for scp, but only found one library that implemented scp as scp and not sftp (different things).

scp stand for "secure copy" and allows copying files and folder to and from a SSH server. As we are executing this command unattended, we'll require a way of skipping the password prompt, and for this we'll use a SSH key.
The command line we will use for uploading a file via scp has the following shape:

scp -i ${pemKeyPath} -Cr ${cssFile} ${cdnUser}@${cdnServer}:${cdnCssPath}

where:

  • -i ${pemKeyPath} : the ssh key used to connect. Remember the public key has to be uploaded to the server, otherwise this won't work. Ref: man ssh-copy-id command.
  • -Cr ${cssFile} : -C to use compression for faster file transfers, -r to operate in a recursive way, meaning it will transfer all files that match the file blob, cssFile is just the file we want to upload.
  • ${cdnUser}@${cdnServer}:${cdnCssPath} : Connection info is the first part before the colon char (:), the second part (after the colon) is the path where the file or files would be stored.

The trick with the scp command is that we'll generate one command line per file (to avoid the single linerfind with a -exec scp …command or piped with the scp ,as we could build directly from the shell).

And almost forgot, the upload should be done only on production builds, so we'll have to find out the compile environment from the build command.
Configurations can be found on the angular.json or project.json files, inside the build target, under the JSON key "configurations".

const webpack                 = require('webpack');
const WebpackShellPluginNext = require('webpack-shell-plugin-next');

const fs = require('fs');
const path = require('path');

// Default build folder and app name
const DIST_FOLDER = 'dist/apps/frontend';
const APP_NAME = 'my-app';

// Avoid errors if the DIST_FOLDER does not exist
// Use the output-path parameter from the nx/ng build command line
let distFolderPath = path.join(process.cwd(), `${DIST_FOLDER}`);
const outputPathParam = JSON.parse(process.argv[2]).overrides?.['output-path'];
if (outputPathParam) {
if (!outputPathParam.startsWith('.')) {
distFolderPath = path.resolve(outputPathParam);
} else {
distFolderPath = path.join(process.cwd(), `${outputPathParam}`);
}
} else {
if (!fs.existsSync(distFolderPath)) {
console.error(`Folder ${distFolderPath} does not exist!`);
console.error(`Please run the build command from the projects root folder.`);

process.exit(1);
}
distFolderPath = path.join(distFolderPath, `${APP_NAME}`);
}
console.log('distFolderPath: ', distFolderPath);

function getAllFiles(basePath) {
let arrAllFiles = [];
// Function to get the array of all available files from the basePath
// probably a recursive function.
return arrAllFiles;
}

// Some required variables
let allBuildFiles = [];
let unhashedCssFiles = [];

// Get the compile environment from the command line
// Uncomment the console.log to understand the parameters
// console.log('Params: ', process.argv);
const configuration = JSON.parse(process.argv[2]).targetDescription?.['configuration'] ?? '---';
const compileEnvironment = ( JSON.parse(process.argv[2]).targetDescription?.['target'] === 'build'
&& [ 'production', 'any-other-production-configuration' ].some(conf => configuration.indexOf(conf) !== -1) )
? 'production' : 'development';
console.log('configuration - compileEnvironment: ', configuration, ' - ', compileEnvironment);

const pemKeyPath = path.resolve('.', 'cdn_key.pem');
const cdnUser = 'mycdnuser';
const cdnServer = 'mycdn.domain.tld'; // can be an URL or an IP
const cdnCssPath = '/opt/www/cdn/styles'; // remote path on cdnServer

module.exports = (config, _options) => {
let plugins = [
new WebpackShellPluginNext({
onBeforeBuild: {
scripts: [ 'echo "Starting webpack build... "' ],
blocking: true,
parallel: false
},
onBuildEnd : {
scripts: [
() => {
allBuildFiles = getAllFiles(distFolderPath);
},
() => {
// Copy all css files without hash
unhashedCssFiles = [];
const cssFiles = allBuildFiles.filter(file => file.endsWith('.css'));
cssFiles.forEach(cssFile => {
// The 2nd match group in the regexp is the hash we are removing
const cssFileWithoutHash = cssFile.replace(/([\w\d-]+)\.([\w\d-]+).css/g, '$1.css');
fs.copyFileSync(cssFile, cssFileWithoutHash);
unhashedCssFiles.push(cssFileWithoutHash);
});
},
// scp is a shell command
...( (compileEnvironment === 'production')
? unhashedCssFiles.map(cssFile => `scp -i ${pemKeyPath} -Cr ${cssFile} ${cdnUser}@${cdnServer}:${cdnCssPath}`)
: [ `echo 'CSS files are only uploaded to the CDN on production builds'` ] ),
// echo is a shell command :)
'echo "Ending webpack build..."'
],
blocking: true,
parallel: false
},
})
];
};

This one wasn't so simple.
And still, so far so good, at least in theory…

You may have noticed, everything works… in theory… The code is good, it's supposed to work. No coding mistakes.

Oh yes! There's a catch…

5. Fake mapfile generation (after the build)

When a SPA, or any kind of web application is "compiled", most of the times you'll get a JS bundle (or many JS files) and as many .js.map files.

These map files are meant for debugging. Your ugly and unreadable (by humans) .js files, when inspected inside the browser's development console, will actually show each function's code.
This is good for development, but not so good when a web app goes on production, as it can reveal any business logic in the code (or, if the developer in charge wasn't careful enough, even more sensitive information such as API tokens, for eg.)

There're more than a couple of tricks we can do with these map files, but we'll just generate a fake map file, just for the pleasure of screwing on curious eyes. (One can still debug the obfuscated code, but without expressive function and variable names it will be kind of difficult).

The fake map file we are building will have the following content:

{"version":3,"file":"/filename.<ugly hash>.js.map","sources":[],"names":[],"mappings":[]}

Empty, no variable nor function references. And each JavaScript file has one. As fake map files will be generated after the build, we will add the generateFakeMap() as another onBuildEnd script.

const webpack                 = require('webpack');
const WebpackShellPluginNext = require('webpack-shell-plugin-next');

function generateFakeMap(filename) {
return `{"version":3,"file":"/${fileName}","sources":[],"names":[],"mappings":[]}`;
}

module.exports = (config, _options) => {

let plugins = [
new WebpackShellPluginNext({
onBeforeBuild: {
scripts: [ 'echo "Starting webpack build... "' ],
blocking: true,
parallel: false
},
onBuildEnd : {
scripts: [
() => {
allBuildFiles = getAllFiles(distFolderPath);
},
() => {
// Generate fake/empty map files
const mapFiles = allBuildFiles.filter(file => file.endsWith('.map'));
mapFiles.forEach(mapFile => {
// filename array has fullPaths, so get only the filename without folders
const fakeMap = mapFile.split('/').pop();
// overwrite the original mapFile
fs.writeFileSync(mapFile, generateFakeMap(fileName));
});
}
]
}
})
];

config.plugins.push(
...plugins
);

return config;
};

Nothing fancy, our webpack.config is still slightly simple and understandable. And so far so good, it still "should work", in theory.

6. Delete not minimized JS from /js folder (after the build)

And here came trouble (here's the catch).

Including a function like this one on the onBuildEnd script array should work:

          () => {
// Delete all non-min.js files
const jsFolder = Object.keys(filesInFolders)?.filter(folder => folder.endsWith('/js'));
const minJsFiles = filesInFolders[jsFolder]?.filter(file => file.endsWith('.js') && !file.endsWith('.min.js'));
minJsFiles?.forEach(minJsFile => {
fs.unlinkSync(minJsFile);
});
},

But… learned and figured this out the hard way, and by the hard way I mean screwing the production deploy chain, and failing to delete the non-min js files.

Since Angular 8, the build process changed more than a bit. Resources and other files, such as custom css, js or any other, expected to be included in the distributable bundle are NOT copied or generated on the webpack process, and happen outside, after it finishes.

So it really didn't matter if the delete non-min.js files was on the onBuildEnd, or on the onBuildExit, or on the onAfterDone script arrays. During the webpack build (while the webpack process was running) there were no non-.min.js files found matching the extra resources I wanted to include in my bundle. The only js files found were the ones as a result of the compilation, and didn't want to delete those.

This brought me to a double check of my webpack-aided build process. Step 1. (replacing the commit hash placeholder on the index.html file) was also failing, and step 3. (anti-scrapper replacement) was also failing. This wasn't good.

So again, went back to the Angular builders package documentation, and searched in the issues.
Actually didn't find anything regarding when the extra file copying was done. But there're a couple of issues explaining that the index.html file is not generated on the webpack build, so any modifications should be done using an indexTranform file.

About the anti-scrapper replacement, it's not as simple. The directTemplateLoading attribute in the AngularCompilerPlugin should be set to false (details are explained here). As expected, copy & pasting the suggested code didn't work (since Angular v8 up to current version we can expect more than a couple of changes).

So the approach should change.

Fixing the "catchs"

1. Replacing the commit hash placeholder in the index.html file

If you use the Angular builderswebpack.config, whenever you need to do certain adjustments to the index.html file in your Angular app (such as replacing certain placeholder), you will have to do it using an indexTransform file.

In our case:

// apps/example-app/indexTransform.ts
import { TargetOptions } from '@angular-builders/custom-webpack';
import { GitRevisionPlugin } from 'git-revision-webpack-plugin';

const gitRevisionPlugin = new GitRevisionPlugin();
let currentCommit = JSON.stringify(gitRevisionPlugin.commithash());

export default (targetOptions: TargetOptions, indexHtml: string) => {
console.log('\nindexTransform.ts: currentCommit: ', currentCommit);
// console.log('\nindexTransform.ts: targetOptions: ', targetOptions);
currentCommit = currentCommit.replace(/["']+/g, '');
return indexHtml.replace(/__COMMIT__/g, currentCommit);
};

The code is mostly the same we used in the webpack.config file. And we are keeping that code, because there's a couple of placeholders in some other .ts files.

And in our project.json file:

"example-app": {
...
"architect": {
...
"build": {
// the example uses the builder attribute, so this may change
"executor": "@angular-builders/custom-webpack:browser",
"options": {
...
"indexTransform": "apps/example-app/indexTransform.ts",
"customWebpackConfig": {
"path": "apps/example-app/webpack.config.js",
...
},
}
}
}

After running the build every __COMMIT__ placeholder will be correctly replaced.

3. Anti-scrapper replacements

As pointed up in the referred issue, we have to change this value directTemplateLoading: false in the AngularCompilerPlugin settings. But we're not going to struggle trying to figure out every other setting for this plugin. So we'll just replace the webpack.config settings, keeping the position for the AngularCompilerPlugin in the plugin array.

For Angular 15 my surprise was not finding an AngularCompilerPlugin, but an AngularWebpackPlugin. Expected…

  // ... rest of your webpack.config

const index = config.plugins.findIndex(p => {
let isFound = false;
try {
isFound = p.constructor.name === 'AngularWebpackPlugin';
} catch (error) {
console.error('Plugin without constructor: ', error);
}
return isFound;
});
// Change the existing AngularWebpackPlugin, keep old options...
const oldOptions = config.plugins[index].pluginOptions;
// ... except the directTemplateLoading value
oldOptions.directTemplateLoading = false;
config.plugins[index] = new AngularWebpackPlugin.AngularWebpackPlugin(oldOptions);

return config;
}

In our project.json file we'll have to add a flag so we don't have a huge repeating mess in our webpack plugins ( replaceDuplicatePlugins: true):

"example-app": {
...
"targets": {
...
"build": {
// the example uses the builder attribute, so this may change
"executor": "@angular-builders/custom-webpack:browser",
"options": {
...
"indexTransform": "apps/example-app/indexTransform.ts",
"customWebpackConfig": {
"path": "apps/example-app/webpack.config.js",
"replaceDuplicatePlugins": true
...
},
}
}
}

With that modification our replacement will work for every .html file in our components.

6. Delete not minimized JS from /js folder

At last…
Had to see this problem from different perspectives, and the answer was not on the code, but in the settings.

As in this particular case the .min.js files are not the result of compiling scripts, but just including some other scripts in the dist bundle, we can tell Angular to include just the .min.js files skipping the non-minimized versions.

So in our project.json file:

"example-app": {
...
"targets": {
...
"build": {
// the example uses the builder attribute, so this may change
"executor": "@angular-builders/custom-webpack:browser",
"options": {
...
"indexTransform": "apps/example-app/indexTransform.ts",
"customWebpackConfig": {
"path": "apps/example-app/webpack.config.js",
"replaceDuplicatePlugins": true,
...
},
"assets": [
{
"input": "libs/frontend/src/lib/assets",
"glob": "**.min.js",
"output": "js"
}
],
...

}
}
}

So by using the glob pattern “**.min.js” we're making sure our Angular compilation includes only the minimized Javascript files, skipping the non-minimized, so no file deleting is required.

Thanks for reading this far.
It was a really long story, and I hope you understood how to make adjustments in your Angular projects build process using webpack.

Most of the times you won't need to do any of these tasks, but if you need to solve any special requirement before or after your build webpack may be the answer.

Stay safe 👍

--

--

Writer for

Polyglot senior software engineer, amateur guitar & bass player, geek, husband and dog father. Not precisely in that order.