Improve progress events for onDownloadProgress and onUploadProgress#736
Improve progress events for onDownloadProgress and onUploadProgress#736sholladay merged 7 commits intosindresorhus:mainfrom
onDownloadProgress and onUploadProgress#736Conversation
Previously, upload streams were pushed as quickly as possible onto the network queue, which meant that progress events were reported faster than the network could actually send data. Now, a pull-based stream with proper backpressure is used to ensure data is transferred to the network just-in-time, when it is ready for more data. Additionally, progress reporting is now more accurate, with progress reported only after a chunk has actually been received by the server.
Similar to 7a06e91, but for download streams
onDownloadProgress and onUploadProgress
|
Initial AI review (will do human review later when I have a chance): Major issues (fix)
Minor issues / polish
Tests to add (high-value)
Minimal patchesDownload path// source/utils/body.ts
export const streamResponse = (response: Response, onDownloadProgress: Options['onDownloadProgress']) => {
const totalBytes = Number(response.headers.get('content-length')) || 0;
let transferredBytes = 0;
let previousChunk: Uint8Array | undefined;
if (response.status === 204) {
onDownloadProgress?.({percent: 1, totalBytes, transferredBytes}, new Uint8Array());
return new Response(null, {headers: response.headers, status: response.status, statusText: response.statusText});
}
return new Response(
new ReadableStream<Uint8Array>({
async pull(controller) {
// Lazily create and keep the reader in the closure.
if (!reader) {
reader = (response.body ?? new Response('').body!).getReader();
}
const {value, done} = await reader.read();
if (previousChunk) {
transferredBytes += previousChunk.byteLength;
const almostOne = 1 - Number.EPSILON;
let percent = totalBytes === 0 ? 0 : transferredBytes / totalBytes;
if (!done && percent >= 1) {
percent = almostOne;
}
onDownloadProgress?.(
{
percent: Math.min(1, percent),
transferredBytes,
totalBytes: Math.max(totalBytes, transferredBytes),
},
previousChunk,
);
}
if (done) {
try {
controller.close();
} finally {
await reader?.cancel();
reader?.releaseLock();
}
return;
}
controller.enqueue(value!);
previousChunk = value!;
},
async cancel(reason) {
try {
await reader?.cancel(reason);
} finally {
reader?.releaseLock();
}
},
}),
{headers: response.headers, status: response.status, statusText: response.statusText},
);
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
};This fixes eager locking, real cancellation, and lock release. It also replaces Upload pathexport const streamRequest = (
request: Request,
onUploadProgress: Options['onUploadProgress'],
originalBody?: BodyInit | null,
) => {
const totalBytes = getBodySize(originalBody ?? request.body);
let transferredBytes = 0;
let previousChunk: Uint8Array | undefined;
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
return new Request(request, {
// @ts-expect-error - Types are outdated.
duplex: 'half',
body: new ReadableStream<Uint8Array>({
async pull(controller) {
if (!reader) {
reader = (request.body ?? new Response('').body!).getReader();
}
const {value, done} = await reader.read();
if (previousChunk) {
transferredBytes += previousChunk.byteLength;
const almostOne = 1 - Number.EPSILON;
let percent = totalBytes === 0 ? 0 : transferredBytes / totalBytes;
if (!done && percent >= 1) {
percent = almostOne;
}
onUploadProgress?.(
{
percent: Math.min(1, percent),
transferredBytes,
totalBytes: Math.max(totalBytes, transferredBytes),
},
previousChunk,
);
}
if (done) {
try {
controller.close();
} finally {
await reader?.cancel();
reader?.releaseLock();
}
return;
}
controller.enqueue(value!);
previousChunk = value!;
},
async cancel(reason) {
try {
await reader?.cancel(reason);
} finally {
reader?.releaseLock();
}
},
}),
});
};Same fixes mirrored for upload. (GitHub) Optional refactor (nice-to-have)Extract a tiny helper to remove duplication: function progressStream(
upstream: ReadableStream<Uint8Array> | null,
totalBytes: number,
emit: (info: {percent: number; transferredBytes: number; totalBytes: number}, chunk: Uint8Array) => void,
): ReadableStream<Uint8Array> {
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
let previousChunk: Uint8Array | undefined;
let transferredBytes = 0;
return new ReadableStream<Uint8Array>({
async pull(controller) {
if (!reader) {
reader = (upstream ?? new Response('').body!).getReader();
}
const {value, done} = await reader.read();
if (previousChunk) {
transferredBytes += previousChunk.byteLength;
const almostOne = 1 - Number.EPSILON;
let percent = totalBytes === 0 ? 0 : transferredBytes / totalBytes;
if (!done && percent >= 1) {
percent = almostOne;
}
emit(
{percent: Math.min(1, percent), transferredBytes, totalBytes: Math.max(totalBytes, transferredBytes)},
previousChunk,
);
}
if (done) {
try {
controller.close();
} finally {
await reader?.cancel();
reader?.releaseLock();
}
return;
}
controller.enqueue(value!);
previousChunk = value!;
},
async cancel(reason) {
try {
await reader?.cancel(reason);
} finally {
reader?.releaseLock();
}
},
});
}Final callouts
|
|
It brings up some good points. I probably do need to improve how cancelation is propagated. Will work on it. |
This makes overages easier to see as visually distinct from 100% on a progress bar.
|
@sindresorhus I fixed most of those issues. I somewhat agree with the AI about the use of There are a few options for what to do when
I am open to any of these, though |
|
Do 1. 3 and 4 breaks a lot of expectations. |
|
|
||
| let percent = totalBytes === 0 ? 0 : transferredBytes / totalBytes; | ||
| if (percent >= 1) { | ||
| percent = 0.999; |
There was a problem hiding this comment.
Needs a succinct code comment
|
Actually, |
When the stream exceeds the expected totalBytes size, we now report 1 - Number.EPSILON as the progress `percent`.
|
It's my least favorite approach, but I made the |
| let percent = totalBytes === 0 ? 0 : transferredBytes / totalBytes; | ||
| // Avoid reporting 100% progress before the stream is actually finished (in case totalBytes is inaccurate) | ||
| if (percent >= 1) { | ||
| percent = 1 - Number.EPSILON; |
There was a problem hiding this comment.
| percent = 1 - Number.EPSILON; | |
| // Prevents percentage from going backwards when close to 100%. | |
| percent = 1 - Number.EPSILON; |
There was a problem hiding this comment.
That is only true relative to the other approaches we could've taken here. I think this comment would be confusing when read by someone in the future.
There was a problem hiding this comment.
Maybe, but I think it needs a clear comment whye we do it like that.
There was a problem hiding this comment.
I wrote a comment above the if condition that I think explains the purpose pretty well. Did you see that? The choice of Number.EPSILON is just a way to achieve that goal. It's merely the largest number that isn't 100%.
There was a problem hiding this comment.
I left a comment to explain epsilon usage. I had to compare it to using 0.99, which we're not doing, for the comment to make any sense.
Fixes #694
Fixes #735
This PR improves the behavior of the
onDownloadProgressandonUploadProgressevent callbacks:percentis calculatedpercentwas rounded for uploads. This rounding has been removed in favor of maximum precision.totalBytesis reached,percentwill now report1 - Number.EPSILONinstead of >= 1, to make it more obvious what is happeningNote: In some cases, body size estimation for
totalBytesmay still be inaccurate. This will be fixed by #734.