├── .eslintrc ├── .gitignore ├── CHANGELOG.md ├── test ├── package.json └── test.js ├── package.json ├── LICENSE.md ├── .github └── workflows │ └── main.yml ├── index.js └── README.md /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ "apostrophe" ], 3 | "rules": { 4 | "no-var": 2 5 | } 6 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore MacOS X metadata forks (fusefs) 2 | ._* 3 | package-lock.json 4 | *.DS_Store 5 | node_modules 6 | 7 | # Never commit a CSS map file, anywhere 8 | *.css.map 9 | 10 | # vim swp files 11 | .*.sw* 12 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.1.0 (2024-01-25) 4 | 5 | ### Fixes 6 | 7 | * Adds a deletion method to override core and allow deletion of login attempt cache 8 | 9 | ## 1.0.0 - 2023-01-16 10 | 11 | * Declared stable. No code changes. 12 | 13 | ## 1.0.0-beta - 2021-12-14 14 | 15 | * The initial beta release, including Redis client support for the standard Apostrophe cache API. 16 | -------------------------------------------------------------------------------- /test/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "-//": "This package.json file is not actually installed.", 3 | "/-/": "Apostrophe requires that all npm modules to be loaded by moog", 4 | "//-": "exist in package.json at project level, which for a test is here", 5 | "dependencies": { 6 | "apostrophe": "^3.9.0", 7 | "@apostrophecms/cache-redis": "git://github.com/apostrophecms/cache-redis.git" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@apostrophecms/cache-redis", 3 | "version": "1.1.0", 4 | "description": "Redis-based cache for the Apostrophe 3", 5 | "main": "index.js", 6 | "scripts": { 7 | "lint": "npm run eslint", 8 | "eslint": "eslint .", 9 | "test": "npm run lint && mocha" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/apostrophecms/cache-redis.git" 14 | }, 15 | "homepage": "https://github.com/apostrophecms/cache-redis#readme", 16 | "author": "Apostrophe Technologies", 17 | "license": "MIT", 18 | "devDependencies": { 19 | "apostrophe": "^3.9.0", 20 | "eslint": "^7.9.0", 21 | "eslint-config-apostrophe": "^3.4.0", 22 | "eslint-config-standard": "^14.1.1", 23 | "eslint-plugin-import": "^2.22.0", 24 | "eslint-plugin-node": "^11.1.0", 25 | "eslint-plugin-promise": "^4.2.1", 26 | "eslint-plugin-standard": "^4.0.1", 27 | "lodash.range": "^3.2.0", 28 | "mocha": "^8.1.3" 29 | }, 30 | "dependencies": { 31 | "redis": "^4.0.1" 32 | } 33 | } -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021 Apostrophe Technologies 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: tests 4 | 5 | # Controls when the action will run. 6 | on: 7 | push: 8 | branches: [ '*' ] 9 | pull_request: 10 | branches: [ '*' ] 11 | 12 | # Allows you to run this workflow manually from the Actions tab 13 | workflow_dispatch: 14 | 15 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 16 | jobs: 17 | # This workflow contains a single job called "build" 18 | build: 19 | # The type of runner that the job will run on 20 | runs-on: ubuntu-latest 21 | strategy: 22 | matrix: 23 | node-version: [18, 20] 24 | mongodb-version: [4.4, 5.0, 6.0] 25 | # Service containers to run with `runner-job` 26 | services: 27 | # Label used to access the service container 28 | redis: 29 | # Docker Hub image 30 | image: redis 31 | # Set health checks to wait until redis has started 32 | options: >- 33 | --health-cmd "redis-cli ping" 34 | --health-interval 10s 35 | --health-timeout 5s 36 | --health-retries 5 37 | ports: 38 | # Maps port 6379 on service container to the host 39 | - 6379:6379 40 | 41 | # Steps represent a sequence of tasks that will be executed as part of the job 42 | steps: 43 | - name: Git checkout 44 | uses: actions/checkout@v2 45 | 46 | - name: Use Node.js ${{ matrix.node-version }} 47 | uses: actions/setup-node@v1 48 | with: 49 | node-version: ${{ matrix.node-version }} 50 | - name: Update npm 51 | run: npm i -g npm 52 | if: ${{ matrix.node-version <= 14 }} 53 | - name: Start MongoDB 54 | uses: supercharge/mongodb-github-action@1.3.0 55 | with: 56 | mongodb-version: ${{ matrix.mongodb-version }} 57 | 58 | - run: npm install 59 | 60 | - run: npm test 61 | env: 62 | CI: true 63 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | const testUtil = require('apostrophe/test-lib/test'); 3 | const range = require('lodash.range'); 4 | 5 | describe('Apostrophe cache implementation in Redis', function() { 6 | let apos; 7 | 8 | this.timeout(10000); 9 | 10 | after(async () => { 11 | testUtil.destroy(apos); 12 | }); 13 | 14 | it('should be a property of the apos object', async () => { 15 | apos = await testUtil.create({ 16 | shortname: 'test-redis', 17 | testModule: true, 18 | modules: { 19 | '@apostrophecms/express': { 20 | options: { 21 | port: 4242, 22 | session: { secret: 'test-the-redis' } 23 | } 24 | }, 25 | '@apostrophecms/cache-redis': { 26 | options: { 27 | redisActive: true 28 | } 29 | } 30 | } 31 | }); 32 | 33 | assert(apos.modules['@apostrophecms/cache'].options.redisActive === true); 34 | }); 35 | 36 | it('initializes a redis client', async () => { 37 | assert(apos.cache.client); 38 | }); 39 | 40 | it('can store 2000 keys in Cache One', async function() { 41 | const values = range(0, 2000); 42 | const responses = []; 43 | for (const val of values) { 44 | const response = await apos.cache.set('cache-one', val, val); 45 | responses.push(response); 46 | } 47 | 48 | assert(responses.length === 2000); 49 | assert(!responses.find(r => r !== 'OK')); 50 | }); 51 | it('can store 2000 keys in Cache Two', async function() { 52 | const values = range(2000, 4000); 53 | const responses = []; 54 | for (const val of values) { 55 | const response = await apos.cache.set('cache-two', val, val); 56 | responses.push(response); 57 | } 58 | 59 | assert(responses.length === 2000); 60 | assert(!responses.find(r => r !== 'OK')); 61 | }); 62 | 63 | it('can retrieve key from cache 1', async function() { 64 | const val = await apos.cache.get('cache-one', 1000); 65 | assert(val === 1000); 66 | }); 67 | it('can retrieve key from cache 2', async function() { 68 | const val = await apos.cache.get('cache-two', 3000); 69 | 70 | assert(val === 3000); 71 | }); 72 | it('cannot retrieve Cache Two key from Cache One (namespacing)', async function() { 73 | const val = await apos.cache.get('cache-one', 3000); 74 | 75 | assert(val === undefined); 76 | }); 77 | 78 | it('can clear a cache', async function() { 79 | await apos.cache.clear('cache-one'); 80 | 81 | const val = await apos.cache.get('cache-one', 1000); 82 | assert(val === undefined); 83 | }); 84 | it('can fetch a key from an uncleared cache', async function() { 85 | const val = await apos.cache.get('cache-two', 3000); 86 | 87 | assert(val === 3000); 88 | }); 89 | // Timeout 90 | it('can store a key with a 1-second timeout', async function() { 91 | const response = await apos.cache.set('cache-one', 'timeout', 'timeout', 1); 92 | assert(response === 'OK'); 93 | }); 94 | 95 | it('can fetch that key within the 1-second timeout', async function() { 96 | const value = await apos.cache.get('cache-one', 'timeout'); 97 | assert(value === 'timeout'); 98 | }); 99 | it('cannot fetch that key after 2 seconds', async function() { 100 | this.timeout(5000); 101 | 102 | await pause(2000); 103 | 104 | const value = await apos.cache.get('cache-one', 'timeout'); 105 | assert(!value); 106 | }); 107 | }); 108 | 109 | async function pause (delay) { 110 | if (!delay) { 111 | return; 112 | } 113 | 114 | return new Promise((resolve) => setTimeout(resolve, delay)); 115 | } 116 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const { createClient } = require('redis'); 2 | 3 | module.exports = { 4 | improve: '@apostrophecms/cache', 5 | methods(self) { 6 | return { 7 | // Fully replace the `enableCollection` method from core. 8 | async enableCollection() { 9 | const redisOptions = self.options.redis || {}; 10 | 11 | if (!self.options.prefix) { 12 | if (self.options.prefix !== false) { 13 | // Distinguish sites 14 | self.options.prefix = self.apos.shortName + ':'; 15 | } 16 | } 17 | 18 | self.prefix = self.options.prefix || ''; 19 | 20 | self.client = createClient(redisOptions); 21 | 22 | self.client.on('error', (err) => { 23 | self.apos.util.error('Redis Client Error', err); 24 | }); 25 | 26 | // Redis 4 requires explicit connection. 27 | await self.client.connect(); 28 | }, 29 | getRedisKey(namespace, key) { 30 | return `${self.prefix}:${namespace}:${key}`; 31 | }, 32 | // Get the cached value associated with the specified key from the 33 | // specified namespace. Returns undefined if not found. Be sure to use 34 | // `await`. 35 | async get(namespace, key) { 36 | key = self.getRedisKey(namespace, key); 37 | 38 | const json = await self.client.get(key); 39 | 40 | if (!json) { 41 | return undefined; 42 | } 43 | 44 | let data; 45 | 46 | try { 47 | data = JSON.parse(json); 48 | } catch (error) { 49 | self.apos.util.error(error); 50 | // An error here is likely due to invalid JSON structure. 51 | return undefined; 52 | } 53 | 54 | return data; 55 | }, 56 | 57 | // Cache a value under with the given key in the given namespace. 58 | // `value` may be any JSON-friendly value, including an object. 59 | // `lifetime` is in seconds. If zero or unspecified, there is no 60 | // time limit, however you must always assume the cache could be 61 | // cleared at some point. It is not for primary storage. 62 | // 63 | // Be sure to use `await`. 64 | // 65 | // The data you store should be JSON-friendly. 66 | // You DO NOT have to stringify it yourself. 67 | set: async function (namespace, key, value, lifetime) { 68 | if (arguments.length === 3) { 69 | lifetime = 0; 70 | } 71 | 72 | key = self.getRedisKey(namespace, key); 73 | 74 | if (lifetime) { 75 | return self.client.setEx(key, lifetime, JSON.stringify(value)); 76 | } else { 77 | return self.client.set(key, JSON.stringify(value)); 78 | } 79 | }, 80 | 81 | // Clear the cache of all keys and values in the given namespace. 82 | // Be sure to use `await`. 83 | async clear(namespace) { 84 | // This is not as simple as it sounds: 85 | // https://stackoverflow.com/questions/4006324/how-to-atomically-delete-keys-matching-a-pattern-using-redis 86 | // 87 | // We're avoiding Lua because of comments in that article that it might 88 | // not play nice with Redis clustering. 89 | // 90 | // Use of `keys` is not deprecated as long as it's for a 91 | // special-purpose, occasional operation, and clearing an entire cache 92 | // qualifies. 93 | let keys = await self.client.keys(`${self.prefix}:${namespace}:*`); 94 | 95 | await removeNextBatch(); 96 | 97 | async function removeNextBatch() { 98 | if (!keys.length) { 99 | return; 100 | } 101 | 102 | await self.client.del(keys.slice(0, 1000)); 103 | 104 | keys = keys.slice(1000); 105 | 106 | return removeNextBatch(); 107 | } 108 | 109 | }, 110 | async delete(namespace, key) { 111 | key = self.getRedisKey(namespace, key); 112 | return self.client.del(key); 113 | } 114 | }; 115 | }, 116 | handlers(self) { 117 | return { 118 | 'apostrophe:destroy': { 119 | closeRedisConnection () { 120 | self.client.quit(); 121 | } 122 | } 123 | }; 124 | } 125 | }; 126 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
20 | 21 | This module enhances the core caching module, `@apostrophecms/cache`, to use [Redis](https://redis.io/) rather than MongoDB. This module does not set up the actual Redis store, but instead allows Apostrophe to access an existing Redis store through the standard Apostrophe caching API and an internal Redis client. 22 | 23 | All normal Apostrophe cache API features are maintained in addition to the Redis-specific features described below. 24 | 25 | ## Installation 26 | 27 | To install the module, use the command line to run this command in an Apostrophe project's root directory: 28 | 29 | ``` 30 | npm install @apostrophecms/cache-redis 31 | ``` 32 | 33 | ## Usage 34 | 35 | Configure the Redis cache module in the `app.js` file: 36 | 37 | ```javascript 38 | require('apostrophe')({ 39 | shortName: 'my-project', 40 | modules: { 41 | '@apostrophecms/cache-redis': {} 42 | } 43 | }); 44 | ``` 45 | 46 | ## Configuring Redis options 47 | 48 | **All options for this module should be applied to `@apostrophecms/cache` in project code.** This module simply "improves" that core module (updates its features). The main caching module still does all the work. 49 | 50 | Configure the underlying Redis client by adding a configuration object on the cache module's `redis` option. 51 | 52 | ```javascript 53 | // modules/@apostrophecms/cache/index.js 54 | module.exports = { 55 | options: { 56 | redis: { 57 | url: 'redis://alice:foobared@awesome.redis.server:6380' 58 | } 59 | } 60 | }; 61 | ``` 62 | 63 | By default, the client will look for a Redis server running on localhost port 6379. See [all the client configuration options](https://github.com/redis/node-redis/blob/master/docs/client-configuration.md) on the Node-Redis documentation. 64 | 65 | Note that this module uses the 4.x version of Node-Redis, which changed its configuration options from earlier major versions. 66 | 67 | ## Cache keys 68 | 69 | The Apostrophe cache `get` and `set` methods take two arguments that are used for individual cache item keys: 70 | 71 | | Argument | Description | 72 | | -------- | ----------- | 73 | | `namespace` | A namespace for related data. Apostrophe core often uses the active module's name for the namespace, e.g., `@apostrophecms/oembed`. | 74 | | `key` | The unique cache item key within a namespace. | 75 | 76 | ```javascript 77 | await apos.cache.get(namespace, key) 78 | await apos.cache.set(namespace, key, value) 79 | ``` 80 | 81 | Example: 82 | 83 | ```javascript 84 | const fetch = node 85 | // modules/api-connect/index.js 86 | module.exports = { 87 | apiRoutes(self) { 88 | return { 89 | get: { 90 | // GET /api/v1/api-connect/set-cache-info 91 | async setCacheInfo(req) { 92 | const info = await myApiClient.get({ latest: true }); 93 | 94 | // 👇 This status will be "OK" if successful. This is due to the Redis 95 | // API, not a common Apostrophe pattern. 96 | const status = await self.apos.cache.set('api-connect', 'latest', info); 97 | 98 | return { status }; 99 | }, 100 | // GET /api/v1/api-connect/get-cache-info 101 | async getCacheInfo(req) { 102 | // 👇 This will return the stored information or `undefined` if not 103 | // set. 104 | const latest = await self.apos.cache.get('api-connect', 'latest'); 105 | 106 | return { latest }; 107 | } 108 | } 109 | }; 110 | } 111 | }; 112 | ``` 113 | 114 | ### Using the `prefix` for multiple sites 115 | 116 | By default, this module applies a prefix to cache keys in the store. This prefix is the shortname of the website, which automatically separates cache items if multiple Apostrophe sites share the same Redis store. 117 | 118 | To disable the cache key prefix, set the cache module's `prefix` option to `false`. 119 | 120 | ```javascript 121 | // modules/@apostrophecms/cache/index.js 122 | module.exports = { 123 | options: { 124 | prefix: false 125 | } 126 | }; 127 | ``` 128 | 129 | To customize the prefix, set the `prefix` option to the desired string value. 130 | 131 | ```javascript 132 | // modules/@apostrophecms/cache/index.js 133 | module.exports = { 134 | options: { 135 | prefix: 'project-alpha' 136 | } 137 | }; 138 | ``` 139 | --------------------------------------------------------------------------------