* mostly correct (supports cache-control, max-age, private and no-store headers)
* in-memory JS storage
* Redis database storage

Does not yet support:
* full cache-correctness (e.g. ETag validation)
* correct HTTP status codes
This commit is contained in:
Eugen Sawin 2014-06-06 01:59:52 +02:00
Родитель edd4351c2d
Коммит 940db522d1
11 изменённых файлов: 295 добавлений и 24 удалений

Просмотреть файл

@ -3,7 +3,7 @@
"curly": true,
"eqeqeq": true,
"es3": false,
"forin": false,
"forin": true,
"freeze": true,
"immed": false,
"indent": 2,
@ -26,5 +26,6 @@
"maxlen": 80,
"node": true,
"browser": false,
"esnext": true
"esnext": true,
"sub": true
}

Просмотреть файл

@ -15,3 +15,16 @@ pac:
gzip:
# Compression level for gzip.
level: 9
# Cache configuration.
cache:
# Enable cache. Don't rename it to 'enabled' as this will crash the parser.
use: true
# Supported cache types are:
# - basic (in-memory JS)
# - redis (requires Redis database)
type: basic
# Configuration for non-basic database server.
database:
host: localhost
port: 55255

41
lib/cache.js Normal file
Просмотреть файл

@ -0,0 +1,41 @@
var storage = require('./storage');
var DEF_MAX_SIZE = 3000;
// Resource caching interface, creates a new storage.
var Cache = function(options) {
this.maxSize = options.maxCacheSize || DEF_MAX_SIZE;
this.storage = storage.create(options.cache.type);
};
Cache.prototype.save = function(key, value, expire) {
this.storage.save(key, value, expire);
};
Cache.prototype.load = function(key, callback) {
this.storage.load(key, callback);
};
// Default cache instance.
var instance = null;
// Initializes a new cache for given storage type.
exports.init = function(options) {
if (options.cache.use) {
instance = new Cache(options);
}
};
// Saves new cache entry.
exports.save = function(key, value, expire) {
if (instance) {
instance.save(key, value, expire);
}
};
// Returns cache entry for given key if available.
exports.load = function(key, callback) {
if (instance) {
instance.load(key, callback);
} else {
callback();
}
};

Просмотреть файл

@ -38,7 +38,6 @@ function addVersionConfig() {
CONFIG.getConfigSources().forEach(function(config) {
addVersionConfig();
console.log('Using configuration %s:', config.name);
console.dir(config.parsed);
});
var SpdyProxy = require('./proxy');

22
lib/plugins/cacheload.js Normal file
Просмотреть файл

@ -0,0 +1,22 @@
var url = require('url');
var cache = require('../cache');
exports.load = function(request, dest, options, callback) {
if (!options.cache.use || request.method !== 'GET') {
// Do nothing for POST and CONNECT requests or when caching is disabled.
callback(null, false);
}
var path = request.headers.path || url.parse(request.url).path;
var key = [request.headers.host, path];
cache.load(key, function(error, cached) {
if (!error && cached) {
var value = cached.value;
request.log('delivering %d bytes from cache', cached.size);
dest.writeHead(value[0][0], value[0][1], value[0][2]);
// TODO(esawin): fix explicit buffer creation workaround for redis.
dest.end(new Buffer(value[1]));
}
callback(error, Boolean(cached));
});
};

86
lib/plugins/cachesave.js Normal file
Просмотреть файл

@ -0,0 +1,86 @@
var url = require('url');
var cache = require('../cache');
var MAX_EXPIRE = 7 * 24 * 60 * 60;
// var DEF_EXPIRE = 1 * 24 * 60 * 60;
var DEF_EXPIRE = 30;
// Parses cache control header and last-modified.
function parseCacheControl(headers) {
var lastMod = headers['last-modified'];
var expires = headers.expires;
var cacheHeaders = {
'last-modified': lastMod ? new Date(lastMod) : null,
expires: expires ? new Date(expires) : null
};
var cacheControl = headers['cache-control'];
if (cacheControl) {
cacheControl.split(',').forEach(function(elem) {
elem = elem.trim();
var i = elem.indexOf('=');
if (i === -1) {
cacheHeaders[elem] = true;
} else {
cacheHeaders[elem.substr(0, i)] = elem.substr(i + 1);
}
});
}
return cacheHeaders;
}
// Returns the expire time in seconds.
function maxAge(cacheHeaders) {
var expire = cacheHeaders['s-maxage'] || cacheHeaders['max-age'];
if (expire) {
expire = parseInt(expire);
} else if (cacheHeaders.expires) {
expire = (cacheHeaders.expires.getTime() - (new Date()).getTime()) / 1000;
} else {
expire = DEF_EXPIRE;
}
return Math.min(MAX_EXPIRE, expire);
}
// Aggregates data and caches it when appropriate.
exports.handleResponse = function(request, source, dest, options) {
var cacheControl = parseCacheControl(source.headers);
if (!options.cache.use || request.method !== 'GET' ||
cacheControl['private'] || cacheControl['no-store']) {
// Do nothing for POST and CONNECT requests or when caching is disabled.
source.pipe(dest);
source.resume();
return;
}
// Expire time in seconds.
var expire = maxAge(cacheControl);
var count = 0;
var data = [];
source.on('data', function(chunk) {
count += chunk.length;
data.push(chunk);
dest.write(chunk);
});
source.on('end', function() {
if (expire > 0) {
// Cache data.
data = Buffer.concat(data);
var path = request.headers.path || url.parse(request.url).path;
var key = [request.headers.host, path];
var header = [source.statusCode, '', source.headers];
cache.save(key,
{ value: [header, data, cacheControl], size: data.length }, expire);
request.log('cached %d bytes for %d s', count, expire);
}
dest.end();
});
source.resume();
};

Просмотреть файл

@ -4,9 +4,10 @@ var Duplex = require('stream').Duplex;
var PassthroughPlugin = require('./passthrough');
var GzipPlugin = require('./gzip');
var DeliverPlugin = require('./deliver');
var CacheSavePlugin = require('./cachesave');
var plugins = {
response: [PassthroughPlugin, GzipPlugin, DeliverPlugin]
response: [PassthroughPlugin, GzipPlugin, CacheSavePlugin, DeliverPlugin]
};
function PipedResponse(response, options) {

Просмотреть файл

@ -4,7 +4,11 @@ var util = require('util');
var net = require('net');
var http = require('http');
var spdy = require('spdy');
var sync = require('synchronize');
var plugins = require('./plugins');
var cache = require('./cache');
var CacheLoadPlugin = require('./plugins/cacheload');
// Shortens the given URL to given maxLen by inserting '...'.
function shortenUrl(url, maxLen) {
@ -22,6 +26,7 @@ function shortenUrl(url, maxLen) {
var SpdyProxy = function(options) {
function handleListen() {
cache.init(options);
console.log('%s listens on port %d', options.name, options.proxy.port);
}
@ -43,31 +48,38 @@ var SpdyProxy = function(options) {
shortenUrl(httpOpts.path)
);
var forwardRequest = http.request(httpOpts, function(forwardResponse) {
forwardResponse.headers['proxy-agent'] = options.title;
request.log = function() {
console.log('%s\t%s\t%s',
new Date().toISOString(),
shortenUrl(request.url),
util.format.apply(null, Array.prototype.slice.call(arguments, 0)));
};
request.log = function() {
console.log('%s\t%s\t%s',
new Date().toISOString(),
shortenUrl(request.url),
util.format.apply(null,
Array.prototype.slice.call(arguments, 0)));
};
sync.fiber(function() {
// Load from cache, if available.
var cached = sync.await(CacheLoadPlugin.load(request, response, options,
sync.defer()));
if (cached) {
return;
}
plugins.handleResponse(request, forwardResponse, response, options);
});
var forwardRequest = http.request(httpOpts, function(forwardResponse) {
forwardResponse.headers['proxy-agent'] = options.title;
plugins.handleResponse(request, forwardResponse, response, options);
});
forwardRequest.on('error', function(e) {
console.error('Client error: %s', e.message);
response.writeHead(502, 'Proxy fetch failed');
response.end();
});
forwardRequest.on('error', function(e) {
console.error('Client error: '.error + e.message);
response.writeHead(502, 'Proxy fetch failed');
response.end();
});
// Pipe POST data.
request.pipe(forwardRequest);
// Pipe POST data.
request.pipe(forwardRequest);
response.on('close', function() {
forwardRequest.abort();
response.on('close', function() {
forwardRequest.abort();
});
});
}

24
lib/storage/basic.js Normal file
Просмотреть файл

@ -0,0 +1,24 @@
var basicCache = require('memory-cache');
var TYPE = 'basic';
// Basic in-memory pure JS database.
var BasicDb = function() {
this.type = TYPE;
};
BasicDb.prototype.save = function(key, value, expire, onSuccess, onExpire) {
basicCache.put(key, value, expire * 1000, onExpire);
onSuccess();
};
BasicDb.prototype.load = function(key, callback) {
var value = basicCache.get(key);
callback(null, value);
};
var instance = new BasicDb();
exports.type = TYPE;
exports.connect = function() {
return instance;
};

49
lib/storage/index.js Normal file
Просмотреть файл

@ -0,0 +1,49 @@
var basic = require('./basic');
var redis = require('./redis');
// Available database modules.
var DATABASES = [basic, redis];
// Creates a new storage connected to given database.
var Storage = function(db) {
this.db = db;
this.size = 0;
this.memSize = 0;
console.log('### new %s storage created', this.db.type);
};
Storage.prototype.save = function(key, value, expire) {
var that = this;
var memSize = value.size;
function onSuccess() {
that.size += 1;
that.memSize += memSize;
console.log('+++ storage %d items %d MB', that.size,
(that.memSize / 1048576).toFixed(2));
}
function onExpire() {
that.size -= 1;
that.memSize -= memSize;
console.log('--- storage %d items %d MB', that.size,
(that.memSize / 1048576).toFixed(2));
}
this.db.save(key, value, expire, onSuccess, onExpire);
};
Storage.prototype.load = function(key, callback) {
this.db.load(key, callback);
};
// Creates a new storage instance connected to given database type.
exports.create = function(type) {
var db = null;
DATABASES.forEach(function(d) {
if (d.type === type) {
db = d.connect();
}
});
return db && new Storage(db);
};

23
lib/storage/redis.js Normal file
Просмотреть файл

@ -0,0 +1,23 @@
var redis = require('redis');
var TYPE = 'redis';
var RedisDb = function() {
this.type = TYPE;
this.client = redis.createClient();
};
RedisDb.prototype.save = function(key, value, expire, onSuccess) {
this.client.set(key.toString(), JSON.stringify(value), onSuccess);
this.client.expire(key.toString(), expire);
};
RedisDb.prototype.load = function(key, callback) {
this.client.get(key.toString(), function(error, reply) {
callback(error, JSON.parse(reply));
});
};
exports.type = TYPE;
exports.connect = function() {
return new RedisDb();
};