Skip to content

Commit e45e0de

Browse files
committed
feat: add multi-file zip-download
1 parent 534f25b commit e45e0de

File tree

7 files changed

+295
-17
lines changed

7 files changed

+295
-17
lines changed

packages/react-storage/package.json

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"scripts": {
3535
"build": "yarn build:rollup",
3636
"build:rollup": "rollup --config",
37+
"build:watch": "rollup -c -w",
3738
"check:esm": "node --input-type=module --eval 'import \"@aws-amplify/ui-react-storage\"'",
3839
"clean": "rimraf dist node_modules",
3940
"dev": "yarn build:rollup --watch",
@@ -48,15 +49,17 @@
4849
"@aws-amplify/ui": "6.12.1",
4950
"@aws-amplify/ui-react": "6.13.1",
5051
"@aws-amplify/ui-react-core": "3.4.6",
51-
"tslib": "^2.5.2"
52+
"tslib": "^2.5.2",
53+
"jszip":"^3.10.1"
5254
},
5355
"peerDependencies": {
5456
"aws-amplify": "^6.14.3",
5557
"react": "^16.14 || ^17 || ^18 || ^19",
5658
"react-dom": "^16.14 || ^17 || ^18 || ^19"
5759
},
5860
"devDependencies": {
59-
"@types/node": "^18.19.50"
61+
"@types/node": "^18.19.50",
62+
"node-fetch":"~3.3.2"
6063
},
6164
"sideEffects": [
6265
"dist/**/*.css"
@@ -66,7 +69,7 @@
6669
"name": "createStorageBrowser",
6770
"path": "dist/esm/browser.mjs",
6871
"import": "{ createStorageBrowser }",
69-
"limit": "70 kB",
72+
"limit": "93.9 kB",
7073
"ignore": [
7174
"@aws-amplify/storage"
7275
]
@@ -75,7 +78,7 @@
7578
"name": "StorageBrowser",
7679
"path": "dist/esm/index.mjs",
7780
"import": "{ StorageBrowser }",
78-
"limit": "92 kB"
81+
"limit": "116 kB"
7982
},
8083
{
8184
"name": "FileUploader",

packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/defaults.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { copyHandler } from '../copy';
22
import { createFolderHandler } from '../createFolder';
33
import { deleteHandler } from '../delete';
4-
import { downloadHandler } from '../download';
4+
import { zipDownloadHandler } from '../zipdownload';
55
import { listLocationItemsHandler } from '../listLocationItems';
66
import { uploadHandler } from '../upload';
77
import { defaultHandlers } from '../defaults';
@@ -12,7 +12,7 @@ describe('defaultHandlers', () => {
1212
copy: copyHandler,
1313
createFolder: createFolderHandler,
1414
delete: deleteHandler,
15-
download: downloadHandler,
15+
download: zipDownloadHandler,
1616
listLocationItems: listLocationItemsHandler,
1717
upload: uploadHandler,
1818
});
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { getUrl, GetUrlInput } from '../../../storage-internal';
2+
3+
import { zipDownloadHandler } from '../zipdownload';
4+
import type { DownloadHandlerInput } from '../download';
5+
6+
jest.mock('../../../storage-internal');
7+
8+
const baseInput: DownloadHandlerInput = {
9+
config: {
10+
accountId: 'accountId',
11+
bucket: 'bucket',
12+
credentials: jest.fn(),
13+
customEndpoint: 'mock-endpoint',
14+
region: 'region',
15+
},
16+
data: {
17+
id: 'id',
18+
key: 'prefix/file-name',
19+
fileKey: 'file-name',
20+
},
21+
};
22+
23+
describe('zipDownloadHandler', () => {
24+
const url = new URL('mock://fake.url');
25+
const mockGetUrl = jest.mocked(getUrl);
26+
27+
beforeAll(() => {
28+
if (!globalThis.fetch) {
29+
globalThis.fetch = jest.fn(() => {
30+
return Promise.resolve({
31+
headers: {
32+
get: (header: string) => {
33+
if (header === 'content-length') {
34+
return 100;
35+
}
36+
},
37+
},
38+
body: {
39+
getReader: jest.fn(() => {
40+
return {
41+
read: () => ({
42+
value: 100,
43+
done: true,
44+
}),
45+
};
46+
}),
47+
},
48+
}) as unknown as Promise<Response>;
49+
});
50+
}
51+
});
52+
beforeEach(() => {
53+
const expiresAt = new Date();
54+
expiresAt.setDate(expiresAt.getDate() + 1);
55+
mockGetUrl.mockResolvedValue({ expiresAt, url });
56+
});
57+
58+
afterEach(() => {
59+
mockGetUrl.mockReset();
60+
});
61+
62+
it('calls `getUrl` with the expected values', () => {
63+
zipDownloadHandler(baseInput);
64+
65+
const expected: GetUrlInput = {
66+
path: baseInput.data.key,
67+
options: {
68+
bucket: {
69+
bucketName: baseInput.config.bucket,
70+
region: baseInput.config.region,
71+
},
72+
customEndpoint: baseInput.config.customEndpoint,
73+
locationCredentialsProvider: baseInput.config.credentials,
74+
validateObjectExistence: true,
75+
contentDisposition: 'attachment',
76+
expectedBucketOwner: baseInput.config.accountId,
77+
},
78+
};
79+
80+
expect(mockGetUrl).toHaveBeenCalledWith(expected);
81+
});
82+
83+
it('returns a complete status', async () => {
84+
const { result } = zipDownloadHandler(baseInput);
85+
86+
expect(await result).toEqual({ status: 'COMPLETE' });
87+
});
88+
89+
it('returns failed status', async () => {
90+
const error = new Error('No download!');
91+
mockGetUrl.mockRejectedValue(error);
92+
const { result } = zipDownloadHandler(baseInput);
93+
94+
expect(await result).toEqual({
95+
error,
96+
message: error.message,
97+
status: 'FAILED',
98+
});
99+
});
100+
});

packages/react-storage/src/components/StorageBrowser/actions/handlers/defaults.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { createFolderHandler } from './createFolder';
55
import type { DeleteHandler } from './delete';
66
import { deleteHandler } from './delete';
77
import type { DownloadHandler } from './download';
8-
import { downloadHandler } from './download';
8+
import { zipDownloadHandler } from './zipdownload';
99
import type { ListLocationItemsHandler } from './listLocationItems';
1010
import { listLocationItemsHandler } from './listLocationItems';
1111
import type { UploadHandler } from './upload';
@@ -24,7 +24,7 @@ export const defaultHandlers: DefaultHandlers = {
2424
copy: copyHandler,
2525
createFolder: createFolderHandler,
2626
delete: deleteHandler,
27-
download: downloadHandler,
27+
download: zipDownloadHandler,
2828
listLocationItems: listLocationItemsHandler,
2929
upload: uploadHandler,
3030
};

packages/react-storage/src/components/StorageBrowser/actions/handlers/download.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ function downloadFromUrl(fileName: string, url: string) {
3838
document.body.removeChild(a);
3939
}
4040

41-
export const downloadHandler: DownloadHandler = ({ config, data }) => {
41+
export const downloadHandler: DownloadHandler = ({ config, data }): DownloadHandlerOutput => {
4242
const { accountId, credentials, customEndpoint } = config;
4343
const { key } = data;
4444

@@ -53,14 +53,14 @@ export const downloadHandler: DownloadHandler = ({ config, data }) => {
5353
expectedBucketOwner: accountId,
5454
},
5555
})
56-
.then(({ url }) => {
57-
downloadFromUrl(key, url.toString());
58-
return { status: 'COMPLETE' as const, value: { url } };
59-
})
60-
.catch((error: Error) => {
61-
const { message } = error;
62-
return { error, message, status: 'FAILED' as const };
63-
});
56+
.then(({ url }) => {
57+
downloadFromUrl(key, url.toString());
58+
return { status: 'COMPLETE' as const, value: { url } };
59+
})
60+
.catch((error: Error) => {
61+
const { message } = error;
62+
return { error, message, status: 'FAILED' as const };
63+
});
6464

6565
return { result };
6666
};

packages/react-storage/src/components/StorageBrowser/actions/handlers/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export * from './createFolder';
33
export * from './defaults';
44
export * from './delete';
55
export * from './download';
6+
export * from './zipdownload';
67
export * from './listLocationItems';
78
export * from './listLocations';
89
export * from './upload';
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import { getUrl } from '../../storage-internal';
2+
import type { DownloadHandler, DownloadHandlerInput } from './download';
3+
import type { TaskResult, TaskResultStatus } from './types';
4+
import { isFunction } from '@aws-amplify/ui';
5+
import { getProgress } from './utils';
6+
import JSZip from 'jszip';
7+
8+
type DownloadTaskResult = TaskResult<TaskResultStatus, { url: URL }>;
9+
10+
interface MyZipper {
11+
addFile: (file: Blob, name: string) => Promise<void>;
12+
getBlobUrl: (
13+
onProgress?: (percent: number, key: string) => void
14+
) => Promise<string>;
15+
}
16+
const zipper: MyZipper = (() => {
17+
let zip: JSZip | null = null;
18+
return {
19+
addFile: (file, name) => {
20+
if (!zip) {
21+
zip = new JSZip();
22+
}
23+
return new Promise((ok, no) => {
24+
try {
25+
zip?.file(name, file);
26+
ok();
27+
} catch (e) {
28+
no();
29+
}
30+
});
31+
},
32+
getBlobUrl: async (onProgress) => {
33+
if (!zip) {
34+
throw new Error('no zip');
35+
}
36+
const blob = await zip.generateAsync(
37+
{
38+
type: 'blob',
39+
streamFiles: true,
40+
compression: 'DEFLATE',
41+
compressionOptions: {
42+
level: 3,
43+
},
44+
},
45+
({ percent, currentFile }) => {
46+
if (isFunction(onProgress) && currentFile) {
47+
onProgress(percent, currentFile);
48+
}
49+
}
50+
);
51+
zip = null;
52+
return URL.createObjectURL(blob);
53+
},
54+
};
55+
})();
56+
57+
const constructBucket = ({
58+
bucket: bucketName,
59+
region,
60+
}: DownloadHandlerInput['config']) => ({ bucketName, region });
61+
62+
const readBody = async (
63+
response: Response,
64+
{ data, options }: DownloadHandlerInput
65+
) => {
66+
let loading = true;
67+
const chunks = [];
68+
const reader = response.body!.getReader();
69+
const size = +(response.headers.get('content-length') ?? 0);
70+
let received = 0;
71+
while (loading) {
72+
const { value, done } = await reader.read();
73+
74+
if (done) {
75+
loading = false;
76+
} else {
77+
chunks.push(value);
78+
received += value.length;
79+
if (isFunction(options?.onProgress)) {
80+
options?.onProgress(
81+
data,
82+
getProgress({
83+
totalBytes: size,
84+
transferredBytes: received,
85+
})
86+
);
87+
}
88+
}
89+
}
90+
91+
return new Blob(chunks);
92+
};
93+
94+
const download = async (
95+
{ config, data, options }: DownloadHandlerInput,
96+
abortController: AbortController
97+
) => {
98+
const { customEndpoint, credentials, accountId } = config;
99+
const { key } = data;
100+
const { url } = await getUrl({
101+
path: key,
102+
options: {
103+
bucket: constructBucket(config),
104+
customEndpoint,
105+
locationCredentialsProvider: credentials,
106+
validateObjectExistence: true,
107+
contentDisposition: 'attachment',
108+
expectedBucketOwner: accountId,
109+
},
110+
});
111+
112+
const response = await fetch(url, {
113+
mode: 'cors',
114+
signal: abortController.signal,
115+
});
116+
const blob = await readBody(response, { config, data, options });
117+
const [filename] = key.split('/').reverse();
118+
await zipper.addFile(blob, filename);
119+
return filename;
120+
};
121+
122+
const downloadHandler = (() => {
123+
const fileDownloadQueue = new Set<string>();
124+
let timer: ReturnType<typeof setTimeout>;
125+
126+
const handler: DownloadHandler = ({ config, data, options }) => {
127+
const { key } = data;
128+
const [, folder] = key.split('/').reverse();
129+
fileDownloadQueue.add(key);
130+
const abortController = new AbortController();
131+
return {
132+
cancel: () => {
133+
abortController.abort();
134+
fileDownloadQueue.delete(key);
135+
},
136+
result: download({ config, data, options }, abortController)
137+
.then((): DownloadTaskResult => {
138+
fileDownloadQueue.delete(key);
139+
return {
140+
status: 'COMPLETE',
141+
};
142+
})
143+
.catch((e): DownloadTaskResult => {
144+
const error = e as Error;
145+
fileDownloadQueue.delete(key);
146+
return {
147+
status: 'FAILED',
148+
message: error.message,
149+
error,
150+
};
151+
})
152+
.finally(() => {
153+
if (timer) clearTimeout(timer);
154+
timer = setTimeout(() => {
155+
if (fileDownloadQueue.size === 0) {
156+
zipper.getBlobUrl().then((blobURL) => {
157+
if (blobURL) {
158+
const anchor = document.createElement('a');
159+
const clickEvent = new MouseEvent('click');
160+
anchor.href = blobURL;
161+
anchor.download = `${folder || 'archive'}.zip`;
162+
anchor.dispatchEvent(clickEvent);
163+
}
164+
});
165+
}
166+
}, 250);
167+
}),
168+
};
169+
};
170+
171+
return handler;
172+
})();
173+
174+
export { downloadHandler as zipDownloadHandler };

0 commit comments

Comments
 (0)