Skip to content

Commit 7a0027a

Browse files
committed
feat(project): add timeout option
1 parent 16d1656 commit 7a0027a

File tree

8 files changed

+9682
-78
lines changed

8 files changed

+9682
-78
lines changed

.eslintrc.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/* eslint-disable prettier/prettier */
12
module.exports = {
23
env: {
34
browser: true,
@@ -22,18 +23,18 @@ module.exports = {
2223
settings: {
2324
'import/resolver': {
2425
node: {
25-
extensions: ['.jsx', '.ts', '.tsx'],
26+
extensions: ['.ts', '.tsx', '.json'],
2627
},
2728
},
2829
},
2930
rules: {
3031
'react-hooks/rules-of-hooks': 'error',
3132
'react-hooks/exhaustive-deps': 'error',
3233
'@typescript-eslint/explicit-function-return-type': 'off',
33-
'react/jsx-filename-extension': ['error', { extensions: ['.jsx', '.tsx'] }],
34+
'react/jsx-filename-extension': ['error', { extensions: ['.tsx'] }],
3435
'react/prop-types': 'off',
3536
'react/jsx-one-expression-per-line': 'off',
36-
'import/extensions': ['error', 'never'],
37+
'import/extensions': 0,
3738
'import/prefer-default-export': 'off',
3839
'import/no-unresolved': ['error', { ignore: ['react-hooks-fetch'] }],
3940
camelcase: [

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,4 @@ yarn-error.log*
2424
.vercel
2525

2626
coverage
27+
.dccache

README.md

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -87,15 +87,15 @@ export default function App() {
8787

8888
- An object with the following keys:
8989

90-
| key | description | arguments |
91-
| ------------ | -------------------------------- | --------------------------------------- |
92-
| size | size in bytes | n/a |
93-
| elapsed | elapsed time in seconds | n/a |
94-
| percentage | percentage in string | n/a |
95-
| download | download function handler | (downloadUrl: string, filename: string) |
96-
| cancel | cancel function handler | n/a |
97-
| error | error object from the request | n/a |
98-
| isInProgress | boolean denoting download status | n/a |
90+
| key | description | arguments |
91+
| ------------ | -------------------------------- | --------------------------------------------------------- |
92+
| size | size in bytes | n/a |
93+
| elapsed | elapsed time in seconds | n/a |
94+
| percentage | percentage in string | n/a |
95+
| download | download function handler | (downloadUrl: string, filename: string, timeout?: number) |
96+
| cancel | cancel function handler | n/a |
97+
| error | error object from the request | n/a |
98+
| isInProgress | boolean denoting download status | n/a |
9999

100100
```jsx
101101
const {

rollup.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export default {
2828
url({ exclude: ['**/*.svg'] }),
2929
resolve(),
3030
typescript(),
31-
commonjs({ extensions: ['.js', '.ts'] }),
31+
commonjs({ extensions: ['.ts'] }),
3232
terser(),
3333
],
3434
};

src/__tests__/index.spec.ts

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
1-
/* eslint-disable no-plusplus */
2-
/* eslint-disable no-prototype-builtins */
3-
import fetch from 'node-fetch';
41
import { ReadableStream } from 'web-streams-polyfill/ponyfill';
52
import { renderHook, act } from '@testing-library/react-hooks';
63
import useDownloader, { jsDownload } from '../index';
4+
import { IWindowDownloaderEmbedded } from '../types';
75

86
const expectedKeys = [
97
'elapsed',
@@ -216,14 +214,18 @@ describe('useDownloader failures', () => {
216214

217215
describe('Tests with msSaveBlob', () => {
218216
beforeAll(() => {
219-
window.navigator.msSaveBlob = () => {
220-
return true;
221-
};
217+
(window as unknown as IWindowDownloaderEmbedded).navigator.msSaveBlob =
218+
() => {
219+
return true;
220+
};
222221
});
223222

224223
it('should test with msSaveBlob', () => {
225224
console.error = jest.fn();
226-
const msSaveBlobSpy = jest.spyOn(window.navigator, 'msSaveBlob');
225+
const msSaveBlobSpy = jest.spyOn(
226+
(window as unknown as IWindowDownloaderEmbedded).navigator,
227+
'msSaveBlob'
228+
);
227229

228230
jsDownload(new Blob(['abcde']), 'test');
229231

@@ -233,20 +235,23 @@ describe('useDownloader failures', () => {
233235

234236
describe('Tests without msSaveBlob', () => {
235237
beforeAll(() => {
236-
window.navigator.msSaveBlob = undefined;
238+
(window as unknown as IWindowDownloaderEmbedded).navigator.msSaveBlob =
239+
undefined;
237240
window.URL.createObjectURL = () => null;
238241
window.URL.revokeObjectURL = () => null;
239242
});
240243

241244
it('should test with URL and being revoked', async () => {
245+
jest.useFakeTimers('modern');
246+
242247
const createObjectURLSpy = jest.spyOn(window.URL, 'createObjectURL');
243248
const revokeObjectURLSpy = jest.spyOn(window.URL, 'revokeObjectURL');
244249

245250
jsDownload(new Blob(['abcde']), 'test');
246251

247252
expect(createObjectURLSpy).toHaveBeenCalled();
248253

249-
await new Promise((resolve) => setTimeout(() => resolve(true), 250));
254+
jest.runAllTimers();
250255

251256
expect(revokeObjectURLSpy).toHaveBeenCalled();
252257
});

src/index.ts

Lines changed: 61 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useCallback, useMemo, useRef, useState } from 'react';
2-
import { IResolverProps, IUseDownloader, TError } from './types';
2+
import { DownloadFunction, IResolverProps, IUseDownloader, IWindowDownloaderEmbedded, TError } from './types';
33

44
export const resolver =
55
({
@@ -8,66 +8,66 @@ export const resolver =
88
setPercentageCallback,
99
setErrorCallback,
1010
}: IResolverProps) =>
11-
(response: Response): Response => {
12-
if (!response.ok) {
13-
throw Error(`${response.status} ${response.type} ${response.statusText}`);
14-
}
11+
(response: Response): Response => {
12+
if (!response.ok) {
13+
throw Error(`${response.status} ${response.type} ${response.statusText}`);
14+
}
1515

16-
if (!response.body) {
17-
throw Error('ReadableStream not yet supported in this browser.');
18-
}
16+
if (!response.body) {
17+
throw Error('ReadableStream not yet supported in this browser.');
18+
}
1919

20-
const responseBody = response.body;
20+
const responseBody = response.body;
2121

22-
const contentEncoding = response.headers.get('content-encoding');
23-
const contentLength = response.headers.get(
24-
contentEncoding ? 'x-file-size' : 'content-length'
25-
);
22+
const contentEncoding = response.headers.get('content-encoding');
23+
const contentLength = response.headers.get(
24+
contentEncoding ? 'x-file-size' : 'content-length'
25+
);
2626

27-
const total = parseInt(contentLength || '0', 10);
27+
const total = parseInt(contentLength || '0', 10);
2828

29-
setSize(() => total);
29+
setSize(() => total);
3030

31-
let loaded = 0;
31+
let loaded = 0;
3232

33-
const stream = new ReadableStream<Uint8Array>({
34-
start(controller) {
35-
setControllerCallback(controller);
33+
const stream = new ReadableStream<Uint8Array>({
34+
start(controller) {
35+
setControllerCallback(controller);
3636

37-
const reader = responseBody.getReader();
37+
const reader = responseBody.getReader();
3838

39-
async function read(): Promise<void> {
40-
return reader
41-
.read()
42-
.then(({ done, value }) => {
43-
if (done) {
44-
return controller.close();
45-
}
39+
async function read(): Promise<void> {
40+
return reader
41+
.read()
42+
.then(({ done, value }) => {
43+
if (done) {
44+
return controller.close();
45+
}
4646

47-
loaded += value?.byteLength || 0;
47+
loaded += value?.byteLength || 0;
4848

49-
if (value) {
50-
controller.enqueue(value);
51-
}
49+
if (value) {
50+
controller.enqueue(value);
51+
}
5252

53-
setPercentageCallback({ loaded, total });
53+
setPercentageCallback({ loaded, total });
5454

55-
return read();
56-
})
57-
.catch((error: Error) => {
58-
setErrorCallback(error);
59-
reader.cancel('Cancelled');
55+
return read();
56+
})
57+
.catch((error: Error) => {
58+
setErrorCallback(error);
59+
reader.cancel('Cancelled');
6060

61-
return controller.error(error);
62-
});
63-
}
61+
return controller.error(error);
62+
});
63+
}
6464

65-
return read();
66-
},
67-
});
65+
return read();
66+
},
67+
});
6868

69-
return new Response(stream);
70-
};
69+
return new Response(stream);
70+
};
7171

7272
export const jsDownload = (
7373
data: Blob,
@@ -79,8 +79,8 @@ export const jsDownload = (
7979
type: mime || 'application/octet-stream',
8080
});
8181

82-
if (typeof window.navigator.msSaveBlob !== 'undefined') {
83-
return window.navigator.msSaveBlob(blob, filename);
82+
if (typeof (window as unknown as IWindowDownloaderEmbedded).navigator.msSaveBlob !== 'undefined') {
83+
return (window as unknown as IWindowDownloaderEmbedded).navigator.msSaveBlob(blob, filename);
8484
}
8585

8686
const blobURL =
@@ -128,6 +128,7 @@ export default function useDownloader(): IUseDownloader {
128128
const errorMap = {
129129
"Failed to execute 'enqueue' on 'ReadableStreamDefaultController': Cannot enqueue a chunk into an errored readable stream":
130130
'Download canceled',
131+
'The user aborted a request.': 'Download timed out',
131132
};
132133
setError(() => {
133134
const resolvedError = errorMap[err.message]
@@ -138,7 +139,7 @@ export default function useDownloader(): IUseDownloader {
138139
});
139140
}, []);
140141

141-
const setControllerCallback = useCallback((controller) => {
142+
const setControllerCallback = useCallback((controller: ReadableStreamController<Uint8Array> | null) => {
142143
controllerRef.current = controller;
143144
}, []);
144145

@@ -157,15 +158,15 @@ export default function useDownloader(): IUseDownloader {
157158
setIsInProgress(() => false);
158159
}, [setControllerCallback]);
159160

160-
const handleDownload = useCallback(
161-
async (downloadUrl, filename) => {
161+
const handleDownload: DownloadFunction = useCallback(
162+
async (downloadUrl, filename, timeout = 0) => {
162163
if (isInProgress) return null;
163164

164165
clearAllStateCallback();
165166
setError(() => null);
166167
setIsInProgress(() => true);
167168

168-
const interval = setInterval(
169+
const intervalId = setInterval(
169170
() => setElapsed((prevValue) => prevValue + 1),
170171
debugMode ? 1 : 1000
171172
);
@@ -176,8 +177,14 @@ export default function useDownloader(): IUseDownloader {
176177
setErrorCallback,
177178
});
178179

180+
const fetchController = new AbortController();
181+
const timeoutId = setTimeout(() => {
182+
if (timeout > 0) fetchController.abort();
183+
}, timeout);
184+
179185
return fetch(downloadUrl, {
180186
method: 'GET',
187+
signal: fetchController.signal
181188
})
182189
.then(resolverWithProgress)
183190
.then((data) => {
@@ -187,7 +194,7 @@ export default function useDownloader(): IUseDownloader {
187194
.then(() => {
188195
clearAllStateCallback();
189196

190-
return clearInterval(interval);
197+
return clearInterval(intervalId);
191198
})
192199
.catch((err) => {
193200
clearAllStateCallback();
@@ -203,7 +210,8 @@ export default function useDownloader(): IUseDownloader {
203210
return prevValue;
204211
});
205212

206-
return clearInterval(interval);
213+
clearTimeout(timeoutId);
214+
return clearInterval(intervalId);
207215
});
208216
},
209217
[

src/types.ts

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,36 @@ export type TError = {
44
errorMessage: string;
55
} | null
66

7+
export type DownloadFunction = (
8+
/** Download url
9+
* @example https://upload.wikimedia.org/wikipedia/commons/4/4d/%D0%93%D0%BE%D0%B2%D0%B5%D1%80%D0%BB%D0%B0_%D1%96_%D0%9F%D0%B5%D1%82%D1%80%D0%BE%D1%81_%D0%B2_%D0%BF%D1%80%D0%BE%D0%BC%D1%96%D0%BD%D1%8F%D1%85_%D0%B2%D1%80%D0%B0%D0%BD%D1%96%D1%88%D0%BD%D1%8C%D0%BE%D0%B3%D0%BE_%D1%81%D0%BE%D0%BD%D1%86%D1%8F.jpg
10+
*/
11+
downloadUrl: string,
12+
/** File name
13+
* @example carpathia.jpeg
14+
*/
15+
filename: string,
16+
/** Optional timeout to download items */
17+
timeout?: number) => Promise<void | null>;
18+
719
export interface IUseDownloader {
20+
/** Size in bytes */
21+
size: number;
22+
/** Elapsed time in seconds */
823
elapsed: number;
24+
/** Percentage in string */
925
percentage: number;
10-
size: number;
11-
download: (downloadUrl: string, filename: string) => Promise<void | null>;
26+
/**
27+
* Download function handler
28+
* @example await download('https://example.com/file.zip', 'file.zip')
29+
* @example await download('https://example.com/file.zip', 'file.zip', 500) timeouts after 500ms
30+
* */
31+
download: DownloadFunction
32+
/** Cancel function handler */
1233
cancel: () => void;
34+
/** Error object from the request */
1335
error: TError;
36+
/** Boolean denoting download status */
1437
isInProgress: boolean;
1538
}
1639

@@ -19,4 +42,12 @@ export interface IResolverProps {
1942
setControllerCallback: (controller: ReadableStreamController<Uint8Array>) => void
2043
setPercentageCallback: ({ loaded, total }: { loaded: number; total: number; }) => void;
2144
setErrorCallback: (err: Error) => void;
22-
}
45+
}
46+
47+
interface CustomNavigator extends Navigator {
48+
msSaveBlob: (blob?: Blob, filename?: string) => boolean | NodeJS.Timeout;
49+
}
50+
51+
export interface IWindowDownloaderEmbedded extends Window {
52+
navigator: CustomNavigator
53+
}

0 commit comments

Comments
 (0)