body.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
  1. 'use strict'
  2. const { Minipass } = require('minipass')
  3. const MinipassSized = require('minipass-sized')
  4. const Blob = require('./blob.js')
  5. const { BUFFER } = Blob
  6. const FetchError = require('./fetch-error.js')
  7. // optional dependency on 'encoding'
  8. let convert
  9. try {
  10. convert = require('encoding').convert
  11. } catch (e) {
  12. // defer error until textConverted is called
  13. }
  14. const INTERNALS = Symbol('Body internals')
  15. const CONSUME_BODY = Symbol('consumeBody')
  16. class Body {
  17. constructor (bodyArg, options = {}) {
  18. const { size = 0, timeout = 0 } = options
  19. const body = bodyArg === undefined || bodyArg === null ? null
  20. : isURLSearchParams(bodyArg) ? Buffer.from(bodyArg.toString())
  21. : isBlob(bodyArg) ? bodyArg
  22. : Buffer.isBuffer(bodyArg) ? bodyArg
  23. : Object.prototype.toString.call(bodyArg) === '[object ArrayBuffer]'
  24. ? Buffer.from(bodyArg)
  25. : ArrayBuffer.isView(bodyArg)
  26. ? Buffer.from(bodyArg.buffer, bodyArg.byteOffset, bodyArg.byteLength)
  27. : Minipass.isStream(bodyArg) ? bodyArg
  28. : Buffer.from(String(bodyArg))
  29. this[INTERNALS] = {
  30. body,
  31. disturbed: false,
  32. error: null,
  33. }
  34. this.size = size
  35. this.timeout = timeout
  36. if (Minipass.isStream(body)) {
  37. body.on('error', er => {
  38. const error = er.name === 'AbortError' ? er
  39. : new FetchError(`Invalid response while trying to fetch ${
  40. this.url}: ${er.message}`, 'system', er)
  41. this[INTERNALS].error = error
  42. })
  43. }
  44. }
  45. get body () {
  46. return this[INTERNALS].body
  47. }
  48. get bodyUsed () {
  49. return this[INTERNALS].disturbed
  50. }
  51. arrayBuffer () {
  52. return this[CONSUME_BODY]().then(buf =>
  53. buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength))
  54. }
  55. blob () {
  56. const ct = this.headers && this.headers.get('content-type') || ''
  57. return this[CONSUME_BODY]().then(buf => Object.assign(
  58. new Blob([], { type: ct.toLowerCase() }),
  59. { [BUFFER]: buf }
  60. ))
  61. }
  62. async json () {
  63. const buf = await this[CONSUME_BODY]()
  64. try {
  65. return JSON.parse(buf.toString())
  66. } catch (er) {
  67. throw new FetchError(
  68. `invalid json response body at ${this.url} reason: ${er.message}`,
  69. 'invalid-json'
  70. )
  71. }
  72. }
  73. text () {
  74. return this[CONSUME_BODY]().then(buf => buf.toString())
  75. }
  76. buffer () {
  77. return this[CONSUME_BODY]()
  78. }
  79. textConverted () {
  80. return this[CONSUME_BODY]().then(buf => convertBody(buf, this.headers))
  81. }
  82. [CONSUME_BODY] () {
  83. if (this[INTERNALS].disturbed) {
  84. return Promise.reject(new TypeError(`body used already for: ${
  85. this.url}`))
  86. }
  87. this[INTERNALS].disturbed = true
  88. if (this[INTERNALS].error) {
  89. return Promise.reject(this[INTERNALS].error)
  90. }
  91. // body is null
  92. if (this.body === null) {
  93. return Promise.resolve(Buffer.alloc(0))
  94. }
  95. if (Buffer.isBuffer(this.body)) {
  96. return Promise.resolve(this.body)
  97. }
  98. const upstream = isBlob(this.body) ? this.body.stream() : this.body
  99. /* istanbul ignore if: should never happen */
  100. if (!Minipass.isStream(upstream)) {
  101. return Promise.resolve(Buffer.alloc(0))
  102. }
  103. const stream = this.size && upstream instanceof MinipassSized ? upstream
  104. : !this.size && upstream instanceof Minipass &&
  105. !(upstream instanceof MinipassSized) ? upstream
  106. : this.size ? new MinipassSized({ size: this.size })
  107. : new Minipass()
  108. // allow timeout on slow response body, but only if the stream is still writable. this
  109. // makes the timeout center on the socket stream from lib/index.js rather than the
  110. // intermediary minipass stream we create to receive the data
  111. const resTimeout = this.timeout && stream.writable ? setTimeout(() => {
  112. stream.emit('error', new FetchError(
  113. `Response timeout while trying to fetch ${
  114. this.url} (over ${this.timeout}ms)`, 'body-timeout'))
  115. }, this.timeout) : null
  116. // do not keep the process open just for this timeout, even
  117. // though we expect it'll get cleared eventually.
  118. if (resTimeout && resTimeout.unref) {
  119. resTimeout.unref()
  120. }
  121. // do the pipe in the promise, because the pipe() can send too much
  122. // data through right away and upset the MP Sized object
  123. return new Promise((resolve) => {
  124. // if the stream is some other kind of stream, then pipe through a MP
  125. // so we can collect it more easily.
  126. if (stream !== upstream) {
  127. upstream.on('error', er => stream.emit('error', er))
  128. upstream.pipe(stream)
  129. }
  130. resolve()
  131. }).then(() => stream.concat()).then(buf => {
  132. clearTimeout(resTimeout)
  133. return buf
  134. }).catch(er => {
  135. clearTimeout(resTimeout)
  136. // request was aborted, reject with this Error
  137. if (er.name === 'AbortError' || er.name === 'FetchError') {
  138. throw er
  139. } else if (er.name === 'RangeError') {
  140. throw new FetchError(`Could not create Buffer from response body for ${
  141. this.url}: ${er.message}`, 'system', er)
  142. } else {
  143. // other errors, such as incorrect content-encoding or content-length
  144. throw new FetchError(`Invalid response body while trying to fetch ${
  145. this.url}: ${er.message}`, 'system', er)
  146. }
  147. })
  148. }
  149. static clone (instance) {
  150. if (instance.bodyUsed) {
  151. throw new Error('cannot clone body after it is used')
  152. }
  153. const body = instance.body
  154. // check that body is a stream and not form-data object
  155. // NB: can't clone the form-data object without having it as a dependency
  156. if (Minipass.isStream(body) && typeof body.getBoundary !== 'function') {
  157. // create a dedicated tee stream so that we don't lose data
  158. // potentially sitting in the body stream's buffer by writing it
  159. // immediately to p1 and not having it for p2.
  160. const tee = new Minipass()
  161. const p1 = new Minipass()
  162. const p2 = new Minipass()
  163. tee.on('error', er => {
  164. p1.emit('error', er)
  165. p2.emit('error', er)
  166. })
  167. body.on('error', er => tee.emit('error', er))
  168. tee.pipe(p1)
  169. tee.pipe(p2)
  170. body.pipe(tee)
  171. // set instance body to one fork, return the other
  172. instance[INTERNALS].body = p1
  173. return p2
  174. } else {
  175. return instance.body
  176. }
  177. }
  178. static extractContentType (body) {
  179. return body === null || body === undefined ? null
  180. : typeof body === 'string' ? 'text/plain;charset=UTF-8'
  181. : isURLSearchParams(body)
  182. ? 'application/x-www-form-urlencoded;charset=UTF-8'
  183. : isBlob(body) ? body.type || null
  184. : Buffer.isBuffer(body) ? null
  185. : Object.prototype.toString.call(body) === '[object ArrayBuffer]' ? null
  186. : ArrayBuffer.isView(body) ? null
  187. : typeof body.getBoundary === 'function'
  188. ? `multipart/form-data;boundary=${body.getBoundary()}`
  189. : Minipass.isStream(body) ? null
  190. : 'text/plain;charset=UTF-8'
  191. }
  192. static getTotalBytes (instance) {
  193. const { body } = instance
  194. return (body === null || body === undefined) ? 0
  195. : isBlob(body) ? body.size
  196. : Buffer.isBuffer(body) ? body.length
  197. : body && typeof body.getLengthSync === 'function' && (
  198. // detect form data input from form-data module
  199. body._lengthRetrievers &&
  200. /* istanbul ignore next */ body._lengthRetrievers.length === 0 || // 1.x
  201. body.hasKnownLength && body.hasKnownLength()) // 2.x
  202. ? body.getLengthSync()
  203. : null
  204. }
  205. static writeToStream (dest, instance) {
  206. const { body } = instance
  207. if (body === null || body === undefined) {
  208. dest.end()
  209. } else if (Buffer.isBuffer(body) || typeof body === 'string') {
  210. dest.end(body)
  211. } else {
  212. // body is stream or blob
  213. const stream = isBlob(body) ? body.stream() : body
  214. stream.on('error', er => dest.emit('error', er)).pipe(dest)
  215. }
  216. return dest
  217. }
  218. }
  219. Object.defineProperties(Body.prototype, {
  220. body: { enumerable: true },
  221. bodyUsed: { enumerable: true },
  222. arrayBuffer: { enumerable: true },
  223. blob: { enumerable: true },
  224. json: { enumerable: true },
  225. text: { enumerable: true },
  226. })
  227. const isURLSearchParams = obj =>
  228. // Duck-typing as a necessary condition.
  229. (typeof obj !== 'object' ||
  230. typeof obj.append !== 'function' ||
  231. typeof obj.delete !== 'function' ||
  232. typeof obj.get !== 'function' ||
  233. typeof obj.getAll !== 'function' ||
  234. typeof obj.has !== 'function' ||
  235. typeof obj.set !== 'function') ? false
  236. // Brand-checking and more duck-typing as optional condition.
  237. : obj.constructor.name === 'URLSearchParams' ||
  238. Object.prototype.toString.call(obj) === '[object URLSearchParams]' ||
  239. typeof obj.sort === 'function'
  240. const isBlob = obj =>
  241. typeof obj === 'object' &&
  242. typeof obj.arrayBuffer === 'function' &&
  243. typeof obj.type === 'string' &&
  244. typeof obj.stream === 'function' &&
  245. typeof obj.constructor === 'function' &&
  246. typeof obj.constructor.name === 'string' &&
  247. /^(Blob|File)$/.test(obj.constructor.name) &&
  248. /^(Blob|File)$/.test(obj[Symbol.toStringTag])
  249. const convertBody = (buffer, headers) => {
  250. /* istanbul ignore if */
  251. if (typeof convert !== 'function') {
  252. throw new Error('The package `encoding` must be installed to use the textConverted() function')
  253. }
  254. const ct = headers && headers.get('content-type')
  255. let charset = 'utf-8'
  256. let res
  257. // header
  258. if (ct) {
  259. res = /charset=([^;]*)/i.exec(ct)
  260. }
  261. // no charset in content type, peek at response body for at most 1024 bytes
  262. const str = buffer.slice(0, 1024).toString()
  263. // html5
  264. if (!res && str) {
  265. res = /<meta.+?charset=(['"])(.+?)\1/i.exec(str)
  266. }
  267. // html4
  268. if (!res && str) {
  269. res = /<meta[\s]+?http-equiv=(['"])content-type\1[\s]+?content=(['"])(.+?)\2/i.exec(str)
  270. if (!res) {
  271. res = /<meta[\s]+?content=(['"])(.+?)\1[\s]+?http-equiv=(['"])content-type\3/i.exec(str)
  272. if (res) {
  273. res.pop()
  274. } // drop last quote
  275. }
  276. if (res) {
  277. res = /charset=(.*)/i.exec(res.pop())
  278. }
  279. }
  280. // xml
  281. if (!res && str) {
  282. res = /<\?xml.+?encoding=(['"])(.+?)\1/i.exec(str)
  283. }
  284. // found charset
  285. if (res) {
  286. charset = res.pop()
  287. // prevent decode issues when sites use incorrect encoding
  288. // ref: https://hsivonen.fi/encoding-menu/
  289. if (charset === 'gb2312' || charset === 'gbk') {
  290. charset = 'gb18030'
  291. }
  292. }
  293. // turn raw buffers into a single utf-8 buffer
  294. return convert(
  295. buffer,
  296. 'UTF-8',
  297. charset
  298. ).toString()
  299. }
  300. module.exports = Body