diff --git a/src/request/StreamProgress.ts b/src/request/StreamProgress.ts index 24657aa..0f89c60 100644 --- a/src/request/StreamProgress.ts +++ b/src/request/StreamProgress.ts @@ -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) => { @@ -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({ @@ -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; } @@ -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; } // ============================================================================= @@ -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, + total: number | null, + onProgress: ProgressCallback +): ReadableStream { let loaded = 0; - const reader = response.body.getReader(); - const stream = new ReadableStream({ + return new ReadableStream({ async pull(controller): Promise { const { done, value } = await reader.read(); @@ -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, @@ -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 { + if (body instanceof ReadableStream) { + return body as ReadableStream; + } + const responseBody = new Response(body).body; + if (responseBody === null) { + return new ReadableStream({ + 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 { + 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, @@ -189,6 +307,8 @@ export function createProgressMiddleware(options: ProgressMiddlewareOptions): Re statusText: response.statusText, headers: response.headers, }; - }, - }; + }; + } + + return result; } diff --git a/src/request/index.ts b/src/request/index.ts index 580e8cd..7d3e710 100644 --- a/src/request/index.ts +++ b/src/request/index.ts @@ -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, diff --git a/tests/request/StreamProgress.test.ts b/tests/request/StreamProgress.test.ts index 2206923..1851287 100644 --- a/tests/request/StreamProgress.test.ts +++ b/tests/request/StreamProgress.test.ts @@ -4,9 +4,11 @@ import { describe, it, expect, vi } from 'vitest'; import { trackDownloadProgress, + trackUploadProgress, createProgressMiddleware, type ProgressInfo, type InterceptedResponse, + type MutableRequestConfig, } from '../../src/request/index.js'; /** @@ -280,8 +282,228 @@ describe('StreamProgress', () => { }); }); + describe('trackUploadProgress', () => { + /** + * Consume a ReadableStream and return all collected bytes. + */ + async function consumeStream(stream: ReadableStream): Promise { + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + } + const totalLength = chunks.reduce((sum, c) => sum + c.byteLength, 0); + const result = new Uint8Array(totalLength); + let offset = 0; + for (const chunk of chunks) { + result.set(chunk, offset); + offset += chunk.byteLength; + } + return result; + } + + it('should track progress for Blob body with known size', async () => { + const data = new Uint8Array([1, 2, 3, 4, 5]); + const blob = new Blob([data]); + const events: ProgressInfo[] = []; + + const stream = trackUploadProgress(blob, (progress) => { + events.push(progress); + }); + + await consumeStream(stream); + + expect(events.length).toBeGreaterThan(0); + const lastEvent = events[events.length - 1]!; + expect(lastEvent.loaded).toBe(5); + expect(lastEvent.total).toBe(5); + expect(lastEvent.percentage).toBe(100); + }); + + it('should track progress for string body with known size', async () => { + const body = 'Hello, World!'; + const expectedSize = new TextEncoder().encode(body).byteLength; + const events: ProgressInfo[] = []; + + const stream = trackUploadProgress(body, (progress) => { + events.push(progress); + }); + + await consumeStream(stream); + + const lastEvent = events[events.length - 1]!; + expect(lastEvent.loaded).toBe(expectedSize); + expect(lastEvent.total).toBe(expectedSize); + expect(lastEvent.percentage).toBe(100); + }); + + it('should track progress for ArrayBuffer body with known size', async () => { + const buffer = new ArrayBuffer(64); + const events: ProgressInfo[] = []; + + const stream = trackUploadProgress(buffer, (progress) => { + events.push(progress); + }); + + await consumeStream(stream); + + const lastEvent = events[events.length - 1]!; + expect(lastEvent.loaded).toBe(64); + expect(lastEvent.total).toBe(64); + expect(lastEvent.percentage).toBe(100); + }); + + it('should track progress for Uint8Array body with known size', async () => { + const data = new Uint8Array(128); + const events: ProgressInfo[] = []; + + const stream = trackUploadProgress(data, (progress) => { + events.push(progress); + }); + + await consumeStream(stream); + + const lastEvent = events[events.length - 1]!; + expect(lastEvent.loaded).toBe(128); + expect(lastEvent.total).toBe(128); + expect(lastEvent.percentage).toBe(100); + }); + + it('should track progress for URLSearchParams with known size', async () => { + const params = new URLSearchParams({ key: 'value', foo: 'bar' }); + const expectedSize = new TextEncoder().encode(params.toString()).byteLength; + const events: ProgressInfo[] = []; + + const stream = trackUploadProgress(params, (progress) => { + events.push(progress); + }); + + await consumeStream(stream); + + const lastEvent = events[events.length - 1]!; + expect(lastEvent.loaded).toBe(expectedSize); + expect(lastEvent.total).toBe(expectedSize); + expect(lastEvent.percentage).toBe(100); + }); + + it('should report null total for ReadableStream body', async () => { + const source = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([1, 2, 3])); + controller.close(); + }, + }); + const events: ProgressInfo[] = []; + + const stream = trackUploadProgress(source, (progress) => { + events.push(progress); + }); + + await consumeStream(stream); + + for (const event of events) { + expect(event.total).toBeNull(); + expect(event.percentage).toBeNull(); + } + expect(events[events.length - 1]!.loaded).toBe(3); + }); + + it('should handle empty Blob body', async () => { + const blob = new Blob([]); + const events: ProgressInfo[] = []; + + const stream = trackUploadProgress(blob, (progress) => { + events.push(progress); + }); + + await consumeStream(stream); + + expect(events.length).toBeGreaterThan(0); + const lastEvent = events[events.length - 1]!; + expect(lastEvent.loaded).toBe(0); + expect(lastEvent.total).toBe(0); + expect(lastEvent.percentage).toBe(100); + }); + + it('should handle empty string body', async () => { + const events: ProgressInfo[] = []; + + const stream = trackUploadProgress('', (progress) => { + events.push(progress); + }); + + await consumeStream(stream); + + const lastEvent = events[events.length - 1]!; + expect(lastEvent.loaded).toBe(0); + expect(lastEvent.total).toBe(0); + expect(lastEvent.percentage).toBe(100); + }); + + it('should propagate cancellation to the source stream', async () => { + const cancelFn = vi.fn(); + const source = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(10)); + }, + cancel: cancelFn, + }); + + const stream = trackUploadProgress(source, vi.fn()); + const reader = stream.getReader(); + + await reader.read(); + await reader.cancel('aborted'); + + expect(cancelFn).toHaveBeenCalledWith('aborted'); + }); + + it('should preserve data integrity through the stream', async () => { + const original = new TextEncoder().encode('Upload data integrity test'); + const blob = new Blob([original]); + + const stream = trackUploadProgress(blob, vi.fn()); + const result = await consumeStream(stream); + + expect(new TextDecoder().decode(result)).toBe('Upload data integrity test'); + }); + + it('should emit final progress event with 100% on stream end', async () => { + const data = new Uint8Array(50); + const blob = new Blob([data]); + const events: ProgressInfo[] = []; + + const stream = trackUploadProgress(blob, (progress) => { + events.push(progress); + }); + + await consumeStream(stream); + + const lastEvent = events[events.length - 1]!; + expect(lastEvent.percentage).toBe(100); + }); + + it('should handle multi-byte UTF-8 strings correctly', async () => { + const body = 'Hallo Welt! 🌍'; + const expectedSize = new TextEncoder().encode(body).byteLength; + const events: ProgressInfo[] = []; + + const stream = trackUploadProgress(body, (progress) => { + events.push(progress); + }); + + await consumeStream(stream); + + const lastEvent = events[events.length - 1]!; + expect(lastEvent.total).toBe(expectedSize); + expect(lastEvent.loaded).toBe(expectedSize); + }); + }); + describe('createProgressMiddleware', () => { - it('should return a middleware with onResponse handler', () => { + it('should return a middleware with only onResponse when download only', () => { const middleware = createProgressMiddleware({ onDownloadProgress: vi.fn(), }); @@ -291,6 +513,99 @@ describe('StreamProgress', () => { expect(middleware.onError).toBeUndefined(); }); + it('should return a middleware with only onRequest when upload only', () => { + const middleware = createProgressMiddleware({ + onUploadProgress: vi.fn(), + }); + + expect(middleware.onRequest).toBeTypeOf('function'); + expect(middleware.onResponse).toBeUndefined(); + expect(middleware.onError).toBeUndefined(); + }); + + it('should return a middleware with both handlers when both provided', () => { + const middleware = createProgressMiddleware({ + onDownloadProgress: vi.fn(), + onUploadProgress: vi.fn(), + }); + + expect(middleware.onRequest).toBeTypeOf('function'); + expect(middleware.onResponse).toBeTypeOf('function'); + expect(middleware.onError).toBeUndefined(); + }); + + it('should return empty middleware when no options provided', () => { + const middleware = createProgressMiddleware({}); + + expect(middleware.onRequest).toBeUndefined(); + expect(middleware.onResponse).toBeUndefined(); + }); + + it('should wrap request body with upload progress tracking', async () => { + const events: ProgressInfo[] = []; + const middleware = createProgressMiddleware({ + onUploadProgress: (progress) => events.push(progress), + }); + + const config: MutableRequestConfig = { + url: 'https://example.com/upload', + method: 'POST', + headers: new Headers(), + body: new Blob([new Uint8Array(100)]), + }; + + const result = middleware.onRequest!(config) as MutableRequestConfig; + + expect(result.body).not.toBe(config.body); + expect(result.body).toBeInstanceOf(ReadableStream); + expect(result.url).toBe('https://example.com/upload'); + expect(result.method).toBe('POST'); + + // Consume the stream to trigger progress + const reader = (result.body as ReadableStream).getReader(); + while (true) { + const { done } = await reader.read(); + if (done) break; + } + + expect(events.length).toBeGreaterThan(0); + expect(events[events.length - 1]!.percentage).toBe(100); + }); + + it('should not wrap null body in upload middleware', () => { + const middleware = createProgressMiddleware({ + onUploadProgress: vi.fn(), + }); + + const config: MutableRequestConfig = { + url: 'https://example.com/data', + method: 'GET', + headers: new Headers(), + body: null, + }; + + const result = middleware.onRequest!(config) as MutableRequestConfig; + + expect(result.body).toBeNull(); + expect(result).toEqual(config); + }); + + it('should not wrap undefined body in upload middleware', () => { + const middleware = createProgressMiddleware({ + onUploadProgress: vi.fn(), + }); + + const config: MutableRequestConfig = { + url: 'https://example.com/data', + method: 'GET', + headers: new Headers(), + }; + + const result = middleware.onRequest!(config) as MutableRequestConfig; + + expect(result.body).toBeUndefined(); + }); + it('should wrap response body with progress tracking', async () => { const events: ProgressInfo[] = []; const middleware = createProgressMiddleware({