@aws-amplify/ui-react-storage 3.17.2 → 3.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/copy-serviceworker.js +36 -0
- package/dist/browser.js +23 -23
- package/dist/{createStorageBrowser-CtfBkr62.js → createStorageBrowser-S_PgkSVv.js} +1123 -264
- package/dist/download-sw.js +70 -0
- package/dist/esm/components/StorageBrowser/actions/configs/defaults.mjs +3 -1
- package/dist/esm/components/StorageBrowser/actions/handlers/composedDownloadHandler.mjs +2 -2
- package/dist/esm/components/StorageBrowser/actions/handlers/download.mjs +1 -1
- package/dist/esm/components/StorageBrowser/actions/handlers/utils.mjs +23 -1
- package/dist/esm/components/StorageBrowser/actions/handlers/zipdownload.mjs +342 -118
- package/dist/esm/components/StorageBrowser/createStorageBrowser/StorageBrowserDefault.mjs +3 -1
- package/dist/esm/components/StorageBrowser/createStorageBrowser/createStorageBrowser.mjs +1 -1
- package/dist/esm/components/StorageBrowser/displayText/libraries/en/downloadView.mjs +4 -0
- package/dist/esm/components/StorageBrowser/service-worker/useServiceWorkerRegistration.mjs +25 -0
- package/dist/esm/components/StorageBrowser/useAction/useHandler.mjs +6 -2
- package/dist/esm/components/StorageBrowser/views/LocationActionView/DownloadView/DownloadViewProvider.mjs +60 -8
- package/dist/esm/components/StorageBrowser/views/LocationActionView/DownloadView/useDownloadView.mjs +376 -5
- package/dist/esm/components/StorageBrowser/views/LocationActionView/DownloadView/utils.mjs +172 -0
- package/dist/esm/components/StorageBrowser/views/context/actionViews.mjs +1 -0
- package/dist/esm/components/StorageBrowser/views/context/primaryViews.mjs +1 -1
- package/dist/esm/version.mjs +1 -1
- package/dist/index.js +1 -1
- package/dist/styles.css +144 -114
- package/dist/types/components/StorageBrowser/actions/handlers/download.d.ts +15 -0
- package/dist/types/components/StorageBrowser/actions/handlers/utils.d.ts +12 -0
- package/dist/types/components/StorageBrowser/actions/handlers/zipdownload.d.ts +2 -2
- package/dist/types/components/StorageBrowser/actions/index.d.ts +1 -1
- package/dist/types/components/StorageBrowser/displayText/types.d.ts +21 -0
- package/dist/types/components/StorageBrowser/service-worker/download-sw.d.ts +2 -0
- package/dist/types/components/StorageBrowser/service-worker/useServiceWorkerRegistration.d.ts +2 -0
- package/dist/types/components/StorageBrowser/useAction/types.d.ts +1 -0
- package/dist/types/components/StorageBrowser/views/LocationActionView/DownloadView/types.d.ts +40 -0
- package/dist/types/components/StorageBrowser/views/LocationActionView/DownloadView/utils.d.ts +99 -0
- package/dist/types/components/StorageBrowser/views/LocationActionView/index.d.ts +1 -1
- package/dist/types/version.d.ts +1 -1
- package/package.json +12 -8
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
(function () {
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/// <reference lib="webworker" />
|
|
5
|
+
const pendingStreams = new Map();
|
|
6
|
+
// Skip waiting to activate immediately on first install (no navigation required)
|
|
7
|
+
self.addEventListener('install', () => {
|
|
8
|
+
self.skipWaiting();
|
|
9
|
+
});
|
|
10
|
+
// Claim clients immediately so navigator.serviceWorker.controller is available
|
|
11
|
+
self.addEventListener('activate', (event) => {
|
|
12
|
+
event.waitUntil(self.clients.claim());
|
|
13
|
+
});
|
|
14
|
+
// Receive stream from main thread via MessageChannel
|
|
15
|
+
self.addEventListener('message', (event) => {
|
|
16
|
+
// Security: only accept messages from same-origin clients. A service worker
|
|
17
|
+
// exclusively communicates with pages it controls, which are same-origin by
|
|
18
|
+
// definition. Rejecting mismatched origins guards against cross-origin
|
|
19
|
+
// senders attempting to inject or hijack download streams.
|
|
20
|
+
if (event.origin !== self.location.origin) {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const data = event.data;
|
|
24
|
+
if (data.type === 'keepalive') {
|
|
25
|
+
// Extend SW lifetime to prevent Firefox's 30s idle timeout from terminating
|
|
26
|
+
// the worker while streaming. Each keepalive holds the SW alive for 15s,
|
|
27
|
+
// overlapping with the 10s ping interval from the page.
|
|
28
|
+
event.waitUntil(new Promise((resolve) => setTimeout(resolve, 15000)));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const { downloadId, filename, stream } = data;
|
|
32
|
+
if (downloadId && stream) {
|
|
33
|
+
// Fall back to the id's last path segment only if no explicit filename was
|
|
34
|
+
// provided (older callers); the page normally sends `${folder}.zip`.
|
|
35
|
+
const resolvedFilename = filename ?? downloadId.split('/').pop() ?? 'download.zip';
|
|
36
|
+
pendingStreams.set(downloadId, { stream, filename: resolvedFilename });
|
|
37
|
+
// Acknowledge receipt so the main thread knows it's safe to trigger the download
|
|
38
|
+
if (event.ports[0]) {
|
|
39
|
+
event.ports[0].postMessage({ ready: true });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
// Intercept fetch requests matching the download URL pattern
|
|
44
|
+
self.addEventListener('fetch', (event) => {
|
|
45
|
+
const url = new URL(event.request.url);
|
|
46
|
+
if (!url.pathname.startsWith('/amplify-storage-download/'))
|
|
47
|
+
return;
|
|
48
|
+
// The download id is percent-encoded by the browser when the <a> navigation
|
|
49
|
+
// fires (folder names may contain spaces or other URL-unsafe characters), so
|
|
50
|
+
// decode it before looking up the stream stored under the unencoded key.
|
|
51
|
+
const rawId = url.pathname.split('/amplify-storage-download/')[1];
|
|
52
|
+
const downloadId = decodeURIComponent(rawId);
|
|
53
|
+
const pending = pendingStreams.get(downloadId);
|
|
54
|
+
if (!pending)
|
|
55
|
+
return;
|
|
56
|
+
pendingStreams.delete(downloadId);
|
|
57
|
+
const { stream, filename } = pending;
|
|
58
|
+
event.respondWith(new Response(stream, {
|
|
59
|
+
headers: {
|
|
60
|
+
// RFC 5987 extended notation encodes arbitrary UTF-8 (including quotes
|
|
61
|
+
// and backslashes that S3 keys may legally contain) without escaping.
|
|
62
|
+
// `filename` is the user-facing name sent by the page (e.g. folder.zip),
|
|
63
|
+
// NOT the timestamped internal downloadId.
|
|
64
|
+
'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`,
|
|
65
|
+
'Content-Type': 'application/octet-stream',
|
|
66
|
+
},
|
|
67
|
+
}));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
})();
|
|
@@ -53,9 +53,11 @@ const uploadActionConfig = {
|
|
|
53
53
|
const downloadActionConfig = {
|
|
54
54
|
viewName: 'DownloadView',
|
|
55
55
|
actionListItem: {
|
|
56
|
+
// Only an empty selection disables Download; folder selections are
|
|
57
|
+
// expanded into files at the view level.
|
|
56
58
|
disable: (selected) => {
|
|
57
59
|
const hasNoSelection = !selected || selected.length === 0;
|
|
58
|
-
return hasNoSelection
|
|
60
|
+
return hasNoSelection;
|
|
59
61
|
},
|
|
60
62
|
hide: (permissions) => !permissions.includes('get'),
|
|
61
63
|
icon: 'download',
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { downloadHandler } from './download.mjs';
|
|
2
|
-
import { zipDownloadHandler
|
|
2
|
+
import { zipDownloadHandler } from './zipdownload.mjs';
|
|
3
3
|
|
|
4
|
-
const composedDownloadHandler = (input) => input.all.length === 1 ? downloadHandler(input) :
|
|
4
|
+
const composedDownloadHandler = (input) => input.all.length === 1 ? downloadHandler(input) : zipDownloadHandler(input);
|
|
5
5
|
|
|
6
6
|
export { composedDownloadHandler };
|
|
@@ -10,7 +10,7 @@ function downloadFromUrl(fileName, url) {
|
|
|
10
10
|
a.click();
|
|
11
11
|
document.body.removeChild(a);
|
|
12
12
|
}
|
|
13
|
-
const downloadHandler = ({ config, data }) => {
|
|
13
|
+
const downloadHandler = ({ config, data, }) => {
|
|
14
14
|
const { accountId, credentials, customEndpoint } = config;
|
|
15
15
|
const { key } = data;
|
|
16
16
|
const result = getUrl({
|
|
@@ -169,7 +169,29 @@ const createFileDataItem = (data) => ({
|
|
|
169
169
|
...data,
|
|
170
170
|
fileKey: getFileKey(data.key),
|
|
171
171
|
});
|
|
172
|
+
/**
|
|
173
|
+
* Builds a download item ({@link DownloadHandlerData}) from a {@link FileData}
|
|
174
|
+
* plus the current browse-location prefix `P`.
|
|
175
|
+
*
|
|
176
|
+
* `relativePath = key.slice(P.length)` yields a zip entry path relative to the
|
|
177
|
+
* selected folder's *parent* (e.g. selecting `photos/` produces entries like
|
|
178
|
+
* `photos/vacation/beach.jpg`). This keeps each top-level folder name as a
|
|
179
|
+
* namespace, avoiding collisions across multiple selected folders. For loose
|
|
180
|
+
* files at the current prefix it reduces to the basename.
|
|
181
|
+
*/
|
|
182
|
+
const createDownloadItem = (data, locationPrefix) => {
|
|
183
|
+
// Assumes `locationPrefix` is '' or ends in '/' (StorageBrowser convention),
|
|
184
|
+
// so the slice yields a clean folder-relative path with no leading segment.
|
|
185
|
+
// Defensive: strip a single leading '/' so a prefix NOT ending in '/' can
|
|
186
|
+
// never yield a leading-slash (absolute) zip entry path.
|
|
187
|
+
const sliced = data.key.slice(locationPrefix.length);
|
|
188
|
+
return {
|
|
189
|
+
...data,
|
|
190
|
+
fileKey: getFileKey(data.key),
|
|
191
|
+
relativePath: sliced.startsWith('/') ? sliced.slice(1) : sliced,
|
|
192
|
+
};
|
|
193
|
+
};
|
|
172
194
|
const getProgress = ({ totalBytes, transferredBytes, }) => totalBytes ? transferredBytes / totalBytes : undefined;
|
|
173
195
|
const isMultipartUpload = (file) => file.size > MULTIPART_UPLOAD_THRESHOLD_BYTES;
|
|
174
196
|
|
|
175
|
-
export { constructBucket, createFileDataItem, deduplicateLocations, getBucketRegion, getFileKey, getFilteredLocations, getProgress, isMultipartUpload, parseAccessGrantLocation, shouldExcludeLocation };
|
|
197
|
+
export { constructBucket, createDownloadItem, createFileDataItem, deduplicateLocations, getBucketRegion, getFileKey, getFilteredLocations, getProgress, isMultipartUpload, parseAccessGrantLocation, shouldExcludeLocation };
|
|
@@ -1,72 +1,178 @@
|
|
|
1
1
|
import { getUrl } from '@aws-amplify/storage/internals';
|
|
2
2
|
import { isFunction } from '@aws-amplify/ui';
|
|
3
3
|
import { getProgress } from './utils.mjs';
|
|
4
|
-
import {
|
|
4
|
+
import { ZipWriter } from '@zip.js/zip.js';
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
6
|
+
/**
|
|
7
|
+
* Zip Download Handler
|
|
8
|
+
*
|
|
9
|
+
* Downloads multiple S3 files sequentially into a streaming zip archive,
|
|
10
|
+
* delivered via service worker (with blob fallback for browsers without SW support).
|
|
11
|
+
*
|
|
12
|
+
* State machine: IDLE → DOWNLOADING → COMPLETE | CANCELLED
|
|
13
|
+
*
|
|
14
|
+
* Batch state is scoped per download session using a WeakMap keyed by the `all`
|
|
15
|
+
* array identity (guaranteed stable per useProcessTasks invocation).
|
|
16
|
+
* This avoids module-level singleton issues if multiple StorageBrowser instances
|
|
17
|
+
* share the same module.
|
|
18
|
+
*
|
|
19
|
+
* Cancel paths:
|
|
20
|
+
* - UI cancel (onActionCancel button): cancelBatch() → aborts fetch + writable → reset
|
|
21
|
+
* - Browser-dialog cancel: SW stream closes → addPromise rejects → cancelled=true → drain remaining files
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* Batch state is keyed by a stable serialization of the file IDs in the batch.
|
|
25
|
+
* useProcessTasks recreates the `all` array on each handler call (spread + map),
|
|
26
|
+
* so we cannot rely on reference identity. Sorting IDs produces a deterministic
|
|
27
|
+
* key that remains identical across calls for the same set of files.
|
|
28
|
+
*/
|
|
29
|
+
const batchMap = new Map();
|
|
30
|
+
/** Derives a stable batch key from the task data array. */
|
|
31
|
+
const getBatchKey = (all) => all
|
|
32
|
+
.map((item) => item.id)
|
|
33
|
+
.sort()
|
|
34
|
+
.join('\0');
|
|
35
|
+
/** Tears down all listeners and removes batch from map. */
|
|
36
|
+
const reset = (batchKey, state) => {
|
|
37
|
+
if (state.keepaliveInterval) {
|
|
38
|
+
clearInterval(state.keepaliveInterval);
|
|
39
|
+
}
|
|
40
|
+
batchMap.delete(batchKey);
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Marks the batch cancelled and tears down the active stream, but deliberately
|
|
44
|
+
* KEEPS the batchMap entry alive. Idempotent.
|
|
45
|
+
*
|
|
46
|
+
* The `cancelled` sentinel and the map entry share one key, so deleting the
|
|
47
|
+
* entry here would erase the very flag the drain path relies on: with
|
|
48
|
+
* `concurrency: 1`, useProcessTasks re-dispatches each remaining QUEUED file to
|
|
49
|
+
* the handler on settle. If the entry were gone, those files would miss the
|
|
50
|
+
* `existingBatch.cancelled` early-exit and build a brand-new batch — resurrecting
|
|
51
|
+
* the download. Instead we leave a tombstoned entry; the remaining files hit the
|
|
52
|
+
* early-exit branch and `reset()` runs only once `batchDone === batchTotal`
|
|
53
|
+
* (in the STEP 1 early-exit or onFileSettled), for both cancel and completion.
|
|
54
|
+
*/
|
|
55
|
+
const cancelBatch = (batchKey) => {
|
|
56
|
+
const state = batchMap.get(batchKey);
|
|
57
|
+
if (!state || state.cancelled)
|
|
58
|
+
return;
|
|
59
|
+
state.cancelled = true;
|
|
60
|
+
state.batchAbort.abort();
|
|
61
|
+
// Terminate the SW response stream by aborting the writable side of the TransformStream.
|
|
62
|
+
// This errors the readable (transferred to SW), which errors the Response, failing the browser download.
|
|
63
|
+
state.zipWritable.abort('Download cancelled').catch(() => { });
|
|
64
|
+
// Stop keepalive pings immediately, but do NOT delete the map entry — the
|
|
65
|
+
// remaining queued files must still find this (cancelled) batch. The entry is
|
|
66
|
+
// removed by reset() at the final drain.
|
|
67
|
+
if (state.keepaliveInterval) {
|
|
68
|
+
clearInterval(state.keepaliveInterval);
|
|
69
|
+
state.keepaliveInterval = null;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
// ─── Utilities ───
|
|
73
|
+
/** Extracts the S3 bucket descriptor from handler config. */
|
|
42
74
|
const constructBucket = ({ bucket: bucketName, region, }) => ({ bucketName, region });
|
|
43
|
-
|
|
44
|
-
|
|
75
|
+
/**
|
|
76
|
+
* Derives the zip filename from the first key's parent folder.
|
|
77
|
+
*
|
|
78
|
+
* Examples:
|
|
79
|
+
* - "photos/vacation/beach.jpg" → "vacation"
|
|
80
|
+
* - "photos/file.txt" → "photos"
|
|
81
|
+
* - "file.txt" (no slash) → "archive"
|
|
82
|
+
* - "/file.txt" (slash at index 0 only) → "archive"
|
|
83
|
+
*
|
|
84
|
+
* For root-level multi-file selections (no common parent folder), returns "archive".
|
|
85
|
+
*/
|
|
86
|
+
const getFolderName = (key) => {
|
|
87
|
+
const lastSlash = key.lastIndexOf('/');
|
|
88
|
+
if (lastSlash <= 0)
|
|
89
|
+
return 'archive';
|
|
90
|
+
const parentPath = key.substring(0, lastSlash);
|
|
91
|
+
return parentPath.split('/').pop() ?? 'archive';
|
|
92
|
+
};
|
|
93
|
+
/** Collects a ReadableStream into a single Blob (fallback when SW is unavailable). */
|
|
94
|
+
const collectBlob = async (readable) => {
|
|
95
|
+
const reader = readable.getReader();
|
|
45
96
|
const chunks = [];
|
|
46
|
-
|
|
47
|
-
const size = +(response.headers.get('content-length') ?? 0);
|
|
48
|
-
let received = 0;
|
|
49
|
-
while (loading) {
|
|
97
|
+
for (;;) {
|
|
50
98
|
const { value, done } = await reader.read();
|
|
51
|
-
if (done)
|
|
52
|
-
|
|
99
|
+
if (done)
|
|
100
|
+
break;
|
|
101
|
+
chunks.push(value);
|
|
102
|
+
}
|
|
103
|
+
return new Blob(chunks, { type: 'application/zip' });
|
|
104
|
+
};
|
|
105
|
+
// ─── Service Worker Initialization ───
|
|
106
|
+
/**
|
|
107
|
+
* Registers the SW stream transfer (MessageChannel handshake + keepalive)
|
|
108
|
+
* or falls back to in-memory blob collection when SW is unavailable.
|
|
109
|
+
* Mutates state.swReady and state.blobPromise.
|
|
110
|
+
*/
|
|
111
|
+
const initServiceWorkerStream = (state) => {
|
|
112
|
+
if (!navigator.serviceWorker) {
|
|
113
|
+
state.blobPromise = collectBlob(state.zipReadable);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
state.swReady = navigator.serviceWorker
|
|
117
|
+
.getRegistration('/amplify-storage-download/')
|
|
118
|
+
.then((reg) => {
|
|
119
|
+
// If the batch was cancelled while getRegistration() was pending, bail out
|
|
120
|
+
// before wiring up the MessageChannel or keepalive interval. Otherwise the
|
|
121
|
+
// interval would be created after reset() already cleared batchMap, leaking
|
|
122
|
+
// a timer with no reference to clear it.
|
|
123
|
+
if (state.cancelled) {
|
|
124
|
+
return;
|
|
53
125
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
if (isFunction(options?.onProgress)) {
|
|
58
|
-
options?.onProgress(data, getProgress({
|
|
59
|
-
totalBytes: size,
|
|
60
|
-
transferredBytes: received,
|
|
61
|
-
}), 'PENDING');
|
|
62
|
-
}
|
|
126
|
+
if (!reg?.active) {
|
|
127
|
+
state.blobPromise = collectBlob(state.zipReadable);
|
|
128
|
+
return;
|
|
63
129
|
}
|
|
64
|
-
|
|
65
|
-
|
|
130
|
+
// Cancel detection works via addPromise.catch: when the user dismisses the
|
|
131
|
+
// download dialog, the SW response stream closes → TransformStream writable
|
|
132
|
+
// errors → zipWriter.add() rejects → cancelled flag is set.
|
|
133
|
+
const { port1, port2 } = new MessageChannel();
|
|
134
|
+
port1.onmessage = () => {
|
|
135
|
+
const a = document.createElement('a');
|
|
136
|
+
a.href = `/amplify-storage-download/${state.downloadId}`;
|
|
137
|
+
a.download = `${state.folder}.zip`;
|
|
138
|
+
a.click();
|
|
139
|
+
port1.close();
|
|
140
|
+
};
|
|
141
|
+
// Send the user-facing filename explicitly. `downloadId` embeds Date.now()
|
|
142
|
+
// to keep the SW's stream-map key unique across batches — that timestamp
|
|
143
|
+
// must NOT leak into the saved filename. The SW uses `filename` for the
|
|
144
|
+
// Content-Disposition header so the SW path and the blob-fallback path both
|
|
145
|
+
// save `${folder}.zip`.
|
|
146
|
+
reg.active.postMessage({
|
|
147
|
+
downloadId: state.downloadId,
|
|
148
|
+
filename: `${state.folder}.zip`,
|
|
149
|
+
stream: state.zipReadable,
|
|
150
|
+
}, [state.zipReadable, port2]);
|
|
151
|
+
state.zipReadable = null;
|
|
152
|
+
// Keepalive pings run for the entire batch duration to prevent Firefox's
|
|
153
|
+
// 30s SW idle timeout from terminating the worker mid-stream.
|
|
154
|
+
state.keepaliveInterval = setInterval(() => {
|
|
155
|
+
reg.active?.postMessage({ type: 'keepalive' });
|
|
156
|
+
}, 10000);
|
|
157
|
+
});
|
|
66
158
|
};
|
|
67
|
-
|
|
159
|
+
// ─── Per-file Download ───
|
|
160
|
+
/**
|
|
161
|
+
* Downloads a single file, streams it into the zip writer entry.
|
|
162
|
+
* Receives the active batch state explicitly — throws if state is invalid.
|
|
163
|
+
*/
|
|
164
|
+
const download = async (state, { config, data, options }) => {
|
|
68
165
|
const { customEndpoint, credentials, accountId } = config;
|
|
69
166
|
const { key } = data;
|
|
167
|
+
// Prefer the folder-relative path (set during folder expansion) so nested
|
|
168
|
+
// files keep their structure inside the zip; fall back to the bare basename
|
|
169
|
+
// for loose files / legacy inputs that don't carry a relativePath.
|
|
170
|
+
const filename = data.relativePath ?? key.split('/').pop();
|
|
171
|
+
await state.swReady;
|
|
172
|
+
if (state.cancelled) {
|
|
173
|
+
throw new Error('Download cancelled');
|
|
174
|
+
}
|
|
175
|
+
// Note: getUrl is a presigned URL generation call (local, fast) — not cancellable
|
|
70
176
|
const { url } = await getUrl({
|
|
71
177
|
path: key,
|
|
72
178
|
options: {
|
|
@@ -80,75 +186,193 @@ const download = async ({ config, data, all, options }, abortController) => {
|
|
|
80
186
|
});
|
|
81
187
|
const response = await fetch(url, {
|
|
82
188
|
mode: 'cors',
|
|
83
|
-
signal:
|
|
189
|
+
signal: state.batchAbort.signal,
|
|
84
190
|
});
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
191
|
+
if (!response.body)
|
|
192
|
+
throw new Error(`Empty response body for ${key}`);
|
|
193
|
+
const size = data.size ?? +(response.headers.get('content-length') ?? 0);
|
|
194
|
+
let transferred = 0;
|
|
195
|
+
// Intermediate stream decouples fetch errors from zip.js internals.
|
|
196
|
+
// Closing this stream cleanly lets zip.js finalize the entry without
|
|
197
|
+
// uncaught AbortError rejections from its codec-worker.
|
|
198
|
+
let streamController;
|
|
199
|
+
const fileStream = new ReadableStream({
|
|
200
|
+
start(controller) {
|
|
201
|
+
streamController = controller;
|
|
94
202
|
},
|
|
95
203
|
});
|
|
204
|
+
// Start the zip add — returns a promise that resolves when the
|
|
205
|
+
// ReadableStream we gave it closes.
|
|
206
|
+
const addPromise = state.zipWriter.add(filename, fileStream, { level: 0 });
|
|
207
|
+
// When the browser dismisses the download dialog, the SW response stream
|
|
208
|
+
// closes → TransformStream writable errors → zipWriter.add() rejects.
|
|
209
|
+
// We flag cancellation from two robust signals: an explicit abort in flight
|
|
210
|
+
// (UI cancel aborts batchAbort) or a standard `AbortError` (the DOM error name
|
|
211
|
+
// zip.js surfaces when its output stream is terminated by a dialog cancel).
|
|
212
|
+
// We deliberately avoid substring-matching zip.js's internal error messages,
|
|
213
|
+
// which are not a public API contract. Any other rejection is a genuine error.
|
|
214
|
+
addPromise.catch((error) => {
|
|
215
|
+
const err = error instanceof Error ? error : undefined;
|
|
216
|
+
if (state.batchAbort.signal.aborted || err?.name === 'AbortError') {
|
|
217
|
+
state.cancelled = true;
|
|
218
|
+
}
|
|
219
|
+
// Re-throw is not needed — the await below will surface the rejection.
|
|
220
|
+
});
|
|
221
|
+
try {
|
|
222
|
+
const reader = response.body.getReader();
|
|
223
|
+
for (;;) {
|
|
224
|
+
if (state.cancelled) {
|
|
225
|
+
streamController.close();
|
|
226
|
+
throw new Error('Download cancelled');
|
|
227
|
+
}
|
|
228
|
+
const { value, done } = await reader.read();
|
|
229
|
+
if (done)
|
|
230
|
+
break;
|
|
231
|
+
transferred += value.length;
|
|
232
|
+
streamController.enqueue(value);
|
|
233
|
+
if (size > 0 && isFunction(options?.onProgress)) {
|
|
234
|
+
options.onProgress(data, getProgress({ totalBytes: size, transferredBytes: transferred }), 'PENDING');
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
// All chunks read — close the stream so zip.js finalizes the entry
|
|
238
|
+
streamController.close();
|
|
239
|
+
await addPromise;
|
|
240
|
+
if (isFunction(options?.onProgress)) {
|
|
241
|
+
options.onProgress(data, 1, 'COMPLETE');
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
catch (e) {
|
|
245
|
+
const err = e;
|
|
246
|
+
try {
|
|
247
|
+
streamController.close();
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
/* already closed */
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
await Promise.race([
|
|
254
|
+
addPromise,
|
|
255
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('addPromise timeout')), 1000)),
|
|
256
|
+
]);
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
/* swallow — zip entry incomplete */
|
|
260
|
+
}
|
|
261
|
+
throw state.cancelled || err.name === 'AbortError'
|
|
262
|
+
? new Error('Download cancelled')
|
|
263
|
+
: err;
|
|
264
|
+
}
|
|
96
265
|
return filename;
|
|
97
266
|
};
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
267
|
+
// ─── Post-file Settlement ───
|
|
268
|
+
/**
|
|
269
|
+
* Called after each file completes (success or failure).
|
|
270
|
+
* Increments progress and triggers batch cleanup when all files are settled.
|
|
271
|
+
*
|
|
272
|
+
* IMPORTANT: For the final file, this function awaits cleanup completion
|
|
273
|
+
* (zipWriter.close + blob download) before returning. This ensures the
|
|
274
|
+
* user receives the download before useProcessTasks marks the task "done"
|
|
275
|
+
* and the component can unmount.
|
|
276
|
+
*/
|
|
277
|
+
const onFileSettled = async (batchKey, state, taskResult) => {
|
|
278
|
+
state.batchDone++;
|
|
279
|
+
if (state.batchDone < state.batchTotal) {
|
|
280
|
+
return taskResult;
|
|
281
|
+
}
|
|
282
|
+
// Final file — run cleanup synchronously in this promise chain so that
|
|
283
|
+
// the task result is not returned until the download is triggered.
|
|
284
|
+
try {
|
|
285
|
+
if (!state.cancelled && state.zipWriter) {
|
|
286
|
+
await state.zipWriter.close();
|
|
287
|
+
if (state.blobPromise) {
|
|
288
|
+
const blob = await state.blobPromise;
|
|
289
|
+
const a = document.createElement('a');
|
|
290
|
+
a.href = URL.createObjectURL(blob);
|
|
291
|
+
a.download = `${state.folder}.zip`;
|
|
292
|
+
a.click();
|
|
293
|
+
URL.revokeObjectURL(a.href);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
// zip close failed — batch was likely cancelled
|
|
299
|
+
}
|
|
300
|
+
finally {
|
|
301
|
+
reset(batchKey, state);
|
|
302
|
+
}
|
|
303
|
+
return taskResult;
|
|
304
|
+
};
|
|
305
|
+
// ─── Handler ───
|
|
306
|
+
/** Main entry point — called once per file in a multi-select download batch. */
|
|
307
|
+
const zipDownloadHandler = ({ config, data, all, options, }) => {
|
|
308
|
+
const { key } = data;
|
|
309
|
+
const firstKey = all[0]?.key ?? key;
|
|
310
|
+
// The view computes the batch zip name (last segment of the common ancestor
|
|
311
|
+
// dir of all files) and stamps it onto every item. Prefer it; fall back to
|
|
312
|
+
// the legacy first-key parent-folder heuristic when it's absent.
|
|
313
|
+
const folder = all[0]?.archiveName ?? getFolderName(firstKey);
|
|
314
|
+
const batchKey = getBatchKey(all);
|
|
315
|
+
const existingBatch = batchMap.get(batchKey);
|
|
316
|
+
// ─── STEP 1: Handle cancelled state ───
|
|
317
|
+
if (existingBatch?.cancelled) {
|
|
318
|
+
existingBatch.batchDone++;
|
|
319
|
+
const result = Promise.resolve({
|
|
320
|
+
status: 'CANCELED',
|
|
321
|
+
message: 'Download cancelled',
|
|
322
|
+
});
|
|
323
|
+
if (existingBatch.batchDone >= existingBatch.batchTotal) {
|
|
324
|
+
result.finally(() => reset(batchKey, existingBatch));
|
|
325
|
+
}
|
|
105
326
|
return {
|
|
327
|
+
result,
|
|
106
328
|
cancel: () => {
|
|
107
|
-
|
|
108
|
-
fileDownloadQueue.set(key, true);
|
|
329
|
+
/* already cancelled — noop */
|
|
109
330
|
},
|
|
110
|
-
result: download({ config, data, all, options }, abortController)
|
|
111
|
-
.then(() => {
|
|
112
|
-
fileDownloadQueue.set(key, true);
|
|
113
|
-
return {
|
|
114
|
-
status: 'COMPLETE',
|
|
115
|
-
};
|
|
116
|
-
})
|
|
117
|
-
.catch((e) => {
|
|
118
|
-
const error = e;
|
|
119
|
-
fileDownloadQueue.set(key, true);
|
|
120
|
-
return {
|
|
121
|
-
status: 'FAILED',
|
|
122
|
-
message: error.message,
|
|
123
|
-
error,
|
|
124
|
-
};
|
|
125
|
-
})
|
|
126
|
-
.finally(() => {
|
|
127
|
-
const done = all.every(({ key }) => {
|
|
128
|
-
return fileDownloadQueue.get(key);
|
|
129
|
-
});
|
|
130
|
-
if (done) {
|
|
131
|
-
zipper
|
|
132
|
-
.getBlobUrl()
|
|
133
|
-
.then((blobURL) => {
|
|
134
|
-
if (blobURL) {
|
|
135
|
-
zipper.destroy();
|
|
136
|
-
const anchor = document.createElement('a');
|
|
137
|
-
const clickEvent = new MouseEvent('click');
|
|
138
|
-
anchor.href = blobURL;
|
|
139
|
-
anchor.download = `${folder || 'archive'}.zip`;
|
|
140
|
-
anchor.dispatchEvent(clickEvent);
|
|
141
|
-
}
|
|
142
|
-
})
|
|
143
|
-
.catch(() => {
|
|
144
|
-
// this catch happens, when no zip was created.
|
|
145
|
-
// it is handled by the UI showing "FAILED" for all files
|
|
146
|
-
});
|
|
147
|
-
}
|
|
148
|
-
}),
|
|
149
331
|
};
|
|
332
|
+
}
|
|
333
|
+
// ─── STEP 2: Initialize zip writer on first file ───
|
|
334
|
+
let currentBatch;
|
|
335
|
+
if (!existingBatch) {
|
|
336
|
+
const { readable, writable } = new TransformStream();
|
|
337
|
+
currentBatch = {
|
|
338
|
+
zipWriter: new ZipWriter(writable),
|
|
339
|
+
zipWritable: writable,
|
|
340
|
+
zipReadable: readable,
|
|
341
|
+
downloadId: `${folder}-${Date.now()}.zip`,
|
|
342
|
+
blobPromise: null,
|
|
343
|
+
swReady: Promise.resolve(),
|
|
344
|
+
cancelled: false,
|
|
345
|
+
batchAbort: new AbortController(),
|
|
346
|
+
batchTotal: all.length,
|
|
347
|
+
batchDone: 0,
|
|
348
|
+
keepaliveInterval: null,
|
|
349
|
+
folder,
|
|
350
|
+
};
|
|
351
|
+
batchMap.set(batchKey, currentBatch);
|
|
352
|
+
initServiceWorkerStream(currentBatch);
|
|
353
|
+
}
|
|
354
|
+
else {
|
|
355
|
+
currentBatch = existingBatch;
|
|
356
|
+
}
|
|
357
|
+
// ─── STEP 3: Normal download ───
|
|
358
|
+
return {
|
|
359
|
+
cancel: () => {
|
|
360
|
+
cancelBatch(batchKey);
|
|
361
|
+
},
|
|
362
|
+
result: download(currentBatch, { config, data, options })
|
|
363
|
+
.then(() => ({ status: 'COMPLETE' }))
|
|
364
|
+
.catch((e) => {
|
|
365
|
+
const err = e;
|
|
366
|
+
if (err.message === 'Download cancelled' ||
|
|
367
|
+
err.name === 'AbortError' ||
|
|
368
|
+
currentBatch.cancelled) {
|
|
369
|
+
currentBatch.cancelled = true;
|
|
370
|
+
return { status: 'CANCELED', message: 'Download cancelled' };
|
|
371
|
+
}
|
|
372
|
+
return { status: 'FAILED', message: err.message, error: err };
|
|
373
|
+
})
|
|
374
|
+
.then((taskResult) => onFileSettled(batchKey, currentBatch, taskResult)),
|
|
150
375
|
};
|
|
151
|
-
|
|
152
|
-
})();
|
|
376
|
+
};
|
|
153
377
|
|
|
154
|
-
export {
|
|
378
|
+
export { zipDownloadHandler };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import React__default from 'react';
|
|
2
|
+
import { useServiceWorkerRegistration } from '../service-worker/useServiceWorkerRegistration.mjs';
|
|
2
3
|
import { useViews } from '../views/context/views.mjs';
|
|
3
4
|
import '../views/LocationActionView/CopyView/CopyView.mjs';
|
|
4
5
|
import '@aws-amplify/ui';
|
|
@@ -22,11 +23,11 @@ import 'aws-amplify';
|
|
|
22
23
|
import '@zip.js/zip.js';
|
|
23
24
|
import 'aws-amplify/storage';
|
|
24
25
|
import '../views/LocationActionView/DownloadView/DownloadView.mjs';
|
|
26
|
+
import '../actions/configs/context.mjs';
|
|
25
27
|
import '../views/context/actionViews.mjs';
|
|
26
28
|
import '../views/LocationActionView/UploadView/UploadView.mjs';
|
|
27
29
|
import '../fileItems/context.mjs';
|
|
28
30
|
import '../views/LocationDetailView/LocationDetailView.mjs';
|
|
29
|
-
import '../actions/configs/context.mjs';
|
|
30
31
|
import '../filePreview/context.mjs';
|
|
31
32
|
import '../views/LocationsView/LocationsView.mjs';
|
|
32
33
|
|
|
@@ -37,6 +38,7 @@ import '../views/LocationsView/LocationsView.mjs';
|
|
|
37
38
|
* - render `ActionView` on action selection
|
|
38
39
|
*/
|
|
39
40
|
function StorageBrowserDefault() {
|
|
41
|
+
useServiceWorkerRegistration();
|
|
40
42
|
const { primary } = useViews();
|
|
41
43
|
const { LocationActionView, LocationDetailView, LocationsView } = primary;
|
|
42
44
|
const [{ actionType, location }] = useStore();
|