DEV Community

Cover image for Custom builder for Angular: My way
Alex KH
Alex KH

Posted on

Custom builder for Angular: My way

Disclamer

Hello everyone, I'd like to share by experience of building custom builder for Angular.

TL;DR Here is the result

Once upon a time, it started with Angular 18 where everything went like clockwork, but the release of Angular 19 changed many things. Many approaches had to be revised, and this process inspired me to write this article.
These steps are like travel notes: why certain decisions were made, what problems arose, and how they were solved. The links to the key commits will follow you through the process.
Sometimes the decisions came from intuition, sometimes - from common sense, and sometimes - just "because." I hope this experience will be useful if you decide to go down this path.

Introduction

Micro-frontend has always aroused my curiosity: I wanted to understand how they work, how to build them, what their pros and cons are. In 2018, inspired by this topic, I tried to build something similar to single-spa in one of the pet projects. At that time, there was no Webpack Module Federation (WMF), and Webpack itself seemed inconvenient. The choice fell on ESBuild and importmap.
Browser support for importmap at the time was mostly on paper or with special flags in browsers. For this reason, I used a polyfill. But, surprisingly, everything worked and even in several projects.

Transition to Native Federation

When Angular started moving away from Webpack towards ESBuild, and WMF was replaced by Native Federation (NF), it was nice to see that the ideas of five years ago were not so crazy. NF was used in recent projects, and everything seemed to be going well.
With the release of Angular 18, Hydration support also appeared. I wanted to try this functionality, but it turned out that NF does not support SSR.
The solution1 proposed by the author of NF didn't seem like a reliable. It called for a wrapper that, instead of a module, made an HTTP request to get the HTML, then parsed it and inserted it into the component. That approach created compatibility issues with Hydration and in my opinion significantly complicated the architecture, since it required running a separate SSR server for each mini-SPA.
In turn, NF already had everything needed to load mini-SPA modules via dynamic import.
Therefore I decided to give it a try:

import('http://localhost:4201/remoteUrl.js')
Enter fullscreen mode Exit fullscreen mode

But it didn't go that smoothly:

Error [ERR_UNSUPPORTED_ESM_URL_SCHEME]: Only URLs with a scheme in: file, data, and node are supported by the default ESM loader. Received protocol 'http:'
Enter fullscreen mode Exit fullscreen mode

Shame on me, I didn't know that Node.js can't load modules via HTTP. So I had to find a workaround. Node.js supports hooks for loading modules, and this is already at the release candidate stage. Angular 19 even uses this method to generate the manifest file.
Wrote an quick and dirty code which worked. Created an issue, suggested a pull request with POC, but there was no response. What's left? Make your own solution.

Goals

Any project starts with setting goals so as not to lose focus during the process.
What I wanted:

  • a tool for developing SPA-applications on Angular with micro-frontend architecture without a lot of refactoring for my current projects;
  • a plugin for nx.dev, because this platform is actively used in my own projects.
  • easy support and testing, so that in the future it would be possible to update and fix bugs without problems;
  • test coverage Who am I kidding.

The first goal is divided into two stages:

  1. Dev environment: create a convenient tool for development and testing via nx run serve app-name.
  2. Build of the application: set up the build process via nx run build app-name so that the result is ready for production. The first step is to create a project that will materialize these ideas.

Step 1: Initialization

Preparing the work environment

An efficient work environment is the key to rapid development and testing. Many of us have heard stories about how it takes days or even weeks to set up a work environment at a new job or project. I am not exception! To avoid such situations, I decided to think through the structure and configuration in advance. The main idea was to make everything reproducible and easy to use. Since the goal was to develop a plugin for nx.dev, I started by creating a new workspace via create-nx-workspace. I used the test application to experiment with SSR, and therefore created a plugin template using @nx/plugin:plugin. Additionally, I generated two applications and one library via NX generators.
As a result, the project structure looked like this:

  • plugin with two tasks: serve and build;
  • host application - the main entry point;
  • two SPA-applications that simulate micro-frontend;
  • shared library to store code used by all applications.

This set covered the main case and allowed me immediately test the micro-frontend architecture.

First steps

After generating the plugin, the first thing was to check that it worked. Of course, there were some problems. The first run gave:

Builder is not a builder
Enter fullscreen mode Exit fullscreen mode

The problem was that @nx/plugin:plugin didn't generate correct builders for Angular. I had to manually add executors and write how they interact with builders. When I fixed this, I encountered another error:

no schema with key or ref 'https://json-schema.org/schema'
Enter fullscreen mode Exit fullscreen mode

The solution turned out to be simple enough: update the link to the current scheme. After that, the command was successfully executed:

> nx run host-application:serve-test
Run serve mf
 NX   Successfully ran target serve-test for project host-application (480ms)
Enter fullscreen mode Exit fullscreen mode

Improvements in build process

At the moment, the plugin was built "on the fly" on each run. This is not the most reliable approach, since the final build may work differently. I decided to make the process more predictable:

  1. before running the serve-test command, the plugin is built;
  2. the application is launched from the dist directory. Those steps allowed me to have more control over the build process and avoid surprises. ### Compatibility with Angular DevKit I wanted the plugin configuration to be similar for the standard @angular-devkit/build-angular:application and @angular-devkit/build-angular:dev-server. That would provide consistent behavior and a minimal entry threshold for the new developers. To do this, I wrote a script that automatically extends the Angular DevKit schema with my own. This script runs before the build so that the final schema is always up-to-date. Then I updated project.json, replacing the default executor for serve and build with my own and did the same for two other apps. As a result, the plugin was integrated into the NX ecosystem as a native tool, and applications were ready to run in a micro-frontend architecture. If all of this sounds obvious now, it will get more interesting soon: the next steps was reveal the details of integration and solutions to the remaining problems. ## Step 2: Dev-server I really didn't like the fact that in NF the dependency build was separated from the main project build. And I caught very strange behavior which could be fixed only by restarting the dev-server. Also, I didn't understand how to work with environments: I couldn't start the dev-server so that dependencies were built as for the prod environment. For this reason, I wanted to revisit it with the way that is more practical for me :)

Dev-server without SSR

The main idea of ​​NF and WMF was to move external dependencies out of the main build and load them when it's needed. Of course, that led to many small requests on the first load, but the browser cache significantly speeded up work in the future. Because dependencies change pretty rare, this approach is acceptable. For the first load, SSR with Incremental Hydration was used, which also reduced the response time.

Rules to extract dependencies

To extract dependencies, first I had to figure out what exactly to extract. NF suggests using dependencies from package.json which sounds logical. However, in the case of monorepos containing backend applications, errors occurred. esbuild tried to add Node.js modules to the build for browser. To avoid this, a filter was implemented that creates two lists: dependencies that need to be extracted, and those that will remain in the build.

Dependency configuration

I added two parameters for dependency configuration: if a string is passed, it is a path to a JSON file, if an array is passed, it is a list of dependencies. The next step was a function that makes the configuration looking as expected. Since I needed to specify the configuration for both build and serve, I implemented an extension of the serve config based on build.

Entry points definition

Each dependency can have multiple entry points. For example, @angular/ssr and @angular/ssr/node are the same package with different entry points. I grabbed the idea from NF: once I got a list of dependencies excluded from the build, Angular config was extended with parameter externalDependencies containing this list.

First run

After configuration, the build was performed nx run host-application:build without any problems. However, running nx run host-application:serve failed with the error:

NX Schema validation failed with the following errors:
Enter fullscreen mode Exit fullscreen mode

The problem was related to the implementation of @angular-devkit/build-angular:dev-server. After analyzing other builders, such as custom-esbuild, a solution was found. Using the approach from this example helped to successfully complete the build of host-application:serve.
On http://localhost:4200/ everything looked correct, but only thanks to SSR. With disabled SSR, there was an error:

Uncaught TypeError: Failed to resolve module specifier "@angular/platform-browser". Relative references must start with either "/", "./", or "../"
Enter fullscreen mode Exit fullscreen mode

The error showed that the excluded dependencies were missing from the build. For restoration I needed to include importmap that specifies where to get dependencies like @angular/platform-browser.

ESBuild plugins injection

To handle dependencies in the separate builds, the capabilities of esbuild was extended via a plugin that adds new entryPoints given the list of dependencies. Changes were also made the build settings:

build.initialOptions.splitting = false; 
delete build.initialOptions.define.ngServerMode;
Enter fullscreen mode Exit fullscreen mode
  • The first line is self-explanatory - I'm disabling code splitting.
  • Removing ngServerMode: This is the more interesting2 part. The ngServerMode variable was introduced in Angular 19 as a global variable for SSR. It is set to true for the server environment and false for the browser. In the code, it is used as:
if (typeof ngServerMode === 'undefined' || !ngServerMode) {
  ...
}
Enter fullscreen mode Exit fullscreen mode

Customization of ESBuild configuration with your own plugins

In my project I also use TypeORM with plugin for NestJS. To do this I needed to add custom options for esbuild. I implemented a feature similar to custom-esbuild to allow users to add their own plugins to the build.

Importmap generation

Once all the setup steps were done, it was time to combine them and create a working importmap. Based on the list of dependencies and their entry points, a JSON was generated which became the basis for importmap. This tool simplified dependency and routing management.
Generation of importmap was organized in advance3, before loading modules. This allowed avoiding of usage of es-module-shims if the browser supports importmap. And at the same time also added the ability to pass your own function to modify index.html.

Micro-frontend architecture

With the dev server generating importmap, everything started working as expected. However, micro-frontend requires a special approach: loading mini-SPAs from remote hosts.
For this, the following were used:

  • remoteEntry — defines where the module is loaded from;
  • exposes — describes the modules available for loading. #### Routing and build configuration For verification purpose I configured the following routes:
  • in host-application two routes with different components;
  • in mf1-application updated configuration for building without dependencies. #### Importmap logic
  • If remoteEntry is specified, its dependencies and exposes are retrieved.
  • The retrieved data is added to importmap and new scopes are created.
  • If the dependency is not present in the main imports, it is added to scopes. The implementation is based on the esbuild plugin, which creates a new entryPoints named import-map-config. Since esbuild does not support JSON directly, I used the alternative approach. Bottom line here: the dev server provides a URL for configuration, and a separate file(import-map-config.json) is generated during the build. #### Refinement of paths By default, paths in importmap are relative which can be a problem when using a CDN. Angular config supports deployUrl parameter, which solves this problem. If the parameter is missing, the dev server host is used. #### Dynamic import Dynamic import also works with importmap. For example:
import('firstRemote/FirstRemoteRoute')
Enter fullscreen mode Exit fullscreen mode

But TypeScript may throw an error about not being able to find the module. To solve this problem, a wrapper 4provided more flexibility was created. Now routes are set like this:

export const appRoutes: Route[] = [{  
  path: 'first',  
  loadChildren: () =>  
    loadModule<{ firstRoutes: Route[] }>('firstRemote/FirstRemoteRoute').then(  
      (r) => r.firstRoutes  
    ),  
}]
Enter fullscreen mode Exit fullscreen mode

SSR on dev-server: problems and the ways to workaround them

When SSR on the dev server side had to be disabled, it seemed like life had become easier. But sooner or later it had to be returned. I enabled it back and immediately ran into a problem.

Vite surprises me again

The error was the following:

Error: Cannot find module '@nx-angular-mf/test-shared-library' imported from '/nx-angular-mf/.angular/vite-root/host-application/main.server.mjs'
Enter fullscreen mode Exit fullscreen mode

The irony is that this is not a Node.js error. This is Vite deciding that it knows better and trying to load a module marked as an external dependency. I opened the Vite source code and dived into the stack trace. I saw that the check for external modules looked something like this5:

export const externalRE = /^(https?:)?\/\// 
export const isExternalUrl = (url: string): boolean => externalRE.test(url)
Enter fullscreen mode Exit fullscreen mode

It means that if a dependency doesn't start with http, Vite ignores it. External module settings? Forget it. The solution is obvious: fake the import by adding http. But how? The only way is the Vite plugin. The problem is that Vite runs inside @angular-devkit/build-angular:dev-server, which is not so easy to get to.

Looking for workarounds

Instead of giving up, I went the other way. If Vite doesn't want to cooperate, I can intercept its behavior via hooks to load modules. Since I was going to use it anyway, I registered a custom loader before starting serveWithVite, limiting work to SSR mode only and gave patched Vite. Result? New error:

Error [ERR_UNSUPPORTED_ESM_URL_SCHEME]: Only URLs with a scheme in: file, data, and node are supported by the default ESM loader. Received protocol 'http:'
Enter fullscreen mode Exit fullscreen mode

It looked like a step back, but it was exactly the error that was needed to move forward.

Intercepting everything at once: working SSR on a dev-server

When I started implementing the loader, it turned out that I had to take into account many details. Here are the main tasks that I faced:

  • work with external files: it's needed to have a list of all used external files to pass them to the loader. If a request comes in for one of these files, pass it;
  • load mini-SPA modules: these modules need to be pulled in via http and passed correctly;
  • use importmap: dependencies need to be updated with each change;
  • include dependencies with the http prefix: all of this only works on the dev-server, so dependencies need a special approach;
  • implement via resolve: apply the solution from this issue. After implementing all these steps, I managed to achieve working SSR on the dev-server. But there was one problem without which a full-fledged environment was still unachievable. #### Problems during rebuild Let's imagine that host-application is running. If at this point I make changes to test-shared-library, then rebuilding happens automatically. However, the result of these changes doesn't appear when the page is refreshed. Why? Because the dev-server doesn't take into account the rebuilt dependency. To see the changes, I have to restart the dev-server that is inconvenient. ##### Update dependencies This behavior is expected. We excluded test-shared-library from the main build, so Vite sees no reason to update itself when it changes. Solution? I have a list of dependencies and change events. I just need to manually initiate the update process by calling this process And again the problem: there is no direct access to Vite. But, since the import of Vite is already intercepted, this can be bypassed with a small trick, which runs the required process.
Problem with remoteEntry

Another case: changes in remoteEntry. esbuild is useless here because the changes may happen in another repository. In my case, it's mf1-application. Does it mean that I need to restart the dev server every time I edit mf1-application? No way.
If I know there is an update in remoteEntry, then I can trigger a Vite restart by myself. Adding a button to the page that restarts the server when clicked.
Now everything is ready: SSR works, changed dependencies are processed, and the dev environment covers all my current tasks. We proceed with of the final build.

Step 3: Main build

This is a key step on the way to the final result. There are many nuances, but the result is worth it. I will describe what I had to do:

  • Mandatory deployUrl : This is the heart of the importmap configuration. It defines where to load dependencies from. Get this wrong and the application will simply crash. deployUrl is also used to specify the path to import-map-config.json when starting the SSR server, which is then responsible for loading dependencies from the CDN.
  • deployUrlEnvName: Added a parameter that contains the name for the environment variable that, in turn, contains deployUrl. If it exists and is set, it should be used in priority.
  • Module loader: Its job is to request import-map-config.json by deployUrl and build importmap that will then be used to download dependencies. If there were changes in any mini-SPA, I just need to restart the SSR server.
  • Loader registration: To do this, the esbuild plugin creates server.ssr.mjs, which registers the loader and imports server.mjs. This ensures that the loader is included before the SSR server is started.
  • Loader transfer: I need to build the loader with all dependencies and move it to the SSR build folder. This ensures access to all components during runtime. Once again, the esbuild plugin came in handy.6
  • Applying the changes: All tasks are integrated into the loader.

Yet-another run

I ran the build... and got an error:

Error [ERR_MODULE_NOT_FOUND]: Cannot find package '@nx-angular-mf/test-shared-library'
Enter fullscreen mode Exit fullscreen mode

Reason: The builder didn't see the module. The dev-server was running, but the build failed. The difference was in the new SSR functionality in Angular 19. It turned out that Angular uses its own custom loader, which simply doesn't see my dependencies.
Found out that there is a parameter:

partialSSRBuild
Enter fullscreen mode Exit fullscreen mode

It disables the route generation during build time. Enabled it and build started working.
But a new problem appeared: server.mjs considered @angular/ssr/node dependency as external, although it couldn't be excluded. Therefore I had to manually specify paths for each dependency.

Final run

When everything was ready:

  1. Build the projects.
  2. Run serve-static.
  3. Then start server.ssr.mjs.

Result — everything works. Now it is a full-fledged environment for development and build.
At the moment, this solution works in the production environment, and there are no problems.


  1. At the time of publication, NF started supporting for hook-based SSR 

  2. Solve the problem with ngServerMode. After upgrading to Angular 19, I spent the whole evening trying to figure out why SSR wasn't working: dev-server was working, but the final build wasn't. The problem was in the ngServerMode variable. I was using browser-based dependencies on the SSR side. After the build, the condition looked like this: if(true){}. And it worked in this place, which led to several hours of fun debugging :) 

  3. Native Federation and ES Module Shims. The specificity of importmap is that it must be declared before any module is loaded. NF creates importmap at runtime when the module has already been loaded. I assume this is the reason why NF always uses a polyfill. 

  4. Looking ahead: When using provideExperimentalZonelessChangeDetection, parts loaded via loadChildren or loadComponent are not hydrated. This is likely related to this issue issue and PendingTasks. Having a wrapper would make it easier to make changes if needed. 

  5. In the latest version of Vite, the regular expression has changed. But it doesn't change the essence, since the logic of the check hasn't changed. I asked a question, but at the time of writing there was no answer yet. 

  6. Strange behavior of esbuild during loader building. When converting cjs to esm, esbuild declared all exports as default, which was similar to this issue. Because of this, loaders can't register the required hooks correctly. So I had to complicate the process a bit. 

Top comments (0)