staticCache.js 5.64 KB

/*!
 * Connect - staticCache
 * Copyright(c) 2011 Sencha Inc.
 * MIT Licensed
 */

/**
 * Module dependencies.
 */

var utils = require('../utils')
  , Cache = require('../cache')
  , fresh = require('fresh');

/**
 * Static cache:
 *
 * Enables a memory cache layer on top of
 * the `static()` middleware, serving popular
 * static files.
 *
 * By default a maximum of 128 objects are
 * held in cache, with a max of 256k each,
 * totalling ~32mb.
 *
 * A Least-Recently-Used (LRU) cache algo
 * is implemented through the `Cache` object,
 * simply rotating cache objects as they are
 * hit. This means that increasingly popular
 * objects maintain their positions while
 * others get shoved out of the stack and
 * garbage collected.
 *
 * Benchmarks:
 *
 *     static(): 2700 rps
 *     node-static: 5300 rps
 *     static() + staticCache(): 7500 rps
 *
 * Options:
 *
 *   - `maxObjects`  max cache objects [128]
 *   - `maxLength`  max cache object length 256kb
 *
 * @param {Object} options
 * @return {Function}
 * @api public
 */

module.exports = function staticCache(options){
  var options = options || {}
    , cache = new Cache(options.maxObjects || 128)
    , maxlen = options.maxLength || 1024 * 256;

  console.warn('connect.staticCache() is deprecated and will be removed in 3.0');
  console.warn('use varnish or similar reverse proxy caches.');

  return function staticCache(req, res, next){
    var key = cacheKey(req)
      , ranges = req.headers.range
      , hasCookies = req.headers.cookie
      , hit = cache.get(key);

    // cache static
    // TODO: change from staticCache() -> cache()
    // and make this work for any request
    req.on('static', function(stream){
      var headers = res._headers
        , cc = utils.parseCacheControl(headers['cache-control'] || '')
        , contentLength = headers['content-length']
        , hit;

      // dont cache set-cookie responses
      if (headers['set-cookie']) return hasCookies = true;

      // dont cache when cookies are present
      if (hasCookies) return;

      // ignore larger files
      if (!contentLength || contentLength > maxlen) return;

      // don't cache partial files
      if (headers['content-range']) return;

      // dont cache items we shouldn't be
      // TODO: real support for must-revalidate / no-cache
      if ( cc['no-cache']
        || cc['no-store']
        || cc['private']
        || cc['must-revalidate']) return;

      // if already in cache then validate
      if (hit = cache.get(key)){
        if (headers.etag == hit[0].etag) {
          hit[0].date = new Date;
          return;
        } else {
          cache.remove(key);
        }
      }

      // validation notifiactions don't contain a steam
      if (null == stream) return;

      // add the cache object
      var arr = [];

      // store the chunks
      stream.on('data', function(chunk){
        arr.push(chunk);
      });

      // flag it as complete
      stream.on('end', function(){
        var cacheEntry = cache.add(key);
        delete headers['x-cache']; // Clean up (TODO: others)
        cacheEntry.push(200);
        cacheEntry.push(headers);
        cacheEntry.push.apply(cacheEntry, arr);
      });
    });

    if (req.method == 'GET' || req.method == 'HEAD') {
      if (ranges) {
        next();
      } else if (!hasCookies && hit && !mustRevalidate(req, hit)) {
        res.setHeader('X-Cache', 'HIT');
        respondFromCache(req, res, hit);
      } else {
        res.setHeader('X-Cache', 'MISS');
        next();
      }
    } else {
      next();
    }
  }
};

/**
 * Respond with the provided cached value.
 * TODO: Assume 200 code, that's iffy.
 *
 * @param {Object} req
 * @param {Object} res
 * @param {Object} cacheEntry
 * @return {String}
 * @api private
 */

function respondFromCache(req, res, cacheEntry) {
  var status = cacheEntry[0]
    , headers = utils.merge({}, cacheEntry[1])
    , content = cacheEntry.slice(2);

  headers.age = (new Date - new Date(headers.date)) / 1000 || 0;

  switch (req.method) {
    case 'HEAD':
      res.writeHead(status, headers);
      res.end();
      break;
    case 'GET':
      if (utils.conditionalGET(req) && fresh(req.headers, headers)) {
        headers['content-length'] = 0;
        res.writeHead(304, headers);
        res.end();
      } else {
        res.writeHead(status, headers);

        function write() {
          while (content.length) {
            if (false === res.write(content.shift())) {
              res.once('drain', write);
              return;
            }
          }
          res.end();
        }

        write();
      }
      break;
    default:
      // This should never happen.
      res.writeHead(500, '');
      res.end();
  }
}

/**
 * Determine whether or not a cached value must be revalidated.
 *
 * @param {Object} req
 * @param {Object} cacheEntry
 * @return {String}
 * @api private
 */

function mustRevalidate(req, cacheEntry) {
  var cacheHeaders = cacheEntry[1]
    , reqCC = utils.parseCacheControl(req.headers['cache-control'] || '')
    , cacheCC = utils.parseCacheControl(cacheHeaders['cache-control'] || '')
    , cacheAge = (new Date - new Date(cacheHeaders.date)) / 1000 || 0;

  if ( cacheCC['no-cache']
    || cacheCC['must-revalidate']
    || cacheCC['proxy-revalidate']) return true;

  if (reqCC['no-cache']) return true;

  if (null != reqCC['max-age']) return reqCC['max-age'] < cacheAge;

  if (null != cacheCC['max-age']) return cacheCC['max-age'] < cacheAge;

  return false;
}

/**
 * The key to use in the cache. For now, this is the URL path and query.
 *
 * 'http://example.com?key=value' -> '/?key=value'
 *
 * @param {Object} req
 * @return {String}
 * @api private
 */

function cacheKey(req) {
  return utils.parseUrl(req).path;
}