2
votes

I recently have to think about a deployment method for a new piece of software that is written with:

  • NestJS 6 / Express
  • TypeORM 0.2
  • TypeScript is used

The software will be deployed on more than 160 servers, distributed all across Europe, and some of them have very bad Internet connections.

I did some research and a lot of people explicitly advices against bundling. The main argument is that native extension will fails with bundlers like webpack or rollup (Spoiler: it's true, but there is a solution). In my opinion, it's largely due to the fact that people don't care for this: the author of node-pre-gyp used nearly the same words for this use case. So usually, I was told to either use yarn install or sync the node_modules/ folder.

The project is new, but the node_modules/ folder is already more than 480 MB. Using XZ with maximum compression gave me an archive of 20 MB. This is still way too large for me, and seems like a huge waste of resources.

I also had a look at the following Q&A:

There are also some separate Q&A for TypeORM, but all of them seems to require the installation of ts-node or typescript:

1

1 Answers

2
votes

I managed to have a good solution, that generate a self-contained RPM of 2.7 MB with the following tools:

  • webpack with special configuration
  • RPM, using webpack, in order to distribute generated files.

The software is an API server, using PostgreSQL for persistence. Users are usually authenticated using an external servers, but we can have local (emergency) users, so we use bcrypt to store and check passwords.

I have to insist: my solution does not work with native extensions. Fortunately, the popular bcrypt can be replaced with a pure JS implementation, and the most popular postgresql package is able of using both compiled or pure JS.

If want to bundle with native extension, you can try to use ncc. They managed to implement a solution for node-pre-gyp dependent packages that worked for me in some preliminary tests. Of course, the compiled extensions should match your target platform, as always for compiled stuff.

I personally chose webpack because NestJS support this in it's build command. This is merely a passthrough to the webpack compiler, but it seems to adjust some paths, so it was kind of easier.

So, how to achieve this? webpack can bundle everything in a single file, but in this use case, I need three of them:

  • The main program
  • The TypeORM migration CLI tool
  • The TypeORM migration scripts, because they can't be bundled with the tool, as it relies on filename

And since each bundling required different options… I used 3 webpack files. Here is the layout:

webpack.config.js
webpack
├── migrations.config.js
└── typeorm-cli.config.js

All those files were based of the same template kindly provided by ZenSoftware. The main difference is that I switched from IgnorePlugin to externals because that is simpler to read, and fits the use case perfectly.

// webpack.config.js
const { NODE_ENV = 'production' } = process.env;

console.log(`-- Webpack <${NODE_ENV}> build --`);

module.exports = {
  target: 'node',
  mode: NODE_ENV,
  externals: [
    // Here are listed all optional dependencies of NestJS,
    // that are not installed and not required by my project
    {
      'fastify-swagger': 'commonjs2 fastify-swagger',
      'aws-sdk': 'commonjs2 aws-sdk',
      '@nestjs/websockets/socket-module': 'commonjs2 @nestjs/websockets/socket-module',
      '@nestjs/microservices/microservices-module': 'commonjs2 @nestjs/microservices/microservices-module',
      
      // I'll skip pg-native in the production deployement, and use the pure JS implementation
      'pg-native': 'commonjs2 pg-native'
    }
  ],
  optimization: {
    // Minimization doesn't work with @Module annotation
    minimize: false,
  }
};

Configuration files for TypeORM are more verbose, because we need to explicit the use of TypeScript. Fortunately, they have some advices for this in their FAQ. However, bundling the migration tool required two more hacks:

  • Ignore the shebang at the beginning of the file. Easily solved with shebang-loader (that stills work as-is after 5 years!)
  • Force webpack not to replace require call to dynamic configuration file, used to load configuration from JSON or env files. I was guided by this QA and finally build my own package.
// webpack/typeorm-cli.config.js

const path = require('path');
// TypeScript compilation option
const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
// Don't try to replace require calls to dynamic files
const IgnoreDynamicRequire = require('webpack-ignore-dynamic-require');

const { NODE_ENV = 'production' } = process.env;

console.log(`-- Webpack <${NODE_ENV}> build for TypeORM CLI --`);

module.exports = {
  target: 'node',
  mode: NODE_ENV,
  entry: './node_modules/typeorm/cli.js',
  output: {
    // Remember that this file is in a subdirectory, so the output should be in the dist/
    // directory of the project root
    path: path.resolve(__dirname, '../dist'),
    filename: 'migration.js',
  },
  resolve: {
    extensions: ['.ts', '.js'],
    // Use the same configuration as NestJS
    plugins: [new TsconfigPathsPlugin({ configFile: './tsconfig.build.json' })],
  },
  module: {
    rules: [
      { test: /\.ts$/, loader: 'ts-loader' },
      // Skip the shebang of typeorm/cli.js
      { test: /\.[tj]s$/i, loader: 'shebang-loader' }
    ],
  },
  externals: [
    {
      // I'll skip pg-native in the production deployement, and use the pure JS implementation
      'pg-native': 'commonjs2 pg-native'
    }
  ],
  plugins: [
    // Let NodeJS handle are requires that can't be resolved at build time
    new IgnoreDynamicRequire()
  ]
};
// webpack/migrations.config.js

const glob = require('glob');
const path = require('path');
// TypeScript compilation option
const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
// Minimization option
const TerserPlugin = require('terser-webpack-plugin');

const { NODE_ENV = 'production' } = process.env;

console.log(`-- Webpack <${NODE_ENV}> build for migrations scripts --`);

module.exports = {
  target: 'node',
  mode: NODE_ENV,
  // Dynamically generate a `{ [name]: sourceFileName }` map for the `entry` option
  // change `src/db/migrations` to the relative path to your migration folder
  entry: glob.sync(path.resolve('src/migration/*.ts')).reduce((entries, filename) => {
    const migrationName = path.basename(filename, '.ts');
    return Object.assign({}, entries, {
      [migrationName]: filename,
    });
  }, {}),
  resolve: {
    // assuming all your migration files are written in TypeScript
    extensions: ['.ts'],
    // Use the same configuration as NestJS
    plugins: [new TsconfigPathsPlugin({ configFile: './tsconfig.build.json' })],
  },
  module: {
    rules: [
      { test: /\.ts$/, loader: 'ts-loader' }
    ]
  },
  output: {
    // Remember that this file is in a subdirectory, so the output should be in the dist/
    // directory of the project root
    path: __dirname + '/../dist/migration',
    // this is important - we want UMD (Universal Module Definition) for migration files.
    libraryTarget: 'umd',
    filename: '[name].js',
  },
  optimization: {
    minimizer: [
      // Migrations rely on class and function names, so keep them.
      new TerserPlugin({
        terserOptions: {
          mangle: true, // Note `mangle.properties` is `false` by default.
          keep_classnames: true,
          keep_fnames: true,
        }
      })
    ],
  },
};

After that, to simplify the build process I added some targets in package.json:

{
  "scripts": {
    "bundle:application": "nest build --webpack",
    "bundle:migrations": "nest build --webpack --webpackPath webpack/typeorm-cli.config.js && nest build --webpack --webpackPath webpack/migrations.config.js",
    "bundle": "yarn bundle:application && yarn bundle:migrations"
  },
}

And… you're nearly done. You can call yarn bundle, and the output will kindly be built in the dist/ directory. I didn't managed to remove some TypeScript definition files that were generated, but that wasn't a real issue.

The final step was writing the RPM specification file:

%build
mkdir yarncache
export YARN_CACHE_FOLDER=yarncache

# Setting to avoid node-gype trying to download headers
export npm_config_nodedir=/opt/rh/rh-nodejs10/root/usr/

%{_yarnbin} install --offline --non-interactive --frozen-lockfile
%{_yarnbin} bundle

rm -r yarncache/

%install
install -D -m644 dist/main.js $RPM_BUILD_ROOT%{app_path}/main.js

install -D -m644 dist/migration.js $RPM_BUILD_ROOT%{app_path}/migration.js
# Migration path have to be changed, let's hack it.
sed -ie 's/src\/migration\/\*\.ts/migration\/*.js/' ormconfig.json
install -D -m644 ormconfig.json $RPM_BUILD_ROOT%{app_path}/ormconfig.json
find dist/migration -name '*.js' -execdir install -D -m644 "{}" "$RPM_BUILD_ROOT%{app_path}/migration/{}" \;

And the systemd service file can give you how to launch this. The target platform is CentOS7, so I have to use NodeJS 10 from software collections. You can adapt the path to your NodeJS binary.

[Unit]
Description=NestJS Server
After=network.target

[Service]
Type=simple
User=nestjs
Environment=SCLNAME=rh-nodejs10
ExecStartPre=/usr/bin/scl enable $SCLNAME -- /usr/bin/env node migration migration:run
ExecStart=/usr/bin/scl enable $SCLNAME -- /usr/bin/env node main
WorkingDirectory=/export/myapplication
Restart=on-failure

# Hardening
PrivateTmp=true
NoNewPrivileges=true
ProtectSystem=full
ProtectHome=read-only

[Install]
WantedBy=multi-user.target

Final statistics:

  • Build time 3 minutes 30 seconds on a dual core virtual machine.
  • RPM size is 2.70 MB, self contained, with 3 JavaScript files, and 2 configuration files (.production.env for the main application and ormconfig.json for migrations)