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
|
---|