Skip to content

Commit a53fd34

Browse files
jadedevin13sindresorhussholladay
authored
Add onUploadProgress option (#632)
Co-authored-by: Sindre Sorhus <[email protected]> Co-authored-by: Seth Holladay <[email protected]>
1 parent f9a9f38 commit a53fd34

File tree

10 files changed

+391
-68
lines changed

10 files changed

+391
-68
lines changed

readme.md

+33-3
Original file line numberDiff line numberDiff line change
@@ -404,9 +404,12 @@ Type: `Function`
404404

405405
Download progress event handler.
406406

407-
The function receives a `progress` and `chunk` argument:
408-
- The `progress` object contains the following elements: `percent`, `transferredBytes` and `totalBytes`. If it's not possible to retrieve the body size, `totalBytes` will be `0`.
409-
- The `chunk` argument is an instance of `Uint8Array`. It's empty for the first call.
407+
The function receives these arguments:
408+
- `progress` is an object with the these properties:
409+
- - `percent` is a number between 0 and 1 representing the progress percentage.
410+
- - `transferredBytes` is the number of bytes transferred so far.
411+
- - `totalBytes` is the total number of bytes to be transferred. This is an estimate and may be 0 if the total size cannot be determined.
412+
- `chunk` is an instance of `Uint8Array` containing the data that was sent. Note: It's empty for the first call.
410413

411414
```js
412415
import ky from 'ky';
@@ -421,6 +424,33 @@ const response = await ky('https://example.com', {
421424
});
422425
```
423426

427+
##### onUploadProgress
428+
429+
Type: `Function`
430+
431+
Upload progress event handler.
432+
433+
The function receives these arguments:
434+
- `progress` is an object with the these properties:
435+
- - `percent` is a number between 0 and 1 representing the progress percentage.
436+
- - `transferredBytes` is the number of bytes transferred so far.
437+
- - `totalBytes` is the total number of bytes to be transferred. This is an estimate and may be 0 if the total size cannot be determined.
438+
- `chunk` is an instance of `Uint8Array` containing the data that was sent. Note: It's empty for the last call.
439+
440+
```js
441+
import ky from 'ky';
442+
443+
const response = await ky.post('https://example.com/upload', {
444+
body: largeFile,
445+
onUploadProgress: (progress, chunk) => {
446+
// Example output:
447+
// `0% - 0 of 1271 bytes`
448+
// `100% - 1271 of 1271 bytes`
449+
console.log(`${progress.percent * 100}% - ${progress.transferredBytes} of ${progress.totalBytes} bytes`);
450+
}
451+
});
452+
```
453+
424454
##### parseJson
425455

426456
Type: `Function`\

source/core/Ky.ts

+18-59
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
SearchParamsInit,
99
} from '../types/options.js';
1010
import {type ResponsePromise} from '../types/ResponsePromise.js';
11+
import {streamRequest, streamResponse} from '../utils/body.js';
1112
import {mergeHeaders, mergeHooks} from '../utils/merge.js';
1213
import {normalizeRequestMethod, normalizeRetryOptions} from '../utils/normalize.js';
1314
import timeout, {type TimeoutOptions} from '../utils/timeout.js';
@@ -64,7 +65,6 @@ export class Ky {
6465
}
6566

6667
// If `onDownloadProgress` is passed, it uses the stream API internally
67-
/* istanbul ignore next */
6868
if (ky._options.onDownloadProgress) {
6969
if (typeof ky._options.onDownloadProgress !== 'function') {
7070
throw new TypeError('The `onDownloadProgress` option must be a function');
@@ -74,7 +74,7 @@ export class Ky {
7474
throw new Error('Streams are not supported in your environment. `ReadableStream` is missing.');
7575
}
7676

77-
return ky._stream(response.clone(), ky._options.onDownloadProgress);
77+
return streamResponse(response.clone(), ky._options.onDownloadProgress);
7878
}
7979

8080
return response;
@@ -205,6 +205,22 @@ export class Ky {
205205
// The spread of `this.request` is required as otherwise it misses the `duplex` option for some reason and throws.
206206
this.request = new globalThis.Request(new globalThis.Request(url, {...this.request}), this._options as RequestInit);
207207
}
208+
209+
// If `onUploadProgress` is passed, it uses the stream API internally
210+
if (this._options.onUploadProgress) {
211+
if (typeof this._options.onUploadProgress !== 'function') {
212+
throw new TypeError('The `onUploadProgress` option must be a function');
213+
}
214+
215+
if (!supportsRequestStreams) {
216+
throw new Error('Request streams are not supported in your environment. The `duplex` option for `Request` is not available.');
217+
}
218+
219+
const originalBody = this.request.body;
220+
if (originalBody) {
221+
this.request = streamRequest(this.request, this._options.onUploadProgress);
222+
}
223+
}
208224
}
209225

210226
protected _calculateRetryDelay(error: unknown) {
@@ -310,61 +326,4 @@ export class Ky {
310326

311327
return timeout(mainRequest, nonRequestOptions, this.abortController, this._options as TimeoutOptions);
312328
}
313-
314-
/* istanbul ignore next */
315-
protected _stream(response: Response, onDownloadProgress: Options['onDownloadProgress']) {
316-
const totalBytes = Number(response.headers.get('content-length')) || 0;
317-
let transferredBytes = 0;
318-
319-
if (response.status === 204) {
320-
if (onDownloadProgress) {
321-
onDownloadProgress({percent: 1, totalBytes, transferredBytes}, new Uint8Array());
322-
}
323-
324-
return new globalThis.Response(
325-
null,
326-
{
327-
status: response.status,
328-
statusText: response.statusText,
329-
headers: response.headers,
330-
},
331-
);
332-
}
333-
334-
return new globalThis.Response(
335-
new globalThis.ReadableStream({
336-
async start(controller) {
337-
const reader = response.body!.getReader();
338-
339-
if (onDownloadProgress) {
340-
onDownloadProgress({percent: 0, transferredBytes: 0, totalBytes}, new Uint8Array());
341-
}
342-
343-
async function read() {
344-
const {done, value} = await reader.read();
345-
if (done) {
346-
controller.close();
347-
return;
348-
}
349-
350-
if (onDownloadProgress) {
351-
transferredBytes += value.byteLength;
352-
const percent = totalBytes === 0 ? 0 : transferredBytes / totalBytes;
353-
onDownloadProgress({percent, transferredBytes, totalBytes}, value);
354-
}
355-
356-
controller.enqueue(value);
357-
await read();
358-
}
359-
360-
await read();
361-
},
362-
}),
363-
{
364-
status: response.status,
365-
statusText: response.statusText,
366-
headers: response.headers,
367-
},
368-
);
369-
}
370329
}

source/core/constants.ts

+4
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ export const responseTypes = {
5454
// The maximum value of a 32bit int (see issue #117)
5555
export const maxSafeTimeout = 2_147_483_647;
5656

57+
// Size in bytes of a typical form boundary, used to help estimate upload size
58+
export const usualFormBoundarySize = new TextEncoder().encode('------WebKitFormBoundaryaxpyiPgbbPti10Rw').length;
59+
5760
export const stop = Symbol('stop');
5861

5962
export const kyOptionKeys: KyOptionsRegistry = {
@@ -67,6 +70,7 @@ export const kyOptionKeys: KyOptionsRegistry = {
6770
hooks: true,
6871
throwHttpErrors: true,
6972
onDownloadProgress: true,
73+
onUploadProgress: true,
7074
fetch: true,
7175
};
7276

source/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export type {
4242
NormalizedOptions,
4343
RetryOptions,
4444
SearchParamsOption,
45-
DownloadProgress,
45+
Progress,
4646
} from './types/options.js';
4747

4848
export type {

source/types/options.ts

+28-3
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'head' | 'delete';
1212

1313
export type Input = string | URL | Request;
1414

15-
export type DownloadProgress = {
15+
export type Progress = {
1616
percent: number;
1717
transferredBytes: number;
1818

@@ -170,7 +170,8 @@ export type KyOptions = {
170170
/**
171171
Download progress event handler.
172172
173-
@param chunk - Note: It's empty for the first call.
173+
@param progress - Object containing download progress information.
174+
@param chunk - Data that was received. Note: It's empty for the first call.
174175
175176
@example
176177
```
@@ -186,7 +187,30 @@ export type KyOptions = {
186187
});
187188
```
188189
*/
189-
onDownloadProgress?: (progress: DownloadProgress, chunk: Uint8Array) => void;
190+
onDownloadProgress?: (progress: Progress, chunk: Uint8Array) => void;
191+
192+
/**
193+
Upload progress event handler.
194+
195+
@param progress - Object containing upload progress information.
196+
@param chunk - Data that was sent. Note: It's empty for the last call.
197+
198+
@example
199+
```
200+
import ky from 'ky';
201+
202+
const response = await ky.post('https://example.com/upload', {
203+
body: largeFile,
204+
onUploadProgress: (progress, chunk) => {
205+
// Example output:
206+
// `0% - 0 of 1271 bytes`
207+
// `100% - 1271 of 1271 bytes`
208+
console.log(`${progress.percent * 100}% - ${progress.transferredBytes} of ${progress.totalBytes} bytes`);
209+
}
210+
});
211+
```
212+
*/
213+
onUploadProgress?: (progress: Progress, chunk: Uint8Array) => void;
190214

191215
/**
192216
User-defined `fetch` function.
@@ -287,6 +311,7 @@ export interface NormalizedOptions extends RequestInit { // eslint-disable-line
287311
retry: RetryOptions;
288312
prefixUrl: string;
289313
onDownloadProgress: Options['onDownloadProgress'];
314+
onUploadProgress: Options['onUploadProgress'];
290315
}
291316

292317
export type {RetryOptions} from './retry.js';

0 commit comments

Comments
 (0)