index.js 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304
  1. const _ = require('lodash')
  2. const { Readable } = require('stream')
  3. const OError = require('@overleaf/o-error')
  4. const fetch = require('node-fetch')
  5. const http = require('http')
  6. const https = require('https')
  7. /**
  8. * Make a request and return the parsed JSON response.
  9. *
  10. * @param {string | URL} url - request URL
  11. * @param {any} [opts] - fetch options
  12. * @return {Promise<any>} the parsed JSON response
  13. * @throws {RequestFailedError} if the response has a failure status code
  14. */
  15. async function fetchJson(url, opts = {}) {
  16. const { json } = await fetchJsonWithResponse(url, opts)
  17. return json
  18. }
  19. async function fetchJsonWithResponse(url, opts = {}) {
  20. const { fetchOpts } = parseOpts(opts)
  21. fetchOpts.headers = fetchOpts.headers ?? {}
  22. fetchOpts.headers.Accept = fetchOpts.headers.Accept ?? 'application/json'
  23. const response = await performRequest(url, fetchOpts)
  24. if (!response.ok) {
  25. const body = await maybeGetResponseBody(response)
  26. throw new RequestFailedError(url, opts, response, body)
  27. }
  28. const json = await response.json()
  29. return { json, response }
  30. }
  31. /**
  32. * Make a request and return a stream.
  33. *
  34. * If the response body is destroyed, the request is aborted.
  35. *
  36. * @param {string | URL} url - request URL
  37. * @param {any} [opts] - fetch options
  38. * @return {Promise<Readable>}
  39. * @throws {RequestFailedError} if the response has a failure status code
  40. */
  41. async function fetchStream(url, opts = {}) {
  42. const { stream } = await fetchStreamWithResponse(url, opts)
  43. return stream
  44. }
  45. async function fetchStreamWithResponse(url, opts = {}) {
  46. const { fetchOpts, abortController } = parseOpts(opts)
  47. const response = await performRequest(url, fetchOpts)
  48. if (!response.ok) {
  49. const body = await maybeGetResponseBody(response)
  50. throw new RequestFailedError(url, opts, response, body)
  51. }
  52. abortOnDestroyedResponse(abortController, response)
  53. const stream = response.body
  54. return { stream, response }
  55. }
  56. /**
  57. * Make a request and discard the response.
  58. *
  59. * @param {string | URL} url - request URL
  60. * @param {any} [opts] - fetch options
  61. * @return {Promise<Response>}
  62. * @throws {RequestFailedError} if the response has a failure status code
  63. */
  64. async function fetchNothing(url, opts = {}) {
  65. const { fetchOpts } = parseOpts(opts)
  66. const response = await performRequest(url, fetchOpts)
  67. if (!response.ok) {
  68. const body = await maybeGetResponseBody(response)
  69. throw new RequestFailedError(url, opts, response, body)
  70. }
  71. await discardResponseBody(response)
  72. return response
  73. }
  74. /**
  75. * Make a request and extract the redirect from the response.
  76. *
  77. * @param {string | URL} url - request URL
  78. * @param {any} [opts] - fetch options
  79. * @return {Promise<string>}
  80. * @throws {RequestFailedError} if the response has a non redirect status code or missing Location header
  81. */
  82. async function fetchRedirect(url, opts = {}) {
  83. const { fetchOpts } = parseOpts(opts)
  84. fetchOpts.redirect = 'manual'
  85. const response = await performRequest(url, fetchOpts)
  86. if (response.status < 300 || response.status >= 400) {
  87. const body = await maybeGetResponseBody(response)
  88. throw new RequestFailedError(url, opts, response, body)
  89. }
  90. const location = response.headers.get('Location')
  91. if (!location) {
  92. const body = await maybeGetResponseBody(response)
  93. throw new RequestFailedError(url, opts, response, body).withCause(
  94. new OError('missing Location response header on 3xx response', {
  95. headers: Object.fromEntries(response.headers.entries()),
  96. })
  97. )
  98. }
  99. await discardResponseBody(response)
  100. return location
  101. }
  102. /**
  103. * Make a request and return a string.
  104. *
  105. * @param {string | URL} url - request URL
  106. * @param {any} [opts] - fetch options
  107. * @return {Promise<string>}
  108. * @throws {RequestFailedError} if the response has a failure status code
  109. */
  110. async function fetchString(url, opts = {}) {
  111. const { body } = await fetchStringWithResponse(url, opts)
  112. return body
  113. }
  114. async function fetchStringWithResponse(url, opts = {}) {
  115. const { fetchOpts } = parseOpts(opts)
  116. const response = await performRequest(url, fetchOpts)
  117. if (!response.ok) {
  118. const body = await maybeGetResponseBody(response)
  119. throw new RequestFailedError(url, opts, response, body)
  120. }
  121. const body = await response.text()
  122. return { body, response }
  123. }
  124. class RequestFailedError extends OError {
  125. constructor(url, opts, response, body) {
  126. super('request failed', {
  127. url,
  128. method: opts.method ?? 'GET',
  129. status: response.status,
  130. })
  131. this.response = response
  132. if (body != null) {
  133. this.body = body
  134. }
  135. }
  136. }
  137. function parseOpts(opts) {
  138. const fetchOpts = _.omit(opts, ['json', 'signal', 'basicAuth'])
  139. if (opts.json) {
  140. setupJsonBody(fetchOpts, opts.json)
  141. }
  142. if (opts.basicAuth) {
  143. setupBasicAuth(fetchOpts, opts.basicAuth)
  144. }
  145. const abortController = new AbortController()
  146. fetchOpts.signal = abortController.signal
  147. if (opts.signal) {
  148. abortOnSignal(abortController, opts.signal)
  149. }
  150. if (opts.body instanceof Readable) {
  151. abortOnDestroyedRequest(abortController, fetchOpts.body)
  152. }
  153. return { fetchOpts, abortController }
  154. }
  155. function setupJsonBody(fetchOpts, json) {
  156. fetchOpts.body = JSON.stringify(json)
  157. fetchOpts.headers = fetchOpts.headers ?? {}
  158. fetchOpts.headers['Content-Type'] = 'application/json'
  159. }
  160. function setupBasicAuth(fetchOpts, basicAuth) {
  161. fetchOpts.headers = fetchOpts.headers ?? {}
  162. fetchOpts.headers.Authorization =
  163. 'Basic ' +
  164. Buffer.from(`${basicAuth.user}:${basicAuth.password}`).toString('base64')
  165. }
  166. function abortOnSignal(abortController, signal) {
  167. const listener = () => {
  168. abortController.abort(signal.reason)
  169. }
  170. if (signal.aborted) {
  171. abortController.abort(signal.reason)
  172. }
  173. signal.addEventListener('abort', listener)
  174. }
  175. function abortOnDestroyedRequest(abortController, stream) {
  176. stream.on('close', () => {
  177. if (!stream.readableEnded) {
  178. abortController.abort()
  179. }
  180. })
  181. }
  182. function abortOnDestroyedResponse(abortController, response) {
  183. response.body.on('close', () => {
  184. if (!response.bodyUsed) {
  185. abortController.abort()
  186. }
  187. })
  188. }
  189. async function performRequest(url, fetchOpts) {
  190. let response
  191. try {
  192. response = await fetch(url, fetchOpts)
  193. } catch (err) {
  194. if (fetchOpts.body instanceof Readable) {
  195. fetchOpts.body.destroy()
  196. }
  197. throw OError.tag(err, err.message, {
  198. url,
  199. method: fetchOpts.method ?? 'GET',
  200. })
  201. }
  202. if (fetchOpts.body instanceof Readable) {
  203. response.body.on('close', () => {
  204. if (!fetchOpts.body.readableEnded) {
  205. fetchOpts.body.destroy()
  206. }
  207. })
  208. }
  209. return response
  210. }
  211. async function discardResponseBody(response) {
  212. // eslint-disable-next-line no-unused-vars
  213. for await (const chunk of response.body) {
  214. // discard the body
  215. }
  216. }
  217. /**
  218. * @typedef {import('node-fetch').Response} Response
  219. *
  220. * @param {Response} response
  221. */
  222. async function maybeGetResponseBody(response) {
  223. try {
  224. return await response.text()
  225. } catch (err) {
  226. return null
  227. }
  228. }
  229. // Define custom http and https agents with support for connect timeouts
  230. class ConnectTimeoutError extends OError {
  231. constructor(options) {
  232. super('connect timeout', options)
  233. }
  234. }
  235. function withTimeout(createConnection, options, callback) {
  236. if (options.connectTimeout) {
  237. // Wrap createConnection in a timeout
  238. const timer = setTimeout(() => {
  239. socket.destroy(new ConnectTimeoutError(options))
  240. }, options.connectTimeout)
  241. const socket = createConnection(options, (err, stream) => {
  242. clearTimeout(timer)
  243. callback(err, stream)
  244. })
  245. return socket
  246. } else {
  247. // Fallback to default createConnection
  248. return createConnection(options, callback)
  249. }
  250. }
  251. class CustomHttpAgent extends http.Agent {
  252. createConnection(options, callback) {
  253. return withTimeout(super.createConnection.bind(this), options, callback)
  254. }
  255. }
  256. class CustomHttpsAgent extends https.Agent {
  257. createConnection(options, callback) {
  258. return withTimeout(super.createConnection.bind(this), options, callback)
  259. }
  260. }
  261. module.exports = {
  262. fetchJson,
  263. fetchJsonWithResponse,
  264. fetchStream,
  265. fetchStreamWithResponse,
  266. fetchNothing,
  267. fetchRedirect,
  268. fetchString,
  269. fetchStringWithResponse,
  270. RequestFailedError,
  271. ConnectTimeoutError,
  272. CustomHttpAgent,
  273. CustomHttpsAgent,
  274. }