Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
218 changes: 169 additions & 49 deletions src/request/StreamProgress.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
/**
* Stream Progress - Download progress tracking for fetch responses.
* Stream Progress - Upload and download progress tracking for fetch requests.
*
* Wraps a Response body ReadableStream to report download progress via callback.
* Wraps request/response body ReadableStreams to report progress via callback.
* Works standalone or as RequestInterceptor middleware.
*
* @example Standalone usage
* @example Download progress (standalone)
* ```TypeScript
* const response = await fetch('https://example.com/large-file.zip');
* const tracked = trackDownloadProgress(response, (progress) => {
Expand All @@ -16,6 +16,15 @@
* const blob = await tracked.blob();
* ```
*
* @example Upload progress (standalone)
* ```TypeScript
* const body = new Blob([largeData]);
* const trackedBody = trackUploadProgress(body, (progress) => {
* console.log(`Uploaded: ${progress.percentage ?? '?'}%`);
* });
* await fetch('https://example.com/upload', { method: 'POST', body: trackedBody });
* ```
*
* @example With RequestInterceptor middleware
* ```TypeScript
* const api = RequestInterceptor.create({
Expand All @@ -26,27 +35,31 @@
* onDownloadProgress: (progress) => {
* console.log(`Downloaded: ${progress.percentage ?? '?'}%`);
* },
* onUploadProgress: (progress) => {
* console.log(`Uploaded: ${progress.percentage ?? '?'}%`);
* },
* }));
*
* const response = await api.get('/files/large.zip');
* await response.blob();
* ```
*/
import type { InterceptedResponse, RequestMiddleware } from './RequestInterceptor.js';
import type {
InterceptedResponse,
MutableRequestConfig,
RequestMiddleware,
} from './RequestInterceptor.js';

// =============================================================================
// Types
// =============================================================================

/**
* Progress information reported during download.
* Progress information reported during upload or download.
*/
export interface ProgressInfo {
/** Bytes received so far */
/** Bytes transferred so far */
readonly loaded: number;
/** Total bytes expected (from Content-Length), or null if unknown */
/** Total bytes expected, or null if unknown */
readonly total: number | null;
/** Download percentage (0-100), or null if total is unknown */
/** Transfer percentage (0-100), or null if total is unknown */
readonly percentage: number | null;
}

Expand All @@ -60,7 +73,9 @@ export type ProgressCallback = (progress: ProgressInfo) => void;
*/
export interface ProgressMiddlewareOptions {
/** Callback invoked on each downloaded chunk */
readonly onDownloadProgress: ProgressCallback;
readonly onDownloadProgress?: ProgressCallback;
/** Callback invoked on each uploaded chunk */
readonly onUploadProgress?: ProgressCallback;
}

// =============================================================================
Expand Down Expand Up @@ -99,37 +114,16 @@ function calculatePercentage(loaded: number, total: number | null): number | nul
}

/**
* Wrap a Response's body stream to track download progress.
*
* Returns a new Response with the same status and headers but a body
* stream that reports progress to the callback on each chunk.
*
* If the response has no body (e.g. 204 No Content), the callback is
* invoked once with loaded=0 and the original response is returned.
*
* The progress stream supports cancellation — cancelling the returned
* response's body will propagate to the original stream.
*
* @param response - The fetch Response to track
* @param onProgress - Callback invoked on each chunk received
* @returns A new Response with progress-tracked body
* Create a ReadableStream that wraps a source reader and reports progress.
*/
export function trackDownloadProgress(response: Response, onProgress: ProgressCallback): Response {
const total = parseContentLength(response.headers);

if (response.body === null) {
onProgress({
loaded: 0,
total,
percentage: calculatePercentage(0, total),
});
return response;
}

function createProgressStream(
reader: ReadableStreamDefaultReader<Uint8Array>,
total: number | null,
onProgress: ProgressCallback
): ReadableStream<Uint8Array> {
let loaded = 0;
const reader = response.body.getReader();

const stream = new ReadableStream<Uint8Array>({
return new ReadableStream<Uint8Array>({
async pull(controller): Promise<void> {
const { done, value } = await reader.read();

Expand Down Expand Up @@ -158,6 +152,37 @@ export function trackDownloadProgress(response: Response, onProgress: ProgressCa
return reader.cancel(reason);
},
});
}

/**
* Wrap a Response's body stream to track download progress.
*
* Returns a new Response with the same status and headers but a body
* stream that reports progress to the callback on each chunk.
*
* If the response has no body (e.g. 204 No Content), the callback is
* invoked once with loaded=0 and the original response is returned.
*
* The progress stream supports cancellation — cancelling the returned
* response's body will propagate to the original stream.
*
* @param response - The fetch Response to track
* @param onProgress - Callback invoked on each chunk received
* @returns A new Response with progress-tracked body
*/
export function trackDownloadProgress(response: Response, onProgress: ProgressCallback): Response {
const total = parseContentLength(response.headers);

if (response.body === null) {
onProgress({
loaded: 0,
total,
percentage: calculatePercentage(0, total),
});
return response;
}

const stream = createProgressStream(response.body.getReader(), total, onProgress);

return new Response(stream, {
headers: response.headers,
Expand All @@ -166,20 +191,113 @@ export function trackDownloadProgress(response: Response, onProgress: ProgressCa
});
}

// =============================================================================
// Upload Progress
// =============================================================================

/**
* Get the byte size of a request body, if deterministic.
* Returns null for types where size cannot be determined without consuming the body
* (FormData, ReadableStream).
*/
function getBodySize(body: BodyInit): number | null {
if (body instanceof Blob) {
return body.size;
}
if (body instanceof ArrayBuffer) {
return body.byteLength;
}
if (ArrayBuffer.isView(body)) {
return body.byteLength;
}
if (typeof body === 'string') {
return new TextEncoder().encode(body).byteLength;
}
if (typeof URLSearchParams !== 'undefined' && body instanceof URLSearchParams) {
return new TextEncoder().encode(body.toString()).byteLength;
}
// ReadableStream, FormData — size unknown
return null;
}

/**
* Convert a BodyInit value to a ReadableStream.
* Returns an empty stream for empty bodies (e.g. empty string, empty Blob).
*/
function bodyToStream(body: BodyInit): ReadableStream<Uint8Array> {
if (body instanceof ReadableStream) {
return body as ReadableStream<Uint8Array>;
}
const responseBody = new Response(body).body;
if (responseBody === null) {
return new ReadableStream<Uint8Array>({
start(controller): void {
controller.close();
},
});
}
return responseBody;
}

/**
* Create a RequestMiddleware that tracks download progress.
* Wrap a request body to track upload progress.
*
* The middleware wraps the response body stream in the `onResponse` phase,
* so progress is reported as the consumer reads the body (e.g. via
* `response.json()`, `response.blob()`, or `response.body.getReader()`).
* Returns a ReadableStream that reports progress to the callback as chunks
* are read. The total size is determined from the body type when possible
* (Blob, ArrayBuffer, string, URLSearchParams); for ReadableStream and
* FormData bodies, total is null.
*
* The returned stream supports cancellation — cancelling it propagates
* to the original body stream.
*
* @param body - The request body to track
* @param onProgress - Callback invoked on each chunk sent
* @returns A ReadableStream with progress-tracked body
*/
export function trackUploadProgress(
body: BodyInit,
onProgress: ProgressCallback
): ReadableStream<Uint8Array> {
const total = getBodySize(body);
const source = bodyToStream(body);

return createProgressStream(source.getReader(), total, onProgress);
}

// =============================================================================
// Middleware
// =============================================================================

/**
* Create a RequestMiddleware that tracks upload and/or download progress.
*
* When `onUploadProgress` is provided, the middleware wraps the request body
* in the `onRequest` phase. When `onDownloadProgress` is provided, it wraps
* the response body in the `onResponse` phase. Both can be used simultaneously.
*
* @param options - Progress middleware options
* @returns A RequestMiddleware instance
*/
export function createProgressMiddleware(options: ProgressMiddlewareOptions): RequestMiddleware {
return {
onResponse(response: InterceptedResponse): InterceptedResponse {
const trackedResponse = trackDownloadProgress(response.response, options.onDownloadProgress);
const result: {
onRequest?: (config: MutableRequestConfig) => MutableRequestConfig;
onResponse?: (response: InterceptedResponse) => InterceptedResponse;
} = {};

if (options.onUploadProgress !== undefined) {
const onProgress = options.onUploadProgress;
result.onRequest = (config: MutableRequestConfig): MutableRequestConfig => {
if (config.body != null) {
return { ...config, body: trackUploadProgress(config.body, onProgress) };
}
return config;
};
}

if (options.onDownloadProgress !== undefined) {
const onProgress = options.onDownloadProgress;
result.onResponse = (response: InterceptedResponse): InterceptedResponse => {
const trackedResponse = trackDownloadProgress(response.response, onProgress);

return {
response: trackedResponse,
Expand All @@ -189,6 +307,8 @@ export function createProgressMiddleware(options: ProgressMiddlewareOptions): Re
statusText: response.statusText,
headers: response.headers,
};
},
};
};
}

return result;
}
6 changes: 5 additions & 1 deletion src/request/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@ export type {
RequestInterceptorInstance,
} from './RequestInterceptor.js';
export { combineAbortSignals, validateContentType } from './RequestValidation.js';
export { trackDownloadProgress, createProgressMiddleware } from './StreamProgress.js';
export {
trackDownloadProgress,
trackUploadProgress,
createProgressMiddleware,
} from './StreamProgress.js';
export type {
ProgressInfo,
ProgressCallback,
Expand Down
Loading
Loading