Skip to content

Commit d30be96

Browse files
Gozalaachingbrain
andauthored
feat: http upload/download progress handlers (#54)
As per #52 this adds `onUploadProgress` / `onDownloadProgress` optional handlers. Intention is to allow ipfs-webui to render file upload progress when new content is added. Fixes #52 Co-authored-by: Alex Potsides <alex@achingbrain.net>
1 parent 78ad2d2 commit d30be96

File tree

7 files changed

+326
-23
lines changed

7 files changed

+326
-23
lines changed

Diff for: package.json

+5-4
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",
@@ -44,15 +45,15 @@
4445
"merge-options": "^2.0.0",
4546
"nanoid": "^3.1.3",
4647
"node-fetch": "^2.6.0",
47-
"stream-to-it": "^0.2.0"
48+
"stream-to-it": "^0.2.0",
49+
"it-to-stream": "^0.1.2"
4850
},
4951
"devDependencies": {
5052
"aegir": "^25.0.0",
5153
"delay": "^4.3.0",
5254
"it-all": "^1.0.2",
5355
"it-drain": "^1.0.1",
54-
"it-last": "^1.0.2",
55-
"it-to-stream": "^0.1.2"
56+
"it-last": "^1.0.2"
5657
},
5758
"contributors": [
5859
"Hugo Dias <hugomrdias@gmail.com>",
@@ -63,4 +64,4 @@
6364
"Irakli Gozalishvili <contact@gozala.io>",
6465
"Marcin Rataj <lidel@lidel.org>"
6566
]
66-
}
67+
}

Diff for: src/http.js

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

4-
const fetch = require('node-fetch')
4+
const { fetch, Request, Headers } = require('./http/fetch')
5+
const { TimeoutError, HTTPError } = require('./http/error')
56
const merge = require('merge-options').bind({ ignoreUndefined: true })
67
const { URL, URLSearchParams } = require('iso-url')
78
const TextDecoder = require('./text-decoder')
89
const AbortController = require('abort-controller')
910
const anySignal = require('any-signal')
1011

11-
const Request = fetch.Request
12-
const Headers = fetch.Headers
13-
14-
class TimeoutError extends Error {
15-
constructor () {
16-
super('Request timed out')
17-
this.name = 'TimeoutError'
18-
}
19-
}
20-
21-
class HTTPError extends Error {
22-
constructor (response) {
23-
super(response.statusText)
24-
this.name = 'HTTPError'
25-
this.response = response
26-
}
27-
}
28-
2912
const timeout = (promise, ms, abortController) => {
3013
if (ms === undefined) {
3114
return promise
@@ -87,6 +70,8 @@ const defaults = {
8770
* @prop {function(URLSearchParams): URLSearchParams } [transformSearchParams]
8871
* @prop {function(any): any} [transform] - When iterating the response body, transform each chunk with this function.
8972
* @prop {function(Response): Promise<void>} [handleError] - Handle errors
73+
* @prop {function({total:number, loaded:number, lengthComputable:boolean}):void} [onUploadProgress] - Can be passed to track upload progress
74+
* @prop {function({total:number, loaded:number, lengthComputable:boolean}):void} [onDownloadProgress] - Can be passed to track download progress
9075
*/
9176

9277
class HTTP {

Diff for: 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

Diff for: src/http/fetch.browser.js

+124
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
'use strict'
2+
/* eslint-env browser */
3+
4+
const { TimeoutError, AbortError } = require('./error')
5+
6+
/**
7+
* @typedef {RequestInit & ExtraFetchOptions} FetchOptions
8+
* @typedef {Object} ExtraFetchOptions
9+
* @property {number} [timeout]
10+
* @property {URLSearchParams} [searchParams]
11+
* @property {function({total:number, loaded:number, lengthComputable:boolean}):void} [onUploadProgress]
12+
* @property {function({total:number, loaded:number, lengthComputable:boolean}):void} [onDownloadProgress]
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 fetch = (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.onDownloadProgress) {
46+
request.onprogress = options.onDownloadProgress
47+
}
48+
49+
if (options.onUploadProgress) {
50+
request.upload.onprogress = options.onUploadProgress
51+
}
52+
53+
return new Promise((resolve, reject) => {
54+
/**
55+
* @param {Event} event
56+
*/
57+
const handleEvent = (event) => {
58+
switch (event.type) {
59+
case 'error': {
60+
resolve(Response.error())
61+
break
62+
}
63+
case 'load': {
64+
resolve(
65+
new ResponseWithURL(request.responseURL, request.response, {
66+
status: request.status,
67+
statusText: request.statusText,
68+
headers: parseHeaders(request.getAllResponseHeaders())
69+
})
70+
)
71+
break
72+
}
73+
case 'timeout': {
74+
reject(new TimeoutError())
75+
break
76+
}
77+
case 'abort': {
78+
reject(new AbortError())
79+
break
80+
}
81+
default: {
82+
break
83+
}
84+
}
85+
}
86+
request.onerror = handleEvent
87+
request.onload = handleEvent
88+
request.ontimeout = handleEvent
89+
request.onabort = handleEvent
90+
91+
request.send(options.body)
92+
})
93+
}
94+
exports.fetch = fetch
95+
exports.Request = Request
96+
exports.Headers = Headers
97+
98+
/**
99+
* @param {string} input
100+
* @returns {Headers}
101+
*/
102+
const parseHeaders = (input) => {
103+
const headers = new Headers()
104+
for (const line of input.trim().split(/[\r\n]+/)) {
105+
const index = line.indexOf(': ')
106+
if (index > 0) {
107+
headers.set(line.slice(0, index), line.slice(index + 1))
108+
}
109+
}
110+
111+
return headers
112+
}
113+
114+
class ResponseWithURL extends Response {
115+
/**
116+
* @param {string} url
117+
* @param {string|Blob|ArrayBufferView|ArrayBuffer|FormData|ReadableStream<Uint8Array>} body
118+
* @param {ResponseInit} options
119+
*/
120+
constructor (url, body, options) {
121+
super(body, options)
122+
Object.defineProperty(this, 'url', { value: url })
123+
}
124+
}

Diff for: 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 !== 'undefined') {
6+
module.exports = require('./fetch.browser')
7+
} else {
8+
module.exports = require('./fetch.node')
9+
}

Diff for: src/http/fetch.node.js

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

0 commit comments

Comments
 (0)