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:
// 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)