├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── custom.md │ └── feature_request.md └── workflows │ └── tests.yaml ├── .gitignore ├── .npmrc ├── .travis.yml ├── LICENSE ├── README.md ├── demos ├── basic.js ├── memory-store.js └── redis-store.js ├── index.js ├── logo.svg ├── package.json ├── test ├── get-keys.test.js └── smoke.test.js └── utils.js /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | custom: https://www.paypal.me/kyberneees 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/custom.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Custom issue template 3 | about: Describe this issue template's purpose here. 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Have you contributed with at least a little donation to support the development of this project?** 11 | - Paypal: https://www.paypal.me/kyberneees 12 | - [TRON](https://www.binance.com/en/buy-TRON) Wallet: `TJ5Bbf9v4kpptnRsePXYDvnYcYrS5Tyxus` 13 | 14 | **Is your feature request related to a problem? Please describe.** 15 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 16 | 17 | **Describe the solution you'd like** 18 | A clear and concise description of what you want to happen. 19 | 20 | **Describe alternatives you've considered** 21 | A clear and concise description of any alternative solutions or features you've considered. 22 | 23 | **Additional context** 24 | Add any other context or screenshots about the feature request here. 25 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: tests 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | testing: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v1 9 | - name: Setup Environment (Using NodeJS 16.x) 10 | uses: actions/setup-node@v1 11 | with: 12 | node-version: 16.x 13 | 14 | - name: Install dependencies 15 | run: npm install 16 | 17 | - name: Linting 18 | run: npx standard 19 | 20 | - name: Run tests 21 | run: npm run test -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | 2 | language: node_js 3 | node_js: 4 | - "10" 5 | - "12" 6 | - "14" 7 | 8 | script: 9 | - npx standard 10 | - npm run test -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Rolando Santamaria Maso 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # http-cache-middleware 2 | [![NPM version](https://badgen.net/npm/v/http-cache-middleware)](https://www.npmjs.com/package/http-cache-middleware) 3 | [![NPM Total Downloads](https://badgen.net/npm/dt/http-cache-middleware)](https://www.npmjs.com/package/http-cache-middleware) 4 | [![License](https://badgen.net/npm/license/http-cache-middleware)](https://www.npmjs.com/package/http-cache-middleware) 5 | [![TypeScript support](https://badgen.net/npm/types/http-cache-middleware)](https://www.npmjs.com/package/http-cache-middleware) 6 | [![Github stars](https://badgen.net/github/stars/jkyberneees/http-cache-middleware?icon=github)](https://github.com/jkyberneees/http-cache-middleware) 7 | 8 | 9 | 10 | High performance connect-like HTTP cache middleware for Node.js. So your latency can decrease to single digit milliseconds 🚀 11 | 12 | > Uses `cache-manager` as caching layer, so multiple 13 | storage engines are supported, i.e: Memory, Redis, ... https://www.npmjs.com/package/cache-manager 14 | 15 | ## Install 16 | ```js 17 | npm i http-cache-middleware 18 | ``` 19 | 20 | ## Usage 21 | ```js 22 | const middleware = require('http-cache-middleware')() 23 | const service = require('restana')() 24 | service.use(middleware) 25 | 26 | service.get('/cache-on-get', (req, res) => { 27 | setTimeout(() => { 28 | // keep response in cache for 1 minute if not expired before 29 | res.setHeader('x-cache-timeout', '1 minute') 30 | res.send('this supposed to be a cacheable response') 31 | }, 50) 32 | }) 33 | 34 | service.delete('/cache', (req, res) => { 35 | // ... the logic here changes the cache state 36 | 37 | // expire the cache keys using pattern 38 | res.setHeader('x-cache-expire', '*/cache-on-get') 39 | res.end() 40 | }) 41 | 42 | service.start(3000) 43 | ``` 44 | ## Redis cache 45 | ```js 46 | // redis setup 47 | const CacheManager = require('cache-manager') 48 | const redisStore = require('cache-manager-ioredis') 49 | const redisCache = CacheManager.caching({ 50 | store: redisStore, 51 | db: 0, 52 | host: 'localhost', 53 | port: 6379, 54 | ttl: 30 55 | }) 56 | 57 | // middleware instance 58 | const middleware = require('http-cache-middleware')({ 59 | stores: [redisCache] 60 | }) 61 | ``` 62 | 63 | ## Why cache? 64 | > Because caching is the last mile for low latency distributed systems! 65 | 66 | Enabling proper caching strategies will drastically reduce the latency of your system, as it reduces network round-trips, database calls and CPU processing. 67 | For our services, we are talking here about improvements in response times from `X ms` to `~2ms`, as an example. 68 | 69 | ### Enabling cache for service endpoints 70 | Enabling a response to be cached just requires the 71 | `x-cache-timeout` header to be set: 72 | ```js 73 | res.setHeader('x-cache-timeout', '1 hour') 74 | ``` 75 | > Here we use the [`ms`](`https://www.npmjs.com/package/ms`) package to convert timeout to seconds. Please note that `millisecond` unit is not supported! 76 | 77 | Example on service using `restana`: 78 | ```js 79 | service.get('/numbers', (req, res) => { 80 | res.setHeader('x-cache-timeout', '1 hour') 81 | 82 | res.send([ 83 | 1, 2, 3 84 | ]) 85 | }) 86 | ``` 87 | 88 | ### Caching on the browser side (304 status codes) 89 | > From version `1.2.x` you can also use the HTTP compatible `Cache-Control` header: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control 90 | When using the Cache-Control header, you can omit the custom `x-cache-timeout` header as the timeout can be passed using the `max-age` directive. 91 | 92 | #### Direct usage: 93 | ```js 94 | res.setHeader('cache-control', 'private, no-cache, max-age=300') 95 | res.setHeader('etag', 'cvbonrw6g00') 96 | 97 | res.end('5 minutes cacheable content here....') 98 | ``` 99 | 100 | #### Indirect usage: 101 | When using: 102 | ```js 103 | res.setHeader('x-cache-timeout', '5 minutes') 104 | ``` 105 | The middleware will now transparently generate default `Cache-Control` and `ETag` headers as described below: 106 | ```js 107 | res.setHeader('cache-control', 'private, no-cache, max-age=300') 108 | res.setHeader('etag', 'ao8onrw6gbt') // random ETag value 109 | ``` 110 | This will enable browser clients to keep a copy of the cache on their side, but still being forced to validate 111 | the cache state on the server before using the cached response, therefore supporting gateway based cache invalidation. 112 | 113 | > NOTE: In order to fetch the generated `Cache-Control` and `ETag` headers, there have to be at least one cache hit. 114 | 115 | ### Invalidating caches 116 | Services can easily expire cache entries on demand, i.e: when the data state changes. Here we use the `x-cache-expire` header to indicate the cache entries to expire using a matching pattern: 117 | ```js 118 | res.setHeader('x-cache-expire', '*/numbers') 119 | ``` 120 | > Here we use the [`matcher`](`https://www.npmjs.com/package/matcher`) package for matching patterns evaluation. 121 | 122 | Example on service using `restana`: 123 | ```js 124 | service.patch('/numbers', (req, res) => { 125 | // ... 126 | 127 | res.setHeader('x-cache-expire', '*/numbers') 128 | res.send(200) 129 | }) 130 | ``` 131 | 132 | #### Invalidating multiple patterns 133 | Sometimes is required to expire cache entries using multiple patterns, that is also possible using the `,` separator: 134 | ```js 135 | res.setHeader('x-cache-expire', '*/pattern1,*/pattern2') 136 | ``` 137 | 138 | #### Direclty invalidating caches from stores 139 | ```js 140 | const stores = [redisCache] 141 | const middleware = require('http-cache-middleware')({ 142 | stores 143 | }) 144 | 145 | const { deleteKeys } = require('http-cache-middleware/utils') 146 | deleteKeys(stores, '*/pattern1,*/pattern2') 147 | ``` 148 | 149 | ### Custom cache keys 150 | Cache keys are generated using: `req.method + req.url`, however, for indexing/segmenting requirements it makes sense to allow cache keys extensions. 151 | 152 | To accomplish this, we simply recommend using middlewares to extend the keys before caching checks happen: 153 | ```js 154 | service.use((req, res, next) => { 155 | req.cacheAppendKey = (req) => req.user.id // here cache key will be: req.method + req.url + req.user.id 156 | return next() 157 | }) 158 | ``` 159 | > In this example we also distinguish cache entries by `user.id`, commonly used for authorization reasons. 160 | 161 | In case full control of the `cache-key` value is preferred, just populate the `req.cacheKey` property with a `string` value. In this case, the req.method + req.url prefix is discarded: 162 | ```js 163 | service.use((req, res, next) => { 164 | req.cacheKey = 'CUSTOM-CACHE-KEY' 165 | return next() 166 | }) 167 | ``` 168 | 169 | ### Disable cache for custom endpoints 170 | You can also disable cache checks for certain requests programmatically: 171 | ```js 172 | service.use((req, res, next) => { 173 | req.cacheDisabled = true 174 | return next() 175 | }) 176 | ``` 177 | 178 | ## Want to contribute? 179 | This is your repo ;) 180 | 181 | > Note: We aim to be 100% code coverage, please consider it on your pull requests. 182 | 183 | ## Related projects 184 | - fast-gateway (https://www.npmjs.com/package/fast-gateway) 185 | 186 | ## Sponsors 187 | - (INACTIVE) Kindly sponsored by [ShareNow](https://www.share-now.com/), a company that promotes innovation! 188 | 189 | ## Support / Donate 💚 190 | You can support the maintenance of this project: 191 | - Paypal: https://www.paypal.me/kyberneees 192 | - [TRON](https://www.binance.com/en/buy-TRON) Wallet: `TJ5Bbf9v4kpptnRsePXYDvnYcYrS5Tyxus` -------------------------------------------------------------------------------- /demos/basic.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const middleware = require('./../index')() 4 | const service = require('restana')() 5 | service.use(middleware) 6 | 7 | service.get('/cache-on-get', (req, res) => { 8 | setTimeout(() => { 9 | // keep response in cache for 1 minute if not expired before 10 | res.setHeader('x-cache-timeout', '1 minute') 11 | res.send('this supposed to be a cacheable response') 12 | }, 50) 13 | }) 14 | 15 | service.get('/cache-control', (req, res) => { 16 | setTimeout(() => { 17 | // keep response in cache for 1 minute if not expired before 18 | res.setHeader('cache-control', 'private, no-cache, max-age=60') 19 | res.send('this supposed to be a cacheable response') 20 | }, 50) 21 | }) 22 | 23 | service.delete('/cache', (req, res) => { 24 | // ... the logic here changes the cache state 25 | 26 | // expire the cache keys using pattern 27 | res.setHeader('x-cache-expire', '*/cache-*') 28 | res.end() 29 | }) 30 | 31 | service.start(3000) 32 | -------------------------------------------------------------------------------- /demos/memory-store.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const CacheManager = require('cache-manager') 4 | const middleware = require('./../index')({ 5 | stores: [CacheManager.caching({ store: 'memory', max: 2000, ttl: 30 })] 6 | }) 7 | const service = require('restana')() 8 | service.use(middleware) 9 | // ... 10 | 11 | service.start(3000) 12 | -------------------------------------------------------------------------------- /demos/redis-store.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const CacheManager = require('cache-manager') 4 | const redisStore = require('cache-manager-ioredis') 5 | const redisCache = CacheManager.caching({ 6 | store: redisStore, 7 | db: 0, 8 | host: 'localhost', 9 | port: 6379, 10 | ttl: 30 11 | }) 12 | const middleware = require('../index')({ 13 | stores: [redisCache] 14 | }) 15 | 16 | const service = require('restana')() 17 | service.use(middleware) 18 | 19 | service.get('/cache', (req, res) => { 20 | setTimeout(() => { 21 | res.setHeader('x-cache-timeout', '1 minute') 22 | res.send('this supposed to be a cacheable response') 23 | }, 50) 24 | }) 25 | 26 | service.delete('/cache', (req, res) => { 27 | res.setHeader('x-cache-expire', '*/cache-*') 28 | res.end() 29 | }) 30 | 31 | service.start(3000) 32 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const CacheManager = require('cache-manager') 4 | const iu = require('middleware-if-unless')() 5 | const { parse: cacheControl } = require('@tusbar/cache-control') 6 | const ms = require('ms') 7 | const onEnd = require('on-http-end') 8 | const { get, deleteKeys, DATA_POSTFIX } = require('./utils') 9 | 10 | const X_CACHE_EXPIRE = 'x-cache-expire' 11 | const X_CACHE_TIMEOUT = 'x-cache-timeout' 12 | const X_CACHE_HIT = 'x-cache-hit' 13 | const CACHE_ETAG = 'etag' 14 | const CACHE_CONTROL = 'cache-control' 15 | const CACHE_IF_NONE_MATCH = 'if-none-match' 16 | 17 | const middleware = (opts) => { 18 | opts = Object.assign({ 19 | stores: [CacheManager.caching({ store: 'memory', max: 1000, ttl: 30 })] 20 | }, opts) 21 | const mcache = CacheManager.multiCaching(opts.stores) 22 | 23 | return iu(async (req, res, next) => { 24 | try { 25 | if (req.cacheDisabled) return next() 26 | 27 | if (typeof req.cacheKey !== 'string') { 28 | let { url, cacheAppendKey = req => '' } = req 29 | cacheAppendKey = await cacheAppendKey(req) 30 | 31 | const key = req.method + url + cacheAppendKey 32 | // ref cache key on req object 33 | req.cacheKey = key 34 | } 35 | 36 | // try to retrieve cached response metadata 37 | const metadata = await get(mcache, req.cacheKey) 38 | 39 | if (metadata) { 40 | // respond from cache if there is a hit 41 | const { status, headers, encoding } = JSON.parse(metadata) 42 | 43 | // pre-checking If-None-Match header 44 | if (req.headers[CACHE_IF_NONE_MATCH] && req.headers[CACHE_IF_NONE_MATCH] === headers[CACHE_ETAG]) { 45 | res.setHeader('content-length', '0') 46 | res.statusCode = 304 47 | res.end() 48 | 49 | return 50 | } else { 51 | // try to retrieve cached response data 52 | const payload = await get(mcache, req.cacheKey + DATA_POSTFIX) 53 | if (payload) { 54 | let { data } = JSON.parse(payload) 55 | if (typeof data === 'object' && data.type === 'Buffer') { 56 | data = Buffer.from(data.data) 57 | } 58 | headers[X_CACHE_HIT] = '1' 59 | 60 | // set cached response headers 61 | Object.keys(headers).forEach(header => res.setHeader(header, headers[header])) 62 | 63 | // send cached payload 64 | req.cacheHit = true 65 | res.statusCode = status 66 | res.end(data, encoding) 67 | 68 | return 69 | } 70 | } 71 | } 72 | 73 | onEnd(res, async (payload) => { 74 | if (payload.status === 304) return 75 | 76 | if (payload.headers[X_CACHE_EXPIRE]) { 77 | // support service level expiration 78 | const keysPattern = payload.headers[X_CACHE_EXPIRE].replace(/\s/g, '') 79 | const patterns = keysPattern.split(',') 80 | // delete keys on all cache tiers 81 | deleteKeys(opts.stores, patterns) 82 | } else if (payload.headers[X_CACHE_TIMEOUT] || payload.headers[CACHE_CONTROL]) { 83 | // extract cache ttl 84 | let ttl = 0 85 | if (payload.headers[CACHE_CONTROL]) { 86 | ttl = cacheControl(payload.headers[CACHE_CONTROL]).maxAge 87 | } 88 | if (!ttl) { 89 | if (payload.headers[X_CACHE_TIMEOUT]) { 90 | ttl = Math.max(ms(payload.headers[X_CACHE_TIMEOUT]), 1000) / 1000 // min value: 1 second 91 | } else { 92 | return // no TTL found, we don't cache 93 | } 94 | } 95 | 96 | // setting cache-control header if absent 97 | if (!payload.headers[CACHE_CONTROL]) { 98 | payload.headers[CACHE_CONTROL] = `private, no-cache, max-age=${ttl}` 99 | } 100 | // setting ETag if absent 101 | if (!payload.headers[CACHE_ETAG]) { 102 | payload.headers[CACHE_ETAG] = Math.random().toString(36).substring(2, 16) 103 | } 104 | 105 | // cache response data 106 | await mcache.set(req.cacheKey + DATA_POSTFIX, JSON.stringify({ data: payload.data }), { ttl }) 107 | delete payload.data 108 | // cache response metadata 109 | await mcache.set(req.cacheKey, JSON.stringify(payload), { ttl }) 110 | } 111 | }) 112 | 113 | return next() 114 | } catch (err) { 115 | return next(err) 116 | } 117 | }) 118 | } 119 | 120 | module.exports = middleware 121 | module.exports.deleteKeys = deleteKeys 122 | -------------------------------------------------------------------------------- /logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "http-cache-middleware", 3 | "version": "1.4.1", 4 | "description": "HTTP Cache Middleware", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "nyc mocha test/*.test.js --exit" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/jkyberneees/http-cache-middleware.git" 12 | }, 13 | "keywords": [ 14 | "http", 15 | "cache", 16 | "middleware" 17 | ], 18 | "files": [ 19 | "index.js", 20 | "utils.js", 21 | "README.md", 22 | "LICENSE" 23 | ], 24 | "author": "Rolando Santamaria Maso ", 25 | "license": "MIT", 26 | "bugs": { 27 | "url": "https://github.com/jkyberneees/http-cache-middleware/issues" 28 | }, 29 | "homepage": "https://github.com/jkyberneees/http-cache-middleware#readme", 30 | "devDependencies": { 31 | "cache-manager-ioredis": "^2.1.0", 32 | "chai": "^4.3.6", 33 | "got": "^11.8.5", 34 | "mocha": "^10.0.0", 35 | "nyc": "^15.1.0", 36 | "restana": "^4.9.6" 37 | }, 38 | "dependencies": { 39 | "@tusbar/cache-control": "^0.6.1", 40 | "cache-manager": "^4.1.0", 41 | "matcher": "^4.0.0", 42 | "middleware-if-unless": "^1.3.0", 43 | "ms": "^2.1.3", 44 | "on-http-end": "^1.0.2" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /test/get-keys.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /* global describe, it */ 4 | 5 | const { getKeys } = require('./../utils') 6 | const expect = require('chai').expect 7 | 8 | describe('get-keys', () => { 9 | const pattern = '*/numbers' 10 | const redisCache = { 11 | store: { name: 'redis' }, 12 | keys: (pattern, callback) => { 13 | if (!pattern) throw new Error('pattern is missing') 14 | return callback(null, ['GET/numbers']) 15 | } 16 | } 17 | 18 | const memoryCache = { 19 | store: { name: 'memory' }, 20 | keys: (callback) => { 21 | return callback(null, ['GET/numbers']) 22 | } 23 | } 24 | 25 | it('should retrieve redis keys', function (done) { 26 | getKeys(redisCache, pattern).then(keys => { 27 | expect(keys.includes('GET/numbers')).to.equal(true) 28 | done() 29 | }) 30 | }) 31 | 32 | it('should skip pattern', function (done) { 33 | getKeys(redisCache, 'GET/numbers').then(keys => { 34 | expect(keys.includes('GET/numbers')).to.equal(true) 35 | done() 36 | }) 37 | }) 38 | 39 | it('should retrieve memory keys', function (done) { 40 | getKeys(memoryCache, pattern).then(keys => { 41 | expect(keys.includes('GET/numbers')).to.equal(true) 42 | done() 43 | }) 44 | }) 45 | }) 46 | -------------------------------------------------------------------------------- /test/smoke.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /* global describe, it */ 4 | const got = require('got') 5 | const expect = require('chai').expect 6 | 7 | describe('cache middleware', () => { 8 | const server = require('restana')() 9 | 10 | it('init', async () => { 11 | const middleware = require('./../index')() 12 | server.use((req, res, next) => { 13 | if (req.url === '/cache-disabled') { 14 | req.cacheDisabled = true 15 | } 16 | 17 | next() 18 | }) 19 | server.use((req, res, next) => { 20 | if (req.url === '/custom-cache-key') { 21 | req.cacheKey = 'my-custom-cache-key' 22 | } 23 | 24 | next() 25 | }) 26 | 27 | server.use(middleware) 28 | 29 | server.get('/health', (req, res) => { 30 | res.send() 31 | }) 32 | 33 | server.get('/cache-disabled', (req, res) => { 34 | setTimeout(() => { 35 | res.setHeader('x-cache-timeout', '1 minute') 36 | res.send('hello') 37 | }, 50) 38 | }) 39 | 40 | server.get('/custom-cache-key', (req, res) => { 41 | res.send(req.cacheKey) 42 | }) 43 | 44 | server.get('/cache', (req, res) => { 45 | setTimeout(() => { 46 | res.setHeader('x-cache-timeout', '1 minute') 47 | res.send('hello') 48 | }, 50) 49 | }) 50 | 51 | server.get('/cache-buffer', (req, res) => { 52 | setTimeout(() => { 53 | res.setHeader('x-cache-timeout', '1 minute') 54 | res.setHeader('etag', '1') 55 | res.setHeader('cache-control', 'no-cache') 56 | res.send(Buffer.from('world')) 57 | }, 50) 58 | }) 59 | 60 | server.get('/cache-no-maxage', (req, res) => { 61 | res.setHeader('etag', '1') 62 | res.setHeader('cache-control', 'no-cache') 63 | res.send('!maxage') 64 | }) 65 | 66 | server.get('/cache-control', (req, res) => { 67 | res.setHeader('cache-control', 'max-age=60') 68 | res.setHeader('etag', '1') 69 | res.send('cache') 70 | }) 71 | 72 | server.delete('/cache', (req, res) => { 73 | res.setHeader('x-cache-expire', '*/cache') 74 | res.end() 75 | }) 76 | 77 | server.delete('/cache2', (req, res) => { 78 | res.setHeader('x-cache-expire', '*/cache*') 79 | res.end() 80 | }) 81 | }) 82 | 83 | it('start', async () => { 84 | return server.start(3000) 85 | }) 86 | 87 | it('no-cache', async () => { 88 | const res = await got('http://localhost:3000/health') 89 | expect(res.headers['x-cache-hit']).to.equal(undefined) 90 | }) 91 | 92 | it('cache disabled', async () => { 93 | await got('http://localhost:3000/cache-disabled') 94 | const res = await got('http://localhost:3000/cache-disabled') 95 | expect(res.headers['x-cache-hit']).to.equal(undefined) 96 | }) 97 | 98 | it('custom cache key', async () => { 99 | const res = await got('http://localhost:3000/custom-cache-key') 100 | expect(res.body).to.equal('my-custom-cache-key') 101 | }) 102 | 103 | it('create cache', async () => { 104 | const res = await got('http://localhost:3000/cache') 105 | expect(res.body).to.equal('hello') 106 | expect(res.headers['x-cache-hit']).to.equal(undefined) 107 | }) 108 | 109 | it('cache hit', async () => { 110 | const res = await got('http://localhost:3000/cache') 111 | expect(res.body).to.equal('hello') 112 | expect(res.headers['x-cache-hit']).to.equal('1') 113 | }) 114 | 115 | it('cache expire', async () => { 116 | await got.delete('http://localhost:3000/cache') 117 | }) 118 | 119 | it('re-create cache', async () => { 120 | const res = await got('http://localhost:3000/cache') 121 | expect(res.body).to.equal('hello') 122 | expect(res.headers['x-cache-hit']).to.equal(undefined) 123 | }) 124 | 125 | it('cache expire using wildcard', async () => { 126 | await got.delete('http://localhost:3000/cache2') 127 | }) 128 | 129 | it('re-create cache', async () => { 130 | const res = await got('http://localhost:3000/cache') 131 | expect(res.body).to.equal('hello') 132 | expect(res.headers['x-cache-hit']).to.equal(undefined) 133 | }) 134 | 135 | it('create cache (buffer)', async () => { 136 | const res = await got('http://localhost:3000/cache-buffer') 137 | expect(res.body).to.equal('world') 138 | expect(res.headers['x-cache-hit']).to.equal(undefined) 139 | }) 140 | 141 | it('no TTL detected', async () => { 142 | await got('http://localhost:3000/cache-no-maxage') 143 | const res = await got('http://localhost:3000/cache-no-maxage') 144 | expect(res.body).to.equal('!maxage') 145 | expect(res.headers['x-cache-hit']).to.equal(undefined) 146 | }) 147 | 148 | it('cache hit (buffer)', async () => { 149 | const res = await got('http://localhost:3000/cache-buffer') 150 | expect(res.body).to.equal('world') 151 | expect(res.headers['x-cache-hit']).to.equal('1') 152 | expect(res.headers['cache-control']).to.equal('no-cache') 153 | expect(res.headers.etag).to.equal('1') 154 | }) 155 | 156 | it('cache hit (buffer) - Etag', async () => { 157 | const res = await got('http://localhost:3000/cache-buffer') 158 | expect(res.body).to.equal('world') 159 | expect(res.headers['x-cache-hit']).to.equal('1') 160 | expect(res.headers.etag).to.equal('1') 161 | }) 162 | 163 | it('cache hit (buffer) - If-None-Match', async () => { 164 | const res = await got('http://localhost:3000/cache-buffer', { 165 | headers: { 166 | 'If-None-Match': '1' 167 | } 168 | }) 169 | expect(res.statusCode).to.equal(304) 170 | }) 171 | 172 | it('cache create - cache-control', async () => { 173 | const res = await got('http://localhost:3000/cache-control') 174 | expect(res.body).to.equal('cache') 175 | }) 176 | 177 | it('cache hit - cache-control', async () => { 178 | const res = await got('http://localhost:3000/cache-control') 179 | expect(res.body).to.equal('cache') 180 | expect(res.headers['x-cache-hit']).to.equal('1') 181 | }) 182 | 183 | it('close', async () => { 184 | return server.close() 185 | }) 186 | }) 187 | -------------------------------------------------------------------------------- /utils.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const matcher = require('matcher') 4 | 5 | const DATA_POSTFIX = '-d' 6 | 7 | const getKeys = (cache, pattern) => new Promise((resolve) => { 8 | if (pattern.indexOf('*') > -1) { 9 | const args = [pattern, (_, res) => resolve(matcher(res, [pattern]))] 10 | if (cache.store.name !== 'redis') { 11 | args.shift() 12 | } 13 | 14 | cache.keys.apply(cache, args) 15 | } else resolve([pattern]) 16 | }) 17 | 18 | const get = (cache, key) => cache.getAndPassUp(key) 19 | 20 | const deleteKeys = (stores, patterns) => { 21 | patterns = patterns.map(pattern => 22 | pattern.endsWith('*') ? pattern : [pattern, pattern + DATA_POSTFIX] 23 | ).flat() 24 | 25 | patterns.forEach(pattern => stores.forEach(store => getKeys(store, pattern).then(keys => keys.length > 0 ? store.del(keys) : null))) 26 | } 27 | 28 | module.exports = { 29 | get, 30 | deleteKeys, 31 | getKeys, 32 | DATA_POSTFIX 33 | } 34 | --------------------------------------------------------------------------------