@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.
Files changed (35) hide show
  1. package/bin/copy-serviceworker.js +36 -0
  2. package/dist/browser.js +23 -23
  3. package/dist/{createStorageBrowser-CtfBkr62.js → createStorageBrowser-S_PgkSVv.js} +1123 -264
  4. package/dist/download-sw.js +70 -0
  5. package/dist/esm/components/StorageBrowser/actions/configs/defaults.mjs +3 -1
  6. package/dist/esm/components/StorageBrowser/actions/handlers/composedDownloadHandler.mjs +2 -2
  7. package/dist/esm/components/StorageBrowser/actions/handlers/download.mjs +1 -1
  8. package/dist/esm/components/StorageBrowser/actions/handlers/utils.mjs +23 -1
  9. package/dist/esm/components/StorageBrowser/actions/handlers/zipdownload.mjs +342 -118
  10. package/dist/esm/components/StorageBrowser/createStorageBrowser/StorageBrowserDefault.mjs +3 -1
  11. package/dist/esm/components/StorageBrowser/createStorageBrowser/createStorageBrowser.mjs +1 -1
  12. package/dist/esm/components/StorageBrowser/displayText/libraries/en/downloadView.mjs +4 -0
  13. package/dist/esm/components/StorageBrowser/service-worker/useServiceWorkerRegistration.mjs +25 -0
  14. package/dist/esm/components/StorageBrowser/useAction/useHandler.mjs +6 -2
  15. package/dist/esm/components/StorageBrowser/views/LocationActionView/DownloadView/DownloadViewProvider.mjs +60 -8
  16. package/dist/esm/components/StorageBrowser/views/LocationActionView/DownloadView/useDownloadView.mjs +376 -5
  17. package/dist/esm/components/StorageBrowser/views/LocationActionView/DownloadView/utils.mjs +172 -0
  18. package/dist/esm/components/StorageBrowser/views/context/actionViews.mjs +1 -0
  19. package/dist/esm/components/StorageBrowser/views/context/primaryViews.mjs +1 -1
  20. package/dist/esm/version.mjs +1 -1
  21. package/dist/index.js +1 -1
  22. package/dist/styles.css +144 -114
  23. package/dist/types/components/StorageBrowser/actions/handlers/download.d.ts +15 -0
  24. package/dist/types/components/StorageBrowser/actions/handlers/utils.d.ts +12 -0
  25. package/dist/types/components/StorageBrowser/actions/handlers/zipdownload.d.ts +2 -2
  26. package/dist/types/components/StorageBrowser/actions/index.d.ts +1 -1
  27. package/dist/types/components/StorageBrowser/displayText/types.d.ts +21 -0
  28. package/dist/types/components/StorageBrowser/service-worker/download-sw.d.ts +2 -0
  29. package/dist/types/components/StorageBrowser/service-worker/useServiceWorkerRegistration.d.ts +2 -0
  30. package/dist/types/components/StorageBrowser/useAction/types.d.ts +1 -0
  31. package/dist/types/components/StorageBrowser/views/LocationActionView/DownloadView/types.d.ts +40 -0
  32. package/dist/types/components/StorageBrowser/views/LocationActionView/DownloadView/utils.d.ts +99 -0
  33. package/dist/types/components/StorageBrowser/views/LocationActionView/index.d.ts +1 -1
  34. package/dist/types/version.d.ts +1 -1
  35. 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 || hasSelectedFolders(selected);
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 as downloadHandler$1 } from './zipdownload.mjs';
2
+ import { zipDownloadHandler } from './zipdownload.mjs';
3
3
 
4
- const composedDownloadHandler = (input) => input.all.length === 1 ? downloadHandler(input) : downloadHandler$1(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 { BlobWriter, ZipWriter, BlobReader } from '@zip.js/zip.js';
4
+ import { ZipWriter } from '@zip.js/zip.js';
5
5
 
6
- const zipper = (() => {
7
- let blobWriter = null;
8
- let zipWriter = null;
9
- return {
10
- addFile: async (file, name, options) => {
11
- const { data, signal, onProgress } = options ?? {};
12
- if (!blobWriter) {
13
- blobWriter = new BlobWriter('application/zip');
14
- zipWriter = new ZipWriter(blobWriter);
15
- }
16
- await zipWriter.add(name, new BlobReader(file), {
17
- level: 0,
18
- signal,
19
- onprogress: (progress, total) => {
20
- if (isFunction(onProgress) && data) {
21
- onProgress(progress / total, name, data);
22
- }
23
- return undefined;
24
- },
25
- });
26
- },
27
- getBlobUrl: async () => {
28
- if (!zipWriter) {
29
- throw new Error('no zip');
30
- }
31
- const blob = await zipWriter.close();
32
- zipWriter = null;
33
- blobWriter = null;
34
- return URL.createObjectURL(blob);
35
- },
36
- destroy: () => {
37
- zipWriter = null;
38
- blobWriter = null;
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
- const readBody = async (response, { data, options }) => {
44
- let loading = true;
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
- const reader = response.body.getReader();
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
- loading = false;
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
- else {
55
- chunks.push(value);
56
- received += value.length;
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
- return new Blob(chunks);
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
- const download = async ({ config, data, all, options }, abortController) => {
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: abortController.signal,
189
+ signal: state.batchAbort.signal,
84
190
  });
85
- const blob = await readBody(response, { data, options });
86
- const [filename] = key.split('/').reverse();
87
- await zipper.addFile(blob, filename, {
88
- data,
89
- signal: abortController.signal,
90
- onProgress: (progress, _name, _data) => {
91
- if (isFunction(options?.onProgress)) {
92
- options?.onProgress(_data, progress, progress === 1 ? 'COMPLETE' : 'FINISHING');
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
- const downloadHandler = (() => {
99
- const fileDownloadQueue = new Map();
100
- const handler = ({ config, data, all, options }) => {
101
- const { key } = data;
102
- const [, folder] = key.split('/').reverse();
103
- fileDownloadQueue.set(key, false);
104
- const abortController = new AbortController();
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
- abortController.abort();
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
- return handler;
152
- })();
376
+ };
153
377
 
154
- export { downloadHandler as zipDownloadHandler };
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();