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