expirable
Version:
Expirable cache
339 lines (278 loc) • 7.34 kB
JavaScript
'use strict';
var EventEmitter = require('events').EventEmitter;
/**
* Simple data interface.
*
* @constructor
* @param {Mixed} data
* @param {Number} expires
* @param {Boolean} streaming
* @api private
*/
function Data(value, expires, streaming) {
this.value = value;
this.expires = expires;
this.streaming = streaming || false;
this.last = Date.now();
}
/**
* Simple automatic expiring cache.
*
* Options:
*
* - expire {String} how long should the objects stay alive.
* - interval {String} when should the cleaning operation start.
*
* @constructor
* @param {Number} expire amount of milliseconds we should cache the data.
* @param {Object} options options.
* @api public
*/
function Expire(options) {
// Legacy formatting
if (typeof options === 'string') {
options = { expire: options };
}
options = options || {};
this.cache = Object.create(null);
this.prefix = options.prefix || '@'+ Date.now();
this.expiree = Expire.parse(options.expire || '5 minutes');
this.interval = Expire.parse(options.interval || '2 minutes');
this.length = 0;
// Start watching for expired items.
if (!options.manually) this.start();
}
Expire.prototype.__proto__ = EventEmitter.prototype;
/**
* Get an item from the cache based on the given key.
*
* @param {String} key
* @param {Boolean} dontUpdate don't update the expire value
* @returns {Mixed} undefined if there isn't a match, otherwise the result
* @api public
*/
Expire.prototype.get = function get(key, dontUpdate) {
var result = this.cache[this.prefix + key];
if (!result) return undefined;
var now = Date.now();
// We are still streaming in data, so return nothing.
if (result.streaming) return undefined;
// We found a match, make sure that it's not expired.
if (now - result.last >= result.expires) {
this.remove(key, true);
return undefined;
}
// Update the last used time stamp.
if (!dontUpdate) result.last = now;
return result.value;
};
/**
* Stores a new item in the cache, if the key already exists it will override
* it with the new value.
*
* @param {String} key guess what, the key
* @param {Mixed} value value of the key
* @param {String} expires custom expire date
* @returns {Mixed} the value you gave it
* @api public
*/
Expire.prototype.set = function set(key, value, expires) {
key = this.prefix + key;
if (!(key in this.cache)) this.length++;
expires = expires ? Expire.parse(expires) : this.expiree;
this.cache[key] = new Data(value, expires);
return value;
};
/**
* Stores the complete output of a stream in memory.
*
* @param {String} key the key
* @param {Stream} stream the stream that we need to read the data off
* @param {String} expires option custom expire
* @returns {Stream} the stream you passed it
*/
Expire.prototype.stream = function streamer(key, stream, expires) {
var error = false
, chunks = []
, self = this;
this.cache[this.prefix + key] = new Data(undefined, undefined, true);
stream.on('data', function data(buffer) {
chunks.push(buffer);
});
stream.on('error', function errors() {
chunks.length = 0;
error = true;
});
stream.on('end', function end(buffer) {
if (!error) {
if (buffer) chunks.push(buffer);
if (chunks.length) {
self.set(key, Buffer.concat(chunks), expires);
} else {
self.remove(key);
}
} else {
self.remove(key);
}
chunks.length = 0;
});
return stream;
};
/**
* Checks if the item exists in the cache.
*
* @param {String} key
* @returns {Boolean}
* @api public
*/
Expire.prototype.has = function has(key) {
var item = this.cache[this.prefix + key]
, now = Date.now();
return !!item && !item.streaming && (now - item.last) <= item.expires;
};
/**
* Expire a key or update it's expiree.
*
* @param {String} key
* @param {Mixed} expire
*/
Expire.prototype.expire = function expires(key, expire) {
if (!expire) return this.remove(key);
// we have the key, bump it's expire time.
if (this.has(key)) {
key = this.prefix + key;
this.cache[key].expires = Expire.parse(expire);
this.cache[key].last = Date.now();
}
return this;
};
/**
* Remove an item from the cache.
*
* @param {String} key
* @param {Boolean} expired was the reason of removal because it was expired
* @api public
*/
Expire.prototype.remove = function remove(key, expired) {
var prefixed = this.prefix + key;
if (prefixed in this.cache) {
this.length--;
this.emit(key + ':removed', !!expired);
delete this.cache[prefixed];
}
return this;
};
/**
* Iterate over all the keys in our cache.
*
* @param {Function} iterator
* @param {String} context
* @api public
*/
Expire.prototype.forEach = function forEach(iterator, context) {
var now = Date.now();
Object.keys(this.cache).forEach(function iterating(key) {
var clean = key.slice(this.prefix.length)
, data = this.cache[key];
// Make sure that it's not expired.
if (now - data.last >= data.expires) {
return this.remove(clean, true);
}
iterator.call(context || this, clean, data.value, data.expires);
}, this);
return this;
};
/**
* Scans the cache for potential items that should expire.
*
* @api private
*/
Expire.prototype.scan = function scan() {
var now = Date.now()
, result
, clean
, key;
for (key in this.cache) {
clean = key.slice(this.prefix.length);
result = this.cache[key];
if (result.streaming) continue;
if (now - result.last >= result.expires) {
this.remove(clean, true);
}
}
return this;
};
/**
* Stops the expire check timer.
*
* @api public
*/
Expire.prototype.stop = function stop() {
if (this.timer) clearInterval(this.timer);
return this;
};
/**
* Starts the expire check timer.
*
* @api public
*/
Expire.prototype.start = function start() {
// Stop old timers before starting a new one
this.stop();
this.timer = setInterval(this.scan.bind(this), this.interval);
return this;
};
/**
* Destroy the whole cache.
*
* @api public
*/
Expire.prototype.destroy = function destroy() {
this.stop();
this.cache = Object.create(null);
this.length = 0;
return this;
};
/**
* Parse durations to milliseconds. Bluntly copy and pasted from `ms.js` so all
* copyright belongs to them. Except the parts that I fixed because it did some
* stupid things like not always returning numbers or only accepting strings..
*
* @param {String} str
* @return {Number}
*/
Expire.parse = function parse(str) {
if (+str) return +str;
var m = /^((?:\d+)?\.?\d+) *(ms|seconds?|s|minutes?|m|hours?|h|days?|d|years?|y)?$/i.exec(str);
if (!m) return 0;
var n = parseFloat(m[1])
, type = (m[2] || 'ms').toLowerCase();
switch (type) {
case 'years':
case 'year':
case 'y':
return n * 31557600000;
case 'days':
case 'day':
case 'd':
return n * 86400000;
case 'hours':
case 'hour':
case 'h':
return n * 3600000;
case 'minutes':
case 'minute':
case 'm':
return n * 60000;
case 'seconds':
case 'second':
case 's':
return n * 1000;
case 'ms':
return n;
}
};
//
// Expose the Expire helper so we can do some unit testing against it.
//
module.exports = Expire;