get-it
Version:
Generic HTTP request library for node, browsers and workers
346 lines (296 loc) • 11.9 kB
text/typescript
import decompressResponse from 'decompress-response'
import follow, {type FollowResponse, type RedirectableRequest} from 'follow-redirects'
import type {FinalizeNodeOptionsPayload, HttpRequest, MiddlewareResponse} from 'get-it'
import http from 'http'
import https from 'https'
import qs from 'querystring'
import {Readable, type Stream} from 'stream'
import url from 'url'
import type {RequestAdapter} from '../types'
import {lowerCaseHeaders} from '../util/lowerCaseHeaders'
import {progressStream} from '../util/progress-stream'
import {getProxyOptions, rewriteUriForProxy} from './node/proxy'
import {concat} from './node/simpleConcat'
import {timedOut} from './node/timedOut'
import * as tunneling from './node/tunnel'
/**
* Taken from:
* https://github.com/sindresorhus/is-stream/blob/fb8caed475b4107cee3c22be3252a904020eb2d4/index.js#L3-L6
*/
const isStream = (stream: any): stream is Stream =>
stream !== null && typeof stream === 'object' && typeof stream.pipe === 'function'
/** @public */
export const adapter: RequestAdapter = 'node'
export class NodeRequestError extends Error {
request: http.ClientRequest
code?: string | undefined
constructor(err: NodeJS.ErrnoException, req: any) {
super(err.message)
this.request = req
this.code = err.code
}
}
// Reduce a fully fledged node-style response object to
// something that works in both browser and node environment
const reduceResponse = (
res: http.IncomingMessage,
remoteAddress: string | undefined,
reqUrl: string,
method: string,
body: any,
): MiddlewareResponse => ({
body,
url: reqUrl,
method: method,
headers: res.headers,
statusCode: res.statusCode || 0,
statusMessage: res.statusMessage || '',
remoteAddress,
})
export const httpRequester: HttpRequest = (context, cb) => {
const {options} = context
const uri = Object.assign({}, url.parse(options.url))
if (typeof fetch === 'function' && options.fetch) {
const controller = new AbortController()
const reqOpts = context.applyMiddleware('finalizeOptions', {
...uri,
method: options.method,
headers: {
...(typeof options.fetch === 'object' && options.fetch.headers
? lowerCaseHeaders(options.fetch.headers)
: {}),
...lowerCaseHeaders(options.headers),
},
maxRedirects: options.maxRedirects,
}) as FinalizeNodeOptionsPayload
const fetchOpts = {
credentials: options.withCredentials ? 'include' : 'omit',
...(typeof options.fetch === 'object' ? options.fetch : {}),
method: reqOpts.method,
headers: reqOpts.headers,
body: options.body,
signal: controller.signal,
} satisfies RequestInit
// Allow middleware to inject a response, for instance in the case of caching or mocking
const injectedResponse = context.applyMiddleware('interceptRequest', undefined, {
adapter,
context,
})
// If middleware injected a response, treat it as we normally would and return it
// Do note that the injected response has to be reduced to a cross-environment friendly response
if (injectedResponse) {
const cbTimer = setTimeout(cb, 0, null, injectedResponse)
const cancel = () => clearTimeout(cbTimer)
return {abort: cancel}
}
const request = fetch(options.url, fetchOpts)
// Let middleware know we're about to do a request
context.applyMiddleware('onRequest', {options, adapter, request, context})
request
.then(async (res) => {
const body = options.rawBody ? res.body : await res.text()
const headers = {} as Record<string, string>
res.headers.forEach((value, key) => {
headers[key] = value
})
cb(null, {
body,
url: res.url,
method: options.method!,
headers,
statusCode: res.status,
statusMessage: res.statusText,
})
})
.catch((err) => {
if (err.name == 'AbortError') return
cb(err)
})
return {abort: () => controller.abort()}
}
const bodyType = isStream(options.body) ? 'stream' : typeof options.body
if (
bodyType !== 'undefined' &&
bodyType !== 'stream' &&
bodyType !== 'string' &&
!Buffer.isBuffer(options.body)
) {
throw new Error(`Request body must be a string, buffer or stream, got ${bodyType}`)
}
const lengthHeader: any = {}
if (options.bodySize) {
lengthHeader['content-length'] = options.bodySize
} else if (options.body && bodyType !== 'stream') {
lengthHeader['content-length'] = Buffer.byteLength(options.body)
}
// Make sure callback is not called in the event of a cancellation
let aborted = false
const callback = (err: Error | null, res?: MiddlewareResponse) => !aborted && cb(err, res)
context.channels.abort.subscribe(() => {
aborted = true
})
// Create a reduced subset of options meant for the http.request() method
let reqOpts: any = Object.assign({}, uri, {
method: options.method,
headers: Object.assign({}, lowerCaseHeaders(options.headers), lengthHeader),
maxRedirects: options.maxRedirects,
})
// Figure out proxying/tunnel options
const proxy = getProxyOptions(options)
const tunnel = proxy && tunneling.shouldEnable(options)
// Allow middleware to inject a response, for instance in the case of caching or mocking
const injectedResponse = context.applyMiddleware('interceptRequest', undefined, {
adapter,
context,
})
// If middleware injected a response, treat it as we normally would and return it
// Do note that the injected response has to be reduced to a cross-environment friendly response
if (injectedResponse) {
const cbTimer = setImmediate(callback, null, injectedResponse)
const abort = () => clearImmediate(cbTimer)
return {abort}
}
// We're using the follow-redirects module to transparently follow redirects
if (options.maxRedirects !== 0) {
reqOpts.maxRedirects = options.maxRedirects || 5
}
// Apply currect options for proxy tunneling, if enabled
if (proxy && tunnel) {
reqOpts = tunneling.applyAgent(reqOpts, proxy)
} else if (proxy && !tunnel) {
reqOpts = rewriteUriForProxy(reqOpts, uri, proxy)
}
// Handle proxy authorization if present
if (!tunnel && proxy && proxy.auth && !reqOpts.headers['proxy-authorization']) {
const [username, password] =
typeof proxy.auth === 'string'
? proxy.auth.split(':').map((item) => qs.unescape(item))
: [proxy.auth.username, proxy.auth.password]
const auth = Buffer.from(`${username}:${password}`, 'utf8')
const authBase64 = auth.toString('base64')
reqOpts.headers['proxy-authorization'] = `Basic ${authBase64}`
}
// Figure out transport (http/https, forwarding/non-forwarding agent)
const transport = getRequestTransport(reqOpts, proxy, tunnel)
if (typeof options.debug === 'function' && proxy) {
options.debug(
'Proxying using %s',
reqOpts.agent ? 'tunnel agent' : `${reqOpts.host}:${reqOpts.port}`,
)
}
// See if we should try to request a compressed response (and decompress on return)
const tryCompressed = reqOpts.method !== 'HEAD'
if (tryCompressed && !reqOpts.headers['accept-encoding'] && options.compress !== false) {
reqOpts.headers['accept-encoding'] =
// Workaround Bun not supporting brotli: https://github.com/oven-sh/bun/issues/267
typeof Bun !== 'undefined' ? 'gzip, deflate' : 'br, gzip, deflate'
}
let _res: http.IncomingMessage | undefined
const finalOptions = context.applyMiddleware(
'finalizeOptions',
reqOpts,
) as FinalizeNodeOptionsPayload
const request = transport.request(finalOptions, (response) => {
const res = tryCompressed ? decompressResponse(response) : response
_res = res
const resStream = context.applyMiddleware('onHeaders', res, {
headers: response.headers,
adapter,
context,
})
// On redirects, `responseUrl` is set
const reqUrl = 'responseUrl' in response ? response.responseUrl : options.url
// Get the remote address from the socket, if available. After the stream is consumed, the socket might be closed, so we grab it here.
const remoteAddress = res.socket?.remoteAddress
if (options.stream) {
callback(null, reduceResponse(res, remoteAddress, reqUrl, reqOpts.method, resStream))
return
}
// Concatenate the response body, then parse the response with middlewares
concat(resStream, (err: any, data: any) => {
if (err) {
return callback(err)
}
const body = options.rawBody ? data : data.toString()
const reduced = reduceResponse(res, remoteAddress, reqUrl, reqOpts.method, body)
return callback(null, reduced)
})
})
function onError(err: NodeJS.ErrnoException) {
// HACK: If we have a socket error, and response has already been assigned this means
// that a response has already been sent. According to node.js docs, this is
// will result in the response erroring with an error code of 'ECONNRESET'.
// We first destroy the response, then the request, with the same error. This way the
// error is forwarded to both the response and the request.
// See the event order outlined here https://nodejs.org/api/http.html#httprequesturl-options-callback for how node.js handles the different scenarios.
if (_res) _res.destroy(err)
request.destroy(err)
}
request.once('socket', (socket: NodeJS.Socket) => {
socket.once('error', onError)
request.once('response', (response) => {
response.once('end', () => {
socket.removeListener('error', onError)
})
})
})
request.once('error', (err: NodeJS.ErrnoException) => {
if (_res) return
// The callback has already been invoked. Any error should be sent to the response.
callback(new NodeRequestError(err, request))
})
if (options.timeout) {
timedOut(request, options.timeout)
}
// Cheating a bit here; since we're not concerned about the "bundle size" in node,
// and modifying the body stream would be sorta tricky, we're just always going
// to put a progress stream in the middle here.
const {bodyStream, progress} = getProgressStream(options)
// Let middleware know we're about to do a request
context.applyMiddleware('onRequest', {options, adapter, request, context, progress})
if (bodyStream) {
bodyStream.pipe(request)
} else {
request.end(options.body)
}
return {abort: () => request.abort()}
}
function getProgressStream(options: any) {
if (!options.body) {
return {}
}
const bodyIsStream = isStream(options.body)
const length = options.bodySize || (bodyIsStream ? null : Buffer.byteLength(options.body))
if (!length) {
return bodyIsStream ? {bodyStream: options.body} : {}
}
const progress = progressStream({time: 32, length})
const bodyStream = bodyIsStream ? options.body : Readable.from(options.body)
return {bodyStream: bodyStream.pipe(progress), progress}
}
function getRequestTransport(
reqOpts: any,
proxy: any,
tunnel: any,
): {
request: (
options: any,
callback: (response: http.IncomingMessage | (http.IncomingMessage & FollowResponse)) => void,
) => http.ClientRequest | RedirectableRequest<http.ClientRequest, http.IncomingMessage>
} {
const isHttpsRequest = reqOpts.protocol === 'https:'
const transports =
reqOpts.maxRedirects === 0
? {http: http, https: https}
: {http: follow.http, https: follow.https}
if (!proxy || tunnel) {
return isHttpsRequest ? transports.https : transports.http
}
// Assume the proxy is an HTTPS proxy if port is 443, or if there is a
// `protocol` option set that starts with https
let isHttpsProxy = proxy.port === 443
if (proxy.protocol) {
isHttpsProxy = /^https:?/.test(proxy.protocol)
}
return isHttpsProxy ? transports.https : transports.http
}