Skip to content

Commit 8d8aa58

Browse files
committed
feat: add fallback when showSaveFilePicker is not available
1 parent a4b30ef commit 8d8aa58

File tree

1 file changed

+85
-24
lines changed

1 file changed

+85
-24
lines changed
Lines changed: 85 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,115 @@
11
/**
2-
* Downloads a ReadableStream as a file by streaming it directly to a file handle
3-
* using the File System Access API. Supports aborting via AbortSignal in options.
4-
*
5-
* @param stream - The ReadableStream of Uint8Array data to download as a file.
6-
* @param filename - The name for the downloaded file (default: 'download.tdf').
7-
* @param options - Optional StreamPipeOptions, supports AbortSignal for cancellation.
8-
* @returns Promise that resolves when the download is triggered or rejects if aborted.
2+
* Downloads a ReadableStream as a file using a service worker stream (streamsaver-like) or a Blob fallback.
3+
* Efficient for large files and supports aborting via AbortSignal in options.
94
*/
105
export async function downloadReadableStream(
116
stream: ReadableStream<Uint8Array>,
127
filename = 'download.tdf',
138
options?: { signal?: AbortSignal }
149
): Promise<void> {
15-
// Use the File System Access API to prompt the user for a save location
16-
const fileHandle = await window.showSaveFilePicker({
17-
suggestedName: filename,
18-
types: [
10+
if (typeof navigator !== 'undefined' && typeof navigator?.serviceWorker !== 'undefined') {
11+
const swUrl = '/stream-saver-sw.js';
12+
try {
13+
await navigator.serviceWorker.register(swUrl, { scope: '/' });
14+
await navigator.serviceWorker.ready;
15+
} catch (e) {
16+
console.log('Downloading service worker registration failed:', e);
17+
return fallbackDownload(stream, filename, options);
18+
}
19+
20+
const channel = new MessageChannel();
21+
const downloadId = Math.random().toString(36).slice(2);
22+
navigator.serviceWorker.controller?.postMessage(
1923
{
20-
description: 'TDF File',
21-
accept: { 'application/octet-stream': ['.tdf'] },
24+
downloadId,
25+
filename,
26+
port: channel.port2,
2227
},
23-
],
24-
});
25-
const writable = await fileHandle.createWritable();
28+
[channel.port2]
29+
);
30+
31+
// Pipe the stream to the service worker via the channel
32+
if (typeof stream.pipeTo === 'function') {
33+
const writer = channel.port1;
34+
const reader = stream.getReader();
35+
function readAndSend() {
36+
reader.read().then(({ done, value }) => {
37+
if (done) {
38+
writer.postMessage({ done: true });
39+
writer.close();
40+
return;
41+
}
42+
writer.postMessage({ chunk: value });
43+
readAndSend();
44+
});
45+
}
46+
readAndSend();
47+
}
48+
49+
// Hidden iframe triggers the browser download event
50+
const iframe = document.createElement('iframe');
51+
iframe.style.display = 'none';
52+
iframe.src = `/stream-saver-download?downloadId=${downloadId}`;
53+
document.body.appendChild(iframe);
54+
setTimeout(() => {
55+
document.body.removeChild(iframe);
56+
}, 2000); // Clean up after 2 seconds
57+
} else {
58+
fallbackDownload(stream, filename, options);
59+
}
60+
}
61+
62+
// Fallback: collect stream into a Blob and trigger download via anchor
63+
async function fallbackDownload(
64+
stream: ReadableStream<Uint8Array>,
65+
filename: string,
66+
options?: { signal?: AbortSignal }
67+
) {
68+
const reader = stream.getReader();
69+
const chunks: Uint8Array[] = [];
70+
let done = false;
2671
let aborted = false;
2772
const signal = options?.signal;
2873
let abortHandler: (() => void) | undefined;
2974

3075
if (signal) {
3176
if (signal.aborted) {
32-
await writable.abort();
3377
throw new DOMException('Aborted', 'AbortError');
3478
}
3579
abortHandler = () => {
3680
aborted = true;
37-
writable.abort();
81+
reader.cancel();
3882
};
3983
signal.addEventListener('abort', abortHandler);
4084
}
41-
4285
try {
43-
await stream.pipeTo(writable, { signal });
44-
} catch (err) {
45-
if (aborted) {
46-
throw new DOMException('Aborted', 'AbortError');
86+
while (!done) {
87+
if (aborted) {
88+
throw new DOMException('Aborted', 'AbortError');
89+
}
90+
const { value, done: streamDone } = await reader.read();
91+
if (value) {
92+
chunks.push(value);
93+
}
94+
done = streamDone;
4795
}
48-
throw err;
4996
} finally {
5097
if (signal && abortHandler) {
5198
signal.removeEventListener('abort', abortHandler);
5299
}
53100
}
101+
if (aborted) {
102+
throw new DOMException('Aborted', 'AbortError');
103+
}
104+
const blob = new Blob(chunks);
105+
const url = URL.createObjectURL(blob);
106+
const a = document.createElement('a');
107+
a.href = url;
108+
a.download = filename;
109+
document.body.appendChild(a);
110+
a.click();
111+
setTimeout(() => {
112+
document.body.removeChild(a);
113+
URL.revokeObjectURL(url);
114+
}, 0);
54115
}

0 commit comments

Comments
 (0)