From 10f2474fac26a2e489488699b5ac1c6089af051e Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Mon, 13 Apr 2026 01:02:15 +0700 Subject: [PATCH 1/3] Improve compatibility with custom `fetch` implementations Fixes #857 --- source/core/Ky.ts | 114 ++++++++++++++++++++++++--------------- test/hooks.ts | 133 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 203 insertions(+), 44 deletions(-) diff --git a/source/core/Ky.ts b/source/core/Ky.ts index e4b03f95..9ff23923 100644 --- a/source/core/Ky.ts +++ b/source/core/Ky.ts @@ -73,6 +73,17 @@ const cloneRetryOptions = (retry: RetryOptions | number): RetryOptions | number }; }; +const objectToString = Object.prototype.toString; + +const isRequestInstance = (value: unknown): value is Request => + value instanceof globalThis.Request || objectToString.call(value) === '[object Request]'; + +// Accepted custom responses are treated as full Responses throughout Ky. +// If a custom fetch returns one, it must behave like a Response for cloning, +// body consumption, `json()` decoration, and any enabled stream features. +const isResponseInstance = (value: unknown): value is Response => + value instanceof globalThis.Response || objectToString.call(value) === '[object Response]'; + // Shallow-clone mutable option properties so init hook mutations don't leak across requests. function cloneInitHookOptions(options: Options): Options { const clonedOptions: Options = { @@ -146,50 +157,61 @@ export class Ky { let response = beforeRequestResponse ?? await ky.#retry(async () => ky.#fetch()); let responseFromHook = beforeRequestResponse !== undefined || ky.#consumeReturnedResponseFromBeforeRetryHook(); - if (!(response instanceof globalThis.Response)) { + // `undefined` means a hook stopped the flow without providing a response. + // Non-native Responses still continue through Ky if they pass `isResponseInstance()`. + if (response === undefined) { return response; } for (;;) { - try { - // eslint-disable-next-line no-await-in-loop - response = await ky.#runAfterResponseHooks(response); - } catch (error) { - if (!(error instanceof ForceRetryError)) { - throw error; - } + if (response === undefined) { + return response; + } - // eslint-disable-next-line no-await-in-loop - const retriedResponse = await ky.#retryFromError(error, async () => ky.#fetch()); - if (!(retriedResponse instanceof globalThis.Response)) { - return retriedResponse; + if (isResponseInstance(response)) { + try { + // eslint-disable-next-line no-await-in-loop + response = await ky.#runAfterResponseHooks(response); + } catch (error) { + if (!(error instanceof ForceRetryError)) { + throw error; + } + + // eslint-disable-next-line no-await-in-loop + const retriedResponse: Response | void = await ky.#retryFromError(error, async () => ky.#fetch()); + if (retriedResponse === undefined) { + return retriedResponse; + } + + response = retriedResponse; + responseFromHook = ky.#consumeReturnedResponseFromBeforeRetryHook(); + continue; } - - response = retriedResponse; - responseFromHook = ky.#consumeReturnedResponseFromBeforeRetryHook(); - continue; } + const currentResponse: Response = response; + // Opaque responses (`response.type === 'opaque'`) from `no-cors` requests always have `status: 0` and `ok: false`, but this is not a failure - the actual status is hidden by the browser. - if (!response.ok && response.type !== 'opaque' && ( + if (!currentResponse.ok && currentResponse.type !== 'opaque' && ( typeof ky.#options.throwHttpErrors === 'function' - ? ky.#options.throwHttpErrors(response.status) + ? ky.#options.throwHttpErrors(currentResponse.status) : ky.#options.throwHttpErrors )) { // `request` must reflect the request that actually failed, but `options` stays as Ky's // normalized options snapshot. Replacement `Request` instances do not preserve the // original `BodyInit`, so trying to make `options` mirror arbitrary requests would be lossy. - const error = new HTTPError(response, ky.#getResponseRequest(response), ky.#getNormalizedOptions()); + const httpError: HTTPError = new HTTPError(currentResponse, ky.#getResponseRequest(currentResponse), ky.#getNormalizedOptions()); + const errorToThrow: Error = httpError; // eslint-disable-next-line no-await-in-loop - error.data = await ky.#getResponseData(response); + httpError.data = await ky.#getResponseData(currentResponse); if (responseFromHook) { - throw error; + throw errorToThrow; } // eslint-disable-next-line no-await-in-loop - const retriedResponse = await ky.#retryFromError(error, async () => ky.#fetch()); - if (!(retriedResponse instanceof globalThis.Response)) { + const retriedResponse: Response | void = await ky.#retryFromError(httpError, async () => ky.#fetch()); + if (retriedResponse === undefined) { return retriedResponse; } @@ -201,6 +223,10 @@ export class Ky { break; } + if (!isResponseInstance(response)) { + return response; + } + ky.#decorateResponse(response); // If `onDownloadProgress` is passed, it uses the stream API internally @@ -739,12 +765,10 @@ export class Ky { retryCount: 0, }); - if (result instanceof Response) { - return result; - } - - if (result instanceof globalThis.Request) { + if (isRequestInstance(result)) { this.#assignRequest(result); + } else if (isResponseInstance(result)) { + return result; } } @@ -755,9 +779,8 @@ export class Ky { const responseRequest = this.#getResponseRequest(response); for (const hook of this.#options.hooks.afterResponse) { - // Clone the response before passing to hook so we can cancel it if needed - const clonedResponse = this.#setResponseRequest(response.clone(), responseRequest); - this.#decorateResponse(clonedResponse); + const hookResponse = this.#setResponseRequest(response.clone(), responseRequest); + this.#decorateResponse(hookResponse); let modifiedResponse; try { @@ -765,12 +788,15 @@ export class Ky { modifiedResponse = await hook({ request: this.request, options: this.#getNormalizedOptions(), - response: clonedResponse, + response: hookResponse, retryCount: this.#retryCount, }); } catch (error) { // Cancel both responses to prevent memory leaks when hook throws - this.#cancelResponseBody(clonedResponse); + if (hookResponse !== response) { + this.#cancelResponseBody(hookResponse); + } + this.#cancelResponseBody(response); throw error; } @@ -778,22 +804,23 @@ export class Ky { if (modifiedResponse instanceof RetryMarker) { // Cancel both the cloned response passed to the hook and the current response to prevent resource leaks (especially important in Deno/Bun). // Do not await cancellation since hooks can clone the response, leaving extra tee branches that keep cancel promises pending per the Streams spec. - this.#cancelResponseBody(clonedResponse); + if (hookResponse !== response) { + this.#cancelResponseBody(hookResponse); + } + this.#cancelResponseBody(response); throw new ForceRetryError(modifiedResponse.options); } - // Determine which response to use going forward - const nextResponse = this.#setResponseRequest( - modifiedResponse instanceof globalThis.Response ? modifiedResponse : response, - responseRequest, - ); + const nextResponse = isResponseInstance(modifiedResponse) + ? this.#setResponseRequest(modifiedResponse, responseRequest) + : response; // Cancel any response bodies we won't use to prevent memory leaks. // Uses fire-and-forget since hooks may have cloned the response, creating tee branches that block cancellation. // If the hook wrapped an existing body into a new Response, both Response objects can still point at the same stream. - if (clonedResponse !== nextResponse && clonedResponse.body !== nextResponse.body) { - this.#cancelResponseBody(clonedResponse); + if (hookResponse !== response && hookResponse !== nextResponse && hookResponse.body !== nextResponse.body) { + this.#cancelResponseBody(hookResponse); } if (response !== nextResponse && response.body !== nextResponse.body) { @@ -863,13 +890,12 @@ export class Ky { throw hookError; } - if (hookResult instanceof globalThis.Request) { + if (isRequestInstance(hookResult)) { this.#assignRequest(hookResult); break; } - // If a Response is returned, use it and skip the retry - if (hookResult instanceof globalThis.Response) { + if (isResponseInstance(hookResult)) { this.#returnedResponseFromBeforeRetryHook = true; this.#retryCount++; return hookResult; diff --git a/test/hooks.ts b/test/hooks.ts index 7b159d9a..5a80334d 100644 --- a/test/hooks.ts +++ b/test/hooks.ts @@ -18,6 +18,50 @@ const withHeader = (request: Request, name: string, value: string) => { return new Request(request, {headers}); }; +const createRequestLike = (request: Request): any => ({ + get headers() { + return request.headers; + }, + get method() { + return request.method; + }, + get signal() { + return request.signal; + }, + get url() { + return request.url; + }, + [Symbol.toStringTag]: 'Request', + clone() { + return createRequestLike(request.clone()); + }, +}); + +const createResponseLike = (response: Response): any => ({ + get body() { + return response.body; + }, + get headers() { + return response.headers; + }, + get ok() { + return response.ok; + }, + get status() { + return response.status; + }, + get type() { + return response.type; + }, + [Symbol.toStringTag]: 'Response', + clone() { + return createResponseLike(response.clone()); + }, + async text() { + return response.text(); + }, +}); + const createStreamBody = (text: string) => new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode(text)); @@ -70,6 +114,41 @@ test('hooks can be async', async t => { t.false(responseJson.foo); }); +test('custom fetch can return Response-like object', async t => { + const responseText = await ky('https://example.com', { + fetch: async () => createResponseLike(new Response('ok')), + }).text(); + + t.is(responseText, 'ok'); +}); + +test('custom fetch can return Response-like object tagged as Response', async t => { + const responseText = await ky('https://example.com', { + fetch: async () => createResponseLike(new Response('ok')), + }).text(); + + t.is(Object.prototype.toString.call(createResponseLike(new Response('ok'))), '[object Response]'); + t.is(responseText, 'ok'); +}); + +test('awaited Response-like object uses parseJson for response.json()', async t => { + const response = await ky('https://example.com', { + fetch: async () => createResponseLike(new Response('{"foo":true}', { + headers: { + 'content-type': 'application/json', + }, + })), + parseJson(text) { + return { + ...JSON.parse(text), + foo: false, + }; + }, + }); + + t.deepEqual(await response.json(), {foo: false}); +}); + test('hooks can be empty object', async t => { const expectedResponse = 'empty hook'; const server = await createHttpTestServer(t); @@ -83,6 +162,19 @@ test('hooks can be empty object', async t => { t.is(response, expectedResponse); }); +test('beforeRequest hook accepts Request-like object tagged as Request', async t => { + const responseText = await ky('https://example.com', { + fetch: async request => new Response(request.headers.get('x-tagged-request')), + hooks: { + beforeRequest: [ + ({request}) => createRequestLike(withHeader(request, 'x-tagged-request', 'yes')), + ], + }, + }).text(); + + t.is(responseText, 'yes'); +}); + test('beforeRequest hook allows modifications', async t => { const server = await createHttpTestServer(t); server.post('/', async (request, response) => { @@ -214,6 +306,47 @@ test('afterResponse hook can return the provided response', async t => { t.true(originalResponse?.bodyUsed); }); +test('afterResponse hook ignores non-Response return values', async t => { + const responseText = await ky('https://example.com', { + fetch: async () => new Response('ok'), + hooks: { + afterResponse: [ + () => false as false | Response, + ], + }, + }).text(); + + t.is(responseText, 'ok'); +}); + +test('afterResponse hook runs for Response-like objects returned by custom fetch', async t => { + const responseText = await ky('https://example.com', { + fetch: async () => createResponseLike(new Response('ok')), + hooks: { + afterResponse: [ + () => new Response('replacement'), + ], + }, + }).text(); + + t.is(responseText, 'replacement'); +}); + +test('afterResponse hook can read Response-like objects without consuming final body', async t => { + const responseText = await ky('https://example.com', { + fetch: async () => createResponseLike(new Response('ok')), + hooks: { + afterResponse: [ + async ({response}) => { + t.is(await response.text(), 'ok'); + }, + ], + }, + }).text(); + + t.is(responseText, 'ok'); +}); + test('afterResponse hook can wrap the provided body in a new response', async t => { const responseText = await ky('https://example.com', { fetch: async () => new Response('ok', { From cd202b3e0bd5d32ccbfded0c8b1a66c228725841 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Tue, 14 Apr 2026 14:07:34 +0700 Subject: [PATCH 2/3] Tweaks --- source/core/Ky.ts | 7 ++----- test/hooks.ts | 1 + 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/source/core/Ky.ts b/source/core/Ky.ts index 9ff23923..145fa59d 100644 --- a/source/core/Ky.ts +++ b/source/core/Ky.ts @@ -157,13 +157,10 @@ export class Ky { let response = beforeRequestResponse ?? await ky.#retry(async () => ky.#fetch()); let responseFromHook = beforeRequestResponse !== undefined || ky.#consumeReturnedResponseFromBeforeRetryHook(); - // `undefined` means a hook stopped the flow without providing a response. - // Non-native Responses still continue through Ky if they pass `isResponseInstance()`. - if (response === undefined) { - return response; - } for (;;) { + // `undefined` means a hook stopped the flow without providing a response. + // Non-native Responses still continue through Ky if they pass `isResponseInstance()`. if (response === undefined) { return response; } diff --git a/test/hooks.ts b/test/hooks.ts index 5a80334d..23d28931 100644 --- a/test/hooks.ts +++ b/test/hooks.ts @@ -115,6 +115,7 @@ test('hooks can be async', async t => { }); test('custom fetch can return Response-like object', async t => { + // This URL is only a Request fixture. The custom `fetch` below handles the request, so no network request is made. const responseText = await ky('https://example.com', { fetch: async () => createResponseLike(new Response('ok')), }).text(); From 2b0fdf38e0818a4d72f4f44c85773ed9ff8cf863 Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Tue, 14 Apr 2026 19:07:57 +0700 Subject: [PATCH 3/3] Tweaks --- test/hooks.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/test/hooks.ts b/test/hooks.ts index 23d28931..5059c096 100644 --- a/test/hooks.ts +++ b/test/hooks.ts @@ -62,6 +62,8 @@ const createResponseLike = (response: Response): any => ({ }, }); +const requestFixtureUrl = 'about:blank'; + const createStreamBody = (text: string) => new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode(text)); @@ -116,7 +118,7 @@ test('hooks can be async', async t => { test('custom fetch can return Response-like object', async t => { // This URL is only a Request fixture. The custom `fetch` below handles the request, so no network request is made. - const responseText = await ky('https://example.com', { + const responseText = await ky(requestFixtureUrl, { fetch: async () => createResponseLike(new Response('ok')), }).text(); @@ -124,7 +126,7 @@ test('custom fetch can return Response-like object', async t => { }); test('custom fetch can return Response-like object tagged as Response', async t => { - const responseText = await ky('https://example.com', { + const responseText = await ky(requestFixtureUrl, { fetch: async () => createResponseLike(new Response('ok')), }).text(); @@ -133,7 +135,7 @@ test('custom fetch can return Response-like object tagged as Response', async t }); test('awaited Response-like object uses parseJson for response.json()', async t => { - const response = await ky('https://example.com', { + const response = await ky(requestFixtureUrl, { fetch: async () => createResponseLike(new Response('{"foo":true}', { headers: { 'content-type': 'application/json', @@ -164,7 +166,7 @@ test('hooks can be empty object', async t => { }); test('beforeRequest hook accepts Request-like object tagged as Request', async t => { - const responseText = await ky('https://example.com', { + const responseText = await ky(requestFixtureUrl, { fetch: async request => new Response(request.headers.get('x-tagged-request')), hooks: { beforeRequest: [ @@ -308,7 +310,7 @@ test('afterResponse hook can return the provided response', async t => { }); test('afterResponse hook ignores non-Response return values', async t => { - const responseText = await ky('https://example.com', { + const responseText = await ky(requestFixtureUrl, { fetch: async () => new Response('ok'), hooks: { afterResponse: [ @@ -321,7 +323,7 @@ test('afterResponse hook ignores non-Response return values', async t => { }); test('afterResponse hook runs for Response-like objects returned by custom fetch', async t => { - const responseText = await ky('https://example.com', { + const responseText = await ky(requestFixtureUrl, { fetch: async () => createResponseLike(new Response('ok')), hooks: { afterResponse: [ @@ -334,7 +336,7 @@ test('afterResponse hook runs for Response-like objects returned by custom fetch }); test('afterResponse hook can read Response-like objects without consuming final body', async t => { - const responseText = await ky('https://example.com', { + const responseText = await ky(requestFixtureUrl, { fetch: async () => createResponseLike(new Response('ok')), hooks: { afterResponse: [