connect-sdch
Version:
SDCH middleware for connect and node.js
481 lines (404 loc) • 12.1 kB
JavaScript
/*!
* connect-sdch
* https://github.com/baranov1ch/connect-sdch
*
* Copyright 2014 Alexey Baranov <me@kotiki.cc>
* Released under the MIT license
*/
var accepts = require('accepts');
var bytes = require('bytes');
var compressible = require('compressible');
var debug = require('debug')('sdch');
var onHeaders = require('on-headers');
var rangeParser = require('range-parser');
var sdch = require('sdch');
var vary = require('vary');
var zlib = require('zlib');
exports.DictionaryStorage = DictionaryStorage;
exports.filter = function (req, res) {
var type = res.getHeader('Content-Type');
if (type === undefined) {
debug(req.url + ' has no content-type header, not compressible');
return false;
}
if (type === 'application/x-sdch-dictionary')
return true;
if (!compressible(type)) {
debug('%s not compressible', type);
return false;
}
return true;
};
exports.postSdchMethods = {
gzip: zlib.createGzip,
deflate: zlib.createDeflate
};
exports.defaultToSend = function(storage) {
return function(req, availableDicts) {
var hash = {}
availableDicts.forEach(function(e) { hash[e] = true; });
return storage.dicts().filter(function(e) {
return !hash[e.clientHash];
});
};
};
exports.defaultToEncode = function(storage) {
return function(req, availableDicts) {
var dicts = availableDicts.map(function(e) {
return storage.getByClientHash(e);
}).filter(function(e) {
return e;
}).filter(function(e) {
return !e.path || sdch.clientUtils.pathMatch(req.url, e.path);
}).sort(function(a, b) {
if (!a.path && !b.path)
return 0;
if (!a.path)
return Infinity;
if (!b.path)
return -Infinity;
return b.path.length - a.path.length;
});
if (dicts.length === 0)
return null;
return dicts[0];
};
};
exports.addCacheControlPrivate = function(res) {
var cc = res.getHeader('Cache-Control');
if (!cc) {
res.setHeader('Cache-Control', 'private');
return;
}
var privateSeen = false;
var newCC = cc.split(',').map(function(e) {
var parts = e.split('=');
var key = parts.shift.trim();
var val = parts.shift();
if (key.toLowerCase() === 'public') {
privateSeen = true;
// Remove public and replace with private.
return 'private';
}
if (key.toLowerCase() === 'private') {
// if we have not already set 'private', then do it and skip all the
// field-name stuff, we're totally private.
if (!privateSeen)
return 'private';
// Else, return nothing.
return '';
}
}).reduce(function(a, b) {
if (a === '')
return b;
if (b === '')
return a;
return a + ',' + b;
});
res.setHeader('Cache-Control', newCC);
};
exports.serve = function(storage, opts) {
opts = opts || {}
return function(req, res, next) {
var dict = storage.getByUrl(req.url);
if (!dict) {
next();
return;
}
if (req.method !== 'GET' && req.method !== 'HEAD') {
res.statusCode = 'OPTIONS' === req.method
? 200
: 405;
res.setHeader('Allow', 'GET, HEAD, OPTIONS');
res.end();
return;
}
var etag = req.headers['if-none-match'];
if (etag) {
etags = etag.split(',').map(function(e) { return e.trim(); });
if (etags.indexOf(dict.etag) !== -1) {
res.setHeader('Etag', dict.etag);
res.setHeader('Accept-Ranges', 'bytes');
res.statusCode = 304;
res.end();
return;
}
}
var ranges = req.headers['range'];
var ifRange = req.headers['if-range'];
if (ifRange && dict.etag !== ifRange)
ranges = null;
if (ranges) {
ranges = rangeParser(dict.getLength(), ranges);
if (ranges.length !== 1 || ranges.type !== 'bytes' || ranges === -1 ||
ranges === -2) {
res.statusCode = 416;
res.end();
return;
} else {
opts.range = ranges[0];
}
}
res.setHeader('content-type', 'application/x-sdch-dictionary');
res.setHeader('Etag', dict.etag);
res.setHeader('Accept-Ranges', 'bytes');
if (req.method === 'HEAD') {
res.end();
return;
}
if (opts.range) {
res.statusCode = 206;
var rangeHeader = opts.range.start + '-' +
opts.range.end + '/' +
dict.getLength();
res.setHeader('Content-Range', rangeHeader);
} else {
res.setHeader('Content-Length', dict.getLength());
}
dict.getOutputStream(opts).pipe(res);
}
};
exports.multicompress = function(options) {
options = options || {};
var compressFilter = options.filter || exports.filter;
var encodingFilter = options.encodingFilter || exports.defaultEncodingFilter;
var acceptable = options.acceptable || [];
if (acceptable.indexOf('identity') === -1)
acceptable.push('identity');
var threshold;
if (false === options.threshold || 0 === options.threshold) {
threshold = 0
} else if ('string' === typeof options.threshold) {
threshold = bytes(options.threshold);
} else {
threshold = options.threshold || 1024;
}
return function(req, res, next) {
var compress = true;
var listeners = [];
var write = res.write;
var on = res.on;
var end = res.end;
var stream;
req.on('close', function() {
res.write = res.end = function() {};
});
// flush is noop by default
res.flush = noop;
// proxy
res.write = function(chunk, encoding) {
if (!this._header) {
// if content-length is set and is lower
// than the threshold, don't compress
var len = Number(res.getHeader('Content-Length'));
checkthreshold(len);
this._implicitHeader();
}
return stream
? stream.write(new Buffer(chunk, encoding))
: write.call(res, chunk, encoding);
};
res.end = function(chunk, encoding) {
var len;
if (chunk)
len = Buffer.isBuffer(chunk)
? chunk.length
: Buffer.byteLength(chunk, encoding);
if (!this._header)
checkthreshold(len);
if (chunk)
this.write(chunk, encoding);
return stream
? stream.end()
: end.call(res);
};
res.on = function(type, listener) {
if (!listeners || type !== 'drain')
return on.call(this, type, listener);
if (stream)
return stream.on(type, listener);
// buffer listeners for future stream
listeners.push([type, listener]);
return this;
}
function checkthreshold(len) {
if (compress && len < threshold) {
debug('size below threshold');
compress = false;
}
}
function nocompress(msg) {
debug('no compression' + (msg ? ': ' + msg : ''));
addListeners(res, on, listeners);
listeners = null;
}
onHeaders(res, function() {
if (!compressFilter(req, res)) {
nocompress('filtered');
return;
}
vary(res, 'Accept-Encoding');
vary(res, 'Avail-Dictionary');
if (!compress) {
nocompress();
return;
}
var encoding = res.getHeader('Content-Encoding') || 'identity';
if (!encodingFilter(encoding)) {
nocompress('unencodable encoding');
return;
}
if ('HEAD' === req.method) {
nocompress('HEAD request');
return;
}
var accept = accepts(req);
var method = accept.encodings(acceptable);
if (!method || method === 'identity') {
nocompress('not acceptable');
return;
}
if (options.beforeEncoding)
options.beforeEncoding(req, res);
if (options.createEncoder)
stream = options.createEncoder(req, res, method);
if (!stream) {
nocompress('no encoder found');
return;
}
var contentEncoding = res.getHeader('Content-Encoding');
if (contentEncoding) {
contentEncoding += ',';
contentEncoding += method;
} else {
contentEncoding = method;
}
addListeners(stream, stream.on, listeners);
// overwrite the flush method
res.flush = function(){
debug('res.flush');
stream.flush();
};
// header fields
res.setHeader('Content-Encoding', contentEncoding);
res.removeHeader('Content-Length');
// compression
stream.on('data', function(chunk){
if (write.call(res, chunk) === false) {
stream.pause();
}
});
stream.on('end', function(){
end.call(res);
});
on.call(res, 'drain', function() {
stream.resume();
});
});
next();
};
};
exports.compress = function(options, encoderOptions) {
options = options || {};
options.acceptable = ['gzip', 'deflate'];
options.encodingFilter = function (enc) {
return enc === 'sdch';
};
options.createEncoder = function(req, res, method) {
if (method === 'gzip')
return zlib.createGzip(encoderOptions);
if (method === 'deflate')
return zlib.createDeflate(encoderOptions);
return null;
};
return exports.multicompress(options);
}
exports.encode = function(options, encoderOptions) {
options = options || {};
if (!options.storage) {
if (!(options.toSend && options.toEncode))
throw new Error('provide either storage or dictionary selectors');
}
var toSend = options.toSend || exports.defaultToSend(options.storage);
var toEncode = options.toEncode || exports.defaultToEncode(options.storage);
encoderOptions = encoderOptions || {};
// Set default vcdiff config from SDCH spec unless user defined smth. else.
if (encoderOptions.interleaved === undefined)
encoderOptions.interleaved = true;
if (encoderOptions.checksum === undefined)
encoderOptions.checksum = true;
options.encodingFilter = function (enc) {
return enc === 'identity';
};
options.acceptable = ['sdch'];
options.beforeEncoding = function (req, res) {
var dicts = toSend(req, getAvailableDictionaries(req));
if (!dicts)
return;
if (!(dicts instanceof Array))
dicts = [dicts];
if (dicts.length === 0)
return;
var getDict = dicts.map(function(e) {
return e.url;
}).reduce(function(a,b) {
return a + ', ' + b;
});
if (getDict)
res.setHeader('Get-Dictionary', getDict);
};
options.createEncoder = function(req, res, method) {
if (method !== 'sdch')
return null;
var availableDicts = getAvailableDictionaries(req);
if (availableDicts.length === 0) {
debug('no dictionaries available');
return null;
}
var dict = toEncode(req, availableDicts);
if (!dict) {
res.setHeader('X-SDCH-Encode', '0');
debug('no dictionaries chosen');
return null;
}
// TODO: adjust caching headers.
// addCacheControlPrivate(res);
return sdch.createSdchEncoder(dict, encoderOptions);
};
return exports.multicompress(options);
}
function DictionaryStorage(dicts) {
if (!(dicts instanceof Array))
throw new Error('dicts should be and Array of SdchDictionary');
this._dicts = dicts;
this._clientHashMap = {};
this._urlMap = {};
this._pathMap = {};
// Fill indices.
var self = this;
dicts.forEach(function(e) {
self._clientHashMap[e.clientHash] = e;
self._urlMap[e.url] = e;
});
};
DictionaryStorage.prototype.getByClientHash = function(hash) {
return this._clientHashMap[hash];
};
DictionaryStorage.prototype.getByUrl = function(url) {
return this._urlMap[url];
};
DictionaryStorage.prototype.dicts = function() {
return this._dicts;
};
function getAvailableDictionaries(req) {
var header = req.headers['avail-dictionary'];
if (typeof header !== 'string')
return [];
return header.split(',').map(function(e) { return e.trim(); });
};
function addListeners(stream, on, listeners) {
for (var i = 0; i < listeners.length; i++) {
on.apply(stream, listeners[i]);
}
};
function noop() {};