├── .gitignore ├── .travis.yml ├── test ├── Makefile ├── README.md ├── cache-miss-1.js ├── cache-hit.js ├── cache-miss-2.js ├── filter.js ├── assurt.js ├── http.js ├── http-simple.js ├── cache-misc.js ├── http-public.js ├── http-only-if-cached.js ├── lib.js └── helper.js ├── package.json ├── examples ├── simple.js ├── web-proxy.js ├── caching-proxy.js └── reverse-proxy.js ├── LICENSE ├── lib ├── handler.js ├── client.js ├── mongodb.js ├── memcached.js ├── memory.js ├── helper.js └── fishback.js └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 0.8 4 | -------------------------------------------------------------------------------- /test/Makefile: -------------------------------------------------------------------------------- 1 | test: 2 | node helper.js 3 | node filter.js 4 | node cache-miss-1.js 5 | node cache-miss-2.js 6 | node cache-hit.js 7 | node cache-misc.js 8 | node http-simple.js 9 | node http-only-if-cached.js 10 | node http-public.js 11 | -------------------------------------------------------------------------------- /test/README.md: -------------------------------------------------------------------------------- 1 | Not very happy with this test code here--it's quite ugly and difficult to read. 2 | But I find that code to test async code is often more difficult to write than 3 | the async code in the first place. 4 | 5 | There's an interesting proxy test suite at 6 | that I will investigate at some point. 7 | Not free, but free for OS projects. 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fishback", 3 | "author": "Michael Stillwell (http://beebo.org/)", 4 | "description": "Simple RFC2616-compliant caching proxy server", 5 | "version": "0.3.1", 6 | "homepage": "https://github.com/ithinkihaveacat/node-fishback", 7 | "repository": "git://github.com/ithinkihaveacat/node-fishback.git", 8 | "bugs": "https://github.com/ithinkihaveacat/node-fishback/issues", 9 | "main": "./lib/fishback", 10 | "dependencies": { 11 | "memjs": ">=0.6.0" 12 | }, 13 | "engines": { 14 | "node": ">=0.8 <0.10" 15 | }, 16 | "scripts": { 17 | "test": "cd test && make" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /test/cache-miss-1.js: -------------------------------------------------------------------------------- 1 | /*jshint forin:true, noarg:true, noempty:true, eqeqeq:true, bitwise:true, strict:true, undef:true, unused:true, curly:true, node:true, indent:4, maxerr:50, globalstrict:true */ 2 | 3 | "use strict"; 4 | 5 | var lib = require("./lib"); 6 | var http = require("./http"); 7 | var assurt = require("./assurt"); 8 | 9 | lib.getCacheList(function (cache, next) { 10 | 11 | var req = new http.ServerRequest({ 12 | url: "/", 13 | method: "GET" 14 | }); 15 | 16 | var res = new http.ServerResponse(); 17 | 18 | req.once('reject', assurt.calls(function () { 19 | cache.close(); 20 | next(); 21 | })); 22 | 23 | cache.request(req, res); 24 | req.fire(); 25 | 26 | }); 27 | -------------------------------------------------------------------------------- /examples/simple.js: -------------------------------------------------------------------------------- 1 | /*jshint forin:true, noarg:true, noempty:true, eqeqeq:true, bitwise:false, strict:true, undef:true, unused:true, curly:true, node:true, indent:4, maxerr:50, globalstrict:true */ 2 | 3 | "use strict"; 4 | 5 | var fishback = require("../lib/fishback"); 6 | var http = require("http"); 7 | 8 | var proxy = fishback.createProxy(new fishback.Client("localhost", 9000)); 9 | proxy.on("newRequest", function (req) { 10 | console.log(req.method + " " + req.url); 11 | }); 12 | proxy.on("newResponse", function (res) { 13 | res.setHeader("cache-control", "public, max-age=3600"); 14 | }); 15 | 16 | http.createServer(proxy.request.bind(proxy)).listen(8000); 17 | 18 | console.log("Listening on port 8000, and proxying to localhost:9000"); 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2011 Michael Stillwell 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /lib/handler.js: -------------------------------------------------------------------------------- 1 | /*jshint forin:true, noarg:true, noempty:true, eqeqeq:true, bitwise:false, strict:true, undef:true, unused:true, curly:true, node:true, indent:4, maxerr:50, globalstrict:true */ 2 | 3 | "use strict"; 4 | 5 | function Handler() { 6 | } 7 | 8 | require('util').inherits(Handler, require('events').EventEmitter); 9 | 10 | /** 11 | * Handles http.Server's 'request' event. 12 | * 13 | * @param {http.ServerRequest} serverRequest [description] 14 | * @param {http.ServerResponse} serverResponse [description] 15 | */ 16 | Handler.prototype.request = function (serverRequest, serverResponse) { 17 | // jshint unused:false 18 | }; 19 | 20 | /** 21 | * Adds an entry to the cache. 22 | * 23 | * @param {http.ClientResponse} res entry to add to the cache 24 | */ 25 | Handler.prototype.response = function (clientResponse) { 26 | // jshint unused:false 27 | }; 28 | 29 | /** 30 | * Closes the underlying memcached connection, etc. 31 | * 32 | * @param {callback} callback) 33 | */ 34 | Handler.prototype.close = function (callback) { 35 | if (callback) { 36 | return callback.call(); 37 | } 38 | }; 39 | 40 | module.exports = Handler; 41 | -------------------------------------------------------------------------------- /examples/web-proxy.js: -------------------------------------------------------------------------------- 1 | /*jshint forin:true, noarg:true, noempty:true, eqeqeq:true, bitwise:false, strict:true, undef:true, unused:true, curly:true, node:true, indent:4, maxerr:50, globalstrict:true */ 2 | 3 | "use strict"; 4 | 5 | // This can be used as a "standard" client-side proxy for browsers, etc. 6 | // Note that SSL is not supported! 7 | 8 | var fishback = require("../lib/fishback"); 9 | var http = require("http"); 10 | 11 | var PORT = 8080; 12 | 13 | var client = new fishback.Client(); 14 | 15 | var proxy = fishback.createProxy(client); 16 | proxy.on('newResponse', function (res) { 17 | console.info(res.method + " " + res.url.slice(0, 75) + (res.url.length > 75 ? " ..." : "")); 18 | res.setHeader("cache-control", "public, max-age=3600"); // Example header adjustment 19 | }); 20 | 21 | var server = new http.Server(); 22 | server.on('request', proxy.request.bind(proxy)); 23 | server.listen(PORT, function () { 24 | console.info("Listening on port " + PORT); 25 | console.info(); 26 | console.info("Try:"); 27 | console.info(); 28 | console.info(" $ curl --proxy localhost:" + PORT + " http://www.bbc.co.uk/news/"); 29 | console.info(); 30 | }); 31 | -------------------------------------------------------------------------------- /test/cache-hit.js: -------------------------------------------------------------------------------- 1 | /*jshint forin:true, noarg:true, noempty:true, eqeqeq:true, bitwise:true, strict:true, undef:true, unused:true, curly:true, node:true, indent:4, maxerr:50, globalstrict:true */ 2 | 3 | "use strict"; 4 | 5 | var lib = require("./lib"); 6 | var http = require("./http"); 7 | var assurt = require("./assurt"); 8 | 9 | lib.getCacheList(function (cache, next) { 10 | 11 | var clientResponse = new http.ClientResponse({ 12 | url: "/", 13 | method: "GET", 14 | statusCode: 200, 15 | headers: { 16 | "cache-control": "public, max-age=60" 17 | }, 18 | data: [ "Hello, World!" ] 19 | }); 20 | 21 | cache.response(clientResponse); 22 | clientResponse.fire(); 23 | 24 | var req = new http.ServerRequest({ 25 | url: "/", 26 | method: "GET" 27 | }); 28 | req.on('reject', assurt.never(function q1() { 29 | cache.close(); 30 | })); 31 | 32 | var res = new http.ServerResponse(); 33 | res.once('end', assurt.calls(function () { 34 | assurt.response(res, { headers: {}, data: "Hello, World!" }); 35 | cache.close(); 36 | next(); 37 | })); 38 | 39 | cache.request(req, res); 40 | req.fire(); 41 | 42 | }); 43 | -------------------------------------------------------------------------------- /test/cache-miss-2.js: -------------------------------------------------------------------------------- 1 | /*jshint forin:true, noarg:true, noempty:true, eqeqeq:true, bitwise:true, strict:true, undef:true, unused:true, curly:true, node:true, indent:4, maxerr:50, globalstrict:true */ 2 | 3 | "use strict"; 4 | 5 | var lib = require("./lib"); 6 | var http = require("./http"); 7 | var assert = require("assert"); 8 | var assurt = require("./assurt"); 9 | 10 | lib.getCacheList(function (cache, next) { 11 | 12 | var req, res; 13 | 14 | res = new http.ClientResponse({ 15 | url: "/foo", 16 | method: "GET", 17 | statusCode: 200, 18 | headers: { 19 | "cache-control": "public, max-age=60" 20 | }, 21 | data: [ "Hello, Foo!" ] 22 | }); 23 | 24 | cache.response(res); 25 | res.fire(); 26 | 27 | req = new http.ServerRequest({ 28 | url: "/", 29 | method: "GET" 30 | }); 31 | 32 | res = new http.ServerResponse(); 33 | res.on('end', function () { 34 | assert.ok(false, "Response is not supposed to be returned!"); 35 | cache.close(); 36 | }); 37 | 38 | req.on('reject', assurt.calls(function () { 39 | cache.close(); 40 | next(); 41 | })); 42 | 43 | cache.request(req, res); 44 | req.fire(); 45 | 46 | }); 47 | -------------------------------------------------------------------------------- /examples/caching-proxy.js: -------------------------------------------------------------------------------- 1 | /*jshint forin:true, noarg:true, noempty:true, eqeqeq:true, bitwise:false, strict:true, undef:true, unused:true, curly:true, node:true, indent:4, maxerr:50, globalstrict:true */ 2 | 3 | "use strict"; 4 | 5 | var fishback = require("../lib/fishback"); 6 | var http = require("http"); 7 | 8 | var PORT = 8080; 9 | var BACKEND_HOST = "www.bbc.co.uk"; 10 | var BACKEND_PORT = "80"; 11 | 12 | var cache = new fishback.Memory(); 13 | cache.on('newRequest', function (req) { 14 | console.info("CACHE " + req.method + " " + req.url); 15 | }); 16 | 17 | var client = new fishback.Client(BACKEND_HOST, BACKEND_PORT); 18 | client.on('newRequest', function (req) { 19 | console.info("CLIENT " + req.method + " " + req.url); 20 | }); 21 | 22 | var proxy = fishback.createCachingProxy(cache, client); 23 | proxy.on('newRequest', function (req) { 24 | console.info("PROXY " + req.method + " " + req.url); 25 | }); 26 | 27 | var server = new http.Server(); 28 | server.on('request', proxy.request.bind(proxy)); 29 | server.listen(PORT, function () { 30 | console.info("Listening on port " + PORT); 31 | console.info(); 32 | console.info("Try:"); 33 | console.info(); 34 | console.info(" $ curl -s -i http://localhost:" + PORT + "/"); 35 | }); 36 | -------------------------------------------------------------------------------- /examples/reverse-proxy.js: -------------------------------------------------------------------------------- 1 | /*jshint forin:true, noarg:true, noempty:true, eqeqeq:true, bitwise:false, strict:true, undef:true, unused:true, curly:true, node:true, indent:4, maxerr:50, globalstrict:true */ 2 | 3 | "use strict"; 4 | 5 | var fishback = require("../lib/fishback"); 6 | var http = require("http"); 7 | 8 | var PORT = 8080; 9 | var BACKEND_HOST = "www.bbc.co.uk"; 10 | var BACKEND_PORT = "80"; 11 | 12 | var client = new fishback.Client(BACKEND_HOST, BACKEND_PORT); 13 | client.on('newRequest', function (req) { 14 | console.info("CLIENT.newRequest " + req.method + " " + req.url); 15 | }); 16 | client.on('newResponse', function (res) { 17 | console.info("CLIENT.newResponse " + res.method + " " + res.backendUrl); 18 | }); 19 | 20 | var proxy = fishback.createProxy(client); 21 | proxy.on('newRequest', function (req) { 22 | console.info("PROXY.newRequest " + req.method + " " + req.url); 23 | }); 24 | proxy.on('newResponse', function (res) { 25 | console.info("PROXY.newResponse " + res.method + " " + res.url); 26 | }); 27 | 28 | var server = new http.Server(); 29 | server.on('request', proxy.request.bind(proxy)); 30 | server.listen(PORT, function () { 31 | console.info("Listening on port " + PORT); 32 | console.info(); 33 | console.info("Try:"); 34 | console.info(); 35 | console.info(" $ curl -s -i http://localhost:" + PORT + "/"); 36 | }); 37 | -------------------------------------------------------------------------------- /test/filter.js: -------------------------------------------------------------------------------- 1 | /*jshint forin:true, noarg:true, noempty:true, eqeqeq:true, bitwise:true, strict:true, undef:true, unused:true, curly:true, node:true, indent:4, maxerr:50, globalstrict:true */ 2 | 3 | "use strict"; 4 | 5 | var DELAY = 500; 6 | 7 | var lib = require("./lib"); 8 | var http = require("./http"); 9 | var assurt = require("./assurt"); 10 | var fishback = require("../lib/fishback"); 11 | 12 | var response = { statusCode: 200, headers: { "cache-control": "max-age=60, private" }, data: [ "Hello, World" ]}; 13 | var expected = { headers: { "foo": "bar", "cache-control": "max-age=60, public" }, data: "Hello, World" }; 14 | 15 | lib.getCacheList(function (cache) { 16 | 17 | var req = new http.ServerRequest({ url: "/", method: "GET" }); 18 | var res = new http.ServerResponse(); 19 | 20 | var client = new fishback.Client(null, null, { 21 | request: assurt.calls(function (options, callback) { 22 | var clientResponse = new http.ClientResponse(response); 23 | callback(clientResponse); 24 | clientResponse.fire(); 25 | return new http.ClientRequest(); 26 | }) 27 | }); 28 | 29 | var proxy = fishback.createCachingProxy(cache, client); 30 | 31 | proxy.on('newRequest', assurt.calls(function (req) { 32 | req.url = "/404"; 33 | })); 34 | 35 | proxy.on('newResponse', assurt.calls(function (res) { 36 | res.setHeader('foo', 'bar'); 37 | res.setHeader( 38 | 'cache-control', 39 | res.getHeader('cache-control').replace(/\bprivate\b/, "public") 40 | ); 41 | })); 42 | 43 | res.on('end', assurt.calls(function () { 44 | assurt.response(res, expected); 45 | })); 46 | 47 | proxy.request(req, res); 48 | req.fire(); 49 | 50 | setTimeout(cache.close.bind(cache), DELAY); 51 | 52 | }); 53 | -------------------------------------------------------------------------------- /test/assurt.js: -------------------------------------------------------------------------------- 1 | /*jshint forin:true, noarg:true, noempty:true, eqeqeq:true, bitwise:false, strict:true, undef:true, unused:true, curly:true, node:true, indent:4, maxerr:50, globalstrict:true */ 2 | 3 | "use strict"; 4 | 5 | var assert = require('assert'); 6 | 7 | /** 8 | * Convenience function for checking whether expected matches actual. 9 | * actual can contain headers not present in expected, but the reverse 10 | * is not true. 11 | * 12 | * @param {object} actual 13 | * @param {object} expected 14 | * @return {boolean} 15 | */ 16 | function response(actual, expected) { 17 | Object.keys(expected.headers).forEach(function (k) { 18 | assert.equal(actual.headers[k], expected.headers[k]); 19 | }); 20 | assert.equal(actual.data, expected.data); 21 | } 22 | 23 | function calls(callback, context) { 24 | 25 | if (calls.count === undefined) { 26 | calls.count = 0; 27 | process.on('exit', function () { 28 | var n_functions = calls.count === 1 ? "1 function" : (calls.count + " functions"); 29 | assert.equal(calls.count, 0, "Failed to call " + n_functions); 30 | }); 31 | } 32 | 33 | calls.count++; 34 | 35 | return function () { 36 | calls.count--; 37 | return callback.apply(context, arguments); 38 | }; 39 | } 40 | 41 | function once(callback, context) { 42 | var count = 0; 43 | process.on('exit', function () { 44 | var name = callback.name ? callback.name : "unknown"; 45 | assert.equal(count, 1, "Unexpectedly called function [" + name + "] " + count + " times"); 46 | }); 47 | return function () { 48 | count++; 49 | return callback.apply(context, arguments); 50 | }; 51 | } 52 | 53 | function never(callback, context) { 54 | return function () { 55 | var name = callback.name ? callback.name : "unknown"; 56 | assert.ok(false, "Unexpectedly called function [" + name + "]"); 57 | return callback.apply(context, arguments); 58 | }; 59 | } 60 | 61 | process.setMaxListeners(20); 62 | 63 | [response, calls, once, never].forEach(function (fn) { 64 | exports[fn.name] = fn; 65 | }); 66 | -------------------------------------------------------------------------------- /lib/client.js: -------------------------------------------------------------------------------- 1 | /*jshint forin:true, noarg:true, noempty:true, eqeqeq:true, bitwise:false, strict:true, undef:true, unused:true, curly:true, node:true, indent:4, maxerr:50, globalstrict:true */ 2 | 3 | "use strict"; 4 | 5 | var fishback = require("./fishback"); 6 | var Handler = require("./handler"); 7 | 8 | function Client(backend_hostname, backend_port, http) { 9 | this.backend_hostname = backend_hostname; 10 | this.backend_port = backend_port; 11 | this.http = http || require('http'); 12 | Handler.call(this); 13 | } 14 | 15 | require('util').inherits(Client, Handler); 16 | 17 | Client.prototype.request = function (serverRequest, serverResponse) { 18 | 19 | var emit = this.emit.bind(this); 20 | 21 | emit('newRequest', serverRequest); 22 | 23 | var req = { 24 | url: serverRequest.url, 25 | method: serverRequest.method, 26 | headers: { } 27 | }; 28 | Object.keys(serverRequest.headers).forEach(function (k) { 29 | req.headers[k] = serverRequest.headers[k]; 30 | }); 31 | 32 | var tmp = require('url').parse(req.url); 33 | 34 | var options = { 35 | "host": tmp.hostname || this.backend_hostname, 36 | "port": tmp.port || this.backend_port || 80, 37 | "path": tmp.pathname + (tmp.search ? tmp.search : ''), 38 | "method": req.method, 39 | "headers": req.headers 40 | }; 41 | options.headers.host = options.host; 42 | 43 | var clientRequest = this.http.request(options, function (clientResponse) { 44 | var port = options.port !== 80 ? ":" + options.port : ""; 45 | var search = options.search ? "?" + options.search : ""; 46 | clientResponse.backendUrl = "http://" + options.host + port + options.path + search; 47 | clientResponse.url = serverRequest.url; 48 | clientResponse.method = serverRequest.method; 49 | clientResponse.headers["x-cache"] = "MISS"; 50 | fishback.proxyResponse(clientResponse, serverResponse); 51 | emit('newResponse', serverResponse); 52 | }); 53 | 54 | fishback.proxyRequest(serverRequest, clientRequest); 55 | 56 | }; 57 | 58 | module.exports = Client; 59 | -------------------------------------------------------------------------------- /lib/mongodb.js: -------------------------------------------------------------------------------- 1 | /*jshint forin:true, noarg:true, noempty:true, eqeqeq:true, bitwise:false, strict:true, undef:true, unused:true, curly:true, node:true, indent:4, maxerr:50, globalstrict:true */ 2 | 3 | "use strict"; 4 | 5 | var fishback = require('./fishback'); 6 | var helper = require('./helper'); 7 | var Handler = require('./handler'); 8 | 9 | function MongoDb(client) { 10 | this.client = client; 11 | Handler.call(this); 12 | } 13 | 14 | require('util').inherits(MongoDb, Handler); 15 | 16 | MongoDb.prototype.request = function (req, res) { 17 | 18 | var emit = this.emit.bind(this); 19 | 20 | emit('newRequest', req); 21 | 22 | if (req.method !== 'GET' || !helper.wantsCache(req)) { 23 | req.emit('reject'); 24 | return; 25 | } 26 | 27 | this.client.findOne({ url: req.url }, function (err, entry) { 28 | 29 | if (err) { 30 | return; // @TODO Handle the error 31 | } 32 | 33 | if (entry && helper.isVaryMatch(entry, req) && helper.isFreshEnough(entry, req)) { 34 | fishback.bufferToResponse(entry, res); 35 | emit('newResponse', res); 36 | return; 37 | } 38 | 39 | if (helper.onlyWantsCache(req)) { 40 | var buffer = { 41 | url: req.url, 42 | method: req.method, 43 | statusCode: 504, 44 | headers: { "x-cache": "MISS" }, 45 | data: [ ] 46 | }; 47 | fishback.bufferToResponse(buffer, res); 48 | emit('newResponse', res); 49 | return; 50 | } 51 | 52 | req.emit('reject'); 53 | return; 54 | 55 | }); 56 | 57 | }; 58 | 59 | MongoDb.prototype.response = function (res) { 60 | 61 | if ((res.method !== 'GET') || (res.statusCode !== 200) || !helper.canCache(res)) { 62 | return; 63 | } 64 | 65 | var client = this.client; 66 | 67 | fishback.responseToBuffer(res, function (buffer) { 68 | 69 | // PREPARE ENTRY 70 | 71 | buffer.created = new Date().getTime(); 72 | buffer.expires = helper.expiresAt(res); 73 | buffer.headers["x-cache"] = "HIT"; 74 | 75 | client.update({ url: buffer.url }, buffer, { w: 0, upsert: true }); 76 | 77 | }); 78 | 79 | }; 80 | 81 | MongoDb.prototype.close = function (callback) { 82 | 83 | this.client.db.close(); 84 | 85 | if (callback) { 86 | return callback.call(); 87 | } 88 | 89 | }; 90 | 91 | module.exports = MongoDb; 92 | -------------------------------------------------------------------------------- /lib/memcached.js: -------------------------------------------------------------------------------- 1 | /*jshint forin:true, noarg:true, noempty:true, eqeqeq:true, bitwise:false, strict:true, undef:true, unused:true, curly:true, node:true, indent:4, maxerr:50, globalstrict:true */ 2 | 3 | "use strict"; 4 | 5 | var fishback = require('./fishback'); 6 | var helper = require('./helper'); 7 | var Handler = require('./handler'); 8 | 9 | var memjs = require('memjs'); 10 | 11 | function Memcached(client) { 12 | this.client = client || memjs.Client.create(); 13 | Handler.call(this); 14 | } 15 | 16 | require('util').inherits(Memcached, Handler); 17 | 18 | Memcached.prototype.request = function (req, res) { 19 | 20 | var emit = this.emit.bind(this); 21 | 22 | emit('newRequest', req); 23 | 24 | if (req.method !== 'GET' || !helper.wantsCache(req)) { 25 | req.emit('reject'); 26 | return; 27 | } 28 | 29 | var client = this.client; 30 | 31 | client.get(req.url, function (err, buffer) { 32 | 33 | if (err) { 34 | return; // @TODO Handle error 35 | } 36 | 37 | if (buffer && buffer.toString()) { 38 | // @TODO Handle malformed JSON 39 | var entry; 40 | try { 41 | entry = JSON.parse(buffer); 42 | } catch (e) { 43 | req.emit('reject'); 44 | return; 45 | } 46 | if (helper.isVaryMatch(entry, req) && helper.isFreshEnough(entry, req)) { 47 | fishback.bufferToResponse(entry, res); 48 | emit('newResponse', res); 49 | return; 50 | } 51 | } 52 | 53 | if (helper.onlyWantsCache(req)) { 54 | fishback.bufferToResponse({ 55 | url: req.url, 56 | method: req.method, 57 | statusCode: 504, 58 | headers: { "x-cache": "MISS" }, 59 | data: [ ] 60 | }, res); 61 | emit('newResponse', res); 62 | return; 63 | } 64 | 65 | req.emit('reject'); 66 | return; 67 | 68 | }); 69 | 70 | }; 71 | 72 | Memcached.prototype.response = function (res) { 73 | 74 | if ((res.method !== 'GET') || (res.statusCode !== 200) || !helper.canCache(res)) { 75 | return; 76 | } 77 | 78 | var client = this.client; 79 | 80 | fishback.responseToBuffer(res, function (entry) { 81 | 82 | // PREPARE ENTRY 83 | 84 | entry.created = new Date().getTime(); 85 | entry.expires = helper.expiresAt(res); 86 | entry.headers["x-cache"] = "HIT"; 87 | 88 | // INSERT ENTRY 89 | 90 | client.set(res.url, JSON.stringify(entry), function (err) { 91 | if (err) { 92 | console.log("error = ", err); 93 | } 94 | }); 95 | 96 | }); 97 | 98 | }; 99 | 100 | Memcached.prototype.close = function () { 101 | return this.client.close(); 102 | }; 103 | 104 | module.exports = Memcached; 105 | -------------------------------------------------------------------------------- /test/http.js: -------------------------------------------------------------------------------- 1 | /*jshint forin:true, noarg:true, noempty:true, eqeqeq:true, bitwise:true, strict:true, undef:true, unused:true, curly:true, node:true, indent:4, maxerr:50, globalstrict:true */ 2 | 3 | "use strict"; 4 | 5 | var assert = require('assert'); 6 | var util = require('util'); 7 | var events = require('events'); 8 | 9 | function ServerRequest(entry) { 10 | this.url = entry.url; 11 | this.method = entry.method || 'GET'; 12 | this.headers = entry.headers || { }; 13 | this.body = entry.body || [ ]; 14 | } 15 | 16 | util.inherits(ServerRequest, events.EventEmitter); 17 | 18 | ServerRequest.prototype.fire = function () { 19 | var emit = this.emit.bind(this); 20 | this.body.forEach(function (chunk) { 21 | emit('data', chunk); 22 | }); 23 | emit('end'); 24 | }; 25 | 26 | ServerRequest.prototype.noReject = function (s) { 27 | this.on('reject', function () { 28 | assert(false, "Unexpected reject event: " + s); 29 | }); 30 | }; 31 | 32 | function ServerResponse() { 33 | this.statusCode = 200; 34 | this.method = 'GET'; 35 | this.headers = { }; 36 | this.data = [ ]; 37 | } 38 | 39 | util.inherits(ServerResponse, events.EventEmitter); 40 | 41 | ServerResponse.prototype.noEnd = function () { 42 | this.on('end', function () { 43 | assert(false, "Unexpected end event"); 44 | }); 45 | }; 46 | 47 | ServerResponse.prototype.writeHead = function (statusCode, headers) { 48 | this.statusCode = statusCode; 49 | var h = this.headers; 50 | Object.keys(headers).forEach(function (k) { 51 | h[k] = headers[k]; 52 | }); 53 | }; 54 | 55 | ServerResponse.prototype.setHeader = function (header, value) { 56 | this.headers[header] = value; 57 | }; 58 | 59 | ServerResponse.prototype.getHeader = function (header) { 60 | return this.headers[header]; 61 | }; 62 | 63 | ServerResponse.prototype.write = function (chunk) { 64 | this.data.push(chunk); 65 | }; 66 | 67 | ServerResponse.prototype.end = function () { 68 | }; 69 | 70 | function ClientRequest() { 71 | } 72 | 73 | util.inherits(ClientRequest, events.EventEmitter); 74 | 75 | ClientRequest.prototype.end = function () { 76 | }; 77 | 78 | function ClientResponse(entry) { 79 | this.url = entry.url; 80 | this.method = entry.method; 81 | this.statusCode = entry.statusCode || 200; 82 | var headers = { }; 83 | Object.keys(entry.headers).forEach(function (k) { 84 | headers[k] = entry.headers[k]; 85 | }); 86 | this.headers = headers; 87 | this.data = entry.data || [ ]; 88 | } 89 | 90 | util.inherits(ClientResponse, events.EventEmitter); 91 | 92 | ClientResponse.prototype.fire = function () { 93 | var emit = this.emit.bind(this); 94 | var data = this.data; 95 | data.forEach(function (chunk) { 96 | emit('data', chunk); 97 | }); 98 | emit('end'); 99 | }; 100 | 101 | module.exports = { 102 | ServerRequest: ServerRequest, 103 | ServerResponse: ServerResponse, 104 | ClientRequest: ClientRequest, 105 | ClientResponse: ClientResponse 106 | }; 107 | -------------------------------------------------------------------------------- /test/http-simple.js: -------------------------------------------------------------------------------- 1 | /*jshint forin:true, noarg:true, noempty:true, eqeqeq:true, bitwise:true, strict:true, undef:true, unused:true, curly:true, node:true, indent:4, maxerr:50, globalstrict:true */ 2 | 3 | "use strict"; 4 | 5 | var DELAY = 500; 6 | 7 | var lib = require("./lib"); 8 | var http = require("./http"); 9 | var assurt = require("./assurt"); 10 | var fishback = require("../lib/fishback"); 11 | var assert = require("assert"); 12 | 13 | var response = { 14 | url: "/", 15 | method: "GET", 16 | statusCode: 200, 17 | headers: { "x-cache": "MISS", "cache-control": "public, max-age=60" }, 18 | data: [ "Hello, World!\n" ] 19 | }; 20 | 21 | lib.getCacheList(function (cache) { 22 | 23 | var client = new fishback.Client(null, null, { 24 | request: function (options, callback) { 25 | var clientResponse = new http.ClientResponse(response); 26 | callback(clientResponse); 27 | clientResponse.fire(); 28 | return new http.ClientRequest(); 29 | } 30 | }); 31 | 32 | var proxy = fishback.createCachingProxy(cache, client); 33 | 34 | lib.step([ 35 | 36 | function (next) { 37 | var req = new http.ServerRequest({ url: "/", method: "GET" }); 38 | var res = new http.ServerResponse(); 39 | res.on('end', assurt.calls(function () { 40 | assert.equal(res.headers["x-cache"], "MISS"); 41 | setTimeout(next, DELAY); 42 | })); 43 | proxy.request(req, res); 44 | req.fire(); 45 | }, 46 | 47 | function (next) { 48 | var req = new http.ServerRequest({ url: "/", method: "GET" }); 49 | var res = new http.ServerResponse(); 50 | res.on('end', assurt.calls(function () { 51 | assert.equal(res.headers["x-cache"], "HIT"); 52 | next.call(); 53 | })); 54 | proxy.request(req, res); 55 | req.fire(); 56 | }, 57 | 58 | function (next) { 59 | var req = new http.ServerRequest({ url: "/", method: "GET" }); 60 | var res = new http.ServerResponse(); 61 | res.on('end', assurt.calls(function () { 62 | assert.equal(res.headers["x-cache"], "HIT"); 63 | next.call(); 64 | })); 65 | proxy.request(req, res); 66 | req.fire(); 67 | }, 68 | 69 | function (next) { 70 | var req = new http.ServerRequest({ url: "/", method: "GET" }); 71 | var res = new http.ServerResponse(); 72 | res.on('end', assurt.calls(function () { 73 | assert.equal(res.headers["x-cache"], "HIT"); 74 | next.call(); 75 | })); 76 | proxy.request(req, res); 77 | req.fire(); 78 | }, 79 | 80 | function (next) { 81 | var req = new http.ServerRequest({ url: "/", method: "GET" }); 82 | var res = new http.ServerResponse(); 83 | res.on('end', assurt.calls(function () { 84 | assert.equal(res.headers["x-cache"], "HIT"); 85 | next.call(); 86 | })); 87 | proxy.request(req, res); 88 | req.fire(); 89 | }, 90 | 91 | function () { 92 | cache.close(); 93 | } 94 | 95 | ]); 96 | 97 | }); 98 | -------------------------------------------------------------------------------- /test/cache-misc.js: -------------------------------------------------------------------------------- 1 | /*jshint forin:true, noarg:true, noempty:true, eqeqeq:true, bitwise:true, strict:true, undef:true, unused:true, curly:true, node:true, indent:4, maxerr:50, globalstrict:true */ 2 | 3 | "use strict"; 4 | 5 | var lib = require("./lib"); 6 | var http = require("./http"); 7 | var assurt = require("./assurt"); 8 | 9 | var DELAY = 500; 10 | 11 | lib.getCacheList(function (cache, next) { 12 | 13 | // Add resource #1 to cache 14 | 15 | (function () { 16 | 17 | var res = new http.ClientResponse({ 18 | url: "/foo", 19 | method: "GET", 20 | statusCode: 200, 21 | headers: { 22 | "cache-control": "public, max-age=60" 23 | }, 24 | data: [ "Hello, Foo!" ] 25 | }); 26 | 27 | cache.response(res); 28 | res.fire(); 29 | 30 | })(); 31 | 32 | // Add resource #2 to cache 33 | 34 | (function () { 35 | 36 | var res = new http.ClientResponse({ 37 | url: "/bar", 38 | method: "GET", 39 | statusCode: 200, 40 | headers: { 41 | "cache-control": "public, max-age=60" 42 | }, 43 | data: [ "Hello, Bar!" ] 44 | }); 45 | 46 | cache.response(res); 47 | res.fire(); 48 | 49 | })(); 50 | 51 | (function () { 52 | 53 | var req = new http.ServerRequest({ 54 | url: "/foo", 55 | method: "GET" 56 | }); 57 | req.noReject("A"); 58 | 59 | var res = new http.ServerResponse(); 60 | res.once('end', assurt.once(function F1() { 61 | assurt.response(res, { headers: {}, data: "Hello, Foo!" }); 62 | })); 63 | 64 | setTimeout(function () { 65 | cache.request(req, res); 66 | req.fire(); 67 | }, DELAY); 68 | 69 | })(); 70 | 71 | (function () { 72 | 73 | var req = new http.ServerRequest({ 74 | url: "/bar", 75 | method: "GET" 76 | }); 77 | req.noReject("B"); 78 | 79 | var res = new http.ServerResponse(); 80 | res.once('end', assurt.once(function F2() { 81 | assurt.response(res, { headers: {}, data: "Hello, Bar!" }); 82 | })); 83 | 84 | setTimeout(function () { 85 | cache.request(req, res); 86 | req.fire(); 87 | }, DELAY); 88 | 89 | })(); 90 | 91 | (function () { 92 | 93 | var req = new http.ServerRequest({ 94 | url: "/foo", 95 | method: "GET" 96 | }); 97 | req.noReject("C"); 98 | 99 | var res = new http.ServerResponse(); 100 | res.once('end', assurt.once(function F3() { 101 | assurt.response(res, { headers: {}, data: "Hello, Foo!" }); 102 | })); 103 | 104 | setTimeout(function () { 105 | cache.request(req, res); 106 | req.fire(); 107 | }, DELAY); 108 | 109 | })(); 110 | 111 | (function () { 112 | 113 | var req = new http.ServerRequest({ 114 | url: "/quux", 115 | method: "GET" 116 | }); 117 | req.once('reject', assurt.once(function F4() { 118 | cache.close(); 119 | next(); 120 | })); 121 | 122 | var res = new http.ServerResponse(); 123 | res.noEnd(); 124 | 125 | setTimeout(function () { 126 | cache.request(req, res); 127 | req.fire(); 128 | }, 2 * DELAY); 129 | 130 | })(); 131 | 132 | next(); 133 | 134 | }); 135 | -------------------------------------------------------------------------------- /lib/memory.js: -------------------------------------------------------------------------------- 1 | /*jshint forin:true, noarg:true, noempty:true, eqeqeq:true, bitwise:false, strict:true, undef:true, unused:true, curly:true, node:true, indent:4, maxerr:50, globalstrict:true */ 2 | 3 | "use strict"; 4 | 5 | var fishback = require('./fishback'); 6 | var helper = require('./helper'); 7 | var Handler = require('./handler'); 8 | 9 | function Memory(maxSize) { 10 | this.data = {}; // hash of cache entries, keyed on URL for efficient lookup 11 | this.list = []; // array of cache entries, for efficient random access (useful for cache cleaning) 12 | this.maxSize = maxSize || 2000; 13 | 14 | Handler.call(this); 15 | } 16 | 17 | require('util').inherits(Memory, Handler); 18 | 19 | Memory.prototype.request = function (req, res) { 20 | 21 | var emit = this.emit.bind(this); 22 | 23 | var i, buffer; 24 | 25 | emit('newRequest', req); 26 | 27 | if (req.method !== 'GET') { 28 | req.emit('reject'); 29 | return; 30 | } 31 | 32 | if (this.data[req.url] && helper.wantsCache(req)) { 33 | for (i = 0; i < this.data[req.url].length; i++) { 34 | buffer = this.data[req.url][i]; 35 | if (helper.isVaryMatch(buffer, req) && helper.isFreshEnough(buffer, req)) { 36 | fishback.bufferToResponse(buffer, res); 37 | emit('newResponse', res); 38 | return; 39 | } 40 | } 41 | } 42 | 43 | if (helper.onlyWantsCache(req)) { 44 | fishback.bufferToResponse({ 45 | url: req.url, 46 | method: req.method, 47 | statusCode: 504, 48 | headers: { "x-cache": "MISS" }, 49 | data: [ ] 50 | }, res); 51 | emit('newResponse', res); 52 | return; 53 | } else { 54 | req.emit('reject'); 55 | return; 56 | } 57 | 58 | }; 59 | 60 | Memory.prototype.response = function (clientResponse) { 61 | 62 | if ((clientResponse.method !== 'GET') || 63 | (clientResponse.statusCode !== 200) || 64 | !helper.canCache(clientResponse)) { 65 | return; 66 | } 67 | 68 | fishback.responseToBuffer(clientResponse, (function (buffer) { 69 | 70 | // PREPARE ENTRY 71 | 72 | buffer.created = new Date().getTime(); 73 | buffer.expires = helper.expiresAt(clientResponse); 74 | buffer.headers["x-cache"] = "HIT"; 75 | 76 | // INSERT ENTRY 77 | 78 | // Clean before adding to the cache, mostly because it would be annoying 79 | // to have our newly-added cache entry cleaned right away. 80 | this.clean(); 81 | 82 | if (!this.data[buffer.url]) { 83 | this.data[buffer.url] = [ buffer ]; 84 | } else { 85 | this.data[buffer.url].push(buffer); 86 | } 87 | 88 | this.list.push(buffer); 89 | 90 | }).bind(this)); 91 | 92 | }; 93 | 94 | /** 95 | * Trims the cache to be less than or equal to this.maxSize entries. 96 | */ 97 | 98 | Memory.prototype.clean = function () { 99 | if ((this.list.length === 0) || (this.list.length <= this.maxSize)) { 100 | return; 101 | } 102 | 103 | // Find the index of the LRU entry of three picked at random. The 104 | // random indices could be exactly the same, but this is unlikely 105 | // (outside of tests), and it's much easier to generate random 106 | // numbers with replacement. (And it won't lead to incorrect 107 | // results.) 108 | var index = [ 109 | Math.floor(Math.random() * this.list.length), 110 | Math.floor(Math.random() * this.list.length), 111 | Math.floor(Math.random() * this.list.length) 112 | ].reduce((function (a, b) { return this.list[a].accessed < this.list[b].accessed ? a : b; }).bind(this)); 113 | 114 | var entry = this.list.splice(index, 1)[0]; 115 | 116 | for (var i = 0; i < this.data[entry.url].length; i++) { 117 | if (entry === this.data[entry.url][i]) { 118 | this.data[entry.url].splice(i, 1); // delete doesn't change the length of the array! 119 | if (this.data[entry.url].length === 0) { 120 | delete this.data[entry.url]; 121 | } 122 | break; 123 | } 124 | } 125 | }; 126 | 127 | module.exports = Memory; 128 | -------------------------------------------------------------------------------- /lib/helper.js: -------------------------------------------------------------------------------- 1 | /*jshint forin:true, noarg:true, noempty:true, eqeqeq:true, bitwise:false, strict:true, undef:true, unused:true, curly:true, node:true, indent:4, maxerr:50, globalstrict:true */ 2 | 3 | "use strict"; 4 | 5 | /** 6 | * Returns a closure that, when called, returns the number of seconds 7 | * since the timer() function was itself called. 8 | */ 9 | 10 | function timer(digits) 11 | { 12 | var t0 = new Date().getTime(); 13 | 14 | return function () { 15 | var t1 = new Date().getTime(); 16 | // linter doesn't like new Number(...) 17 | return Number.prototype.toFixed.call((t1 - t0) / 1000, digits || 3); 18 | }; 19 | } 20 | 21 | function parseHeader(header) 22 | { 23 | return !header.trim() ? {} : header.trim().split(/\s*,\s*/).sort().reduce(function (p, c) { 24 | var t = c.split(/\s*=\s*/, 2); 25 | p[t[0].toLowerCase()] = t[1]; 26 | return p; 27 | }, {}); 28 | } 29 | 30 | 31 | function expiresAt(res) 32 | { 33 | if (!("expires" in res.headers) && !("cache-control" in res.headers)) { 34 | return new Date().getTime(); 35 | } 36 | 37 | if (res.headers["cache-control"]) { 38 | var headers = parseHeader(res.headers["cache-control"]); 39 | if (headers["s-maxage"]) { 40 | return new Date().getTime() + (headers["s-maxage"] * 1000); 41 | } else if (headers["max-age"]) { 42 | return new Date().getTime() + (headers["max-age"] * 1000); 43 | } 44 | } 45 | 46 | if (res.headers.expires) { 47 | return Date.parse(res.headers.expires); 48 | } 49 | 50 | return null; 51 | } 52 | 53 | function canCache(res) 54 | { 55 | if (!("cache-control" in res.headers)) { 56 | return false; 57 | } 58 | 59 | var headers = parseHeader(res.headers["cache-control"]); 60 | 61 | return !("private" in headers) && !("no-store" in headers) && !("must-revalidate" in headers) && 62 | (("public" in headers) || ("max-age" in headers) || ("s-maxage" in headers)); 63 | } 64 | 65 | // http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.9.3 66 | 67 | // true if the candidate reponse satisfies the request in terms 68 | // of freshness, otherwise false. 69 | 70 | function isFreshEnough(entry, req) 71 | { 72 | // If no cache-control header in request, then entry is fresh 73 | // if it hasn't expired. 74 | if (!("cache-control" in req.headers)) { 75 | return new Date().getTime() < entry.expires; 76 | } 77 | 78 | var headers = parseHeader(req.headers["cache-control"]); 79 | 80 | if ("must-revalidate" in headers) { 81 | return false; 82 | } else if ("max-stale" in headers) { 83 | // TODO Tell the client that the resource is stale, as RFC2616 requires 84 | return !headers["max-stale"] || ((headers["max-stale"] * 1000) > (new Date().getTime() - entry.expires)); // max-stale > "staleness" 85 | } else if ("max-age" in headers) { 86 | return (headers["max-age"] * 1000) > (new Date().getTime() - entry.created); // max-age > "age" 87 | } else if ("min-fresh" in headers) { 88 | return (headers["min-fresh"] * 1000) < (entry.expires - new Date().getTime()); // min-fresh < "time until expiry" 89 | } else { 90 | return new Date().getTime() < entry.expires; 91 | } 92 | 93 | } 94 | 95 | function wantsCache(req) 96 | { 97 | return !("cache-control" in req.headers) || 98 | (req.headers["cache-control"].indexOf("no-cache") === -1) || 99 | !("no-cache" in parseHeader(req.headers["cache-control"])); 100 | } 101 | 102 | // http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.9.4 103 | function onlyWantsCache(req) 104 | { 105 | return ("cache-control" in req.headers) && 106 | ("only-if-cached" in parseHeader(req.headers["cache-control"])); 107 | } 108 | 109 | function isVaryMatch(entry, req) 110 | { 111 | if (!("vary" in entry.headers)) { 112 | return true; 113 | } 114 | 115 | if (entry.headers.vary === "*") { 116 | return false; 117 | } 118 | 119 | return entry.headers.vary.split(/\s*,\s/).every(function (h) { 120 | return req.headers[h] === entry.headers[h]; 121 | }); 122 | } 123 | 124 | [parseHeader, expiresAt, canCache, isFreshEnough, wantsCache, onlyWantsCache, isVaryMatch].forEach(function (fn) { 125 | exports[fn.name] = fn; 126 | }); 127 | -------------------------------------------------------------------------------- /test/http-public.js: -------------------------------------------------------------------------------- 1 | /*jshint forin:true, noarg:true, noempty:true, eqeqeq:true, bitwise:true, strict:true, undef:true, unused:true, curly:true, node:true, indent:4, maxerr:50, globalstrict:true */ 2 | 3 | "use strict"; 4 | 5 | var lib = require("./lib"); 6 | var http = require("./http"); 7 | var assurt = require("./assurt"); 8 | var fishback = require("../lib/fishback"); 9 | 10 | var NOW = 0; 11 | var DELAY = 500; 12 | 13 | var response = { 14 | url: '/', 15 | method: 'GET', 16 | statusCode: 200, 17 | headers: { "x-cache": "MISS", "cache-control": "max-age=60, public" }, 18 | data: [ "Hello, World" ] 19 | }; 20 | 21 | var expected_miss = [ 22 | { headers: { "x-cache": "MISS", "cache-control": "max-age=60, public" }, data: "Hello, World" }, 23 | { headers: { "x-cache": "HIT", "cache-control": "max-age=60, public" }, data: "Hello, World" }, 24 | { headers: { "x-cache": "HIT", "cache-control": "max-age=60, public" }, data: "Hello, World" } 25 | ]; 26 | 27 | var expected_hit = [ 28 | { headers: { "x-cache": "HIT", "cache-control": "max-age=60, public" }, data: "Hello, World" }, 29 | { headers: { "x-cache": "HIT", "cache-control": "max-age=60, public" }, data: "Hello, World" }, 30 | { headers: { "x-cache": "HIT", "cache-control": "max-age=60, public" }, data: "Hello, World" } 31 | ]; 32 | 33 | lib.getCacheList(function (cache) { 34 | 35 | Date.prototype.getTime = function() { 36 | return NOW; 37 | }; 38 | 39 | var client = new fishback.Client(null, null, { 40 | request: function (options, callback) { 41 | var clientResponse = new http.ClientResponse(response); 42 | callback(clientResponse); 43 | clientResponse.fire(); 44 | return new http.ClientRequest(); 45 | } 46 | }); 47 | 48 | var proxy = fishback.createCachingProxy(cache, client); 49 | 50 | lib.step([ 51 | 52 | function (callback) { 53 | 54 | Date.prototype.getTime = function() { 55 | return NOW + 0; 56 | }; 57 | 58 | lib.amap( 59 | [0, 1, 2], 60 | function (i, next) { 61 | var req = new http.ServerRequest({ 62 | url: "/", 63 | method: "GET" 64 | }); 65 | var res = new http.ServerResponse(); 66 | res.on('end', assurt.calls(function () { 67 | assurt.response(res, expected_miss[i]); 68 | setTimeout(next, DELAY); 69 | })); 70 | proxy.request(req, res); 71 | req.fire(); 72 | }, 73 | callback 74 | ); 75 | 76 | }, 77 | 78 | // No cache misses 79 | function (callback) { 80 | 81 | Date.prototype.getTime = function() { 82 | return NOW + 30000; 83 | }; 84 | 85 | lib.amap( 86 | [0, 1, 2], 87 | function (i, next) { 88 | var req = new http.ServerRequest({ 89 | url: "/", 90 | method: "GET" 91 | }); 92 | var res = new http.ServerResponse(); 93 | res.on('end', assurt.calls(function () { 94 | assurt.response(res, expected_hit[i]); 95 | setTimeout(next, DELAY); 96 | })); 97 | proxy.request(req, res); 98 | req.fire(); 99 | }, 100 | callback 101 | ); 102 | 103 | }, 104 | 105 | // Should get a cache miss the first time, because we're 120 seconds 106 | // on. 107 | function (callback) { 108 | 109 | Date.prototype.getTime = function() { 110 | return NOW + 120000; 111 | }; 112 | 113 | lib.amap( 114 | [0, 1, 2], 115 | function (i, next) { 116 | var req = new http.ServerRequest({ 117 | url: "/", 118 | method: "GET" 119 | }); 120 | var res = new http.ServerResponse(); 121 | res.on('end', assurt.calls(function () { 122 | assurt.response(res, expected_miss[i]); 123 | setTimeout(next, DELAY); 124 | })); 125 | proxy.request(req, res); 126 | req.fire(); 127 | }, 128 | callback 129 | ); 130 | 131 | }, 132 | 133 | function () { 134 | cache.close(); 135 | } 136 | 137 | ]); 138 | 139 | }); 140 | -------------------------------------------------------------------------------- /test/http-only-if-cached.js: -------------------------------------------------------------------------------- 1 | /*jshint forin:true, noarg:true, noempty:true, eqeqeq:true, bitwise:true, strict:true, undef:true, unused:true, curly:true, node:true, indent:4, maxerr:50, globalstrict:true */ 2 | 3 | "use strict"; 4 | 5 | var assert = require('assert'); 6 | var assurt = require("./assurt"); 7 | var lib = require('./lib'); 8 | var http = require("./http"); 9 | var fishback = require("../lib/fishback"); 10 | 11 | var NOW = 198025200000; 12 | 13 | lib.getCacheList(function (cache) { 14 | 15 | (function () { 16 | 17 | var client = new fishback.Client(null, null, { 18 | request: function () { assert.equal(false, true); } 19 | }); 20 | 21 | var proxy = fishback.createCachingProxy(cache, client); 22 | 23 | var req = new http.ServerRequest({ 24 | url: "/", 25 | method: "GET", 26 | headers: { "cache-control": "only-if-cached" } 27 | }); 28 | 29 | var res = new http.ServerResponse(); 30 | res.on('end', assurt.once(function F1() { 31 | assurt.response(res, { 32 | statusCode: 504, 33 | headers: { "x-cache": "MISS" }, 34 | data: "" 35 | }); 36 | cache.close(); 37 | })); 38 | 39 | proxy.request(req, res); 40 | req.fire(); 41 | 42 | })(); 43 | 44 | }); 45 | 46 | lib.getCacheList(function (cache, next) { 47 | 48 | (function () { 49 | 50 | var response = { 51 | url: "/", 52 | method: "GET", 53 | statusCode: 200, 54 | headers: { "x-cache": "MISS", "cache-control": "public, max-age=60" }, 55 | body: [ "Hello, World!\n" ] 56 | }; 57 | 58 | var client = new fishback.Client(null, null, { 59 | request: function (options, callback) { 60 | var clientResponse = new http.ClientResponse(response); 61 | callback(clientResponse); 62 | clientResponse.fire(); 63 | return new http.ClientRequest(); 64 | } 65 | }); 66 | 67 | var proxy = fishback.createCachingProxy(cache, client); 68 | 69 | lib.step([ 70 | 71 | function (callback) { 72 | Date.prototype.getTime = function() { 73 | return NOW; 74 | }; 75 | var req = new http.ServerRequest({ url: "/", method: "GET" }); 76 | var res = new http.ServerResponse(); 77 | res.on('end', assurt.once(function F2() { 78 | assert.equal(res.statusCode, 200); 79 | assert.equal(res.headers["x-cache"], "MISS"); 80 | callback(); 81 | })); 82 | proxy.request(req, res); 83 | req.fire(); 84 | }, 85 | function (callback) { 86 | var req = new http.ServerRequest({ url: "/", method: "GET" }); 87 | var res = new http.ServerResponse(); 88 | res.on('end', assurt.once(function F3() { 89 | assert.equal(res.statusCode, 200); 90 | assert.equal(res.headers["x-cache"], "HIT"); 91 | callback(); 92 | })); 93 | proxy.request(req, res); 94 | req.fire(); 95 | }, 96 | function (callback) { 97 | var req = new http.ServerRequest({ 98 | url: "/", 99 | method: "GET", 100 | headers: { "cache-control": "only-if-cached, max-age=60" } 101 | }); 102 | var res = new http.ServerResponse(); 103 | res.on('end', assurt.once(function F4() { 104 | assert.equal(res.statusCode, 200); 105 | assert.equal(res.headers["x-cache"], "HIT"); 106 | callback(); 107 | })); 108 | proxy.request(req, res); 109 | req.fire(); 110 | }, 111 | function (callback) { 112 | Date.prototype.getTime = function() { 113 | return NOW + 120000; 114 | }; 115 | var req = new http.ServerRequest({ 116 | url: "/", 117 | method: "GET", 118 | headers: { "cache-control": "only-if-cached, max-age=60" } 119 | }); 120 | var res = new http.ServerResponse(); 121 | res.on('end', assurt.once(function F5() { 122 | assert.equal(res.statusCode, 504); 123 | assert.equal(res.headers["x-cache"], "MISS"); 124 | callback(); 125 | cache.close(); 126 | })); 127 | proxy.request(req, res); 128 | req.fire(); 129 | } 130 | 131 | ]); 132 | })(); 133 | 134 | next(); 135 | }); 136 | -------------------------------------------------------------------------------- /lib/fishback.js: -------------------------------------------------------------------------------- 1 | /*jshint forin:true, noarg:true, noempty:true, eqeqeq:true, bitwise:false, strict:true, undef:true, unused:true, curly:true, node:true, indent:4, maxerr:50, globalstrict:true */ 2 | 3 | "use strict"; 4 | 5 | var Handler = require("./handler"); 6 | 7 | var Client = require("./client"); 8 | var Memory = require("./memory"); 9 | var MongoDb = require("./mongodb"); 10 | var Memcached = require("./memcached"); 11 | 12 | function Fishback(list) { 13 | this.list = list; 14 | Handler.call(this); 15 | } 16 | 17 | require('util').inherits(Fishback, Handler); 18 | 19 | Fishback.prototype.request = function (req, res) { 20 | 21 | var emit = this.emit.bind(this); 22 | 23 | emit('newRequest', req); 24 | 25 | res.on('endHead', function () { 26 | emit('newResponse', res, req); 27 | }); 28 | 29 | // Call list[0].request(), if we get a "reject", call list[1].request(), 30 | // and so on. 31 | function process(list) { 32 | var head = list[0]; 33 | var tail = list.slice(1); 34 | // @TODO Handle null head (all handlers emitted "reject") 35 | req.once("reject", function () { 36 | process(tail); 37 | }); 38 | head.request(req, res); 39 | } 40 | 41 | process(this.list); 42 | 43 | }; 44 | 45 | function proxyRequest(serverRequest, clientRequest) { 46 | // Because of inconsistencies in the http.request() API, 47 | // clientRequest.headers can't be written to; the caller needs to have 48 | // proxied these already. 49 | serverRequest.on('data', function (chunk) { 50 | clientRequest.write(chunk); 51 | }); 52 | serverRequest.on('end', function () { 53 | clientRequest.end(); 54 | }); 55 | serverRequest.on('error', function () { 56 | clientRequest.end(); 57 | }); 58 | } 59 | 60 | function proxyResponse(clientResponse, serverResponse) { 61 | serverResponse.url = clientResponse.url; 62 | serverResponse.backendUrl = clientResponse.backendUrl; 63 | serverResponse.method = clientResponse.method; 64 | // Avoid writeHead() to give listeners a change to modify the serverResponse 65 | serverResponse.statusCode = clientResponse.statusCode; 66 | serverResponse.headers = { }; // read-only! 67 | Object.keys(clientResponse.headers).forEach(function (k) { 68 | serverResponse.setHeader(k, clientResponse.headers[k]); 69 | serverResponse.headers[k] = clientResponse.headers[k]; 70 | }); 71 | serverResponse.emit('endHead'); 72 | clientResponse.on('data', function (chunk) { 73 | serverResponse.write(chunk); 74 | serverResponse.emit('data', chunk); 75 | }); 76 | clientResponse.on('end', function () { 77 | serverResponse.end(); 78 | serverResponse.emit('end'); 79 | }); 80 | } 81 | 82 | function bufferToResponse(buffer, serverResponse) { 83 | serverResponse.url = buffer.url; 84 | serverResponse.method = buffer.method; 85 | // Avoid writeHead() to give listeners a change to modify the serverResponse 86 | serverResponse.statusCode = buffer.statusCode; 87 | serverResponse.headers = { }; 88 | Object.keys(buffer.headers).forEach(function (k) { 89 | serverResponse.setHeader(k, buffer.headers[k]); 90 | // Copy to "headers" property as a convenience--the "headers" property 91 | // is read-only. If trying to modify the response, use the setHeader() 92 | // method. 93 | serverResponse.headers[k] = buffer.headers[k]; 94 | }); 95 | serverResponse.emit('endHead'); 96 | // nextTick so that anything listening for endHead has the chance to attach 97 | // listeners for the "data" and "end" events 98 | process.nextTick(function () { 99 | buffer.data.forEach(function (chunk) { 100 | serverResponse.write(chunk); 101 | serverResponse.emit('data', chunk); 102 | }); 103 | serverResponse.end(); 104 | serverResponse.emit('end'); 105 | }); 106 | } 107 | 108 | // clientResponse can be a ServerResponse or a ClientResponse (?) 109 | function responseToBuffer(clientResponse, callback) { 110 | var buffer = { 111 | url: clientResponse.url, 112 | method: clientResponse.method, 113 | statusCode: clientResponse.statusCode, 114 | headers: { }, 115 | data: [ ] 116 | }; 117 | Object.keys(clientResponse.headers).forEach(function (k) { 118 | buffer.headers[k] = clientResponse.headers[k]; 119 | }); 120 | clientResponse.on('data', function (chunk) { 121 | buffer.data.push(chunk); 122 | // @TODO If data gets too big, callback(null) 123 | }); 124 | clientResponse.on('end', function () { 125 | // @TODO Dispose of clientResponse and remove listeners? 126 | callback(buffer); 127 | }); 128 | clientResponse.on('close', function () { 129 | // @TODO Dispose of clientResponse and remove listeners? 130 | callback(null); 131 | }); 132 | } 133 | 134 | function createCachingProxy(cache, client) { 135 | cache = cache || new Memory(); 136 | client = client || new Client(); 137 | 138 | // Save any new responses emitted by the client to the cache 139 | var fishback = new Fishback([cache, client]); 140 | client.on('newResponse', function (serverResponse) { 141 | cache.response(serverResponse); 142 | }); 143 | 144 | return fishback; 145 | } 146 | 147 | function createProxy(client) { 148 | client = client || new Client(); 149 | 150 | return new Fishback([client]); 151 | } 152 | 153 | [ 154 | Client, Memory, Memcached, MongoDb, Fishback, 155 | createProxy, createCachingProxy, 156 | bufferToResponse, responseToBuffer, 157 | proxyRequest, proxyResponse 158 | ].forEach(function (fn) { 159 | exports[fn.name] = fn; 160 | }); 161 | -------------------------------------------------------------------------------- /test/lib.js: -------------------------------------------------------------------------------- 1 | /*jshint forin:true, noarg:true, noempty:true, eqeqeq:true, bitwise:true, strict:true, undef:true, unused:true, curly:true, node:true, indent:4, maxerr:50, globalstrict:true */ 2 | 3 | "use strict"; 4 | 5 | var fishback = require("../lib/fishback"); 6 | 7 | /** 8 | * Asynchronous map function. For each element of arr, fn(element, callback) is 9 | * called, where callback receives the result. Note that the individual "maps" 10 | * are performed sequentially; the result, however, is delivered to a callback, 11 | * instead of being returned, and the maps can be asynchronous. 12 | * 13 | * Example: 14 | * 15 | * function aadd(i, callback) { 16 | * callback(i + 1); 17 | * } 18 | * 19 | * amap([ 2, 3, 4 ], aadd, console.log); 20 | * // -> [ 3, 4, 5 ] 21 | * 22 | * @param arr the array over which 23 | * @param fn function of the form function(n, callback) 24 | * @param callback function of the form function(arr) 25 | */ 26 | 27 | function amap(arr, fn, callback) { 28 | 29 | // https://gist.github.com/846521 30 | 31 | process.nextTick(function () { 32 | if (arr.length === 0) { 33 | callback([]); 34 | } else { 35 | fn(arr[0], function (v) { 36 | amap(arr.slice(1), fn, function (list) { 37 | callback([v].concat(list)); 38 | }); 39 | }); 40 | } 41 | }); 42 | 43 | } 44 | 45 | /** 46 | * Passed array of async functions (i.e. whose last argument is a callback), and calls 47 | * them in order. 48 | * 49 | * Example: 50 | * 51 | * step([ 52 | * function (i, callback) { 53 | * console.log("got ", i); 54 | * callback(null, 7); 55 | * }, 56 | * function (i, callback) { 57 | * console.log("got ", i); 58 | * callback(); 59 | * } 60 | * ], null, 6); 61 | * // -> got 6 62 | * // -> got 7 63 | * 64 | * @param tasks array of functions with arguments ([arg]..., callback); callback has arguments (err, arg...) 65 | * @param [optional] errback called if any of the tasks returns an error 66 | * @param [optional] arg... arguments to the first task 67 | */ 68 | 69 | // Libraries that do similar things: 70 | // 71 | // https://github.com/creationix/step 72 | // https://github.com/caolan/async 73 | 74 | function step(tasks, errback) { 75 | 76 | if (tasks && tasks[0]) { 77 | var args = Array.prototype.slice.call(arguments, 2); 78 | // Empty the event queue, to ensure isolation between steps 79 | process.nextTick(function () { 80 | tasks[0].apply(null, args.concat(function () { // Note: exception thrown if tasks[0] not a function 81 | var args = Array.prototype.slice.call(arguments); 82 | if (args[0] && errback) { 83 | errback(args[0]); // error returned, abort tasks (Note: exception thrown if errback not a function) 84 | } else { 85 | step.apply(null, [tasks.slice(1)].concat(errback, args.slice(1))); 86 | } 87 | })); 88 | }); 89 | } 90 | 91 | } 92 | 93 | /** 94 | * Returns a function that, when called n times, in turn calls callback. 95 | * 96 | * @param {int} n 97 | * @param {Function} callback 98 | * @return {Function} 99 | */ 100 | function knock(n, callback) { 101 | if (n <= 0) { 102 | callback(); 103 | return function () { }; 104 | } else { 105 | return function () { 106 | if (--n === 0) { 107 | callback(); 108 | } 109 | }; 110 | } 111 | } 112 | 113 | /** 114 | * @param {object} req 115 | * @param {Function} callback 116 | */ 117 | function group(req, callback) { 118 | var res = { }; 119 | Object.keys(req).forEach(function (k) { 120 | req[k](function () { 121 | var args = Array.prototype.slice.call(arguments); 122 | res[k] = args.length === 1 ? args[0] : args; 123 | if (Object.keys(res).length === Object.keys(req).length) { 124 | callback(res); 125 | } 126 | }); 127 | }); 128 | } 129 | 130 | function getCacheMemory(callback) { 131 | callback(new fishback.Memory()); 132 | } 133 | 134 | function getCacheMemcached(callback) { 135 | var client = require('memjs').Client.create(); 136 | client.flush(function () { 137 | callback(new fishback.Memcached(client)); 138 | }); 139 | } 140 | 141 | function getCacheMongoDb(callback) { 142 | var uri = process.env.MONGOLAB_URI || 143 | process.env.MONGOHQ_URL || 144 | 'mongodb://localhost:27017/fishback'; 145 | 146 | var collname = "test" + (Math.random() + Math.pow(10, -9)).toString().substr(2, 8); 147 | 148 | require('mongodb').MongoClient.connect(uri, function (err, client) { 149 | 150 | if (err) { console.error(err); return; } 151 | 152 | function createCollection() { 153 | client.createCollection(collname, { capped: true, size: 10000 }, function (err, coll) { 154 | if (err) { console.error(err); return; } 155 | // @TODO Add index to url 156 | // http://mongodb.github.com/node-mongodb-native/api-generated/db.html#ensureindex 157 | callback(new fishback.MongoDb(coll)); 158 | }); 159 | } 160 | 161 | client.collectionNames(collname, function (err, coll) { 162 | if (err) { console.error(err); return; } 163 | if (coll.length) { 164 | client.dropCollection(collname, function (err) { 165 | if (err) { console.error(err); return; } 166 | createCollection(); 167 | }); 168 | } else { 169 | createCollection(); 170 | } 171 | }); 172 | 173 | }); 174 | } 175 | 176 | function getCacheList(callback) { 177 | 178 | function _getCacheList(list) { 179 | var head = list[0]; 180 | var tail = list.slice(1); 181 | if (head) { 182 | head(function (cache) { 183 | callback(cache, _getCacheList.bind(null, tail)); 184 | }); 185 | } 186 | } 187 | 188 | _getCacheList([ getCacheMemory, getCacheMemcached ]); 189 | } 190 | 191 | [knock, group, amap, step, getCacheList, getCacheMemory, getCacheMongoDb, getCacheMemcached].forEach(function (fn) { 192 | exports[fn.name] = fn; 193 | }); 194 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/ithinkihaveacat/node-fishback.png)](https://travis-ci.org/ithinkihaveacat/node-fishback) 2 | 3 | ## Overview 4 | 5 | Fishback is a simple NodeJS-powered caching HTTP proxy. 6 | 7 | As well as supporting different caching backends, the design lends itself to 8 | filtering and processing the headers of both requests and responses. (For 9 | example, changing `Cache-Control` headers.) It is not well-suited to 10 | transforming request or response *bodies*, though it can be integrated into 11 | systems that do provide this feature. 12 | 13 | Fishback tries hard to be RFC2616 compliant (and many of the slightly unusual 14 | features like `only-if-cached` and `max-stale` are supported), but there's 15 | probably some things it doesn't do completely correctly. (Though any variation 16 | from RFC2616 should be considered a bug.) 17 | 18 | ## Example 19 | 20 | ````js 21 | var fishback = require("../lib/fishback"); 22 | var http = require("http"); 23 | 24 | var proxy = fishback.createProxy(new fishback.Client("localhost", 9000)); 25 | proxy.on("newRequest", function (req) { 26 | console.log(req.method + " " + req.url); 27 | }); 28 | proxy.on("newResponse", function (res) { 29 | res.setHeader("cache-control", "public, max-age=3600"); 30 | }); 31 | 32 | http.createServer(proxy.request.bind(proxy)).listen(8000); 33 | 34 | console.log("Listening on port 8000, and proxying to localhost:9000"); 35 | ```` 36 | 37 | For more, see the [examples](examples) directory. 38 | 39 | ## Installation 40 | 41 | ````sh 42 | $ npm install fishback 43 | ```` 44 | 45 | ## API 46 | 47 | Fishback is heavily event based, and it relies heavily on the four event 48 | emitters `http.ServerRequest`, `http.ServerResponse`, `http.ClientRequest` and 49 | `http.ClientResponse`. 50 | 51 | In contrast to most NodeJS "middleware" systems (including 52 | [Connect](http://www.senchalabs.org/connect/)), Fishback itself does not contain 53 | a web server. Instead, Fishback provides a handler for `http.Server's` 54 | ['request' event](http://nodejs.org/api/http.html#http_event_request). 55 | 56 | ### fishback.createProxy(client) 57 | 58 | * `fishback.Handler` `client` - probably a `fishback.Client` 59 | 60 | Convenience function for creating a simple proxy from a client. 61 | 62 | ### fishback.createCachingProxy(cache, client) 63 | 64 | * `fishback.Handler` `cache` - probably one of the cache backends 65 | * `fishback.Handler` `client` - probably a `fishback.Client` 66 | 67 | Convenience function for creating a proxy from a cache and client. 68 | 69 | ### Class: fishback.Handler(...) 70 | 71 | "Abstract" base class for all handlers. All the classes below (the HTTP client 72 | that does real requests, the various cache backends, and the Fishback class that 73 | ties them together) are derived from this class. 74 | 75 | #### Event: 'newRequest' 76 | 77 | `function (serverRequest) { }` 78 | 79 | * `http.ServerRequest` `serverRequest` 80 | 81 | Emitted when a new request has been received. (At the point the event is 82 | emitted, only headers are available, though you can of course arrange to listen 83 | to other events.) 84 | 85 | #### Event: 'newResponse' 86 | 87 | `function (serverResponse) { }` 88 | 89 | * `http.ServerResponse` `serverResponse` 90 | 91 | Emitted when a new response is being sent. (At the point the event is emitted, 92 | only headers are available. Because of limitations in the 93 | [http.ServerResponse](http://nodejs.org/api/http.html#http_class_http_serverresponse) 94 | API (`write()` does not fire any events), it is not possible to observe any 95 | "write" events.) 96 | 97 | #### cache.request(serverRequest, serverResponse) 98 | 99 | * `http.ServerRequest` `serverRequest` 100 | * `http.ServerResponse` `serverResponse` 101 | 102 | Processes a request/response pair. 103 | 104 | If unable to handle the request (e.g. resource is not cached), `serverRequest` 105 | will emit the `reject` event. 106 | 107 | If request is accepted (i.e. the handler is writing to `serverResponse`), 108 | `serverResponse` will emit the `endHead` event when headers have been set on the 109 | response. 110 | 111 | (If overriding this method, note that the handler must ensure that if a request 112 | is rejected, any handlers that may subsequently be invoked are actually able to 113 | fulfill the request! The most important implication of this constraint is that 114 | if the request method is not `GET`, it must be rejected immediately 115 | (synchronously). If the method is `GET`, the request can be rejected 116 | asynchronously since subsequent handlers do not need any information from `data` 117 | events they would otherwise have missed).) 118 | 119 | #### cache.response(clientResponse) 120 | 121 | * `http.ClientResponse` `clientResponse` 122 | 123 | Process a *client* response. 124 | 125 | This is really only useful for caching handlers--it allows them to populate 126 | their caches from responses to any real HTTP requests that are issued. 127 | 128 | (For example, `fishback.createCachingProxy()` arranges things so that if the 129 | cache handler fires a `reject` event, a "real" HTTP request to be issued; the 130 | response from this request is then passed to the cache.) 131 | 132 | ### Class: fishback.Fishback(list) 133 | 134 | Derived from `fishback.Handler`. 135 | 136 | * `list` an array of `fishback.Handler` objects 137 | 138 | The last object in list is assumed to be a real HTTP client that will never 139 | `reject` a request. The other objects can reject requests. 140 | 141 | ### Class: fishback.Client(backend_hostname, backend_port, http) 142 | 143 | Derived from `fishback.Handler`. 144 | 145 | * `backend_hostname` - e.g. 'localhost' 146 | * `backend_port` - e.g. 80 147 | * `http` - object with a `request()` method, such as `require('http')` 148 | 149 | Does a real HTTP request. 150 | 151 | ### Class: fishback.Memory() 152 | 153 | Derived from `fishback.Handler`. 154 | 155 | Caching backend. 156 | 157 | ### Class: fishback.MongoDb() 158 | 159 | Derived from `fishback.Handler`. 160 | 161 | ## Bugs 162 | 163 | * There's no HTTPS support. 164 | * If the proxy server is able to read from the origin faster than the client 165 | can receive data, content needs to be buffered, either by node or the 166 | kernel. (This can be fixed by backing off when `write()` returns false, and 167 | resuming only when the ["drain" 168 | event](http://nodejs.org/api/stream.html#stream_event_drain) is 169 | triggered. This is only likely to be a problem if you're streaming very 170 | large files through node.) 171 | * ETags (and `must-revalidate`) are not supported. (You don't get incorrect 172 | results; you just need retrieve the entire resource from the origin each 173 | time.) 174 | 175 | ## See Also 176 | 177 | If you're only after a proxy (rather than a caching proxy), 178 | [node-http-proxy](https://github.com/nodejitsu/node-http-proxy) may be more 179 | suitable. 180 | 181 | ## Author 182 | 183 | Michael Stillwell 184 | 185 | -------------------------------------------------------------------------------- /test/helper.js: -------------------------------------------------------------------------------- 1 | /*jshint forin:true, noarg:true, noempty:true, eqeqeq:true, bitwise:true, strict:true, undef:true, unused:true, curly:true, node:true, indent:4, maxerr:50, globalstrict:true */ 2 | 3 | "use strict"; 4 | 5 | var assert = require('assert'); 6 | var fishback = require('../lib/helper'); 7 | var lib = require('./lib'); 8 | 9 | (function () { 10 | 11 | assert.doesNotThrow(function () { 12 | lib.step([]); 13 | }); 14 | 15 | })(); 16 | 17 | (function () { 18 | 19 | var a = []; 20 | 21 | lib.step([ 22 | function (callback) { 23 | a.push(1); 24 | callback(null, "a"); 25 | }, 26 | function (s, callback) { 27 | a.push(s); 28 | a.push(2); 29 | callback(null, "b"); 30 | }, 31 | function (s, callback) { 32 | a.push(s); 33 | a.push(3); 34 | callback(null, "c"); 35 | }, 36 | function (s, callback) { 37 | assert.equal(s, "c"); 38 | assert.deepEqual(a, [1, "a", 2, "b", 3]); 39 | callback(); 40 | } 41 | ]); 42 | 43 | })(); 44 | 45 | (function () { 46 | 47 | var a = []; 48 | 49 | lib.step( 50 | [ 51 | function (callback) { 52 | a.push(1); 53 | callback("ooops", "a"); 54 | }, 55 | function (s, callback) { 56 | // This shouldn't be called, because previous function returned error 57 | a.push(s); 58 | a.push(2); 59 | callback(); 60 | } 61 | ], function (err) { 62 | assert.equal(err, "ooops"); 63 | assert.deepEqual(a, [ 1 ]); 64 | } 65 | ); 66 | 67 | })(); 68 | 69 | (function () { 70 | 71 | var data = [ 72 | [ { }, false ], 73 | [ { "cache-control": "public" }, true ], 74 | [ { "cache-control": "s-maxage=7773, public, foo=bar" }, true ], 75 | [ { "cache-control": "no-cache, foo=bar" }, false ], 76 | [ { "cache-control": "no-store, foo=bar" }, false ], 77 | [ { "cache-control": "s-maxage=7773, private, foo=bar" }, false ], 78 | [ { "cache-control": "s-maxage=7773, qqq=public, foo=bar" }, true ], 79 | [ { "cache-control": "qqq=public, foo=bar" }, false ], 80 | [ { "expires": "Tue, 17 Jan 2012 00:49:02 GMT", "cache-control": "public, max-age=31536000" }, true ] 81 | ]; 82 | 83 | data.forEach(function (d) { 84 | assert.equal(fishback.canCache({ headers: d[0] }), d[1], require('util').inspect(d)); 85 | }); 86 | 87 | })(); 88 | 89 | (function () { 90 | 91 | var now = 1295222561275; 92 | Date.prototype.getTime = function () { return now; }; 93 | 94 | var data = [ 95 | [ 96 | { created: (now - (180 * 1000)), expires: (now + (180 * 1000)) }, 97 | { "cache-control": "max-age=120" }, 98 | false 99 | ], 100 | [ 101 | { created: now, expires: now + (180 * 1000) }, 102 | { "cache-control": "max-age=120" }, 103 | true 104 | ], 105 | [ 106 | { created: 0, expires: now - (60 * 1000) }, 107 | { "cache-control": "max-stale=120" }, 108 | true 109 | ], 110 | [ 111 | { created: 0, expires: now - (60 * 1000) }, 112 | { "cache-control": "max-stale=120, max-age=0" }, 113 | true 114 | ], 115 | [ 116 | { created: 0, expires: now - (60 * 1000) }, 117 | { "cache-control": "max-stale=30" }, 118 | false 119 | ], 120 | [ 121 | { created: 0, expires: now - (60 * 1000) }, 122 | { "cache-control": "max-stale" }, 123 | true 124 | ], 125 | [ 126 | { created: 0, expires: now + (60 * 1000) }, 127 | { "cache-control": "min-fresh=30" }, 128 | true 129 | ], 130 | [ 131 | { created: 0, expires: now + (60 * 1000) }, 132 | { "cache-control": "min-fresh=120" }, 133 | false 134 | ], 135 | [ 136 | { created: 0, expires: now - (60 * 1000) }, 137 | { }, 138 | false 139 | ], 140 | [ 141 | { created: 0, expires: now + (60 * 1000) }, 142 | { "cache-control": "max-age=0" }, 143 | false 144 | ], 145 | [ 146 | { created: 0, expires: now + (60 * 1000) }, 147 | { "cache-control": "must-revalidate" }, 148 | false 149 | ], 150 | [ 151 | { created: 0, expires: now - (60 * 1000) }, 152 | { "cache-control": "jjj" }, 153 | false 154 | ] 155 | ]; 156 | 157 | data.forEach(function (d) { 158 | assert.equal(fishback.isFreshEnough(d[0], { headers: d[1] }), d[2], require('util').inspect(d)); 159 | }); 160 | 161 | })(); 162 | 163 | (function () { 164 | 165 | var getTime = Date.prototype.getTime; 166 | 167 | var now = 1295222561275; 168 | Date.prototype.getTime = function () { return now; }; 169 | 170 | var data = [ 171 | [ { }, 1295222561275 ], 172 | [ { "expires": "Thu, 01 Dec 1994 16:00:00 GMT" }, 786297600000 ], 173 | [ { "cache-control": "max-age=60" }, 1295222621275 ] 174 | ]; 175 | 176 | data.forEach(function (d) { 177 | assert.equal(fishback.expiresAt({ headers: d[0] }), d[1]); 178 | }); 179 | 180 | Date.prototype.getTIme = getTime; 181 | 182 | })(); 183 | 184 | (function () { 185 | 186 | var data = [ 187 | [ { "cache-control": "jjjj", "foo": "no-cache" }, true ], 188 | [ { "cache-control": "no-cachejjj,foo=no-cache" }, true ], 189 | [ { "cache-control": "no-cachejjj,foo=no-cache, no-cache", "foo": "bar" }, false ] 190 | ]; 191 | 192 | data.forEach(function (d) { 193 | assert.equal(fishback.wantsCache({ headers: d[0] }), d[1]); 194 | 195 | }); 196 | 197 | })(); 198 | 199 | (function () { 200 | 201 | var data = [ 202 | [ { "cache-control": "jjjj", "foo": "no-cache" }, false ], 203 | [ { "cache-control": "no-cachejjj,only-if-cached" }, true ] 204 | ]; 205 | 206 | data.forEach(function (d) { 207 | assert.equal(fishback.onlyWantsCache({ headers: d[0] }), d[1]); 208 | 209 | }); 210 | 211 | })(); 212 | 213 | (function () { 214 | 215 | var data = [ 216 | [ 217 | { }, 218 | { }, 219 | true 220 | ], 221 | [ 222 | { "foo": "bar" }, 223 | { }, 224 | true 225 | ], 226 | [ 227 | { "foo": "bar", "vary": "*" }, 228 | { }, // doesn't have foo: bar 229 | false 230 | ], 231 | [ 232 | { "foo": "bar", "vary": "quux" }, 233 | { }, 234 | true 235 | ], 236 | [ 237 | { "foo": "bar", "vary": "foo" }, 238 | { }, 239 | false 240 | ], 241 | [ 242 | { "foo": "bar", "vary": "foo" }, 243 | { "foo": "bar" }, 244 | true 245 | ] 246 | 247 | ]; 248 | 249 | data.forEach(function (d) { 250 | assert.equal(fishback.isVaryMatch({ headers: d[0] }, { headers: d[1] }), d[2]); 251 | }); 252 | 253 | })(); 254 | 255 | (function () { 256 | 257 | var data = [ 258 | [ "", {} ], 259 | [ " ", {} ], 260 | [ "foo=bar ", { "foo": "bar" } ], 261 | [ "foo=bar,baz", { "foo": "bar", "baz": undefined } ], 262 | [ " MAX-AGE=60, private", { "max-age": 60, "private": undefined } ] 263 | ]; 264 | 265 | data.forEach(function (d) { 266 | assert.deepEqual(fishback.parseHeader(d[0]), d[1]); 267 | }); 268 | 269 | })(); 270 | 271 | (function () { 272 | var calledFoo = false; 273 | var calledBar = false; 274 | lib.group({ 275 | foo: function (callback) { 276 | process.nextTick(function () { 277 | calledFoo = true; 278 | callback("qqqq"); 279 | }); 280 | }, 281 | bar: function (callback) { 282 | process.nextTick(function () { 283 | calledBar = true; 284 | callback(1, 2, 3); 285 | }); 286 | } 287 | }, function (group) { 288 | assert.equal(true, calledFoo); 289 | assert.equal(true, calledBar); 290 | assert.deepEqual({ foo: "qqqq", bar: [ 1, 2, 3 ]}, group); 291 | }); 292 | assert.equal(false, calledFoo); 293 | assert.equal(false, calledBar); 294 | })(); 295 | 296 | (function () { 297 | var called = false; 298 | var n = 0; 299 | var knock = lib.knock(n, function () { 300 | assert.equal(false, called); 301 | called = true; 302 | }); 303 | assert.equal(true, called); 304 | knock(); 305 | assert.equal(true, called); 306 | })(); 307 | 308 | (function () { 309 | var called = false; 310 | var n = 3; 311 | var knock = lib.knock(n, function () { 312 | assert.equal(false, called); 313 | called = true; 314 | }); 315 | assert.equal(false, called); 316 | knock(); 317 | assert.equal(false, called); 318 | knock(); 319 | assert.equal(false, called); 320 | knock(); 321 | assert.equal(true, called); 322 | assert.equal(3, n); 323 | })(); 324 | --------------------------------------------------------------------------------