# Prebuilding Libraries

NPM packages referenced in any application are always included as part of the Webpack compilation. When building an application, Webpack analyzes the dependencies and imports specified in the code and bundles all the required NPM packages along with the application code. This ensures that all the necessary packages are available and can be used by the application at runtime:

import Rollbar from 'rollbar';

// Or
import('rollbar').then(m => {
  console.log(m.default);
});

Webpack is responsible for watching file changes, rebuilding the dependency graph, and rebuilding dependencies when any of the dependencies change. The behavior may vary depending on the target (whether it's node or web), but in general, Webpack creates a NormalModule for each dependency within the rollbar package. For example, when resolving rollbar -> package.json[main], it resolves to rollbar/src/server/rollbar.js. This rollbar.js file requires another 10 modules, which in turn may have their own dependencies.

We can use prebuilt libraries and load them from CDNs or use prebuilt libraries that don't require any changes. A prime example of this is zone.js, which is always included in the compilation. Since zone.js remains constant and doesn't need to be built or minified during each build, we can take advantage of prebuilt versions. Here are the steps you need to follow to use a prebuilt zone.js:

  • remove the app/src/polyfills.ts file (if you only do import 'zone.js' there)
  • remove src/polyfills.ts from tsconfig.app.json[files]
  • remove the polyfills entry from Webpack config:
    module.exports = config => {
      if (config.entry.polyfills) {
        delete config.entry.polyfills;
      }
      return config;
    };
    
  • add this to index.html -> <head> (the code snippet has been copied from CloudFlare):
    <script
      src="https://cdnjs.cloudflare.com/ajax/libs/zone.js/0.13.1/zone.min.js"
      integrity="sha512-+cWrFbFSw+41uXDoayGA63htnbwihSP7gbW5dwd7zK2lf7uvy0TmORZPtlWr/41sHRwSZTkO3Fav7bd8fvLiqg=="
      crossorigin="anonymous"
      referrerpolicy="no-referrer"
    ></script>
    

# Prebuilding with Rollup

We can use Rollup, expose exports through the window or globalThis, and load the library through the <script>. Given the following JavaScript file that must be run through Rollup:

import Rollbar from 'rollbar';

globalThis.__prebuiltLibraries = globalThis.__prebuiltLibraries || {};
globalThis.__prebuiltLibraries.Rollbar = Rollbar;

Assume the output file name is rollbar.min.js. After Rollup has built the file, we can load it through the <script>:

declare namespace globalThis {
  const __prebuiltLibraries: {
    Rollbar: typeof import('rollbar');
  };
}

@Injectable()
class RollbarErrorHandler implements ErrorHandler {
  private readonly rollbar$ = defer(() => {
    const script = document.createElement('script');
    script.src = '/assets/js/rollbar.min.js';
    document.head.appendChild(script);
    return fromEvent(script, 'load').pipe(
      take(1),
      tap(() => script.remove()),
    );
  }).pipe(
    map(() => new globalThis.__prebuiltLibraries.Rollbar(/* options */)),
    shareReplay({ bufferSize: 1, refCount: true }),
  );

  private readonly ngZone = inject(NgZone);

  handleError(error: any) {
    this.ngZone.runOutsideAngular(() => {
      this.rollbar$.subscribe(rollbar => rollbar.error(error));
    });
  }
}

We may need a separate project where we'll only have Rollup and its dependencies for prebuilding libraries. We'll need to setup these packages:

$ yarn add rollbar
$ yarn add -D rollup @rollup/{plugin-commonjs,plugin-json,plugin-node-resolve,plugin-terser}

These plugins may be required in different scenarios when prebuilding other packages. For instance, some packages may use require('./file.json'), so we need @rollup/plugin-json. Additionally, some packages may only expose CommonJS modules, so we need @rollup/plugin-commonjs.

Now let's create the src/rollbar.js:

import Rollbar from 'rollbar';

globalThis.__prebuiltLibraries = globalThis.__prebuiltLibraries || {};
globalThis.__prebuiltLibraries.Rollbar = Rollbar;

Now let's create the configuration file rollup.config.mjs:

export default {
  input: './src/rollbar.js',
  output: {
    file: 'dist/rollbar.min.js',
    sourcemap: true,
    format: 'iife',
  },
  onwarn: message => {
    if (message.code !== 'CIRCULAR_DEPENDENCY') {
      console.warn(message);
    }
  },
  plugins: [
    terser({
      format: {
        comments: false,
      },
    }),
    nodeResolve({
      mainFields: ['browser'],
    }),
    commonjs(),
    json(),
  ],
};

Run the build:

$ yarn rollup -c rollup.config.mjs

Rollup will create the dist folder containing the rollbar.min.js and rollbar.min.js.map files. These files can be copied into the assets/js folder of any Angular app.

For unit testing purposes, we can employ a dependency inversion and extract the loading mechanism into a separate class:

@Injectable({ providedIn: 'root' })
export class RollbarLoader {
  private readonly rollbar$ = defer(() => {
    const script = document.createElement('script');
    script.src = '/assets/js/rollbar.min.js';
    document.head.appendChild(script);
    return fromEvent(script, 'load').pipe(
      take(1),
      tap(() => script.remove()),
    );
  }).pipe(shareReplay({ bufferSize: 1, refCount: true }));

  load() {
    return this.rollbar$;
  }
}

Now we can mock this class in unit tests and directly require the rollbar.min.js file. The example below demonstrates this using @ngneat/spectator:

mockProvider(RollbarLoader, {
  load() {
    require('rollbar');
    return of(null);
  },
});

Note that the rollbar module does not exist, and we need to inform our test runner about the location of the actual file. For example, if using Jest, we can edit its jest.preset.js configuration file to specify the resolution path:

const path = require('path');

module.exports = {
  moduleNameMapper: {
    rollbar: path.join(__dirname, 'path/to/app/assets/js/rollbar.min.js'),
  },
};