• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1'use strict'
2
3/**
4 * body.js
5 *
6 * Body interface provides common methods for Request and Response
7 */
8
9const Buffer = require('safe-buffer').Buffer
10
11const Blob = require('./blob.js')
12const BUFFER = Blob.BUFFER
13const convert = require('encoding').convert
14const parseJson = require('json-parse-better-errors')
15const FetchError = require('./fetch-error.js')
16const Stream = require('stream')
17
18const PassThrough = Stream.PassThrough
19const DISTURBED = Symbol('disturbed')
20
21/**
22 * Body class
23 *
24 * Cannot use ES6 class because Body must be called with .call().
25 *
26 * @param   Stream  body  Readable stream
27 * @param   Object  opts  Response options
28 * @return  Void
29 */
30exports = module.exports = Body
31
32function Body (body, opts) {
33  if (!opts) opts = {}
34  const size = opts.size == null ? 0 : opts.size
35  const timeout = opts.timeout == null ? 0 : opts.timeout
36  if (body == null) {
37    // body is undefined or null
38    body = null
39  } else if (typeof body === 'string') {
40    // body is string
41  } else if (body instanceof Blob) {
42    // body is blob
43  } else if (Buffer.isBuffer(body)) {
44    // body is buffer
45  } else if (body instanceof Stream) {
46    // body is stream
47  } else {
48    // none of the above
49    // coerce to string
50    body = String(body)
51  }
52  this.body = body
53  this[DISTURBED] = false
54  this.size = size
55  this.timeout = timeout
56}
57
58Body.prototype = {
59  get bodyUsed () {
60    return this[DISTURBED]
61  },
62
63  /**
64   * Decode response as ArrayBuffer
65   *
66   * @return  Promise
67   */
68  arrayBuffer () {
69    return consumeBody.call(this).then(buf => buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength))
70  },
71
72  /**
73   * Return raw response as Blob
74   *
75   * @return Promise
76   */
77  blob () {
78    let ct = (this.headers && this.headers.get('content-type')) || ''
79    return consumeBody.call(this).then(buf => Object.assign(
80      // Prevent copying
81      new Blob([], {
82        type: ct.toLowerCase()
83      }),
84      {
85        [BUFFER]: buf
86      }
87    ))
88  },
89
90  /**
91   * Decode response as json
92   *
93   * @return  Promise
94   */
95  json () {
96    return consumeBody.call(this).then(buffer => parseJson(buffer.toString()))
97  },
98
99  /**
100   * Decode response as text
101   *
102   * @return  Promise
103   */
104  text () {
105    return consumeBody.call(this).then(buffer => buffer.toString())
106  },
107
108  /**
109   * Decode response as buffer (non-spec api)
110   *
111   * @return  Promise
112   */
113  buffer () {
114    return consumeBody.call(this)
115  },
116
117  /**
118   * Decode response as text, while automatically detecting the encoding and
119   * trying to decode to UTF-8 (non-spec api)
120   *
121   * @return  Promise
122   */
123  textConverted () {
124    return consumeBody.call(this).then(buffer => convertBody(buffer, this.headers))
125  }
126
127}
128
129Body.mixIn = function (proto) {
130  for (const name of Object.getOwnPropertyNames(Body.prototype)) {
131    // istanbul ignore else: future proof
132    if (!(name in proto)) {
133      const desc = Object.getOwnPropertyDescriptor(Body.prototype, name)
134      Object.defineProperty(proto, name, desc)
135    }
136  }
137}
138
139/**
140 * Decode buffers into utf-8 string
141 *
142 * @return  Promise
143 */
144function consumeBody (body) {
145  if (this[DISTURBED]) {
146    return Body.Promise.reject(new Error(`body used already for: ${this.url}`))
147  }
148
149  this[DISTURBED] = true
150
151  // body is null
152  if (this.body === null) {
153    return Body.Promise.resolve(Buffer.alloc(0))
154  }
155
156  // body is string
157  if (typeof this.body === 'string') {
158    return Body.Promise.resolve(Buffer.from(this.body))
159  }
160
161  // body is blob
162  if (this.body instanceof Blob) {
163    return Body.Promise.resolve(this.body[BUFFER])
164  }
165
166  // body is buffer
167  if (Buffer.isBuffer(this.body)) {
168    return Body.Promise.resolve(this.body)
169  }
170
171  // istanbul ignore if: should never happen
172  if (!(this.body instanceof Stream)) {
173    return Body.Promise.resolve(Buffer.alloc(0))
174  }
175
176  // body is stream
177  // get ready to actually consume the body
178  let accum = []
179  let accumBytes = 0
180  let abort = false
181
182  return new Body.Promise((resolve, reject) => {
183    let resTimeout
184
185    // allow timeout on slow response body
186    if (this.timeout) {
187      resTimeout = setTimeout(() => {
188        abort = true
189        reject(new FetchError(`Response timeout while trying to fetch ${this.url} (over ${this.timeout}ms)`, 'body-timeout'))
190      }, this.timeout)
191    }
192
193    // handle stream error, such as incorrect content-encoding
194    this.body.on('error', err => {
195      reject(new FetchError(`Invalid response body while trying to fetch ${this.url}: ${err.message}`, 'system', err))
196    })
197
198    this.body.on('data', chunk => {
199      if (abort || chunk === null) {
200        return
201      }
202
203      if (this.size && accumBytes + chunk.length > this.size) {
204        abort = true
205        reject(new FetchError(`content size at ${this.url} over limit: ${this.size}`, 'max-size'))
206        return
207      }
208
209      accumBytes += chunk.length
210      accum.push(chunk)
211    })
212
213    this.body.on('end', () => {
214      if (abort) {
215        return
216      }
217
218      clearTimeout(resTimeout)
219      resolve(Buffer.concat(accum))
220    })
221  })
222}
223
224/**
225 * Detect buffer encoding and convert to target encoding
226 * ref: http://www.w3.org/TR/2011/WD-html5-20110113/parsing.html#determining-the-character-encoding
227 *
228 * @param   Buffer  buffer    Incoming buffer
229 * @param   String  encoding  Target encoding
230 * @return  String
231 */
232function convertBody (buffer, headers) {
233  const ct = headers.get('content-type')
234  let charset = 'utf-8'
235  let res, str
236
237  // header
238  if (ct) {
239    res = /charset=([^;]*)/i.exec(ct)
240  }
241
242  // no charset in content type, peek at response body for at most 1024 bytes
243  str = buffer.slice(0, 1024).toString()
244
245  // html5
246  if (!res && str) {
247    res = /<meta.+?charset=(['"])(.+?)\1/i.exec(str)
248  }
249
250  // html4
251  if (!res && str) {
252    res = /<meta[\s]+?http-equiv=(['"])content-type\1[\s]+?content=(['"])(.+?)\2/i.exec(str)
253
254    if (res) {
255      res = /charset=(.*)/i.exec(res.pop())
256    }
257  }
258
259  // xml
260  if (!res && str) {
261    res = /<\?xml.+?encoding=(['"])(.+?)\1/i.exec(str)
262  }
263
264  // found charset
265  if (res) {
266    charset = res.pop()
267
268    // prevent decode issues when sites use incorrect encoding
269    // ref: https://hsivonen.fi/encoding-menu/
270    if (charset === 'gb2312' || charset === 'gbk') {
271      charset = 'gb18030'
272    }
273  }
274
275  // turn raw buffers into a single utf-8 buffer
276  return convert(
277    buffer
278    , 'UTF-8'
279    , charset
280  ).toString()
281}
282
283/**
284 * Clone body given Res/Req instance
285 *
286 * @param   Mixed  instance  Response or Request instance
287 * @return  Mixed
288 */
289exports.clone = function clone (instance) {
290  let p1, p2
291  let body = instance.body
292
293  // don't allow cloning a used body
294  if (instance.bodyUsed) {
295    throw new Error('cannot clone body after it is used')
296  }
297
298  // check that body is a stream and not form-data object
299  // note: we can't clone the form-data object without having it as a dependency
300  if ((body instanceof Stream) && (typeof body.getBoundary !== 'function')) {
301    // tee instance body
302    p1 = new PassThrough()
303    p2 = new PassThrough()
304    body.pipe(p1)
305    body.pipe(p2)
306    // set instance body to teed body and return the other teed body
307    instance.body = p1
308    body = p2
309  }
310
311  return body
312}
313
314/**
315 * Performs the operation "extract a `Content-Type` value from |object|" as
316 * specified in the specification:
317 * https://fetch.spec.whatwg.org/#concept-bodyinit-extract
318 *
319 * This function assumes that instance.body is present and non-null.
320 *
321 * @param   Mixed  instance  Response or Request instance
322 */
323exports.extractContentType = function extractContentType (instance) {
324  const body = instance.body
325
326  // istanbul ignore if: Currently, because of a guard in Request, body
327  // can never be null. Included here for completeness.
328  if (body === null) {
329    // body is null
330    return null
331  } else if (typeof body === 'string') {
332    // body is string
333    return 'text/plain;charset=UTF-8'
334  } else if (body instanceof Blob) {
335    // body is blob
336    return body.type || null
337  } else if (Buffer.isBuffer(body)) {
338    // body is buffer
339    return null
340  } else if (typeof body.getBoundary === 'function') {
341    // detect form data input from form-data module
342    return `multipart/form-data;boundary=${body.getBoundary()}`
343  } else {
344    // body is stream
345    // can't really do much about this
346    return null
347  }
348}
349
350exports.getTotalBytes = function getTotalBytes (instance) {
351  const body = instance.body
352
353  // istanbul ignore if: included for completion
354  if (body === null) {
355    // body is null
356    return 0
357  } else if (typeof body === 'string') {
358    // body is string
359    return Buffer.byteLength(body)
360  } else if (body instanceof Blob) {
361    // body is blob
362    return body.size
363  } else if (Buffer.isBuffer(body)) {
364    // body is buffer
365    return body.length
366  } else if (body && typeof body.getLengthSync === 'function') {
367    // detect form data input from form-data module
368    if ((
369      // 1.x
370      body._lengthRetrievers &&
371      body._lengthRetrievers.length === 0
372    ) || (
373      // 2.x
374      body.hasKnownLength && body.hasKnownLength()
375    )) {
376      return body.getLengthSync()
377    }
378    return null
379  } else {
380    // body is stream
381    // can't really do much about this
382    return null
383  }
384}
385
386exports.writeToStream = function writeToStream (dest, instance) {
387  const body = instance.body
388
389  if (body === null) {
390    // body is null
391    dest.end()
392  } else if (typeof body === 'string') {
393    // body is string
394    dest.write(body)
395    dest.end()
396  } else if (body instanceof Blob) {
397    // body is blob
398    dest.write(body[BUFFER])
399    dest.end()
400  } else if (Buffer.isBuffer(body)) {
401    // body is buffer
402    dest.write(body)
403    dest.end()
404  } else {
405    // body is stream
406    body.pipe(dest)
407  }
408}
409
410// expose Promise
411Body.Promise = global.Promise
412