From 4e9fc3cf8bb8671b9ebb868f8ad9a021247bef48 Mon Sep 17 00:00:00 2001 From: Ivan Tymoshenko Date: Fri, 5 May 2023 14:30:15 +0200 Subject: [PATCH] feat: change @fastify/restartable api (#40) * feat: change @fastify/restartable api * feat: add backward compatibility for older nodejs versions * feat: make fastify factory default option * docs: update readme example * feat: execute one restart call at the time * test: remove async setTimeout * test: use common port when set it explicitly * fix: set new app after restart * feat: add reference to a persistant app instance * feat: use prototype substitution instead of mutable proxy * test: server close event should be emitted only after closing it * fix: closing an app during a restart * feat: create first app server * test: increase timeout between tests * feat: use getters for decorated app fields * test: set keepAliveTimeout equal to 1 * feat: use preClose hook instead of patching server close method * feat: add closingRestartable status * refactor: use closingServer instead of server close counter * docs: fixed restartable import in README.md --- README.md | 37 +- example.mjs | 22 +- index.js | 244 +++++++++----- lib/errors.js | 9 - lib/server.js | 76 +++-- package.json | 9 +- test/index.test.js | 764 +++++++++++++++++++++++++++++++++--------- types/index.d.ts | 40 +-- types/index.test-d.ts | 70 ++-- 9 files changed, 871 insertions(+), 400 deletions(-) delete mode 100644 lib/errors.js diff --git a/README.md b/README.md index 5dfa792..4a44547 100644 --- a/README.md +++ b/README.md @@ -19,36 +19,35 @@ npm i @fastify/restartable ## Usage ```js -import { start } from '@fastify/restartable' +import { restartable } from '@fastify/restartable' -async function myApp (app, opts) { - // opts are the options passed to start() - console.log('plugin loaded', opts) +async function createApp (fastify, opts) { + const app = fastify(opts) - app.get('/restart', async (req, reply) => { + app.get('/restart', async () => { await app.restart() return { status: 'ok' } }) + + return app } -const { stop, restart, listen, inject } = await start({ - protocol: 'http', // or 'https' - // key: ..., - // cert: ..., - // add all other options that you would pass to fastify - hostname: '127.0.0.1', - port: 3000, - app: myApp -}) +const app = await restartable(createApp, { logger: true }) +const host = await app.listen({ port: 3000 }) -const { address, port } = await listen() +console.log('server listening on', host) -console.log('server listening on', address, port) // call restart() if you want to restart -// call restart(newOpts) if you want to restart Fastify with new options -// you can't change all the protocol details. +process.on('SIGUSR1', () => { + console.log('Restarting the server') + app.restart() +}) + +process.once('SIGINT', () => { + console.log('Stopping the server') + app.close() +}) -// call inject() to inject a request, see Fastify docs ``` ## License diff --git a/example.mjs b/example.mjs index 44e638d..3ce733f 100644 --- a/example.mjs +++ b/example.mjs @@ -1,28 +1,28 @@ -import { start } from './index.js' +import { restartable } from './index.js' -async function myApp (app, opts) { - console.log('plugin loaded') +async function createApp (fastify, opts) { + const app = fastify(opts) - app.get('/restart', async (req, reply) => { + app.get('/restart', async () => { await app.restart() return { status: 'ok' } }) + + return app } -const { stop, port, restart, address } = await start({ - port: 3000, - app: myApp -}) +const app = await restartable(createApp, { logger: true }) +const host = await app.listen({ port: 3000 }) -console.log('server listening on', address, port) +console.log('server listening on', host) // call restart() if you want to restart process.on('SIGUSR1', () => { console.log('Restarting the server') - restart() + app.restart() }) process.once('SIGINT', () => { console.log('Stopping the server') - stop() + app.close() }) diff --git a/index.js b/index.js index 333e01f..cf38d14 100644 --- a/index.js +++ b/index.js @@ -1,113 +1,175 @@ 'use strict' -const Fastify = require('fastify') -const buildServer = require('./lib/server') - -async function start (opts) { - const serverWrapper = buildServer(opts) - - let listening = false - let stopped = false - let handler - - const res = { - app: await (spinUpFastify(opts, serverWrapper, restart, true).ready()), - restart, - get address () { - if (!listening) { - throw new Error('Server is not listening') - } - return serverWrapper.address - }, - get port () { - if (!listening) { - throw new Error('Server is not listening') - } - return serverWrapper.port - }, - inject (...args) { - return res.app.inject(...args) - }, - async listen () { - await serverWrapper.listen() - listening = true - res.app.log.info({ url: `${opts.protocol || 'http'}://${serverWrapper.address}:${serverWrapper.port}` }, 'server listening') - return { - address: serverWrapper.address, - port: serverWrapper.port - } - }, - stop - } +const defaultFastify = require('fastify') +const getServerInstance = require('./lib/server') + +const closingServer = Symbol('closingServer') + +async function restartable (factory, opts, fastify = defaultFastify) { + const proxy = { then: undefined } - res.app.server.on('request', handler) + let app = await factory((opts) => createApplication(opts, false), opts) + const server = wrapServer(app.server) - return res + let newHandler = null - async function restart (_opts = opts) { - const old = res.app - const oldHandler = handler - const clientErrorListeners = old.server.listeners('clientError') - const newApp = spinUpFastify(_opts, serverWrapper, restart) + async function restart (restartOptions) { + const requestListeners = server.listeners('request') + const clientErrorListeners = server.listeners('clientError') + + let newApp = null try { - await newApp.ready() - } catch (err) { - const listenersNow = newApp.server.listeners('clientError') - handler = oldHandler - // Creating a new Fastify apps adds one clientError listener - // Let's remove all the new ones - for (const listener of listenersNow) { - if (clientErrorListeners.indexOf(listener) === -1) { - old.server.removeListener('clientError', listener) - } + newApp = await factory(createApplication, opts, restartOptions) + if (server.listening) { + const { port, address } = server.address() + await newApp.listen({ port, host: address }) + } else { + await newApp.ready() } - await newApp.close() - throw err - } + } catch (error) { + restoreClientErrorListeners(server, clientErrorListeners) - // Remove the old handler and add the new one - // the handler variable was updated in the spinUpFastify function - old.server.removeListener('request', oldHandler) - newApp.server.on('request', handler) - for (const listener of clientErrorListeners) { - old.server.removeListener('clientError', listener) + // In case if fastify.listen() would throw an error + // istanbul ignore next + if (newApp !== null) { + await closeApplication(newApp) + } + throw error } - res.app = newApp - await old.close() + server.on('request', newHandler) + + removeRequestListeners(server, requestListeners) + removeClientErrorListeners(server, clientErrorListeners) + + Object.setPrototypeOf(proxy, newApp) + await closeApplication(app) + + app = newApp } - async function stop () { - if (stopped) { - return + let debounce = null + // TODO: think about queueing restarts with different options + async function debounceRestart (...args) { + if (debounce === null) { + debounce = restart(...args).finally(() => { debounce = null }) } - stopped = true - const toClose = [] - if (listening) { - toClose.push(serverWrapper.close()) - } - toClose.push(res.app.close()) - await Promise.all(toClose) - res.app.log.info('server stopped') + return debounce } - function spinUpFastify (opts, serverWrapper, restart, isStart = false) { - const server = serverWrapper.server - const _opts = Object.assign({}, opts) - _opts.serverFactory = function (_handler) { - handler = _handler - return server + let serverCloseCounter = 0 + let closingRestartable = false + + function createApplication (newOpts, isRestarted = true) { + opts = newOpts + + let createServerCounter = 0 + function serverFactory (handler, options) { + // this cause an uncaughtException because of the bug in Fastify + // see: https://github.com/fastify/fastify/issues/4730 + // istanbul ignore next + if (++createServerCounter > 1) { + throw new Error( + 'Cannot create multiple server bindings for a restartable application. ' + + 'Please specify an IP address as a host parameter to the fastify.listen()' + ) + } + + if (isRestarted) { + newHandler = handler + return server + } + return getServerInstance(options, handler) + } + + const app = fastify({ ...newOpts, serverFactory }) + + if (!isRestarted) { + Object.setPrototypeOf(proxy, app) } - const app = Fastify(_opts) - app.decorate('restart', restart) - app.decorate('restarted', !isStart) - app.register(opts.app, opts) + app.decorate('restart', debounceRestart) + app.decorate('restarted', { + getter: () => isRestarted + }) + app.decorate('persistentRef', { + getter: () => proxy + }) + app.decorate('closingRestartable', { + getter: () => closingRestartable + }) + + app.addHook('preClose', async () => { + if (++serverCloseCounter > 0) { + closingRestartable = true + server[closingServer] = true + } + }) return app } + + async function closeApplication (app) { + serverCloseCounter-- + await app.close() + } + + return proxy +} + +function wrapServer (server) { + const _listen = server.listen.bind(server) + + server.listen = (...args) => { + const cb = args[args.length - 1] + return server.listening ? cb() : _listen(...args) + } + + server[closingServer] = false + + const _close = server.close.bind(server) + server.close = (cb) => server[closingServer] ? _close(cb) : cb() + + // istanbul ignore next + // closeAllConnections was added in Nodejs v18.2.0 + if (server.closeAllConnections) { + const _closeAllConnections = server.closeAllConnections.bind(server) + server.closeAllConnections = () => server[closingServer] && _closeAllConnections() + } + + // istanbul ignore next + // closeIdleConnections was added in Nodejs v18.2.0 + if (server.closeIdleConnections) { + const _closeIdleConnections = server.closeIdleConnections.bind(server) + server.closeIdleConnections = () => server[closingServer] && _closeIdleConnections() + } + + return server +} + +function removeRequestListeners (server, listeners) { + for (const listener of listeners) { + server.removeListener('request', listener) + } +} + +function removeClientErrorListeners (server, listeners) { + for (const listener of listeners) { + server.removeListener('clientError', listener) + } +} + +function restoreClientErrorListeners (server, oldListeners) { + // Creating a new Fastify apps adds one clientError listener + // Let's remove all the new ones + const listeners = server.listeners('clientError') + for (const listener of listeners) { + if (!oldListeners.includes(listener)) { + server.removeListener('clientError', listener) + } + } } -module.exports = start -module.exports.default = start -module.exports.start = start +module.exports = restartable +module.exports.default = restartable +module.exports.restartable = restartable diff --git a/lib/errors.js b/lib/errors.js deleted file mode 100644 index baffd0e..0000000 --- a/lib/errors.js +++ /dev/null @@ -1,9 +0,0 @@ -'use strict' - -const createError = require('@fastify/error') - -const FST_RST_UNKNOWN_PROTOCOL = createError('FST_RST_UNKNOWN_PROTOCOL', 'Unknown Protocol %s') - -module.exports = { - FST_RST_UNKNOWN_PROTOCOL -} diff --git a/lib/server.js b/lib/server.js index b8fffb7..9d60ea6 100644 --- a/lib/server.js +++ b/lib/server.js @@ -2,40 +2,54 @@ const http = require('http') const https = require('https') -const { once } = require('events') -const { FST_RST_UNKNOWN_PROTOCOL } = require('./errors') -module.exports = function buildServer ({ protocol = 'http', port, hostname = '127.0.0.1', key, cert }) { - let server +const { FST_ERR_HTTP2_INVALID_VERSION } = require('fastify').errorCodes - switch (protocol) { - case 'http': - server = http.createServer() - break - case 'https': - server = https.createServer({ key, cert }) - break - default: - throw new FST_RST_UNKNOWN_PROTOCOL(protocol) +function getServerInstance (options, httpHandler) { + let server = null + if (options.http2) { + if (options.https) { + server = http2().createSecureServer(options.https, httpHandler) + } else { + server = http2().createServer(httpHandler) + } + server.on('session', sessionTimeout(options.http2SessionTimeout)) + } else { + // this is http1 + if (options.https) { + server = https.createServer(options.https, httpHandler) + } else { + server = http.createServer(options.http, httpHandler) + } + server.keepAliveTimeout = options.keepAliveTimeout + server.requestTimeout = options.requestTimeout + // we treat zero as null + // and null is the default setting from nodejs + // so we do not pass the option to server + if (options.maxRequestsPerSocket > 0) { + server.maxRequestsPerSocket = options.maxRequestsPerSocket + } } + return server +} - return { - server, - protocol, - get address () { - return server.address().address - }, - get port () { - return server.address().port - }, - async listen () { - server.listen(port, hostname) - await once(server, 'listening') - return server.address() - }, - async close () { - server.close() - await once(server, 'close') - } +function http2 () { + try { + return require('http2') + } catch (err) { + // istanbul ignore next + throw new FST_ERR_HTTP2_INVALID_VERSION() } } + +function sessionTimeout (timeout) { + return function (session) { + session.setTimeout(timeout, close) + } +} + +function close () { + this.close() +} + +module.exports = getServerInstance diff --git a/package.json b/package.json index d95174a..5970872 100644 --- a/package.json +++ b/package.json @@ -28,18 +28,17 @@ }, "homepage": "https://github.com/fastify/restartable#readme", "devDependencies": { - "@types/node": "^18.0.6", "@fastify/pre-commit": "^2.0.2", + "@types/node": "^18.0.6", "snazzy": "^9.0.0", "split2": "^4.1.0", "standard": "^17.0.0-2", "tap": "^16.0.0", - "undici": "^5.0.0", - "tsd": "^0.28.0" + "tsd": "^0.28.0", + "undici": "^5.0.0" }, "dependencies": { - "@fastify/error": "^3.0.0", - "fastify": "^4.0.2" + "fastify": "^4.16.3" }, "pre-commit": [ "lint", diff --git a/test/index.test.js b/test/index.test.js index 0912e42..d7dbf86 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -1,315 +1,745 @@ 'use strict' -const { test } = require('tap') -const { start } = require('..') -const { request, setGlobalDispatcher, Agent } = require('undici') -const path = require('path') -const { readFile } = require('fs').promises -const split = require('split2') +const { join } = require('path') const { once } = require('events') +const { readFile } = require('fs/promises') +const http2 = require('http2') + +const t = require('tap') +const split = require('split2') +const { request, setGlobalDispatcher, Agent } = require('undici') + +const { restartable } = require('..') setGlobalDispatcher(new Agent({ - keepAliveTimeout: 10, - keepAliveMaxTimeout: 10, + keepAliveTimeout: 1, + keepAliveMaxTimeout: 1, tls: { rejectUnauthorized: false } })) -test('restart fastify', async ({ pass, teardown, plan, same, equal }) => { - plan(11) +const COMMON_PORT = 4242 - const _opts = { - port: 0, - app: myApp - } +const test = t.test +t.jobs = 1 +t.afterEach(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)) +}) + +test('should create and restart fastify app', async (t) => { + async function createApplication (fastify, opts) { + const app = fastify(opts) - async function myApp (app, opts) { - pass('application loaded') - equal(opts, _opts) app.get('/', async () => { return { hello: 'world' } }) - } - const server = await start(_opts) + return app + } - same(server.app.restarted, false) + const app = await restartable(createApplication, { + keepAliveTimeout: 1 + }) - const { stop, restart, listen } = server - teardown(stop) + t.teardown(async () => { + await app.close() + }) - const { port, address } = await listen() + const host = await app.listen({ host: '127.0.0.1', port: COMMON_PORT }) + t.equal(host, `http://127.0.0.1:${COMMON_PORT}`) + t.equal(app.addresses()[0].address, '127.0.0.1') + t.equal(app.addresses()[0].port, COMMON_PORT) - equal(address, '127.0.0.1') - equal(port, server.port) - equal(address, server.address) + t.equal(app.restarted, false) { - const res = await request(`http://127.0.0.1:${port}`) - same(await res.body.json(), { hello: 'world' }) + const res = await request(host) + t.same(await res.body.json(), { hello: 'world' }) } - await restart() - same(server.app.restarted, true) + await app.restart() + t.same(app.restarted, true) { - const res = await request(`http://127.0.0.1:${port}`) - same(await res.body.json(), { hello: 'world' }) + const res = await request(host) + t.same(await res.body.json(), { hello: 'world' }) } }) -test('https', async ({ pass, teardown, plan, same, equal }) => { - plan(5) +test('should create and restart fastify app twice', async (t) => { + t.plan(15) + + let closingRestartable = false - async function myApp (app, opts) { - pass('application loaded') - app.get('/', async (req, reply) => { + async function createApplication (fastify, opts) { + const app = fastify(opts) + + app.get('/', async () => { return { hello: 'world' } }) + + let closeCounter = 0 + app.addHook('onClose', async () => { + if (++closeCounter > 1) { + t.fail('onClose hook called more than once') + } + t.equal(app.closingRestartable, closingRestartable) + t.pass('onClose hook called') + }) + + return app } - const { listen, stop, restart } = await start({ - port: 0, - protocol: 'https', - key: await readFile(path.join(__dirname, 'fixtures', 'key.pem')), - cert: await readFile(path.join(__dirname, 'fixtures', 'cert.pem')), - app: myApp + const app = await restartable(createApplication, { + keepAliveTimeout: 1 }) - teardown(stop) - const { address, port } = await listen() + const host = await app.listen({ host: '127.0.0.1', port: COMMON_PORT }) + t.equal(host, `http://127.0.0.1:${COMMON_PORT}`) + t.equal(app.addresses()[0].address, '127.0.0.1') + t.equal(app.addresses()[0].port, COMMON_PORT) + + t.equal(app.restarted, false) + + { + const res = await request(host) + t.same(await res.body.json(), { hello: 'world' }) + } - equal(address, '127.0.0.1') + await app.restart() + t.same(app.restarted, true) { - const res = await request(`https://127.0.0.1:${port}`) - same(await res.body.json(), { hello: 'world' }) + const res = await request(host) + t.same(await res.body.json(), { hello: 'world' }) } - await restart() + await app.restart() + t.same(app.restarted, true) { - const res = await request(`https://127.0.0.1:${port}`) - same(await res.body.json(), { hello: 'world' }) + const res = await request(host) + t.same(await res.body.json(), { hello: 'world' }) } + + closingRestartable = true + await app.close() }) -test('wrong protocol', async function (t) { - await t.rejects(() => { - return start({ - port: 0, - protocol: 'foobar', - app: () => {} +test('should create and restart fastify https app', async (t) => { + async function createApplication (fastify, opts) { + const app = fastify(opts) + + app.get('/', async () => { + return { hello: 'world' } }) - }, /Unknown Protocol foobar/) + + return app + } + + const opts = { + https: { + key: await readFile(join(__dirname, 'fixtures', 'key.pem')), + cert: await readFile(join(__dirname, 'fixtures', 'cert.pem')) + }, + keepAliveTimeout: 1, + maxRequestsPerSocket: 42 + } + const app = await restartable(createApplication, opts) + + t.teardown(async () => { + await app.close() + }) + + const host = await app.listen({ host: '127.0.0.1', port: COMMON_PORT }) + t.equal(host, `https://127.0.0.1:${COMMON_PORT}`) + t.equal(app.addresses()[0].address, '127.0.0.1') + t.equal(app.addresses()[0].port, COMMON_PORT) + + { + const res = await request(host) + t.same(await res.body.json(), { hello: 'world' }) + } + + await app.restart() + t.same(app.restarted, true) + + { + const res = await request(host) + t.same(await res.body.json(), { hello: 'world' }) + } }) -test('restart from a route', async ({ pass, teardown, plan, same, equal }) => { - plan(3) +test('should create and restart fastify http2 app', async (t) => { + async function createApplication (fastify, opts) { + const app = fastify(opts) - async function myApp (app, opts) { - pass('application loaded') - app.get('/restart', async (req, reply) => { - await app.restart() + app.get('/', async () => { return { hello: 'world' } }) + + return app + } + + const opts = { + http2: true, + http2SessionTimeout: 1000, + keepAliveTimeout: 1 } + const app = await restartable(createApplication, opts) - const { stop, listen } = await start({ - port: 0, - app: myApp + t.teardown(async () => { + await app.close() }) - teardown(stop) - const { port } = await listen() + const host = await app.listen({ host: '127.0.0.1', port: COMMON_PORT }) + t.equal(host, `http://127.0.0.1:${COMMON_PORT}`) + t.equal(app.addresses()[0].address, '127.0.0.1') + t.equal(app.addresses()[0].port, COMMON_PORT) + + const client = http2.connect(host) + + t.teardown(() => { + client.close() + }) { - const res = await request(`http://127.0.0.1:${port}/restart`) - same(await res.body.json(), { hello: 'world' }) + const req = client.request({ ':path': '/' }) + req.setEncoding('utf8') + + let data = '' + req.on('data', (chunk) => { data += chunk }) + await once(req, 'end') + req.end() + + t.same(JSON.parse(data), { hello: 'world' }) + } + + await app.restart() + t.same(app.restarted, true) + + { + const req = client.request({ ':path': '/' }) + req.setEncoding('utf8') + + let data = '' + req.on('data', (chunk) => { data += chunk }) + await once(req, 'end') + req.end() + + t.same(JSON.parse(data), { hello: 'world' }) } }) -test('inject', async ({ pass, teardown, plan, same, equal }) => { - plan(3) +test('should create and restart fastify https2 app', async (t) => { + async function createApplication (fastify, opts) { + const app = fastify(opts) + + app.get('/', async () => { + return { hello: 'world' } + }) - async function myApp (app, opts) { - pass('application loaded') - app.get('/restart', async (req, reply) => { + return app + } + + const opts = { + http2: true, + https: { + key: await readFile(join(__dirname, 'fixtures', 'key.pem')), + cert: await readFile(join(__dirname, 'fixtures', 'cert.pem')) + }, + keepAliveTimeout: 1 + } + const app = await restartable(createApplication, opts) + + t.teardown(async () => { + await app.close() + }) + + const host = await app.listen({ host: '127.0.0.1', port: COMMON_PORT }) + t.equal(host, `https://127.0.0.1:${COMMON_PORT}`) + t.equal(app.addresses()[0].address, '127.0.0.1') + t.equal(app.addresses()[0].port, COMMON_PORT) + + await app.restart() + t.same(app.restarted, true) +}) + +test('should restart an app from a route handler', async (t) => { + async function createApplication (fastify, opts) { + const app = fastify(opts) + + app.get('/restart', async () => { await app.restart() return { hello: 'world' } }) + + return app } - const { stop, inject } = await start({ - port: 0, - app: myApp + const app = await restartable(createApplication, { + keepAliveTimeout: 1 + }) + + t.teardown(async () => { + await app.close() }) - teardown(stop) + + const host = await app.listen({ host: '127.0.0.1', port: 0 }) { - const res = await inject('/restart') - same(res.json(), { hello: 'world' }) + const res = await request(`${host}/restart`) + t.same(await res.body.json(), { hello: 'world' }) + } + + t.same(app.restarted, true) + + { + const res = await request(`${host}/restart`) + t.same(await res.body.json(), { hello: 'world' }) } }) -test('not listening', async function (t) { - const res = await start({ - app: async () => {} +test('should restart an app from inject call', async (t) => { + async function createApplication (fastify, opts) { + const app = fastify(opts) + + app.get('/restart', async () => { + await app.restart() + return { hello: 'world' } + }) + + return app + } + + const app = await restartable(createApplication, { + keepAliveTimeout: 1 }) + t.same(app.server.listening, false) - t.throws(() => res.address, /not listening/) - t.throws(() => res.port, /not listening/) -}) + { + const res = await app.inject('/restart') + t.same(res.json(), { hello: 'world' }) + } -test('logger', async ({ pass, teardown, equal }) => { - const stream = split(JSON.parse) + t.same(app.restarted, true) + t.same(app.server.listening, false) - const _opts = { - port: 0, - app: myApp, - logger: { - stream - } + { + const res = await app.inject('/restart') + t.same(res.json(), { hello: 'world' }) } +}) + +test('logger', async (t) => { + async function createApplication (fastify, opts) { + const app = fastify(opts) - async function myApp (app, opts) { - pass('application loaded') - equal(opts, _opts) app.get('/', async () => { return { hello: 'world' } }) + + return app } - const server = await start(_opts) - const { stop, listen } = server - teardown(stop) + const stream = split(JSON.parse) + const opts = { + logger: { stream }, + keepAliveTimeout: 1 + } - const { port, address } = await listen() + const app = await restartable(createApplication, opts) - { - const [{ level, msg, url }] = await once(stream, 'data') - equal(level, 30) - equal(url, `http://${address}:${port}`) - equal(msg, 'server listening') - } + t.teardown(async () => { + await app.close() + }) - await stop() + const host = await app.listen({ host: '127.0.0.1', port: 0 }) { const [{ level, msg }] = await once(stream, 'data') - equal(level, 30) - equal(msg, 'server stopped') + t.equal(level, 30) + t.equal(msg, `Server listening at ${host}`) } }) -test('change opts', async ({ teardown, plan, equal }) => { - plan(2) - - const _opts = { - port: 0, - app: myApp +test('should save new default options after restart', async (t) => { + const opts1 = { + keepAliveTimeout: 1, + requestTimeout: 1000 + } + const opts2 = { + keepAliveTimeout: 1, + requestTimeout: 2000 } - const expected = [undefined, 'bar'] + let restartCounter = 0 + const expectedOpts = [opts1, opts2] - async function myApp (_, opts) { - equal(opts.foo, expected.shift()) - } + async function createApplication (fastify, opts) { + const expected = expectedOpts[restartCounter++] + t.same(opts, expected) + + const newOpts = expectedOpts[restartCounter] + const app = fastify(newOpts) + + app.get('/', async () => { + return { hello: 'world' } + }) - const server = await start(_opts) - const { stop, restart, listen } = server - teardown(stop) + return app + } - await listen() + const app = await restartable(createApplication, opts1) - await restart({ - foo: 'bar', - app: myApp + t.teardown(async () => { + await app.close() }) + + await app.listen({ host: '127.0.0.1', port: 0 }) + await app.restart() }) -test('no warnings', async ({ pass, teardown, plan, same, equal, fail }) => { - plan(12) +test('should send a restart options', async (t) => { + const restartOpts1 = undefined + const restartOpts2 = { foo: 'bar' } + + let restartCounter = 0 + const expectedOpts = [restartOpts1, restartOpts2] - const _opts = { - port: 0, - app: myApp + async function createApplication (fastify, opts, restartOpts) { + const expected = expectedOpts[restartCounter++] + t.same(restartOpts, expected) + + const app = fastify(opts) + + app.get('/', async () => { + return { hello: 'world' } + }) + + return app } - async function myApp (app, opts) { - pass('application loaded') + const app = await restartable(createApplication, { + keepAliveTimeout: 1 + }) + + t.teardown(async () => { + await app.close() + }) + + await app.listen({ host: '127.0.0.1', port: 0 }) + await app.restart(restartOpts2) +}) + +test('no warnings', async (t) => { + async function createApplication (fastify, opts) { + const app = fastify(opts) + + app.get('/', async () => { + return { hello: 'world' } + }) + + return app } const onWarning = (warning) => { - fail(warning.message) + t.fail(warning.message) } process.on('warning', onWarning) - teardown(() => { + + t.teardown(async () => { process.removeListener('warning', onWarning) }) - const server = await start(_opts) - const { stop, restart, listen } = server - teardown(stop) + const app = await restartable(createApplication) + + t.teardown(async () => { + await app.close() + }) - await listen() + await app.listen({ host: '127.0.0.1', port: 0 }) for (let i = 0; i < 11; i++) { - await restart() + await app.restart() } }) -test('restart fastify after a failed start', async ({ pass, teardown, plan, same, equal, rejects }) => { - plan(11) - - const _opts = { - port: 0, - app: myApp - } - +test('should not restart fastify after a failed start', async (t) => { let count = 0 - async function myApp (app, opts) { - pass('application loaded') - equal(opts, _opts) + async function createApplication (fastify, opts) { + const app = fastify(opts) app.register(async function () { if (count++ % 2) { throw new Error('kaboom') } }) + app.get('/', async () => { return { hello: 'world' } }) + + return app } - const server = await start(_opts) + const app = await restartable(createApplication, { + keepAliveTimeout: 1 + }) - same(server.app.restarted, false) + t.same(app.restarted, false) - const { stop, restart, listen } = server - teardown(stop) + t.teardown(async () => { + await app.close() + }) - const { port } = await listen() + const host = await app.listen({ host: '127.0.0.1', port: 0 }) { - const res = await request(`http://127.0.0.1:${port}`) - same(await res.body.json(), { hello: 'world' }) + const res = await request(host) + t.same(await res.body.json(), { hello: 'world' }) } - await rejects(restart()) + await t.rejects(app.restart()) { - const res = await request(`http://127.0.0.1:${port}`) - same(await res.body.json(), { hello: 'world' }) + const res = await request(host) + t.same(await res.body.json(), { hello: 'world' }) + } + + await app.restart() + + const res = await request(host, { method: 'GET' }) + t.same(await res.body.json(), { hello: 'world' }) +}) + +test('should create and restart fastify app with forceCloseConnections', async (t) => { + async function createApplication (fastify, opts) { + const app = fastify(opts) + + app.get('/', async () => { + return { hello: 'world' } + }) + + return app + } + + const app = await restartable(createApplication, { + forceCloseConnections: true, + keepAliveTimeout: 1 + }) + + t.teardown(async () => { + await app.close() + }) + + const host = await app.listen({ host: '127.0.0.1', port: 0 }) + t.equal(app.restarted, false) + + { + const res = await request(host) + t.same(await res.body.json(), { hello: 'world' }) + } + + await app.restart() + t.same(app.restarted, true) + + { + const res = await request(host) + t.same(await res.body.json(), { hello: 'world' }) + } +}) + +test('should not set the server handler before application is ready', async (t) => { + let restartCounter = 0 + + async function createApplication (fastify, opts) { + const app = fastify(opts) + + if (app.restarted) { + const res = await request(host) + t.same(await res.body.json(), { version: 1 }) + } + + app.get('/', async () => { + return { version: restartCounter } + }) + + restartCounter++ + return app + } + + const app = await restartable(createApplication, { + keepAliveTimeout: 1 + }) + + t.teardown(async () => { + await app.close() + }) + + const host = await app.listen({ host: '127.0.0.1', port: 0 }) + + { + const res = await request(host) + t.same(await res.body.json(), { version: 1 }) + } + + await app.restart() + t.same(app.restarted, true) + + { + const res = await request(host) + t.same(await res.body.json(), { version: 2 }) + } +}) + +test('should not restart an application multiple times simultaneously', async (t) => { + let startCounter = 0 + + async function createApplication (fastify, opts) { + startCounter++ + + const app = fastify(opts) + + app.get('/', async () => { + return { hello: 'world' } + }) + + await new Promise((resolve) => setTimeout(resolve, 500)) + return app } - await restart() + const app = await restartable(createApplication, { + keepAliveTimeout: 1 + }) + + t.teardown(async () => { + await app.close() + }) + + const host = await app.listen({ host: '127.0.0.1', port: 0 }) + + await Promise.all([ + app.restart(), + app.restart(), + app.restart(), + app.restart(), + app.restart() + ]) + + t.same(app.restarted, true) + t.same(startCounter, 2) { - const res = await request(`http://127.0.0.1:${port}`) - same(await res.body.json(), { hello: 'world' }) + const res = await request(host) + t.same(await res.body.json(), { hello: 'world' }) } }) + +test('should contain a persistentRef property', async (t) => { + let firstPersistentRef = null + + async function createApplication (fastify, opts) { + const app = fastify(opts) + + if (app.restarted) { + t.equal(app.persistentRef, proxy) + } else { + firstPersistentRef = app.persistentRef + } + + return app + } + + const proxy = await restartable(createApplication, { + keepAliveTimeout: 1 + }) + + t.equal(firstPersistentRef, proxy) + + t.teardown(async () => { + await proxy.close() + }) + + await proxy.listen({ host: '127.0.0.1', port: 0 }) + + t.equal(proxy.persistentRef, proxy) + + await proxy.restart() + + t.equal(proxy.persistentRef, proxy) +}) + +test('server close event should be emitted only when after closing server', async (t) => { + t.plan(2) + + async function createApplication (fastify, opts) { + return fastify(opts) + } + + const app = await restartable(createApplication, { + keepAliveTimeout: 1 + }) + await app.listen({ host: '127.0.0.1', port: 0 }) + + t.ok(app.server.listening) + + app.server.on('close', () => { + t.pass('server close event emitted') + }) + + await app.restart() + await app.restart() + await app.restart() + await app.restart() + await app.restart() + + await app.close() +}) + +test('should close application during the restart', async (t) => { + async function createApplication (fastify, opts) { + const app = fastify(opts) + + app.addHook('onClose', async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)) + }) + + return app + } + + const app = await restartable(createApplication, { + keepAliveTimeout: 1 + }) + await app.listen({ host: '127.0.0.1', port: 0 }) + + t.ok(app.server.listening) + + app.restart() + await new Promise((resolve) => setTimeout(resolve, 500)) + await app.close() + + t.ok(!app.server.listening) +}) + +test('should restart an app before listening', async (t) => { + async function createApplication (fastify, opts) { + return fastify(opts) + } + + const app = await restartable(createApplication, { + keepAliveTimeout: 1 + }) + + await app.restart() + t.ok(app.restarted) + + await app.listen({ host: '127.0.0.1', port: 0 }) + t.ok(app.server.listening) + + await app.close() + t.ok(!app.server.listening) +}) diff --git a/types/index.d.ts b/types/index.d.ts index b9797b7..fab209b 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1,42 +1,14 @@ -import { - FastifyInstance, - FastifyServerOptions, - InjectOptions, - LightMyRequestResponse, -} from 'fastify' +import { fastify, FastifyInstance, FastifyServerOptions } from 'fastify' declare module 'fastify' { interface FastifyInstance { - restart: () => Promise + restart: (restartOpts?: unknown) => Promise, + restarted: boolean } } -type FastifyRestartable = (opts: start.FastifyRestartableOptions) => Promise<{ - app: FastifyInstance - address: string - port: number - restart: () => Promise - listen: () => Promise<{ - address: string - port: number - }> - stop: () => Promise - inject: (opts: InjectOptions | string) => Promise -}> +type Fastify = typeof fastify; -declare namespace start { - export type FastifyRestartableOptions = FastifyServerOptions & { - port: number - hostname?: string - protocol?: 'http' | 'https' - key?: string - cert?: string - app: (app: FastifyInstance, opts: FastifyRestartableOptions) => Promise - } - - export const start: FastifyRestartable - export { start as default } -} +export type ApplicationFactory = (fastify: Fastify, opts: FastifyServerOptions, restartOpts?: unknown) => Promise -declare function start(...params: Parameters): ReturnType -export = start +export declare function restartable(factory: ApplicationFactory, opts?: FastifyServerOptions, fastify?: Fastify): Promise diff --git a/types/index.test-d.ts b/types/index.test-d.ts index e9bce19..fabcb23 100644 --- a/types/index.test-d.ts +++ b/types/index.test-d.ts @@ -1,40 +1,44 @@ -import { FastifyInstance, LightMyRequestResponse } from 'fastify' -import { expectAssignable, expectType, expectError } from 'tsd' -import { FastifyRestartableOptions, start } from '..' - -const myApp = async (app: FastifyInstance, opts: FastifyRestartableOptions) => { - app.get('/', async () => { - expectType<() => Promise>(app.restart) - return { hello: 'world' } - }) -} +import { fastify, FastifyInstance, FastifyServerOptions } from 'fastify' +import { expectAssignable, expectType } from 'tsd' +import { restartable, ApplicationFactory } from './index' -const _badProtocol = { - port: 4001, - app: myApp, - protocol: 'ftp', -} -expectError(start(_badProtocol)) +type Fastify = typeof fastify -const _missingAppOpt = { - port: 4001, -} -expectError(start(_missingAppOpt)) +async function createApplication ( + fastify: Fastify, + opts: FastifyServerOptions, + restartOpts?: unknown +): Promise { + const app = fastify(opts) + + expectAssignable(app) + expectAssignable(restartOpts) + + expectType(app.restarted) + expectType<(restartOpts?: unknown) => Promise>(app.restart) -const _opts = { - port: 4001, - app: myApp, - ignoreTrailingSlash: true, + return app } -expectAssignable(_opts) +expectType(createApplication) -const restartable = await start(_opts) +{ + const app = await restartable(createApplication) + expectType(app) + expectType(app.restarted) + expectType<(restartOpts?: unknown) => Promise>(app.restart) +} + +{ + const app = await restartable(createApplication, { logger: true }) + expectType(app) + expectType(app.restarted) + expectType<(restartOpts?: unknown) => Promise>(app.restart) +} -expectType(restartable.address) -expectType(restartable.port) -expectType(restartable.app) -expectType>(restartable.restart()) -expectType>(restartable.inject('/')) -expectType>(restartable.listen()) -expectType>(restartable.stop()) +{ + const app = await restartable(createApplication, { logger: true }, fastify) + expectType(app) + expectType(app.restarted) + expectType<(restartOpts?: unknown) => Promise>(app.restart) +}