|
| 1 | +/** Bounded binary reads shared by Desktop and Cloud; previews have separate limits. */ |
| 2 | +export const FILE_DOWNLOAD_CHUNK_BYTES = 256 * 1024 |
| 3 | +export interface FileDownloadChunk { |
| 4 | + data: Uint8Array |
| 5 | + size: number |
| 6 | + revision: string |
| 7 | +} |
| 8 | + |
| 9 | +/** Fetch the first chunk before sending headers so missing files remain HTTP errors. |
| 10 | + * Later failures abort the body: a partial download must never look successful. */ |
| 11 | +export async function fileDownloadResponse( |
| 12 | + path: string, |
| 13 | + read: (offset: number) => Promise<FileDownloadChunk>, |
| 14 | + options: { signal?: AbortSignal; close?: () => Promise<void> } = {}, |
| 15 | +): Promise<Response> { |
| 16 | + let closed = false |
| 17 | + let onAbort: (() => void) | undefined |
| 18 | + const close = async () => { |
| 19 | + if (closed) return |
| 20 | + closed = true |
| 21 | + if (onAbort) options.signal?.removeEventListener('abort', onAbort) |
| 22 | + await options.close?.() |
| 23 | + } |
| 24 | + let first: FileDownloadChunk |
| 25 | + try { |
| 26 | + options.signal?.throwIfAborted() |
| 27 | + first = await read(0) |
| 28 | + options.signal?.throwIfAborted() |
| 29 | + if (!Number.isSafeInteger(first.size) || first.size < 0) throw new Error('Invalid download size') |
| 30 | + } catch (error) { |
| 31 | + await close() |
| 32 | + throw error |
| 33 | + } |
| 34 | + let offset = 0 |
| 35 | + let initial: FileDownloadChunk | undefined = first |
| 36 | + const body = new ReadableStream<Uint8Array>({ |
| 37 | + start(controller) { |
| 38 | + onAbort = () => { |
| 39 | + controller.error(options.signal?.reason ?? new Error('Download cancelled')) |
| 40 | + void close().catch(() => {}) |
| 41 | + } |
| 42 | + options.signal?.addEventListener('abort', onAbort, { once: true }) |
| 43 | + }, |
| 44 | + async pull(controller) { |
| 45 | + try { |
| 46 | + options.signal?.throwIfAborted() |
| 47 | + const chunk = initial ?? await read(offset) |
| 48 | + initial = undefined |
| 49 | + if (closed) return |
| 50 | + options.signal?.throwIfAborted() |
| 51 | + if (chunk.size !== first.size || chunk.revision !== first.revision || |
| 52 | + chunk.data.length !== Math.min(FILE_DOWNLOAD_CHUNK_BYTES, first.size - offset)) |
| 53 | + throw new Error('The file changed during download. Please try again.') |
| 54 | + offset += chunk.data.length |
| 55 | + if (chunk.data.length) controller.enqueue(chunk.data) |
| 56 | + if (offset === first.size) { |
| 57 | + await close() |
| 58 | + controller.close() |
| 59 | + } |
| 60 | + } catch (error) { |
| 61 | + await close() |
| 62 | + controller.error(error) |
| 63 | + } |
| 64 | + }, |
| 65 | + cancel: close, |
| 66 | + }) |
| 67 | + const filename = path.split(/[\\/]/).pop() || 'download' |
| 68 | + return new Response(body, { headers: { |
| 69 | + 'Content-Type': 'application/octet-stream', |
| 70 | + 'Content-Length': String(first.size), |
| 71 | + 'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(filename).replace(/['()*]/g, char => '%' + char.charCodeAt(0).toString(16))}`, |
| 72 | + 'Cache-Control': 'private, no-store, no-transform', |
| 73 | + 'X-Content-Type-Options': 'nosniff', |
| 74 | + } }) |
| 75 | +} |
0 commit comments