Skip to content

Commit 16f754d

Browse files
Gozalaachingbrain
andauthored
fix: report ipfs.add progress over http (#3310)
The browser fetch api doesn't allow reading of any data until the whole request has been sent which means progress events only fire after the upload is complete which rather defeats the purpose of reporting upload progress. Here we switch to XHR for uploads with progress that does allow reading response data before the request is complete. Co-authored-by: achingbrain <[email protected]>
1 parent c281053 commit 16f754d

File tree

5 files changed

+155
-87
lines changed

5 files changed

+155
-87
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@
5656
"debug": "^4.1.1",
5757
"form-data": "^3.0.0",
5858
"ipfs-core-utils": "^0.5.1",
59-
"ipfs-utils": "^4.0.0",
59+
"ipfs-utils": "^5.0.0",
6060
"ipld-block": "^0.11.0",
6161
"ipld-dag-cbor": "^0.17.0",
6262
"ipld-dag-pb": "^0.20.0",

src/add-all.js

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,20 @@ module.exports = configure((api) => {
1313
* @type {import('.').Implements<typeof import('ipfs-core/src/components/add-all/index')>}
1414
*/
1515
async function * addAll (source, options = {}) {
16-
const progressFn = options.progress
17-
1816
// allow aborting requests on body errors
1917
const controller = new AbortController()
2018
const signal = anySignal([controller.signal, options.signal])
19+
const { headers, body, total, parts } =
20+
await multipartRequest(source, controller, options.headers)
21+
22+
// In browser response body only starts streaming once upload is
23+
// complete, at which point all the progress updates are invalid. If
24+
// length of the content is computable we can interpret progress from
25+
// `{ total, loaded}` passed to `onUploadProgress` and `multipart.total`
26+
// in which case we disable progress updates to be written out.
27+
const [progressFn, onUploadProgress] = typeof options.progress === 'function'
28+
? createProgressHandler(total, parts, options.progress)
29+
: [null, null]
2130

2231
const res = await api.post('add', {
2332
searchParams: toUrlSearchParams({
@@ -26,10 +35,10 @@ module.exports = configure((api) => {
2635
progress: Boolean(progressFn)
2736
}),
2837
timeout: options.timeout,
38+
onUploadProgress,
2939
signal,
30-
...(
31-
await multipartRequest(source, controller, options.headers)
32-
)
40+
headers,
41+
body
3342
})
3443

3544
for await (let file of res.ndjson()) {
@@ -45,6 +54,48 @@ module.exports = configure((api) => {
4554
return addAll
4655
})
4756

57+
/**
58+
* Returns simple progress callback when content length isn't computable or a
59+
* progress event handler that calculates progress from upload progress events.
60+
*
61+
* @param {number} total
62+
* @param {{name:string, start:number, end:number}[]|null} parts
63+
* @param {(n:number, name:string) => void} progress
64+
*/
65+
const createProgressHandler = (total, parts, progress) =>
66+
parts ? [null, createOnUploadPrgress(total, parts, progress)] : [progress, null]
67+
68+
/**
69+
* Creates a progress handler that interpolates progress from upload progress
70+
* events and total size of the content that is added.
71+
*
72+
* @param {number} size - actual content size
73+
* @param {{name:string, start:number, end:number}[]} parts
74+
* @param {(n:number, name:string) => void} progress
75+
* @returns {(event:{total:number, loaded: number}) => void}
76+
*/
77+
const createOnUploadPrgress = (size, parts, progress) => {
78+
let index = 0
79+
const count = parts.length
80+
return ({ loaded, total }) => {
81+
// Derive position from the current progress.
82+
const position = Math.floor(loaded / total * size)
83+
while (index < count) {
84+
const { start, end, name } = parts[index]
85+
// If within current part range report progress and break the loop
86+
if (position < end) {
87+
progress(position - start, name)
88+
break
89+
// If passed current part range report final byte for the chunk and
90+
// move to next one.
91+
} else {
92+
progress(end - start, name)
93+
index += 1
94+
}
95+
}
96+
}
97+
}
98+
4899
/**
49100
* @param {any} input
50101
* @returns {UnixFSEntry}
@@ -67,6 +118,7 @@ function toCoreInterface ({ name, hash, size, mode, mtime, mtimeNsecs }) {
67118
}
68119
}
69120

121+
// @ts-ignore
70122
return output
71123
}
72124

src/lib/multipart-request.browser.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
'use strict'
22

3+
// Import browser version otherwise electron-renderer will end up with node
4+
// version and fail.
35
const normaliseInput = require('ipfs-core-utils/src/files/normalise-input/index.browser')
46
const modeToString = require('./mode-to-string')
57
const mtimeToObject = require('./mtime-to-object')
68
const { File, FormData } = require('ipfs-utils/src/globalthis')
79

810
async function multipartRequest (source = '', abortController, headers = {}) {
11+
const parts = []
912
const formData = new FormData()
1013
let index = 0
14+
let total = 0
1115

1216
for await (const { content, path, mode, mtime } of normaliseInput(source)) {
1317
let fileSuffix = ''
@@ -41,6 +45,9 @@ async function multipartRequest (source = '', abortController, headers = {}) {
4145

4246
if (content) {
4347
formData.set(fieldName, content, encodeURIComponent(path))
48+
const end = total + content.size
49+
parts.push({ name: path, start: total, end })
50+
total = end
4451
} else {
4552
formData.set(fieldName, new File([''], encodeURIComponent(path), { type: 'application/x-directory' }))
4653
}
@@ -49,6 +56,8 @@ async function multipartRequest (source = '', abortController, headers = {}) {
4956
}
5057

5158
return {
59+
total,
60+
parts,
5261
headers,
5362
body: formData
5463
}

src/lib/multipart-request.js

Lines changed: 4 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -1,87 +1,10 @@
11
'use strict'
2-
3-
const normaliseInput = require('ipfs-core-utils/src/files/normalise-input/index')
4-
const { nanoid } = require('nanoid')
5-
const modeToString = require('../lib/mode-to-string')
6-
const mtimeToObject = require('../lib/mtime-to-object')
7-
const merge = require('merge-options').bind({ ignoreUndefined: true })
8-
const toStream = require('it-to-stream')
92
const { isElectronRenderer } = require('ipfs-utils/src/env')
103

11-
/**
12-
*
13-
* @param {Object} source
14-
* @param {AbortController} abortController
15-
* @param {Headers|Record<string, string>} [headers]
16-
* @param {string} [boundary]
17-
*/
18-
async function multipartRequest (source = '', abortController, headers = {}, boundary = `-----------------------------${nanoid()}`) {
19-
async function * streamFiles (source) {
20-
try {
21-
let index = 0
22-
23-
for await (const { content, path, mode, mtime } of normaliseInput(source)) {
24-
let fileSuffix = ''
25-
const type = content ? 'file' : 'dir'
26-
27-
if (index > 0) {
28-
yield '\r\n'
29-
30-
fileSuffix = `-${index}`
31-
}
32-
33-
let fieldName = type + fileSuffix
34-
const qs = []
35-
36-
if (mode !== null && mode !== undefined) {
37-
qs.push(`mode=${modeToString(mode)}`)
38-
}
39-
40-
const time = mtimeToObject(mtime)
41-
if (time != null) {
42-
const { secs, nsecs } = time
43-
44-
qs.push(`mtime=${secs}`)
45-
46-
if (nsecs != null) {
47-
qs.push(`mtime-nsecs=${nsecs}`)
48-
}
49-
}
50-
51-
if (qs.length) {
52-
fieldName = `${fieldName}?${qs.join('&')}`
53-
}
54-
55-
yield `--${boundary}\r\n`
56-
yield `Content-Disposition: form-data; name="${fieldName}"; filename="${encodeURIComponent(path)}"\r\n`
57-
yield `Content-Type: ${content ? 'application/octet-stream' : 'application/x-directory'}\r\n`
58-
yield '\r\n'
59-
60-
if (content) {
61-
yield * content
62-
}
63-
64-
index++
65-
}
66-
} catch (err) {
67-
// workaround for https://github.com/node-fetch/node-fetch/issues/753
68-
// @ts-ignore - abort does not expect an arguments
69-
abortController.abort(err)
70-
} finally {
71-
yield `\r\n--${boundary}--\r\n`
72-
}
73-
}
74-
75-
return {
76-
headers: merge(headers, {
77-
'Content-Type': `multipart/form-data; boundary=${boundary}`
78-
}),
79-
body: await toStream(streamFiles(source))
80-
}
81-
}
82-
83-
module.exports = multipartRequest
84-
4+
// In electron-renderer we use native fetch and should encode body using native
5+
// form data.
856
if (isElectronRenderer) {
867
module.exports = require('./multipart-request.browser')
8+
} else {
9+
module.exports = require('./multipart-request.node')
8710
}

src/lib/multipart-request.node.js

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
'use strict'
2+
3+
const normaliseInput = require('ipfs-core-utils/src/files/normalise-input')
4+
const { nanoid } = require('nanoid')
5+
const modeToString = require('./mode-to-string')
6+
const mtimeToObject = require('./mtime-to-object')
7+
const merge = require('merge-options').bind({ ignoreUndefined: true })
8+
const toStream = require('it-to-stream')
9+
10+
/**
11+
*
12+
* @param {Object} source
13+
* @param {AbortController} abortController
14+
* @param {Headers|Record<string, string>} [headers]
15+
* @param {string} [boundary]
16+
*/
17+
async function multipartRequest (source = '', abortController, headers = {}, boundary = `-----------------------------${nanoid()}`) {
18+
async function * streamFiles (source) {
19+
try {
20+
let index = 0
21+
22+
for await (const { content, path, mode, mtime } of normaliseInput(source)) {
23+
let fileSuffix = ''
24+
const type = content ? 'file' : 'dir'
25+
26+
if (index > 0) {
27+
yield '\r\n'
28+
29+
fileSuffix = `-${index}`
30+
}
31+
32+
let fieldName = type + fileSuffix
33+
const qs = []
34+
35+
if (mode !== null && mode !== undefined) {
36+
qs.push(`mode=${modeToString(mode)}`)
37+
}
38+
39+
const time = mtimeToObject(mtime)
40+
if (time != null) {
41+
const { secs, nsecs } = time
42+
43+
qs.push(`mtime=${secs}`)
44+
45+
if (nsecs != null) {
46+
qs.push(`mtime-nsecs=${nsecs}`)
47+
}
48+
}
49+
50+
if (qs.length) {
51+
fieldName = `${fieldName}?${qs.join('&')}`
52+
}
53+
54+
yield `--${boundary}\r\n`
55+
yield `Content-Disposition: form-data; name="${fieldName}"; filename="${encodeURIComponent(path)}"\r\n`
56+
yield `Content-Type: ${content ? 'application/octet-stream' : 'application/x-directory'}\r\n`
57+
yield '\r\n'
58+
59+
if (content) {
60+
yield * content
61+
}
62+
63+
index++
64+
}
65+
} catch (err) {
66+
// workaround for https://github.com/node-fetch/node-fetch/issues/753
67+
// @ts-ignore - abort does not expect an arguments
68+
abortController.abort(err)
69+
} finally {
70+
yield `\r\n--${boundary}--\r\n`
71+
}
72+
}
73+
74+
return {
75+
parts: null,
76+
total: -1,
77+
headers: merge(headers, {
78+
'Content-Type': `multipart/form-data; boundary=${boundary}`
79+
}),
80+
body: await toStream(streamFiles(source))
81+
}
82+
}
83+
84+
module.exports = multipartRequest

0 commit comments

Comments
 (0)