You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

865 lines
23 KiB

'use strict'
const eos = require('stream').finished
const statusCodes = require('http').STATUS_CODES
const flatstr = require('flatstr')
const FJS = require('fast-json-stringify')
const {
kSchemaResponse,
kFourOhFourContext,
kReplyErrorHandlerCalled,
kReplySent,
kReplySentOverwritten,
kReplyStartTime,
kReplyEndTime,
kReplySerializer,
kReplySerializerDefault,
kReplyIsError,
kReplyHeaders,
kReplyTrailers,
kReplyHasStatusCode,
kReplyIsRunningOnErrorHook,
kDisableRequestLogging
} = require('./symbols.js')
const { hookRunner, hookIterator, onSendHookRunner } = require('./hooks')
const internals = require('./handleRequest')[Symbol.for('internals')]
const loggerUtils = require('./logger')
const now = loggerUtils.now
const wrapThenable = require('./wrapThenable')
const serializeError = FJS({
type: 'object',
properties: {
statusCode: { type: 'number' },
code: { type: 'string' },
error: { type: 'string' },
message: { type: 'string' }
}
})
const CONTENT_TYPE = {
JSON: 'application/json; charset=utf-8',
PLAIN: 'text/plain; charset=utf-8',
OCTET: 'application/octet-stream'
}
const {
FST_ERR_REP_INVALID_PAYLOAD_TYPE,
FST_ERR_REP_ALREADY_SENT,
FST_ERR_REP_SENT_VALUE,
FST_ERR_SEND_INSIDE_ONERR,
FST_ERR_BAD_STATUS_CODE,
FST_ERR_BAD_TRAILER_NAME,
FST_ERR_BAD_TRAILER_VALUE
} = require('./errors')
const warning = require('./warnings')
function Reply (res, request, log) {
this.raw = res
this[kReplySent] = false
this[kReplySerializer] = null
this[kReplyErrorHandlerCalled] = false
this[kReplyIsError] = false
this[kReplyIsRunningOnErrorHook] = false
this.request = request
this[kReplyHeaders] = {}
this[kReplyTrailers] = null
this[kReplyHasStatusCode] = false
this[kReplyStartTime] = undefined
this.log = log
}
Reply.props = []
Object.defineProperties(Reply.prototype, {
context: {
get () {
return this.request.context
}
},
res: {
get () {
warning.emit('FSTDEP002')
return this.raw
}
},
sent: {
enumerable: true,
get () {
return this[kReplySent]
},
set (value) {
if (value !== true) {
throw new FST_ERR_REP_SENT_VALUE()
}
if (this[kReplySent]) {
throw new FST_ERR_REP_ALREADY_SENT()
}
this[kReplySentOverwritten] = true
this[kReplySent] = true
}
},
statusCode: {
get () {
return this.raw.statusCode
},
set (value) {
this.code(value)
}
},
server: {
value: null,
writable: true
}
})
Reply.prototype.hijack = function () {
this[kReplySent] = true
return this
}
Reply.prototype.send = function (payload) {
if (this[kReplyIsRunningOnErrorHook] === true) {
throw new FST_ERR_SEND_INSIDE_ONERR()
}
if (this[kReplySent]) {
this.log.warn({ err: new FST_ERR_REP_ALREADY_SENT() }, 'Reply already sent')
return this
}
if (payload instanceof Error || this[kReplyIsError] === true) {
onErrorHook(this, payload, onSendHook)
return this
}
if (payload === undefined) {
onSendHook(this, payload)
return this
}
const contentType = this.getHeader('content-type')
const hasContentType = contentType !== undefined
if (payload !== null) {
if (Buffer.isBuffer(payload) || typeof payload.pipe === 'function') {
if (hasContentType === false) {
this[kReplyHeaders]['content-type'] = CONTENT_TYPE.OCTET
}
onSendHook(this, payload)
return this
}
if (hasContentType === false && typeof payload === 'string') {
this[kReplyHeaders]['content-type'] = CONTENT_TYPE.PLAIN
onSendHook(this, payload)
return this
}
}
if (this[kReplySerializer] !== null) {
if (typeof payload !== 'string') {
preserializeHook(this, payload)
return this
} else {
payload = this[kReplySerializer](payload)
}
// The indexOf below also matches custom json mimetypes such as 'application/hal+json' or 'application/ld+json'
} else if (hasContentType === false || contentType.indexOf('json') > -1) {
if (hasContentType === false) {
this[kReplyHeaders]['content-type'] = CONTENT_TYPE.JSON
} else {
// If hasContentType === true, we have a JSON mimetype
if (contentType.indexOf('charset') === -1) {
// If we have simply application/json instead of a custom json mimetype
if (contentType.indexOf('/json') > -1) {
this[kReplyHeaders]['content-type'] = CONTENT_TYPE.JSON
} else {
const currContentType = this[kReplyHeaders]['content-type']
// We extract the custom mimetype part (e.g. 'hal+' from 'application/hal+json')
const customJsonType = currContentType.substring(
currContentType.indexOf('/'),
currContentType.indexOf('json') + 4
)
// We ensure we set the header to the proper JSON content-type if necessary
// (e.g. 'application/hal+json' instead of 'application/json')
this[kReplyHeaders]['content-type'] = CONTENT_TYPE.JSON.replace('/json', customJsonType)
}
}
}
if (typeof payload !== 'string') {
preserializeHook(this, payload)
return this
}
}
onSendHook(this, payload)
return this
}
Reply.prototype.getHeader = function (key) {
key = key.toLowerCase()
const res = this.raw
let value = this[kReplyHeaders][key]
if (value === undefined && res.hasHeader(key)) {
value = res.getHeader(key)
}
return value
}
Reply.prototype.getHeaders = function () {
return {
...this.raw.getHeaders(),
...this[kReplyHeaders]
}
}
Reply.prototype.hasHeader = function (key) {
key = key.toLowerCase()
if (this[kReplyHeaders][key] !== undefined) {
return true
}
return this.raw.hasHeader(key)
}
Reply.prototype.removeHeader = function (key) {
// Node.js does not like headers with keys set to undefined,
// so we have to delete the key.
delete this[kReplyHeaders][key.toLowerCase()]
return this
}
Reply.prototype.header = function (key, value) {
const _key = key.toLowerCase()
// default the value to ''
value = value === undefined ? '' : value
if (this[kReplyHeaders][_key] && _key === 'set-cookie') {
// https://tools.ietf.org/html/rfc7230#section-3.2.2
if (typeof this[kReplyHeaders][_key] === 'string') {
this[kReplyHeaders][_key] = [this[kReplyHeaders][_key]]
}
if (Array.isArray(value)) {
Array.prototype.push.apply(this[kReplyHeaders][_key], value)
} else {
this[kReplyHeaders][_key].push(value)
}
} else {
this[kReplyHeaders][_key] = value
}
return this
}
Reply.prototype.headers = function (headers) {
const keys = Object.keys(headers)
/* eslint-disable no-var */
for (var i = 0; i !== keys.length; ++i) {
const key = keys[i]
this.header(key, headers[key])
}
return this
}
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Trailer#directives
// https://httpwg.org/specs/rfc7230.html#chunked.trailer.part
const INVALID_TRAILERS = new Set([
'transfer-encoding',
'content-length',
'host',
'cache-control',
'max-forwards',
'te',
'authorization',
'set-cookie',
'content-encoding',
'content-type',
'content-range',
'trailer'
])
Reply.prototype.trailer = function (key, fn) {
key = key.toLowerCase()
if (INVALID_TRAILERS.has(key)) {
throw new FST_ERR_BAD_TRAILER_NAME(key)
}
if (typeof fn !== 'function') {
throw new FST_ERR_BAD_TRAILER_VALUE(key, typeof fn)
}
if (this[kReplyTrailers] === null) this[kReplyTrailers] = {}
this[kReplyTrailers][key] = fn
return this
}
Reply.prototype.hasTrailer = function (key) {
if (this[kReplyTrailers] === null) return false
return this[kReplyTrailers][key.toLowerCase()] !== undefined
}
Reply.prototype.removeTrailer = function (key) {
if (this[kReplyTrailers] === null) return this
this[kReplyTrailers][key.toLowerCase()] = undefined
return this
}
Reply.prototype.code = function (code) {
const intValue = parseInt(code)
if (isNaN(intValue) || intValue < 100 || intValue > 600) {
throw new FST_ERR_BAD_STATUS_CODE(code || String(code))
}
this.raw.statusCode = intValue
this[kReplyHasStatusCode] = true
return this
}
Reply.prototype.status = Reply.prototype.code
Reply.prototype.serialize = function (payload) {
if (this[kReplySerializer] !== null) {
return this[kReplySerializer](payload)
} else {
if (this.context && this.context[kReplySerializerDefault]) {
return this.context[kReplySerializerDefault](payload, this.raw.statusCode)
} else {
return serialize(this.context, payload, this.raw.statusCode)
}
}
}
Reply.prototype.serializer = function (fn) {
this[kReplySerializer] = fn
return this
}
Reply.prototype.type = function (type) {
this[kReplyHeaders]['content-type'] = type
return this
}
Reply.prototype.redirect = function (code, url) {
if (typeof code === 'string') {
url = code
code = this[kReplyHasStatusCode] ? this.raw.statusCode : 302
}
this.header('location', url).code(code).send()
}
Reply.prototype.callNotFound = function () {
notFound(this)
}
Reply.prototype.getResponseTime = function () {
let responseTime = 0
if (this[kReplyStartTime] !== undefined) {
responseTime = (this[kReplyEndTime] || now()) - this[kReplyStartTime]
}
return responseTime
}
// Make reply a thenable, so it could be used with async/await.
// See
// - https://github.com/fastify/fastify/issues/1864 for the discussions
// - https://promisesaplus.com/ for the definition of thenable
// - https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/then for the signature
Reply.prototype.then = function (fulfilled, rejected) {
if (this.sent) {
fulfilled()
return
}
eos(this.raw, (err) => {
// We must not treat ERR_STREAM_PREMATURE_CLOSE as
// an error because it is created by eos, not by the stream.
if (err && err.code !== 'ERR_STREAM_PREMATURE_CLOSE') {
if (rejected) {
rejected(err)
} else {
this.log && this.log.warn('unhandled rejection on reply.then')
}
} else {
fulfilled()
}
})
}
function preserializeHook (reply, payload) {
if (reply.context.preSerialization !== null) {
onSendHookRunner(
reply.context.preSerialization,
reply.request,
reply,
payload,
preserializeHookEnd
)
} else {
preserializeHookEnd(null, reply.request, reply, payload)
}
}
function preserializeHookEnd (err, request, reply, payload) {
if (err != null) {
onErrorHook(reply, err)
return
}
try {
if (reply[kReplySerializer] !== null) {
payload = reply[kReplySerializer](payload)
} else if (reply.context && reply.context[kReplySerializerDefault]) {
payload = reply.context[kReplySerializerDefault](payload, reply.raw.statusCode)
} else {
payload = serialize(reply.context, payload, reply.raw.statusCode)
}
} catch (e) {
wrapSeralizationError(e, reply)
onErrorHook(reply, e)
return
}
flatstr(payload)
onSendHook(reply, payload)
}
function wrapSeralizationError (error, reply) {
error.serialization = reply.context.config
}
function onSendHook (reply, payload) {
if (reply.context.onSend !== null) {
reply[kReplySent] = true
onSendHookRunner(
reply.context.onSend,
reply.request,
reply,
payload,
wrapOnSendEnd
)
} else {
onSendEnd(reply, payload)
}
}
function wrapOnSendEnd (err, request, reply, payload) {
if (err != null) {
onErrorHook(reply, err)
} else {
onSendEnd(reply, payload)
}
}
function onSendEnd (reply, payload) {
const res = reply.raw
const req = reply.request
const statusCode = res.statusCode
// we check if we need to update the trailers header and set it
if (reply[kReplyTrailers] !== null) {
const trailerHeaders = Object.keys(reply[kReplyTrailers])
let header = ''
for (const trailerName of trailerHeaders) {
if (typeof reply[kReplyTrailers][trailerName] !== 'function') continue
header += ' '
header += trailerName
}
// it must be chunked for trailer to work
reply.header('Transfer-Encoding', 'chunked')
reply.header('Trailer', header.trim())
}
if (payload === undefined || payload === null) {
reply[kReplySent] = true
// according to https://tools.ietf.org/html/rfc7230#section-3.3.2
// we cannot send a content-length for 304 and 204, and all status code
// < 200
// A sender MUST NOT send a Content-Length header field in any message
// that contains a Transfer-Encoding header field.
// For HEAD we don't overwrite the `content-length`
if (statusCode >= 200 && statusCode !== 204 && statusCode !== 304 && req.method !== 'HEAD' && reply[kReplyTrailers] === null) {
reply[kReplyHeaders]['content-length'] = '0'
}
res.writeHead(statusCode, reply[kReplyHeaders])
sendTrailer(payload, res, reply)
// avoid ArgumentsAdaptorTrampoline from V8
res.end(null, null, null)
return
}
if (typeof payload.pipe === 'function') {
reply[kReplySent] = true
sendStream(payload, res, reply)
return
}
if (typeof payload !== 'string' && !Buffer.isBuffer(payload)) {
throw new FST_ERR_REP_INVALID_PAYLOAD_TYPE(typeof payload)
}
if (reply[kReplyTrailers] === null) {
if (!reply[kReplyHeaders]['content-length']) {
reply[kReplyHeaders]['content-length'] = '' + Buffer.byteLength(payload)
} else if (req.raw.method !== 'HEAD' && reply[kReplyHeaders]['content-length'] !== Buffer.byteLength(payload)) {
reply[kReplyHeaders]['content-length'] = '' + Buffer.byteLength(payload)
}
}
reply[kReplySent] = true
res.writeHead(statusCode, reply[kReplyHeaders])
// write payload first
res.write(payload)
// then send trailers
sendTrailer(payload, res, reply)
// avoid ArgumentsAdaptorTrampoline from V8
res.end(null, null, null)
}
function logStreamError (logger, err, res) {
if (err.code === 'ERR_STREAM_PREMATURE_CLOSE') {
if (!logger[kDisableRequestLogging]) {
logger.info({ res }, 'stream closed prematurely')
}
} else {
logger.warn({ err }, 'response terminated with an error with headers already sent')
}
}
function sendStream (payload, res, reply) {
let sourceOpen = true
let errorLogged = false
// set trailer when stream ended
sendStreamTrailer(payload, res, reply)
eos(payload, { readable: true, writable: false }, function (err) {
sourceOpen = false
if (err != null) {
if (res.headersSent || reply.request.raw.aborted === true) {
if (!errorLogged) {
errorLogged = true
logStreamError(reply.log, err, res)
}
res.destroy()
} else {
onErrorHook(reply, err)
}
}
// there is nothing to do if there is not an error
})
eos(res, function (err) {
if (sourceOpen) {
if (err != null && res.headersSent && !errorLogged) {
errorLogged = true
logStreamError(reply.log, err, res)
}
if (typeof payload.destroy === 'function') {
payload.destroy()
} else if (typeof payload.close === 'function') {
payload.close(noop)
} else if (typeof payload.abort === 'function') {
payload.abort()
} else {
reply.log.warn('stream payload does not end properly')
}
}
})
// streams will error asynchronously, and we want to handle that error
// appropriately, e.g. a 404 for a missing file. So we cannot use
// writeHead, and we need to resort to setHeader, which will trigger
// a writeHead when there is data to send.
if (!res.headersSent) {
for (const key in reply[kReplyHeaders]) {
res.setHeader(key, reply[kReplyHeaders][key])
}
} else {
reply.log.warn('response will send, but you shouldn\'t use res.writeHead in stream mode')
}
payload.pipe(res)
}
function sendTrailer (payload, res, reply) {
if (reply[kReplyTrailers] === null) return
const trailerHeaders = Object.keys(reply[kReplyTrailers])
const trailers = {}
for (const trailerName of trailerHeaders) {
if (typeof reply[kReplyTrailers][trailerName] !== 'function') continue
trailers[trailerName] = reply[kReplyTrailers][trailerName](reply, payload)
}
res.addTrailers(trailers)
}
function sendStreamTrailer (payload, res, reply) {
if (reply[kReplyTrailers] === null) return
payload.on('end', () => sendTrailer(null, res, reply))
}
function onErrorHook (reply, error, cb) {
reply[kReplySent] = true
if (reply.context.onError !== null && reply[kReplyErrorHandlerCalled] === true) {
reply[kReplyIsRunningOnErrorHook] = true
onSendHookRunner(
reply.context.onError,
reply.request,
reply,
error,
() => handleError(reply, error, cb)
)
} else {
handleError(reply, error, cb)
}
}
function handleError (reply, error, cb) {
reply[kReplyIsRunningOnErrorHook] = false
const res = reply.raw
let statusCode = res.statusCode
statusCode = (statusCode >= 400) ? statusCode : 500
// treat undefined and null as same
if (error != null) {
if (error.headers !== undefined) {
reply.headers(error.headers)
}
if (error.status >= 400) {
statusCode = error.status
} else if (error.statusCode >= 400) {
statusCode = error.statusCode
}
}
res.statusCode = statusCode
const errorHandler = reply.context.errorHandler
if (errorHandler && reply[kReplyErrorHandlerCalled] === false) {
reply[kReplySent] = false
reply[kReplyIsError] = false
reply[kReplyErrorHandlerCalled] = true
// remove header is needed in here, because when we pipe to a stream
// `undefined` value header will directly passed to node response
reply.removeHeader('content-length')
const result = errorHandler(error, reply.request, reply)
if (result !== undefined) {
if (result !== null && typeof result.then === 'function') {
wrapThenable(result, reply)
} else {
reply.send(result)
}
}
return
}
let payload
try {
const serializerFn = getSchemaSerializer(reply.context, statusCode)
payload = (serializerFn === false)
? serializeError({
error: statusCodes[statusCode + ''],
code: error.code,
message: error.message || '',
statusCode
})
: serializerFn(Object.create(error, {
error: { value: statusCodes[statusCode + ''] },
message: { value: error.message || '' },
statusCode: { value: statusCode }
}))
if (serializerFn !== false && typeof payload !== 'string') {
throw new FST_ERR_REP_INVALID_PAYLOAD_TYPE(typeof payload)
}
} catch (err) {
// error is always FST_ERR_SCH_SERIALIZATION_BUILD because this is called from route/compileSchemasForSerialization
reply.log.error({ err, statusCode: res.statusCode }, 'The serializer for the given status code failed')
res.statusCode = 500
payload = serializeError({
error: statusCodes['500'],
code: err.code,
message: err.message,
statusCode: 500
})
}
flatstr(payload)
reply[kReplyHeaders]['content-type'] = CONTENT_TYPE.JSON
reply[kReplyHeaders]['content-length'] = '' + Buffer.byteLength(payload)
if (cb) {
cb(reply, payload)
return
}
reply[kReplySent] = true
res.writeHead(res.statusCode, reply[kReplyHeaders])
res.end(payload)
}
function setupResponseListeners (reply) {
reply[kReplyStartTime] = now()
const onResFinished = err => {
reply[kReplyEndTime] = now()
reply.raw.removeListener('finish', onResFinished)
reply.raw.removeListener('error', onResFinished)
const ctx = reply.context
if (ctx && ctx.onResponse !== null) {
hookRunner(
ctx.onResponse,
onResponseIterator,
reply.request,
reply,
onResponseCallback
)
} else {
onResponseCallback(err, reply.request, reply)
}
}
reply.raw.on('finish', onResFinished)
reply.raw.on('error', onResFinished)
}
function onResponseIterator (fn, request, reply, next) {
return fn(request, reply, next)
}
function onResponseCallback (err, request, reply) {
if (reply.log[kDisableRequestLogging]) {
return
}
const responseTime = reply.getResponseTime()
if (err != null) {
reply.log.error({
res: reply,
err,
responseTime
}, 'request errored')
return
}
reply.log.info({
res: reply,
responseTime
}, 'request completed')
}
function buildReply (R) {
const props = [...R.props]
function _Reply (res, request, log) {
this.raw = res
this[kReplyIsError] = false
this[kReplyErrorHandlerCalled] = false
this[kReplySent] = false
this[kReplySentOverwritten] = false
this[kReplySerializer] = null
this.request = request
this[kReplyHeaders] = {}
this[kReplyTrailers] = null
this[kReplyStartTime] = undefined
this[kReplyEndTime] = undefined
this.log = log
// eslint-disable-next-line no-var
var prop
// eslint-disable-next-line no-var
for (var i = 0; i < props.length; i++) {
prop = props[i]
this[prop.key] = prop.value
}
}
_Reply.prototype = new R()
_Reply.props = props
return _Reply
}
function notFound (reply) {
reply[kReplySent] = false
reply[kReplyIsError] = false
if (reply.context[kFourOhFourContext] === null) {
reply.log.warn('Trying to send a NotFound error inside a 404 handler. Sending basic 404 response.')
reply.code(404).send('404 Not Found')
return
}
reply.request.context = reply.context[kFourOhFourContext]
// preHandler hook
if (reply.context.preHandler !== null) {
hookRunner(
reply.context.preHandler,
hookIterator,
reply.request,
reply,
internals.preHandlerCallback
)
} else {
internals.preHandlerCallback(null, reply.request, reply)
}
}
/**
* This function runs when a payload that is not a string|buffer|stream or null
* should be serialized to be streamed to the response.
* This is the default serializer that can be customized by the user using the replySerializer
*
* @param {object} context the request context
* @param {object} data the JSON payload to serialize
* @param {number} statusCode the http status code
* @returns {string} the serialized payload
*/
function serialize (context, data, statusCode) {
const fnSerialize = getSchemaSerializer(context, statusCode)
if (fnSerialize) {
return fnSerialize(data)
}
return JSON.stringify(data)
}
/**
* Search for the right JSON schema compiled function in the request context
* setup by the route configuration `schema.response`.
* It will look for the exact match (eg 200) or generic (eg 2xx)
*
* @param {object} context the request context
* @param {number} statusCode the http status code
* @returns {function|boolean} the right JSON Schema function to serialize
* the reply or false if it is not set
*/
function getSchemaSerializer (context, statusCode) {
const responseSchemaDef = context[kSchemaResponse]
if (!responseSchemaDef) {
return false
}
if (responseSchemaDef[statusCode]) {
return responseSchemaDef[statusCode]
}
const fallbackStatusCode = (statusCode + '')[0] + 'xx'
if (responseSchemaDef[fallbackStatusCode]) {
return responseSchemaDef[fallbackStatusCode]
}
return false
}
function noop () { }
module.exports = Reply
module.exports.buildReply = buildReply
module.exports.setupResponseListeners = setupResponseListeners