Files
bee-dashboard/src/modules/filemanager/utils/download.ts
T
Bálint Ujvári 308ec3dcc0 fix: use upload and download abort signals (#212)
* fix: use upload and download abort signals

* fix: progress trackers

chore: update fm-lib and bee-js

* chore: bump-up fm-lib version
2026-03-20 16:02:17 +01:00

416 lines
12 KiB
TypeScript

import { FileInfo, FileManager } from '@solarpunkltd/file-manager-lib'
import { DownloadProgress, DownloadState } from '../constants/transfers'
import { AbortManager } from './abortManager'
import { guessMime, VIEWERS } from './view'
const downloadAborts = new AbortManager()
enum Errors {
AbortError = 'AbortError',
NotAllowedError = 'NotAllowedError',
SecurityError = 'SecurityError',
}
export function createDownloadAbort(id: string): void {
downloadAborts.create(id)
}
export function abortDownload(id: string): void {
downloadAborts.abort(id)
}
const processStream = async (
stream: ReadableStream<Uint8Array>,
fileHandle: FileSystemFileHandle,
onDownloadProgress?: (progress: DownloadProgress) => void,
signal?: AbortSignal,
): Promise<void> => {
const reader = stream.getReader()
let writable: WritableStreamDefaultWriter<Uint8Array> | undefined
let progress = 0
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
writable = (await (fileHandle as any).createWritable()) as WritableStreamDefaultWriter<Uint8Array>
let done = false
while (!done) {
if (signal?.aborted) throw new DOMException('Aborted', Errors.AbortError)
const { value, done: streamDone } = await reader.read()
if (value) {
await writable.write(value)
progress += value.length
}
done = streamDone
onDownloadProgress?.({ progress, isDownloading: !done })
}
} catch (e: unknown) {
const isAbort = (e as { name?: string }).name === Errors.AbortError
if (isAbort) {
onDownloadProgress?.({ progress, isDownloading: false, state: DownloadState.Cancelled })
} else {
onDownloadProgress?.({ progress, isDownloading: false, state: DownloadState.Error })
// eslint-disable-next-line no-console
console.error('Failed to process stream: ', e)
}
throw e
} finally {
reader.releaseLock()
try {
if (signal?.aborted) {
await writable?.abort()
} else {
await writable?.close()
}
} catch (e: unknown) {
/* no-op */
// eslint-disable-next-line no-console
console.error('filehandle close/abort error: ', e)
}
}
}
const streamToBlob = async (
stream: ReadableStream<Uint8Array>,
mimeType: string,
onDownloadProgress?: (dp: DownloadProgress) => void,
signal?: AbortSignal,
): Promise<Blob | undefined> => {
const reader = stream.getReader()
const chunks: Uint8Array[] = []
let progress = 0
try {
let done = false
while (!done) {
if (signal?.aborted) throw new DOMException('Aborted', Errors.AbortError)
const { value, done: streamDone } = await reader.read()
if (value) {
chunks.push(value)
progress += value.length
}
done = streamDone
onDownloadProgress?.({ progress, isDownloading: !done })
}
} catch (error: unknown) {
if ((error as { name?: string }).name === Errors.AbortError) {
onDownloadProgress?.({ progress, isDownloading: false, state: DownloadState.Cancelled })
} else {
onDownloadProgress?.({ progress, isDownloading: false, state: DownloadState.Error })
// eslint-disable-next-line no-console
console.error('Error during stream processing: ', error)
}
return
} finally {
reader.releaseLock()
}
const combined = new Uint8Array(chunks.reduce((acc, c) => acc + c.length, 0))
let offset = 0
for (const c of chunks) {
combined.set(c, offset)
offset += c.length
}
return new Blob([combined], { type: mimeType })
}
export interface FileInfoWithUUID {
uuid: string
info: FileInfo
}
interface FileInfoWithHandle {
infoWithId: FileInfoWithUUID
handle?: FileSystemFileHandle
cancelled?: boolean
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const isPickerSupported = (): boolean => typeof (window as any).showSaveFilePicker === 'function'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const isDirectoryPickerSupported = (): boolean => typeof (window as any).showDirectoryPicker === 'function'
const isUserCancellation = (error: unknown): boolean => {
const errName = (error as { name?: string })?.name
return errName === Errors.AbortError || errName === Errors.NotAllowedError || errName === Errors.SecurityError
}
const getSingleFileHandle = async (
infoWithId: FileInfoWithUUID,
defaultDownloadFolder: string,
): Promise<FileInfoWithHandle[] | undefined> => {
const { mime, ext } = guessMime(infoWithId.info.name, infoWithId.info.customMetadata)
const pickerOptions: {
suggestedName: string
startIn: string
types?: Array<{ accept: Record<string, string[]> }>
} = {
suggestedName: infoWithId.info.name,
startIn: defaultDownloadFolder,
}
if (ext) {
pickerOptions.types = [{ accept: { [mime]: [`.${ext}`] } }]
}
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handle = (await (window as any).showSaveFilePicker(pickerOptions)) as FileSystemFileHandle
return [{ infoWithId, handle }]
} catch (error: unknown) {
return isUserCancellation(error) ? [{ infoWithId, cancelled: true }] : undefined
}
}
const getMultipleFileHandles = async (
infoWithIdList: FileInfoWithUUID[],
defaultDownloadFolder: string,
): Promise<FileInfoWithHandle[] | undefined> => {
if (!isDirectoryPickerSupported()) {
const handles: FileInfoWithHandle[] = []
for (const info of infoWithIdList) {
const result = await getSingleFileHandle(info, defaultDownloadFolder)
if (!result) return undefined
handles.push(result[0])
}
return handles
}
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const dirHandle = (await (window as any).showDirectoryPicker({
mode: 'readwrite',
startIn: defaultDownloadFolder,
})) as FileSystemDirectoryHandle
const handles: FileInfoWithHandle[] = []
for (const infoWithId of infoWithIdList) {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const fileHandle = (await (dirHandle as any).getFileHandle(infoWithId.info.name, {
create: true,
})) as FileSystemFileHandle
handles.push({ infoWithId, handle: fileHandle })
} catch (error: unknown) {
// eslint-disable-next-line no-console
console.error(`Failed to create file handle for ${infoWithId.info.name}:`, error)
handles.push({ infoWithId, cancelled: true })
}
}
return handles
} catch (error: unknown) {
return isUserCancellation(error) ? infoWithIdList.map(infoWithId => ({ infoWithId, cancelled: true })) : undefined
}
}
const getFileHandles = (infoWithIdList: FileInfoWithUUID[]): Promise<FileInfoWithHandle[] | undefined> => {
const defaultDownloadFolder = 'downloads'
if (!isPickerSupported()) return Promise.resolve(infoWithIdList.map(infoWithId => ({ infoWithId })))
if (infoWithIdList.length === 1) {
return getSingleFileHandle(infoWithIdList[0], defaultDownloadFolder)
}
return getMultipleFileHandles(infoWithIdList, defaultDownloadFolder)
}
const downloadToDisk = async (
streams: ReadableStream<Uint8Array>[],
handle: FileSystemFileHandle,
onDownloadProgress?: (progress: DownloadProgress) => void,
signal?: AbortSignal,
): Promise<boolean> => {
try {
for (const stream of streams) {
await processStream(stream, handle, onDownloadProgress, signal)
}
return true
} catch (error: unknown) {
if ((error as { name?: string }).name !== Errors.AbortError) {
// eslint-disable-next-line no-console
console.error('Error during download to disk: ', error)
}
return false
}
}
const downloadToBlob = async (
streams: ReadableStream<Uint8Array>[],
info: FileInfo,
onDownloadProgress?: (progress: DownloadProgress) => void,
isOpenWindow?: boolean,
signal?: AbortSignal,
): Promise<boolean> => {
try {
for (const stream of streams) {
const { mime } = guessMime(info.name, info.customMetadata)
const blob = await streamToBlob(stream, mime, onDownloadProgress, signal)
if (!blob) {
return false
}
const url = URL.createObjectURL(blob)
let opened = false
if (isOpenWindow) {
opened = openNewWindow(info.name, mime, url)
}
if (!opened) {
downloadFromUrl(url, info.name)
}
}
return true
} catch (error: unknown) {
if ((error as { name?: string }).name !== Errors.AbortError) {
// eslint-disable-next-line no-console
console.error('Error during download and open: ', error)
}
return false
}
}
const openNewWindow = (name: string, mime: string, url: string): boolean => {
const viewer = VIEWERS.find(v => v.test(mime))
const win = window.open('', '_blank')
if (viewer && win) {
viewer.render(win, url, mime, name)
return true
}
win?.close()
return false
}
const downloadFromUrl = (url: string, fileName: string): void => {
const a = document.createElement('a')
a.href = url
a.download = fileName
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
}
export const startDownloadingQueue = async (
fm: FileManager,
infoListWithIds: FileInfoWithUUID[],
trackers?: Array<(progress: DownloadProgress) => void>,
isOpenWindow?: boolean,
): Promise<void> => {
if (!infoListWithIds.length || (trackers && trackers.length !== infoListWithIds.length)) return
try {
const fileHandles: FileInfoWithHandle[] | undefined = isOpenWindow
? infoListWithIds.map(infoWithId => ({ infoWithId }))
: await getFileHandles(infoListWithIds)
if (!fileHandles) return
await Promise.all(
fileHandles.map(async (fh, i) => {
const tracker = trackers ? trackers[i] : undefined
const uuid = fh.infoWithId.uuid
createDownloadAbort(uuid)
const signal = downloadAborts.getSignal(uuid)
try {
if (fh.cancelled) {
tracker?.({ progress: 0, isDownloading: false, state: DownloadState.Cancelled })
return
}
const dataStreams = (await fm.download(fh.infoWithId.info, undefined, undefined, {
signal,
})) as ReadableStream<Uint8Array>[]
if (!dataStreams || dataStreams.length === 0) {
// eslint-disable-next-line no-console
console.error(`No data streams returned for ${fh.infoWithId.info.name}`)
tracker?.({ progress: 0, isDownloading: false, state: DownloadState.Error })
return
}
let success = false
if (isOpenWindow || !fh.handle) {
success = await downloadToBlob(dataStreams, fh.infoWithId.info, tracker, isOpenWindow, signal)
} else {
success = await downloadToDisk(dataStreams, fh.handle, tracker, signal)
}
if (!tracker) {
return
}
if (success) {
const size = fh.infoWithId.info.customMetadata?.size
const finalProgress = size ? Number(size) : 0
tracker({ progress: finalProgress, isDownloading: false })
return
}
if (!signal?.aborted) {
tracker({ progress: 0, isDownloading: false, state: DownloadState.Error })
}
} catch (error: unknown) {
const isAbortError = (error as { name?: string }).name === Errors.AbortError
if (!isAbortError) {
tracker?.({ progress: 0, isDownloading: false, state: DownloadState.Error })
// eslint-disable-next-line no-console
console.error('download queue error: ', error)
} else {
tracker?.({ progress: 0, isDownloading: false, state: DownloadState.Cancelled })
}
} finally {
downloadAborts.abort(uuid)
}
}),
)
} catch (e: unknown) {
// eslint-disable-next-line no-console
console.error('An error happened in the download queue: ', e)
}
}