Skip to content

Commit 38f2abc

Browse files
authored
feat(request): add upload progress tracking (#86)
1 parent c1192b6 commit 38f2abc

3 files changed

Lines changed: 490 additions & 51 deletions

File tree

src/request/StreamProgress.ts

Lines changed: 169 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
/**
2-
* Stream Progress - Download progress tracking for fetch responses.
2+
* Stream Progress - Upload and download progress tracking for fetch requests.
33
*
4-
* Wraps a Response body ReadableStream to report download progress via callback.
4+
* Wraps request/response body ReadableStreams to report progress via callback.
55
* Works standalone or as RequestInterceptor middleware.
66
*
7-
* @example Standalone usage
7+
* @example Download progress (standalone)
88
* ```TypeScript
99
* const response = await fetch('https://example.com/large-file.zip');
1010
* const tracked = trackDownloadProgress(response, (progress) => {
@@ -16,6 +16,15 @@
1616
* const blob = await tracked.blob();
1717
* ```
1818
*
19+
* @example Upload progress (standalone)
20+
* ```TypeScript
21+
* const body = new Blob([largeData]);
22+
* const trackedBody = trackUploadProgress(body, (progress) => {
23+
* console.log(`Uploaded: ${progress.percentage ?? '?'}%`);
24+
* });
25+
* await fetch('https://example.com/upload', { method: 'POST', body: trackedBody });
26+
* ```
27+
*
1928
* @example With RequestInterceptor middleware
2029
* ```TypeScript
2130
* const api = RequestInterceptor.create({
@@ -26,27 +35,31 @@
2635
* onDownloadProgress: (progress) => {
2736
* console.log(`Downloaded: ${progress.percentage ?? '?'}%`);
2837
* },
38+
* onUploadProgress: (progress) => {
39+
* console.log(`Uploaded: ${progress.percentage ?? '?'}%`);
40+
* },
2941
* }));
30-
*
31-
* const response = await api.get('/files/large.zip');
32-
* await response.blob();
3342
* ```
3443
*/
35-
import type { InterceptedResponse, RequestMiddleware } from './RequestInterceptor.js';
44+
import type {
45+
InterceptedResponse,
46+
MutableRequestConfig,
47+
RequestMiddleware,
48+
} from './RequestInterceptor.js';
3649

3750
// =============================================================================
3851
// Types
3952
// =============================================================================
4053

4154
/**
42-
* Progress information reported during download.
55+
* Progress information reported during upload or download.
4356
*/
4457
export interface ProgressInfo {
45-
/** Bytes received so far */
58+
/** Bytes transferred so far */
4659
readonly loaded: number;
47-
/** Total bytes expected (from Content-Length), or null if unknown */
60+
/** Total bytes expected, or null if unknown */
4861
readonly total: number | null;
49-
/** Download percentage (0-100), or null if total is unknown */
62+
/** Transfer percentage (0-100), or null if total is unknown */
5063
readonly percentage: number | null;
5164
}
5265

@@ -60,7 +73,9 @@ export type ProgressCallback = (progress: ProgressInfo) => void;
6073
*/
6174
export interface ProgressMiddlewareOptions {
6275
/** Callback invoked on each downloaded chunk */
63-
readonly onDownloadProgress: ProgressCallback;
76+
readonly onDownloadProgress?: ProgressCallback;
77+
/** Callback invoked on each uploaded chunk */
78+
readonly onUploadProgress?: ProgressCallback;
6479
}
6580

6681
// =============================================================================
@@ -99,37 +114,16 @@ function calculatePercentage(loaded: number, total: number | null): number | nul
99114
}
100115

101116
/**
102-
* Wrap a Response's body stream to track download progress.
103-
*
104-
* Returns a new Response with the same status and headers but a body
105-
* stream that reports progress to the callback on each chunk.
106-
*
107-
* If the response has no body (e.g. 204 No Content), the callback is
108-
* invoked once with loaded=0 and the original response is returned.
109-
*
110-
* The progress stream supports cancellation — cancelling the returned
111-
* response's body will propagate to the original stream.
112-
*
113-
* @param response - The fetch Response to track
114-
* @param onProgress - Callback invoked on each chunk received
115-
* @returns A new Response with progress-tracked body
117+
* Create a ReadableStream that wraps a source reader and reports progress.
116118
*/
117-
export function trackDownloadProgress(response: Response, onProgress: ProgressCallback): Response {
118-
const total = parseContentLength(response.headers);
119-
120-
if (response.body === null) {
121-
onProgress({
122-
loaded: 0,
123-
total,
124-
percentage: calculatePercentage(0, total),
125-
});
126-
return response;
127-
}
128-
119+
function createProgressStream(
120+
reader: ReadableStreamDefaultReader<Uint8Array>,
121+
total: number | null,
122+
onProgress: ProgressCallback
123+
): ReadableStream<Uint8Array> {
129124
let loaded = 0;
130-
const reader = response.body.getReader();
131125

132-
const stream = new ReadableStream<Uint8Array>({
126+
return new ReadableStream<Uint8Array>({
133127
async pull(controller): Promise<void> {
134128
const { done, value } = await reader.read();
135129

@@ -158,6 +152,37 @@ export function trackDownloadProgress(response: Response, onProgress: ProgressCa
158152
return reader.cancel(reason);
159153
},
160154
});
155+
}
156+
157+
/**
158+
* Wrap a Response's body stream to track download progress.
159+
*
160+
* Returns a new Response with the same status and headers but a body
161+
* stream that reports progress to the callback on each chunk.
162+
*
163+
* If the response has no body (e.g. 204 No Content), the callback is
164+
* invoked once with loaded=0 and the original response is returned.
165+
*
166+
* The progress stream supports cancellation — cancelling the returned
167+
* response's body will propagate to the original stream.
168+
*
169+
* @param response - The fetch Response to track
170+
* @param onProgress - Callback invoked on each chunk received
171+
* @returns A new Response with progress-tracked body
172+
*/
173+
export function trackDownloadProgress(response: Response, onProgress: ProgressCallback): Response {
174+
const total = parseContentLength(response.headers);
175+
176+
if (response.body === null) {
177+
onProgress({
178+
loaded: 0,
179+
total,
180+
percentage: calculatePercentage(0, total),
181+
});
182+
return response;
183+
}
184+
185+
const stream = createProgressStream(response.body.getReader(), total, onProgress);
161186

162187
return new Response(stream, {
163188
headers: response.headers,
@@ -166,20 +191,113 @@ export function trackDownloadProgress(response: Response, onProgress: ProgressCa
166191
});
167192
}
168193

194+
// =============================================================================
195+
// Upload Progress
196+
// =============================================================================
197+
198+
/**
199+
* Get the byte size of a request body, if deterministic.
200+
* Returns null for types where size cannot be determined without consuming the body
201+
* (FormData, ReadableStream).
202+
*/
203+
function getBodySize(body: BodyInit): number | null {
204+
if (body instanceof Blob) {
205+
return body.size;
206+
}
207+
if (body instanceof ArrayBuffer) {
208+
return body.byteLength;
209+
}
210+
if (ArrayBuffer.isView(body)) {
211+
return body.byteLength;
212+
}
213+
if (typeof body === 'string') {
214+
return new TextEncoder().encode(body).byteLength;
215+
}
216+
if (typeof URLSearchParams !== 'undefined' && body instanceof URLSearchParams) {
217+
return new TextEncoder().encode(body.toString()).byteLength;
218+
}
219+
// ReadableStream, FormData — size unknown
220+
return null;
221+
}
222+
223+
/**
224+
* Convert a BodyInit value to a ReadableStream.
225+
* Returns an empty stream for empty bodies (e.g. empty string, empty Blob).
226+
*/
227+
function bodyToStream(body: BodyInit): ReadableStream<Uint8Array> {
228+
if (body instanceof ReadableStream) {
229+
return body as ReadableStream<Uint8Array>;
230+
}
231+
const responseBody = new Response(body).body;
232+
if (responseBody === null) {
233+
return new ReadableStream<Uint8Array>({
234+
start(controller): void {
235+
controller.close();
236+
},
237+
});
238+
}
239+
return responseBody;
240+
}
241+
169242
/**
170-
* Create a RequestMiddleware that tracks download progress.
243+
* Wrap a request body to track upload progress.
171244
*
172-
* The middleware wraps the response body stream in the `onResponse` phase,
173-
* so progress is reported as the consumer reads the body (e.g. via
174-
* `response.json()`, `response.blob()`, or `response.body.getReader()`).
245+
* Returns a ReadableStream that reports progress to the callback as chunks
246+
* are read. The total size is determined from the body type when possible
247+
* (Blob, ArrayBuffer, string, URLSearchParams); for ReadableStream and
248+
* FormData bodies, total is null.
249+
*
250+
* The returned stream supports cancellation — cancelling it propagates
251+
* to the original body stream.
252+
*
253+
* @param body - The request body to track
254+
* @param onProgress - Callback invoked on each chunk sent
255+
* @returns A ReadableStream with progress-tracked body
256+
*/
257+
export function trackUploadProgress(
258+
body: BodyInit,
259+
onProgress: ProgressCallback
260+
): ReadableStream<Uint8Array> {
261+
const total = getBodySize(body);
262+
const source = bodyToStream(body);
263+
264+
return createProgressStream(source.getReader(), total, onProgress);
265+
}
266+
267+
// =============================================================================
268+
// Middleware
269+
// =============================================================================
270+
271+
/**
272+
* Create a RequestMiddleware that tracks upload and/or download progress.
273+
*
274+
* When `onUploadProgress` is provided, the middleware wraps the request body
275+
* in the `onRequest` phase. When `onDownloadProgress` is provided, it wraps
276+
* the response body in the `onResponse` phase. Both can be used simultaneously.
175277
*
176278
* @param options - Progress middleware options
177279
* @returns A RequestMiddleware instance
178280
*/
179281
export function createProgressMiddleware(options: ProgressMiddlewareOptions): RequestMiddleware {
180-
return {
181-
onResponse(response: InterceptedResponse): InterceptedResponse {
182-
const trackedResponse = trackDownloadProgress(response.response, options.onDownloadProgress);
282+
const result: {
283+
onRequest?: (config: MutableRequestConfig) => MutableRequestConfig;
284+
onResponse?: (response: InterceptedResponse) => InterceptedResponse;
285+
} = {};
286+
287+
if (options.onUploadProgress !== undefined) {
288+
const onProgress = options.onUploadProgress;
289+
result.onRequest = (config: MutableRequestConfig): MutableRequestConfig => {
290+
if (config.body != null) {
291+
return { ...config, body: trackUploadProgress(config.body, onProgress) };
292+
}
293+
return config;
294+
};
295+
}
296+
297+
if (options.onDownloadProgress !== undefined) {
298+
const onProgress = options.onDownloadProgress;
299+
result.onResponse = (response: InterceptedResponse): InterceptedResponse => {
300+
const trackedResponse = trackDownloadProgress(response.response, onProgress);
183301

184302
return {
185303
response: trackedResponse,
@@ -189,6 +307,8 @@ export function createProgressMiddleware(options: ProgressMiddlewareOptions): Re
189307
statusText: response.statusText,
190308
headers: response.headers,
191309
};
192-
},
193-
};
310+
};
311+
}
312+
313+
return result;
194314
}

src/request/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@ export type {
1414
RequestInterceptorInstance,
1515
} from './RequestInterceptor.js';
1616
export { combineAbortSignals, validateContentType } from './RequestValidation.js';
17-
export { trackDownloadProgress, createProgressMiddleware } from './StreamProgress.js';
17+
export {
18+
trackDownloadProgress,
19+
trackUploadProgress,
20+
createProgressMiddleware,
21+
} from './StreamProgress.js';
1822
export type {
1923
ProgressInfo,
2024
ProgressCallback,

0 commit comments

Comments
 (0)