Coverage

98%
158
156
2

/Users/mk2/git/webcache/lib/webcache.js

98%
158
156
2
LineHitsSource
1/*!
2 * webcache - lib/webcache.js
3 * Copyright(c) 2012 fengmk2 <fengmk2@gmail.com>
4 * MIT Licensed
5 */
6
71"use strict";
8
9/**
10 * Module dependencies.
11 */
12
131require('buffer-concat');
141var debug = require('debug')('webcache');
151var eventproxy = require('eventproxy');
161var urlparse = require('url').parse;
17
18/**
19 * Parse the given Cache-Control `str`.
20 *
21 * @param {String} str
22 * @return {Object}
23 * @api private
24 */
251var parseCacheControl = function (str) {
269 var directives = str.split(',');
279 var obj = {};
28
299 for (var i = 0, len = directives.length; i < len; i++) {
309 var parts = directives[i].split('=');
319 var key = parts.shift().trim();
329 var val = parseInt(parts.shift(), 10);
33
349 obj[key] = isNaN(val) ? true : val;
35 }
36
379 return obj;
38};
39
40/**
41 * Web Cache middleware.
42 *
43 * ```js
44 * app.use(webcache(
45 * webcache.redisStore(redis),
46 * [
47 * { match: /^\/article\/\w+/, maxAge: 3600000, ignoreQuerystring: true },
48 * { match: /^\/$/, maxAge: 3600000 * 24 },
49 * { match: /^\/comments?/ }, // 5 minutes cache
50 * ],
51 * { version: 2012 }
52 * ));
53 * ```
54 *
55 * @param {CacheStore} cache, cache store impl `get()` and `set()` methods.
56 * #get(key, callback) and
57 * #set(key, value, maxAge, [callback]), or use a array and exports.setCacheIndex
58 * @param {Array} rules, match url cache rules.
59 * - {Object} rule: {
60 * - {RegExp} match: regex to detect which `req.url` need to be cache.
61 * - {Number} maxAge: cache millisecond, default is `options.maxAge`.
62 * - {Boolean} ignoreQuerystring: ignore `req.url` querystring params.
63 * - {Boolean} clientCache: client side cache, default is `options.clientCache`.
64 * - {Array} ignoreQueryParams: An Array of query params key name in url.
65 * }
66 * @param options
67 * - {Number} maxAge: global cache millisecond, default is 300000ms (5 minutes).
68 * - {String} version: cache version, append to cache store key, default is `''`.
69 * - {Boolean} ignoreQuerystring: ignore `req.url` querystring params, default is false.
70 * - {Boolean} clientCache: client side cache, default is false.
71 * - {Array} ignoreQueryParams: An Array of query params key name in url.
72 * @returns {Function (req, res, next)}
73 * @api public
74 */
751exports = module.exports = function webcache(cache, rules, options) {
7615 if (!cache || typeof cache.get !== 'function' || typeof cache.set !== 'function') {
776 throw new TypeError('cache must support #get() and #set()');
78 }
799 if (!rules || !rules.length) {
806 throw new TypeError('rules must not empty');
81 }
82
833 options = options || {};
843 var defaultMaxAge = options.maxAge || 300000;
853 var defaultIgnoreQuerystring = options.ignoreQuerystring || false;
863 var defaultIgnoreQueryParams = options.ignoreQueryParams || [];
873 var version = options.version || '';
883 var clientCache = !!options.clientCache;
89
903 var TEXT_RE = /^(?:text\/|application\/json|application\/javascript)/i;
91
923 for (var i = 0; i < rules.length; i++) {
939 var rule = rules[i];
949 rule.maxAge = rule.maxAge || defaultMaxAge;
959 rule.ignoreQuerystring = rule.ignoreQuerystring || defaultIgnoreQuerystring;
969 var ignoreQueryParams = rule.ignoreQueryParams || defaultIgnoreQueryParams;
979 rule.ignoreQueryParamsRE = [];
98 // like '?spm=2.3.45&' <=> /(\&|\?)spm[^\&]*\&?/
999 for (var j = 0; j < ignoreQueryParams.length; j ++) {
10018 rule.ignoreQueryParamsRE[j] = new RegExp('(\\&|\\?)' + ignoreQueryParams[j] + '[^\\&]*\\&?' , 'ig');
101 }
1029 rule.clientCache = rule.clientCache || clientCache;
103 }
104
1053 return function webcache(req, res, next) {
106129 if (req.method !== 'GET') {
10718 return next();
108 }
109
110111 var matchRule = null;
111111 var urlinfo = urlparse(req.url);
112111 for (var i = 0; i < rules.length; i++) {
113333 var rule = rules[i];
114333 if (rule.match.test(urlinfo.pathname)) {
115105 matchRule = rule;
116 }
117 }
118
119111 if (!matchRule) {
1206 return next();
121 }
122105 var url = req.url;
123105 if (matchRule.ignoreQuerystring) {
12469 url = urlinfo.pathname;
125 }
126105 for (var i = 0; i < matchRule.ignoreQueryParamsRE.length; i ++) {
127210 url = url.replace(matchRule.ignoreQueryParamsRE[i], function (match0, match1) {
128 // if first char is '?', leave it
12912 if (match1 === '?') {
1306 return match1;
131 }
1326 return '';
133 });
134 }
135105 var key = 'wc_' + url + '_' + version;
136105 var keyContentType = key + '_ct';
137105 var maxAge = matchRule.maxAge;
138
139105 var nextHandle = function () {
14063 var chunks = [];
14163 var size = 0;
14263 res.__source_write = res.write;
14363 res.write = function (chunk, encoding) {
14463 this.__source_write(chunk, encoding);
145
14663 if (!Buffer.isBuffer(chunk)) {
14754 chunk = new Buffer(chunk, encoding);
148 }
14963 chunks.push(chunk);
15063 size += chunk.length;
151 };
15263 res.__source_end = res.end;
15363 res.end = function (chunk, encoding) {
15463 if (chunk) {
15563 this.write(chunk, encoding);
156 }
15763 this.end = this.__source_end;
15863 this.end();
159
16063 if (res.statusCode !== 200) {
161 // dont cache non 200 status response
16212 return;
163 }
164
16551 var cc = res.getHeader('cache-control');
16651 if (cc && parseCacheControl(cc)['no-cache']) {
1676 return;
168 }
169
17045 if (chunks.length > 0) {
17145 var buf = Buffer.concat(chunks, size);
17245 if (buf && buf.length > 0) {
17345 var contentType = res.getHeader('Content-Type');
17445 if (contentType) {
17545 var need = TEXT_RE.test(contentType);
17645 debug('check %j %s %s', req.url, contentType, need);
17745 if (need) {
17839 cache.set(key, buf, maxAge);
17939 cache.set(keyContentType, contentType, maxAge);
180 }
181 }
182 }
183 }
184
185 };
18663 next();
187 };
188
189105 var ep = eventproxy.create('content_type', 'content', function (contentType, content) {
19099 if (content) {
19145 var cc = req.headers['cache-control'];
19245 if (!cc || !parseCacheControl(cc)['no-cache']) {
19342 if (contentType) {
19442 res.setHeader('Content-Type', contentType);
195 }
19642 res.setHeader('X-Cache-By', 'WebCache' + exports.version);
19742 if (matchRule.clientCache && maxAge >= 1000) {
19818 var seconds = parseInt(maxAge / 1000, 10);
19918 res.setHeader('Cache-Control', 'public, max-age=' + seconds);
200 }
20142 debug('hit %s %s, %d bytes', key, contentType, content.length);
20242 return res.end(content);
203 }
204 }
20557 debug('miss %s', key);
206
20757 nextHandle();
208 });
209
210105 ep.on('error', function (err) {
2116 debug('[%s] %s', new Date(), err.stack);
2126 ep.unbind();
2136 nextHandle();
214 });
215
216105 cache.get(key, function (err, content) {
217105 if (err) {
2186 return ep.emit('error', err);
219 }
22099 ep.emit('content', content);
221 });
222
223105 cache.get(keyContentType, function (err, keyContentType) {
224105 if (err) {
2256 return ep.emit('error', err);
226 }
22799 ep.emit('content_type', keyContentType);
228 });
229 };
230};
231
232/**
233 * Cache in process memory, Don't use int production env.
234 */
2351function MemoryStore() {
2362 if (!(this instanceof MemoryStore)) {
2371 return new MemoryStore();
238 }
2391 this._data = {};
2401 if (process.env.NODE_ENV !== 'test') {
2410 console.warn('[webcache][%s] MUST not use MemoryStore in production env.', new Date());
242 }
243}
244
2451MemoryStore.prototype.set = function (key, value, maxAge, callback) {
246350 var expired = 0;
247350 if (maxAge) {
24830 expired = Date.now() + maxAge;
249 }
250350 this._data[key] = [value, expired, maxAge];
251350 process.nextTick(function () {
252350 callback && callback();
253 });
254};
255
2561MemoryStore.prototype.get = function (key, callback) {
25766 var data = this._data[key];
25866 var value = null;
25966 if (data) {
26043 if (data[1] && Date.now() >= data[1]) {
2612 delete this._data[key];
262 } else {
26341 value = data[0];
264 }
265 }
26666 process.nextTick(function () {
26766 callback(null, value);
268 });
269};
270
2711exports.MemoryStore = MemoryStore;
272
2731function RedisStore(cache) {
2742 this.cache = cache;
275}
276
2771RedisStore.prototype.set = function (key, value, maxAge, callback) {
278688 if (!value) {
279640 debug('del %s', key);
280640 return this.cache.del(key, callback);
281 }
28248 var seconds = 0;
28348 if (maxAge && maxAge >= 1000) {
28448 seconds = parseInt(maxAge / 1000, 10);
285 }
28648 debug('set %s %s', key, seconds);
28748 if (seconds) {
28848 this.cache.setex(key, seconds, value, callback);
289 } else {
2900 this.cache.set(key, value, callback);
291 }
292};
293
2941RedisStore.prototype.get = function (key, callback) {
295132 this.cache.get(key, callback);
296};
297
298/**
299 * Create a cache store.
300 * @param {String} type, cache type, e.g.: 'redis', 'tair'
301 * @param {Object} cache, cache instance
302 * @return {Object} store instance
303 */
3041exports.createStore = function (type, cache) {
3053 if (type === 'redis') {
3062 return new RedisStore(cache);
307 }
3081 throw new TypeError(type + ' not support, please implement the get() and set() methods yourself');
309};
310
3111exports.version = require('../package.json').version;
312
313