diff --git a/README.md b/README.md index db3cd2b..00c077f 100644 --- a/README.md +++ b/README.md @@ -46,12 +46,6 @@ fetch('http://google.com') }).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 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. +## 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 For feature requests or help, please visit [the discussions page on GitHub](https://github.com/mistval/node-fetch-cache/discussions). diff --git a/package-lock.json b/package-lock.json index 94c97e1..4c5fa0a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "node-fetch-cache", - "version": "3.0.5", + "version": "3.1.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "node-fetch-cache", - "version": "3.0.5", + "version": "3.1.0", "license": "MIT", "dependencies": { "cacache": "^15.2.0", diff --git a/package.json b/package.json index 7fc1d4d..00871e1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "node-fetch-cache", - "version": "3.0.5", + "version": "3.1.0", "description": "node-fetch with caching.", "main": "src/index.js", "type": "module", @@ -10,7 +10,7 @@ }, "scripts": { "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", "lint": "./node_modules/.bin/eslint .", "lintfix": "./node_modules/.bin/eslint . --fix", diff --git a/src/index.js b/src/index.js index 4abf2d7..0bdd666 100644 --- a/src/index.js +++ b/src/index.js @@ -5,7 +5,7 @@ import locko from 'locko'; import { NFCResponse } from './classes/response.js'; import { MemoryCache } from './classes/caching/memory_cache.js'; -const CACHE_VERSION = 3; +const CACHE_VERSION = 4; function md5(str) { return crypto.createHash('md5').update(str).digest('hex'); @@ -35,6 +35,14 @@ function getFormDataCacheKey(formData) { 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) { if (!body) { return body; @@ -54,11 +62,13 @@ function getBodyCacheKeyJson(body) { } function getRequestCacheKey(req) { + const headersPojo = Object.fromEntries([...req.headers.entries()]); + return { cache: req.cache, credentials: req.credentials, destination: req.destination, - headers: req.headers, + headers: getHeadersCacheKeyJson(headersPojo), integrity: req.integrity, method: req.method, redirect: req.redirect, @@ -69,15 +79,15 @@ function getRequestCacheKey(req) { }; } -function getCacheKey(requestArguments) { - const resource = requestArguments[0]; - const init = requestArguments[1] || {}; - +export function getCacheKey(resource, init = {}) { const resourceCacheKeyJson = resource instanceof Request ? getRequestCacheKey(resource) : { url: resource }; - const initCacheKeyJson = { ...init }; + const initCacheKeyJson = { + ...init, + headers: getHeadersCacheKeyJson(init.headers || {}), + }; resourceCacheKeyJson.body = getBodyCacheKeyJson(resourceCacheKeyJson.body); initCacheKeyJson.body = getBodyCacheKeyJson(initCacheKeyJson.body); @@ -87,8 +97,25 @@ function getCacheKey(requestArguments) { 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) { - const cacheKey = getCacheKey(requestArguments); + const cacheKey = getCacheKey(...requestArguments); let cachedValue = await cache.get(cacheKey); const ejectSelfFromCache = () => cache.remove(cacheKey); @@ -102,6 +129,10 @@ async function getResponse(cache, requestArguments) { ); } + if (hasOnlyWithCacheOption(...requestArguments)) { + return undefined; + } + await locko.lock(cacheKey); try { cachedValue = await cache.get(cacheKey); diff --git a/test/tests.js b/test/tests.js index ac877d3..a6a49bd 100644 --- a/test/tests.js +++ b/test/tests.js @@ -7,7 +7,7 @@ import rimraf from 'rimraf'; import path from 'path'; import { URLSearchParams } from 'url'; 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'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -326,6 +326,17 @@ describe('Data tests', function() { 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() { res = await cachedFetch(TEXT_BODY_URL); await res.text(); @@ -476,3 +487,45 @@ describe('File system cache tests', function() { 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); + }); +});