Skip to content

Commit 1c3a041

Browse files
committed
Add beforeCache hook
Fixes #746
1 parent f004564 commit 1c3a041

File tree

5 files changed

+585
-41
lines changed

5 files changed

+585
-41
lines changed

documentation/9-hooks.md

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,77 @@ await got('https://httpbin.org/status/500', {
234234
});
235235
```
236236

237+
#### `beforeCache`
238+
239+
**Type: `BeforeCacheHook[]`**\
240+
**Default: `[]`**
241+
242+
```ts
243+
(response: PlainResponse) => false | void
244+
```
245+
246+
Called right before the response is cached. Allows you to control caching behavior by modifying response properties or preventing caching entirely.
247+
248+
This is especially useful when you want to prevent caching of specific responses or modify cache headers.
249+
250+
**Return value:**
251+
> - `false` - Prevent caching (remaining hooks are skipped)
252+
> - `void`/`undefined` - Use default caching behavior (mutations take effect)
253+
254+
**Modifying the response:**
255+
> - Hooks can directly mutate response properties like `headers`, `statusCode`, and `statusMessage`
256+
> - Mutations to `response.headers` affect how the caching layer decides whether to cache the response and for how long
257+
> - Changes are applied to what gets cached, not to the response the user receives (they are separate objects)
258+
259+
**Note:**
260+
> - This hook is only called when the `cache` option is enabled.
261+
262+
**Note:**
263+
> - This hook must be synchronous. It cannot return a Promise. If you need async logic to determine caching behavior, use a `beforeRequest` hook instead.
264+
265+
**Note:**
266+
> - When returning `false`, remaining hooks are skipped. The response headers the user receives are NOT modified - only the caching layer sees modified headers.
267+
268+
**Note:**
269+
> - If a hook throws an error, it will be propagated and the request will fail. This is consistent with how other hooks in Got handle errors.
270+
271+
**Note:**
272+
> - At this stage, the response body has not been read yet - it's still a stream. Properties like `response.body` and `response.rawBody` are not available. You can only inspect/modify response headers and status code.
273+
274+
```js
275+
import got from 'got';
276+
277+
// Simple: Don't cache errors
278+
const instance = got.extend({
279+
cache: new Map(),
280+
hooks: {
281+
beforeCache: [
282+
(response) => response.statusCode >= 400 ? false : undefined
283+
]
284+
}
285+
});
286+
287+
await instance('https://example.com');
288+
```
289+
290+
```js
291+
import got from 'got';
292+
293+
// Advanced: Modify headers for fine control
294+
const instance2 = got.extend({
295+
cache: new Map(),
296+
hooks: {
297+
beforeCache: [
298+
(response) => {
299+
// Force caching with explicit duration
300+
// Mutations work directly - no need to return
301+
response.headers['cache-control'] = 'public, max-age=3600';
302+
}
303+
]
304+
}
305+
});
306+
```
307+
237308
#### `afterResponse`
238309

239310
**Type: `AfterResponseHook[]`**\

source/as-promise/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ export default function asPromise<T>(firstRequest?: Request): CancelableRequest<
5757
request.once('response', async (response: Response) => {
5858
// Parse body
5959
const contentEncoding = (response.headers['content-encoding'] ?? '').toLowerCase();
60-
const isCompressed = contentEncoding === 'gzip' || contentEncoding === 'deflate' || contentEncoding === 'br';
60+
const isCompressed = contentEncoding === 'gzip' || contentEncoding === 'deflate' || contentEncoding === 'br' || contentEncoding === 'zstd';
6161

6262
const {options} = request;
6363

source/core/index.ts

Lines changed: 114 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -455,7 +455,7 @@ export default class Request extends Duplex implements RequestEvents<Request> {
455455
await hook(typedError, this.retryCount + 1);
456456
}
457457
} catch (error_: any) {
458-
void this._error(new RequestError(error_.message, error, this));
458+
void this._error(new RequestError(error_.message, error_, this));
459459
return;
460460
}
461461

@@ -1207,49 +1207,121 @@ export default class Request extends Duplex implements RequestEvents<Request> {
12071207
}
12081208

12091209
private _prepareCache(cache: string | StorageAdapter) {
1210-
if (!cacheableStore.has(cache)) {
1211-
const cacheableRequest = new CacheableRequest(
1212-
((requestOptions: RequestOptions, handler?: (response: IncomingMessageWithTimings) => void): ClientRequest => {
1213-
const result = (requestOptions as any)._request(requestOptions, handler);
1214-
1215-
// TODO: remove this when `cacheable-request` supports async request functions.
1216-
if (is.promise(result)) {
1217-
// We only need to implement the error handler in order to support HTTP2 caching.
1218-
// The result will be a promise anyway.
1219-
// @ts-expect-error ignore
1220-
result.once = (event: string, handler: (reason: unknown) => void) => {
1221-
if (event === 'error') {
1222-
(async () => {
1223-
try {
1224-
await result;
1225-
} catch (error) {
1226-
handler(error);
1227-
}
1228-
})();
1229-
} else if (event === 'abort' || event === 'destroy') {
1230-
// The empty catch is needed here in case when
1231-
// it rejects before it's `await`ed in `_makeRequest`.
1232-
(async () => {
1233-
try {
1234-
const request = (await result) as ClientRequest;
1235-
request.once(event, handler);
1236-
} catch {}
1237-
})();
1238-
} else {
1239-
/* istanbul ignore next: safety check */
1240-
throw new Error(`Unknown HTTP2 promise event: ${event}`);
1210+
if (cacheableStore.has(cache)) {
1211+
return;
1212+
}
1213+
1214+
const cacheableRequest = new CacheableRequest(
1215+
((requestOptions: RequestOptions, handler?: (response: IncomingMessageWithTimings) => void): ClientRequest => {
1216+
/**
1217+
Wraps the cacheable-request handler to run beforeCache hooks.
1218+
These hooks control caching behavior by:
1219+
- Directly mutating the response object (changes apply to what gets cached)
1220+
- Returning `false` to prevent caching
1221+
- Returning `void`/`undefined` to use default caching behavior
1222+
1223+
Hooks use direct mutation - they can modify response.headers, response.statusCode, etc.
1224+
Mutations take effect immediately and determine what gets cached.
1225+
*/
1226+
const wrappedHandler = handler ? (response: IncomingMessageWithTimings) => {
1227+
const {beforeCacheHooks, gotRequest} = requestOptions as any;
1228+
1229+
// Early return if no hooks - cache the original response
1230+
if (!beforeCacheHooks || beforeCacheHooks.length === 0) {
1231+
handler(response);
1232+
return;
1233+
}
1234+
1235+
try {
1236+
// Call each beforeCache hook with the response
1237+
// Hooks can directly mutate the response - mutations take effect immediately
1238+
for (const hook of beforeCacheHooks) {
1239+
const result = hook(response);
1240+
1241+
if (result === false) {
1242+
// Prevent caching by adding no-cache headers
1243+
// Mutate the response directly to add headers
1244+
response.headers['cache-control'] = 'no-cache, no-store, must-revalidate';
1245+
response.headers.pragma = 'no-cache';
1246+
response.headers.expires = '0';
1247+
handler(response);
1248+
// Don't call remaining hooks - we've decided not to cache
1249+
return;
12411250
}
12421251

1243-
return result;
1244-
};
1252+
if (is.promise(result)) {
1253+
// BeforeCache hooks must be synchronous because cacheable-request's handler is synchronous
1254+
throw new TypeError('beforeCache hooks must be synchronous. The hook returned a Promise, but this hook must return synchronously. If you need async logic, use beforeRequest hook instead.');
1255+
}
1256+
1257+
if (result !== undefined) {
1258+
// Hooks should return false or undefined only
1259+
// Mutations work directly - no need to return the response
1260+
throw new TypeError('beforeCache hook must return false or undefined. To modify the response, mutate it directly.');
1261+
}
1262+
// Else: void/undefined = continue
1263+
}
1264+
} catch (error: any) {
1265+
// Convert hook errors to RequestError and propagate
1266+
// This is consistent with how other hooks handle errors
1267+
if (gotRequest) {
1268+
gotRequest._beforeError(error instanceof RequestError ? error : new RequestError(error.message, error, gotRequest));
1269+
// Don't call handler when error was propagated successfully
1270+
return;
1271+
}
1272+
1273+
// If gotRequest is missing, log the error to aid debugging
1274+
// We still call the handler to prevent the request from hanging
1275+
console.error('Got: beforeCache hook error (request context unavailable):', error);
1276+
// Call handler with response (potentially partially modified)
1277+
handler(response);
1278+
return;
12451279
}
12461280

1247-
return result;
1248-
}) as typeof http.request,
1249-
cache as StorageAdapter,
1250-
);
1251-
cacheableStore.set(cache, cacheableRequest.request());
1252-
}
1281+
// All hooks ran successfully
1282+
// Cache the response with any mutations applied
1283+
handler(response);
1284+
} : handler;
1285+
1286+
const result = (requestOptions as any)._request(requestOptions, wrappedHandler);
1287+
1288+
// TODO: remove this when `cacheable-request` supports async request functions.
1289+
if (is.promise(result)) {
1290+
// We only need to implement the error handler in order to support HTTP2 caching.
1291+
// The result will be a promise anyway.
1292+
// @ts-expect-error ignore
1293+
result.once = (event: string, handler: (reason: unknown) => void) => {
1294+
if (event === 'error') {
1295+
(async () => {
1296+
try {
1297+
await result;
1298+
} catch (error) {
1299+
handler(error);
1300+
}
1301+
})();
1302+
} else if (event === 'abort' || event === 'destroy') {
1303+
// The empty catch is needed here in case when
1304+
// it rejects before it's `await`ed in `_makeRequest`.
1305+
(async () => {
1306+
try {
1307+
const request = (await result) as ClientRequest;
1308+
request.once(event, handler);
1309+
} catch {}
1310+
})();
1311+
} else {
1312+
/* istanbul ignore next: safety check */
1313+
throw new Error(`Unknown HTTP2 promise event: ${event}`);
1314+
}
1315+
1316+
return result;
1317+
};
1318+
}
1319+
1320+
return result;
1321+
}) as typeof http.request,
1322+
cache as StorageAdapter,
1323+
);
1324+
cacheableStore.set(cache, cacheableRequest.request());
12531325
}
12541326

12551327
private async _createCacheableRequest(url: URL, options: RequestOptions): Promise<ClientRequest | ResponseLike> {
@@ -1356,6 +1428,8 @@ export default class Request extends Duplex implements RequestEvents<Request> {
13561428
(this._requestOptions as any)._request = request;
13571429
(this._requestOptions as any).cache = options.cache;
13581430
(this._requestOptions as any).body = options.body;
1431+
(this._requestOptions as any).beforeCacheHooks = options.hooks.beforeCache;
1432+
(this._requestOptions as any).gotRequest = this;
13591433

13601434
try {
13611435
this._prepareCache(options.cache as StorageAdapter);

source/core/options.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ export type BeforeRequestHook = (options: NormalizedOptions, context: BeforeRequ
105105
export type BeforeRedirectHook = (updatedOptions: NormalizedOptions, plainResponse: PlainResponse) => Promisable<void>;
106106
export type BeforeErrorHook = (error: RequestError) => Promisable<RequestError>;
107107
export type BeforeRetryHook = (error: RequestError, retryCount: number) => Promisable<void>;
108+
export type BeforeCacheHook = (response: PlainResponse) => false | void;
108109
export type AfterResponseHook<ResponseType = unknown> = (response: Response<ResponseType>, retryWithMergedOptions: (options: OptionsInit) => never) => Promisable<Response | CancelableRequest<Response>>;
109110

110111
/**
@@ -356,6 +357,73 @@ export type Hooks = {
356357
*/
357358
beforeRetry: BeforeRetryHook[];
358359

360+
/**
361+
Called right before the response is cached. Allows you to control caching behavior by directly modifying the response or preventing caching.
362+
363+
This is especially useful when you want to prevent caching of specific responses or modify cache headers.
364+
365+
@default []
366+
367+
**Return value:**
368+
> - `false` - Prevent caching (remaining hooks are skipped)
369+
> - `void`/`undefined` - Use default caching behavior (mutations take effect)
370+
371+
**Modifying the response:**
372+
> - Hooks can directly mutate response properties like `headers`, `statusCode`, and `statusMessage`
373+
> - Mutations to `response.headers` affect how the caching layer decides whether to cache the response and for how long
374+
> - Changes are applied to what gets cached
375+
376+
**Note:**
377+
> - This hook is only called when the `cache` option is enabled.
378+
379+
**Note:**
380+
> - This hook must be synchronous. It cannot return a Promise. If you need async logic to determine caching behavior, use a `beforeRequest` hook instead.
381+
382+
**Note:**
383+
> - When returning `false`, remaining hooks are skipped and the response will not be cached.
384+
385+
**Note:**
386+
> - Returning anything other than `false` or `undefined` will throw a TypeError.
387+
388+
**Note:**
389+
> - If a hook throws an error, it will be propagated and the request will fail. This is consistent with how other hooks in Got handle errors.
390+
391+
**Note:**
392+
> - At this stage, the response body has not been read yet - it's still a stream. Properties like `response.body` and `response.rawBody` are not available. You can only inspect/modify response headers and status code.
393+
394+
@example
395+
```
396+
import got from 'got';
397+
398+
// Simple: Don't cache errors
399+
const instance = got.extend({
400+
cache: new Map(),
401+
hooks: {
402+
beforeCache: [
403+
(response) => response.statusCode >= 400 ? false : undefined
404+
]
405+
}
406+
});
407+
408+
// Advanced: Modify headers for fine control
409+
const instance2 = got.extend({
410+
cache: new Map(),
411+
hooks: {
412+
beforeCache: [
413+
(response) => {
414+
// Force caching with explicit duration
415+
// Mutations work directly - no need to return
416+
response.headers['cache-control'] = 'public, max-age=3600';
417+
}
418+
]
419+
}
420+
});
421+
422+
await instance('https://example.com');
423+
```
424+
*/
425+
beforeCache: BeforeCacheHook[];
426+
359427
/**
360428
Each function should return the response. This is especially useful when you want to refresh an access token.
361429
@@ -916,6 +984,7 @@ const defaultInternals: Options['_internals'] = {
916984
beforeError: [],
917985
beforeRedirect: [],
918986
beforeRetry: [],
987+
beforeCache: [],
919988
afterResponse: [],
920989
},
921990
followRedirect: true,
@@ -1069,6 +1138,7 @@ const cloneInternals = (internals: typeof defaultInternals) => {
10691138
beforeError: [...hooks.beforeError],
10701139
beforeRedirect: [...hooks.beforeRedirect],
10711140
beforeRetry: [...hooks.beforeRetry],
1141+
beforeCache: [...hooks.beforeCache],
10721142
afterResponse: [...hooks.afterResponse],
10731143
},
10741144
searchParams: internals.searchParams ? new URLSearchParams(internals.searchParams as URLSearchParams) : undefined,
@@ -1152,6 +1222,10 @@ const cloneRaw = (raw: OptionsInit) => {
11521222
result.hooks.beforeRetry = [...hooks.beforeRetry];
11531223
}
11541224

1225+
if (is.array(hooks.beforeCache)) {
1226+
result.hooks.beforeCache = [...hooks.beforeCache];
1227+
}
1228+
11551229
if (is.array(hooks.afterResponse)) {
11561230
result.hooks.afterResponse = [...hooks.afterResponse];
11571231
}

0 commit comments

Comments
 (0)