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