Permalink
Browse files

Initial implementation of connect-cachify-simple

hash-store takes care of calculating and caching the hashes
connect-cachify replaces request.url if known hash is encountered
  • Loading branch information...
1 parent 4c7651c commit d433b8eec40b3762ea26a5ea605172e9b108cba6 @pirxpilot committed Sep 17, 2013
Showing with 340 additions and 4 deletions.
  1. +3 −1 .jshintrc
  2. +3 −0 Readme.md
  3. +1 −0 index.js
  4. +66 −0 lib/cachify-static.js
  5. +41 −0 lib/hash-store.js
  6. +8 −3 package.json
  7. +59 −0 test/cachify-static.js
  8. +1 −0 test/fixtures/a.css
  9. +1 −0 test/fixtures/texts/b.txt
  10. +1 −0 test/fixtures/texts/c.json
  11. +38 −0 test/hash-store.js
  12. +2 −0 test/mocha.opts
  13. +116 −0 test/support/http.js
View
@@ -2,9 +2,11 @@
"undef": true,
"unused": true,
"laxbreak": true,
+ "laxcomma": true,
+ "proto": true,
"globals": {
"console": false,
- "exports": false,
+ "exports": true,
"process": false,
"require": false,
"module": false,
View
@@ -6,6 +6,9 @@
static (simpler and faster) variant of [connect-cachify][] middleware
+Works by running all hash calculations during application startup, which means that it won't handle
+dynamically generated files.
+
## Installation
$ npm install connect-cachify-static
View
@@ -0,0 +1 @@
+module.exports = require('./lib/cachify-static');
View
@@ -0,0 +1,66 @@
+var debug = require('debug')('connect:cachify-static');
+var utils = require('connect').utils;
+var url = require('url');
+var hashStore = require('./hash-store');
+
+var store;
+
+function cachify(path) {
+ var hash = store.getHash(path);
+ if (!hash) {
+ debug('cachify called for unknown path %s', path);
+ return path;
+ }
+ return '/' + hash + path;
+}
+
+function toHashAndPath(pathname) {
+ var split, hap = {};
+
+ pathname = pathname.slice(1);
+ split = pathname.indexOf('/');
+ if (split < 1) {
+ hap.path = pathname;
+ }
+ else {
+ hap.hash = pathname.slice(0, split);
+ hap.path = pathname.slice(split + 1);
+ }
+ return hap;
+}
+
+exports = module.exports = function(root, opts) {
+
+ opts = opts || {};
+ opts.match = opts.match || /\.js$|\.css$|\.png$|\.gif$|\.jpg$/;
+
+ store = hashStore(root, opts.match);
+
+ return function cachifyStatic(req, res, next) {
+ var parsedUrl = utils.parseUrl(req);
+ var hashAndPath = toHashAndPath(parsedUrl.pathname);
+
+ if (typeof res.locals === 'function') {
+ res.locals({cachify: cachify});
+ }
+
+ if (!store.isHash(hashAndPath.hash)) {
+ return next();
+ }
+ // this is where magic happens
+ res.setHeader('Cache-Control', 'public, max-age=31536000');
+ parsedUrl.pathname = '/' + hashAndPath.path;
+ req.url = url.format(parsedUrl);
+ debug('Updated URL: %s', req.url);
+
+ if (opts.control_headers) {
+ // strip cache related headers
+ res.on('header', function () {
+ ['ETag', 'Last-Modified'].forEach(res.removeHeader, res);
+ });
+ }
+ next();
+ };
+};
+
+exports.cachify = cachify;
View
@@ -0,0 +1,41 @@
+var debug = require('debug')('connect:cachify-static');
+var fs = require('fs');
+var find = require('find');
+var crypto = require('crypto');
+
+module.exports = function hashStore(root, match) {
+ var my = {
+ hashes: Object.create(null),
+ paths2hashes: Object.create(null)
+ };
+
+ // using last 10 characters of MD5 hash is arbitrary - any pseudo uniqe number would do
+ function calculate(path) {
+ var md5 = crypto.createHash('md5');
+ var data = fs.readFileSync(path);
+ md5.update(data);
+ return md5.digest('hex').slice(-10);
+ }
+
+ function isHash(hash) {
+ return hash && my.hashes[hash];
+ }
+
+ function getHash(path) {
+ path = path.slice(1); // strip leading '/'
+ return my.paths2hashes[path];
+ }
+
+ debug('Calculating hashes for files in %s.', root);
+ find.fileSync(match, root).forEach(function(file) {
+ var hash = calculate(file);
+ my.hashes[hash] = true;
+ my.paths2hashes[file.slice(root.length + 1)] = hash;
+ });
+ debug('Calculating hashes for %d files', Object.keys(my.paths2hashes).length);
+
+ return {
+ isHash: isHash,
+ getHash: getHash
+ };
+};
View
@@ -13,16 +13,21 @@
"keywords": [
"connect",
"middleware",
- "gzip",
- "compress"
+ "cachify"
],
"author": "Damian Krzeminski <pirxpilot@code42day.com>",
"license": "MIT",
"bugs": {
"url": "https://github.com/code42day/connect-cachify-static/issues"
},
+ "dependencies": {
+ "find": "~0",
+ "debug": "~0",
+ "connect": "~2"
+ },
"devDependencies": {
"jshint": "~2",
- "mocha": "~1"
+ "mocha": "~1",
+ "should": "~1"
}
}
@@ -0,0 +1,59 @@
+var cachifyStatic = require('..');
+
+var connect = require('connect');
+var fixtures = __dirname + '/fixtures';
+var app = connect();
+
+/* global describe, it */
+
+
+app.use(cachifyStatic(fixtures, {
+ match: /\.css$|\.txt$/,
+ control_headers: true
+}));
+app.use(connect.static(fixtures));
+
+app.use(function(req, res){
+ res.statusCode = 404;
+ res.end('sorry!');
+});
+
+describe('cachifyStatic', function(){
+ it('should serve static files', function(done){
+ var url = cachifyStatic.cachify('/a.css');
+
+ url.should.be.eql('/9a6f75849b/a.css');
+
+ app.request()
+ .get(url)
+ .expect('1', done);
+ });
+
+ it('should set cache headers', function(done){
+ app.request()
+ .get(cachifyStatic.cachify('/a.css'))
+ .expect('Cache-Control', 'public, max-age=31536000', done);
+ });
+
+ it('should strip other headers', function(done){
+ app.request()
+ .get(cachifyStatic.cachify('/a.css'))
+ .end(function(res) {
+ res.headers.should.not.have.property('etag');
+ done();
+ });
+ });
+
+ it('should not mess not cachified files', function(done){
+ app.request()
+ .get('/texts/b.txt')
+ .expect('2', done);
+ });
+
+ it('should ignore wrong hashes', function(done){
+ app.request()
+ .set('Accept-Encoding', 'gzip')
+ .post('/0123456789/a.css')
+ .expect(404, done);
+ });
+});
View
@@ -0,0 +1 @@
+1
@@ -0,0 +1 @@
+2
@@ -0,0 +1 @@
+3
View
@@ -0,0 +1,38 @@
+var should = require('should');
+var path = require('path');
+var hashStore = require('../lib/hash-store');
+
+/*global describe, it */
+
+// 6d7fce9fee471194aa8b5b d9f2a7baf3 fixtures/texts/c.json
+// b026324c6904b2a9cb4b88 9a6f75849b fixtures/a.css
+
+// 26ab0db90d72e28ad0ba1e 89cc14862c fixtures/texts/b.txt
+
+describe('hash store', function() {
+ var root = path.join(__dirname, 'fixtures');
+ var store = hashStore(root, /\.json$|\.css$/);
+
+ it('finds all matching files', function() {
+ store.getHash('/texts/c.json').should.eql('d9f2a7baf3');
+ store.getHash('/a.css').should.eql('9a6f75849b');
+ });
+
+ it('ignores unmatched files', function() {
+ should.not.exist(store.getHash('/texts/b.txt'));
+ });
+
+ it('ignores nonexistent files', function() {
+ should.not.exist(store.getHash('/no/such/file'));
+ });
+
+ it('properly determines if hash is valid', function() {
+ store.isHash('d9f2a7baf3').should.be.eql(true);
+ store.isHash('9a6f75849b').should.be.eql(true);
+
+ should.not.exist(store.isHash('89cc14862c'));
+ should.not.exist(store.isHash('qqqq'));
+ should.not.exist(store.isHash());
+ });
+
+});
View
@@ -0,0 +1,2 @@
+--require should
+--require test/support/http
View
@@ -0,0 +1,116 @@
+
+/**
+ * Module dependencies.
+ */
+
+var EventEmitter = require('events').EventEmitter
+ , methods = ['get', 'post', 'put', 'delete', 'head']
+ , connect = require('connect')
+ , http = require('http');
+
+module.exports = request;
+
+connect.proto.request = function(){
+ return request(this);
+};
+
+function request(app) {
+ return new Request(app);
+}
+
+function Request(app) {
+ var self = this;
+ this.data = [];
+ this.header = {};
+ this.app = app;
+ if (!this.server) {
+ this.server = http.Server(app);
+ this.server.listen(0, function(){
+ self.addr = self.server.address();
+ self.listening = true;
+ });
+ }
+}
+
+/**
+ * Inherit from `EventEmitter.prototype`.
+ */
+
+Request.prototype.__proto__ = EventEmitter.prototype;
+
+methods.forEach(function(method){
+ Request.prototype[method] = function(path){
+ return this.request(method, path);
+ };
+});
+
+Request.prototype.set = function(field, val){
+ this.header[field] = val;
+ return this;
+};
+
+Request.prototype.write = function(data){
+ this.data.push(data);
+ return this;
+};
+
+Request.prototype.request = function(method, path){
+ this.method = method;
+ this.path = path;
+ return this;
+};
+
+Request.prototype.expect = function(body, fn){
+ var args = arguments;
+ this.end(function(res){
+ switch (args.length) {
+ case 3:
+ res.headers.should.have.property(body.toLowerCase(), args[1]);
+ args[2]();
+ break;
+ default:
+ if ('number' == typeof body) {
+ res.statusCode.should.equal(body);
+ } else {
+ res.body.should.equal(body);
+ }
+ fn();
+ }
+ });
+};
+
+Request.prototype.end = function(fn){
+ var self = this;
+
+ if (this.listening) {
+ var req = http.request({
+ method: this.method
+ , port: this.addr.port
+ , host: this.addr.address
+ , path: this.path
+ , headers: this.header
+ });
+
+ this.data.forEach(function(chunk){
+ req.write(chunk);
+ });
+
+ req.on('response', function(res){
+ var buf = '';
+ res.setEncoding('utf8');
+ res.on('data', function(chunk){ buf += chunk; });
+ res.on('end', function(){
+ res.body = buf;
+ fn(res);
+ });
+ });
+
+ req.end();
+ } else {
+ this.server.on('listening', function(){
+ self.end(fn);
+ });
+ }
+
+ return this;
+};

0 comments on commit d433b8e

Please sign in to comment.