UNPKG

fastify

Version:

Fast and low overhead web framework, for Node.js

952 lines (801 loc) 27.9 kB
'use strict' const FindMyWay = require('find-my-way') const avvio = require('avvio') const http = require('http') const https = require('https') const Middie = require('middie') const lightMyRequest = require('light-my-request') const abstractLogging = require('abstract-logging') const proxyAddr = require('proxy-addr') const Reply = require('./lib/reply') const Request = require('./lib/request') const supportedMethods = ['DELETE', 'GET', 'HEAD', 'PATCH', 'POST', 'PUT', 'OPTIONS'] const buildSchema = require('./lib/validation').build const handleRequest = require('./lib/handleRequest') const validation = require('./lib/validation') const isValidLogger = validation.isValidLogger const buildSchemaCompiler = validation.buildSchemaCompiler const decorator = require('./lib/decorate') const ContentTypeParser = require('./lib/contentTypeParser') const Hooks = require('./lib/hooks') const Schemas = require('./lib/schemas') const loggerUtils = require('./lib/logger') const pluginUtils = require('./lib/pluginUtils') const runHooks = require('./lib/hookRunner').hookRunner const DEFAULT_BODY_LIMIT = 1024 * 1024 // 1 MiB const childrenKey = Symbol('fastify.children') function validateBodyLimitOption (bodyLimit) { if (bodyLimit === undefined) return if (!Number.isInteger(bodyLimit) || bodyLimit <= 0) { throw new TypeError(`'bodyLimit' option must be an integer > 0. Got '${bodyLimit}'`) } } function noop () { } function build (options) { options = options || {} if (typeof options !== 'object') { throw new TypeError('Options must be an object') } var log var hasLogger = true if (isValidLogger(options.logger)) { log = loggerUtils.createLogger({ logger: options.logger, serializers: Object.assign({}, loggerUtils.serializers, options.logger.serializers) }) } else if (!options.logger) { hasLogger = false log = Object.create(abstractLogging) log.child = () => log } else { options.logger = typeof options.logger === 'object' ? options.logger : {} options.logger.level = options.logger.level || 'info' options.logger.serializers = Object.assign({}, loggerUtils.serializers, options.logger.serializers) log = loggerUtils.createLogger(options.logger) } const fastify = { [childrenKey]: [] } const router = FindMyWay({ defaultRoute: defaultRoute, ignoreTrailingSlash: options.ignoreTrailingSlash, maxParamLength: options.maxParamLength, caseSensitive: options.caseSensitive, versioning: options.versioning }) const requestIdHeader = options.requestIdHeader || 'request-id' fastify.printRoutes = router.prettyPrint.bind(router) // logger utils const customGenReqId = options.logger ? options.logger.genReqId : null const handleTrustProxy = options.trustProxy ? _handleTrustProxy : _ipAsRemoteAddress const proxyFn = getTrustProxyFn() const genReqId = customGenReqId || loggerUtils.reqIdGenFactory(requestIdHeader) const now = loggerUtils.now const onResponseIterator = loggerUtils.onResponseIterator const onResponseCallback = hasLogger ? loggerUtils.onResponseCallback : noop const app = avvio(fastify, { autostart: false, timeout: Number(options.pluginTimeout) || 0 }) // Override to allow the plugin incapsulation app.override = override var listening = false var closing = false // true when Fastify is ready to go var started = false app.on('start', () => { started = true }) function throwIfAlreadyStarted (msg) { if (started) throw new Error(msg) } var server const httpHandler = router.lookup.bind(router) if (options.serverFactory) { server = options.serverFactory(httpHandler, options) } else if (options.https) { if (options.http2) { server = http2().createSecureServer(options.https, httpHandler) } else { server = https.createServer(options.https, httpHandler) } } else if (options.http2) { server = http2().createServer(httpHandler) } else { server = http.createServer(httpHandler) } app.once('preReady', () => { fastify.onClose((instance, done) => { closing = true if (listening) { instance.server.close(done) } else { done(null) } }) }) if (Number(process.version.match(/v(\d+)/)[1]) >= 6) { server.on('clientError', handleClientError) } // body limit option validateBodyLimitOption(options.bodyLimit) fastify._bodyLimit = options.bodyLimit || DEFAULT_BODY_LIMIT // shorthand methods fastify.delete = _delete fastify.get = _get fastify.head = _head fastify.patch = _patch fastify.post = _post fastify.put = _put fastify.options = _options fastify.all = _all // extended route fastify.route = route fastify._routePrefix = '' fastify._logLevel = '' Object.defineProperty(fastify, 'basePath', { get: function () { return this._routePrefix } }) // expose logger instance fastify.log = log // hooks fastify.addHook = addHook fastify._hooks = new Hooks() // schemas fastify.addSchema = addSchema fastify._schemas = new Schemas() fastify.getSchemas = fastify._schemas.getSchemas.bind(fastify._schemas) const onRouteHooks = [] // custom parsers fastify.addContentTypeParser = addContentTypeParser fastify.hasContentTypeParser = hasContentTypeParser fastify._contentTypeParser = new ContentTypeParser(fastify._bodyLimit, options.onProtoPoisoning) fastify.setSchemaCompiler = setSchemaCompiler fastify.setSchemaCompiler(buildSchemaCompiler()) // plugin fastify.register = fastify.use fastify.listen = listen fastify.server = server fastify[pluginUtils.registeredPlugins] = [] // extend server methods fastify.decorate = decorator.add fastify.hasDecorator = decorator.exist fastify.decorateReply = decorator.decorateReply fastify.decorateRequest = decorator.decorateRequest fastify.hasRequestDecorator = decorator.existRequest fastify.hasReplyDecorator = decorator.existReply fastify._Reply = Reply.buildReply(Reply) fastify._Request = Request.buildRequest(Request) // middleware support fastify.use = use fastify._middlewares = [] // fake http injection fastify.inject = inject var fourOhFour = FindMyWay({ defaultRoute: fourOhFourFallBack }) fastify._canSetNotFoundHandler = true fastify._404LevelInstance = fastify fastify._404Context = null fastify.setNotFoundHandler = setNotFoundHandler fastify.setNotFoundHandler() // Set the default 404 handler fastify.setErrorHandler = setErrorHandler return fastify function getTrustProxyFn () { const tp = options.trustProxy if (typeof tp === 'function') { return tp } if (tp === true) { // Support plain true/false return function () { return true } } if (typeof tp === 'number') { // Support trusting hop count return function (a, i) { return i < tp } } if (typeof tp === 'string') { // Support comma-separated tps const vals = tp.split(',').map(it => it.trim()) return proxyAddr.compile(vals) } return proxyAddr.compile(tp || []) } function _handleTrustProxy (req) { req.ip = proxyAddr(req, proxyFn) req.ips = proxyAddr.all(req, proxyFn) if (req.ip !== undefined) { req.hostname = req.headers['x-forwarded-host'] } } function _ipAsRemoteAddress (req) { req.ip = req.connection.remoteAddress } function routeHandler (req, res, params, context) { if (closing === true) { const headers = { 'Content-Type': 'application/json', 'Content-Length': '80' } if (req.httpVersionMajor !== 2) { headers.Connection = 'close' } res.writeHead(503, headers) res.end('{"error":"Service Unavailable","message":"Service Unavailable","statusCode":503}') if (req.httpVersionMajor !== 2) { // This is not needed in HTTP/2 setImmediate(() => req.destroy()) } return } req.id = genReqId(req) handleTrustProxy(req) req.hostname = req.hostname || req.headers['host'] req.log = res.log = log.child({ reqId: req.id, level: context.logLevel }) req.originalUrl = req.url res._startTime = hasLogger ? now() : undefined res._context = context req.log.info({ req }, 'incoming request') if (hasLogger === true || context.onResponse !== null) { res.on('finish', onResFinished) res.on('error', onResFinished) } if (context.onRequest !== null) { runHooks( context.onRequest, hookIterator, new State(req, res, params, context), middlewareCallback ) } else { middlewareCallback(null, new State(req, res, params, context)) } } function onResFinished (err) { this.removeListener('finish', onResFinished) this.removeListener('error', onResFinished) var ctx = this._context if (ctx && ctx.onResponse !== null) { // deferring this with setImmediate will // slow us by 10% runHooks( ctx.onResponse, onResponseIterator, this, onResponseCallback ) } else { onResponseCallback(err, this) } } function listenPromise (port, address, backlog) { if (listening) { return Promise.reject(new Error('Fastify is already listening')) } return fastify.ready().then(() => { var errEventHandler var errEvent = new Promise((resolve, reject) => { errEventHandler = (err) => { listening = false reject(err) } server.once('error', errEventHandler) }) var listen = new Promise((resolve, reject) => { server.listen(port, address, backlog, () => { server.removeListener('error', errEventHandler) resolve(logServerAddress(server.address(), options.https)) }) // we set it afterwards because listen can throw listening = true }) return Promise.race([ errEvent, // e.g invalid port range error is always emitted before the server listening listen ]) }) } function listen (port, address, backlog, cb) { /* Deal with listen (cb) */ if (typeof port === 'function') { cb = port port = 0 } /* Deal with listen (port, cb) */ if (typeof address === 'function') { cb = address address = undefined } // This will listen to what localhost is. // It can be 127.0.0.1 or ::1, depending on the operating system. // Fixes https://github.com/fastify/fastify/issues/1022. address = address || 'localhost' /* Deal with listen (port, address, cb) */ if (typeof backlog === 'function') { cb = backlog backlog = undefined } if (cb === undefined) return listenPromise(port, address, backlog) fastify.ready(function (err) { if (err) return cb(err) if (listening) { return cb(new Error('Fastify is already listening'), null) } server.once('error', wrap) if (backlog) { server.listen(port, address, backlog, wrap) } else { server.listen(port, address, wrap) } listening = true }) function wrap (err) { server.removeListener('error', wrap) if (!err) { address = logServerAddress(server.address(), options.https) cb(null, address) } else { listening = false cb(err, null) } } } function logServerAddress (address, isHttps) { const isUnixSocket = typeof address === 'string' if (!isUnixSocket) { if (address.address.indexOf(':') === -1) { address = address.address + ':' + address.port } else { address = '[' + address.address + ']:' + address.port } } address = (isUnixSocket ? '' : ('http' + (isHttps ? 's' : '') + '://')) + address fastify.log.info('Server listening at ' + address) return address } function State (req, res, params, context) { this.req = req this.res = res this.params = params this.context = context } function hookIterator (fn, state, next) { if (state.res.finished === true) return undefined return fn(state.req, state.res, next) } function middlewareCallback (err, state) { if (state.res.finished === true) return if (err) { const req = state.req const request = new state.context.Request(state.params, req, null, req.headers, req.log) const reply = new state.context.Reply(state.res, state.context, request) reply.send(err) return } if (state.context._middie !== null) { state.context._middie.run(state.req, state.res, state) } else { onRunMiddlewares(null, state.req, state.res, state) } } function onRunMiddlewares (err, req, res, state) { if (err) { const request = new state.context.Request(state.params, req, null, req.headers, req.log) const reply = new state.context.Reply(res, state.context, request) reply.send(err) return } handleRequest(req, res, state.params, state.context) } function override (old, fn, opts) { const shouldSkipOverride = pluginUtils.registerPlugin.call(old, fn) if (shouldSkipOverride) { return old } const instance = Object.create(old) old[childrenKey].push(instance) instance[childrenKey] = [] instance._Reply = Reply.buildReply(instance._Reply) instance._Request = Request.buildRequest(instance._Request) instance._contentTypeParser = ContentTypeParser.buildContentTypeParser(instance._contentTypeParser) instance._hooks = Hooks.buildHooks(instance._hooks) instance._routePrefix = buildRoutePrefix(instance._routePrefix, opts.prefix) instance._logLevel = opts.logLevel || instance._logLevel instance._middlewares = old._middlewares.slice() instance[pluginUtils.registeredPlugins] = Object.create(instance[pluginUtils.registeredPlugins]) if (opts.prefix) { instance._canSetNotFoundHandler = true instance._404LevelInstance = instance } return instance } function buildRoutePrefix (instancePrefix, pluginPrefix) { if (!pluginPrefix) { return instancePrefix } // Ensure that there is a '/' between the prefixes if (instancePrefix.endsWith('/')) { if (pluginPrefix[0] === '/') { // Remove the extra '/' to avoid: '/first//second' pluginPrefix = pluginPrefix.slice(1) } } else if (pluginPrefix[0] !== '/') { pluginPrefix = '/' + pluginPrefix } return instancePrefix + pluginPrefix } // Shorthand methods function _delete (url, opts, handler) { return _route(this, 'DELETE', url, opts, handler) } function _get (url, opts, handler) { return _route(this, 'GET', url, opts, handler) } function _head (url, opts, handler) { return _route(this, 'HEAD', url, opts, handler) } function _patch (url, opts, handler) { return _route(this, 'PATCH', url, opts, handler) } function _post (url, opts, handler) { return _route(this, 'POST', url, opts, handler) } function _put (url, opts, handler) { return _route(this, 'PUT', url, opts, handler) } function _options (url, opts, handler) { return _route(this, 'OPTIONS', url, opts, handler) } function _all (url, opts, handler) { return _route(this, supportedMethods, url, opts, handler) } function _route (_fastify, method, url, options, handler) { if (!handler && typeof options === 'function') { handler = options options = {} } else if (handler && typeof handler === 'function') { if (Object.prototype.toString.call(options) !== '[object Object]') { throw new Error(`Options for ${method}:${url} route must be an object`) } else if (options.handler) { if (typeof options.handler === 'function') { throw new Error(`Duplicate handler for ${method}:${url} route is not allowed!`) } else { throw new Error(`Handler for ${method}:${url} route must be a function`) } } } options = Object.assign({}, options, { method, url, handler: handler || (options && options.handler) }) return _fastify.route(options) } // Route management function route (opts) { throwIfAlreadyStarted('Cannot add route when fastify instance is already started!') const _fastify = this if (Array.isArray(opts.method)) { for (var i = 0; i < opts.method.length; i++) { if (supportedMethods.indexOf(opts.method[i]) === -1) { throw new Error(`${opts.method[i]} method is not supported!`) } } } else { if (supportedMethods.indexOf(opts.method) === -1) { throw new Error(`${opts.method} method is not supported!`) } } if (!opts.handler) { throw new Error(`Missing handler function for ${opts.method}:${opts.url} route.`) } validateBodyLimitOption(opts.bodyLimit) _fastify.after(function afterRouteAdded (notHandledErr, done) { const prefix = _fastify._routePrefix var path = opts.url || opts.path if (path === '/' && prefix.length > 0) { // Ensure that '/prefix' + '/' gets registered as '/prefix' path = '' } else if (path[0] === '/' && prefix.endsWith('/')) { // Ensure that '/prefix/' + '/route' gets registered as '/prefix/route' path = path.slice(1) } const url = prefix + path opts.url = url opts.path = url opts.prefix = prefix opts.logLevel = opts.logLevel || _fastify._logLevel if (opts.attachValidation == null) { opts.attachValidation = false } // run 'onRoute' hooks for (var h of onRouteHooks) { h.call(_fastify, opts) } const config = opts.config || {} config.url = url const context = new Context( opts.schema, opts.handler.bind(_fastify), _fastify._Reply, _fastify._Request, _fastify._contentTypeParser, config, _fastify._errorHandler, opts.bodyLimit, opts.logLevel, opts.attachValidation ) try { buildSchema(context, opts.schemaCompiler || _fastify._schemaCompiler, _fastify._schemas) } catch (error) { done(error) return } if (opts.beforeHandler) { if (Array.isArray(opts.beforeHandler)) { opts.beforeHandler.forEach((h, i) => { opts.beforeHandler[i] = h.bind(_fastify) }) } else { opts.beforeHandler = opts.beforeHandler.bind(_fastify) } } try { router.on(opts.method, url, { version: opts.version }, routeHandler, context) } catch (err) { done(err) return } // It can happen that a user register a plugin with some hooks/middlewares *after* // the route registration. To be sure to load also that hoooks/middlwares, // we must listen for the avvio's preReady event, and update the context object accordingly. app.once('preReady', () => { const onRequest = _fastify._hooks.onRequest const onResponse = _fastify._hooks.onResponse const onSend = _fastify._hooks.onSend const preHandler = _fastify._hooks.preHandler.concat(opts.beforeHandler || []) context.onRequest = onRequest.length ? onRequest : null context.preHandler = preHandler.length ? preHandler : null context.onSend = onSend.length ? onSend : null context.onResponse = onResponse.length ? onResponse : null context._middie = buildMiddie(_fastify._middlewares) // Must store the 404 Context in 'preReady' because it is only guaranteed to // be available after all of the plugins and routes have been loaded. const _404Context = Object.assign({}, _fastify._404Context) _404Context.onSend = context.onSend context._404Context = _404Context }) done(notHandledErr) }) // chainable api return _fastify } function Context (schema, handler, Reply, Request, contentTypeParser, config, errorHandler, bodyLimit, logLevel, attachValidation) { this.schema = schema this.handler = handler this.Reply = Reply this.Request = Request this.contentTypeParser = contentTypeParser this.onRequest = null this.onSend = null this.preHandler = null this.onResponse = null this.config = config this.errorHandler = errorHandler this._middie = null this._parserOptions = { limit: bodyLimit || null } this.logLevel = logLevel this._404Context = null this.attachValidation = attachValidation } function inject (opts, cb) { if (started) { return lightMyRequest(httpHandler, opts, cb) } if (cb) { this.ready(err => { if (err) throw err return lightMyRequest(httpHandler, opts, cb) }) } else { return this.ready() .then(() => lightMyRequest(httpHandler, opts)) } } function use (url, fn) { throwIfAlreadyStarted('Cannot call "use" when fastify instance is already started!') if (typeof url === 'string') { const prefix = this._routePrefix url = prefix + (url === '/' && prefix.length > 0 ? '' : url) } return this.after((err, done) => { addMiddleware(this, [url, fn]) done(err) }) } function addMiddleware (instance, middleware) { instance._middlewares.push(middleware) instance[childrenKey].forEach(child => addMiddleware(child, middleware)) } function addHook (name, fn) { throwIfAlreadyStarted('Cannot call "addHook" when fastify instance is already started!') if (name === 'onClose') { this._hooks.validate(name, fn) this.onClose(fn) } else if (name === 'onRoute') { this._hooks.validate(name, fn) onRouteHooks.push(fn) } else { this.after((err, done) => { _addHook(this, name, fn) done(err) }) } return this } function _addHook (instance, name, fn) { instance._hooks.add(name, fn.bind(instance)) instance[childrenKey].forEach(child => _addHook(child, name, fn)) } function addSchema (schema) { throwIfAlreadyStarted('Cannot call "addSchema" when fastify instance is already started!') this._schemas.add(schema) this[childrenKey] .filter(child => !Object.is(this._schemas, child._schemas)) .forEach(child => child.addSchema(schema)) return this } function addContentTypeParser (contentType, opts, parser) { throwIfAlreadyStarted('Cannot call "addContentTypeParser" when fastify instance is already started!') if (typeof opts === 'function') { parser = opts opts = {} } if (!opts) { opts = {} } if (!opts.bodyLimit) { opts.bodyLimit = this._bodyLimit } if (Array.isArray(contentType)) { contentType.forEach((type) => this._contentTypeParser.add(type, opts, parser)) } else { this._contentTypeParser.add(contentType, opts, parser) } return this } function hasContentTypeParser (contentType, fn) { return this._contentTypeParser.hasParser(contentType) } function handleClientError (err, socket) { const body = JSON.stringify({ error: http.STATUS_CODES['400'], message: 'Client Error', statusCode: 400 }) log.debug({ err }, 'client error') socket.end(`HTTP/1.1 400 Bad Request\r\nContent-Length: ${body.length}\r\nContent-Type: application/json\r\n\r\n${body}`) } function defaultRoute (req, res) { if (req.headers['accept-version'] !== undefined) { req.headers['accept-version'] = undefined } fourOhFour.lookup(req, res) } function basic404 (req, reply) { reply.code(404).send(new Error('Not Found')) } function fourOhFourFallBack (req, res) { // if this happen, we have a very bad bug // we might want to do some hard debugging // here, let's print out as much info as // we can req.id = genReqId(req) req.log = res.log = log.child({ reqId: req.id }) req.originalUrl = req.url req.log.info({ req }, 'incoming request') res._startTime = now() res.on('finish', onResFinished) res.on('error', onResFinished) req.log.warn('the default handler for 404 did not catch this, this is likely a fastify bug, please report it') req.log.warn(fourOhFour.prettyPrint()) const request = new Request(null, req, null, req.headers, req.log) const reply = new Reply(res, { onSend: [] }, request) reply.code(404).send(new Error('Not Found')) } function setNotFoundHandler (opts, handler) { throwIfAlreadyStarted('Cannot call "setNotFoundHandler" when fastify instance is already started!') const _fastify = this const prefix = this._routePrefix || '/' if (this._canSetNotFoundHandler === false) { throw new Error(`Not found handler already set for Fastify instance with prefix: '${prefix}'`) } if (typeof opts === 'object' && opts.beforeHandler) { if (Array.isArray(opts.beforeHandler)) { opts.beforeHandler.forEach((h, i) => { opts.beforeHandler[i] = h.bind(_fastify) }) } else { opts.beforeHandler = opts.beforeHandler.bind(_fastify) } } if (typeof opts === 'function') { handler = opts opts = undefined } opts = opts || {} if (handler) { this._404LevelInstance._canSetNotFoundHandler = false handler = handler.bind(this) } else { handler = basic404 } this.after((notHandledErr, done) => { _setNotFoundHandler.call(this, prefix, opts, handler) done(notHandledErr) }) } function _setNotFoundHandler (prefix, opts, handler) { const context = new Context( opts.schema, handler, this._Reply, this._Request, this._contentTypeParser, opts.config || {}, this._errorHandler, this._bodyLimit, this._logLevel ) app.once('preReady', () => { const context = this._404Context const onRequest = this._hooks.onRequest const preHandler = this._hooks.preHandler.concat(opts.beforeHandler || []) const onSend = this._hooks.onSend const onResponse = this._hooks.onResponse context.onRequest = onRequest.length ? onRequest : null context.preHandler = preHandler.length ? preHandler : null context.onSend = onSend.length ? onSend : null context.onResponse = onResponse.length ? onResponse : null context._middie = buildMiddie(this._middlewares) }) if (this._404Context !== null && prefix === '/') { Object.assign(this._404Context, context) // Replace the default 404 handler return } this._404LevelInstance._404Context = context fourOhFour.all(prefix + (prefix.endsWith('/') ? '*' : '/*'), routeHandler, context) fourOhFour.all(prefix || '/', routeHandler, context) } function setSchemaCompiler (schemaCompiler) { throwIfAlreadyStarted('Cannot call "setSchemaCompiler" when fastify instance is already started!') this._schemaCompiler = schemaCompiler return this } function setErrorHandler (func) { throwIfAlreadyStarted('Cannot call "setErrorHandler" when fastify instance is already started!') this._errorHandler = func return this } function buildMiddie (middlewares) { if (!middlewares.length) { return null } const middie = Middie(onRunMiddlewares) for (var i = 0; i < middlewares.length; i++) { middie.use.apply(middie, middlewares[i]) } return middie } } function http2 () { try { return require('http2') } catch (err) { console.error('http2 is available only from node >= 8.8.1') } } module.exports = build