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
115 changes: 69 additions & 46 deletions source/core/Ky.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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;
}

Expand All @@ -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
Expand Down Expand Up @@ -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;
}
}

Expand All @@ -757,45 +778,48 @@ 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 {
// eslint-disable-next-line no-await-in-loop
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;
}

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) {
Expand Down Expand Up @@ -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;
Expand Down
136 changes: 136 additions & 0 deletions test/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(text));
Expand Down Expand Up @@ -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);
Expand All @@ -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) => {
Expand Down Expand Up @@ -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', {
Expand Down