@fastify/reply-from
Version:
forward your HTTP request to another server, for fastify
321 lines (279 loc) • 10.3 kB
JavaScript
'use strict'
const http2 = require('node:http2')
const fp = require('fastify-plugin')
const { LruMap } = require('toad-cache')
const querystring = require('fast-querystring')
const fastContentTypeParse = require('fast-content-type-parse')
const Stream = require('node:stream')
const buildRequest = require('./lib/request')
const {
filterPseudoHeaders,
copyHeaders,
stripHttp1ConnectionHeaders,
buildURL
} = require('./lib/utils')
const {
TimeoutError,
ServiceUnavailableError,
GatewayTimeoutError,
ConnectionResetError,
ConnectTimeoutError,
UndiciSocketError,
InternalServerError,
BadGatewayError
} = require('./lib/errors')
const { NGHTTP2_CANCEL } = http2.constants
const fastifyReplyFrom = fp(function from (fastify, opts, next) {
const contentTypesToEncode = new Set([
'application/json',
...(opts.contentTypesToEncode || [])
])
const retryMethods = new Set(opts.retryMethods || [
'GET', 'HEAD', 'OPTIONS', 'TRACE'])
const cache = opts.disableCache ? undefined : new LruMap(opts.cacheURLs || 100)
const base = opts.base
const requestBuilt = buildRequest({
http: opts.http,
http2: opts.http2,
base,
sessionTimeout: opts.sessionTimeout,
undici: opts.undici,
globalAgent: opts.globalAgent,
destroyAgent: opts.destroyAgent
})
const isHttp2 = !!opts.http2
if (requestBuilt instanceof Error) {
next(requestBuilt)
return
}
const { request, close, retryOnError } = requestBuilt
const disableRequestLogging = opts.disableRequestLogging || false
fastify.decorateReply('from', function (source, opts) {
opts = opts || {}
const req = this.request.raw
const method = opts.method || req.method
const timeout = opts.timeout
const onResponse = opts.onResponse
const rewriteHeaders = opts.rewriteHeaders || headersNoOp
const rewriteRequestHeaders = opts.rewriteRequestHeaders || requestHeadersNoOp
const getUpstream = opts.getUpstream || upstreamNoOp
const onError = opts.onError || onErrorDefault
const retriesCount = opts.retriesCount || 0
const maxRetriesOn503 = opts.maxRetriesOn503 || 10
const retryDelay = opts.retryDelay || undefined
if (!source) {
const requestUrl = req.url
const queryIndex = requestUrl.indexOf('?')
source = queryIndex >= 0 ? requestUrl.substring(0, queryIndex) : requestUrl
}
// we leverage caching to avoid parsing the destination URL
const dest = getUpstream(this.request, base)
let url
if (cache) {
const cacheKey = dest + source
url = cache.get(cacheKey) || buildURL(source, dest)
cache.set(cacheKey, url)
} else {
url = buildURL(source, dest)
}
const sourceHttp2 = req.httpVersionMajor === 2
const headers = sourceHttp2 ? filterPseudoHeaders(req.headers) : { ...req.headers }
headers.host = url.host
const qs = getQueryString(url.search, req.url, opts, this.request)
let body = ''
if (opts.body !== undefined) {
if (opts.body !== null) {
if (typeof opts.body.pipe === 'function') {
throw new Error('sending a new body as a stream is not supported yet')
}
if (opts.contentType) {
body = opts.body
} else {
body = JSON.stringify(opts.body)
opts.contentType = 'application/json'
}
headers['content-length'] = Buffer.byteLength(body)
headers['content-type'] = opts.contentType
} else {
body = undefined
headers['content-length'] = 0
delete headers['content-type']
}
} else if (this.request.body) {
if (this.request.body instanceof Stream) {
body = this.request.body
} else {
// Per RFC 7231 §3.1.1.5 if this header is not present we MAY assume application/octet-stream
let contentType = 'application/octet-stream'
if (req.headers['content-type']) {
const plainContentType = fastContentTypeParse.parse(req.headers['content-type'])
contentType = plainContentType.type
}
const shouldEncodeJSON = contentTypesToEncode.has(contentType)
// transparently support JSON encoding
body = shouldEncodeJSON ? JSON.stringify(this.request.body) : this.request.body
// update origin request headers after encoding
headers['content-length'] = Buffer.byteLength(body)
headers['content-type'] = contentType
}
}
// according to https://tools.ietf.org/html/rfc2616#section-4.3
// fastify ignore message body when it's a GET or HEAD request
// when proxy this request, we should reset the content-length to make it a valid http request
// discussion: https://github.com/fastify/fastify/issues/953
if (method === 'GET' || method === 'HEAD') {
// body will be populated here only if opts.body is passed.
// if we are doing that with a GET or HEAD request is a programmer error
// and as such we can throw immediately.
if (body) {
throw new Error(`Rewriting the body when doing a ${method} is not allowed`)
}
}
!disableRequestLogging && this.request.log.info({ source }, 'fetching from remote server')
const requestHeaders = rewriteRequestHeaders(this.request, headers)
const contentLength = requestHeaders['content-length']
let requestImpl
const getDefaultDelay = (req, res, err, retries) => {
if (retryMethods.has(method) && !contentLength) {
// Magic number, so why not 42? We might want to make this configurable.
let retryAfter = 42 * Math.random() * (retries + 1)
if (res?.headers['retry-after']) {
retryAfter = res.headers['retry-after']
}
if (res && res.statusCode === 503 && req.method === 'GET') {
if (retriesCount === 0 && retries < maxRetriesOn503) {
return retryAfter
}
} else if (retriesCount > retries && err && err.code === retryOnError) {
return retryAfter
}
}
return null
}
if (retryDelay) {
requestImpl = createRequestRetry(request, this, (req, res, err, retries) => retryDelay({ err, req, res, attempt: retries, getDefaultDelay, retriesCount }))
} else {
requestImpl = createRequestRetry(request, this, getDefaultDelay)
}
requestImpl({ method, url, qs, headers: requestHeaders, body, timeout }, (err, res) => {
if (err) {
this.request.log.warn(err, 'response errored')
if (!this.sent) {
if (err.code === 'ERR_HTTP2_STREAM_CANCEL' || err.code === 'ENOTFOUND') {
onError(this, { error: ServiceUnavailableError() })
} else if (err instanceof TimeoutError || err.code === 'UND_ERR_HEADERS_TIMEOUT') {
onError(this, { error: new GatewayTimeoutError() })
} else if (err.code === 'ECONNRESET') {
onError(this, { error: new ConnectionResetError() })
} else if (err.code === 'UND_ERR_SOCKET') {
onError(this, { error: new UndiciSocketError() })
} else if (err.code === 'UND_ERR_CONNECT_TIMEOUT') {
onError(this, { error: new ConnectTimeoutError() })
} else {
onError(this, { error: new InternalServerError(err.message) })
}
}
return
}
!disableRequestLogging && this.request.log.info('response received')
if (sourceHttp2) {
copyHeaders(
rewriteHeaders(stripHttp1ConnectionHeaders(res.headers), this.request),
this
)
} else {
copyHeaders(rewriteHeaders(res.headers, this.request), this)
}
try {
this.code(res.statusCode)
} catch (err) {
// Since we know `FST_ERR_BAD_STATUS_CODE` will be recieved
onError(this, { error: new BadGatewayError() })
this.request.log.warn(err, 'response has invalid status code')
}
if (this.request.raw.aborted && isHttp2) {
// the request could have been canceled before we got a response from the target
// forward this to the upstream server and close the stream to prevent leaks
res.stream.close(NGHTTP2_CANCEL)
// no need to send a reply for aborted requests or call the onResponse callback
return
}
if (onResponse) {
onResponse(this.request, this, res)
} else {
this.send(res.stream)
}
})
return this
})
fastify.addHook('onReady', (done) => {
if (isFastifyMultipartRegistered(fastify)) {
fastify.log.warn('@fastify/reply-from might not behave as expected when used with @fastify/multipart')
}
done()
})
fastify.onClose((_fastify, next) => {
close()
// let the event loop do a full run so that it can
// actually destroy those sockets
setImmediate(next)
})
next()
}, {
fastify: '5.x',
name: '@fastify/reply-from'
})
function getQueryString (search, reqUrl, opts, request) {
if (typeof opts.queryString === 'function') {
return '?' + opts.queryString(search, reqUrl, request)
}
if (opts.queryString) {
return '?' + querystring.stringify(opts.queryString)
}
if (search.length > 0) {
return search
}
const queryIndex = reqUrl.indexOf('?')
if (queryIndex > 0) {
return reqUrl.slice(queryIndex)
}
return ''
}
function headersNoOp (headers, _originalReq) {
return headers
}
function requestHeadersNoOp (_originalReq, headers) {
return headers
}
function upstreamNoOp (_req, base) {
return base
}
function onErrorDefault (reply, { error }) {
reply.send(error)
}
function isFastifyMultipartRegistered (fastify) {
return fastify.hasPlugin('@fastify/multipart')
}
function createRequestRetry (requestImpl, reply, retryHandler) {
function requestRetry (req, cb) {
let retries = 0
function run () {
requestImpl(req, function (err, res) {
const retryDelay = retryHandler(req, res, err, retries)
if (!reply.sent && retryDelay) {
return retry(retryDelay)
}
cb(err, res)
})
}
function retry (after) {
retries += 1
setTimeout(run, after)
}
run()
}
return requestRetry
}
module.exports = fastifyReplyFrom
module.exports.default = fastifyReplyFrom
module.exports.fastifyReplyFrom = fastifyReplyFrom