Skip to content

Commit aec65db

Browse files
authored
Fix beforeRequest hooks being skipped when a Request is returned (#832)
1 parent 6f3d1bd commit aec65db

5 files changed

Lines changed: 524 additions & 11 deletions

File tree

readme.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -351,7 +351,7 @@ This hook enables you to modify the request right before it is sent. Ky will mak
351351

352352
The `retryCount` is `0` for the initial request and increments with each retry. This allows you to distinguish between initial requests and retries, which is useful when you need different behavior for retries (e.g., avoiding overwriting headers set in `beforeRetry`).
353353

354-
The hook can return a [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) to replace the outgoing request, or return a [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) to completely avoid making an HTTP request. This can be used to mock a request, check an internal cache, etc. An **important** consideration when returning a request or response from this hook is that any remaining `beforeRequest` hooks will be skipped, so you may want to only return them from the last hook.
354+
The hook can return a [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) to replace the outgoing request (remaining hooks will still run with the updated request). It can also return a [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) to completely avoid making an HTTP request, in which case remaining `beforeRequest` hooks are skipped. This can be used to mock a request, check an internal cache, etc.
355355

356356
```js
357357
import ky from 'ky';

source/core/Ky.ts

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -336,8 +336,6 @@ export class Ky {
336336
if (!supportsRequestStreams) {
337337
throw new Error('Request streams are not supported in your environment. The `duplex` option for `Request` is not available.');
338338
}
339-
340-
this.request = this.#wrapRequestWithUploadProgress(this.request, this.#options.body ?? undefined);
341339
}
342340
}
343341

@@ -685,30 +683,31 @@ export class Ky {
685683

686684
if (result instanceof globalThis.Request) {
687685
this.#assignRequest(result);
688-
break;
689686
}
690687
}
691688

692689
const nonRequestOptions = findUnknownOptions(this.request, this.#options);
690+
const retryRequest = this.#options.retry.limit > 0 ? this.request.clone() : undefined;
691+
const request = this.#wrapRequestWithUploadProgress(this.request, this.#options.body ?? undefined);
693692

694693
// Cloning is done here to prepare in advance for retries.
695694
// Skip cloning when retries are disabled — cloning a streaming body calls ReadableStream#tee()
696695
// which buffers the entire stream in memory, causing excessive memory usage for large uploads.
697-
this.#originalRequest = this.request;
698-
if (this.#options.retry.limit > 0) {
699-
this.request = this.#originalRequest.clone();
696+
this.#originalRequest = request;
697+
if (retryRequest) {
698+
this.request = retryRequest;
700699
}
701700

702701
if (this.#options.timeout === false) {
703-
return this.#options.fetch(this.#originalRequest, nonRequestOptions);
702+
return this.#options.fetch(request, nonRequestOptions);
704703
}
705704

706705
const remainingTimeout = this.#getRemainingTimeout() ?? this.#options.timeout;
707706
if (remainingTimeout <= 0) {
708707
throw new TimeoutError(this.request);
709708
}
710709

711-
return timeout(this.#originalRequest, nonRequestOptions, this.#abortController, {
710+
return timeout(request, nonRequestOptions, this.#abortController, {
712711
...this.#options,
713712
timeout: remainingTimeout,
714713
} as TimeoutOptions);
@@ -754,7 +753,7 @@ export class Ky {
754753

755754
#assignRequest(request: Request): void {
756755
this.#cachedNormalizedOptions = undefined;
757-
this.request = this.#wrapRequestWithUploadProgress(request);
756+
this.request = request;
758757
}
759758

760759
#wrapRequestWithUploadProgress(request: Request, originalBody?: BodyInit): Request {

source/types/hooks.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ export type Hooks = {
6565
6666
The `retryCount` is `0` for the initial request and increments with each retry. This allows you to distinguish between initial requests and retries, which is useful when you need different behavior for retries (e.g., avoiding overwriting headers set in `beforeRetry`).
6767
68-
A [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) can be returned from this hook to completely avoid making an HTTP request. This can be used to mock a request, check an internal cache, etc. An **important** consideration when returning a `Response` from this hook is that all the following hooks will be skipped, so **ensure you only return a `Response` from the last hook**.
68+
A [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) can be returned from this hook to replace the outgoing request; remaining hooks will still run with the updated request. A [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) can be returned to completely avoid making an HTTP request, in which case remaining `beforeRequest` hooks are skipped. This can be used to mock a request, check an internal cache, etc.
6969
7070
@example
7171
```

test/hooks.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ import ky, {
1111
import {type Options} from '../source/types/options.js';
1212
import {createHttpTestServer} from './helpers/create-http-test-server.js';
1313

14+
const withHeader = (request: Request, name: string, value: string) => {
15+
const headers = new Headers(request.headers);
16+
headers.set(name, value);
17+
return new Request(request, {headers});
18+
};
19+
1420
const createStreamBody = (text: string) => new ReadableStream<Uint8Array>({
1521
start(controller) {
1622
controller.enqueue(new TextEncoder().encode(text));
@@ -1143,6 +1149,76 @@ test('beforeError runs when beforeRetry rethrows network errors', async t => {
11431149
t.true(thrownError instanceof TypeError);
11441150
});
11451151

1152+
test('hooks beforeRequest returning Request continues running remaining hooks', async t => {
1153+
let capturedRequest: Request | undefined;
1154+
1155+
await ky.get('https://example.com', {
1156+
async fetch(request) {
1157+
capturedRequest = request as Request;
1158+
return new Response('ok');
1159+
},
1160+
hooks: {
1161+
beforeRequest: [
1162+
({request}) => withHeader(request, 'x-hook-1', 'hook-1'),
1163+
({request}) => {
1164+
// Verify hook 2 receives the updated request produced by hook 1
1165+
t.is(request.headers.get('x-hook-1'), 'hook-1');
1166+
return withHeader(request, 'x-hook-2', 'hook-2');
1167+
},
1168+
],
1169+
},
1170+
});
1171+
1172+
t.truthy(capturedRequest);
1173+
t.is(capturedRequest!.headers.get('x-hook-1'), 'hook-1');
1174+
t.is(capturedRequest!.headers.get('x-hook-2'), 'hook-2');
1175+
});
1176+
1177+
test('hooks beforeRequest returning Request then non-returning hook both run', async t => {
1178+
let capturedRequest: Request | undefined;
1179+
let hook2Ran = false;
1180+
1181+
await ky.get('https://example.com', {
1182+
async fetch(request) {
1183+
capturedRequest = request as Request;
1184+
return new Response('ok');
1185+
},
1186+
hooks: {
1187+
beforeRequest: [
1188+
({request}) => withHeader(request, 'x-hook-1', 'hook-1'),
1189+
() => {
1190+
hook2Ran = true;
1191+
},
1192+
],
1193+
},
1194+
});
1195+
1196+
t.truthy(capturedRequest);
1197+
t.true(hook2Ran);
1198+
t.is(capturedRequest!.headers.get('x-hook-1'), 'hook-1');
1199+
});
1200+
1201+
test('hooks beforeRequest returning Request then Response skips HTTP request', async t => {
1202+
const expectedResponse = 'intercepted';
1203+
let fetchCalled = false;
1204+
1205+
const response = await ky.get('https://example.com', {
1206+
async fetch() {
1207+
fetchCalled = true;
1208+
return new Response('should not reach');
1209+
},
1210+
hooks: {
1211+
beforeRequest: [
1212+
({request}) => withHeader(request, 'x-hook-1', 'hook-1'),
1213+
() => new Response(expectedResponse, {status: 200}),
1214+
],
1215+
},
1216+
}).text();
1217+
1218+
t.false(fetchCalled);
1219+
t.is(response, expectedResponse);
1220+
});
1221+
11461222
test('hooks beforeRequest returning Response skips HTTP Request', async t => {
11471223
const expectedResponse = 'empty hook';
11481224

0 commit comments

Comments
 (0)