codekingpro/portable-devtools
114k
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 'iconv-lite'
10let decode
11try {
12 decode = require('iconv-lite').decode
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 /* istanbul ignore if */
96 if (typeof decode !== 'function') {
97 throw new Error('The package `iconv-lite` must be installed to use the textConverted() function')
98 }
99 return this[CONSUME_BODY]().then(buf => convertBody(buf, this.headers))
100 }
101
102 [CONSUME_BODY] () {
103 if (this[INTERNALS].disturbed) {
104 return Promise.reject(new TypeError(`body used already for: ${
105 this.url}`))
106 }
107
108 this[INTERNALS].disturbed = true
109
110 if (this[INTERNALS].error) {
111 return Promise.reject(this[INTERNALS].error)
112 }
113
114 // body is null
115 if (this.body === null) {
116 return Promise.resolve(Buffer.alloc(0))
117 }
118
119 if (Buffer.isBuffer(this.body)) {
120 return Promise.resolve(this.body)
121 }
122
123 const upstream = isBlob(this.body) ? this.body.stream() : this.body
124
125 /* istanbul ignore if: should never happen */
126 if (!Minipass.isStream(upstream)) {
127 return Promise.resolve(Buffer.alloc(0))
128 }
129
130 const stream = this.size && upstream instanceof MinipassSized ? upstream
131 : !this.size && upstream instanceof Minipass &&
132 !(upstream instanceof MinipassSized) ? upstream
133 : this.size ? new MinipassSized({ size: this.size })
134 : new Minipass()
135
136 // allow timeout on slow response body, but only if the stream is still writable. this
137 // makes the timeout center on the socket stream from lib/index.js rather than the
138 // intermediary minipass stream we create to receive the data
139 const resTimeout = this.timeout && stream.writable ? setTimeout(() => {
140 stream.emit('error', new FetchError(
141 `Response timeout while trying to fetch ${
142 this.url} (over ${this.timeout}ms)`, 'body-timeout'))
143 }, this.timeout) : null
144
145 // do not keep the process open just for this timeout, even
146 // though we expect it'll get cleared eventually.
147 if (resTimeout && resTimeout.unref) {
148 resTimeout.unref()
149 }
150
151 // do the pipe in the promise, because the pipe() can send too much
152 // data through right away and upset the MP Sized object
153 return new Promise((resolve) => {
154 // if the stream is some other kind of stream, then pipe through a MP
155 // so we can collect it more easily.
156 if (stream !== upstream) {
157 upstream.on('error', er => stream.emit('error', er))
158 upstream.pipe(stream)
159 }
160 resolve()
161 }).then(() => stream.concat()).then(buf => {
162 clearTimeout(resTimeout)
163 return buf
164 }).catch(er => {
165 clearTimeout(resTimeout)
166 // request was aborted, reject with this Error
167 if (er.name === 'AbortError' || er.name === 'FetchError') {
168 throw er
169 } else if (er.name === 'RangeError') {
170 throw new FetchError(`Could not create Buffer from response body for ${
171 this.url}: ${er.message}`, 'system', er)
172 } else {
173 // other errors, such as incorrect content-encoding or content-length
174 throw new FetchError(`Invalid response body while trying to fetch ${
175 this.url}: ${er.message}`, 'system', er)
176 }
177 })
178 }
179
180 static clone (instance) {
181 if (instance.bodyUsed) {
182 throw new Error('cannot clone body after it is used')
183 }
184
185 const body = instance.body
186
187 // check that body is a stream and not form-data object
188 // NB: can't clone the form-data object without having it as a dependency
189 if (Minipass.isStream(body) && typeof body.getBoundary !== 'function') {
190 // create a dedicated tee stream so that we don't lose data
191 // potentially sitting in the body stream's buffer by writing it
192 // immediately to p1 and not having it for p2.
193 const tee = new Minipass()
194 const p1 = new Minipass()
195 const p2 = new Minipass()
196 tee.on('error', er => {
197 p1.emit('error', er)
198 p2.emit('error', er)
199 })
200 body.on('error', er => tee.emit('error', er))
201 tee.pipe(p1)
202 tee.pipe(p2)
203 body.pipe(tee)
204 // set instance body to one fork, return the other
205 instance[INTERNALS].body = p1
206 return p2
207 } else {
208 return instance.body
209 }
210 }
211
212 static extractContentType (body) {
213 return body === null || body === undefined ? null
214 : typeof body === 'string' ? 'text/plain;charset=UTF-8'
215 : isURLSearchParams(body)
216 ? 'application/x-www-form-urlencoded;charset=UTF-8'
217 : isBlob(body) ? body.type || null
218 : Buffer.isBuffer(body) ? null
219 : Object.prototype.toString.call(body) === '[object ArrayBuffer]' ? null
220 : ArrayBuffer.isView(body) ? null
221 : typeof body.getBoundary === 'function'
222 ? `multipart/form-data;boundary=${body.getBoundary()}`
223 : Minipass.isStream(body) ? null
224 : 'text/plain;charset=UTF-8'
225 }
226
227 static getTotalBytes (instance) {
228 const { body } = instance
229 return (body === null || body === undefined) ? 0
230 : isBlob(body) ? body.size
231 : Buffer.isBuffer(body) ? body.length
232 : body && typeof body.getLengthSync === 'function' && (
233 // detect form data input from form-data module
234 body._lengthRetrievers &&
235 /* istanbul ignore next */ body._lengthRetrievers.length === 0 || // 1.x
236 body.hasKnownLength && body.hasKnownLength()) // 2.x
237 ? body.getLengthSync()
238 : null
239 }
240
241 static writeToStream (dest, instance) {
242 const { body } = instance
243
244 if (body === null || body === undefined) {
245 dest.end()
246 } else if (Buffer.isBuffer(body) || typeof body === 'string') {
247 dest.end(body)
248 } else {
249 // body is stream or blob
250 const stream = isBlob(body) ? body.stream() : body
251 stream.on('error', er => dest.emit('error', er)).pipe(dest)
252 }
253
254 return dest
255 }
256}
257
258Object.defineProperties(Body.prototype, {
259 body: { enumerable: true },
260 bodyUsed: { enumerable: true },
261 arrayBuffer: { enumerable: true },
262 blob: { enumerable: true },
263 json: { enumerable: true },
264 text: { enumerable: true },
265})
266
267const isURLSearchParams = obj =>
268 // Duck-typing as a necessary condition.
269 (typeof obj !== 'object' ||
270 typeof obj.append !== 'function' ||
271 typeof obj.delete !== 'function' ||
272 typeof obj.get !== 'function' ||
273 typeof obj.getAll !== 'function' ||
274 typeof obj.has !== 'function' ||
275 typeof obj.set !== 'function') ? false
276 // Brand-checking and more duck-typing as optional condition.
277 : obj.constructor.name === 'URLSearchParams' ||
278 Object.prototype.toString.call(obj) === '[object URLSearchParams]' ||
279 typeof obj.sort === 'function'
280
281const isBlob = obj =>
282 typeof obj === 'object' &&
283 typeof obj.arrayBuffer === 'function' &&
284 typeof obj.type === 'string' &&
285 typeof obj.stream === 'function' &&
286 typeof obj.constructor === 'function' &&
287 typeof obj.constructor.name === 'string' &&
288 /^(Blob|File)$/.test(obj.constructor.name) &&
289 /^(Blob|File)$/.test(obj[Symbol.toStringTag])
290
291const convertBody = (buffer, headers) => {
292 const ct = headers && headers.get('content-type')
293 let charset = 'utf-8'
294 let res
295
296 // header
297 if (ct) {
298 res = /charset=([^;]*)/i.exec(ct)
299 }
300
301 // no charset in content type, peek at response body for at most 1024 bytes
302 const str = buffer.slice(0, 1024).toString()
303
304 // html5
305 if (!res && str) {
306 res = /<meta.+?charset=(['"])(.+?)\1/i.exec(str)
307 }
308
309 // html4
310 if (!res && str) {
311 res = /<meta[\s]+?http-equiv=(['"])content-type\1[\s]+?content=(['"])(.+?)\2/i.exec(str)
312
313 if (!res) {
314 res = /<meta[\s]+?content=(['"])(.+?)\1[\s]+?http-equiv=(['"])content-type\3/i.exec(str)
315 if (res) {
316 res.pop()
317 } // drop last quote
318 }
319
320 if (res) {
321 res = /charset=(.*)/i.exec(res.pop())
322 }
323 }
324
325 // xml
326 if (!res && str) {
327 res = /<\?xml.+?encoding=(['"])(.+?)\1/i.exec(str)
328 }
329
330 // found charset
331 if (res) {
332 charset = res.pop()
333
334 // prevent decode issues when sites use incorrect encoding
335 // ref: https://hsivonen.fi/encoding-menu/
336 if (charset === 'gb2312' || charset === 'gbk') {
337 charset = 'gb18030'
338 }
339 }
340
341 if (charset === 'UTF-8') {
342 return buffer.toString('UTF-8')
343 }
344
345 charset = charset.toString().trim()
346 .replace(/^latin[-_]?(\d+)$/i, 'ISO-8859-$1')
347 .replace(/^win(?:dows)?[-_]?(\d+)$/i, 'WINDOWS-$1')
348 .replace(/^utf[-_]?(\d+)$/i, 'UTF-$1')
349 .replace(/^ks_c_5601-1987$/i, 'CP949')
350 .replace(/^us[-_]?ascii$/i, 'ASCII')
351 .toUpperCase()
352 try {
353 return decode(buffer, charset).toString()
354 } catch {
355 /* istanbul ignore next */
356 return buffer.toString('UTF-8')
357 }
358}
359
360module.exports = Body
361 