Why we need a new liferay-npm-bundler (3 of 3)

A real life example of the use of bundler 2.x

This is the last of a three articles series motivating and explaining the enhancements we have done to Liferay's npm bundler. You can read previous article here.

To analyze how the bundler works we are going to examine a real life example comprising a portlet and an OSGi bundle providing Angular so that the portlet can import it. The project looks like this:


 npm-angular5-portlet-say-hello
     package.json
        {
            "name": "npm-angular5-portlet-say-hello",
            "version": "1.0.0",
            "main": "js/angular.pre.loader.js",
            "scripts": {
                "build": "tsc && liferay-npm-bundler"
            }
            …
        }
     tsconfig.json
        {
            "target": "es5",
            "moduleResolution": "node",
            …
        }
     .npmbundlerrc
        {
            …  
            "exclude": {
                "*": true
            },
            "config": {
                "imports": {
                    "npm-angular5-provider": {
                        "@angular/animations": "^5.0.0",
                        "@angular/cdk": "^5.0.0",
                        "@angular/common": "^5.0.0",
                        "@angular/compiler": "^5.0.0",
                        …
                    },
                    "": {
                        "npm-angular5-provider": "^1.0.0"
                    }
                }
            }
        }
     src/main/resources/META-INF/resources/css
         indigo-pink.css
            …  
     src/main/resources/META-INF/resources/js
         angular.pre.loader.ts
            // Bootstrap shims and providers
            import 'npm-angular5-provider';
            …  
        …
     npm-angular5-provider
         package.json
            {
                "name": "npm-angular5-provider",
                "version": "1.0.0",
                "main": "bootstrap.js",
                "scripts": {
                    "build": "liferay-npm-bundler"
                },
                "dependencies": {
                    "@angular/animations": "^5.0.0",
                    "@angular/cdk": "^5.0.0",
                    "@angular/common": "^5.0.0",
                    "@angular/compiler": "^5.0.0",
                    …
                }
                …
            }
     src/main/resources/META-INF/resources
         bootstrap.js
            /**
              * This file includes polyfills needed by Angular and must be loaded before the app.
            …  
            require('core-js/es6/reflect');
            require('core-js/es7/reflect');
            …
            require('zone.js/dist/zone');
            …
    …


You can find the whole project available for download here. Also, keep in mind that it is supposed to be run in Liferay 7.1.0 B2 at least (download it from here). It will not work in Liferay 7.0.0 unless you do some modifications!

As you can see, the portlet's build process includes calling the Typescript compiler (tsc) and then the bundler. We need to invoke tsc because Angular is based on the Typescript language and tsc is responsible for transpiling it to ES5. The Typescript compiler is configured in the tsconfig.json file and it is important that we set its output to es5 and its module resolution to node. That is because the bundler always expects that the input JS files are in those language and module formats.

Next, have a look at .npmbundlerrc where the imports for Angular are configured. Please note that we also import npm-angular5-provider with no namespace because we are going to invoke one of its modules to bootstrap Angular shims: see the angular.pre.loader.ts file, where npm-angular5-provider is imported. That import, in turn, loads npm-angular5-provider's main file (bootstrap.js).

Also, pay attention to the exclude section where every dependency of npm-angular5-portlet-say-hello is excluded to prevent Angular from appearing inside its JAR. This makes the build process faster and optimizes deployment but don't worry if you forget to exclude any unneeded dependency because nothing will fail: it just won't be used and will use a bit more space than needed.

The setup for npm-angular5-provider is very simple. It just declares Angular dependencies and invokes liferay-npm-bundler to bundle them. No need to do anything in this project. However, note how it also includes the bootstrap.js that is responsible for loading some shims needed by Angular. This file must always be invoked (by importing npm-angular5-provider from any portlet using it) before any portlet is run so that Angular doesn't fail because of missing APIs.

To finish with, check out the indigo-pink.css file of npm-angular5-portlet-say-hello. To keep this example simple, we have copied this file from the @angular/material npm package. It contains a prebuilt theme suitable for the Angular's Material Design widgets framework. In a real setup, that file's styles should be provided by a Liferay theme instead of being directly bundled inside each portlet needing it.

Now, suppose we run both builds. Let's see how the output would look like:


 npm-angular5-portlet-say-hello
     build/resources/main/META-INF/resources
         package.json
            {
                "dependencies": {
                    "@npm-angular5-provider$angular/animations": "^5.0.0",
                    "@npm-angular5-provider$angular/cdk": "^5.0.0",
                    "@npm-angular5-provider$angular/common": "^5.0.0",
                    "@npm-angular5-provider$angular/compiler": "^5.0.0",
            …
         js
             angular.loader.js
                "use strict";

                Liferay.Loader.define(
 ➥ "npm-angular5-portlet-say-hello@1.0.0/js/angular.loader",
 ➥ ['module', 'exports', 'require',
 ➥ '@npm-angular5-provider$angular/platform-browser-dynamic',
 ➥ './app.component',
 ➥ ...
 ➥ function (module, exports, require) {
                    var define = undefined;
                …
 npm-angular5-provider
     build/resources/main/META-INF/resources
         package.json
            {
                "name": "npm-angular5-provider",
                "version": "1.0.0",
                "main": "bootstrap.js",
                "dependencies": {
                    "@npm-angular5-provider$angular/animations": "^5.0.0",
                    "@npm-angular5-provider$angular/cdk": "^5.0.0",
                    "@npm-angular5-provider$angular/common": "^5.0.0",
                    "@npm-angular5-provider$angular/compiler": "^5.0.0",
                    …
                }
                …
            }
         bootstrap.js
            Liferay.Loader.define(
➥ 'npm-angular5-provider@1.0.0/bootstrap',
➥ ['module', 'exports', 'require',
➥ 'npm-angular5-provider$core-js/es6/reflect',
➥ ...
➥ function (module, exports, require) {
                var define = undefined;
                /**
                * This file includes polyfills needed by Angular and must be loaded before the app.
                …  
                require('npm-angular5-provider$core-js/es6/reflect');
                require('npm-angular5-provider$core-js/es7/reflect');
                …
                require('npm-angular5-provider$zone.js/dist/zone');
                …
            }
        …
         node_modules/npm-angular5-provider$core-js@2.5.7
             index.js
                Liferay.Loader.define(
➥ 'npm-angular5-provider$core-js@2.5.7/index',
➥ ['module', 'exports', 'require',
➥ './shim',
➥ ...
➥ function (module, exports, require) {
                    var define = undefined;
                    require('./shim');
            …
        …


Take a look at the output of npm-angular5-provider. As you can see, the bundler has copied the project and node_modules' JS files to the output and has wrapped them inside a Liferay.Loader.define() call so that the Liferay AMD Loader know how to handle them. Also, the module names in require() calls and inside the Liferay.Loader.define() dependencies array have been namespaced with the npm-angular5-provider$ prefix to achieve dependency isolation.

You may also have noted the var define = undefined; addition to the top of the file. This is introduced by liferay-npm-bundler to make the module think that it is inside a CommonJS environment (instead of an AMD one). This is because some npm packages are written in UMD format and, because we are wrapping it inside our AMD define() call, we don't want them to execute their own define() but prefer them to take the CommonJS path, where the exports are done through the module.exports global.

We have said that liferay-npm-bundler added these modifications but, to be fair, the real responsible is babel-plugin-wrap-modules-amd, a Babel plugin that is executed by Babel when it is invoked from the liferay-npm-bundler in one of its build phases.

If you are curious on how that plugin is configured, take a look at the default preset used by liferay-npm-bundler where the liferay-standard Babel preset is referenced which, in turn, configures the babel-plugin-wrap-modules-amd plugin.

Now, let's look at the package.json file and notice how the dependencies have been namespaced too. This is necessary to make the namespaced define() and require() calls work inside the JS modules, and it is done by the liferay-npm-bundler-plugin-namespace-packages plugin, configured here.

There are more plugins involved in the build that serve miscellaneous purposes. You can check their descriptions and use in the Liferay Docs.

Now let's see how the bundler has modified npm-angular5-portlet-say-hello. In this case we will only pay attention to the changes made in two files, because the rest is more or less the same as with the npm-angular5-provider.

First of all, the angular-loader.ts file has been converted to angular.loader.js. This has happened in two steps:

  1. The Typescript compiler transpiled angular-loader.ts to angular.loader.js generating a CommonJS module written in ECMAscript 5.
  2. The bundler then wrapped that code inside a Liferay.Loader.define() call to make it executable inside Liferay AMD Loader.

But more important: the module is importing Angular modules like @angular/platform-browser-dynamic which the bundler usually namespaces with the bundle's name (in this case npm-angular5-portlet-say-hello) but, because we are importing them from npm-angular5-provider, they have been namespaced with npm-angular5-provider$ instead so that they are loaded from that bundle at runtime (by the Liferay AMD Loader).

Finally, if you look at the dependencies inside the package.json file you will notice that the bundler has injected the ones pertaining to npm-angular5-provider to make them available at runtime.

And that's it. Shall this huge and boring series of articles help you understand how the new bundler works and how to leverage it to deploy your most exciting portlets.

Have fun coding!

Write a blogpost too!

Write a deep dive into how you use Liferay projects in your technology stack. Or let people know useful tips and tricks for a particular functionality. The Liferay community needs you!

Login or Create an account