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