UNPKG

@fastify/reply-from

Version:

forward your HTTP request to another server, for fastify

343 lines (303 loc) 10 kB
'use strict' const http = require('node:http') const https = require('node:https') const querystring = require('node:querystring') const eos = require('end-of-stream') const { pipeline } = require('node:stream') const undici = require('undici') const { stripHttp1ConnectionHeaders } = require('./utils') const http2 = require('node:http2') const { TimeoutError, Http2RequestTimeoutError, Http2SessionTimeoutError, HttpRequestTimeoutError } = require('./errors') function shouldUseUndici (opts) { if (opts.undici === false || opts.http || opts.http2) { return false } return true } function isRequestable (obj) { return obj !== null && typeof obj === 'object' && typeof obj.request === 'function' } function isUndiciInstance (obj) { return obj instanceof undici.Pool || obj instanceof undici.Client || obj instanceof undici.Dispatcher || isRequestable(obj) } function buildRequest (opts) { const isHttp2 = !!opts.http2 if (Array.isArray(opts.base) && opts.base.length === 1) { opts.base = opts.base[0] } const hasUndiciOptions = shouldUseUndici(opts) const requests = { 'http:': http, 'https:': https, 'unix+http:': { base: http, request: unixRequest }, 'unix+https:': { base: https, request: unixRequest } } const http2Opts = getHttp2Opts(opts) const httpOpts = getHttpOpts(opts) const baseUrl = Array.isArray(opts.base) ? null : (opts.base && new URL(opts.base).origin) const isBalanced = Array.isArray(opts.base) && opts.base.length > 1 const undiciOpts = opts.undici || {} const globalAgent = opts.globalAgent const destroyAgent = opts.destroyAgent let http2Client let undiciAgent let undiciInstance let agents if (isHttp2) { if (!opts.base) return new Error('Option base is required when http2 is true') if (opts.base.startsWith('unix+')) { return new Error('Unix socket destination is not supported when http2 is true') } } else if (!globalAgent) { agents = httpOpts.agents || { 'http:': new http.Agent(httpOpts.agentOptions), 'https:': new https.Agent(httpOpts.agentOptions) } } else { agents = { 'http:': http.globalAgent, 'https:': https.globalAgent } } if (isHttp2) { return { request: handleHttp2Req, close, retryOnError: 'ECONNRESET' } } else if (hasUndiciOptions) { if (isBalanced) { const origins = opts.base.map(u => new URL(u).origin) undiciInstance = new undici.BalancedPool(origins, getUndiciOptions(opts.undici)) } else if (opts.base?.startsWith('unix+')) { const undiciOpts = getUndiciOptions(opts.undici) undiciOpts.socketPath = decodeURIComponent(new URL(opts.base).host) const protocol = opts.base.startsWith('unix+https') ? 'https' : 'http' undiciInstance = new undici.Pool(protocol + '://localhost', undiciOpts) } else if (isUndiciInstance(opts.undici)) { undiciInstance = opts.undici } else if (!globalAgent) { if (undiciOpts.proxy) { undiciAgent = new undici.ProxyAgent(getUndiciProxyOptions(opts.undici)) } else { undiciAgent = new undici.Agent(getUndiciOptions(opts.undici)) } } else { undiciAgent = undici.getGlobalDispatcher() } return { request: handleUndici, close, retryOnError: 'UND_ERR_SOCKET' } } else { return { request: handleHttp1Req, close, retryOnError: 'ECONNRESET' } } function close () { if (globalAgent || destroyAgent === false) { return } if (hasUndiciOptions) { undiciAgent?.destroy() undiciInstance?.destroy() } else if (!isHttp2) { agents['http:'].destroy() agents['https:'].destroy() } else if (http2Client) { http2Client.destroy() } } function handleHttp1Req (opts, done) { const req = requests[opts.url.protocol].request({ method: opts.method, port: opts.url.port, path: opts.url.pathname + opts.qs, hostname: opts.url.hostname, headers: opts.headers, agent: agents[opts.url.protocol.replace(/^unix:/, '')], ...httpOpts.requestOptions, timeout: opts.timeout ?? httpOpts.requestOptions.timeout }) req.on('error', done) req.on('response', res => { // remove timeout for sse connections if (res.headers['content-type'] === 'text/event-stream') { req.setTimeout(0) } done(null, { statusCode: res.statusCode, headers: res.headers, stream: res }) }) req.once('timeout', () => { const err = new HttpRequestTimeoutError() req.abort() done(err) }) end(req, opts.body, done) } function handleUndici (opts, done) { const req = { origin: baseUrl || opts.url.origin, path: opts.url.pathname + opts.qs, method: opts.method, headers: Object.assign({}, opts.headers), body: opts.body, headersTimeout: opts.timeout ?? undiciOpts.headersTimeout, bodyTimeout: opts.timeout ?? undiciOpts.bodyTimeout } let pool if (undiciInstance) { pool = undiciInstance } else if (pool instanceof undici.BalancedPool) { delete req.origin } else if (!baseUrl && opts.url.protocol.startsWith('unix')) { done(new Error('unix socket not supported with undici yet')) return } else { pool = undiciAgent } // remove forbidden headers req.headers.connection = undefined req.headers['transfer-encoding'] = undefined pool.request(req, function (err, res) { if (err) { done(err) return } // using delete, otherwise it will render as an empty string delete res.headers['transfer-encoding'] done(null, { statusCode: res.statusCode, headers: res.headers, stream: res.body }) }) } function handleHttp2Req (opts, done) { let cancelRequest let sessionTimedOut = false if (!http2Client || http2Client.destroyed || http2Client.closed) { if (http2Client && !http2Client.destroyed) http2Client.destroy() http2Client = http2.connect(baseUrl, http2Opts.sessionOptions) http2Client.once('error', done) // we might enqueue a large number of requests in this connection // before it's connected http2Client.setMaxListeners(0) http2Client.setTimeout(http2Opts.sessionTimeout, function () { if (cancelRequest) { cancelRequest() cancelRequest = undefined sessionTimedOut = true } http2Client.destroy() }) http2Client.once('connect', () => { // reset the max listener to 10 on connect http2Client.setMaxListeners(10) http2Client.removeListener('error', done) }) } const req = http2Client.request({ ':method': opts.method, ':path': opts.url.pathname + opts.qs, ...stripHttp1ConnectionHeaders(opts.headers) }, http2Opts.requestOptions) const isGet = opts.method === 'GET' || opts.method === 'get' const isDelete = opts.method === 'DELETE' || opts.method === 'delete' if (!isGet && !isDelete) { end(req, opts.body, done) } req.setTimeout(opts.timeout ?? http2Opts.requestTimeout, () => { const err = new Http2RequestTimeoutError() req.close(http2.constants.NGHTTP2_CANCEL) done(err) }) req.once('close', () => { if (sessionTimedOut) { const err = new Http2SessionTimeoutError() done(err) } }) cancelRequest = eos(req, err => { if (err) done(err) }) req.on('response', headers => { // remove timeout for sse connections if (headers['content-type'] === 'text/event-stream') { req.setTimeout(0) http2Client.setTimeout(0) } const statusCode = headers[':status'] done(null, { statusCode, headers, stream: req }) }) } } module.exports = buildRequest module.exports.TimeoutError = TimeoutError function unixRequest (opts) { delete opts.port opts.socketPath = querystring.unescape(opts.hostname) delete opts.hostname return this.base.request(opts) } function end (req, body, cb) { if (!body || typeof body === 'string' || body instanceof Uint8Array) { req.end(body) } else if (body.pipe) { pipeline(body, req, err => { if (err) cb(err) }) } else { cb(new Error(`type unsupported for body: ${body.constructor}`)) } } function getHttp2Opts (opts) { if (!opts.http2) { return {} } let http2Opts = opts.http2 if (typeof http2Opts === 'boolean') { http2Opts = {} } http2Opts.sessionOptions = http2Opts.sessionOptions || {} if (http2Opts.sessionTimeout === undefined) { http2Opts.sessionTimeout = opts.sessionTimeout || 60000 } if (http2Opts.requestTimeout === undefined) { http2Opts.requestTimeout = 10000 } http2Opts.sessionOptions.rejectUnauthorized = http2Opts.sessionOptions.rejectUnauthorized || false return http2Opts } function getHttpOpts (opts) { const httpOpts = typeof opts.http === 'object' ? opts.http : {} httpOpts.requestOptions = httpOpts.requestOptions || {} if (!httpOpts.requestOptions.timeout) { httpOpts.requestOptions.timeout = 10000 } httpOpts.requestOptions.rejectUnauthorized = httpOpts.requestOptions.rejectUnauthorized || false httpOpts.agentOptions = getAgentOptions(opts) return httpOpts } function getAgentOptions (opts) { return { keepAlive: true, keepAliveMsecs: 60 * 1000, // 1 minute maxSockets: 2048, maxFreeSockets: 2048, ...(opts.http?.agentOptions) } } function getUndiciProxyOptions ({ proxy, ...opts }) { if (typeof proxy === 'string' || proxy instanceof URL) { return getUndiciOptions({ uri: proxy, ...opts }) } return getUndiciOptions({ ...proxy, ...opts }) } function getUndiciOptions (opts = {}) { const res = { pipelining: 1, connections: 128, tls: {}, ...(opts) } res.tls.rejectUnauthorized = res.tls.rejectUnauthorized || false return res } module.exports.getUndiciOptions = getUndiciOptions