feat: show live progress while downloading named files

Bunny/cross-origin downloads are fetched into a blob before saving, which
on large files or slow connections looked stuck (spinner only). Stream the
body through a counting transform and show real byte progress in a toast:
per-file percent for single downloads and file N/M + percent for bulk.

- Progress is measured from Content-Length + received bytes (not estimated).
- The blob is assembled by the browser from the stream (can be disk-backed),
  so we don't accumulate chunks in the JS heap.
- Only the blob path shows a toast; same-origin (R2/S3/MinIO) and the >10 GB
  fallback use the browser's native download UI.
This commit is contained in:
yusufipk
2026-07-10 22:27:53 +07:00
parent 8845c2c643
commit 5821f73d38
4 changed files with 157 additions and 21 deletions
+70 -8
View File
@@ -13,6 +13,12 @@ const MIME_EXTENSION_MAP: Record<string, string> = {
'video/x-msvideo': 'avi',
};
export type DownloadProgress = {
receivedBytes: number;
/** null when the server didn't send a Content-Length. */
totalBytes: number | null;
};
export function extensionFromUrl(url: string): string {
const path = url.split('?')[0] ?? url;
const dot = path.lastIndexOf('.');
@@ -27,6 +33,22 @@ function replaceExtension(fileName: string, ext: string): string {
return `${stem}.${ext}`;
}
export function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return '0 MB';
const mb = bytes / (1024 * 1024);
if (mb < 1024) return `${mb.toFixed(mb < 10 ? 1 : 0)} MB`;
return `${(mb / 1024).toFixed(2)} GB`;
}
export function downloadProgressLabel(progress: DownloadProgress): string {
const { receivedBytes, totalBytes } = progress;
if (totalBytes && totalBytes > 0) {
const pct = Math.min(100, Math.floor((receivedBytes / totalBytes) * 100));
return `${pct}% · ${formatBytes(receivedBytes)} / ${formatBytes(totalBytes)}`;
}
return formatBytes(receivedBytes);
}
export function saveBlobAs(blob: Blob, fileName: string): void {
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement('a');
@@ -45,12 +67,20 @@ export function saveBlobAs(blob: Blob, fileName: string): void {
* CDN sources we pull the bytes (CORS is open on them) and save the blob, which
* lets us control the name and derive the real extension from the content type.
*
* The body is streamed through a counting transform so `onProgress` can report
* live progress; the Blob itself is assembled by the browser (which can back
* large blobs on disk) rather than accumulated in the JS heap.
*
* Returns `false` (without downloading) when the file is larger than
* MAX_NAMED_DOWNLOAD_BYTES — buffering that in memory would be unsafe — or when
* the fetch isn't usable, so the caller can fall back to a plain navigation.
* Returns `true` when the named blob was saved.
* MAX_NAMED_DOWNLOAD_BYTES — buffering that would be unsafe — or when the fetch
* isn't usable, so the caller can fall back to a plain navigation. Returns
* `true` when the named blob was saved.
*/
export async function downloadNamedFile(url: string, fileName: string): Promise<boolean> {
export async function downloadNamedFile(
url: string,
fileName: string,
onProgress?: (progress: DownloadProgress) => void
): Promise<boolean> {
let res: Response;
try {
res = await fetch(url, { cache: 'no-store' });
@@ -59,20 +89,52 @@ export async function downloadNamedFile(url: string, fileName: string): Promise<
}
if (!res.ok) return false;
const contentLength = Number(res.headers.get('content-length'));
if (Number.isFinite(contentLength) && contentLength > MAX_NAMED_DOWNLOAD_BYTES) {
const contentLengthRaw = Number(res.headers.get('content-length'));
const totalBytes =
Number.isFinite(contentLengthRaw) && contentLengthRaw > 0 ? contentLengthRaw : null;
if (totalBytes !== null && totalBytes > MAX_NAMED_DOWNLOAD_BYTES) {
await res.body?.cancel().catch(() => {});
return false;
}
// Extension comes from the response Content-Type (a stream-built Blob has no
// type), falling back to the URL.
const contentType = (res.headers.get('content-type') || '').split(';')[0]?.trim() ?? '';
let streamed: ReadableStream<Uint8Array<ArrayBufferLike>> | null = res.body;
if (res.body && onProgress) {
let received = 0;
let lastMarker = -1;
const counter = new TransformStream<Uint8Array<ArrayBufferLike>, Uint8Array<ArrayBufferLike>>({
transform(chunk, controller) {
received += chunk.byteLength;
// Safety net for streams without a Content-Length.
if (received > MAX_NAMED_DOWNLOAD_BYTES) {
controller.error(new Error('File exceeds the in-memory download limit'));
return;
}
// Throttle: emit on each whole-percent change (or per ~2 MB if unknown).
const marker = totalBytes
? Math.floor((received / totalBytes) * 100)
: Math.floor(received / (2 * 1024 * 1024));
if (marker !== lastMarker) {
lastMarker = marker;
onProgress({ receivedBytes: received, totalBytes });
}
controller.enqueue(chunk);
},
});
streamed = res.body.pipeThrough(counter);
}
let blob: Blob;
try {
blob = await res.blob();
blob = streamed ? await new Response(streamed).blob() : await res.blob();
} catch {
return false;
}
const mimeExt = MIME_EXTENSION_MAP[blob.type];
const mimeExt = MIME_EXTENSION_MAP[contentType];
saveBlobAs(blob, mimeExt ? replaceExtension(fileName, mimeExt) : fileName);
return true;
}
+42 -7
View File
@@ -4,6 +4,15 @@ import { downloadNamedFile, navigateDownload } from '@/lib/client/download-file'
const DOWNLOAD_STAGGER_MS = 500;
export type ManifestDownloadProgress = {
/** 1-based index of the file currently downloading. */
index: number;
total: number;
fileName: string;
receivedBytes: number;
totalBytes: number | null;
};
export type ProjectDownloadManifestFile = {
fileName: string;
url: string;
@@ -21,10 +30,14 @@ function sleep(ms: number): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
async function triggerBrowserDownload(file: ProjectDownloadManifestFile): Promise<void> {
async function triggerBrowserDownload(
file: ProjectDownloadManifestFile,
onProgress?: (received: number, total: number | null) => void
): Promise<void> {
// Same-origin proxy files (R2 / S3 / MinIO via /api/upload/video/...): the
// download attribute applies and the browser streams straight to disk, so the
// name is correct at any size with no memory cost.
// name is correct at any size with no memory cost (browser shows its own
// progress, so we don't track bytes here).
if (file.url.startsWith('/api/upload/video/')) {
navigateDownload(file.url, file.fileName);
return;
@@ -33,17 +46,39 @@ async function triggerBrowserDownload(file: ProjectDownloadManifestFile): Promis
// Bunny (CDN redirect) and external direct hosts are cross-origin, so the name
// only applies if we fetch the bytes. downloadNamedFile does that for files up
// to 10 GB; larger ones fall back to a plain navigation (CDN filename).
const saved = await downloadNamedFile(file.url, file.fileName);
const saved = await downloadNamedFile(file.url, file.fileName, (p) =>
onProgress?.(p.receivedBytes, p.totalBytes)
);
if (!saved) {
navigateDownload(file.url, file.fileName);
}
}
export async function runProjectDownloadManifest(manifest: ProjectDownloadManifest): Promise<void> {
for (let index = 0; index < manifest.files.length; index += 1) {
export async function runProjectDownloadManifest(
manifest: ProjectDownloadManifest,
onProgress?: (progress: ManifestDownloadProgress) => void
): Promise<void> {
const total = manifest.files.length;
for (let index = 0; index < total; index += 1) {
const file = manifest.files[index]!;
onProgress?.({
index: index + 1,
total,
fileName: file.fileName,
receivedBytes: 0,
totalBytes: file.sizeBytes,
});
// Sequential so at most one file is buffered in memory at a time.
await triggerBrowserDownload(manifest.files[index]!);
if (index < manifest.files.length - 1) {
await triggerBrowserDownload(file, (received, fileTotal) =>
onProgress?.({
index: index + 1,
total,
fileName: file.fileName,
receivedBytes: received,
totalBytes: fileTotal,
})
);
if (index < total - 1) {
await sleep(DOWNLOAD_STAGGER_MS);
}
}