diff --git a/source/core/Ky.ts b/source/core/Ky.ts index 39987afc..5e8c8f57 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,58 @@ export class Ky { let response = beforeRequestResponse ?? await ky.#retry(async () => ky.#fetch()); let responseFromHook = beforeRequestResponse !== undefined || ky.#consumeReturnedResponseFromBeforeRetryHook(); - if (!(response instanceof globalThis.Response)) { - 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; - } + // `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; + } - // 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 +220,10 @@ export class Ky { break; } + if (!isResponseInstance(response)) { + return response; + } + ky.#decorateResponse(response); // If `onDownloadProgress` is passed, it uses the stream API internally @@ -741,12 +764,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; } } @@ -757,9 +778,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 { @@ -767,12 +787,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; } @@ -780,22 +803,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) { @@ -865,13 +889,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..5059c096 100644 --- a/test/hooks.ts +++ b/test/hooks.ts @@ -18,6 +18,52 @@ 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 requestFixtureUrl = 'about:blank'; + const createStreamBody = (text: string) => new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode(text)); @@ -70,6 +116,42 @@ test('hooks can be async', async t => { t.false(responseJson.foo); }); +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(requestFixtureUrl, { + 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(requestFixtureUrl, { + 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(requestFixtureUrl, { + 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 +165,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(requestFixtureUrl, { + 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 +309,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(requestFixtureUrl, { + 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(requestFixtureUrl, { + 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(requestFixtureUrl, { + 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', {