const http = require('node:http'); const net = require('node:net'); const process = require('node:process'); const util = require('node:util'); const isPromise = require('p-is-promise'); const HttpTerminator = require('lil-http-terminator'); const debug = util.debuglog('@ladjs/graceful'); class Graceful { constructor(config) { this.config = { servers: [], brees: [], redisClients: [], mongooses: [], customHandlers: [], logger: console, timeoutMs: 5000, lilHttpTerminator: {}, ignoreHook: 'ignore_hook', hideMeta: 'hide_meta', uncaughtExceptionTimeoutMsMs: 100, ...config }; // noop logger if false if (this.config.logger === false) this.config.logger = { info() {}, warn() {}, error() {} }; // if lilHttpTerminator does not have a logger set then re-use `this.config.logger` if (!this.config.lilHttpTerminator.logger) this.config.lilHttpTerminator.logger = this.config.logger; // prevent multiple SIGTERM/SIGHUP/SIGINT from firing graceful exit this._isExiting = false; // // create instances of HTTP terminator in advance for faster shutdown // for (const server of this.config.servers) { // backwards compatible support (get the right http or net server object instance) let serverInstance = server; if (serverInstance.server instanceof net.Server) serverInstance = serverInstance.server; else if (!(serverInstance instanceof net.Server)) throw new Error('Servers passed must be instances of net.Server'); if (serverInstance instanceof http.Server) { server.terminator = new HttpTerminator({ server: serverInstance, ...this.config.lilHttpTerminator }); } } // bind this to everything this.listen = this.listen.bind(this); this.stopServer = this.stopServer.bind(this); this.stopServers = this.stopServers.bind(this); this.stopRedisClient = this.stopRedisClient.bind(this); this.stopRedisClients = this.stopRedisClients.bind(this); this.stopMongoose = this.stopMongoose.bind(this); this.stopMongooses = this.stopMongooses.bind(this); this.stopBree = this.stopBree.bind(this); this.stopBrees = this.stopBrees.bind(this); this.stopCustomHandler = this.stopCustomHandler.bind(this); this.stopCustomHandlers = this.stopCustomHandlers.bind(this); this.exit = this.exit.bind(this); } listen() { // handle warnings process.on('warning', (warning) => { // warning.emitter = null; if (this.config.hideMeta) this.config.logger.warn(warning, { [this.config.hideMeta]: true }); else this.config.logger.warn(warning); }); // handle uncaught promises // process.on('unhandledRejection', (err) => { // always log to console the error (e.g. so we can see it on pm2 logs) console.error(err); // we want to throw so that processes exit or bubble up to middleware error handling // we need to support listening to unhandledRejections (backward compatibility) // (even though node is deprecating this in future versions) // throw err; }); // handle uncaught exceptions // NOTE: this was commented out since we monitor `uncaughtException` instead // process.on('uncaughtExceptionMonitor', (err, origin) => { // console.error(err, { origin }); // this.config.logger.fatal(err, { origin }); // }); // // NOTE: due to undici having core issues across various node versions // we unfortunately have to listen for it specifically here // (e.g. `ConnectTimeoutError` causes an uncaught exception even with a custom dispatcher and using `undici.request` on Node v18) // process.on('uncaughtException', (err, origin) => { console.error(err, { origin }); this.config.logger.fatal(err, { origin }); }); // handle windows support (signals not available) // process.on('message', async (message) => { if (message === 'shutdown') { this.config.logger.info('Received shutdown message', { ...(this.config.ignoreHook ? { [this.config.ignoreHook]: true } : {}), ...(this.config.hideMeta ? { [this.config.hideMeta]: true } : {}) }); await this.exit(); } }); // handle graceful restarts for (const sig of ['SIGTERM', 'SIGHUP', 'SIGINT']) { process.once(sig, async () => { await this.exit(sig); }); } } async stopServer(server, code) { try { if (server.terminator) { // HTTP servers debug('server.terminator'); const { error } = await server.terminator.terminate(); if (error) throw error; } else if (server.stop) { // support for `stoppable` debug('server.stop'); await (isPromise(server.stop) ? server.stop() : util.promisify(server.stop).bind(server)()); } else if (server.close) { // all other servers (e.g. SMTP) debug('server.close'); await (isPromise(server.close) ? server.close() : util.promisify(server.close).bind(server)()); } } catch (err) { this.config.logger.error(err, { code, ...(this.config.ignoreHook ? { [this.config.ignoreHook]: true } : {}), ...(this.config.hideMeta ? { [this.config.hideMeta]: true } : {}) }); } } async stopServers(code) { await Promise.all( this.config.servers.map((server) => this.stopServer(server, code)) ); } async stopRedisClient(client, code) { if (client.status === 'end') return; try { await client.disconnect(); } catch (err) { this.config.logger.error(err, { code, ...(this.config.ignoreHook ? { [this.config.ignoreHook]: true } : {}), ...(this.config.hideMeta ? { [this.config.hideMeta]: true } : {}) }); } } async stopRedisClients(code) { await Promise.all( this.config.redisClients.map((client) => this.stopRedisClient(client, code) ) ); } async stopMongoose(mongoose, code) { try { await mongoose.disconnect(); } catch (err) { this.config.logger.error(err, { code, ...(this.config.ignoreHook ? { [this.config.ignoreHook]: true } : {}), ...(this.config.hideMeta ? { [this.config.hideMeta]: true } : {}) }); } } async stopMongooses(code) { await Promise.all( this.config.mongooses.map((mongoose) => this.stopMongoose(mongoose, code)) ); } async stopBree(bree, code) { try { await bree.stop(); } catch (err) { this.config.logger.error(err, { code, ...(this.config.ignoreHook ? { [this.config.ignoreHook]: true } : {}), ...(this.config.hideMeta ? { [this.config.hideMeta]: true } : {}) }); } } async stopBrees(code) { await Promise.all( this.config.brees.map((bree) => this.stopBree(bree, code)) ); } async stopCustomHandler(handler, code) { try { await handler(); } catch (err) { this.config.logger.error(err, { code, ...(this.config.ignoreHook ? { [this.config.ignoreHook]: true } : {}), ...(this.config.hideMeta ? { [this.config.hideMeta]: true } : {}) }); } } stopCustomHandlers(code) { return Promise.all( this.config.customHandlers.map((handler) => this.stopCustomHandler(handler, code) ) ); } async exit(code) { if (code) this.config.logger.info('Gracefully exiting', { code, ...(this.config.ignoreHook ? { [this.config.ignoreHook]: true } : {}), ...(this.config.hideMeta ? { [this.config.hideMeta]: true } : {}) }); if (this._isExiting) { this.config.logger.info('Graceful exit already in progress', { code, ...(this.config.ignoreHook ? { [this.config.ignoreHook]: true } : {}), ...(this.config.hideMeta ? { [this.config.hideMeta]: true } : {}) }); return; } this._isExiting = true; // give it only X ms to gracefully exit setTimeout(() => { this.config.logger.error( new Error( `Graceful exit failed, timeout of ${this.config.timeoutMs}ms was exceeded` ), { code, ...(this.config.ignoreHook ? { [this.config.ignoreHook]: true } : {}), ...(this.config.hideMeta ? { [this.config.hideMeta]: true } : {}) } ); // eslint-disable-next-line unicorn/no-process-exit process.exit(1); }, this.config.timeoutMs); try { await Promise.all([ // servers this.stopServers(code), // brees this.stopBrees(code), // custom handlers this.stopCustomHandlers(code) ]); // // don't stop redis/mongoose until all other operations have ended // (a lot of time the server cleanup will release counters/limiters) // (or the job scheduler or application-layer code will require DB connections) // (and closing them early may also cause uncaught exceptions) // await Promise.all([ // redisClients this.stopRedisClients(code), // mongooses this.stopMongooses(code) ]); this.config.logger.info('Gracefully exited', { code, ...(this.config.ignoreHook ? { [this.config.ignoreHook]: true } : {}), ...(this.config.hideMeta ? { [this.config.hideMeta]: true } : {}) }); // eslint-disable-next-line unicorn/no-process-exit process.exit(0); } catch (err) { this.config.logger.error(err, { code, ...(this.config.ignoreHook ? { [this.config.ignoreHook]: true } : {}), ...(this.config.hideMeta ? { [this.config.hideMeta]: true } : {}) }); // eslint-disable-next-line unicorn/no-process-exit process.exit(1); } } } module.exports = Graceful;