UNPKG

fastify

Version:

Fast and low overhead web framework, for Node.js

752 lines (655 loc) 23.8 kB
'use strict' const FindMyWay = require('find-my-way') const Avvio = require('avvio') const http = require('http') const querystring = require('querystring') const Middie = require('middie') let lightMyRequest const proxyAddr = require('proxy-addr') const { kChildren, kBodyLimit, kRoutePrefix, kLogLevel, kHooks, kSchemas, kSchemaCompiler, kContentTypeParser, kReply, kRequest, kMiddlewares, kFourOhFour, kState, kOptions, kGlobalHooks } = require('./lib/symbols.js') const { createServer } = require('./lib/server') const Reply = require('./lib/reply') const Request = require('./lib/request') const Context = require('./lib/context') 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 buildSchemaCompiler = validation.buildSchemaCompiler const decorator = require('./lib/decorate') const ContentTypeParser = require('./lib/contentTypeParser') const { Hooks, hookRunner, hookIterator, buildHooks } = require('./lib/hooks') const { Schemas, buildSchemas } = require('./lib/schemas') const { createLogger } = require('./lib/logger') const pluginUtils = require('./lib/pluginUtils') const reqIdGenFactory = require('./lib/reqIdGenFactory') const build404 = require('./lib/fourOhFour') const { beforeHandlerWarning } = require('./lib/warnings') const getSecuredInitialConfig = require('./lib/initialConfigValidation') const { defaultInitOptions } = getSecuredInitialConfig function build (options) { // Options validations options = options || {} if (typeof options !== 'object') { throw new TypeError('Options must be an object') } if (options.querystringParser && typeof options.querystringParser !== 'function') { throw new Error(`querystringParser option should be a function, instead got '${typeof options.querystringParser}'`) } validateBodyLimitOption(options.bodyLimit) if (options.logger && options.logger.genReqId) { process.emitWarning(`Using 'genReqId' in logger options is deprecated. Use fastify options instead. See: https://www.fastify.io/docs/latest/Server/#gen-request-id`) options.genReqId = options.logger.genReqId } const trustProxy = options.trustProxy const modifyCoreObjects = options.modifyCoreObjects !== false const requestIdHeader = options.requestIdHeader || defaultInitOptions.requestIdHeader const querystringParser = options.querystringParser || querystring.parse const genReqId = options.genReqId || reqIdGenFactory() const requestIdLogLabel = options.requestIdLogLabel || 'reqId' const bodyLimit = options.bodyLimit || defaultInitOptions.bodyLimit // Instance Fastify components const { logger, hasLogger } = createLogger(options) // Update the options with the fixed values options.logger = logger options.modifyCoreObjects = modifyCoreObjects options.genReqId = genReqId // Default router const router = FindMyWay({ defaultRoute: defaultRoute, ignoreTrailingSlash: options.ignoreTrailingSlash || defaultInitOptions.ignoreTrailingSlash, maxParamLength: options.maxParamLength || defaultInitOptions.maxParamLength, caseSensitive: options.caseSensitive, versioning: options.versioning }) // 404 router, used for handling encapsulated 404 handlers const fourOhFour = build404(options) // HTTP server and its handler const httpHandler = router.lookup.bind(router) const { server, listen } = createServer(options, httpHandler) if (Number(process.version.match(/v(\d+)/)[1]) >= 6) { server.on('clientError', handleClientError) } const setupResponseListeners = Reply.setupResponseListeners const proxyFn = getTrustProxyFn(options) const schemas = new Schemas() // Public API const fastify = { // Fastify internals [kState]: { listening: false, closing: false, started: false }, [kOptions]: options, [kChildren]: [], [kBodyLimit]: bodyLimit, [kRoutePrefix]: '', [kLogLevel]: '', [kHooks]: new Hooks(), [kSchemas]: schemas, [kSchemaCompiler]: null, [kContentTypeParser]: new ContentTypeParser(bodyLimit, (options.onProtoPoisoning || defaultInitOptions.onProtoPoisoning)), [kReply]: Reply.buildReply(Reply), [kRequest]: Request.buildRequest(Request), [kMiddlewares]: [], [kFourOhFour]: fourOhFour, [kGlobalHooks]: { onRoute: [], onRegister: [] }, [pluginUtils.registeredPlugins]: [], // routes shorthand methods delete: function _delete (url, opts, handler) { return prepareRoute.call(this, 'DELETE', url, opts, handler) }, get: function _get (url, opts, handler) { return prepareRoute.call(this, 'GET', url, opts, handler) }, head: function _head (url, opts, handler) { return prepareRoute.call(this, 'HEAD', url, opts, handler) }, patch: function _patch (url, opts, handler) { return prepareRoute.call(this, 'PATCH', url, opts, handler) }, post: function _post (url, opts, handler) { return prepareRoute.call(this, 'POST', url, opts, handler) }, put: function _put (url, opts, handler) { return prepareRoute.call(this, 'PUT', url, opts, handler) }, options: function _options (url, opts, handler) { return prepareRoute.call(this, 'OPTIONS', url, opts, handler) }, all: function _all (url, opts, handler) { return prepareRoute.call(this, supportedMethods, url, opts, handler) }, // extended route route: route, // expose logger instance log: logger, // hooks addHook: addHook, // schemas addSchema: addSchema, getSchemas: schemas.getSchemas.bind(schemas), setSchemaCompiler: setSchemaCompiler, // custom parsers addContentTypeParser: ContentTypeParser.helpers.addContentTypeParser, hasContentTypeParser: ContentTypeParser.helpers.hasContentTypeParser, // Fastify architecture methods (initialized by Avvio) register: null, after: null, ready: null, onClose: null, close: null, // http server listen: listen, server: server, // extend fastify objects decorate: decorator.add, hasDecorator: decorator.exist, decorateReply: decorator.decorateReply, decorateRequest: decorator.decorateRequest, hasRequestDecorator: decorator.existRequest, hasReplyDecorator: decorator.existReply, // middleware support use: use, // fake http injection inject: inject, // pretty print of the registered routes printRoutes: router.prettyPrint.bind(router), // custom error handling setNotFoundHandler: setNotFoundHandler, setErrorHandler: setErrorHandler, // Set fastify initial configuration options read-only object initialConfig: getSecuredInitialConfig(options) } Object.defineProperty(fastify, 'schemaCompiler', { get: function () { return this[kSchemaCompiler] }, set: function (schemaCompiler) { this.setSchemaCompiler(schemaCompiler) } }) Object.defineProperty(fastify, 'prefix', { get: function () { return this[kRoutePrefix] } }) Object.defineProperty(fastify, 'basePath', { get: function () { process.emitWarning('basePath is deprecated. Use prefix instead. See: https://www.fastify.io/docs/latest/Server/#prefix') return this[kRoutePrefix] } }) // Install and configure Avvio // Avvio will update the following Fastify methods: // - register // - after // - ready // - onClose // - close const avvio = Avvio(fastify, { autostart: false, timeout: Number(options.pluginTimeout) || defaultInitOptions.pluginTimeout, expose: { use: 'register' } }) // Override to allow the plugin incapsulation avvio.override = override avvio.on('start', () => (fastify[kState].started = true)) // cache the closing value, since we are checking it in an hot path var closing = false avvio.once('preReady', () => { fastify.onClose((instance, done) => { fastify[kState].closing = true closing = true if (fastify[kState].listening) { instance.server.close(done) } else { done(null) } }) }) // Set the default 404 handler fastify.setNotFoundHandler() fourOhFour.arrange404(fastify) const schemaCache = new Map() schemaCache.put = schemaCache.set return fastify // HTTP request entry point, the routing has already been executed 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 = req.headers[requestIdHeader] || genReqId(req) req.originalUrl = req.url var hostname = req.headers['host'] var ip = req.connection.remoteAddress var ips if (trustProxy) { ip = proxyAddr(req, proxyFn) ips = proxyAddr.all(req, proxyFn) if (ip !== undefined && req.headers['x-forwarded-host']) { hostname = req.headers['x-forwarded-host'] } } var childLogger = logger.child({ [requestIdLogLabel]: req.id, level: context.logLevel }) // added hostname, ip, and ips back to the Node req object to maintain backward compatibility if (modifyCoreObjects) { req.hostname = hostname req.ip = ip req.ips = ips req.log = res.log = childLogger } childLogger.info({ req }, 'incoming request') var queryPrefix = req.url.indexOf('?') var query = querystringParser(queryPrefix > -1 ? req.url.slice(queryPrefix + 1) : '') var request = new context.Request(params, req, query, req.headers, childLogger, ip, ips, hostname) var reply = new context.Reply(res, context, request, childLogger) if (hasLogger === true || context.onResponse !== null) { setupResponseListeners(reply) } if (context.onRequest !== null) { hookRunner( context.onRequest, hookIterator, request, reply, middlewareCallback ) } else { middlewareCallback(null, request, reply) } } function middlewareCallback (err, request, reply) { if (reply.sent === true) return if (err != null) { reply.send(err) return } if (reply.context._middie !== null) { reply.context._middie.run(request.raw, reply.res, reply) } else { onRunMiddlewares(null, null, null, reply) } } function onRunMiddlewares (err, req, res, reply) { if (err != null) { reply.send(err) return } if (reply.context.preParsing !== null) { hookRunner( reply.context.preParsing, hookIterator, reply.request, reply, handleRequest ) } else { handleRequest(null, reply.request, reply) } } function throwIfAlreadyStarted (msg) { if (fastify[kState].started) throw new Error(msg) } // Convert shorthand to extended route declaration function prepareRoute (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 route.call(this, options) } // Route management function route (opts) { throwIfAlreadyStarted('Cannot add route when fastify instance is already started!') 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) const prefix = this[kRoutePrefix] this.after((notHandledErr, done) => { var path = opts.url || opts.path if (path === '/' && prefix.length > 0) { switch (opts.prefixTrailingSlash) { case 'slash': afterRouteAdded.call(this, path, notHandledErr, done) break case 'no-slash': afterRouteAdded.call(this, '', notHandledErr, done) break case 'both': default: afterRouteAdded.call(this, '', notHandledErr, done) afterRouteAdded.call(this, path, notHandledErr, done) } } else if (path[0] === '/' && prefix.endsWith('/')) { // Ensure that '/prefix/' + '/route' gets registered as '/prefix/route' afterRouteAdded.call(this, path.slice(1), notHandledErr, done) } else { afterRouteAdded.call(this, path, notHandledErr, done) } }) // chainable api return this function afterRouteAdded (path, notHandledErr, done) { const url = prefix + path opts.url = url opts.path = url opts.prefix = prefix opts.logLevel = opts.logLevel || this[kLogLevel] if (opts.attachValidation == null) { opts.attachValidation = false } // run 'onRoute' hooks for (const hook of this[kGlobalHooks].onRoute) hook.call(this, opts) const config = opts.config || {} config.url = url const context = new Context( opts.schema, opts.handler.bind(this), this[kReply], this[kRequest], this[kContentTypeParser], config, this._errorHandler, opts.bodyLimit, opts.logLevel, opts.attachValidation ) // TODO this needs to be refactored so that buildSchemaCompiler is // not called for every single route. Creating a new one for every route // is going to be very expensive. if (opts.schema) { try { if (opts.schemaCompiler == null && this[kSchemaCompiler] == null) { const externalSchemas = this[kSchemas].getJsonSchemas({ onlyAbsoluteUri: true }) this.setSchemaCompiler(buildSchemaCompiler(externalSchemas, schemaCache)) } buildSchema(context, opts.schemaCompiler || this[kSchemaCompiler], this[kSchemas]) } catch (error) { done(error) return } } if (opts.preHandler == null && opts.beforeHandler != null) { beforeHandlerWarning() opts.preHandler = opts.beforeHandler } const hooks = ['preParsing', 'preValidation', 'onRequest', 'preHandler', 'preSerialization'] for (let hook of hooks) { if (opts[hook]) { if (Array.isArray(opts[hook])) { opts[hook] = opts[hook].map(fn => fn.bind(this)) } else { opts[hook] = opts[hook].bind(this) } } } 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 hooks/middlewares, // we must listen for the avvio's preReady event, and update the context object accordingly. avvio.once('preReady', () => { const onResponse = this[kHooks].onResponse const onSend = this[kHooks].onSend const onError = this[kHooks].onError context.onSend = onSend.length ? onSend : null context.onError = onError.length ? onError : null context.onResponse = onResponse.length ? onResponse : null for (let hook of hooks) { const toSet = this[kHooks][hook].concat(opts[hook] || []) context[hook] = toSet.length ? toSet : null } context._middie = buildMiddie(this[kMiddlewares]) // 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. fourOhFour.setContext(this, context) }) done(notHandledErr) } } // HTTP injection handling // If the server is not ready yet, this // utility will automatically force it. function inject (opts, cb) { // lightMyRequest is dynamically laoded as it seems very expensive // because of Ajv if (lightMyRequest === undefined) { lightMyRequest = require('light-my-request') } if (fastify[kState].started) { return lightMyRequest(httpHandler, opts, cb) } if (cb) { this.ready(err => { if (err) cb(err, null) else lightMyRequest(httpHandler, opts, cb) }) } else { return this.ready() .then(() => lightMyRequest(httpHandler, opts)) } } // wrapper tha we expose to the user for middlewares handling function use (url, fn) { throwIfAlreadyStarted('Cannot call "use" when fastify instance is already started!') if (typeof url === 'string') { const prefix = this[kRoutePrefix] url = prefix + (url === '/' && prefix.length > 0 ? '' : url) } return this.after((err, done) => { addMiddleware.call(this, [url, fn]) done(err) }) function addMiddleware (middleware) { this[kMiddlewares].push(middleware) this[kChildren].forEach(child => addMiddleware.call(child, middleware)) } } // wrapper that we expose to the user for hooks handling function addHook (name, fn) { throwIfAlreadyStarted('Cannot call "addHook" when fastify instance is already started!') if (name === 'onClose') { this[kHooks].validate(name, fn) this.onClose(fn) } else if (name === 'onRoute') { this[kHooks].validate(name, fn) this[kGlobalHooks].onRoute.push(fn) } else if (name === 'onRegister') { this[kHooks].validate(name, fn) this[kGlobalHooks].onRegister.push(fn) } else { this.after((err, done) => { _addHook.call(this, name, fn) done(err) }) } return this function _addHook (name, fn) { this[kHooks].add(name, fn.bind(this)) this[kChildren].forEach(child => _addHook.call(child, name, fn)) } } // wrapper that we expose to the user for schemas handling function addSchema (schema) { throwIfAlreadyStarted('Cannot call "addSchema" when fastify instance is already started!') this[kSchemas].add(schema) this[kChildren].forEach(child => child.addSchema(schema)) return this } function handleClientError (err, socket) { const body = JSON.stringify({ error: http.STATUS_CODES['400'], message: 'Client Error', statusCode: 400 }) logger.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}`) } // If the router does not match any route, every request will land here // req and res are Node.js core objects function defaultRoute (req, res) { if (req.headers['accept-version'] !== undefined) { req.headers['accept-version'] = undefined } fourOhFour.router.lookup(req, res) } function setNotFoundHandler (opts, handler) { throwIfAlreadyStarted('Cannot call "setNotFoundHandler" when fastify instance is already started!') fourOhFour.setNotFoundHandler.call(this, opts, handler, avvio, routeHandler, buildMiddie) } // wrapper that we expose to the user for schemas compiler handling function setSchemaCompiler (schemaCompiler) { throwIfAlreadyStarted('Cannot call "setSchemaCompiler" when fastify instance is already started!') this[kSchemaCompiler] = schemaCompiler return this } // wrapper that we expose to the user for configure the custom error handler 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 getTrustProxyFn (options) { 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 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 that runs the encapsulation magic. // Everything that need to be encapsulated must be handled in this function. function override (old, fn, opts) { const shouldSkipOverride = pluginUtils.registerPlugin.call(old, fn) if (shouldSkipOverride) { return old } const instance = Object.create(old) old[kChildren].push(instance) instance[kChildren] = [] instance[kReply] = Reply.buildReply(instance[kReply]) instance[kRequest] = Request.buildRequest(instance[kRequest]) instance[kContentTypeParser] = ContentTypeParser.helpers.buildContentTypeParser(instance[kContentTypeParser]) instance[kHooks] = buildHooks(instance[kHooks]) instance[kRoutePrefix] = buildRoutePrefix(instance[kRoutePrefix], opts.prefix) instance[kLogLevel] = opts.logLevel || instance[kLogLevel] instance[kMiddlewares] = old[kMiddlewares].slice() instance[kSchemas] = buildSchemas(old[kSchemas]) instance.getSchemas = instance[kSchemas].getSchemas.bind(instance[kSchemas]) instance[pluginUtils.registeredPlugins] = Object.create(instance[pluginUtils.registeredPlugins]) if (opts.prefix) { instance[kFourOhFour].arrange404(instance) } for (const hook of instance[kGlobalHooks].onRegister) hook.call(this, 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 } module.exports = build