├── .gitignore ├── .jshintrc ├── .travis.yml ├── README.md ├── index.js ├── package.json └── test ├── .jshintrc └── caching-test.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "node": true, 3 | "esnext": true, 4 | "unused": true, 5 | "undef": true 6 | } 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "stable" 4 | - "5" 5 | - "4" 6 | 7 | env: 8 | - CXX=g++-4.8 WORKER_COUNT=2 9 | addons: 10 | apt: 11 | sources: 12 | - ubuntu-toolchain-r-test 13 | packages: 14 | - g++-4.8 15 | 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## FastBoot Redis Cache 2 | 3 | This cache for the [FastBoot App Server][app-server] works with a Redis 4 | cluster to cache the results of rendered FastBoot pages. 5 | 6 | [app-server]: https://github.com/ember-fastboot/fastboot-app-server 7 | 8 | To use the cache, configure it with a Redis host and/or port: 9 | 10 | ```js 11 | const FastBootAppServer = require('fastboot-app-server'); 12 | const RedisCache = require('fastboot-redis-cache'); 13 | 14 | let cache = new RedisCache({ 15 | host: FASTBOOT_REDIS_HOST, 16 | port: FASTBOOT_REDIS_PORT 17 | }); 18 | 19 | let server = new FastBootAppServer({ 20 | cache: cache 21 | }); 22 | ``` 23 | 24 | When an incoming request arrives, the App Server will consult the 25 | cache for the given route. If it doesn't exist, the page will be 26 | rendered and saved in Redis provided it does not have a 4xx or 5xx 27 | HTTP status code. By default, cached pages are set to expire 28 | after 5 minutes. You can change the expiry by setting the `expiration` 29 | option (in seconds): 30 | 31 | ```js 32 | let cache = new RedisCache({ 33 | host: FASTBOOT_REDIS_HOST, 34 | port: FASTBOOT_REDIS_PORT, 35 | expiration: 60 // one minute 36 | }); 37 | ``` 38 | 39 | Additionally, if you would like your cache key to vary based on 40 | information in the request (like headers), you can 41 | provide a `cacheKey(path, request)` function that takes in as 42 | parameters the path being requested and the request object. 43 | 44 | ```js 45 | let cache = new RedisCache({ 46 | host: FASTBOOT_REDIS_HOST, 47 | port: FASTBOOT_REDIS_PORT, 48 | cacheKey(path, request) { 49 | return `${path}_${request && request.cookies && request.cookies.chocolateChip}`; 50 | } 51 | }); 52 | ``` 53 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const redis = require('redis'); 4 | 5 | const FIVE_MINUTES = 5 * 60; 6 | 7 | class RedisCache { 8 | constructor(options) { 9 | let client = this.client = redis.createClient({ 10 | host: options.host, 11 | port: options.port 12 | }); 13 | 14 | this.expiration = options.expiration || FIVE_MINUTES; 15 | this.connected = false; 16 | this.cacheKey = typeof options.cacheKey === 'function' ? 17 | options.cacheKey : (path) => path; 18 | 19 | client.on('error', error => { 20 | this.ui.writeLine(`redis error; err=${error}`); 21 | }); 22 | 23 | this.client.on('connect', () => { 24 | this.connected = true; 25 | this.ui.writeLine('redis connected'); 26 | }); 27 | 28 | this.client.on('end', () => { 29 | this.connected = false; 30 | this.ui.writeLine('redis disconnected'); 31 | }); 32 | } 33 | 34 | fetch(path, request) { 35 | if (!this.connected) { return; } 36 | 37 | let key = this.cacheKey(path, request); 38 | 39 | return new Promise((res, rej) => { 40 | this.client.get(key, (err, reply) => { 41 | if (err) { 42 | rej(err); 43 | } else { 44 | res(reply); 45 | } 46 | }); 47 | }); 48 | } 49 | 50 | put(path, body, response) { 51 | if (!this.connected) { return; } 52 | 53 | let request = response && response.req; 54 | let key = this.cacheKey(path, request); 55 | 56 | return new Promise((res, rej) => { 57 | let statusCode = response && response.statusCode; 58 | let statusCodeStr = statusCode && (statusCode + ''); 59 | 60 | if (statusCodeStr && statusCodeStr.length && 61 | (statusCodeStr.charAt(0) === '4' || statusCodeStr.charAt(0) === '5' || statusCodeStr.charAt(0) === '3')) { 62 | res(); 63 | return; 64 | } 65 | 66 | this.client.multi() 67 | .set(key, body) 68 | .expire(path, this.expiration) 69 | .exec(err => { 70 | if (err) { 71 | rej(err); 72 | } else { 73 | res(); 74 | } 75 | }); 76 | }); 77 | } 78 | } 79 | 80 | module.exports = RedisCache; 81 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fastboot-redis-cache", 3 | "version": "0.2.0", 4 | "description": "A FastBoot App Server cache for Redis", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "mocha" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/tomdale/fastboot-redis-cache.git" 12 | }, 13 | "keywords": [ 14 | "ember", 15 | "fastboot", 16 | "redis", 17 | "cache" 18 | ], 19 | "author": "Tom Dale ", 20 | "license": "MIT", 21 | "bugs": { 22 | "url": "https://github.com/tomdale/fastboot-redis-cache/issues" 23 | }, 24 | "homepage": "https://github.com/tomdale/fastboot-redis-cache#readme", 25 | "dependencies": { 26 | "redis": "^2.6.0-2" 27 | }, 28 | "devDependencies": { 29 | "chai": "^3.5.0", 30 | "mocha": "^2.4.5" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /test/.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "node": true, 3 | "esnext": true, 4 | "unused": true, 5 | "mocha": true, 6 | "undef": true 7 | } 8 | -------------------------------------------------------------------------------- /test/caching-test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const expect = require('chai').expect; 4 | const RedisCache = require('../index'); 5 | 6 | let cache; 7 | let mockRedis = {}; 8 | 9 | describe('caching tests', function() { 10 | 11 | describe('basic tests', function() { 12 | beforeEach(function() { 13 | cache = new RedisCache({ 14 | }); 15 | cache.client = mockRedisClient(); 16 | cache.connected = true; 17 | mockRedis = {}; 18 | }); 19 | 20 | it('can put a response in the cache', function() { 21 | let body = 'Hola'; 22 | 23 | return cache.put('/', body).then(() => { 24 | expect(mockRedis['/']).to.equal(body); 25 | }); 26 | }); 27 | 28 | it('can retreive a response from the cache', function() { 29 | let body = 'Hola'; 30 | mockRedis['/yellow'] = body; 31 | 32 | return cache.fetch('/yellow').then(actual => { 33 | expect(actual).to.equal(body); 34 | }); 35 | }); 36 | 37 | it('can put a response in the cache for success responses', function() { 38 | let body = 'Hola'; 39 | let mockResponse = { statusCode: 200 }; 40 | 41 | return cache.put('/', body, mockResponse).then(() => { 42 | expect(mockRedis['/']).to.equal(body); 43 | }); 44 | }); 45 | 46 | it('does not cache 5xx error responses', function() { 47 | let body = 'OMG there are so many errors'; 48 | let mockResponse = { statusCode: 500 }; 49 | 50 | return cache.put('/', body, mockResponse).then(() => { 51 | expect(mockRedis['/']).to.be.undefined; 52 | }); 53 | }); 54 | 55 | it('does not cache 4xx error responses', function() { 56 | let body = 'You can`t'; 57 | let mockResponse = { statusCode: 400 }; 58 | 59 | return cache.put('/', body, mockResponse).then(() => { 60 | expect(mockRedis['/']).to.be.undefined; 61 | }); 62 | }); 63 | 64 | it('does not cache 3xx error responses', function() { 65 | let body = 'Redirect to '; 66 | let mockResponse = { statusCode: 301 }; 67 | 68 | return cache.put('/', body, mockResponse).then(() => { 69 | expect(mockRedis['/']).to.be.undefined; 70 | }); 71 | }); 72 | }); 73 | 74 | describe('custom keys tests', function() { 75 | beforeEach(function() { 76 | cache = new RedisCache({ 77 | cacheKey (path, request) { 78 | return `${path}_${request && request.cookies && request.cookies.chocolateChip}`; 79 | } 80 | }); 81 | cache.client = mockRedisClient(); 82 | cache.connected = true; 83 | mockRedis = {}; 84 | }); 85 | 86 | it('can build a custom cache key from the request object', function() { 87 | let body = 'Hola'; 88 | let mockResponse = { 89 | req: { 90 | cookies: { 91 | chocolateChip: 'mmmmmm' 92 | } 93 | } 94 | }; 95 | 96 | return cache.put('/', body, mockResponse).then(() => { 97 | expect(mockRedis['/_mmmmmm']).to.equal(body); 98 | }); 99 | }); 100 | 101 | it('can get a cache item based on a custom cache key', function() { 102 | let body = 'Hola'; 103 | let cookieValue = 'mmmmmm'; 104 | mockRedis[`/_${cookieValue}`] = body; 105 | let mockRequest = { 106 | cookies: { 107 | chocolateChip: cookieValue 108 | } 109 | }; 110 | 111 | return cache.fetch('/', mockRequest).then(actual => { 112 | expect(actual).to.equal(body); 113 | }); 114 | }); 115 | 116 | }); 117 | }); 118 | 119 | function mockRedisClient() { 120 | let next = () => { 121 | return { 122 | set(key, value) { 123 | mockRedis[key] = value; 124 | return next(); 125 | }, 126 | expire() { 127 | return next(); 128 | }, 129 | exec(callback) { 130 | callback(); 131 | } 132 | }; 133 | }; 134 | 135 | return { 136 | on() { 137 | }, 138 | 139 | get(key, callback) { 140 | return callback(null, mockRedis[key]); 141 | }, 142 | 143 | multi() { 144 | return next(); 145 | } 146 | }; 147 | } 148 | --------------------------------------------------------------------------------