Skip to content

Commit

Permalink
feat: change @fastify/restartable api (#40)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
ivan-tymoshenko authored May 5, 2023
1 parent 637fe99 commit 4e9fc3c
Show file tree
Hide file tree
Showing 9 changed files with 871 additions and 400 deletions.
37 changes: 18 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 11 additions & 11 deletions example.mjs
Original file line number Diff line number Diff line change
@@ -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()
})
244 changes: 153 additions & 91 deletions index.js
Original file line number Diff line number Diff line change
@@ -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
9 changes: 0 additions & 9 deletions lib/errors.js

This file was deleted.

Loading

0 comments on commit 4e9fc3c

Please sign in to comment.