├── .babelrc.js ├── .gitignore ├── .npmignore ├── .prettierrc ├── .travis.yml ├── .vscode ├── launch.json └── settings.json ├── LICENSE ├── README.md ├── doc └── sozialhelden-logo.svg ├── jest.config.json ├── package-lock.json ├── package.json ├── src ├── FetchCache.spec.ts ├── FetchCache.ts ├── defaultTTL.spec.ts ├── defaultTTL.ts ├── index.ts └── types.ts ├── tests └── unit │ └── spec-bundle.js ├── tsconfig.json └── tslint.json /.babelrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [['@babel/preset-env', { useBuiltIns: 'usage' }], '@babel/preset-typescript'], 3 | plugins: ['@babel/plugin-proposal-class-properties'], 4 | }; 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | buildcache 3 | *.tsbuildinfo 4 | coverage 5 | .DS_Store 6 | *.log 7 | .idea 8 | dist 9 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sozialhelden/fetch-cache/75abc4c1966f0063469c4b2fdd035b90c2ad2fb4/.npmignore -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "tabWidth": 2, 4 | "trailingComma": "es5", 5 | "semi": true, 6 | "printWidth": 100 7 | } 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "8" 4 | - "9" 5 | - "10" 6 | - "11" 7 | - "12" 8 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug Jest Tests", 6 | "type": "node", 7 | "request": "launch", 8 | "runtimeArgs": ["--inspect-brk", "${workspaceRoot}/node_modules/.bin/jest", "--runInBand"], 9 | "console": "integratedTerminal", 10 | "internalConsoleOptions": "neverOpen", 11 | "port": 9229 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Workspace settings 2 | { 3 | "editor.tabSize": 2, 4 | "editor.formatOnSave": true, 5 | "prettier.tslintIntegration": true 6 | } 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Sozialhelden 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fetch-cache 🐕 2 | 3 | A cache for WhatWG fetch calls. 4 | 5 | - Supports TypeScript 6 | - Uses normalized URLs as cache keys 7 | - Can normalize URLs for better performance (you can configure how) 8 | - Does not request the same resource twice if the first request is still loading 9 | - Customizable TTLs per request, dependent on HTTP status code or in case of network errors 10 | - Supports all [Hamster Cache](https://github.com/sozialhelden/hamster-cache) features, e.g. eviction based on LRU, maximal cached item count and/or per-item TTL. 11 | - Runs in NodeJS, but should be isometric && browser-compatible (**not tested yet! try at your own risk 🙃**) 12 | 13 | ## Installation 14 | 15 | ```bash 16 | npm install --save @sozialhelden/fetch-cache 17 | #or 18 | yarn add @sozialhelden/fetch-cache 19 | ``` 20 | 21 | ## Usage examples 22 | 23 | ### Initialization 24 | 25 | Bring your own `fetch` - for example: 26 | 27 | - your modern browser's [fetch function](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) 28 | - [node-fetch](https://github.com/bitinn/node-fetch), a NodeJS implementation 29 | - [fetch-retry](https://github.com/jonbern/fetch-retry) for automatic request retrying with exponential backoff 30 | - [isomorphic-unfetch](https://github.com/developit/unfetch/tree/master/packages/isomorphic-unfetch), an isometric implementation for browsers (legacy and modern) and Node.js 31 | 32 | Configure the cache and use `cache.fetch()` as if you would call `fetch()` directly: 33 | 34 | ```typescript 35 | import FetchCache from '@sozialhelden/fetch-cache'; 36 | 37 | const fetch = require('node-fetch'); // in NodeJS 38 | // or 39 | const fetch = window.fetch; // in newer browsers 40 | 41 | const fetchCache = new FetchCache({ 42 | fetch, 43 | cacheOptions: { 44 | // Don't save more than 100 responses in the cache. Allows infinite responses by default 45 | maximalItemCount: 100, 46 | // When should the cache evict responses when its full? 47 | evictExceedingItemsBy: 'lru', // Valid values: 'lru' or 'age' 48 | // ...see https://github.com/sozialhelden/hamster-cache for all possible options 49 | }, 50 | }); 51 | 52 | // either fetches a response over the network, 53 | // or returns a cached promise with the same URL (if available) 54 | const url = 'https://jsonplaceholder.typicode.com/todos/1'; 55 | fetchCache 56 | .fetch(url, fetchOptions) 57 | .then(response => response.body()) 58 | .then(console.log) 59 | .catch(console.log); 60 | ``` 61 | 62 | ### Basic caching operations 63 | 64 | ```typescript 65 | // Add an external response promise and cache it for 10 seconds 66 | const response = fetch('https://api.example.com'); 67 | 68 | // Insert a response you got from somewhere else 69 | fetchCache.cache.set('http://example.com', response); 70 | 71 | // Set a custom TTL of 10 seconds for this specific response 72 | fetchCache.cache.set('http://example.com', response, { ttl: 10000 }); 73 | 74 | // gets the cached response without side effects 75 | fetchCache.cache.peek(url); 76 | 77 | // `true` if a response exists in the cache, `false` otherwise 78 | fetchCache.cache.has(url); 79 | 80 | // same as `peek`, but returns response with meta information 81 | fetchCache.cache.peekItem(url); 82 | 83 | // same as `get`, but returns response with meta information 84 | fetchCache.cache.getItem(url); 85 | 86 | // Let the cache collect garbage to save memory, for example in fixed time intervals 87 | fetchCache.cache.evictExpiredItems(); 88 | 89 | // removes a response from the cache 90 | fetchCache.cache.delete(url); 91 | 92 | // forgets all cached responses 93 | fetchCache.cache.clear(); 94 | ``` 95 | 96 | ### Vary TTLs depending on HTTP response code, headers, and more 97 | 98 | While the cache tries to [guess working TTLs for most use cases](./src/defaultTTL.ts), you might 99 | want to customize how long a response (or rejected promise) should stay in the cache before it 100 | makes a new request when you fetch the same URL again. 101 | 102 | For example, you could set the TTL to one second, no matter if a request succeeds or fails (please 103 | don't really do this, except you have a good reason): 104 | 105 | ```typescript 106 | const fetchCache = new FetchCache({ fetch, ttl: () => 1000 }); 107 | ``` 108 | 109 | …or configure varying TTLs for specific HTTP response status codes (better). You can customize TTLs depending on response content, HTTP statuses, and network errors. See [the default implementation](./src/defaultTTL.ts) for an example how to do this. Don't forget that requests can be aborted, in which case you might want to set the TTL to 0. 110 | 111 | ### Normalize URLs 112 | 113 | You can improve caching performance by letting the cache know if more than one URL points to the 114 | same server-side resource. For this, provide a `normalizeURL` function that builds a canonical URL 115 | from a given one. 116 | 117 | The cache will only hold one response per canonical URL then. This saves memory and network 118 | bandwidth. 119 | 120 | `normalize-url` is a helpful NPM package implementing real-world normalization rules like SSL 121 | enforcement and `www.` vs. non-`www.`-domain names. You can use it as normalization function: 122 | 123 | ```bash 124 | # Install the package with 125 | npm install normalize-url 126 | # or 127 | yarn add normalize-url 128 | ``` 129 | 130 | ```typescript 131 | import normalizeURL from 'normalize-url'; 132 | import fetch from 'node-fetch'; 133 | 134 | // See https://github.com/sindresorhus/normalize-url#readme for all available normalization options 135 | const cache = new FetchCache({ 136 | fetch, 137 | normalizeURL(url) { 138 | return normalizeURL(url, { forceHttps: true }); 139 | }, 140 | }); 141 | ``` 142 | 143 | ## Contributors 144 | 145 | - [@dakeyras7](https://github.com/dakeyras7) 146 | - [@lennerd](https://github.com/lennerd) 147 | - [@mutaphysis](https://github.com/mutaphysis) 148 | - [@opyh](https://github.com/opyh) 149 | 150 | Supported by 151 | 152 | . 153 | -------------------------------------------------------------------------------- /doc/sozialhelden-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /jest.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "transform": { 3 | ".(ts|tsx)": "/node_modules/ts-jest/preprocessor.js" 4 | }, 5 | "testRegex": "(/__tests__/.*|\\.(test|spec))\\.(ts|tsx|js)$", 6 | "moduleFileExtensions": ["ts", "tsx", "js"], 7 | "coveragePathIgnorePatterns": ["/node_modules/", "/test/"], 8 | "coverageThreshold": { 9 | "global": { 10 | "branches": 90, 11 | "functions": 95, 12 | "lines": 95, 13 | "statements": 95 14 | } 15 | }, 16 | "collectCoverage": true, 17 | "mapCoverage": true 18 | } 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sozialhelden/fetch-cache", 3 | "private": false, 4 | "version": "1.1.0", 5 | "license": "MIT", 6 | "main": "dist/index.js", 7 | "module": "dist/index.mjs", 8 | "author": "Sebastian Felix Zappe ", 9 | "repository": "sozialhelden/fetch-cache", 10 | "files": [ 11 | "dist" 12 | ], 13 | "types": "dist/index.d.ts", 14 | "bundleDependencies": [], 15 | "deprecated": false, 16 | "description": "A cached WhatWG fetch with URLs as cache keys, featuring eviction based on LRU, maximal item count and/or TTL.", 17 | "devDependencies": { 18 | "@babel/parser": "^7.5.5", 19 | "@babel/plugin-proposal-class-properties": "^7.5.5", 20 | "@babel/plugin-transform-runtime": "^7.5.5", 21 | "@babel/polyfill": "^7.4.4", 22 | "@babel/preset-env": "^7.5.5", 23 | "@babel/preset-typescript": "^7.3.3", 24 | "@babel/runtime": "^7.5.5", 25 | "@babel/types": "^7.5.5", 26 | "@types/express": "^4.17.0", 27 | "@types/jest": "^24.0.15", 28 | "@types/node-fetch": "^2.5.0", 29 | "abort-controller": "^3.0.0", 30 | "babel-jest": "^24.8.0", 31 | "commitizen": "^4.0.3", 32 | "core-js": "^2.6.9", 33 | "coveralls": "^3.0.0", 34 | "cross-env": "^5.1.1", 35 | "cz-conventional-changelog": "^2.1.0", 36 | "express": "^4.17.1", 37 | "jest": "^24.8.0", 38 | "jest-cli": "^24.8.0", 39 | "node-fetch": "^3.1.1", 40 | "normalize-url": "^4.3.0", 41 | "rimraf": "^2.6.1", 42 | "semantic-release": "^17.2.3", 43 | "ts-jest": "^24.0.2", 44 | "tslint": "^5.5.0", 45 | "tslint-config-airbnb": "^5.11.1", 46 | "tslint-config-prettier": "^1.6.0", 47 | "tslint-config-standard": "^7.0.0", 48 | "typescript": "^3.3.3333" 49 | }, 50 | "scripts": { 51 | "prebuild": "rimraf dist", 52 | "build-mjs": "tsc -d && mv dist/index.js dist/index.mjs", 53 | "build-cjs": "tsc -m commonjs", 54 | "build": "npm run build-mjs && npm run build-cjs && rm dist/*.spec.*", 55 | "start": "tsc -w", 56 | "test": "jest", 57 | "lint": "tslint tslint.json", 58 | "test:watch": "jest --watch", 59 | "test:prod": "npm run lint && npm run test -- --coverage --no-cache", 60 | "deploy-docs": "ts-node tools/gh-pages-publish", 61 | "report-coverage": "cat ./coverage/lcov.info | coveralls", 62 | "commit": "git-cz", 63 | "semantic-release": "semantic-release pre && npm publish && semantic-release post", 64 | "semantic-release-prepare": "ts-node tools/semantic-release-prepare", 65 | "format": "tslint --fix src/**/*.ts", 66 | "prettier": "npx prettier --fix src/**/*.ts test/**/*.ts --write --single-quote", 67 | "deploy": "npm run build && npm run test:prod && npm publish --access public" 68 | }, 69 | "dependencies": { 70 | "@sozialhelden/hamster-cache": "^1.0.0" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/FetchCache.spec.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable no-empty 2 | // tslint:disable-next-line: no-implicit-dependencies 3 | import AbortController from 'abort-controller'; 4 | // tslint:disable-next-line: no-implicit-dependencies 5 | import express from 'express'; 6 | import * as http from 'http'; 7 | // tslint:disable-next-line: no-implicit-dependencies 8 | import nodeFetch from 'node-fetch'; 9 | // tslint:disable-next-line: no-implicit-dependencies 10 | import normalizeURL from 'normalize-url'; 11 | import FetchCache from './FetchCache'; 12 | import { TTLFunction } from './types'; 13 | 14 | async function createMinimalServer(): Promise { 15 | const app = express(); 16 | // tslint:disable-next-line: variable-name 17 | app.get('/works', (_req, res: express.Response) => res.send('👍')); 18 | app.get('/timeout/:milliseconds', (req, res: express.Response) => () => { 19 | setTimeout(() => res.send('👍'), req.params.milliseconds); 20 | }); 21 | return new Promise(resolve => { 22 | const server: http.Server = app.listen({ host: '127.0.0.1', port: 0 }, () => resolve(server)); 23 | }); 24 | } 25 | 26 | describe('FetchCache', () => { 27 | let dateNowSpy: jest.SpyInstance; 28 | 29 | beforeEach(() => { 30 | // Lock Time 31 | dateNowSpy = jest.spyOn(Date, 'now'); 32 | }); 33 | 34 | afterEach(() => { 35 | // Unlock Time 36 | if (dateNowSpy) dateNowSpy.mockRestore(); 37 | }); 38 | 39 | afterEach(() => { 40 | jest.restoreAllMocks(); 41 | }); 42 | 43 | describe('Basics', () => { 44 | it('can be initialized', () => { 45 | const mockResponse = {}; 46 | const fetch = jest.fn().mockResolvedValue(mockResponse); 47 | // tslint:disable-next-line: no-unused-expression 48 | new FetchCache({ fetch }); 49 | }); 50 | 51 | it('resolves when `fetch` resolves', async () => { 52 | const mockResponse = {}; 53 | const fetch = jest.fn().mockResolvedValue(mockResponse); 54 | const cache = new FetchCache({ fetch }); 55 | const promise = cache.fetch('url'); 56 | expect(cache.cache.peek('url')).toMatchObject({ state: 'running' }); 57 | await expect(promise).resolves.toBe(mockResponse); 58 | expect(cache.cache.peek('url')).toMatchObject({ state: 'resolved' }); 59 | }); 60 | 61 | it('rejects when `fetch` rejects', async () => { 62 | const fetch = () => Promise.reject(new Error('error')); 63 | const cache = new FetchCache({ fetch }); 64 | try { 65 | const promise = cache.fetch('url'); 66 | expect(cache.cache.peek('url')).toMatchObject({ state: 'running' }); 67 | await promise; 68 | throw new Error('This should not be reached'); 69 | } catch (e) { 70 | expect(e).toMatchObject({ message: 'error' }); 71 | } 72 | expect(cache.cache.peek('url')).toMatchObject({ state: 'rejected' }); 73 | }); 74 | 75 | it('caches results from `fetch` when requesting the same URL', async () => { 76 | const fetch = jest 77 | .fn() 78 | .mockResolvedValueOnce('a') 79 | .mockResolvedValueOnce('b'); 80 | const cache = new FetchCache({ fetch }); 81 | await expect(cache.fetch('url')).resolves.toBe('a'); 82 | await expect(cache.fetch('url')).resolves.toBe('a'); 83 | await expect(cache.fetch('url')).resolves.toBe('a'); 84 | }); 85 | }); 86 | 87 | describe('TTL handling', () => { 88 | it('returns cached response, evicts response after TTL, and makes a second request', async () => { 89 | const fetch = jest 90 | .fn() 91 | .mockResolvedValueOnce('a') 92 | .mockResolvedValueOnce('b'); 93 | dateNowSpy.mockReturnValue(0); 94 | const cache = new FetchCache({ fetch, ttl: () => 10000 }); 95 | const internalCache = cache.cache; 96 | if (!internalCache) { 97 | throw new Error('Internal cache must be defined.'); 98 | } 99 | await expect(cache.fetch('url')).resolves.toBe('a'); 100 | expect(fetch).toHaveBeenCalledTimes(1); 101 | let cachedItem = internalCache.peek('url'); 102 | expect(cachedItem && cachedItem.promise).resolves.toBe('a'); 103 | dateNowSpy.mockReturnValue(9999); 104 | await expect(cache.fetch('url')).resolves.toBe('a'); 105 | cachedItem = internalCache.peek('url'); 106 | expect(cachedItem && cachedItem.promise).resolves.toBe('a'); 107 | expect(fetch).toHaveBeenCalledTimes(1); 108 | dateNowSpy.mockReturnValue(10000); 109 | expect(internalCache.getItem('url')).toBeUndefined(); 110 | await expect(cache.fetch('url')).resolves.toBe('b'); 111 | expect(fetch).toHaveBeenCalledTimes(2); 112 | cachedItem = internalCache.peek('url'); 113 | expect(cachedItem && cachedItem.state === 'resolved' && cachedItem.response).toBe('b'); 114 | }); 115 | 116 | it('evicts items after different TTLs depending on response status/error', async () => { 117 | let resolveWith200: ((value?: unknown) => void) | undefined; 118 | const response200 = { status: 200 }; 119 | let resolveWith404: ((value?: unknown) => void) | undefined; 120 | const response404 = { status: 404 }; 121 | let rejectWithError: ((value?: unknown) => void) | undefined; 122 | 123 | const fetch = jest.fn().mockImplementation((url: string) => { 124 | switch (url) { 125 | case '200': 126 | return new Promise(resolve => (resolveWith200 = resolve)); 127 | case '404': 128 | return new Promise(resolve => (resolveWith404 = resolve)); 129 | case 'error': 130 | // tslint:disable-next-line: variable-name 131 | return new Promise((_resolve, reject) => (rejectWithError = reject)); 132 | } 133 | throw new Error(`Unknown URL: ${url}`); 134 | }); 135 | 136 | const ttlFunction: TTLFunction = cachedValue => { 137 | switch (cachedValue.state) { 138 | case 'running': 139 | return 5000; 140 | case 'resolved': { 141 | if (!cachedValue.response) { 142 | throw new Error( 143 | 'Cached value was marked as resolved, but has no response - this should never happen.' 144 | ); 145 | } 146 | if (cachedValue.response && cachedValue.response.status === 200) return 6000; 147 | return 7000; 148 | } 149 | case 'rejected': 150 | return 10000; 151 | } 152 | throw new Error('This code should not be reached.'); 153 | }; 154 | 155 | dateNowSpy.mockReturnValue(0); 156 | const cache = new FetchCache({ fetch, ttl: ttlFunction }); 157 | 158 | const promise200 = cache.fetch('200'); 159 | expect(cache.cache.peekItem('200')).toMatchObject({ expireAfterTimestamp: 5000 }); 160 | if (!resolveWith200) { 161 | throw new Error( 162 | "resolveWith200 must be defined by now. If it's not defined, that means that fetch was not called correctly." 163 | ); 164 | } 165 | resolveWith200(response200); 166 | await expect(promise200).resolves.toBe(response200); 167 | expect(cache.cache.peekItem('200')).toMatchObject({ expireAfterTimestamp: 6000 }); 168 | 169 | const promise404 = cache.fetch('404'); 170 | expect(cache.cache.peekItem('404')).toMatchObject({ expireAfterTimestamp: 5000 }); 171 | if (!resolveWith404) { 172 | throw new Error( 173 | "resolveWith404 must be defined by now. If it's not defined, that means that fetch was not called correctly." 174 | ); 175 | } 176 | resolveWith404(response404); 177 | await expect(promise404).resolves.toMatchObject(response404); 178 | expect(cache.cache.peekItem('404')).toMatchObject({ expireAfterTimestamp: 7000 }); 179 | 180 | const promiseWithError = cache.fetch('error'); 181 | expect(cache.cache.peekItem('error')).toMatchObject({ expireAfterTimestamp: 5000 }); 182 | if (!rejectWithError) { 183 | throw new Error( 184 | "rejectWithError must be defined by now. If it's not defined, that means that fetch was not called correctly." 185 | ); 186 | } 187 | rejectWithError('A timeout, for example!'); 188 | await expect(promiseWithError).rejects.toBe('A timeout, for example!'); 189 | 190 | expect(cache.cache.peekItem('error')).toMatchObject({ expireAfterTimestamp: 10000 }); 191 | }); 192 | }); 193 | 194 | describe('compatibility with 3rd party implementations', () => { 195 | describe('with `node-fetch` NPM package', () => { 196 | it('fetches from a local HTTP test server', async () => { 197 | // tslint:disable-next-line: no-implicit-dependencies 198 | const app = await createMinimalServer(); 199 | const cache = new FetchCache({ fetch: nodeFetch }); 200 | const address = app.address(); 201 | const url = 202 | address && 203 | typeof address === 'object' && 204 | `http://[${address.address}]:${address.port}/works`; 205 | const promise = url && cache.fetch(url).then(r => r.text()); 206 | await expect(promise).resolves.toBe('👍'); 207 | app.close(); 208 | }); 209 | 210 | it('works when aborting using AbortController', async () => { 211 | const app = await createMinimalServer(); 212 | const cache = new FetchCache({ fetch: nodeFetch }); 213 | const address = app.address(); 214 | const url = 215 | address && 216 | typeof address === 'object' && 217 | `http://[${address.address}]:${address.port}/timeout/10000`; 218 | const abortController = new AbortController(); 219 | const { signal } = abortController; 220 | dateNowSpy.mockReturnValue(0); 221 | const promise = url && cache.fetch(url, { signal }); 222 | dateNowSpy.mockReturnValue(1000); 223 | abortController.abort(); 224 | await expect(promise).rejects.toMatchObject({ message: 'The user aborted a request.' }); 225 | // With the default TTL implementation, we expect that the response is marked for eviction. 226 | expect(url && cache.cache.peekItem(url)).toMatchObject({ expireAfterTimestamp: 1000 }); 227 | app.close(); 228 | }); 229 | }); 230 | 231 | describe('with `normalize-url` package', () => { 232 | it('returns the cached response when requesting variants of the same normalized URL', async () => { 233 | const urlNormalized = 'http://xn--xample-hva.com/?a=foo&b=bar'; 234 | const urlVariant1 = 'http://êxample.com/?a=foo&b=bar'; 235 | const urlVariant2 = 'HTTP://xn--xample-hva.com:80/?b=bar&a=foo'; 236 | 237 | const fetch = jest.fn().mockImplementation(url => { 238 | switch (url) { 239 | case urlNormalized: 240 | return Promise.resolve('👌🏽'); 241 | case urlVariant1: 242 | case urlVariant2: 243 | // If this is returned, the requested URL was used to look the response up and the 244 | // URL was not normalized correctly. 245 | return Promise.resolve('🐞'); 246 | default: 247 | // If this is returned, something else went wrong. 248 | return Promise.reject( 249 | new Error( 250 | `This function should be called with the normalized URL, but was called with ${url}.` 251 | ) 252 | ); 253 | } 254 | }); 255 | const cache = new FetchCache({ fetch, normalizeURL }); 256 | await expect(cache.fetch('http://êxample.com/?a=foo&b=bar')).resolves.toBe('👌🏽'); 257 | await expect(cache.fetch('HTTP://xn--xample-hva.com:80/?b=bar&a=foo')).resolves.toBe('👌🏽'); 258 | }); 259 | }); 260 | }); 261 | }); 262 | -------------------------------------------------------------------------------- /src/FetchCache.ts: -------------------------------------------------------------------------------- 1 | import hamsterCache from '@sozialhelden/hamster-cache'; 2 | import defaultTTL from './defaultTTL'; 3 | import { CachedValue, Config, IMinimalResponse, Options } from './types'; 4 | 5 | interface IHasFetchMethodWithSameReturnTypeAs any> { 6 | fetch: (...args: any[]) => ReturnType; 7 | } 8 | 9 | /** 10 | * A HTTP cache for WhatWG fetch. 11 | */ 12 | export default class FetchCache< 13 | RequestInitT extends {}, 14 | ResponseT extends IMinimalResponse, 15 | FetchT extends (url: string, init?: RequestInitT) => Promise 16 | > implements IHasFetchMethodWithSameReturnTypeAs { 17 | public readonly options: Config; 18 | public readonly cache: hamsterCache>; 19 | 20 | constructor({ 21 | cacheOptions = {}, 22 | fetch, 23 | normalizeURL = url => url, 24 | ttl = defaultTTL, 25 | }: Options) { 26 | this.cache = new hamsterCache(cacheOptions); 27 | this.options = Object.freeze({ cacheOptions, fetch, normalizeURL, ttl }); 28 | } 29 | 30 | public fetch(input: string, init?: RequestInitT, dispose?: () => void): ReturnType { 31 | const normalizedURL = this.options.normalizeURL(input); 32 | const existingItem = this.cache.getItem(normalizedURL); 33 | if (existingItem) { 34 | return existingItem.value.promise as ReturnType; 35 | } 36 | return this.createFetchCacheItem(normalizedURL, init, dispose); 37 | } 38 | 39 | private createFetchCacheItem( 40 | url: string, 41 | init?: RequestInitT, 42 | dispose?: () => void 43 | ): ReturnType { 44 | const cache = this.cache; 45 | const options = this.options; 46 | const promise = this.options 47 | .fetch(url, init) 48 | .then(response => { 49 | Object.assign(value, { response, state: 'resolved' }); 50 | const ttl = options.ttl(value); 51 | cache.setTTL(url, ttl === undefined ? defaultTTL(value) : ttl); 52 | return response; 53 | }) 54 | .catch(error => { 55 | Object.assign(value, { error, state: 'rejected' }); 56 | const ttl = options.ttl(value); 57 | cache.setTTL(url, ttl === undefined ? defaultTTL(value) : ttl); 58 | throw error; 59 | }) as ReturnType; 60 | const value: CachedValue = { 61 | promise, 62 | state: 'running', 63 | }; 64 | this.cache.set(url, value, { dispose, ttl: options.ttl(value) }); 65 | return promise; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/defaultTTL.spec.ts: -------------------------------------------------------------------------------- 1 | import defaultTTL from './defaultTTL'; 2 | 3 | describe('defaultTTL', () => { 4 | it('returns 0 when aborting a request', () => { 5 | const error = { name: 'AbortError' }; 6 | 7 | const ttl = defaultTTL({ 8 | error, 9 | promise: Promise.reject(error), 10 | state: 'rejected', 11 | }); 12 | 13 | expect(ttl).toBe(0); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/defaultTTL.ts: -------------------------------------------------------------------------------- 1 | import { CachedValue, IMinimalResponse } from './types'; 2 | 3 | export default function defaultTTL( 4 | cachedValue: CachedValue 5 | ) { 6 | switch (cachedValue.state) { 7 | case 'running': 8 | // Evict running promises after 30s if they are not resolved to allow re-requesting. 9 | // This leaves it up to the fetch implementation to clean up resources if requests are not 10 | // aborted and the same URL is requested multiple times. 11 | return 30000; 12 | 13 | case 'resolved': 14 | const { response } = cachedValue; 15 | // Keep successful or 'resource missing' responses in the cache for 120 minutes 16 | if (response && (response.status === 200 || response.status === 404)) { 17 | return 120 * 60 * 1000; 18 | } 19 | // Allow retrying all other responses after 10 seconds 20 | return 10000; 21 | 22 | case 'rejected': 23 | const { error } = cachedValue; 24 | if (typeof error.name !== 'undefined' && error.name === 'AbortError') { 25 | return 0; 26 | } 27 | // Allow reattempting failed requests after 10 seconds 28 | return 10000; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import FetchCache from './FetchCache'; 2 | 3 | export default FetchCache; 4 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { IOptions as ICacheOptions } from '@sozialhelden/hamster-cache'; 2 | 3 | type State = 'running' | 'resolved' | 'rejected'; 4 | 5 | export interface IMinimalResponse { 6 | status: number; 7 | } 8 | 9 | export interface ICachedValueWithState { 10 | state: State; 11 | } 12 | export type CachedValue = 13 | | ICachedValueWithState & { 14 | promise: Promise; 15 | state: 'running'; 16 | } 17 | | { 18 | promise: Promise; 19 | response?: ResponseT; 20 | state: 'resolved'; 21 | } 22 | | { 23 | error?: any; 24 | promise: Promise; 25 | state: 'rejected'; 26 | }; 27 | 28 | export type TTLFunction = ( 29 | cachedValue: CachedValue 30 | ) => number; 31 | 32 | export interface IMandatoryOptions { 33 | fetch: FetchT; 34 | } 35 | 36 | export interface IOptionalOptions { 37 | cacheOptions: Partial>>; 38 | ttl: TTLFunction; 39 | normalizeURL: (url: string) => string; 40 | } 41 | 42 | /** 43 | * Describes fully configured caching behavior. All fields are mandatory. 44 | */ 45 | export type Config = Readonly< 46 | IMandatoryOptions & IOptionalOptions 47 | >; 48 | 49 | /** 50 | * Describes 51 | */ 52 | export type Options = IMandatoryOptions & 53 | Partial>; 54 | -------------------------------------------------------------------------------- /tests/unit/spec-bundle.js: -------------------------------------------------------------------------------- 1 | Error.stackTraceLimit = Infinity; 2 | 3 | var testContext = require.context('./../../src', true, /\.spec\.ts/); 4 | 5 | function requireAll(requireContext) { 6 | return requireContext.keys().map(requireContext); 7 | } 8 | 9 | var modules = requireAll(testContext); -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2018", 4 | "module": "es2015", 5 | "declaration": true, 6 | "sourceMap": true, 7 | "downlevelIteration": true, 8 | "experimentalDecorators": true, 9 | "sourceRoot": "src", 10 | "moduleResolution": "node", 11 | "outDir": "dist", 12 | "lib": ["es2017", "dom"], 13 | "noImplicitAny": true, 14 | "noImplicitReturns": true, 15 | "noImplicitThis": true, 16 | "noUnusedLocals": true, 17 | "noUnusedParameters": true, 18 | "strictFunctionTypes": true, 19 | "alwaysStrict": true, 20 | "forceConsistentCasingInFileNames": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "removeComments": true, 23 | "strictNullChecks": true, 24 | "allowSyntheticDefaultImports": true 25 | }, 26 | "exclude": ["node_modules", "dist"] 27 | } 28 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint:latest", "tslint-config-airbnb", "tslint-config-prettier"], 3 | "linterOptions": { 4 | "exclude": ["*.json", "**/*.json"] 5 | }, 6 | "rules": { 7 | "import-name": [0] 8 | } 9 | } 10 | --------------------------------------------------------------------------------