|
1 | 1 | /**
|
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. |
9 | 4 | */
|
10 | 5 | export async function downloadReadableStream(
|
11 | 6 | stream: ReadableStream<Uint8Array>,
|
12 | 7 | filename = 'download.tdf',
|
13 | 8 | options?: { signal?: AbortSignal }
|
14 | 9 | ): 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( |
19 | 23 | {
|
20 |
| - description: 'TDF File', |
21 |
| - accept: { 'application/octet-stream': ['.tdf'] }, |
| 24 | + downloadId, |
| 25 | + filename, |
| 26 | + port: channel.port2, |
22 | 27 | },
|
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; |
26 | 71 | let aborted = false;
|
27 | 72 | const signal = options?.signal;
|
28 | 73 | let abortHandler: (() => void) | undefined;
|
29 | 74 |
|
30 | 75 | if (signal) {
|
31 | 76 | if (signal.aborted) {
|
32 |
| - await writable.abort(); |
33 | 77 | throw new DOMException('Aborted', 'AbortError');
|
34 | 78 | }
|
35 | 79 | abortHandler = () => {
|
36 | 80 | aborted = true;
|
37 |
| - writable.abort(); |
| 81 | + reader.cancel(); |
38 | 82 | };
|
39 | 83 | signal.addEventListener('abort', abortHandler);
|
40 | 84 | }
|
41 |
| - |
42 | 85 | 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; |
47 | 95 | }
|
48 |
| - throw err; |
49 | 96 | } finally {
|
50 | 97 | if (signal && abortHandler) {
|
51 | 98 | signal.removeEventListener('abort', abortHandler);
|
52 | 99 | }
|
53 | 100 | }
|
| 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); |
54 | 115 | }
|
0 commit comments