Angular and Microfrontend Pipelines
A lot of people use the microfrontend these days. I’m sure you know the reasons, so I won’t dwell on it. This article is very technical.
Here, I’ll cover some tricks on how to make a (relatively) seamless integration between an Angular host app and a React-based microfrontend. This is a fairly brief overview without a deep dive, almost no theory, and with very simplified — illustrative — subsystems, but the approach is real, tested and used in the wild.
TLDR: how to build, serve and test Angular application alongside a React microfrontend with a single ng *
command.
Demo: https://github.com/lexey111/angular-microfrontend-pipeline
Under the Hood
- Angular Host.
- React-based micro-frontend.
- Webpack.
- ESBuild.
- Typescript.
- Jest for Unit Tests.
- Playwright for end-to-end tests.
Let’s speak about the Microfrontend first.
Microfrontend
Here I use an approach that has proven itself over the years of practice and is applied in different projects. There are several basic principles.
First, each micro-application is a separate independent package (a Javascript file plus a CSS file) that follows some conventions so that it can be attached to and detached from the Host application.
Second, there is a special module, Loader, which dynamically loads that bundles in runtime to the Host application, and mounts their parts on demand to the Containers.
This is a slightly different approach than the module federation — micro-application packages are simply placed in the main application’s output folder and the Loader makes requests to them, for example
script src="/microfront/app1"
.However, such micro-applications are easy to serve in the same way Webpack module federation does, with HTTP and separate URL/port.
Third, there are same shared dependencies which are not included into micro-applications and must be provided as an external, vendor packages. E.g., React or React-DOM.
Simplifications:
There are no separate repositories for each part. There are no NPM packages for Loader, UI Kit and Services — in fact, there is no UI Kit and Services at all.
No container builds. No build consolidation and shared configs and build scripts.
No runtime configuration, no runtime application register (dynamic per-user UI management).
Very simple feature set, it is just a skeleton with little to no generalisation or configuration, lots of hardcoded values, names and paths. Almost no Typescript interfaces, generic types and aliases.
Technical principles
The purpose of this article is to show you how to integrate with Angular, not how to create full microfrontends and hybrid architectures. However, this approach can be scaled up to production in stages.
The functional foundation of a microfrontend (how micro-applications are built) should follow these principles:
- Be lightweight, otherwise it makes little sense to use.
- Allow dynamic mounting and unmounting (very important remark) to an external container — unlike AngularJS.
- Allow to be instantiated multiple times (no global scope pollution and conflicts).
- Allow to use static dependencies, aka loading the runtime from CDN or from pre-built packages.
- Not being a framework which has its own opinion regarding app lifecycle, routing, change detection, patching of global objects — like Angular.
- Be able to be isolated: provide separate rebuilds, containerised pipelines, independent versioning, testing and deployment. This is not about the engine itself but about the ecosystem, but anyway, it must be mentioned.
That’s why React. Well, it could actually be Vue, or Svelte, or vanilla JS, whatever — any “library” — but React is very famous, has many components, and a large community.
Micro-applications and Views
Each micro-application can — not must– provide one or more Views. View is a basic named piece which can be addressed by the Loader mount function. E.g., micro-application Users may include views UsersList, UserCard, AdminProfile, etc.
Non-visual micro-applications are usually Services and are not covered by this example, as well as an UI Kit package. These topics are really big and sophisticated, and include data management, design systems, local- and global stores, local- and global message buses, dependency resolving, etc.
Host Container component
A special host app component (i.e. existing in the Angular code) that provides a) a DOM container, usually a div, that a particular micro-application should occupy, and b) calls the loader to mount the micro app on initialisation and unmount it when the container is destroyed.
// Host Container component usage
<div class="two-columns">
<app-module app="app-simple" view="one" [delay]=100></app-module>
<app-module app="app-simple" view="two" [delay]=1500></app-module>
</div>
<h2>Another micro-application: "app-second"</h2>
<app-module app="app-second" view="text" [delay]=800></app-module>
In addition, the container component is often responsible for passing data in and out of the micro-application — sharing data and logic between the host application and the micro-applications, dynamic loading — displaying a loading indicator during loading and initialisation of the micro-application, etc.
Loading flow
Micro-frontend Loader must be, umm, loaded before first usage of the microapp in a complex hybrid application. Then — static dependencies. Then — if exist — Services and UI Library. This is a bootstrapping phase.
But where are the micro-applications? They will be loaded later by the Loader and Host Container Components. To be clear, vice versa: Container requests the particular micro-application to be loaded by Loader, and, then, provides a div
where requested View must be mounted.
So,
- Host application provides a Container,
- Container requests some App/View, like
app-users/card
, - Loader resolves and loads the app bundle, both JS and CSS,
- Loader/mount instantiates and attaches the View requested to the Container,
- Everything lives together.
Building and Testing
Each part of microfrontend architecture must be independent, but somehow orchestrated to work together.
It means, each subsystem has to –
- Be able to be isolated in a separate Git repository,
- Have own dependencies (
package.json
file,node_modules
folder), - Have standardised build interface,
- Have standardised output folder,
- Be able to be an NPM package (optional, not covered here),
- Provide Dockerized build-, test-, develop pipelines (is not covered here).
Therefore, the loader, and micro-applications, and any other possible parts must support such commands and contracts:
npm run build
for production build,npm run dev
for development build,npm run test
to run unit tests,npm run e2e
to run e2e tests,./dist
as the output folder.
There are a lot of nuances in building full-fledged solutions, most of them are related to the establishment of contracts, rules and automated tools: how to build everything uniformly, how to bring it all together and manage it all — not even how to use the micro-frontend architecture, but how to organise and maintain its parts . As with microservices, the question is not how they work, but how they work together.
These topics are very important but are not covered in this article. Just a minimal set of requirements, no physical separation, no advanced control over the structure of the source code.
Well, it’s time to start.
The Integration
1. Packages and dependencies
A very important topic in the microfrontend is dependency management. Not only because if you have independent node_modules
in each micro-application, you will easily end up with tens of gigabytes of duplicate packages. But because you may end up with an incompatible set of underlying (vendor) dependencies, such as different versions of React.
Also, we need to somehow centralise the installation process. Running npm install
for each nested folder may be very inconvenient.
We will use workspaces
from NPM.
Let’s add the workspaces we have in the microfrontend
folder (into main package.json
):
"workspaces": [
"./microfrontend/*"
]
The second step is aligning dependencies. Let’s remove all node_modules
folders in the project, including nested, if any, and run npm install
. It will collect the dependencies from all the workspaces PLUS Angular, dedupe, install packages and create symlinks to nested folders. It means, there will be a single node_modules
instead of a bunch of local packages — great economy.
Please pay attention: in the nested package.json
files field name
must be unique and (this is not mandatory but a good practice) be the same as the containing folder:
"name": "app-simple"
In the current setup, the name
is also the name of micro-application. The Host Container will demand this application as app-simple
as well — for more sophisticated configurations you can use advanced naming, but here the name of folder = name of workspace = name of micro-application = names of the bundle files (app-simple.js
and app-simple.css
).
The third, optional step, is projecting NPM commands from microfrontend parts:
"scripts": {
"microfrontend-build": "npm run build --workspaces",
"microfrontend-dev": "npm run dev --workspaces",
"microfrontend-test": "npm run test --workspaces",
...
},
With that you’ll be able to run independent build or test of all the micro-application in one turn. However, if you want more granularity, you can add more specific commands:
"scripts": {
"microfrontend-build": "npm run build --workspaces",
"microfrontend-build-loader": "npm run build --workspace=microfrontend/loader",
"microfrontend-build-app-second": "npm run build --workspace=microfrontend/app-second",
"microfrontend-dev": "npm run dev --workspaces",
"microfrontend-test": "npm run test --workspaces",
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
to be able to build, e.g., the Loader.
Well, hereby we are able to build the Angular App with ng build
, and all the microfrontend with npm run microfrontend-build
.
Now we need to automate the process because it is inconvenient. We need to make such things automatic:
- Building Angular,
- Building every part of microfrontend,
- Copying build artefacts from each microfrontend part to Angular’s output
assets
folder, - Extend Angular’s
index.html
file with microfrontend: add loading the Loader, add vendors packages.
2. Custom builder configuration
We need to run npm run build
in each microfrontend folder, or just execute npm run microfrontend-build
we’ve created on the previous step. But auto-build is not optimal because then we need to individually copy build artefacts to output assets
folder anyway.
So, I‘ve created a Webpack plugin which will run on every rebuild and put the results into current compilation assets.
Interesting: the approach can be adapted to work with React, or Vue, or any other Webpack-based pipeline. Just add the plugin(s) manually.
The most simple way to alter Angular’s pipeline is using a custom builder. I use this project to attach to the build pipeline.
Let’s install the dependency with npm i -D @angular-builders/custom-webpack
. Then edit angular.json
file to call our custom Webpack extension:
"architect": {
"build": {
"builder": "@angular-builders/custom-webpack:browser",
"options": {
"customWebpackConfig" : {
"path": "./microfrontend/microfrontend-webpack.config.js"
},
"indexTransform": "./microfrontend/microfrontend-html-transform.js",
...
Angular’s implicit Webpack config does not allow multi-configurations, otherwise we would just return an array of Webpack-configs and everything would work auto-magically.
Our microfrontend is built upon ESBuild, not Webpack, and anyway — pipelines must be isolated. Perhaps, some of your micro-applications built with Vue + Vite, or Rollup: that’s why using of npm run
is better than sharing a Webpack config.
All the our Webpack config does is adding a couple of plugins to the process:
module.exports = (config) => {
const isDevelopment = config.mode === 'development' ;
const isWatch = Boolean(config.watch);
console.log(`${isDevelopment ? 'Development' : 'Production'} mode | ${isWatch ? 'watch' : 'build'}`);
config.plugins.push(new MicrofrontendCompiler(isDevelopment, isWatch));
if (isWatch) {
config.plugins.push(new MicrofrontendWatcher());
}
return config;
}
2.1. Index.html transformation
Angular Host knows nothing about the needs of the microfrontend. We need to manually add the loader.js
script loading, and static dependencies to index.html
. Static deps (React, React-DOM) will be added from CDN for simplicity.
I added a simple transformer, microfrontend-html-transform.js
, which adds dependencies to the end of <head>
.
Possible improvements
- Make vendor’s dependencies to use local copies, i.e.,
node_modules/react/umd/react.production.min.js
instead of CDN — copy the file toassets
and write corresponding path to theindex.html
. - Add a consistency check, like runtime versions tests for each micro-application, version of the Loader, UI Kit, Services. You need to add some fields to package.json files and process them on transformation.
- Add web-worker to support the hot reloading in dev mode.
3. Building and Serving
Here we have two plugins: microfrontend-plugin-compilation.js
and microfrontend-plugin-watch.js
Our Webpack file, microfrontend-webpack.config.js
, detects the environment and mode and adds the plugins to the config. First plugin does all the work whereas the second one just adds files to the watch list in serve
mode (ng serve
).
The serve mode implementation is pretty straightforward. Plugin just scans all the files by microfrontend/**/src/*
pattern and adds them to the dependencies of the current compilation. As a result, if you change any of the files — the build process is restarted for each part of the microfrontend. This, of course, is not an optimal solution, but a full recovery takes about 2 seconds and is tolerable.
Even worse, there is no automatic host application reload and/or module hot-swap — it’s up to you. In the current configuration, the developer needs to press F5 or Ctrl/Cmd-R to reload the page after rebuilding the micro-application. In addition, when a file added, build must be manually restarted because scanning is performed only at startup.
Microfrontend Loader can include its own listener or just a manual ‘re-mount’ button to reload a particular micro-application on demand, or automatically based on websocket notification.
However, in full-scaled application configuration there should be a detection of a project changed, partial rebuild (and redeploy) and some reload approach, or at least configurable ‘watch’ mode when developer can specify what micro-applications must be watched.
There are a lot of things that can be improved here:
- source code maps in dev mode,
- better watch, including additions/removals, change tracking, hot reloading, reporting of build errors in the browser,
- better reporting, like file sizes, total files processed, etc.
- ESLint support and automatic unit testing on production builds.
4. Testing
Well, testing is a big topic with a lot of options available. Here, in this example, two types of tests are available: Unit testing with npm run test
and end-to-end testing with npm run e2e
.
The second kind of testing is based on the npm run start
command which compiles per-project mini-application (see src/tests/e2e/index.e2e.tsx
). To do that, a special builder configuration, esbuild.e2e.js
, builds and serves it on port 8000.
In this article I will cover only Unit Tests pipelines integration.
The most simple approach is adding a launcher to karma.conf.js
file:
const MicrofrontendKarma = require('./microfrontend/microfrontend-test');
MicrofrontendKarma(); // run all the micro-frontend tests once, on start ng test
...
files: [
{
pattern: 'microfrontend/test.summary.spec.js', watched: false
} // disable watching on summary file
],
MicrofrontendKarma()
function executes all the npm run test
commands for each piece of microfrontend, and intercepts the output with tee
:
execSync(`npm run test 2>&1 | tee ${log}`, {
cwd: path.resolve(base, folder),
stdio: 'inherit'
});
Then, with pretty simple parser it extracts the build stat (‘FAIL’, total tests number) and provides a summary fake test suite — special spec where each it(…)
entry is the name of microfrontend part tested.
This way, the developer can see the results of the unit tests in one run of the ng test
:
Possible improvements
Of course, the approach is very basic and a lot of features can be added. E.g. Webpack plugins (need to use karma-webpack
) to automatically re-run tests on file change — like it is implemented for build, one plugin to run tests, another one to watch the files.
Also, better logs integration to see a full report in Jasmine. Also, merge coverage reports. A SonarQube analyzer. E2E reports consolidation.
That’s all. In this article, I’ve described a hybrid app, an Angular-based host, and a React-based microfrontend, and how parts can be consistent during build and test processes.
Each micro-application here can be built, tested — with both Unit tests and e2e tests — and independently developed with npm start
.
All together can be installed with npm install
, built with ng build
, tested with ng test
in a single run.