Skip to content

Commit 3c79205

Browse files
lukechildssindresorhus
authored andcommitted
Add cache option (#284)
1 parent 33cbb6f commit 3c79205

4 files changed

Lines changed: 235 additions & 45 deletions

File tree

index.js

Lines changed: 59 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const Transform = require('stream').Transform;
77
const urlLib = require('url');
88
const fs = require('fs');
99
const querystring = require('querystring');
10+
const CacheableRequest = require('cacheable-request');
1011
const duplexer3 = require('duplexer3');
1112
const intoStream = require('into-stream');
1213
const isStream = require('is-stream');
@@ -87,7 +88,8 @@ function requestAsEventEmitter(opts) {
8788

8889
let progressInterval;
8990

90-
const req = fn.request(opts, res => {
91+
const cacheableRequest = new CacheableRequest(fn.request, opts.cache);
92+
const cacheReq = cacheableRequest(opts, res => {
9193
clearInterval(progressInterval);
9294

9395
ee.emit('uploadProgress', {
@@ -172,7 +174,7 @@ function requestAsEventEmitter(opts) {
172174

173175
const response = opts.decompress === true &&
174176
typeof decompressResponse === 'function' &&
175-
req.method !== 'HEAD' ? decompressResponse(progressStream) : progressStream;
177+
opts.method !== 'HEAD' ? decompressResponse(progressStream) : progressStream;
176178

177179
if (!opts.decompress && ['gzip', 'deflate'].indexOf(res.headers['content-encoding']) !== -1) {
178180
opts.encoding = null;
@@ -190,62 +192,66 @@ function requestAsEventEmitter(opts) {
190192
});
191193
});
192194

193-
req.once('error', err => {
194-
clearInterval(progressInterval);
195+
cacheReq.on('error', err => ee.emit('error', new got.CacheError(err, opts)));
195196

196-
const backoff = opts.retries(++retryCount, err);
197+
cacheReq.on('request', req => {
198+
req.once('error', err => {
199+
clearInterval(progressInterval);
197200

198-
if (backoff) {
199-
setTimeout(get, backoff, opts);
200-
return;
201-
}
201+
const backoff = opts.retries(++retryCount, err);
202202

203-
ee.emit('error', new got.RequestError(err, opts));
204-
});
203+
if (backoff) {
204+
setTimeout(get, backoff, opts);
205+
return;
206+
}
205207

206-
ee.on('request', req => {
207-
ee.emit('uploadProgress', {
208-
percent: 0,
209-
transferred: 0,
210-
total: uploadBodySize
208+
ee.emit('error', new got.RequestError(err, opts));
211209
});
212210

213-
req.connection.on('connect', () => {
214-
const uploadEventFrequency = 150;
211+
ee.on('request', req => {
212+
ee.emit('uploadProgress', {
213+
percent: 0,
214+
transferred: 0,
215+
total: uploadBodySize
216+
});
215217

216-
progressInterval = setInterval(() => {
217-
const lastUploaded = uploaded;
218-
const headersSize = Buffer.byteLength(req._header);
219-
uploaded = req.connection.bytesWritten - headersSize;
218+
req.connection.on('connect', () => {
219+
const uploadEventFrequency = 150;
220220

221-
// Prevent the known issue of `bytesWritten` being larger than body size
222-
if (uploadBodySize && uploaded > uploadBodySize) {
223-
uploaded = uploadBodySize;
224-
}
221+
progressInterval = setInterval(() => {
222+
const lastUploaded = uploaded;
223+
const headersSize = Buffer.byteLength(req._header);
224+
uploaded = req.connection.bytesWritten - headersSize;
225225

226-
// Don't emit events with unchanged progress and
227-
// prevent last event from being emitted, because
228-
// it's emitted when `response` is emitted
229-
if (uploaded === lastUploaded || uploaded === uploadBodySize) {
230-
return;
231-
}
226+
// Prevent the known issue of `bytesWritten` being larger than body size
227+
if (uploadBodySize && uploaded > uploadBodySize) {
228+
uploaded = uploadBodySize;
229+
}
232230

233-
ee.emit('uploadProgress', {
234-
percent: uploadBodySize ? uploaded / uploadBodySize : 0,
235-
transferred: uploaded,
236-
total: uploadBodySize
237-
});
238-
}, uploadEventFrequency);
231+
// Don't emit events with unchanged progress and
232+
// prevent last event from being emitted, because
233+
// it's emitted when `response` is emitted
234+
if (uploaded === lastUploaded || uploaded === uploadBodySize) {
235+
return;
236+
}
237+
238+
ee.emit('uploadProgress', {
239+
percent: uploadBodySize ? uploaded / uploadBodySize : 0,
240+
transferred: uploaded,
241+
total: uploadBodySize
242+
});
243+
}, uploadEventFrequency);
244+
});
239245
});
240-
});
241246

242-
if (opts.gotTimeout) {
243-
clearInterval(progressInterval);
244-
timedOut(req, opts.gotTimeout);
245-
}
247+
if (opts.gotTimeout) {
248+
clearInterval(progressInterval);
249+
timedOut(req, opts.gotTimeout);
250+
}
246251

247-
setImmediate(() => {
248-
ee.emit('request', req);
252+
setImmediate(() => {
253+
ee.emit('request', req);
254+
});
249255
});
250256
};
251257

@@ -434,6 +440,7 @@ function normalizeArguments(url, opts) {
434440
{
435441
path: '',
436442
retries: 2,
443+
cache: false,
437444
decompress: true,
438445
useElectronNet: false
439446
},
@@ -589,6 +596,13 @@ class StdError extends Error {
589596
}
590597
}
591598

599+
got.CacheError = class extends StdError {
600+
constructor(error, opts) {
601+
super(error.message, error, opts);
602+
this.name = 'CacheError';
603+
}
604+
};
605+
592606
got.RequestError = class extends StdError {
593607
constructor(error, opts) {
594608
super(error.message, error, opts);

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
"electron"
5151
],
5252
"dependencies": {
53+
"cacheable-request": "^2.0.0",
5354
"decompress-response": "^3.2.0",
5455
"duplexer3": "^0.1.4",
5556
"get-stream": "^3.0.0",

readme.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ Created because [`request`](https://github.com/request/request) is bloated *(sev
1919

2020
- [Promise & stream API](#api)
2121
- [Request cancelation](#aborting-the-request)
22+
- [RFC compliant caching](#cache-adapters)
2223
- [Follows redirects](#followredirect)
2324
- [Retries on network failure](#retries)
2425
- [Progress events](#onuploadprogress-progress)
@@ -69,6 +70,10 @@ It's a `GET` request by default, but can be changed in `options`.
6970

7071
Returns a Promise for a `response` object with a `body` property, a `url` property with the request URL or the final URL after redirects, and a `requestUrl` property with the original request URL.
7172

73+
The response object will normally be a [Node.js HTTP response stream](https://nodejs.org/api/http.html#http_class_http_incomingmessage), however if returned from the cache it will be a [responselike object](https://github.com/lukechilds/responselike) which behaves in the same way.
74+
75+
The response will also have a `fromCache` property set with a boolean value.
76+
7277
##### url
7378

7479
Type: `string` `Object`
@@ -170,6 +175,13 @@ Decompress the response automatically.
170175

171176
If this is disabled, a compressed response is returned as a `Buffer`. This may be useful if you want to handle decompression yourself or stream the raw compressed data.
172177

178+
###### cache
179+
180+
Type: `Object`<br>
181+
Default: `false`
182+
183+
[Cache adapter instance](#cache-adapters) for storing cached data.
184+
173185
###### useElectronNet
174186

175187
Type: `boolean`<br>
@@ -253,6 +265,10 @@ Each error contains (if available) `statusCode`, `statusMessage`, `host`, `hostn
253265

254266
In Promise mode, the `response` is attached to the error.
255267

268+
#### got.CacheError
269+
270+
When a cache method fails, for example if the database goes down, or there's a filesystem error.
271+
256272
#### got.RequestError
257273

258274
When a request fails. Contains a `code` property with error class code, like `ECONNREFUSED`.
@@ -316,6 +332,58 @@ request.catch(err => {
316332
request.cancel();
317333
```
318334

335+
<a name="cache-adapters"></a>
336+
## Cache
337+
338+
You can use the JavaScript `Map` type as an in memory cache:
339+
340+
```js
341+
const got = require('got');
342+
const map = new Map();
343+
344+
(async () => {
345+
let response = await got('todomvc.com', {cache: map});
346+
console.log(response.fromCache);
347+
//=> false
348+
349+
response = await got('todomvc.com', {cache: map});
350+
console.log(response.fromCache);
351+
//=> true
352+
})();
353+
```
354+
355+
Got uses [Keyv](https://github.com/lukechilds/keyv) internally to support a wide range of storage adapters. For something more scalable you could use an [official Keyv storage adapter](https://github.com/lukechilds/keyv#official-storage-adapters):
356+
357+
```
358+
npm install @keyv/redis
359+
```
360+
361+
```js
362+
const got = require('got');
363+
const KeyvRedis = require('@keyv/redis');
364+
365+
const redis = new KeyvRedis('redis://user:pass@localhost:6379');
366+
367+
got('todomvc.com', {cache: redis});
368+
```
369+
370+
Got supports anything that follows the Map API so it's easy to write your own storage adapter or use a third-party solution.
371+
372+
For example, the following are all valid storage adapters
373+
374+
```js
375+
const storageAdapter = new Map();
376+
// or
377+
const storageAdapter = require('./my-storage-adapter');
378+
// or
379+
const QuickLRU = require('quick-lru');
380+
const storageAdapter = new QuickLRU({maxSize: 1000});
381+
382+
got('todomvc.com', {cache: storageAdapter});
383+
```
384+
385+
View the [Keyv docs](https://github.com/lukechilds/keyv) for more information on how to use storage adapters.
386+
319387

320388
## Proxies
321389

test/cache.js

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import test from 'ava';
2+
import got from '../';
3+
import {createServer} from './helpers/server';
4+
5+
let s;
6+
7+
test.before('setup', async () => {
8+
s = await createServer();
9+
10+
let noStoreIndex = 0;
11+
s.on('/no-store', (req, res) => {
12+
res.setHeader('Cache-Control', 'public, no-cache, no-store');
13+
res.end(noStoreIndex.toString());
14+
noStoreIndex++;
15+
});
16+
17+
let cacheIndex = 0;
18+
s.on('/cache', (req, res) => {
19+
res.setHeader('Cache-Control', 'public, max-age=60');
20+
res.end(cacheIndex.toString());
21+
cacheIndex++;
22+
});
23+
24+
let status301Index = 0;
25+
s.on('/301', (req, res) => {
26+
if (status301Index === 0) {
27+
res.setHeader('Cache-Control', 'public, max-age=60');
28+
res.setHeader('Location', s.url + '/302');
29+
res.statusCode = 301;
30+
}
31+
res.end();
32+
status301Index++;
33+
});
34+
35+
let status302Index = 0;
36+
s.on('/302', (req, res) => {
37+
if (status302Index === 0) {
38+
res.setHeader('Cache-Control', 'public, max-age=60');
39+
res.setHeader('Location', s.url + '/cache');
40+
res.statusCode = 302;
41+
}
42+
res.end();
43+
status302Index++;
44+
});
45+
46+
await s.listen(s.port);
47+
});
48+
49+
test('Non cacheable responses are not cached', async t => {
50+
const endpoint = '/no-store';
51+
const cache = new Map();
52+
53+
const firstResponseInt = Number((await got(s.url + endpoint, {cache})).body);
54+
const secondResponseInt = Number((await got(s.url + endpoint, {cache})).body);
55+
56+
t.is(cache.size, 0);
57+
t.true(firstResponseInt < secondResponseInt);
58+
});
59+
60+
test('Cacheable responses are cached', async t => {
61+
const endpoint = '/cache';
62+
const cache = new Map();
63+
64+
const firstResponse = await got(s.url + endpoint, {cache});
65+
const secondResponse = await got(s.url + endpoint, {cache});
66+
67+
t.is(cache.size, 1);
68+
t.is(firstResponse.body, secondResponse.body);
69+
});
70+
71+
test('Cached response is re-encoded to current encoding option', async t => {
72+
const endpoint = '/cache';
73+
const cache = new Map();
74+
const firstEncoding = 'base64';
75+
const secondEncoding = 'hex';
76+
77+
const firstResponse = await got(s.url + endpoint, {cache, encoding: firstEncoding});
78+
const secondResponse = await got(s.url + endpoint, {cache, encoding: secondEncoding});
79+
80+
const expectedSecondResponseBody = Buffer.from(firstResponse.body, firstEncoding).toString(secondEncoding);
81+
82+
t.is(cache.size, 1);
83+
t.is(secondResponse.body, expectedSecondResponseBody);
84+
});
85+
86+
test('Redirects are cached and re-used internally', async t => {
87+
const endpoint = '/301';
88+
const cache = new Map();
89+
90+
const firstResponse = await got(s.url + endpoint, {cache});
91+
const secondResponse = await got(s.url + endpoint, {cache});
92+
93+
t.is(cache.size, 3);
94+
t.is(firstResponse.body, secondResponse.body);
95+
});
96+
97+
test('Cache error throws got.CacheError', async t => {
98+
const endpoint = '/no-store';
99+
const cache = {};
100+
101+
const err = await t.throws(got(s.url + endpoint, {cache}));
102+
t.is(err.name, 'CacheError');
103+
});
104+
105+
test.after('cleanup', async () => {
106+
await s.close();
107+
});

0 commit comments

Comments
 (0)