Skip to content
Permalink
Browse files

Add `onDownloadProgress` option (#34)

Co-authored-by: Szymon Marczak <sz.marczak@gmail.com>
Co-authored-by: Sindre Sorhus <sindresorhus@gmail.com>
  • Loading branch information...
3 people committed May 10, 2019
1 parent 7793c53 commit f89d796d3cfe69dfb45a922b6c7b7693f17d7c18
Showing with 193 additions and 2 deletions.
  1. +17 −0 index.d.ts
  2. +54 −1 index.js
  3. +9 −1 index.test-d.ts
  4. +23 −0 readme.md
  5. +89 −0 test/browser.js
  6. +1 −0 test/helpers/disable-stream-support.js
@@ -6,6 +6,16 @@ export type BeforeRequestHook = (options: Options) => void | Promise<void>;

export type AfterResponseHook = (response: Response) => Response | void | Promise<Response | void>;

export interface DownloadProgress {
percent: number;
transferredBytes: number;

/**
If it's not possible to retrieve the body size, it will be `0`.
*/
totalBytes: number;
}

export interface Hooks {
/**
Before the request is sent.
@@ -73,6 +83,13 @@ export interface Options extends RequestInit {
@default true
*/
throwHttpErrors?: boolean;

/**
Download progress event handler.
@param chunk - Note: It's empty for the first call.
*/
onDownloadProgress?: (progress: DownloadProgress, chunk: Uint8Array) => void;
}

interface OptionsWithoutBody extends Omit<Options, 'body'> {
@@ -24,12 +24,14 @@ const getGlobal = property => {
const document = getGlobal('document');
const Headers = getGlobal('Headers');
const Response = getGlobal('Response');
const ReadableStream = getGlobal('ReadableStream');
const fetch = getGlobal('fetch');
const AbortController = getGlobal('AbortController');
const FormData = getGlobal('FormData');

const isObject = value => value !== null && typeof value === 'object';
const supportsAbortController = typeof getGlobal('AbortController') === 'function';
const supportsAbortController = typeof AbortController === 'function';
const supportsStreams = typeof ReadableStream === 'function';
const supportsFormData = typeof FormData === 'function';

const deepMerge = (...sources) => {
@@ -234,6 +236,20 @@ class Ky {
throw new HTTPError(response);
}

// If `onDownloadProgress` is passed it uses the stream API internally
/* istanbul ignore next */
if (this._options.onDownloadProgress) {
if (typeof this._options.onDownloadProgress !== 'function') {
throw new TypeError('The `onDownloadProgress` option must be a function');
}

if (!supportsStreams) {
throw new Error('Streams are not supported in your environment. `ReadableStream` is missing.');
}

return this._stream(response.clone(), this._options.onDownloadProgress);
}

return response;
};

@@ -311,6 +327,43 @@ class Ky {

return timeout(fetch(this._input, this._options), this._timeout, this.abortController);
}

/* istanbul ignore next */
_stream(response, onDownloadProgress) {
const totalBytes = Number(response.headers.get('content-length')) || 0;
let transferredBytes = 0;

return new Response(
new ReadableStream({
start(controller) {
const reader = response.body.getReader();

if (onDownloadProgress) {
onDownloadProgress({percent: 0, transferredBytes: 0, totalBytes}, new Uint8Array());
}

async function read() {
const {done, value} = await reader.read();
if (done) {
controller.close();
return;
}

if (onDownloadProgress) {
transferredBytes += value.byteLength;
const percent = totalBytes === 0 ? 0 : transferredBytes / totalBytes;
onDownloadProgress({percent, transferredBytes, totalBytes}, value);
}

controller.enqueue(value);
read();
}

read();
}
})
);
}
}

const validateAndMerge = (...sources) => {
@@ -1,5 +1,5 @@
import {expectType} from 'tsd';
import ky, {HTTPError, TimeoutError, ResponsePromise} from '.';
import ky, {HTTPError, TimeoutError, ResponsePromise, DownloadProgress} from '.';

const url = 'https://sindresorhus';

@@ -81,3 +81,11 @@ interface Result {
value: number;
}
expectType<Promise<Result>>(ky(url).json<Result>());

// `onDownloadProgress` option
ky(url, {
onDownloadProgress: (progress, chunk) => {
expectType<DownloadProgress>(progress);
expectType<Uint8Array>(chunk);
}
});
@@ -242,6 +242,29 @@ Throw a `HTTPError` for error responses (non-2xx status codes).

Setting this to `false` may be useful if you are checking for resource availability and are expecting error responses.

##### onDownloadProgress

Type: `Function`

Download progress event handler.

The function receives a `progress` and `chunk` argument:
- 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`.
- The `chunk` argument is an instance of `Uint8Array`. It's empty for the first call.

```js
import ky from 'ky';
await ky('https://example.com', {
onProgress: (progress, chunk) => {
// Example output:
// `0% - 0 of 1271 bytes`
// `100% - 1271 of 1271 bytes`
console.log(`${progress.percent * 100}% - ${progress.transferredBytes} of ${progress.totalBytes} bytes`);
}
})
```

### ky.extend(defaultOptions)

Create a new `ky` instance with some defaults overridden with your own.
@@ -17,6 +17,7 @@ test('prefixUrl option', withPage, async (t, page) => {
await t.throwsAsync(async () => {
return page.evaluate(() => {
window.ky = window.ky.default;

return window.ky('/foo', {prefixUrl: '/'});
});
}, /`input` must not begin with a slash when using `prefixUrl`/);
@@ -52,6 +53,7 @@ test('aborting a request', withPage, async (t, page) => {

const error = await page.evaluate(url => {
window.ky = window.ky.default;

const controller = new AbortController();
const request = window.ky(`${url}/test`, {signal: controller.signal}).text();
controller.abort();
@@ -86,3 +88,90 @@ test('throws TimeoutError even though it does not support AbortController', with

await server.close();
});

test('onDownloadProgress works', withPage, async (t, page) => {
const server = await createTestServer();

server.get('/', (request, response) => {
response.writeHead(200, {
'content-length': 4
});

response.write('me');
setTimeout(() => {
response.end('ow');
}, 1000);
});

await page.goto(server.url);
await page.addScriptTag({path: './umd.js'});

const result = await page.evaluate(async url => {
window.ky = window.ky.default;

// `new TextDecoder('utf-8').decode` hangs up?
const decodeUTF8 = array => String.fromCharCode(...array);

const data = [];
const text = await window.ky(url, {
onDownloadProgress: (progress, chunk) => {
const stringifiedChunk = decodeUTF8(chunk);
data.push([progress, stringifiedChunk]);
}
}).text();

return {data, text};
}, server.url);

t.deepEqual(result.data, [
[{percent: 0, transferredBytes: 0, totalBytes: 4}, ''],
[{percent: 0.5, transferredBytes: 2, totalBytes: 4}, 'me'],
[{percent: 1, transferredBytes: 4, totalBytes: 4}, 'ow']
]);
t.is(result.text, 'meow');

await server.close();
});

test('throws if onDownloadProgress is not a function', withPage, async (t, page) => {
const server = await createTestServer();

server.get('/', (request, response) => {
response.end();
});

await page.goto(server.url);
await page.addScriptTag({path: './umd.js'});

const error = await page.evaluate(url => {
window.ky = window.ky.default;

const request = window.ky(url, {onDownloadProgress: 1}).text();
return request.catch(error => error.toString());
}, server.url);
t.is(error, 'TypeError: The `onDownloadProgress` option must be a function');

await server.close();
});

test('throws if does not support ReadableStream', withPage, async (t, page) => {
const server = await createTestServer();

server.get('/', (request, response) => {
response.end();
});

await page.goto(server.url);
await page.addScriptTag({path: './test/helpers/disable-stream-support.js'});
await page.addScriptTag({path: './umd.js'});

const error = await page.evaluate(url => {
window.ky = window.ky.default;

const request = window.ky(url, {onDownloadProgress: () => {}}).text();
return request.catch(error => error.toString());
}, server.url);
t.is(error, 'Error: Streams are not supported in your environment. `ReadableStream` is missing.');

await server.close();
});
@@ -0,0 +1 @@
window.ReadableStream = undefined;

0 comments on commit f89d796

Please sign in to comment.
You can’t perform that action at this time.