From 2879f644ecf65f34ecf007775ade9013d0336241 Mon Sep 17 00:00:00 2001 From: Jade Devin Cabatlao Date: Sat, 7 Sep 2024 00:20:28 +0200 Subject: [PATCH 01/14] Add onUploadProgress option --- source/core/Ky.ts | 77 +++++++++++++++++++++++++++++++++++++++++ source/types/options.ts | 30 ++++++++++++++++ 2 files changed, 107 insertions(+) diff --git a/source/core/Ky.ts b/source/core/Ky.ts index d69c03ef..a0965b62 100644 --- a/source/core/Ky.ts +++ b/source/core/Ky.ts @@ -203,6 +203,24 @@ export class Ky { // The spread of `this.request` is required as otherwise it misses the `duplex` option for some reason and throws. this.request = new globalThis.Request(new globalThis.Request(url, {...this.request}), this._options as RequestInit); } + + // Add onUploadProgress handling + if (this._options.onUploadProgress && typeof this._options.onUploadProgress === 'function') { + if (!supportsRequestStreams) { + throw new Error('Streams are not supported in your environment. `ReadableStream` is missing.'); + } + + const originalBody = this.request.body; + if (originalBody) { + const totalBytes = this._getTotalBytes(originalBody); + this.request = new Request(this.request, { + body: this._wrapBodyWithUploadProgress(originalBody, totalBytes, this._options.onUploadProgress), + headers: this.request.headers, + method: this.request.method, + signal: this.request.signal, + }); + } + } } protected _calculateRetryDelay(error: unknown) { @@ -365,4 +383,63 @@ export class Ky { }, ); } + + protected _getTotalBytes(body: BodyInit): number { + if (body instanceof Blob) { + return body.size; + } + if (body instanceof ArrayBuffer) { + return body.byteLength; + } + if (typeof body === 'string') { + return new Blob([body]).size; + } + if (body instanceof URLSearchParams) { + return new Blob([body.toString()]).size; + } + if (body instanceof globalThis.FormData) { + // This is an approximation, as FormData size calculation is not straightforward + return Array.from(body.entries()).reduce((acc, [_, value]) => { + if (typeof value === 'string') { + return acc + new Blob([value]).size; + } + if (value instanceof Blob) { + return acc + value.size; + } + return acc; + }, 0); + } + return 0; // Default case, unable to determine size + } + + protected _wrapBodyWithUploadProgress( + body: BodyInit, + totalBytes: number, + onUploadProgress: (progress: { percent: number; transferredBytes: number; totalBytes: number }) => void + ): globalThis.ReadableStream { + let transferredBytes = 0; + + return new globalThis.ReadableStream({ + async start(controller) { + const reader = body instanceof globalThis.ReadableStream ? body.getReader() : new Blob([body]).stream().getReader(); + + async function read() { + const { done, value } = await reader.read(); + if (done) { + controller.close(); + return; + } + + transferredBytes += value.byteLength; + const percent = totalBytes === 0 ? 0 : transferredBytes / totalBytes; + onUploadProgress({ percent, transferredBytes, totalBytes }); + + controller.enqueue(value); + await read(); + } + + await read(); + }, + }); + } } diff --git a/source/types/options.ts b/source/types/options.ts index 1142617a..739ff6c2 100644 --- a/source/types/options.ts +++ b/source/types/options.ts @@ -12,6 +12,16 @@ export type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'head' | 'delete'; export type Input = string | URL | Request; +export type UploadProgress = { + percent: number; + transferredBytes: number; + + /** + Note: If it's not possible to retrieve the body size, it will be `0`. + */ + totalBytes: number; +}; + export type DownloadProgress = { percent: number; transferredBytes: number; @@ -188,6 +198,25 @@ export type KyOptions = { */ onDownloadProgress?: (progress: DownloadProgress, chunk: Uint8Array) => void; + /** + Upload progress event handler. + + @param progress - Object containing upload progress information. + + @example + ``` + import ky from 'ky'; + + const response = await ky.post('https://example.com/upload', { + body: new FormData(), + onUploadProgress: (progress) => { + console.log(`${progress.percent * 100}% - ${progress.transferredBytes} of ${progress.totalBytes} bytes`); + } + }); + ``` + */ + onUploadProgress?: (progress: UploadProgress) => void; + /** User-defined `fetch` function. Has to be fully compatible with the [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) standard. @@ -287,6 +316,7 @@ export interface NormalizedOptions extends RequestInit { // eslint-disable-line retry: RetryOptions; prefixUrl: string; onDownloadProgress: Options['onDownloadProgress']; + onUploadProgress: Options['onUploadProgress']; } export type {RetryOptions} from './retry.js'; From ca5bc8a4f10be29985c5123b493563853045f565 Mon Sep 17 00:00:00 2001 From: Jade Devin Cabatlao Date: Sat, 7 Sep 2024 00:24:00 +0200 Subject: [PATCH 02/14] Add onUploadProgress to registry --- source/core/constants.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/source/core/constants.ts b/source/core/constants.ts index 86055489..5428f5ae 100644 --- a/source/core/constants.ts +++ b/source/core/constants.ts @@ -67,6 +67,7 @@ export const kyOptionKeys: KyOptionsRegistry = { hooks: true, throwHttpErrors: true, onDownloadProgress: true, + onUploadProgress: true, fetch: true, }; From 80f0b4efb1949cb44733e04bd47dddff85f57b22 Mon Sep 17 00:00:00 2001 From: Jade Devin Cabatlao Date: Sat, 7 Sep 2024 00:33:05 +0200 Subject: [PATCH 03/14] Update to use globalThis.Response to getReader instead of Blob --- source/core/Ky.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/source/core/Ky.ts b/source/core/Ky.ts index a0965b62..23f8f0df 100644 --- a/source/core/Ky.ts +++ b/source/core/Ky.ts @@ -384,24 +384,24 @@ export class Ky { ); } - protected _getTotalBytes(body: BodyInit): number { - if (body instanceof Blob) { + protected _getTotalBytes(body: globalThis.BodyInit): number { + if (body instanceof globalThis.Blob) { return body.size; } - if (body instanceof ArrayBuffer) { + if (body instanceof globalThis.ArrayBuffer) { return body.byteLength; } if (typeof body === 'string') { - return new Blob([body]).size; + return new globalThis.TextEncoder().encode(body).length; } if (body instanceof URLSearchParams) { - return new Blob([body.toString()]).size; + return new globalThis.TextEncoder().encode(body.toString()).length; } if (body instanceof globalThis.FormData) { // This is an approximation, as FormData size calculation is not straightforward return Array.from(body.entries()).reduce((acc, [_, value]) => { if (typeof value === 'string') { - return acc + new Blob([value]).size; + return acc + new globalThis.TextEncoder().encode(value).length; } if (value instanceof Blob) { return acc + value.size; @@ -409,6 +409,9 @@ export class Ky { return acc; }, 0); } + if ('byteLength' in body) { + return (body as globalThis.ArrayBufferView).byteLength; + } return 0; // Default case, unable to determine size } @@ -421,7 +424,7 @@ export class Ky { return new globalThis.ReadableStream({ async start(controller) { - const reader = body instanceof globalThis.ReadableStream ? body.getReader() : new Blob([body]).stream().getReader(); + const reader = body instanceof globalThis.ReadableStream ? body.getReader() : new globalThis.Response(body).body!.getReader(); async function read() { const { done, value } = await reader.read(); From 7ba8edc68cd7b4609b3891223628ce8f0ac3f580 Mon Sep 17 00:00:00 2001 From: Jade Cabatlao Date: Sat, 7 Sep 2024 18:32:23 +0800 Subject: [PATCH 04/14] Update onUploadProgress to report done progress --- source/core/Ky.ts | 55 +++++++++++++++++++++++++++++++---------------- 1 file changed, 36 insertions(+), 19 deletions(-) diff --git a/source/core/Ky.ts b/source/core/Ky.ts index 23f8f0df..f098d912 100644 --- a/source/core/Ky.ts +++ b/source/core/Ky.ts @@ -213,12 +213,12 @@ export class Ky { const originalBody = this.request.body; if (originalBody) { const totalBytes = this._getTotalBytes(originalBody); - this.request = new Request(this.request, { - body: this._wrapBodyWithUploadProgress(originalBody, totalBytes, this._options.onUploadProgress), - headers: this.request.headers, - method: this.request.method, - signal: this.request.signal, - }); + this.request + = new globalThis.Request(this._input, { + ...this._options, + body: this._wrapBodyWithUploadProgress( + originalBody, totalBytes, this._options.onUploadProgress), + }); } } } @@ -384,41 +384,56 @@ export class Ky { ); } - protected _getTotalBytes(body: globalThis.BodyInit): number { + protected _getTotalBytes(body?: globalThis.BodyInit): number { + if (!body) { + return 0; + } + if (body instanceof globalThis.Blob) { return body.size; } + if (body instanceof globalThis.ArrayBuffer) { return body.byteLength; } + if (typeof body === 'string') { return new globalThis.TextEncoder().encode(body).length; } + if (body instanceof URLSearchParams) { return new globalThis.TextEncoder().encode(body.toString()).length; } + if (body instanceof globalThis.FormData) { // This is an approximation, as FormData size calculation is not straightforward - return Array.from(body.entries()).reduce((acc, [_, value]) => { + let size = 0; + // eslint-disable-next-line unicorn/no-array-for-each -- FormData uses forEach method + body.forEach((value: globalThis.FormDataEntryValue, key: string) => { if (typeof value === 'string') { - return acc + new globalThis.TextEncoder().encode(value).length; + size += new globalThis.TextEncoder().encode(value).length; + } else if (value instanceof globalThis.Blob) { + size += value.size; } - if (value instanceof Blob) { - return acc + value.size; - } - return acc; - }, 0); + + // Add some bytes for field name and multipart boundaries + size += new TextEncoder().encode(key).length + 40; // 40 is an approximation for multipart overhead + }); + + return size; } + if ('byteLength' in body) { - return (body as globalThis.ArrayBufferView).byteLength; + return (body).byteLength; } + return 0; // Default case, unable to determine size } protected _wrapBodyWithUploadProgress( body: BodyInit, totalBytes: number, - onUploadProgress: (progress: { percent: number; transferredBytes: number; totalBytes: number }) => void + onUploadProgress: (progress: {percent: number; transferredBytes: number; totalBytes: number}) => void, ): globalThis.ReadableStream { let transferredBytes = 0; @@ -427,15 +442,17 @@ export class Ky { const reader = body instanceof globalThis.ReadableStream ? body.getReader() : new globalThis.Response(body).body!.getReader(); async function read() { - const { done, value } = await reader.read(); + const {done, value} = await reader.read(); if (done) { + // Ensure 100% progress is reported when the upload is complete + onUploadProgress({percent: 1, transferredBytes, totalBytes: Math.max(totalBytes, transferredBytes)}); controller.close(); return; } - transferredBytes += value.byteLength; + transferredBytes += value.byteLength as number; const percent = totalBytes === 0 ? 0 : transferredBytes / totalBytes; - onUploadProgress({ percent, transferredBytes, totalBytes }); + onUploadProgress({percent, transferredBytes, totalBytes}); controller.enqueue(value); await read(); From e612cc48ffece82f7298d290393512000e7b5e47 Mon Sep 17 00:00:00 2001 From: Jade Cabatlao Date: Sat, 7 Sep 2024 18:59:26 +0800 Subject: [PATCH 05/14] Reuse DownloadProgress to Progress --- source/core/Ky.ts | 8 ++++---- source/index.ts | 2 +- source/types/options.ts | 16 +++------------- 3 files changed, 8 insertions(+), 18 deletions(-) diff --git a/source/core/Ky.ts b/source/core/Ky.ts index f098d912..52403e59 100644 --- a/source/core/Ky.ts +++ b/source/core/Ky.ts @@ -74,7 +74,7 @@ export class Ky { throw new Error('Streams are not supported in your environment. `ReadableStream` is missing.'); } - return ky._stream(response.clone(), ky._options.onDownloadProgress); + return ky._streamResponse(response.clone(), ky._options.onDownloadProgress); } return response; @@ -216,7 +216,7 @@ export class Ky { this.request = new globalThis.Request(this._input, { ...this._options, - body: this._wrapBodyWithUploadProgress( + body: this._streamRequest( originalBody, totalBytes, this._options.onUploadProgress), }); } @@ -328,7 +328,7 @@ export class Ky { } /* istanbul ignore next */ - protected _stream(response: Response, onDownloadProgress: Options['onDownloadProgress']) { + protected _streamResponse(response: Response, onDownloadProgress: Options['onDownloadProgress']) { const totalBytes = Number(response.headers.get('content-length')) || 0; let transferredBytes = 0; @@ -430,7 +430,7 @@ export class Ky { return 0; // Default case, unable to determine size } - protected _wrapBodyWithUploadProgress( + protected _streamRequest( body: BodyInit, totalBytes: number, onUploadProgress: (progress: {percent: number; transferredBytes: number; totalBytes: number}) => void, diff --git a/source/index.ts b/source/index.ts index 600b4479..3d727bbc 100644 --- a/source/index.ts +++ b/source/index.ts @@ -42,7 +42,7 @@ export type { NormalizedOptions, RetryOptions, SearchParamsOption, - DownloadProgress, + Progress, } from './types/options.js'; export type { diff --git a/source/types/options.ts b/source/types/options.ts index 739ff6c2..0e1defc1 100644 --- a/source/types/options.ts +++ b/source/types/options.ts @@ -12,17 +12,7 @@ export type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'head' | 'delete'; export type Input = string | URL | Request; -export type UploadProgress = { - percent: number; - transferredBytes: number; - - /** - Note: If it's not possible to retrieve the body size, it will be `0`. - */ - totalBytes: number; -}; - -export type DownloadProgress = { +export type Progress = { percent: number; transferredBytes: number; @@ -196,7 +186,7 @@ export type KyOptions = { }); ``` */ - onDownloadProgress?: (progress: DownloadProgress, chunk: Uint8Array) => void; + onDownloadProgress?: (progress: Progress, chunk: Uint8Array) => void; /** Upload progress event handler. @@ -215,7 +205,7 @@ export type KyOptions = { }); ``` */ - onUploadProgress?: (progress: UploadProgress) => void; + onUploadProgress?: (progress: Progress) => void; /** User-defined `fetch` function. From 682d34ed79695d72b17bd4dfffc1614cff9ca20a Mon Sep 17 00:00:00 2001 From: Jade Cabatlao Date: Sat, 7 Sep 2024 19:38:53 +0800 Subject: [PATCH 06/14] update progress type in test --- test/browser.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/browser.ts b/test/browser.ts index fb67bf50..490faaf4 100644 --- a/test/browser.ts +++ b/test/browser.ts @@ -3,7 +3,7 @@ import busboy from 'busboy'; import express from 'express'; import {chromium, webkit, type Page} from 'playwright'; import type ky from '../source/index.js'; // eslint-disable-line import/no-duplicates -import type {DownloadProgress} from '../source/index.js'; // eslint-disable-line import/no-duplicates +import type {Progress} from '../source/index.js'; // eslint-disable-line import/no-duplicates import {createHttpTestServer, type ExtendedHttpTestServer, type HttpServerOptions} from './helpers/create-http-test-server.js'; import {parseRawBody} from './helpers/parse-body.js'; import {browserTest, defaultBrowsersTest} from './helpers/with-page.js'; @@ -265,7 +265,7 @@ browserTest('onDownloadProgress works', [chromium, webkit], async (t: ExecutionC await addKyScriptToPage(page); const result = await page.evaluate(async (url: string) => { - const data: Array> = []; + const data: Array> = []; const text = await window .ky(url, { onDownloadProgress(progress, chunk) { From 3b1a25f9cd62601cfe8b61654a1b70d8a0580b3f Mon Sep 17 00:00:00 2001 From: Jade Cabatlao Date: Sat, 7 Sep 2024 19:50:42 +0800 Subject: [PATCH 07/14] add doc for onUploadProgress --- readme.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/readme.md b/readme.md index 79bf0945..91523d72 100644 --- a/readme.md +++ b/readme.md @@ -421,6 +421,37 @@ const response = await ky('https://example.com', { }); ``` +##### onUploadProgress + +Type: `Function` + +Upload progress event handler. + +The function receives a `progress` object containing the following properties: +- `percent`: A number between 0 and 1 representing the progress percentage. +- `transferredBytes`: The number of bytes transferred so far. +- `totalBytes`: The total number of bytes to be transferred. This is an estimate and may be 0 if the total size cannot be determined. + +```js +import ky from 'ky'; + +const response = await ky.post('https://example.com/api/upload', { + body: largeFile, + onUploadProgress: (progress) => { + console.log(`${progress.percent * 100}% - ${progress.transferredBytes} of ${progress.totalBytes} bytes`); + } +}); +``` + +Notes: +- This option requires environment support for `ReadableStream`. If streams are not supported, an error will be thrown. +- The `body` of the request must be set for this option to have an effect. +- The total size calculation is an approximation for certain types of bodies (e.g., `FormData`). +- For `FormData` bodies, the size calculation includes an estimation for multipart boundaries and field names. +- If the total size cannot be determined, `totalBytes` will be 0, and `percent` will remain at 0 throughout the upload and will be 1 once upload is finished. + +This feature is useful for tracking the progress of large file uploads or when sending substantial amounts of data to a server. + ##### parseJson Type: `Function`\ From 8590ec2f9b2ed55a9378ec39399e7f7262936a6c Mon Sep 17 00:00:00 2001 From: Jade Cabatlao Date: Sat, 7 Sep 2024 20:53:01 +0800 Subject: [PATCH 08/14] add tests for upload progress --- source/core/Ky.ts | 59 ++++++----- test/helpers/create-large-file.ts | 7 ++ test/helpers/index.ts | 1 + test/upload-progress.ts | 157 ++++++++++++++++++++++++++++++ 4 files changed, 202 insertions(+), 22 deletions(-) create mode 100644 test/helpers/create-large-file.ts create mode 100644 test/upload-progress.ts diff --git a/source/core/Ky.ts b/source/core/Ky.ts index 52403e59..2f7de5a9 100644 --- a/source/core/Ky.ts +++ b/source/core/Ky.ts @@ -212,7 +212,7 @@ export class Ky { const originalBody = this.request.body; if (originalBody) { - const totalBytes = this._getTotalBytes(originalBody); + const totalBytes = this._getTotalBytes(this._options.body); this.request = new globalThis.Request(this._input, { ...this._options, @@ -384,27 +384,11 @@ export class Ky { ); } - protected _getTotalBytes(body?: globalThis.BodyInit): number { + protected _getTotalBytes(body?: globalThis.BodyInit | undefined): number { if (!body) { return 0; } - if (body instanceof globalThis.Blob) { - return body.size; - } - - if (body instanceof globalThis.ArrayBuffer) { - return body.byteLength; - } - - if (typeof body === 'string') { - return new globalThis.TextEncoder().encode(body).length; - } - - if (body instanceof URLSearchParams) { - return new globalThis.TextEncoder().encode(body.toString()).length; - } - if (body instanceof globalThis.FormData) { // This is an approximation, as FormData size calculation is not straightforward let size = 0; @@ -412,8 +396,9 @@ export class Ky { body.forEach((value: globalThis.FormDataEntryValue, key: string) => { if (typeof value === 'string') { size += new globalThis.TextEncoder().encode(value).length; - } else if (value instanceof globalThis.Blob) { - size += value.size; + } else if (typeof value === 'object' && value !== null && 'size' in value) { + // This catches File objects as well, as File extends Blob + size += (value as Blob).size; } // Add some bytes for field name and multipart boundaries @@ -423,10 +408,36 @@ export class Ky { return size; } + if (body instanceof globalThis.Blob) { + return body.size; + } + + if (body instanceof globalThis.ArrayBuffer) { + return body.byteLength; + } + + if (typeof body === 'string') { + return new globalThis.TextEncoder().encode(body).length; + } + + if (body instanceof URLSearchParams) { + return new globalThis.TextEncoder().encode(body.toString()).length; + } + if ('byteLength' in body) { return (body).byteLength; } + if (typeof body === 'object' && body !== null) { + try { + const jsonString = JSON.stringify(body); + return new TextEncoder().encode(jsonString).length; + } catch (error) { + console.warn('Unable to stringify object:', error); + return 0; + } + } + return 0; // Default case, unable to determine size } @@ -451,8 +462,12 @@ export class Ky { } transferredBytes += value.byteLength as number; - const percent = totalBytes === 0 ? 0 : transferredBytes / totalBytes; - onUploadProgress({percent, transferredBytes, totalBytes}); + let percent = totalBytes === 0 ? 0 : transferredBytes / totalBytes; + if (totalBytes < transferredBytes || percent === 1) { + percent = 0.99; + } + + onUploadProgress({percent: Number(percent.toFixed(2)), transferredBytes, totalBytes}); controller.enqueue(value); await read(); diff --git a/test/helpers/create-large-file.ts b/test/helpers/create-large-file.ts new file mode 100644 index 00000000..e639a842 --- /dev/null +++ b/test/helpers/create-large-file.ts @@ -0,0 +1,7 @@ +// Helper function to create a large Blob +export function createLargeBlob(sizeInMB: number): Blob { + const chunkSize = 1024 * 1024; // 1MB + // eslint-disable-next-line unicorn/no-new-array + const chunks = new Array(sizeInMB).fill('x'.repeat(chunkSize)); + return new Blob(chunks, {type: 'application/octet-stream'}); +} diff --git a/test/helpers/index.ts b/test/helpers/index.ts index 8ed0b5f5..982b8936 100644 --- a/test/helpers/index.ts +++ b/test/helpers/index.ts @@ -1,3 +1,4 @@ export * from './create-http-test-server.js'; export * from './parse-body.js'; export * from './with-page.js'; +export * from './create-large-file.js'; diff --git a/test/upload-progress.ts b/test/upload-progress.ts new file mode 100644 index 00000000..334fae7a --- /dev/null +++ b/test/upload-progress.ts @@ -0,0 +1,157 @@ +import test from 'ava'; +import ky, {type Progress} from '../source/index.js'; +import {createLargeBlob} from './helpers/create-large-file.js'; +import {createHttpTestServer} from './helpers/create-http-test-server.js'; +import {parseRawBody} from './helpers/parse-body.js'; + +test('POST JSON with upload progress', async t => { + const server = await createHttpTestServer({bodyParser: false}); + server.post('/', async (request, response) => { + response.json(await parseRawBody(request)); + }); + + const json = {test: 'test'}; + const data: Progress[] = []; + const responseJson = await ky + .post(server.url, { + json, + onUploadProgress(progress) { + data.push(progress); + }, + }) + .json(); + + // Check if we have at least two progress updates + t.true(data.length >= 2, 'Should have at least two progress updates'); + + // Check the first progress update + t.true( + data[0].percent >= 0 && data[0].percent < 1, + 'First update should have progress between 0 and 100%', + ); + t.true( + data[0].transferredBytes >= 0, + 'First update should have non-negative transferred bytes', + ); + + // Check intermediate updates (if any) + for (let i = 1; i < data.length - 1; i++) { + t.true( + data[i].percent >= data[i - 1].percent, + `Update ${i} should have higher or equal percent than previous`, + ); + t.true( + data[i].transferredBytes >= data[i - 1].transferredBytes, + `Update ${i} should have more or equal transferred bytes than previous`, + ); + } + + // Check the last progress update + const lastUpdate = data.at(-1); + t.is(lastUpdate.percent, 1, 'Last update should have 100% progress'); + t.true( + lastUpdate.totalBytes > 0, + 'Last update should have positive total bytes', + ); + t.is( + lastUpdate.transferredBytes, + lastUpdate.totalBytes, + 'Last update should have transferred all bytes', + ); + + await server.close(); +}); + +test('POST FormData with 10MB file upload progress', async t => { + const server = await createHttpTestServer({bodyParser: false}); + server.post('/', async (request, response) => { + let totalBytes = 0; + for await (const chunk of request) { + totalBytes += chunk.length as number; + } + + response.json({receivedBytes: totalBytes}); + }); + + const largeBlob = createLargeBlob(10); // 10MB Blob + const formData = new FormData(); + formData.append('file', largeBlob, 'large-file.bin'); + + if (formData instanceof globalThis.FormData) { + // This is an approximation, as FormData size calculation is not straightforward + let size = 0; + // eslint-disable-next-line unicorn/no-array-for-each -- FormData uses forEach method + formData.forEach((value: globalThis.FormDataEntryValue, key: string) => { + if (typeof value === 'string') { + size += new globalThis.TextEncoder().encode(value).length; + t.log(size, 'size is string'); + } else if ( + typeof value === 'object' + && value !== null + && 'size' in value + ) { + // This catches File objects as well, as File extends Blob + size += (value as Blob).size; + t.log(size, 'size is file or blob'); + } + + // Add some bytes for field name and multipart boundaries + size += new TextEncoder().encode(key).length + 40; // 40 is an approximation for multipart overhead + }); + // Return size + } + + const data: Array<{ + percent: number; + transferredBytes: number; + totalBytes: number; + }> = []; + const response = await ky + .post(server.url, { + body: formData, + onUploadProgress(progress) { + data.push(progress); + }, + }) + .json<{receivedBytes: number}>(); + + // Check if we have at least two progress updates + t.true(data.length >= 2, 'Should have at least two progress updates'); + + // Check the first progress update + t.true( + data[0].percent >= 0 && data[0].percent < 1, + 'First update should have progress between 0 and 100%', + ); + t.true( + data[0].transferredBytes >= 0, + 'First update should have non-negative transferred bytes', + ); + + // Check intermediate updates (if any) + for (let i = 1; i < data.length - 1; i++) { + t.true( + data[i].percent >= data[i - 1].percent, + `Update ${i} should have higher or equal percent than previous`, + ); + t.true( + data[i].transferredBytes >= data[i - 1].transferredBytes, + `Update ${i} should have more or equal transferred bytes than previous`, + ); + } + + // Check the last progress update + const lastUpdate = data.at(-1); + t.is(lastUpdate.percent, 1, 'Last update should have 100% progress'); + t.true( + lastUpdate.totalBytes > 0, + 'Last update should have positive total bytes', + ); + t.is( + lastUpdate.transferredBytes, + lastUpdate.totalBytes, + 'Last update should have transferred all bytes', + ); + + await server.close(); +}); From d214b681a7d54e00b600df6b77577a6eb977c3ae Mon Sep 17 00:00:00 2001 From: Jade Cabatlao Date: Sat, 7 Sep 2024 20:54:49 +0800 Subject: [PATCH 09/14] fix type error for getTotalBytes --- source/core/Ky.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/core/Ky.ts b/source/core/Ky.ts index 2f7de5a9..628529b2 100644 --- a/source/core/Ky.ts +++ b/source/core/Ky.ts @@ -384,7 +384,7 @@ export class Ky { ); } - protected _getTotalBytes(body?: globalThis.BodyInit | undefined): number { + protected _getTotalBytes(body?: globalThis.BodyInit | null): number { if (!body) { return 0; } From d18f532be2ebfec1aabc16f4c6a47c05a0be530b Mon Sep 17 00:00:00 2001 From: Jade Cabatlao Date: Sat, 7 Sep 2024 20:57:10 +0800 Subject: [PATCH 10/14] fix type error for getTotalBytes --- source/core/Ky.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/source/core/Ky.ts b/source/core/Ky.ts index 628529b2..66761525 100644 --- a/source/core/Ky.ts +++ b/source/core/Ky.ts @@ -384,6 +384,7 @@ export class Ky { ); } + // eslint-disable-next-line @typescript-eslint/ban-types protected _getTotalBytes(body?: globalThis.BodyInit | null): number { if (!body) { return 0; From dcfc11530527dfa716f09d59a5f885eac5a13960 Mon Sep 17 00:00:00 2001 From: Jade Cabatlao Date: Sat, 7 Sep 2024 21:10:34 +0800 Subject: [PATCH 11/14] remove log for test in uploadprogress --- test/upload-progress.ts | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/test/upload-progress.ts b/test/upload-progress.ts index 334fae7a..668e1f6a 100644 --- a/test/upload-progress.ts +++ b/test/upload-progress.ts @@ -77,30 +77,6 @@ test('POST FormData with 10MB file upload progress', async t => { const formData = new FormData(); formData.append('file', largeBlob, 'large-file.bin'); - if (formData instanceof globalThis.FormData) { - // This is an approximation, as FormData size calculation is not straightforward - let size = 0; - // eslint-disable-next-line unicorn/no-array-for-each -- FormData uses forEach method - formData.forEach((value: globalThis.FormDataEntryValue, key: string) => { - if (typeof value === 'string') { - size += new globalThis.TextEncoder().encode(value).length; - t.log(size, 'size is string'); - } else if ( - typeof value === 'object' - && value !== null - && 'size' in value - ) { - // This catches File objects as well, as File extends Blob - size += (value as Blob).size; - t.log(size, 'size is file or blob'); - } - - // Add some bytes for field name and multipart boundaries - size += new TextEncoder().encode(key).length + 40; // 40 is an approximation for multipart overhead - }); - // Return size - } - const data: Array<{ percent: number; transferredBytes: number; From d6283a250052fa93f9b4ba0c8da16b5e133d554f Mon Sep 17 00:00:00 2001 From: Jade Cabatlao Date: Sun, 8 Sep 2024 01:46:04 +0800 Subject: [PATCH 12/14] Change error message for request stream onUploadProgress stream support --- source/core/Ky.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/core/Ky.ts b/source/core/Ky.ts index 66761525..a4f30b13 100644 --- a/source/core/Ky.ts +++ b/source/core/Ky.ts @@ -207,7 +207,7 @@ export class Ky { // Add onUploadProgress handling if (this._options.onUploadProgress && typeof this._options.onUploadProgress === 'function') { if (!supportsRequestStreams) { - throw new Error('Streams are not supported in your environment. `ReadableStream` is missing.'); + throw new Error('Request streams are not supported in your environment. The `duplex` option for `Request` is not available.'); } const originalBody = this.request.body; From 0b2ed43dc5ae81f308be966421db107aab6a8577 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Sat, 14 Sep 2024 15:07:42 +0700 Subject: [PATCH 13/14] Update options.ts --- source/types/options.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/types/options.ts b/source/types/options.ts index 0e1defc1..7d0e15a8 100644 --- a/source/types/options.ts +++ b/source/types/options.ts @@ -199,7 +199,7 @@ export type KyOptions = { const response = await ky.post('https://example.com/upload', { body: new FormData(), - onUploadProgress: (progress) => { + onUploadProgress: progress => { console.log(`${progress.percent * 100}% - ${progress.transferredBytes} of ${progress.totalBytes} bytes`); } }); From 7f8aec36a0bd2a2e7812dac11ba300a4e5bb9f25 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Sat, 14 Sep 2024 15:08:43 +0700 Subject: [PATCH 14/14] Update readme.md --- readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/readme.md b/readme.md index 91523d72..40e32997 100644 --- a/readme.md +++ b/readme.md @@ -437,7 +437,7 @@ import ky from 'ky'; const response = await ky.post('https://example.com/api/upload', { body: largeFile, - onUploadProgress: (progress) => { + onUploadProgress: progress => { console.log(`${progress.percent * 100}% - ${progress.transferredBytes} of ${progress.totalBytes} bytes`); } });