Skip to content

Commit a2e88e2

Browse files
authored
feat: add onUploadProgress handler (#60)
1 parent d9324b7 commit a2e88e2

File tree

7 files changed

+335
-22
lines changed

7 files changed

+335
-22
lines changed

package.json

+4-2
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"dist"
1313
],
1414
"browser": {
15+
"./src/http/fetch.js": "./src/http/fetch.browser.js",
1516
"./src/text-encoder.js": "./src/text-encoder.browser.js",
1617
"./src/text-decoder.js": "./src/text-decoder.browser.js",
1718
"./src/temp-dir.js": "./src/temp-dir.browser.js",
@@ -48,15 +49,16 @@
4849
"native-abort-controller": "0.0.3",
4950
"native-fetch": "^2.0.0",
5051
"node-fetch": "^2.6.0",
51-
"stream-to-it": "^0.2.0"
52+
"stream-to-it": "^0.2.0",
53+
"it-to-stream": "^0.1.2"
5254
},
5355
"devDependencies": {
5456
"aegir": "^28.1.0",
5557
"delay": "^4.3.0",
5658
"it-all": "^1.0.2",
5759
"it-drain": "^1.0.1",
5860
"it-last": "^1.0.2",
59-
"it-to-stream": "^0.1.2"
61+
"uint8arrays": "^1.1.0"
6062
},
6163
"contributors": [
6264
"Hugo Dias <[email protected]>",

src/http.js

+4-20
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,14 @@
11
/* eslint-disable no-undef */
22
'use strict'
33

4-
const {
5-
default: fetch,
6-
Request,
7-
Headers
8-
} = require('./fetch')
4+
const { fetch, Request, Headers } = require('./http/fetch')
5+
const { TimeoutError, HTTPError } = require('./http/error')
96
const merge = require('merge-options').bind({ ignoreUndefined: true })
107
const { URL, URLSearchParams } = require('iso-url')
118
const TextDecoder = require('./text-decoder')
129
const AbortController = require('native-abort-controller')
1310
const anySignal = require('any-signal')
1411

15-
class TimeoutError extends Error {
16-
constructor () {
17-
super('Request timed out')
18-
this.name = 'TimeoutError'
19-
}
20-
}
21-
22-
class HTTPError extends Error {
23-
constructor (response) {
24-
super(response.statusText)
25-
this.name = 'HTTPError'
26-
this.response = response
27-
}
28-
}
29-
3012
const timeout = (promise, ms, abortController) => {
3113
if (ms === undefined) {
3214
return promise
@@ -88,6 +70,8 @@ const defaults = {
8870
* @property {function(URLSearchParams): URLSearchParams } [transformSearchParams]
8971
* @property {function(any): any} [transform] - When iterating the response body, transform each chunk with this function.
9072
* @property {function(Response): Promise<void>} [handleError] - Handle errors
73+
* @property {function({total:number, loaded:number, lengthComputable:boolean}):void} [onUploadProgress] - Can be passed to track upload progress.
74+
* Note that if this option in passed underlying request will be performed using `XMLHttpRequest` and response will not be streamed.
9175
*/
9276

9377
class HTTP {

src/http/error.js

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
'use strict'
2+
3+
class TimeoutError extends Error {
4+
constructor (message = 'Request timed out') {
5+
super(message)
6+
this.name = 'TimeoutError'
7+
}
8+
}
9+
exports.TimeoutError = TimeoutError
10+
11+
class AbortError extends Error {
12+
constructor (message = 'The operation was aborted.') {
13+
super(message)
14+
this.name = 'AbortError'
15+
}
16+
}
17+
exports.AbortError = AbortError
18+
19+
class HTTPError extends Error {
20+
constructor (response) {
21+
super(response.statusText)
22+
this.name = 'HTTPError'
23+
this.response = response
24+
}
25+
}
26+
exports.HTTPError = HTTPError

src/http/fetch.browser.js

+135
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
'use strict'
2+
/* eslint-env browser */
3+
4+
const { TimeoutError, AbortError } = require('./error')
5+
const { Request, Response, Headers } = require('../fetch')
6+
7+
/**
8+
* @typedef {RequestInit & ExtraFetchOptions} FetchOptions
9+
* @typedef {Object} ExtraFetchOptions
10+
* @property {number} [timeout]
11+
* @property {URLSearchParams} [searchParams]
12+
* @property {function({total:number, loaded:number, lengthComputable:boolean}):void} [onUploadProgress]
13+
* @property {string} [overrideMimeType]
14+
* @returns {Promise<Response>}
15+
*/
16+
17+
/**
18+
* @param {string|URL} url
19+
* @param {FetchOptions} [options]
20+
* @returns {Promise<Response>}
21+
*/
22+
const fetchWithProgress = (url, options = {}) => {
23+
const request = new XMLHttpRequest()
24+
request.open(options.method || 'GET', url.toString(), true)
25+
26+
const { timeout } = options
27+
if (timeout > 0 && timeout < Infinity) {
28+
request.timeout = options.timeout
29+
}
30+
31+
if (options.overrideMimeType != null) {
32+
request.overrideMimeType(options.overrideMimeType)
33+
}
34+
35+
if (options.headers) {
36+
for (const [name, value] of options.headers.entries()) {
37+
request.setRequestHeader(name, value)
38+
}
39+
}
40+
41+
if (options.signal) {
42+
options.signal.onabort = () => request.abort()
43+
}
44+
45+
if (options.onUploadProgress) {
46+
request.upload.onprogress = options.onUploadProgress
47+
}
48+
49+
// Note: Need to use `arraybuffer` here instead of `blob` because `Blob`
50+
// instances coming from JSDOM are not compatible with `Response` from
51+
// node-fetch (which is the setup we get when testing with jest because
52+
// it uses JSDOM which does not provide a global fetch
53+
// https://github.com/jsdom/jsdom/issues/1724)
54+
request.responseType = 'arraybuffer'
55+
56+
return new Promise((resolve, reject) => {
57+
/**
58+
* @param {Event} event
59+
*/
60+
const handleEvent = (event) => {
61+
switch (event.type) {
62+
case 'error': {
63+
resolve(Response.error())
64+
break
65+
}
66+
case 'load': {
67+
resolve(
68+
new ResponseWithURL(request.responseURL, request.response, {
69+
status: request.status,
70+
statusText: request.statusText,
71+
headers: parseHeaders(request.getAllResponseHeaders())
72+
})
73+
)
74+
break
75+
}
76+
case 'timeout': {
77+
reject(new TimeoutError())
78+
break
79+
}
80+
case 'abort': {
81+
reject(new AbortError())
82+
break
83+
}
84+
default: {
85+
break
86+
}
87+
}
88+
}
89+
request.onerror = handleEvent
90+
request.onload = handleEvent
91+
request.ontimeout = handleEvent
92+
request.onabort = handleEvent
93+
94+
request.send(options.body)
95+
})
96+
}
97+
98+
const fetchWithStreaming = fetch
99+
100+
const fetchWith = (url, options = {}) =>
101+
(options.onUploadProgress != null)
102+
? fetchWithProgress(url, options)
103+
: fetchWithStreaming(url, options)
104+
105+
exports.fetch = fetchWith
106+
exports.Request = Request
107+
exports.Headers = Headers
108+
109+
/**
110+
* @param {string} input
111+
* @returns {Headers}
112+
*/
113+
const parseHeaders = (input) => {
114+
const headers = new Headers()
115+
for (const line of input.trim().split(/[\r\n]+/)) {
116+
const index = line.indexOf(': ')
117+
if (index > 0) {
118+
headers.set(line.slice(0, index), line.slice(index + 1))
119+
}
120+
}
121+
122+
return headers
123+
}
124+
125+
class ResponseWithURL extends Response {
126+
/**
127+
* @param {string} url
128+
* @param {string|Blob|ArrayBufferView|ArrayBuffer|FormData|ReadableStream<Uint8Array>} body
129+
* @param {ResponseInit} options
130+
*/
131+
constructor (url, body, options) {
132+
super(body, options)
133+
Object.defineProperty(this, 'url', { value: url })
134+
}
135+
}

src/http/fetch.js

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
'use strict'
2+
3+
// Electron has `XMLHttpRequest` and should get the browser implementation
4+
// instead of node.
5+
if (typeof XMLHttpRequest === 'function') {
6+
module.exports = require('./fetch.browser')
7+
} else {
8+
module.exports = require('./fetch.node')
9+
}

src/http/fetch.node.js

+126
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
// @ts-check
2+
'use strict'
3+
4+
const { Request, Response, Headers, default: nodeFetch } = require('../fetch')
5+
const toStream = require('it-to-stream')
6+
const { Buffer } = require('buffer')
7+
8+
/**
9+
* @typedef {RequestInit & ExtraFetchOptions} FetchOptions
10+
*
11+
* @typedef {import('stream').Readable} Readable
12+
* @typedef {Object} LoadProgress
13+
* @property {number} total
14+
* @property {number} loaded
15+
* @property {boolean} lengthComputable
16+
* @typedef {Object} ExtraFetchOptions
17+
* @property {number} [timeout]
18+
* @property {URLSearchParams} [searchParams]
19+
* @property {function(LoadProgress):void} [onUploadProgress]
20+
* @property {function(LoadProgress):void} [onDownloadProgress]
21+
* @property {string} [overrideMimeType]
22+
* @returns {Promise<Response>}
23+
*/
24+
25+
/**
26+
* @param {string|URL} url
27+
* @param {FetchOptions} [options]
28+
* @returns {Promise<Response>}
29+
*/
30+
const fetch = (url, options = {}) =>
31+
// @ts-ignore
32+
nodeFetch(url, withUploadProgress(options))
33+
34+
exports.fetch = fetch
35+
exports.Request = Request
36+
exports.Headers = Headers
37+
38+
/**
39+
* Takes fetch options and wraps request body to track upload progress if
40+
* `onUploadProgress` is supplied. Otherwise returns options as is.
41+
*
42+
* @param {FetchOptions} options
43+
* @returns {FetchOptions}
44+
*/
45+
const withUploadProgress = (options) => {
46+
const { onUploadProgress } = options
47+
if (onUploadProgress) {
48+
return {
49+
...options,
50+
// @ts-ignore
51+
body: bodyWithUploadProgress(options, onUploadProgress)
52+
}
53+
} else {
54+
return options
55+
}
56+
}
57+
58+
/**
59+
* Takes request `body` and `onUploadProgress` handler and returns wrapped body
60+
* that as consumed will report progress to supplied `onUploadProgress` handler.
61+
*
62+
* @param {FetchOptions} init
63+
* @param {function(LoadProgress):void} onUploadProgress
64+
* @returns {Readable}
65+
*/
66+
const bodyWithUploadProgress = (init, onUploadProgress) => {
67+
// This works around the fact that electron-fetch serializes `Uint8Array`s
68+
// and `ArrayBuffer`s to strings.
69+
const content = normalizeBody(init.body)
70+
71+
// @ts-ignore - Response does not accept node `Readable` streams.
72+
const { body } = new Response(content, init)
73+
// @ts-ignore - Unlike standard Response, node-fetch `body` has a differnt
74+
// type see: see https://github.com/node-fetch/node-fetch/blob/master/src/body.js
75+
const source = iterateBodyWithProgress(body, onUploadProgress)
76+
return toStream.readable(source)
77+
}
78+
79+
/**
80+
* @param {BodyInit} [input]
81+
* @returns {Buffer|Readable|Blob|null}
82+
*/
83+
const normalizeBody = (input = null) => {
84+
if (input instanceof ArrayBuffer) {
85+
return Buffer.from(input)
86+
} else if (ArrayBuffer.isView(input)) {
87+
return Buffer.from(input.buffer, input.byteOffset, input.byteLength)
88+
} else if (typeof input === 'string') {
89+
return Buffer.from(input)
90+
} else {
91+
// @ts-ignore - Could be FormData|URLSearchParams|ReadableStream<Uint8Array>
92+
// however electron-fetch does not support either of those types and
93+
// node-fetch normalizes those to node streams.
94+
return input
95+
}
96+
}
97+
98+
/**
99+
* Takes body from native-fetch response as body and `onUploadProgress` handler
100+
* and returns async iterable that emits body chunks and emits
101+
* `onUploadProgress`.
102+
*
103+
* @param {Buffer|null|Readable} body
104+
* @param {function(LoadProgress):void} onUploadProgress
105+
* @returns {AsyncIterable<Buffer>}
106+
*/
107+
const iterateBodyWithProgress = async function * (body, onUploadProgress) {
108+
/** @type {Buffer|null|Readable} */
109+
if (body == null) {
110+
onUploadProgress({ total: 0, loaded: 0, lengthComputable: true })
111+
} else if (Buffer.isBuffer(body)) {
112+
const total = body.byteLength
113+
const lengthComputable = true
114+
yield body
115+
onUploadProgress({ total, loaded: total, lengthComputable })
116+
} else {
117+
const total = 0
118+
const lengthComputable = false
119+
let loaded = 0
120+
for await (const chunk of body) {
121+
loaded += chunk.byteLength
122+
yield chunk
123+
onUploadProgress({ total, loaded, lengthComputable })
124+
}
125+
}
126+
}

0 commit comments

Comments
 (0)