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) => {
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({
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 */
4457export 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 */
6174export 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 */
179281export 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}
0 commit comments