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