-
Notifications
You must be signed in to change notification settings - Fork 1
feature: adding progress tracking when zipping and when uploading #139
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
2a315c0
ed1a4bf
eca52f9
180bc4d
7f60e3d
a8c7120
f8cae3f
611d5ec
d084d52
ac65e6c
4f06929
041bf57
dfc6424
76277cb
6691772
739cf6b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| import {Readable, Transform} from 'stream' | ||
|
|
||
| export const ON_PROGRESS_THROTTLE_MS = 2000 | ||
|
|
||
| export function createProgressStream( | ||
| totalSize: number, | ||
| onProgress: (sent: number, total: number) => void, | ||
| throttleMs?: number | ||
| ): Transform { | ||
| let sent = 0 | ||
| let lastCallTime = 0 | ||
|
|
||
| return new Transform({ | ||
| transform(chunk, encoding, callback) { | ||
| sent += chunk.length | ||
|
|
||
| const now = Date.now() | ||
| if (!throttleMs || now - lastCallTime >= throttleMs) { | ||
| onProgress(sent, totalSize) | ||
| lastCallTime = now | ||
| } | ||
|
|
||
| callback(null, chunk) | ||
| }, | ||
| }) | ||
|
Comment on lines
+13
to
+25
|
||
| } | ||
|
|
||
| interface ProgressData { | ||
| progress: number | ||
| loadedBytes: number | ||
| totalBytes: number | ||
| speedMBps: number | ||
| elapsedSeconds: number | ||
| } | ||
|
|
||
| interface UploadProps { | ||
| url: string | ||
| zipStream: Readable | ||
| zipSize: number | ||
| onProgress: (data: ProgressData) => void | ||
| } | ||
|
|
||
| // Uploads a zip file with progress tracking | ||
| export function uploadZip({url, zipStream, zipSize, onProgress}: UploadProps): Promise<Response> { | ||
| const startTime = Date.now() | ||
|
|
||
| const progressStream = createProgressStream(zipSize, (sent, total) => { | ||
| const elapsedSeconds = (Date.now() - startTime) / 1000 | ||
| const speedMBps = elapsedSeconds < 0.001 ? 0 : sent / elapsedSeconds / 1024 / 1024 | ||
| onProgress({ | ||
| progress: total ? sent / total : 0, | ||
| loadedBytes: sent, | ||
| totalBytes: total, | ||
| speedMBps, | ||
| elapsedSeconds, | ||
| }) | ||
| }, ON_PROGRESS_THROTTLE_MS) | ||
|
|
||
| const streamWithProgress = zipStream.pipe(progressStream) | ||
| const webStream = Readable.toWeb(streamWithProgress) as ReadableStream<Uint8Array> | ||
|
|
||
| // The 'duplex' property is required when using a ReadableStream as the request body. | ||
| // 'duplex: half' indicates half-duplex communication (one direction at a time), | ||
| // which is the mode needed for streaming request bodies with fetch(). | ||
| // Type assertion is necessary because 'duplex' is not yet part of the standard | ||
| // TypeScript RequestInit type definition, though it's required by the fetch spec | ||
| // for streaming uploads. | ||
| const response = fetch(url, { | ||
| method: 'PUT', | ||
| headers: { | ||
| 'Content-Type': 'application/zip', | ||
| 'Content-Length': zipSize.toString(), | ||
| }, | ||
| body: webStream, | ||
| duplex: 'half', | ||
| } as RequestInit & {duplex: 'half'}) | ||
madebydavid marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| return response | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,102 @@ | ||
| import fs from 'node:fs' | ||
| import {ZipFile} from 'yazl' | ||
|
|
||
| import {createProgressStream, ON_PROGRESS_THROTTLE_MS} from './upload.js' | ||
|
|
||
| // Used to estimate the final zip size | ||
madebydavid marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| const COMPRESSION_RATIO = 0.65 | ||
|
|
||
| export interface ZipProgressData { | ||
| progress: number | ||
| writtenBytes: number | ||
| estimatedTotalBytes: number | ||
| sourceTotalBytes: number | ||
| elapsedSeconds: number | ||
| speedMBps: number | ||
| } | ||
|
|
||
| interface CreateZipProps { | ||
| files: string[] | ||
| outputPath: string | ||
| onProgress: (data: ZipProgressData) => void | ||
| } | ||
|
|
||
| // Creates a zip file with progress tracking | ||
| export async function createZip({files, outputPath, onProgress}: CreateZipProps): Promise<void> { | ||
| const startTime = Date.now() | ||
|
|
||
| const statPromises = files.map(async (file) => { | ||
| try { | ||
| return await fs.promises.stat(file) | ||
| } catch { | ||
| // Skip inaccessible files | ||
| return null | ||
| } | ||
| }) | ||
|
|
||
| const statsResults = await Promise.all(statPromises) | ||
|
|
||
| let totalSourceSize = 0 | ||
| for (const stats of statsResults) { | ||
| if (stats) { | ||
| totalSourceSize += stats.size | ||
| } | ||
| } | ||
|
|
||
| const estimatedZipSize = Math.max(Math.round(totalSourceSize * COMPRESSION_RATIO), 1) | ||
|
|
||
| const zipFile = new ZipFile() | ||
| for (const file of files) { | ||
| zipFile.addFile(file, file) | ||
| } | ||
|
|
||
| return new Promise<void>((resolve, reject) => { | ||
| let settled = false | ||
|
|
||
| const handleError = (error: Error) => { | ||
| if (settled) return | ||
| settled = true | ||
|
|
||
| // Clean up streams | ||
| zipFile.outputStream.destroy() | ||
| progressStream.destroy() | ||
| outputStream.destroy() | ||
|
|
||
| reject(error) | ||
| } | ||
|
|
||
| const handleSuccess = () => { | ||
| if (settled) return | ||
| settled = true | ||
| resolve() | ||
| } | ||
|
|
||
| const outputStream = fs.createWriteStream(outputPath) | ||
|
|
||
| const progressStream = createProgressStream(estimatedZipSize, (written, total) => { | ||
| const elapsedSeconds = (Date.now() - startTime) / 1000 | ||
| const speedMBps = elapsedSeconds < 0.001 ? 0 : written / elapsedSeconds / 1024 / 1024 | ||
| onProgress({ | ||
| progress: total ? Math.min(1, written / total) : 0, | ||
| writtenBytes: written, | ||
| estimatedTotalBytes: total, | ||
| sourceTotalBytes: totalSourceSize, | ||
| elapsedSeconds, | ||
| speedMBps, | ||
| }) | ||
| }, ON_PROGRESS_THROTTLE_MS) | ||
|
|
||
| // Add error handlers to all streams in the pipe chain | ||
| zipFile.outputStream.on('error', handleError) | ||
| progressStream.on('error', handleError) | ||
| outputStream.on('error', handleError) | ||
| outputStream.on('close', handleSuccess) | ||
|
|
||
| zipFile.outputStream | ||
| .pipe(progressStream) | ||
| .pipe(outputStream) | ||
|
|
||
| zipFile.end() | ||
| }) | ||
| } | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.