├── .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 |
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 |
--------------------------------------------------------------------------------