diff --git a/README.md b/README.md index 2fea31e..1b74f15 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,18 @@ $ npm install @adobe/helix-fetch ### Customization +Set cache size limit (Default: 100 \* 1024 \* 1024 bytes, i.e. 100mb): + +```javascript + const { fetch, cacheStats } = require('@adobe/helix-fetch').context({ + maxCacheSize: 100 * 1024, // 100kb + }); + + let resp = await fetch('http://httpbin.org/bytes/60000'); // ~60kb response + resp = await fetch('http://httpbin.org/bytes/50000'); // ~50kb response + console.log(cacheStats()); +``` + Force HTTP/1(.1) protocol: ```javascript diff --git a/package-lock.json b/package-lock.json index b383f0e..e0b4822 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@adobe/helix-fetch", - "version": "1.1.0", + "version": "1.3.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -1617,6 +1617,11 @@ } } }, + "base64-js": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.1.tgz", + "integrity": "sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g==" + }, "before-after-hook": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.1.0.tgz", @@ -1686,6 +1691,15 @@ "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", "dev": true }, + "buffer": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.4.3.tgz", + "integrity": "sha512-zvj65TkFeIt3i6aj5bIvJDzjjQQGs4o/sNoezg1F1kYap9Nu2jcUdpwzRSJTHMMzG0H7bZkn4rNQpImhuxWX2A==", + "requires": { + "base64-js": "^1.0.2", + "ieee754": "^1.1.4" + } + }, "cache-base": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/cache-base/-/cache-base-1.0.1.tgz", @@ -4315,6 +4329,11 @@ "safer-buffer": ">= 2.1.2 < 3" } }, + "ieee754": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz", + "integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==" + }, "ignore": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", @@ -10442,6 +10461,14 @@ "integrity": "sha512-FTMyFUm2wBcGHnH2eXmz7tC6IwlqQZ6mVZ+6dm6vZ4IQIHjs6FdNsQBuKGPuUUUY6NfJw2PshC08Tn6LzLDOag==", "dev": true }, + "object-sizeof": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/object-sizeof/-/object-sizeof-1.5.3.tgz", + "integrity": "sha512-YtPJaDLJ9PnrwsvEgvsejxuOnc/Uc/keGSRCJ/GNsUincXo7LTHaLKYUmxu6NK4vCtP4TuXclV378mgKv5UfTQ==", + "requires": { + "buffer": "^5.4.3" + } + }, "object-to-spawn-args": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-to-spawn-args/-/object-to-spawn-args-1.1.1.tgz", diff --git a/package.json b/package.json index 1c7a160..be1e3e5 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ "fetch-h2": "^2.2.2", "get-stream": "^5.1.0", "http-cache-semantics": "^4.0.3", - "lru-cache": "^5.1.1" + "lru-cache": "^5.1.1", + "object-sizeof": "^1.5.3" }, "devDependencies": { "@adobe/eslint-config-helix": "1.1.0", diff --git a/src/index.js b/src/index.js index afa296b..12028d8 100644 --- a/src/index.js +++ b/src/index.js @@ -20,6 +20,7 @@ const { TimeoutError, } = require('fetch-h2'); const LRU = require('lru-cache'); +const sizeof = require('object-sizeof'); const CachePolicy = require('./policy'); const { cacheableResponse, decoratedResponse } = require('./response'); @@ -28,7 +29,7 @@ const { decorateHeaders } = require('./headers'); const CACHEABLE_METHODS = ['GET', 'HEAD']; const DEFAULT_FETCH_OPTIONS = { method: 'GET', cache: 'default' }; const DEFAULT_CONTEXT_OPTIONS = { userAgent: 'helix-fetch', overwriteUserAgent: true }; -const DEFAULT_MAX_CACHE_ENTRIES = 100; +const DEFAULT_MAX_CACHE_SIZE = 100 * 1024 * 1024; // 100mb // events const PUSH_EVENT = 'push'; @@ -108,9 +109,14 @@ const wrappedFetch = async (ctx, url, options = {}) => { class FetchContext { constructor(options = {}) { - this._ctx = context({ ...DEFAULT_CONTEXT_OPTIONS, ...options }); - - this._ctx.cache = new LRU({ max: DEFAULT_MAX_CACHE_ENTRIES }); + // setup context + const opts = { ...DEFAULT_CONTEXT_OPTIONS, ...options }; + this._ctx = context(opts); + // setup cache + const max = typeof opts.maxCacheSize === 'number' && opts.maxCacheSize >= 0 ? opts.maxCacheSize : DEFAULT_MAX_CACHE_SIZE; + const length = ({ response }, _) => sizeof(response); + this._ctx.cache = new LRU({ max, length }); + // event emitter this._ctx.eventEmitter = new EventEmitter(); // register push handler this._ctx.onPush(createPushHandler(this._ctx)); @@ -159,7 +165,16 @@ class FetchContext { */ offPush: (fn) => this.offPush(fn), + /** + * Clear the cache entirely, throwing away all values. + */ clearCache: () => this.clearCache(), + + /** + * Cache stats for diagnostic purposes + */ + cacheStats: () => this.cacheStats(), + /** * Error thrown when a request timed out. */ @@ -188,6 +203,13 @@ class FetchContext { this._ctx.cache.reset(); } + cacheStats() { + return { + size: this._ctx.cache.length, + count: this._ctx.cache.itemCount, + }; + } + async fetch(url, options) { return wrappedFetch(this._ctx, url, options); } diff --git a/test/index.test.js b/test/index.test.js index 0f274ea..ca42813 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -24,7 +24,7 @@ const parseCacheControl = require('parse-cache-control'); const { WritableStreamBuffer } = require('stream-buffers'); const { - fetch, onPush, offPush, disconnectAll, clearCache, context, TimeoutError, + fetch, onPush, offPush, disconnectAll, clearCache, cacheStats, context, TimeoutError, } = require('../src/index.js'); const WOKEUP = 'woke up!'; @@ -59,7 +59,7 @@ describe('Fetch Tests', () => { }); it('fetch supports binary response body (ArrayBuffer)', async () => { - const dataLen = 64 * 1024; // httpbin.org/stream-bytes has a limit of 100kb ... + const dataLen = 64 * 1024; // httpbin.org/stream-bytes/{n} has a limit of 100kb ... const contentType = 'application/octet-stream'; const resp = await fetch(`https://httpbin.org/stream-bytes/${dataLen}`, { headers: { accept: contentType }, @@ -72,7 +72,7 @@ describe('Fetch Tests', () => { }); it('fetch supports binary response body (Stream)', async () => { - const dataLen = 64 * 1024; // httpbin.org/stream-bytes has a limit of 100kb ... + const dataLen = 64 * 1024; // httpbin.org/stream-bytes/{n} has a limit of 100kb ... const contentType = 'application/octet-stream'; const resp = await fetch(`https://httpbin.org/stream-bytes/${dataLen}`, { headers: { accept: contentType }, @@ -145,12 +145,35 @@ describe('Fetch Tests', () => { // clear client cache clearCache(); + const { size, count } = cacheStats(); + assert.equal(size, 0); + assert.equal(count, 0); + // re-send request, make sure it's returning a fresh response resp = await fetch(url); assert.equal(resp.status, 200); assert(!resp.fromCache); }); + it('cache size limit is configurable', async () => { + const maxCacheSize = 100 * 1024; // 100kb + // custom context with cache size limit + const ctx = context({ maxCacheSize }); + + const sizes = [34 * 1024, 35 * 1024, 36 * 1024]; // sizes add up to >100kb + const urls = sizes.map((size) => `http://httpbin.org/bytes/${size}`); + // prime cache with multiple requests that together hit the cache size limit of 100kb + const resps = await Promise.all(urls.map((url) => ctx.fetch(url))); + assert.equal(resps.filter((resp) => resp.status === 200).length, urls.length); + + const { size, count } = ctx.cacheStats(); + assert(size < maxCacheSize); + assert.equal(count, urls.length - 1); + + ctx.clearCache(); + await ctx.disconnectAll(); + }); + // eslint-disable-next-line func-names it('fetch supports max-age directive', async function () { this.timeout(5000); @@ -388,18 +411,18 @@ describe('Fetch Tests', () => { }); it('creating custom fetch context works', async () => { - const { fetch: customFetch } = context(); - const resp = await customFetch('https://httpbin.org/status/200'); + const ctx = context(); + const resp = await ctx.fetch('https://httpbin.org/status/200'); assert.equal(resp.status, 200); }); it('overriding user-agent works', async () => { const customUserAgent = 'helix-custom-fetch'; - const { fetch: customFetch } = context({ + const ctx = context({ userAgent: customUserAgent, overwriteUserAgent: true, }); - const resp = await customFetch('https://httpbin.org/user-agent'); + const resp = await ctx.fetch('https://httpbin.org/user-agent'); assert.equal(resp.status, 200); assert.equal(resp.headers.get('content-type'), 'application/json'); const json = await resp.json(); @@ -416,10 +439,10 @@ describe('Fetch Tests', () => { assert.equal(resp.httpVersion, 2); // custom context forces http1 - const { fetch: customFetch } = context({ + const ctx = context({ httpsProtocols: ['http1'], }); - resp = await customFetch(url); + resp = await ctx.fetch(url); assert.equal(resp.status, 200); assert.equal(resp.httpVersion, 1); });