├── .eslintrc ├── .gitignore ├── logos ├── logo-box-builtby.png └── logo-box-madefor.png ├── CHANGELOG.md ├── package.json ├── index.js ├── test └── test.js └── README.md /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ "apostrophe" ], 3 | "rules": { 4 | "no-console": "off" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | npm-debug.log 2 | *.DS_Store 3 | node_modules 4 | # We do not commit CSS, only LESS 5 | public/css/*.css 6 | -------------------------------------------------------------------------------- /logos/logo-box-builtby.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apostrophecms/express-cache-on-demand/main/logos/logo-box-builtby.png -------------------------------------------------------------------------------- /logos/logo-box-madefor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apostrophecms/express-cache-on-demand/main/logos/logo-box-madefor.png -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.0.4 (2025-12-01) 4 | 5 | * Default hash function now distinguishes requests by hostname, and respects `req.originalUrl` in preference to `req.url` if available (ApostropheCMS). 6 | * Passing the empty string to `res.send()` no longer causes a failure. 7 | * If `express-cache-on-demand` does fail due to an unsupported way of ending a response, just issue a 500 error and log a useful message. Don't terminate the process. 8 | 9 | ## 1.0.3 (2022-02-18) 10 | 11 | * eslint fixes 12 | 13 | ## 1.0.2 (2017-11-17) 14 | 15 | * Supports getHeader 16 | 17 | ## 1.0.1 (2016-10-07) 18 | 19 | * Modern dependencies 20 | 21 | ## 1.0.0 (2016-10-17) 22 | 23 | * First stable release. Status code support in `res.redirect`, thanks to Alexey Astafiev 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "express-cache-on-demand", 3 | "version": "1.0.4", 4 | "description": "Express middleware providing on-demand caching in high traffic situations.", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "npm run lint && (cd test; mocha test)", 8 | "lint": "eslint ." 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/punkave/express-cache-on-demand" 13 | }, 14 | "keywords": [ 15 | "express", 16 | "cache", 17 | "performance", 18 | "cache", 19 | "on", 20 | "demand" 21 | ], 22 | "author": "P'unk Avenue LLC", 23 | "license": "MIT", 24 | "bugs": { 25 | "url": "https://github.com/punkave/express-cache-on-demand/issues" 26 | }, 27 | "homepage": "https://github.com/punkave/express-cache-on-demand", 28 | "dependencies": { 29 | "cache-on-demand": "^1.0.0", 30 | "lodash": "^4.0.0" 31 | }, 32 | "devDependencies": { 33 | "eslint": "^8.9.0", 34 | "eslint-config-apostrophe": "^5.0.0", 35 | "express": "^4.9.8", 36 | "mocha": "^9.2.0", 37 | "request": "^2.45.0" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const cacheOnDemand = require('cache-on-demand'); 3 | 4 | module.exports = expressCacheOnDemand; 5 | 6 | function expressCacheOnDemand(hasher = expressHasher) { 7 | const codForMiddleware = cacheOnDemand(worker, hasher); 8 | 9 | return (req, res, next) => { 10 | return codForMiddleware(req, res, next, (_res) => { 11 | // Replay the captured response 12 | if (_res.statusCode) { 13 | res.statusCode = _res.statusCode; 14 | } 15 | _.each(_res.headers || {}, (val, key) => { 16 | res.setHeader(key, val); 17 | }); 18 | if (_res.redirect != null) { 19 | return res.redirect(_res.redirectStatus, _res.redirect); 20 | } 21 | if (_res.body != null) { 22 | return res.send(_res.body); 23 | } 24 | if (_res.raw != null) { 25 | return res.end(_res.raw); 26 | } 27 | 28 | // We know about ending a request with one of 29 | // the above three methods. Anything else doesn't 30 | // make sense with this middleware 31 | 32 | console.error( 33 | `cacheOnDemand.middleware does not know how to deliver a response for ${req.originalUrl || req.url},\n` + 34 | 'use the middleware only with routes that end with res.redirect, res.send or res.end' 35 | ); 36 | // Report the bad news, but don't take the entire process down 37 | return res.status(500).send('error'); 38 | }); 39 | }; 40 | 41 | } 42 | 43 | function worker(req, res, next, callback) { 44 | // Patch the response object so that it doesn't respond 45 | // directly, it builds a description of the response that 46 | // can be replayed by each pending res object 47 | 48 | const _res = { headers: {} }; 49 | const originals = {}; 50 | 51 | // We're the first in, we get to do the real work. 52 | // Patch our response object to collect data for 53 | // replay into many response objects 54 | 55 | patch(res, { 56 | redirect (url) { 57 | let status = 302; 58 | 59 | if (typeof arguments[0] === 'number') { 60 | status = arguments[0]; 61 | url = arguments[1]; 62 | } 63 | 64 | _res.redirectStatus = status; 65 | _res.redirect = url; 66 | return finish(); 67 | }, 68 | send (data) { 69 | _res.body = data; 70 | return finish(); 71 | }, 72 | end (raw) { 73 | _res.raw = raw; 74 | return finish(); 75 | }, 76 | getHeader (key) { 77 | return _res.headers[key]; 78 | }, 79 | setHeader (key, val) { 80 | _res.headers[key] = val; 81 | } 82 | }); 83 | 84 | function finish() { 85 | // Folks tend to write to this one directly 86 | _res.statusCode = res.statusCode; 87 | // Undo the patching so we can replay into this 88 | // response object, as well as others 89 | restore(res); 90 | // Great, we're done 91 | return callback(_res); 92 | } 93 | 94 | // All set to continue the middleware chain 95 | return next(); 96 | 97 | function patch(obj, overrides) { 98 | _.assign(originals, _.pick(obj, _.keys(overrides))); 99 | _.assign(obj, overrides); 100 | } 101 | 102 | function restore(obj) { 103 | _.assign(obj, originals); 104 | } 105 | } 106 | 107 | function expressHasher(req) { 108 | if ((req.method !== 'GET') && (req.method !== 'HEAD')) { 109 | return false; 110 | } 111 | if (req.user) { 112 | return false; 113 | } 114 | // Examine the session 115 | let safe = true; 116 | _.each(req.session || {}, function(val, key) { 117 | if (key === 'cookie') { 118 | // The mere existence of a session cookie 119 | // doesn't mean we can't cache. There has 120 | // to actually be something in the session 121 | return; 122 | } 123 | if ((key === 'flash') || (key === 'passport')) { 124 | // These two are often empty objects, which 125 | // are safe to cache 126 | if (!_.isEmpty(val)) { 127 | safe = false; 128 | return false; 129 | } 130 | } else { 131 | // Other session properties must be assumed to 132 | // be specific to this user, with a possible 133 | // impact on the response, and thus mean 134 | // this request must not be cached 135 | safe = false; 136 | return false; 137 | } 138 | }); 139 | if (!safe) { 140 | return false; 141 | } 142 | // Create a key that distinguishes requests by hostname, and respect req.originalUrl 143 | // if available (ApostropheCMS) 144 | return `${req.hostname}:${req.originalUrl || req.url}`; 145 | } 146 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | /* jshint node:true */ 2 | 3 | const assert = require('assert'); 4 | const request = require('request'); 5 | const app = require('express')(); 6 | const expressCacheOnDemand = require('../index.js')(); 7 | 8 | describe('expressCacheOnDemand', () => { 9 | let workCount = 0; 10 | let server; 11 | 12 | before(() => { 13 | app.get('/welcome', expressCacheOnDemand, (req, res) => { 14 | // Simulate time-consuming async work 15 | setTimeout(() => { 16 | workCount++; 17 | return res.send('URL was: ' + req.url + ', work count is: ' + workCount); 18 | }, 100); 19 | }); 20 | app.get('/empty', expressCacheOnDemand, (req, res) => { 21 | // Simulate time-consuming async work 22 | setTimeout(() => { 23 | return res.send(''); 24 | }, 100); 25 | }); 26 | app.get('/redirect', expressCacheOnDemand, (req, res) => { 27 | return res.redirect('/welcome'); 28 | }); 29 | app.get('/redirect-301', expressCacheOnDemand, (req, res) => { 30 | return res.redirect(301, '/welcome'); 31 | }); 32 | app.get('/redirect-302', expressCacheOnDemand, (req, res) => { 33 | return res.redirect(302, '/welcome'); 34 | }); 35 | 36 | server = app.listen(9765); 37 | }); 38 | 39 | after(() => { 40 | server.close(); 41 | }); 42 | 43 | it('replies to simultaneous requests with the same response', (done) => { 44 | let count = 0; 45 | 46 | for (let i = 0; (i < 5); i++) { 47 | attempt(i); 48 | } 49 | 50 | function attempt(i) { 51 | request('http://localhost:9765/welcome', (err, response, body) => { 52 | assert(!err); 53 | assert(response.statusCode === 200); 54 | assert(body === 'URL was: /welcome, work count is: 1'); 55 | count++; 56 | 57 | if (count === 5) { 58 | done(); 59 | } 60 | }); 61 | } 62 | }); 63 | it('replies to a subsequent request with a separate response', (done) => { 64 | request('http://localhost:9765/welcome', (err, response, body) => { 65 | assert(!err); 66 | assert(response.statusCode === 200); 67 | assert(body === 'URL was: /welcome, work count is: 2'); 68 | 69 | done(); 70 | }); 71 | }); 72 | it('replies correctly when res.send is given an empty string', (done) => { 73 | let count = 0; 74 | 75 | for (let i = 0; (i < 5); i++) { 76 | attempt(i); 77 | } 78 | 79 | function attempt(i) { 80 | request('http://localhost:9765/empty', (err, response, body) => { 81 | assert(!err); 82 | assert(response.statusCode === 200); 83 | assert(body === ''); 84 | count++; 85 | 86 | if (count === 5) { 87 | done(); 88 | } 89 | }); 90 | } 91 | }); 92 | it('handles redirects successfully', (done) => { 93 | return request('http://localhost:9765/redirect', (err, response, body) => { 94 | assert(!err); 95 | assert(response.statusCode === 200); 96 | assert(body === 'URL was: /welcome, work count is: 3'); 97 | 98 | done(); 99 | }); 100 | }); 101 | describe('handles redirects successfully with different statusCode', () => { 102 | it('handles 301 statusCode', (done) => { 103 | return request('http://localhost:9765/redirect-301', 104 | { followRedirect: false }, 105 | (err, response, body) => { 106 | assert(!err); 107 | assert(response.statusCode === 301); 108 | 109 | done(); 110 | } 111 | ); 112 | }); 113 | it('handles 302 statusCode', (done) => { 114 | return request('http://localhost:9765/redirect-302', 115 | { followRedirect: false }, 116 | (err, response, body) => { 117 | assert(!err); 118 | assert(response.statusCode === 302); 119 | 120 | done(); 121 | }); 122 | }); 123 | it('redirects to welcome from 301 statusCode', (done) => { 124 | return request('http://localhost:9765/redirect-301', 125 | { followRedirect: true }, 126 | (err, response, body) => { 127 | assert(!err); 128 | assert(response.statusCode === 200); 129 | assert(body === 'URL was: /welcome, work count is: 9'); 130 | 131 | done(); 132 | }); 133 | }); 134 | it('redirects to welcome from 302 statusCode', (done) => { 135 | return request('http://localhost:9765/redirect-302', 136 | { followRedirect: true }, 137 | (err, response, body) => { 138 | assert(!err); 139 | assert(response.statusCode === 200); 140 | assert(body === 'URL was: /welcome, work count is: 10'); 141 | 142 | done(); 143 | }); 144 | }); 145 | 146 | }); 147 | it('replies to separate URLs with separate responses', (done) => { 148 | let count = 0; 149 | 150 | for (let i = 0; (i < 5); i++) { 151 | attempt(i); 152 | } 153 | 154 | function attempt(i) { 155 | request('http://localhost:9765/welcome?' + i, (err, response, body) => { 156 | assert(!err); 157 | assert(response.statusCode === 200); 158 | count++; 159 | if (count === 5) { 160 | assert(workCount === 8); 161 | done(); 162 | } 163 | }); 164 | } 165 | }); 166 | }); 167 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # express-cache-on-demand 2 | 3 | 4 | 5 | Express middleware providing "on demand" caching that kicks in only when requests arrive simultaneously. 6 | 7 | ## Install 8 | 9 | ``` 10 | npm install express-cache-on-demand 11 | ``` 12 | 13 | ## Example 14 | 15 | ```javascript 16 | var expressCacheOnDemand = require('express-cache-on-demand')(); 17 | 18 | // Fetch a page from a database and build fancy 19 | // navigation, then render the template; this is 20 | // just an example of a big, possibly slow task. 21 | // Use the expressCacheOnDemand middleware to 22 | // send the same response to all simultaneous 23 | // requests. 24 | 25 | app.get('/:page', expressCacheOnDemand, function(req, res) { 26 | return getPage(req.params.page, function(page) { 27 | return addFancyNavigation(page, function(links) { 28 | return res.render('page.html', { links: links }); 29 | }); 30 | }); 31 | }); 32 | 33 | ``` 34 | Under light load, with requests arriving far apart, every request for a given `req.url` will get an individually generated response, which gives them the newest content. This is the same behavior you see without the middleware. 35 | 36 | But under heavy load, with new requests arriving while the first request is still being processed, the additional requests are queued up. When the first response is ready, it is simply sent to all of them. And then the response is discarded, so that the next request to arrive will generate a new response with the latest content. 37 | 38 | This gives us "on demand" caching. The server is still allowed to generate new responses often, just not many of them simultaneously. It is the shortest practical lifetime for cached data and largely eliminates concerns about users seeing old content, as well as concerns about cache memory management. 39 | 40 | ## When to use it, when not to 41 | 42 | This middleware is intended for routes that potentially take a long time to generate a relatively small response (under a megabyte, let's say). Dynamic web pages with lots of complicated moving parts are a perfect example. 43 | 44 | You should *not* use this middleware for your entire site. In particular: 45 | 46 | * It does not work and is not suitable anyway for routes that deliver entire files via `res.sendFile` and related methods 47 | * It does not work and is not suitable anyway for routes that `pipe` content into `res` 48 | * It shouldn't be registered globally before the `express.static` middleware 49 | 50 | There may be other possible endings for an Express `res` object that are not properly handled by this middleware yet. Pull requests welcome. 51 | 52 | ## When we cache, when we don't 53 | 54 | By default, the middleware only caches requests when: 55 | 56 | * `req.method` is `GET` or `HEAD`. 57 | * `req.user` is falsy. 58 | * `req.session` is empty (*). 59 | 60 | If the above conditions are not met, every request will generate its own response. This way we don't cause surprising behavior for logged-in users who are modifying site content and seeing personalized displays. 61 | 62 | (*) The middleware is smart enough to ignore a few special cases, such as `req.session.cookie`, an empty `req.session.flash`, and an empty `req.session.passport`. 63 | 64 | ## Deciding when to cache on your own 65 | 66 | If you don't like our rules for caching, you can write your own. Just pass a function that returns `false` for requests that should not be hashed, and a hash key such as `req.url` for requests that should be hashed. 67 | 68 | ```javascript 69 | var expressCacheOnDemand = 70 | require('express-cache-on-demand')(hasher); 71 | 72 | function hasher(req) { 73 | if (req.url.match(/nevercacheme/)) { 74 | return false; 75 | } 76 | return req.url; 77 | } 78 | ``` 79 | 80 | ## Using `cache-on-demand` for other tasks 81 | 82 | This module is an Express middleware wrapper for our [cache-on-demand](https://github.com/punkave/cache-on-demand) module. If you would like to do the same trick with code that isn't powered by Express, try using that module directly. 83 | 84 | ## About P'unk Avenue and Apostrophe 85 | 86 | `express-cache-on-demand` was created at [P'unk Avenue](http://punkave.com) for use in many projects built with Apostrophe, an open-source content management system built on node.js. If you like `cache-on-demand` you should definitely [check out apostrophecms.org](http://apostrophecms.org). 87 | 88 | ## Support 89 | 90 | Feel free to open issues on [github](http://github.com/punkave/express-cache-on-demand). 91 | 92 | 93 | 94 | ## Changelog 95 | 96 | ### CHANGES IN 1.0.3 97 | 98 | * The default hash function now correctly refuses to cache in the documented circumstances (i.e. logged-in users or a nontrivial `req.session` object). Previously a `return false` was missing, resulting in the possibility of a cached result going to a user with a different session. 99 | * `var` has been eliminated and the code has been lightly refactored without other changes to behavior. 100 | 101 | ### CHANGES IN 1.0.2 102 | 103 | `res.getHeader` support. Thanks to Vadim Fedorov. 104 | 105 | ### CHANGES IN 1.0.1 106 | 107 | `cache-on-demand` is now at 1.0.0 also, plus the lodash dependency now points to a modern release. No functional changes. 108 | ### CHANGES IN 1.0.0 109 | 110 | `redirect` now supports the optional status code argument properly. Thanks to Alexey Astafiev. 111 | 112 | This module has been in successful production use for many moons, so we're declaring it stable (1.0.0). 113 | 114 | ### CHANGES IN 0.1.1 115 | 116 | Fixed a bug in `redirect` support, which now works properly. 117 | 118 | ### CHANGES IN 0.1.0 119 | 120 | Initial release. With shiny unit tests, of course. 121 | --------------------------------------------------------------------------------