add support for only-if-cached and expose cache key calculation function
This commit is contained in:
parent
4f93c9ba1c
commit
5f48f0fc8a
56
README.md
56
README.md
@ -46,12 +46,6 @@ fetch('http://google.com')
|
|||||||
}).then(text => console.log(text));
|
}).then(text => console.log(text));
|
||||||
```
|
```
|
||||||
|
|
||||||
## Streaming
|
|
||||||
|
|
||||||
This module does not support Stream request bodies, except for fs.ReadStream. And when using fs.ReadStream, the cache key is generated based only on the path of the stream, not its content. That means if you stream `/my/desktop/image.png` twice, you will get a cached response the second time, **even if the content of image.png has changed**.
|
|
||||||
|
|
||||||
Streams don't quite play nice with the concept of caching based on request characteristics, because we would have to read the stream to the end to find out what's in it and hash it into a proper cache key.
|
|
||||||
|
|
||||||
## Cache Customization
|
## Cache Customization
|
||||||
|
|
||||||
By default responses are cached in memory, but you can also cache to files on disk, or implement your own cache.
|
By default responses are cached in memory, but you can also cache to files on disk, or implement your own cache.
|
||||||
@ -111,6 +105,56 @@ The remove function should accept a key and remove the cached value associated w
|
|||||||
|
|
||||||
All three functions may be async.
|
All three functions may be async.
|
||||||
|
|
||||||
|
## Misc Tips
|
||||||
|
|
||||||
|
### Streaming
|
||||||
|
|
||||||
|
This module does not support Stream request bodies, except for fs.ReadStream. And when using fs.ReadStream, the cache key is generated based only on the path of the stream, not its content. That means if you stream `/my/desktop/image.png` twice, you will get a cached response the second time, **even if the content of image.png has changed**.
|
||||||
|
|
||||||
|
Streams don't quite play nice with the concept of caching based on request characteristics, because we would have to read the stream to the end to find out what's in it and hash it into a proper cache key.
|
||||||
|
|
||||||
|
### Request Concurrency
|
||||||
|
|
||||||
|
Requests with the same cache key are queued. For example, you might wonder if making the same request 100 times simultaneously would result in 100 HTTP requests:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import fetch from 'node-fetch-cache';
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
Array(100).fill().map(() => fetch('https://google.com')),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
The answer is no. Only one request would be made, and 99 of the `fetch()`s will read the response from the cache.
|
||||||
|
|
||||||
|
### Cache-Control: only-if-cached Requests
|
||||||
|
|
||||||
|
The HTTP standard describes a [Cache-Control request header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#request_directives) to control certain aspects of cache behavior. Node-fetch ignores these, but node-fetch-cache respects the `Cache-Control: only-if-cached` directive. When `only-if-cached` is specified, node-fetch-cache will return `undefined` if there is no cached response. No HTTP request will be made. For example:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import fetch from 'node-fetch-cache';
|
||||||
|
|
||||||
|
const response = await fetch('https://google.com', { headers: { 'Cache-Control': 'only-if-cached' } });
|
||||||
|
if (response === undefined) {
|
||||||
|
// No response was found in the cache
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note that this is slightly different from browser fetch, which returns a `504 Gateway Timeout` response if no cached response is available.
|
||||||
|
|
||||||
|
### Calculating the Cache Key
|
||||||
|
|
||||||
|
This module exports a `getCacheKey()` function to calculate a cache key string from request parameters, which may be useful for enabling some advanced use cases (especially if you want to call cache functions directly). Call `getCacheKey()` exactly like you would call `fetch()`.
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { fetchBuilder, MemoryCache, getCacheKey } from 'node-fetch-cache';
|
||||||
|
|
||||||
|
const cache = new MemoryCache();
|
||||||
|
const fetch = fetchBuilder.withCache(cache);
|
||||||
|
|
||||||
|
const rawCacheData = await cache.get(getCacheKey('https://google.com'));
|
||||||
|
```
|
||||||
|
|
||||||
## Bugs / Help / Feature Requests / Contributing
|
## Bugs / Help / Feature Requests / Contributing
|
||||||
|
|
||||||
For feature requests or help, please visit [the discussions page on GitHub](https://github.com/mistval/node-fetch-cache/discussions).
|
For feature requests or help, please visit [the discussions page on GitHub](https://github.com/mistval/node-fetch-cache/discussions).
|
||||||
|
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "node-fetch-cache",
|
"name": "node-fetch-cache",
|
||||||
"version": "3.0.5",
|
"version": "3.1.0",
|
||||||
"lockfileVersion": 2,
|
"lockfileVersion": 2,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "node-fetch-cache",
|
"name": "node-fetch-cache",
|
||||||
"version": "3.0.5",
|
"version": "3.1.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cacache": "^15.2.0",
|
"cacache": "^15.2.0",
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "node-fetch-cache",
|
"name": "node-fetch-cache",
|
||||||
"version": "3.0.5",
|
"version": "3.1.0",
|
||||||
"description": "node-fetch with caching.",
|
"description": "node-fetch with caching.",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@ -10,7 +10,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"buildcjs": "rollup src/index.js --file commonjs/index.cjs --format cjs",
|
"buildcjs": "rollup src/index.js --file commonjs/index.cjs --format cjs",
|
||||||
"test": "npm run buildcjs && mocha --timeout 10000 --exit",
|
"test": "npm run lintfix && npm run buildcjs && mocha --timeout 10000 --exit",
|
||||||
"coverage": "nyc --reporter=lcov --reporter=text npm test",
|
"coverage": "nyc --reporter=lcov --reporter=text npm test",
|
||||||
"lint": "./node_modules/.bin/eslint .",
|
"lint": "./node_modules/.bin/eslint .",
|
||||||
"lintfix": "./node_modules/.bin/eslint . --fix",
|
"lintfix": "./node_modules/.bin/eslint . --fix",
|
||||||
|
47
src/index.js
47
src/index.js
@ -5,7 +5,7 @@ import locko from 'locko';
|
|||||||
import { NFCResponse } from './classes/response.js';
|
import { NFCResponse } from './classes/response.js';
|
||||||
import { MemoryCache } from './classes/caching/memory_cache.js';
|
import { MemoryCache } from './classes/caching/memory_cache.js';
|
||||||
|
|
||||||
const CACHE_VERSION = 3;
|
const CACHE_VERSION = 4;
|
||||||
|
|
||||||
function md5(str) {
|
function md5(str) {
|
||||||
return crypto.createHash('md5').update(str).digest('hex');
|
return crypto.createHash('md5').update(str).digest('hex');
|
||||||
@ -35,6 +35,14 @@ function getFormDataCacheKey(formData) {
|
|||||||
return cacheKey;
|
return cacheKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getHeadersCacheKeyJson(headersObj) {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(headersObj)
|
||||||
|
.map(([key, value]) => [key.toLowerCase(), value])
|
||||||
|
.filter(([key, value]) => key !== 'cache-control' || value !== 'only-if-cached'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function getBodyCacheKeyJson(body) {
|
function getBodyCacheKeyJson(body) {
|
||||||
if (!body) {
|
if (!body) {
|
||||||
return body;
|
return body;
|
||||||
@ -54,11 +62,13 @@ function getBodyCacheKeyJson(body) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getRequestCacheKey(req) {
|
function getRequestCacheKey(req) {
|
||||||
|
const headersPojo = Object.fromEntries([...req.headers.entries()]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cache: req.cache,
|
cache: req.cache,
|
||||||
credentials: req.credentials,
|
credentials: req.credentials,
|
||||||
destination: req.destination,
|
destination: req.destination,
|
||||||
headers: req.headers,
|
headers: getHeadersCacheKeyJson(headersPojo),
|
||||||
integrity: req.integrity,
|
integrity: req.integrity,
|
||||||
method: req.method,
|
method: req.method,
|
||||||
redirect: req.redirect,
|
redirect: req.redirect,
|
||||||
@ -69,15 +79,15 @@ function getRequestCacheKey(req) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCacheKey(requestArguments) {
|
export function getCacheKey(resource, init = {}) {
|
||||||
const resource = requestArguments[0];
|
|
||||||
const init = requestArguments[1] || {};
|
|
||||||
|
|
||||||
const resourceCacheKeyJson = resource instanceof Request
|
const resourceCacheKeyJson = resource instanceof Request
|
||||||
? getRequestCacheKey(resource)
|
? getRequestCacheKey(resource)
|
||||||
: { url: resource };
|
: { url: resource };
|
||||||
|
|
||||||
const initCacheKeyJson = { ...init };
|
const initCacheKeyJson = {
|
||||||
|
...init,
|
||||||
|
headers: getHeadersCacheKeyJson(init.headers || {}),
|
||||||
|
};
|
||||||
|
|
||||||
resourceCacheKeyJson.body = getBodyCacheKeyJson(resourceCacheKeyJson.body);
|
resourceCacheKeyJson.body = getBodyCacheKeyJson(resourceCacheKeyJson.body);
|
||||||
initCacheKeyJson.body = getBodyCacheKeyJson(initCacheKeyJson.body);
|
initCacheKeyJson.body = getBodyCacheKeyJson(initCacheKeyJson.body);
|
||||||
@ -87,8 +97,25 @@ function getCacheKey(requestArguments) {
|
|||||||
return md5(JSON.stringify([resourceCacheKeyJson, initCacheKeyJson, CACHE_VERSION]));
|
return md5(JSON.stringify([resourceCacheKeyJson, initCacheKeyJson, CACHE_VERSION]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasOnlyWithCacheOption(resource, init) {
|
||||||
|
if (
|
||||||
|
init
|
||||||
|
&& init.headers
|
||||||
|
&& Object.entries(init.headers)
|
||||||
|
.some(([key, value]) => key.toLowerCase() === 'cache-control' && value === 'only-if-cached')
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resource instanceof Request && resource.headers.get('Cache-Control') === 'only-if-cached') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
async function getResponse(cache, requestArguments) {
|
async function getResponse(cache, requestArguments) {
|
||||||
const cacheKey = getCacheKey(requestArguments);
|
const cacheKey = getCacheKey(...requestArguments);
|
||||||
let cachedValue = await cache.get(cacheKey);
|
let cachedValue = await cache.get(cacheKey);
|
||||||
|
|
||||||
const ejectSelfFromCache = () => cache.remove(cacheKey);
|
const ejectSelfFromCache = () => cache.remove(cacheKey);
|
||||||
@ -102,6 +129,10 @@ async function getResponse(cache, requestArguments) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasOnlyWithCacheOption(...requestArguments)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
await locko.lock(cacheKey);
|
await locko.lock(cacheKey);
|
||||||
try {
|
try {
|
||||||
cachedValue = await cache.get(cacheKey);
|
cachedValue = await cache.get(cacheKey);
|
||||||
|
@ -7,7 +7,7 @@ import rimraf from 'rimraf';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { URLSearchParams } from 'url';
|
import { URLSearchParams } from 'url';
|
||||||
import standardFetch from 'node-fetch';
|
import standardFetch from 'node-fetch';
|
||||||
import FetchCache, { MemoryCache, FileSystemCache } from '../src/index.js';
|
import FetchCache, { MemoryCache, FileSystemCache, getCacheKey } from '../src/index.js';
|
||||||
import { Agent } from 'http';
|
import { Agent } from 'http';
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
@ -326,6 +326,17 @@ describe('Data tests', function() {
|
|||||||
assert.strictEqual(res.fromCache, true);
|
assert.strictEqual(res.fromCache, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('Supports request objects with custom headers', async function() {
|
||||||
|
const request1 = new standardFetch.Request(TWO_HUNDRED_URL, { headers: { 'XXX': 'YYY' } });
|
||||||
|
const request2 = new standardFetch.Request(TWO_HUNDRED_URL, { headers: { 'XXX': 'ZZZ' } });
|
||||||
|
|
||||||
|
res = await cachedFetch(request1);
|
||||||
|
assert.strictEqual(res.fromCache, false);
|
||||||
|
|
||||||
|
res = await cachedFetch(request2);
|
||||||
|
assert.strictEqual(res.fromCache, false);
|
||||||
|
});
|
||||||
|
|
||||||
it('Refuses to consume body twice', async function() {
|
it('Refuses to consume body twice', async function() {
|
||||||
res = await cachedFetch(TEXT_BODY_URL);
|
res = await cachedFetch(TEXT_BODY_URL);
|
||||||
await res.text();
|
await res.text();
|
||||||
@ -476,3 +487,45 @@ describe('File system cache tests', function() {
|
|||||||
assert.strictEqual(res.fromCache, true);
|
assert.strictEqual(res.fromCache, true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Cache mode tests', function() {
|
||||||
|
it('Can use the only-if-cached cache control setting via init', async function() {
|
||||||
|
res = await cachedFetch(TWO_HUNDRED_URL, { headers: { 'Cache-Control': 'only-if-cached' } });
|
||||||
|
assert(!res);
|
||||||
|
res = await cachedFetch(TWO_HUNDRED_URL, { headers: { 'Cache-Control': 'only-if-cached' } });
|
||||||
|
assert(!res);
|
||||||
|
res = await cachedFetch(TWO_HUNDRED_URL);
|
||||||
|
assert(res && !res.fromCache);
|
||||||
|
res = await cachedFetch(TWO_HUNDRED_URL, { headers: { 'Cache-Control': 'only-if-cached' } });
|
||||||
|
assert(res && res.fromCache);
|
||||||
|
await res.ejectFromCache();
|
||||||
|
res = await cachedFetch(TWO_HUNDRED_URL, { headers: { 'Cache-Control': 'only-if-cached' } });
|
||||||
|
assert(!res);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Can use the only-if-cached cache control setting via resource', async function() {
|
||||||
|
res = await cachedFetch(new standardFetch.Request(TWO_HUNDRED_URL, { headers: { 'Cache-Control': 'only-if-cached' } }));
|
||||||
|
assert(!res);
|
||||||
|
res = await cachedFetch(new standardFetch.Request(TWO_HUNDRED_URL));
|
||||||
|
assert(res && !res.fromCache);
|
||||||
|
res = await cachedFetch(new standardFetch.Request(TWO_HUNDRED_URL, { headers: { 'Cache-Control': 'only-if-cached' } }));
|
||||||
|
assert(res && res.fromCache);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Cache key tests', function() {
|
||||||
|
it('Can calculate a cache key and check that it exists', async function() {
|
||||||
|
const cache = new MemoryCache();
|
||||||
|
cachedFetch = FetchCache.withCache(cache);
|
||||||
|
await cachedFetch(TWO_HUNDRED_URL);
|
||||||
|
|
||||||
|
const cacheKey = getCacheKey(TWO_HUNDRED_URL);
|
||||||
|
const nonExistentCacheKey = getCacheKey(TEXT_BODY_URL);
|
||||||
|
|
||||||
|
const cacheKeyResult = await cache.get(cacheKey);
|
||||||
|
const nonExistentCacheKeyResult = await cache.get(nonExistentCacheKey);
|
||||||
|
|
||||||
|
assert(cacheKeyResult);
|
||||||
|
assert(!nonExistentCacheKeyResult);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
Reference in New Issue
Block a user