102 lines
3.6 KiB
TypeScript
102 lines
3.6 KiB
TypeScript
import type { ArtifactDownloadReferenceResponse } from "../api/types";
|
|
|
|
type ArtifactContentLoader = (artifactId: string) => Promise<Response>;
|
|
type DownloadChunk = ArrayBuffer;
|
|
|
|
export async function downloadArtifactReference(
|
|
reference: ArtifactDownloadReferenceResponse,
|
|
openContent: ArtifactContentLoader,
|
|
onProgress?: (progress: number) => void
|
|
) {
|
|
const response = await openContent(reference.artifactId);
|
|
const writer = await createArtifactFileWriter(reference.filename, reference.contentType);
|
|
const totalBytes = Number(response.headers.get("Content-Length") ?? reference.sizeBytes);
|
|
let downloadedBytes = 0;
|
|
onProgress?.(0);
|
|
|
|
try {
|
|
const reader = response.body?.getReader();
|
|
if (!reader) {
|
|
const payload = await response.arrayBuffer();
|
|
await writer.write(payload);
|
|
downloadedBytes = payload.byteLength;
|
|
onProgress?.(progressPercent(downloadedBytes, totalBytes));
|
|
} else {
|
|
for (;;) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
const chunk = copyStreamChunk(value);
|
|
await writer.write(chunk);
|
|
downloadedBytes += chunk.byteLength;
|
|
onProgress?.(progressPercent(downloadedBytes, totalBytes));
|
|
}
|
|
}
|
|
await writer.close();
|
|
} catch (error) {
|
|
await writer.abort();
|
|
throw error;
|
|
}
|
|
|
|
onProgress?.(100);
|
|
}
|
|
|
|
export function openArtifactBlob(reference: ArtifactDownloadReferenceResponse, chunks: DownloadChunk[]) {
|
|
saveArtifactBlob(new Blob(chunks, { type: reference.contentType }), reference.filename);
|
|
}
|
|
|
|
export function saveArtifactBlob(blob: Blob, filename: string) {
|
|
if (typeof document === "undefined" || typeof URL === "undefined") {
|
|
return;
|
|
}
|
|
const url = URL.createObjectURL(blob);
|
|
const anchor = document.createElement("a");
|
|
anchor.href = url;
|
|
anchor.download = safeArtifactFilename(filename);
|
|
anchor.rel = "noopener";
|
|
document.body.append(anchor);
|
|
anchor.click();
|
|
anchor.remove();
|
|
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
}
|
|
|
|
export function safeArtifactFilename(filename: string): string {
|
|
const cleaned = filename.replace(/[\\/:*?"<>|]+/g, "-").trim();
|
|
return cleaned || "artifact.bin";
|
|
}
|
|
|
|
interface ArtifactFileWriter {
|
|
write(data: DownloadChunk): Promise<void>;
|
|
close(): Promise<void>;
|
|
abort(): Promise<void>;
|
|
}
|
|
|
|
async function createArtifactFileWriter(filename: string, contentType: string): Promise<ArtifactFileWriter> {
|
|
const picker = typeof window === "undefined" ? undefined : (window as Window & { showSaveFilePicker?: (options?: { suggestedName?: string }) => Promise<{ createWritable(): Promise<FileSystemWritableFileStream> }> }).showSaveFilePicker;
|
|
if (picker) {
|
|
const handle = await picker({ suggestedName: safeArtifactFilename(filename) });
|
|
const writable = await handle.createWritable();
|
|
return {
|
|
write: async (data) => { await writable.write(data); },
|
|
close: async () => { await writable.close(); },
|
|
abort: async () => { await writable.abort(); }
|
|
};
|
|
}
|
|
const chunks: DownloadChunk[] = [];
|
|
return {
|
|
write: async (data) => { chunks.push(data); },
|
|
close: async () => { saveArtifactBlob(new Blob(chunks, { type: contentType }), filename); },
|
|
abort: async () => { chunks.length = 0; }
|
|
};
|
|
}
|
|
|
|
function progressPercent(downloadedBytes: number, totalBytes: number): number {
|
|
if (!Number.isFinite(totalBytes) || totalBytes <= 0) return 0;
|
|
return Math.min(100, Math.round((downloadedBytes / totalBytes) * 100));
|
|
}
|
|
|
|
function copyStreamChunk(value: Uint8Array): ArrayBuffer {
|
|
const copy = new Uint8Array(value.byteLength);
|
|
copy.set(value);
|
|
return copy.buffer;
|
|
}
|