I recently have to think about a deployment method for a new piece of software that is written with:
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:
node_modules/
out of the box.IgnorePlugins
seems to be overkill.There are also some separate Q&A for TypeORM, but all of them seems to require the installation of ts-node
or typescript
:
Now that ormconfig.json
have been seriously deprecated from TypeORM in version 0.3.0, the data source definition is a separate JS (or even TS) file that must be bundled by Webpack.
This was tricky, since this will be a dynamic require(), so Webpack can't know how to handle it in TypeORM code. The hint is the library
key in entry point definition, without a name. You can go to the code above, but here is the excerpt:
entry: {
main: './src/main.ts',
console: "./src/console.ts",
'data-source': {
import: './src/data-source.ts',
library: {
type: 'commonjs2'
}
},
typeorm: './node_modules/typeorm/cli.js',
},
The original response was done with NestJS 6, that used Webpack 4. Since NestJS 8 uses Webpack 5, chunk split is available, and provides a better solution.
I also integrated the use of webpack-merge to have only one configuration file. This only changes the Webpack configuration that you'll see while reading.
I managed to have a good solution, that generate a self-contained RPM of 2.7 MB with the following tools:
webpack
with special configurationwebpack
, 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:
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:
shebang-loader
(that stills work as-is after 5 years!)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,
}
})
],
},
};
Since nest-cli moved to webpack 5, an interesting feature is now available: chunk split for node target.
I was also upset to manage multiple files with the same logic, so I decided to use webpack-merge to have only one configuration file.
You'll have to yarn add -D webpack-merge
and have the following webpack.config.js
// webpack.config.js
const { merge } = require("webpack-merge")
const path = require('path')
const glob = require('glob')
const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin')
const TerserPlugin = require('terser-webpack-plugin')
const MomentLocalesPlugin = require('moment-locales-webpack-plugin')
const IgnoreDynamicRequire = require('webpack-ignore-dynamic-require')
const { NODE_ENV = 'production', ENTRY, npm_lifecycle_event: lifecycle } = process.env
// Build platform don't support ?? and ?. operators
const entry = ENTRY || (lifecycle && lifecycle.match(/bundle:(?<entry>\w+)/).groups["entry"])
if (entry === undefined) {
throw new Error("ENTRY must be defined")
}
console.log(`-- Webpack <${NODE_ENV}> build for <${entry}> --`);
const BASE_CONFIG = {
target: 'node',
mode: NODE_ENV,
resolve: {
extensions: ['.ts', '.js'],
plugins: [new TsconfigPathsPlugin({ configFile: './tsconfig.build.json' })],
},
module: {
rules: [
{ test: /\.ts$/, loader: 'ts-loader' }
]
},
output: {
path: path.resolve(__dirname, 'dist/'),
filename: '[name].js',
},
}
const MIGRATION_CONFIG = {
// 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,
})
}, {}),
output: {
path: path.resolve(__dirname, 'dist/migration'),
// this is important - we want UMD (Universal Module Definition) for migration files.
libraryTarget: 'umd',
filename: '[name].js',
},
optimization: {
minimizer: [
new TerserPlugin({
terserOptions: {
mangle: true, // Note `mangle.properties` is `false` by default.
keep_classnames: true,
keep_fnames: true,
}
})
],
}
}
const TYPEORM_CONFIG = {
entry: {
typeorm: './node_modules/typeorm/cli.js'
},
externals: [
{
'pg-native': 'commonjs2 pg-native',
}
],
plugins: [
new IgnoreDynamicRequire(),
],
module: {
rules: [
{ test: /\.[tj]s$/i, loader: 'shebang-loader' }
],
},
}
const MAIN_AND_CONSOLE_CONFIG = {
entry: {
main: './src/main.ts',
console: "./src/console.ts"
},
externals: [
{
'pg-native': 'commonjs2 pg-native',
'fastify-swagger': 'commonjs2 fastify-swagger',
'@nestjs/microservices/microservices-module': 'commonjs2 @nestjs/microservices/microservices-module',
'@nestjs/websockets/socket-module': 'commonjs2 @nestjs/websockets/socket-module',
// This one is a must have to generate the swagger document, but we remove it in production
'swagger-ui-express': 'commonjs2 swagger-ui-express',
'aws-sdk': 'commonjs2 aws-sdk',
}
],
plugins: [
// We don't need moment locale
new MomentLocalesPlugin()
],
optimization: {
// Full minization doesn't work with @Module annotation
minimizer: [
new TerserPlugin({
terserOptions: {
mangle: true, // Note `mangle.properties` is `false` by default.
keep_classnames: true,
keep_fnames: true,
}
})
],
splitChunks: {
cacheGroups: {
commons: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all'
}
}
}
}
}
const withPlugins = (config) => (runtimeConfig) => ({
...config,
plugins: [
...runtimeConfig.plugins,
...(config.plugins || [])
]
})
const config = entry === "migrations" ? merge(BASE_CONFIG, MIGRATION_CONFIG)
: entry === "typeorm" ? merge(BASE_CONFIG, TYPEORM_CONFIG)
: entry === "main" ? merge(BASE_CONFIG, MAIN_AND_CONSOLE_CONFIG)
: undefined
module.exports = withPlugins(config)
With this file, the webpack configuration is chosen from the current command: bundle:main
will select the configuration for the main entry point.
You'll notice also that there are now multiple entry points in the main: main
and console
. The former is for the main application, and the latter is for a CLI helper. But they all share the same (and huge) amount of code, and Webpack 5 is able to do that with splitChunks
section. This was available in Webpack 4 but not working for node
targets.
Finally, some optimization are now fully working even with decorators (that uses reflection), when you keep class and function names.
Bundle is smaller, code is shared, package.json
is clearer, everyone is happy.
Since we now have to use a JS data source, it must be discoverable from TypeORM CLI.
const TYPEORM_CONFIG = {
entry: {
'data-source': {
// Are you ready? You must provide a data source as TypeORM cli >= 0.3
// But this will be a dynamic require(), so Webpack can't know how to handle it
// in TypeORM code. Instead, export this data source as a library.
// BUT, TypeORM expect it to be at the top level instead of a module variable, so
// we MUST remove the library name to make webpack export each variable from data source into the module
import: './src/data-source.ts',
library: {
type: 'commonjs2'
}
},
typeorm: './node_modules/typeorm/cli.js'
},
externals: [
{
'pg-native': 'commonjs2 pg-native',
}
],
plugins: [
new IgnoreDynamicRequire(),
],
module: {
rules: [
{ test: /\.[tj]s$/i, loader: 'shebang-loader' }
],
},
}
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/{}" \;
If you use TypeORM >= 0.3 with a JS data source, the sed line doesn't have to be done, since a DataSource can accept production and development sources. However, the ormconfig.json
must be replaced by the data-source.js.
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
If you use a TypeORM JS datasource, you must add -f data-source.js
to ExecStartPre
.
Final statistics:
.production.env
for the main application and ormconfig.json
for migrations)