| 1 | | /*! |
| 2 | | * webcache - lib/webcache.js |
| 3 | | * Copyright(c) 2012 fengmk2 <fengmk2@gmail.com> |
| 4 | | * MIT Licensed |
| 5 | | */ |
| 6 | | |
| 7 | 1 | "use strict"; |
| 8 | | |
| 9 | | /** |
| 10 | | * Module dependencies. |
| 11 | | */ |
| 12 | | |
| 13 | 1 | require('buffer-concat'); |
| 14 | 1 | var debug = require('debug')('webcache'); |
| 15 | 1 | var eventproxy = require('eventproxy'); |
| 16 | 1 | var 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 | | */ |
| 25 | 1 | var parseCacheControl = function (str) { |
| 26 | 9 | var directives = str.split(','); |
| 27 | 9 | var obj = {}; |
| 28 | | |
| 29 | 9 | for (var i = 0, len = directives.length; i < len; i++) { |
| 30 | 9 | var parts = directives[i].split('='); |
| 31 | 9 | var key = parts.shift().trim(); |
| 32 | 9 | var val = parseInt(parts.shift(), 10); |
| 33 | | |
| 34 | 9 | obj[key] = isNaN(val) ? true : val; |
| 35 | | } |
| 36 | | |
| 37 | 9 | 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 | | */ |
| 75 | 1 | exports = module.exports = function webcache(cache, rules, options) { |
| 76 | 15 | if (!cache || typeof cache.get !== 'function' || typeof cache.set !== 'function') { |
| 77 | 6 | throw new TypeError('cache must support #get() and #set()'); |
| 78 | | } |
| 79 | 9 | if (!rules || !rules.length) { |
| 80 | 6 | throw new TypeError('rules must not empty'); |
| 81 | | } |
| 82 | | |
| 83 | 3 | options = options || {}; |
| 84 | 3 | var defaultMaxAge = options.maxAge || 300000; |
| 85 | 3 | var defaultIgnoreQuerystring = options.ignoreQuerystring || false; |
| 86 | 3 | var defaultIgnoreQueryParams = options.ignoreQueryParams || []; |
| 87 | 3 | var version = options.version || ''; |
| 88 | 3 | var clientCache = !!options.clientCache; |
| 89 | | |
| 90 | 3 | var TEXT_RE = /^(?:text\/|application\/json|application\/javascript)/i; |
| 91 | | |
| 92 | 3 | for (var i = 0; i < rules.length; i++) { |
| 93 | 9 | var rule = rules[i]; |
| 94 | 9 | rule.maxAge = rule.maxAge || defaultMaxAge; |
| 95 | 9 | rule.ignoreQuerystring = rule.ignoreQuerystring || defaultIgnoreQuerystring; |
| 96 | 9 | var ignoreQueryParams = rule.ignoreQueryParams || defaultIgnoreQueryParams; |
| 97 | 9 | rule.ignoreQueryParamsRE = []; |
| 98 | | // like '?spm=2.3.45&' <=> /(\&|\?)spm[^\&]*\&?/ |
| 99 | 9 | for (var j = 0; j < ignoreQueryParams.length; j ++) { |
| 100 | 18 | rule.ignoreQueryParamsRE[j] = new RegExp('(\\&|\\?)' + ignoreQueryParams[j] + '[^\\&]*\\&?' , 'ig'); |
| 101 | | } |
| 102 | 9 | rule.clientCache = rule.clientCache || clientCache; |
| 103 | | } |
| 104 | | |
| 105 | 3 | return function webcache(req, res, next) { |
| 106 | 129 | if (req.method !== 'GET') { |
| 107 | 18 | return next(); |
| 108 | | } |
| 109 | | |
| 110 | 111 | var matchRule = null; |
| 111 | 111 | var urlinfo = urlparse(req.url); |
| 112 | 111 | for (var i = 0; i < rules.length; i++) { |
| 113 | 333 | var rule = rules[i]; |
| 114 | 333 | if (rule.match.test(urlinfo.pathname)) { |
| 115 | 105 | matchRule = rule; |
| 116 | | } |
| 117 | | } |
| 118 | | |
| 119 | 111 | if (!matchRule) { |
| 120 | 6 | return next(); |
| 121 | | } |
| 122 | 105 | var url = req.url; |
| 123 | 105 | if (matchRule.ignoreQuerystring) { |
| 124 | 69 | url = urlinfo.pathname; |
| 125 | | } |
| 126 | 105 | for (var i = 0; i < matchRule.ignoreQueryParamsRE.length; i ++) { |
| 127 | 210 | url = url.replace(matchRule.ignoreQueryParamsRE[i], function (match0, match1) { |
| 128 | | // if first char is '?', leave it |
| 129 | 12 | if (match1 === '?') { |
| 130 | 6 | return match1; |
| 131 | | } |
| 132 | 6 | return ''; |
| 133 | | }); |
| 134 | | } |
| 135 | 105 | var key = 'wc_' + url + '_' + version; |
| 136 | 105 | var keyContentType = key + '_ct'; |
| 137 | 105 | var maxAge = matchRule.maxAge; |
| 138 | | |
| 139 | 105 | var nextHandle = function () { |
| 140 | 63 | var chunks = []; |
| 141 | 63 | var size = 0; |
| 142 | 63 | res.__source_write = res.write; |
| 143 | 63 | res.write = function (chunk, encoding) { |
| 144 | 63 | this.__source_write(chunk, encoding); |
| 145 | | |
| 146 | 63 | if (!Buffer.isBuffer(chunk)) { |
| 147 | 54 | chunk = new Buffer(chunk, encoding); |
| 148 | | } |
| 149 | 63 | chunks.push(chunk); |
| 150 | 63 | size += chunk.length; |
| 151 | | }; |
| 152 | 63 | res.__source_end = res.end; |
| 153 | 63 | res.end = function (chunk, encoding) { |
| 154 | 63 | if (chunk) { |
| 155 | 63 | this.write(chunk, encoding); |
| 156 | | } |
| 157 | 63 | this.end = this.__source_end; |
| 158 | 63 | this.end(); |
| 159 | | |
| 160 | 63 | if (res.statusCode !== 200) { |
| 161 | | // dont cache non 200 status response |
| 162 | 12 | return; |
| 163 | | } |
| 164 | | |
| 165 | 51 | var cc = res.getHeader('cache-control'); |
| 166 | 51 | if (cc && parseCacheControl(cc)['no-cache']) { |
| 167 | 6 | return; |
| 168 | | } |
| 169 | | |
| 170 | 45 | if (chunks.length > 0) { |
| 171 | 45 | var buf = Buffer.concat(chunks, size); |
| 172 | 45 | if (buf && buf.length > 0) { |
| 173 | 45 | var contentType = res.getHeader('Content-Type'); |
| 174 | 45 | if (contentType) { |
| 175 | 45 | var need = TEXT_RE.test(contentType); |
| 176 | 45 | debug('check %j %s %s', req.url, contentType, need); |
| 177 | 45 | if (need) { |
| 178 | 39 | cache.set(key, buf, maxAge); |
| 179 | 39 | cache.set(keyContentType, contentType, maxAge); |
| 180 | | } |
| 181 | | } |
| 182 | | } |
| 183 | | } |
| 184 | | |
| 185 | | }; |
| 186 | 63 | next(); |
| 187 | | }; |
| 188 | | |
| 189 | 105 | var ep = eventproxy.create('content_type', 'content', function (contentType, content) { |
| 190 | 99 | if (content) { |
| 191 | 45 | var cc = req.headers['cache-control']; |
| 192 | 45 | if (!cc || !parseCacheControl(cc)['no-cache']) { |
| 193 | 42 | if (contentType) { |
| 194 | 42 | res.setHeader('Content-Type', contentType); |
| 195 | | } |
| 196 | 42 | res.setHeader('X-Cache-By', 'WebCache' + exports.version); |
| 197 | 42 | if (matchRule.clientCache && maxAge >= 1000) { |
| 198 | 18 | var seconds = parseInt(maxAge / 1000, 10); |
| 199 | 18 | res.setHeader('Cache-Control', 'public, max-age=' + seconds); |
| 200 | | } |
| 201 | 42 | debug('hit %s %s, %d bytes', key, contentType, content.length); |
| 202 | 42 | return res.end(content); |
| 203 | | } |
| 204 | | } |
| 205 | 57 | debug('miss %s', key); |
| 206 | | |
| 207 | 57 | nextHandle(); |
| 208 | | }); |
| 209 | | |
| 210 | 105 | ep.on('error', function (err) { |
| 211 | 6 | debug('[%s] %s', new Date(), err.stack); |
| 212 | 6 | ep.unbind(); |
| 213 | 6 | nextHandle(); |
| 214 | | }); |
| 215 | | |
| 216 | 105 | cache.get(key, function (err, content) { |
| 217 | 105 | if (err) { |
| 218 | 6 | return ep.emit('error', err); |
| 219 | | } |
| 220 | 99 | ep.emit('content', content); |
| 221 | | }); |
| 222 | | |
| 223 | 105 | cache.get(keyContentType, function (err, keyContentType) { |
| 224 | 105 | if (err) { |
| 225 | 6 | return ep.emit('error', err); |
| 226 | | } |
| 227 | 99 | ep.emit('content_type', keyContentType); |
| 228 | | }); |
| 229 | | }; |
| 230 | | }; |
| 231 | | |
| 232 | | /** |
| 233 | | * Cache in process memory, Don't use int production env. |
| 234 | | */ |
| 235 | 1 | function MemoryStore() { |
| 236 | 2 | if (!(this instanceof MemoryStore)) { |
| 237 | 1 | return new MemoryStore(); |
| 238 | | } |
| 239 | 1 | this._data = {}; |
| 240 | 1 | if (process.env.NODE_ENV !== 'test') { |
| 241 | 0 | console.warn('[webcache][%s] MUST not use MemoryStore in production env.', new Date()); |
| 242 | | } |
| 243 | | } |
| 244 | | |
| 245 | 1 | MemoryStore.prototype.set = function (key, value, maxAge, callback) { |
| 246 | 350 | var expired = 0; |
| 247 | 350 | if (maxAge) { |
| 248 | 30 | expired = Date.now() + maxAge; |
| 249 | | } |
| 250 | 350 | this._data[key] = [value, expired, maxAge]; |
| 251 | 350 | process.nextTick(function () { |
| 252 | 350 | callback && callback(); |
| 253 | | }); |
| 254 | | }; |
| 255 | | |
| 256 | 1 | MemoryStore.prototype.get = function (key, callback) { |
| 257 | 66 | var data = this._data[key]; |
| 258 | 66 | var value = null; |
| 259 | 66 | if (data) { |
| 260 | 43 | if (data[1] && Date.now() >= data[1]) { |
| 261 | 2 | delete this._data[key]; |
| 262 | | } else { |
| 263 | 41 | value = data[0]; |
| 264 | | } |
| 265 | | } |
| 266 | 66 | process.nextTick(function () { |
| 267 | 66 | callback(null, value); |
| 268 | | }); |
| 269 | | }; |
| 270 | | |
| 271 | 1 | exports.MemoryStore = MemoryStore; |
| 272 | | |
| 273 | 1 | function RedisStore(cache) { |
| 274 | 2 | this.cache = cache; |
| 275 | | } |
| 276 | | |
| 277 | 1 | RedisStore.prototype.set = function (key, value, maxAge, callback) { |
| 278 | 688 | if (!value) { |
| 279 | 640 | debug('del %s', key); |
| 280 | 640 | return this.cache.del(key, callback); |
| 281 | | } |
| 282 | 48 | var seconds = 0; |
| 283 | 48 | if (maxAge && maxAge >= 1000) { |
| 284 | 48 | seconds = parseInt(maxAge / 1000, 10); |
| 285 | | } |
| 286 | 48 | debug('set %s %s', key, seconds); |
| 287 | 48 | if (seconds) { |
| 288 | 48 | this.cache.setex(key, seconds, value, callback); |
| 289 | | } else { |
| 290 | 0 | this.cache.set(key, value, callback); |
| 291 | | } |
| 292 | | }; |
| 293 | | |
| 294 | 1 | RedisStore.prototype.get = function (key, callback) { |
| 295 | 132 | 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 | | */ |
| 304 | 1 | exports.createStore = function (type, cache) { |
| 305 | 3 | if (type === 'redis') { |
| 306 | 2 | return new RedisStore(cache); |
| 307 | | } |
| 308 | 1 | throw new TypeError(type + ' not support, please implement the get() and set() methods yourself'); |
| 309 | | }; |
| 310 | | |
| 311 | 1 | exports.version = require('../package.json').version; |
| 312 | | |
| 313 | | |