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