Browse Source

Massive rewrite - v1.0.0 (#7)

see release notes in the readme
Dave Eddy 4 years ago
parent
commit
586bfea910
9 changed files with 3285 additions and 293 deletions
  1. 2 0
      .gitignore
  2. 159 11
      README.md
  3. 571 276
      fs-caching-server.js
  4. 2067 0
      package-lock.json
  5. 10 6
      package.json
  6. 1 0
      smf/manifest.xml
  7. 449 0
      tests/all.js
  8. 10 0
      tests/dist-config.json
  9. 16 0
      tests/lib/index.js

+ 2 - 0
.gitignore

@@ -1,2 +1,4 @@
 node_modules
 node_modules
 cache
 cache
+tests/config.json
+tests/tmp

+ 159 - 11
README.md

@@ -6,24 +6,38 @@ A caching HTTP server/proxy that stores data on the local filesystem
 Installation
 Installation
 ------------
 ------------
 
 
-    [sudo] npm install -g fs-caching-server
+    [sudo] npm install [-g] fs-caching-server
 
 
-Description
------------
+`v1.0.0` Release Notes
+----------------------
+
+`v1.0.0` adds the following changes as well as bug fixes.
+
+- Fixes HEAD before GET caching - old behavior would cache 0-byte files
+- Handles redirects (or more accurately, doesn't handle - just proxies them)
+- Can retrieve from an HTTPS backend URL
+- Tests! there were none before - now a lot of tests have been added to ensure functionality
+- Can be used as a module (this added mostly for testing)
+- Cache dir can be specified as an argument/env variable - CWD not required anymore
+- Access logs now contain debug UUID if debug is specified
+
+CLI
+---
+
+### Description
 
 
 The `fs-caching-server` program installed can be used to spin up an HTTP server
 The `fs-caching-server` program installed can be used to spin up an HTTP server
 that acts a proxy to any other HTTP(s) server - with the added ability to
 that acts a proxy to any other HTTP(s) server - with the added ability to
 cache GET and HEAD requests that match a given regex.
 cache GET and HEAD requests that match a given regex.
 
 
-Example
--------
+### Example
 
 
 This will create a caching proxy that fronts Joyent's pkgsrc servers
 This will create a caching proxy that fronts Joyent's pkgsrc servers
 
 
     $ mkdir cache
     $ mkdir cache
-    $ fs-caching-server -c cache/ -d -U http://pkgsrc.joyent.com
+    $ fs-caching-server -c cache/ -d -U https://pkgsrc.joyent.com
     listening on http://0.0.0.0:8080
     listening on http://0.0.0.0:8080
-    proxying requests to http://pkgsrc.joyent.com
+    proxying requests to https://pkgsrc.joyent.com
     caching matches of /\.(png|jpg|jpeg|css|html|js|tar|tgz|tar\.gz)$/
     caching matches of /\.(png|jpg|jpeg|css|html|js|tar|tgz|tar\.gz)$/
     caching to /home/dave/dev/fs-caching-server/cache
     caching to /home/dave/dev/fs-caching-server/cache
 
 
@@ -49,23 +63,157 @@ and the local filesystem.  The second request shows the file was already
 present so it was streamed to the client without ever reaching out to
 present so it was streamed to the client without ever reaching out to
 pkgsrc.joyent.com.
 pkgsrc.joyent.com.
 
 
-Usage
------
+### Usage
 
 
     $ fs-caching-server -h
     $ fs-caching-server -h
     usage: fs-caching-server [options]
     usage: fs-caching-server [options]
 
 
     options
     options
-      -c, --cache-dir <dir>     directory to use for caching data, defaults to CWD
+      -c, --cache-dir <dir>     [env FS_CACHE_DIR] directory to use for caching data, defaults to CWD
       -d, --debug               enable debug logging to stderr
       -d, --debug               enable debug logging to stderr
       -H, --host <host>         [env FS_CACHE_HOST] the host on which to listen, defaults to 0.0.0.0
       -H, --host <host>         [env FS_CACHE_HOST] the host on which to listen, defaults to 0.0.0.0
       -h, --help                print this message and exit
       -h, --help                print this message and exit
       -p, --port <port>         [env FS_CACHE_PORT] the port on which to listen, defaults to 8080
       -p, --port <port>         [env FS_CACHE_PORT] the port on which to listen, defaults to 8080
-      -r, --regex <regex>       [env FS_CACHE_REGEX] regex to match to cache files, defaults to \.(png|jpg|jpeg|css|html|js|tar|tgz|tar\.gz)$
+      -r, --regex <regex>       [env FS_CACHE_REGEX] regex to match to cache files, defaults to /\.(png|jpg|jpeg|css|html|js|tar|tgz|tar\.gz)$/
       -U, --url <url>           [env FS_CACHE_URL] URL to proxy to
       -U, --url <url>           [env FS_CACHE_URL] URL to proxy to
       -u, --updates             check npm for available updates
       -u, --updates             check npm for available updates
       -v, --version             print the version number and exit
       -v, --version             print the version number and exit
 
 
+Module
+------
+
+### Description
+
+This module can also be used as a JavaScript module.
+
+### Example
+
+``` js
+var FsCachingServer = require('fs-caching-server').FsCachingServer;
+
+// proxy to Joyent's pkgsrc
+var opts = {
+    cacheDir: '/home/dave/cache-dir',
+    host: '0.0.0.0',
+    port: 8080,
+    backendUrl: 'https://pkgsrc.joyent.com'
+};
+
+var cachingServer = new FsCachingServer(opts);
+
+cachingServer.once('start', function () {
+    console.log('server started');
+});
+
+if (process.env.NODE_DEBUG) {
+    // debug messages go to stderr
+    cachingServer.on('log', console.error);
+}
+
+// access log messages to stdout
+cachingServer.on('access-log', console.log);
+
+cachingServer.start();
+```
+
+### Usage
+
+``` js
+/*
+ * FsCachingServer
+ *
+ * Create an instance of an FS Caching Server
+ *
+ * Aurguments
+ *  opts                  Object
+ *    opts.host           String (Required) Host to bind to. ex: '0.0.0.0',
+ *                                          '127.0.0.1', etc.
+ *    opts.port           Number (Required) Port to bind to. ex: 80, 8080, etc.
+ *    opts.backendUrl     String (Required) URL of the backend to proxy
+ *                                          requests to. ex:
+ *                                          'http://1.2.3.4:5678'
+ *    opts.cacheDir       String (Required) Directory for the cached items. ex:
+ *                                          '/tmp/fs-caching-server'
+ *    opts.regex          RegExp (Optional) Regex to match to enable caching,
+ *                                          defaults to REGEX above.
+ *    opts.noProxyHeaders Array  (Optional) An array of headers to not proxy to
+ *                                          the backend, default is [date,
+ *                                          server, host].
+ *    opts.cacheMethods   Array  (Optional) An array of methods to proxy,
+ *                                          default is [GET, HEAD].
+ *
+ * Methods
+ *
+ * .start()
+ *  - Start the server.
+ *
+ * .stop()
+ *  - Stop the server.
+ *
+ * .onIdle(cb)
+ *  - Call the callback when the caching server is "idle" (see events below).
+ *
+ * Events
+ *
+ * 'start'
+ *  - Called when the listener is started.
+ *
+ * 'stop'
+ *  - Called when the listener is stopped.
+ *
+ * 'access-log'
+ *  - Called per-request with a CLF-formatted apache log style string.
+ *
+ * 'log'
+ *  - Called with debug logs from the server - useful for debugging.
+ *
+ * 'idle'
+ *  - Called when the server is idle.  "idle" does not mean there are not
+ *  pending web requests, but instead means there are no pending filesystem
+ *  actions remaining.  This is useful for writing automated tests.
+ */
+ ```
+
+Testing
+-------
+
+```
+$ NODE_DEBUG=1 npm test
+
+
+> fs-caching-server@0.0.3 test /home/dave/dev/node-fs-caching-server
+> ./node_modules/tape/bin/tape tests/*.js
+
+TAP version 13
+# start cachingServer
+ok 1 tmp dir "/home/dave/dev/node-fs-caching-server/tests/tmp" cleared
+starting server
+listening on http://127.0.0.1:8081
+proxying requests to http://127.0.0.1:8080
+caching matches of /\.(png|jpg|jpeg|css|html|js|tar|tgz|tar\.gz)$/
+caching to /home/dave/dev/node-fs-caching-server/tests/tmp
+ok 2 cachingServer started
+# start backendServer
+ok 3 backendServer started on http://127.0.0.1:8080
+# simple cached request
+[63e44996-ac28-4a0f-b306-61aeeb88b53c] INCOMING REQUEST - GET /hello.png
+[63e44996-ac28-4a0f-b306-61aeeb88b53c] proxying GET to http://127.0.0.1:8080/hello.png
+[63e44996-ac28-4a0f-b306-61aeeb88b53c] saving local file to /home/dave/dev/node-fs-caching-server/tests/tmp/hello.png.in-progress
+[63e44996-ac28-4a0f-b306-61aeeb88b53c] 127.0.0.1 - - [13/Mar/2021:20:58:52 -0500] "GET /hello.png HTTP/1.1" 200 48 "-" "-"
+...
+...
+...
+ok 50 backendServer closed
+# stop cachingServer
+ok 51 cachingServer stopped
+
+1..51
+# tests 51
+# pass  51
+
+# ok
+```
+
 License
 License
 -------
 -------
 
 

+ 571 - 276
fs-caching-server.js

@@ -7,323 +7,618 @@
  * License: MIT
  * License: MIT
  */
  */
 
 
+var events = require('events');
 var fs = require('fs');
 var fs = require('fs');
 var http = require('http');
 var http = require('http');
+var https = require('https');
+var path = require('path');
 var url = require('url');
 var url = require('url');
 var util = require('util');
 var util = require('util');
 
 
 var accesslog = require('access-log');
 var accesslog = require('access-log');
-var getopt = require('posix-getopt');
+var assert = require('assert-plus');
 var mime = require('mime');
 var mime = require('mime');
 var mkdirp = require('mkdirp');
 var mkdirp = require('mkdirp');
-var path = require('path-platform');
-var uuid = require('node-uuid');
-var clone = require("readable-stream-clone");
+var uuid = require('uuid');
+var Clone = require('readable-stream-clone');
 
 
-var package = require('./package.json');
+/*
+ * default headers to ignore when proxying request (not copied to backend
+ * server).
+ */
+var NO_PROXY_HEADERS = ['date', 'server', 'host'];
+
+/*
+ * default methods that will be considered for caching - all others will be
+ * proxied directly.
+ */
+var CACHE_METHODS = ['GET', 'HEAD'];
 
 
+// default regex to match for caching.
+var REGEX = /\.(png|jpg|jpeg|css|html|js|tar|tgz|tar\.gz)$/;
+
+// safe hasOwnProperty
 function hap(o, p) {
 function hap(o, p) {
-  return ({}).hasOwnProperty.call(o, p);
+    return ({}).hasOwnProperty.call(o, p);
 }
 }
 
 
-// don't copy these headers when proxying request
-var NO_PROXY_HEADERS = ['date', 'server', 'host'];
+/*
+ * FsCachingServer
+ *
+ * Create an instance of an FS Caching Server
+ *
+ * Aurguments
+ *  opts                  Object
+ *    opts.host           String (Required) Host to bind to. ex: '0.0.0.0',
+ *                                          '127.0.0.1', etc.
+ *    opts.port           Number (Required) Port to bind to. ex: 80, 8080, etc.
+ *    opts.backendUrl     String (Required) URL of the backend to proxy
+ *                                          requests to. ex:
+ *                                          'http://1.2.3.4:5678'
+ *    opts.cacheDir       String (Required) Directory for the cached items. ex:
+ *                                          '/tmp/fs-caching-server'
+ *    opts.regex          RegExp (Optional) Regex to match to enable caching,
+ *                                          defaults to REGEX above.
+ *    opts.noProxyHeaders Array  (Optional) An array of headers to not proxy to
+ *                                          the backend, default is [date,
+ *                                          server, host].
+ *    opts.cacheMethods   Array  (Optional) An array of methods to proxy,
+ *                                          default is [GET, HEAD].
+ *
+ * Methods
+ *
+ * .start()
+ *  - Start the server.
+ *
+ * .stop()
+ *  - Stop the server.
+ *
+ * .onIdle(cb)
+ *  - Call the callback when the caching server is "idle" (see events below).
+ *
+ * Events
+ *
+ * 'start'
+ *  - Called when the listener is started.
+ *
+ * 'stop'
+ *  - Called when the listener is stopped.
+ *
+ * 'access-log'
+ *  - Called per-request with a CLF-formatted apache log style string.
+ *
+ * 'log'
+ *  - Called with debug logs from the server - useful for debugging.
+ *
+ * 'idle'
+ *  - Called when the server is idle.  "idle" does not mean there are not
+ *  pending web requests, but instead means there are no pending filesystem
+ *  actions remaining.  This is useful for writing automated tests.
+ */
+function FsCachingServer(opts) {
+    var self = this;
 
 
-// these methods use the cache, everything is proxied
-var CACHE_METHODS = ['GET', 'HEAD'];
+    assert.object(opts, 'opts');
+    assert.string(opts.host, 'opts.host');
+    assert.number(opts.port, 'opts.port');
+    assert.string(opts.backendUrl, 'opts.backendUrl');
+    assert.string(opts.cacheDir, 'opts.cacheDir');
+    assert.optionalRegexp(opts.regex, 'opts.regex');
+    assert.optionalArrayOfString(opts.noProxyHeaders, 'opts.noProxyHeaders');
+    assert.optionalArrayOfString(opts.cacheMethods, 'opts.cacheMethods');
 
 
-// command line arguments
-var opts = {
-  host: process.env.FS_CACHE_HOST || '0.0.0.0',
-  port: process.env.FS_CACHE_PORT || 8080,
-  url: process.env.FS_CACHE_URL,
-  regex: process.env.FS_CACHE_REGEX || '\\.(png|jpg|jpeg|css|html|js|tar|tgz|tar\\.gz)$',
-  debug: process.env.FS_CACHE_DEBUG,
-};
+    events.EventEmitter.call(self);
 
 
-var usage = [
-  'usage: fs-caching-server [options]',
-  '',
-  'options',
-  '  -c, --cache-dir <dir>     directory to use for caching data, defaults to CWD',
-  '  -d, --debug               enable debug logging to stderr',
-  '  -H, --host <host>         [env FS_CACHE_HOST] the host on which to listen, defaults to ' + opts.host,
-  '  -h, --help                print this message and exit',
-  '  -p, --port <port>         [env FS_CACHE_PORT] the port on which to listen, defaults to ' + opts.port,
-  '  -r, --regex <regex>       [env FS_CACHE_REGEX] regex to match to cache files, defaults to ' + opts.regex,
-  '  -U, --url <url>           [env FS_CACHE_URL] URL to proxy to',
-  '  -u, --updates             check npm for available updates',
-  '  -v, --version             print the version number and exit',
-].join('\n');
-
-var options = [
-  'c:(cache-dir)',
-  'd(debug)',
-  'H:(host)',
-  'h(help)',
-  'p:(port)',
-  'r:(regex)',
-  'U:(url)',
-  'u(updates)',
-  'v(version)'
-].join('');
-var parser = new getopt.BasicParser(options, process.argv);
-var option;
-while ((option = parser.getopt()) !== undefined) {
-  switch (option.option) {
-    case 'c': process.chdir(option.optarg); break;
-    case 'd': opts.debug = true; break;
-    case 'H': opts.host = option.optarg; break;
-    case 'h': console.log(usage); process.exit(0); break;
-    case 'p': opts.port = parseInt(option.optarg, 10); break;
-    case 'r': opts.regex = option.optarg; break;
-    case 'U': opts.url = option.optarg; break;
-    case 'u': // check for updates
-      require('latest').checkupdate(package, function(ret, msg) {
-        console.log(msg);
-        process.exit(ret);
-      });
-      return;
-    case 'v': console.log(package.version); process.exit(0); break;
-    default: console.error(usage); process.exit(1);
-  }
-}
+    self.host = opts.host;
+    self.port = opts.port;
+    self.backendUrl = opts.backendUrl;
+    self.cacheDir = opts.cacheDir;
+    self.regex = opts.regex || REGEX;
+    self.noProxyHeaders = opts.noProxyHeaders || NO_PROXY_HEADERS;
+    self.cacheMethods = opts.cacheMethods || CACHE_METHODS;
+    self.server = null;
+    self.idle = true;
+    self.backendHttps = !!self.backendUrl.match(/^https:/);
 
 
-if (!opts.url) {
-  console.error('url must be specified with `-U <url>` or as FS_CACHE_URL');
-  process.exit(1);
+    self._opts = opts;
 }
 }
+util.inherits(FsCachingServer, events.EventEmitter);
 
 
+/*
+ * Start the server
+ *
+ * emits "listening" when the server starts
+ */
+FsCachingServer.prototype.start = function start() {
+    var self = this;
 
 
-// remove trailing slash
-opts.url = opts.url.replace(/\/*$/, '');
+    assert(!self.server, 'server already exists');
+    assert(!self.inProgress, 'requests in progress');
 
 
-// create the regex option - this may throw
-opts.regex = new RegExp(opts.regex);
+    self._log('starting server');
 
 
-// start the server
-http.createServer(onrequest).listen(opts.port, opts.host, listening);
+    self.server = http.createServer(onRequest);
+    self.server.listen(self.port, self.host, onListen);
+    self.inProgress = {};
+    self.idle = true;
 
 
-function listening() {
-  console.log('listening on http://%s:%d', opts.host, opts.port);
-  console.log('proxying requests to %s', opts.url);
-  console.log('caching matches of %s', opts.regex);
-  console.log('caching to %s', process.cwd());
-}
+    function onListen() {
+        self._log('listening on http://%s:%d', self.host, self.port);
+        self._log('proxying requests to %s', self.backendUrl);
+        self._log('caching matches of %s', self.regex);
+        self._log('caching to %s', self.cacheDir);
 
 
-// store files that are currently in progress -
-// if multiple requests are made for the same file, this will ensure that
-// only 1 connection is made to the server, and all subsequent requests will
-// be queued and then handled after the initial transfer is finished
-var inprogress = {};
-function onrequest(req, res) {
-  accesslog(req, res);
-
-  var _id = uuid.v4();
-  function log() {
-    if (!opts.debug)
-      return;
-    var s = util.format.apply(util, arguments);
-    return console.error('[%s] %s', _id, s);
-  }
-  log('INCOMING REQUEST - %s %s', req.method, req.url);
-
-  // parse the URL and determine the filename
-  var parsed = url.parse(req.url);
-  var file;
-  try {
-    file = '.' + path.posix.normalize(decodeURIComponent(parsed.pathname));
-  } catch (e) {
-    log('failed to parse pathname - sending 400 to client -', e.message);
-    res.statusCode = 400;
-    res.end();
-    return;
-  }
-
-  // If the request is not a HEAD or GET request, or if it does not match the
-  // regex supplied, we simply proxy it without a cache.
-  if (CACHE_METHODS.indexOf(req.method) < 0 || ! opts.regex.test(file)) {
-    log('request will be proxied with no caching');
-    var uristring = opts.url + parsed.path;
-    var uri = url.parse(uristring);
-    uri.method = req.method;
-    uri.headers = {};
-    Object.keys(req.headers || {}).forEach(function(header) {
-      if (NO_PROXY_HEADERS.indexOf(header) === -1)
-        uri.headers[header] = req.headers[header];
-    });
-    uri.headers.host = uri.host;
-    var oreq = http.request(uri, function(ores) {
-      res.statusCode = ores.statusCode;
-      Object.keys(ores.headers || {}).forEach(function(header) {
-        if (NO_PROXY_HEADERS.indexOf(header) === -1)
-          res.setHeader(header, ores.headers[header]);
-      });
-      ores.pipe(res);
-    });
-    oreq.on('error', function(e) {
-      res.statusCode = 500;
-      res.end();
-    });
-    req.pipe(oreq);
-    return;
-  }
-
-  // check to see if the file exists
-  fs.stat(file, function(err, stats) {
-    // directory, give up
-    if (stats && stats.isDirectory()) {
-      log('%s is a directory - sending 400 to client', file);
-      res.statusCode = 400;
-      res.end();
-      return;
+        self.emit('start');
     }
     }
 
 
-    // file exists, stream it locally
-    if (stats) {
-      log('%s is a file (cached) - streaming to client', file);
-      streamfile(file, stats, req, res);
-      return;
+    function onRequest(req, res) {
+        self._onRequest(req, res);
     }
     }
+};
+
+/*
+ * Stop the server
+ *
+ * emits "stop" when the server stops
+ */
+FsCachingServer.prototype.stop = function stop() {
+    var self = this;
+
+    assert(self.server, 'server does not exist');
+
+    self.server.once('close', function () {
+        self.idle = true;
+        self.server = null;
+        self.emit('stop');
+    });
+    self.server.close();
+};
 
 
-    // another request is already proxying for this file, we wait
-    if (hap(inprogress, file)) {
-      log('%s download in progress - response queued', file);
-      inprogress[file].push([req, res]);
-      return;
+/*
+ * A convience method for calling the given 'cb' when the server is idle.  The
+ * callback will be invoked immediately if the server is idle, or will be
+ * scheduled to run when the server becomes idle.
+ */
+FsCachingServer.prototype.onIdle = function onIdle(cb) {
+    var self = this;
+
+    assert.func(cb, 'cb');
+
+    if (self.idle) {
+        cb();
+    } else {
+        self.once('idle', cb);
+    }
+};
+
+/*
+ * Called internally when a new request is received
+ */
+FsCachingServer.prototype._onRequest = function _onRequest(req, res) {
+    var self = this;
+
+    var _id = uuid.v4();
+
+    function log() {
+        var s = util.format.apply(util, arguments);
+        self._log('[%s] %s', _id, s);
     }
     }
 
 
-    // error with stat, proxy it
-    inprogress[file] = [];
-    var uristring = opts.url + parsed.path;
-    var uri = url.parse(uristring);
-    uri.method = req.method;
-    uri.headers = {};
-    Object.keys(req.headers || {}).forEach(function(header) {
-      if (NO_PROXY_HEADERS.indexOf(header) === -1)
-        uri.headers[header] = req.headers[header];
+    accesslog(req, res, undefined, function (s) {
+        self.emit('access-log', s);
+        log(s);
     });
     });
-    uri.headers.host = uri.host;
-    log('proxying %s to %s', uri.method, uristring);
-
-    // proxy it
-    var oreq = http.request(uri, function(ores) {
-      res.statusCode = ores.statusCode;
-      Object.keys(ores.headers || {}).forEach(function(header) {
-        if (NO_PROXY_HEADERS.indexOf(header) === -1)
-          res.setHeader(header, ores.headers[header]);
-      });
-
-      if (res.statusCode !== 200) {
-        ores.pipe(res);
-        finish();
+
+    log('INCOMING REQUEST - %s %s', req.method, req.url);
+
+    // parse the URL and determine the filename
+    var parsed = url.parse(req.url);
+    var file;
+    try {
+        file = path.posix.normalize(decodeURIComponent(parsed.pathname));
+    } catch (e) {
+        log('failed to parse pathname - sending 400 to client -', e.message);
+        res.statusCode = 400;
+        res.end();
         return;
         return;
-      }
-
-      mkdirp(path.dirname(file), function(err) {
-        var tmp = file + '.in-progress';
-        log('saving local file to %s', tmp);
-        var ws = fs.createWriteStream(tmp);
-        ws.on('finish', function() {
-          fs.rename(tmp, file, function(err) {
-            if (err) {
-              log('failed to rename %s to %s', tmp, file);
-              finish();
-            } else {
-              log('renamed %s to %s', tmp, file);
-              finish(file, ores);
+    }
+
+    /*
+     * Any request that isn't in the list of methods to cache, or any request
+     * to a file that doesn't match the regex, gets proxied directly.
+     */
+    if (self.cacheMethods.indexOf(req.method) < 0 || ! self.regex.test(file)) {
+        proxyRequest();
+        return;
+    }
+
+    // make the filename relative to the cache dir
+    file = path.join(self.cacheDir, file);
+
+    // check to see if the file exists
+    fs.stat(file, function (err, stats) {
+        // directory, give up
+        if (stats && stats.isDirectory()) {
+            log('%s is a directory - sending 400 to client', file);
+            res.statusCode = 400;
+            res.end();
+            return;
+        }
+
+        // file exists, stream it locally
+        if (stats) {
+            log('%s is a file (cached) - streaming to client', file);
+            streamFile(file, stats, req, res);
+            return;
+        }
+
+        // another request is already proxying for this file, we wait
+        if (hap(self.inProgress, file)) {
+            log('%s download in progress - response queued', file);
+            self.inProgress[file].push({
+                id: _id,
+                req: req,
+                res: res,
+            });
+            return;
+        }
+
+        /*
+         * If we are here the file matches the caching requirements based on
+         * method and regex, and is also not found on the local filesystem.
+         *
+         * The final step before caching the request is to ensure it is *not* a
+         * HEAD requests.  HEAD requests should only ever be cached if the data
+         * was retrieved and cached first by another type of request.  In this
+         * specific case the HEAD request should just be proxied directly.
+         */
+        if (req.method === 'HEAD') {
+            proxyRequest();
+            return;
+        }
+
+        // error with stat, proxy it
+        self.inProgress[file] = [];
+        self.idle = false;
+
+        var uristring = self.backendUrl + parsed.path;
+        var uri = url.parse(uristring);
+        uri.method = req.method;
+        uri.headers = {};
+        Object.keys(req.headers || {}).forEach(function (header) {
+            if (self.noProxyHeaders.indexOf(header) === -1) {
+                uri.headers[header] = req.headers[header];
+            }
+        });
+        uri.headers.host = uri.host;
+
+        log('proxying %s to %s', uri.method, uristring);
+
+        // proxy it
+        var oreq = self._request(uri, function (ores) {
+            res.statusCode = ores.statusCode;
+
+            Object.keys(ores.headers || {}).forEach(function (header) {
+                if (self.noProxyHeaders.indexOf(header) === -1) {
+                    res.setHeader(header, ores.headers[header]);
+                }
+            });
+
+            if (res.statusCode < 200 || res.statusCode >= 300) {
+                //ores.pipe(res);
+                log('statusCode %d from backend not in 200 range - proxying ' +
+                    'back to caller', res.statusCode);
+                finish({
+                    statusCode: res.statusCode,
+                });
+                res.end();
+                return;
             }
             }
-          });
+
+            mkdirp(path.dirname(file), function (err) {
+                var tmp = file + '.in-progress';
+
+                log('saving local file to %s', tmp);
+
+                var ws = fs.createWriteStream(tmp);
+
+                ws.once('finish', function () {
+                    fs.rename(tmp, file, function (err) {
+                        if (err) {
+                            log('failed to rename %s to %s', tmp, file);
+                            finish({
+                                statusCode: 500
+                            });
+                            return;
+                        }
+
+                        // everything worked! proxy all with success
+                        log('renamed %s to %s', tmp, file);
+                        finish({
+                            ores: ores
+                        });
+                    });
+                });
+
+                ws.once('error', function (e) {
+                    log('failed to save local file %s', e.message);
+                    ores.unpipe(ws);
+                    finish({
+                        statusCode: 500,
+                    });
+                });
+
+                var ores_ws = new Clone(ores);
+                var ores_res = new Clone(ores);
+                ores_ws.pipe(ws);
+                ores_res.pipe(res);
+            });
         });
         });
-        ws.on('error', function(e) {
-          log('failed to save local file %s', e.message);
-          ores.unpipe(ws);
-          finish();
+
+        oreq.on('error', function (e) {
+            log('error with proxy request %s', e.message);
+            res.statusCode = 500;
+            res.end();
+            finish({
+                statusCode: 500
+            });
         });
         });
-        ores_ws = new clone(ores);
-        ores_res = new clone(ores);
-        ores_ws.pipe(ws);
-        ores_res.pipe(res);
-      });
-    });
-    oreq.on('error', function(e) {
-      log('error with proxy request %s', e.message);
-      finish();
-      res.statusCode = 500;
-      res.end();
-    });
-    oreq.end();
-  });
-}
 
 
-// finish queued up requests
-function finish(file, ores) {
-  if (!file || !ores) {
-    inprogress[file].forEach(function(o) {
-      var res = o[1];
-      res.statusCode = 400;
-      res.end();
+        oreq.end();
     });
     });
-    delete inprogress[file];
-    return;
-  }
-  fs.stat(file, function(err, stats) {
-    if (stats && stats.isDirectory()) {
-      // directory, give up
-      inprogress[file].forEach(function(o) {
-        var res = o[1];
-        res.statusCode = 400;
-        res.end();
-      });
-    } else if (stats) {
-      // file exists, stream it locally
-      inprogress[file].forEach(function(o) {
-        var req = o[0];
-        var res = o[1];
-        res.statusCode = ores.statusCode;
-        Object.keys(ores.headers || {}).forEach(function(header) {
-          if (NO_PROXY_HEADERS.indexOf(header) === -1)
-            res.setHeader(header, ores.headers[header]);
+
+    /*
+     * Proxy file directly with no caching
+     */
+    function proxyRequest() {
+        log('request will be proxied with no caching');
+
+        var uristring = self.backendUrl + parsed.path;
+        var uri = url.parse(uristring);
+        uri.method = req.method;
+        uri.headers = {};
+
+        Object.keys(req.headers || {}).forEach(function (header) {
+            if (self.noProxyHeaders.indexOf(header) === -1) {
+                uri.headers[header] = req.headers[header];
+            }
+        });
+
+        uri.headers.host = uri.host;
+
+        var oreq = self._request(uri, function (ores) {
+            res.statusCode = ores.statusCode;
+            Object.keys(ores.headers || {}).forEach(function (header) {
+                if (self.noProxyHeaders.indexOf(header) === -1) {
+                    res.setHeader(header, ores.headers[header]);
+                }
+            });
+            ores.pipe(res);
+        });
+
+        oreq.once('error', function (e) {
+            res.statusCode = 500;
+            res.end();
+        });
+
+        req.pipe(oreq);
+        return;
+    }
+
+    /*
+     * Process requests that may be blocked on the current file to be cached.
+     */
+    function finish(opts) {
+        assert.object(opts, 'opts');
+        assert.optionalNumber(opts.statusCode, 'opts.statusCode');
+        assert.optionalObject(opts.ores, 'opts.ores');
+
+        if (hap(opts, 'statusCode')) {
+            self.inProgress[file].forEach(function (o) {
+                o.res.statusCode = opts.statusCode;
+                o.res.end();
+            });
+
+            delete self.inProgress[file];
+            checkIdle();
+            return;
+        }
+
+        assert.object(opts.ores, 'opts.ores');
+        fs.stat(file, function (err, stats) {
+            if (stats && stats.isDirectory()) {
+                // directory, give up
+                self.inProgress[file].forEach(function (o) {
+                    o.res.statusCode = 400;
+                    o.res.end();
+                });
+            } else if (stats) {
+                // file exists, stream it locally
+                self.inProgress[file].forEach(function (o) {
+                    o.res.statusCode = opts.ores.statusCode;
+
+                    Object.keys(opts.ores.headers || {}).forEach(function (header) {
+                        if (self.noProxyHeaders.indexOf(header) === -1) {
+                            o.res.setHeader(header, opts.ores.headers[header]);
+                        }
+                    });
+
+                    streamFile(file, stats, o.req, o.res);
+                });
+            } else {
+                // not found
+                self.inProgress[file].forEach(function (o) {
+                    o.res.statusCode = 500;
+                    o.res.end();
+                });
+            }
+
+            delete self.inProgress[file];
+            checkIdle();
         });
         });
-        streamfile(file, stats, req, res);
-      });
+    }
+
+    /*
+     * Check if the server is idle and emit an event if it is
+     */
+    function checkIdle() {
+        if (Object.keys(self.inProgress).length === 0) {
+            self.idle = true;
+            self.emit('idle');
+        }
+    }
+};
+
+/*
+ * Emit a "log" event with the given arguments (formatted via util.format)
+ */
+FsCachingServer.prototype._log = function _log() {
+    var self = this;
+
+    var s = util.format.apply(util, arguments);
+
+    self.emit('log', s);
+};
+
+/*
+ * Create an outgoing http/https request based on the backend URL
+ */
+FsCachingServer.prototype._request = function _request(uri, cb) {
+    var self = this;
+
+    if (self.backendHttps) {
+        return https.request(uri, cb);
     } else {
     } else {
-      // not found
-      inprogress[file].forEach(function(o) {
-        var res = o[1];
-        res.statusCode = 500;
+        return http.request(uri, cb);
+    }
+};
+
+/*
+ * Given a filename and its stats object (and req and res)
+ * stream it to the caller.
+ */
+function streamFile(file, stats, req, res) {
+    var etag = util.format('"%d-%d"', stats.size, stats.mtime.getTime());
+
+    res.setHeader('Last-Modified', stats.mtime.toUTCString());
+    res.setHeader('Content-Type', mime.lookup(file));
+    res.setHeader('ETag', etag);
+
+    if (req.headers['if-none-match'] === etag) {
+        // etag matched, end the request
+        res.statusCode = 304;
         res.end();
         res.end();
-      });
+        return;
     }
     }
-    delete inprogress[file];
-  });
+
+    res.setHeader('Content-Length', stats.size);
+    if (req.method === 'HEAD') {
+        res.end();
+        return;
+    }
+
+    var rs = fs.createReadStream(file);
+    rs.pipe(res);
+    rs.once('error', function (e) {
+        res.statusCode = e.code === 'ENOENT' ? 404 : 500;
+        res.end();
+    });
+    res.once('close', function () {
+        rs.destroy();
+    });
+}
+
+/*
+ * Main method (invoked from CLI)
+ */
+function main() {
+    var getopt = require('posix-getopt');
+
+    var package = require('./package.json');
+
+    // command line arguments
+    var opts = {
+        host: process.env.FS_CACHE_HOST || '0.0.0.0',
+        port: process.env.FS_CACHE_PORT || 8080,
+        backendUrl: process.env.FS_CACHE_URL,
+        cacheDir: process.env.FS_CACHE_DIR || process.cwd(),
+        regex: process.env.FS_CACHE_REGEX,
+    };
+    var debug = !!process.env.FS_CACHE_DEBUG;
+
+    var usage = [
+        'usage: fs-caching-server [options]',
+        '',
+        'options',
+        '  -c, --cache-dir <dir>     [env FS_CACHE_DIR] directory to use for caching data, defaults to CWD',
+        '  -d, --debug               enable debug logging to stderr',
+        '  -H, --host <host>         [env FS_CACHE_HOST] the host on which to listen, defaults to ' + opts.host,
+        '  -h, --help                print this message and exit',
+        '  -p, --port <port>         [env FS_CACHE_PORT] the port on which to listen, defaults to ' + opts.port,
+        '  -r, --regex <regex>       [env FS_CACHE_REGEX] regex to match to cache files, defaults to ' + REGEX,
+        '  -U, --url <url>           [env FS_CACHE_URL] URL to proxy to',
+        '  -u, --updates             check npm for available updates',
+        '  -v, --version             print the version number and exit',
+    ].join('\n');
+
+    var options = [
+        'c:(cache-dir)',
+        'd(debug)',
+        'H:(host)',
+        'h(help)',
+        'p:(port)',
+        'r:(regex)',
+        'U:(url)',
+        'u(updates)',
+        'v(version)'
+    ].join('');
+    var parser = new getopt.BasicParser(options, process.argv);
+    var option;
+    while ((option = parser.getopt()) !== undefined) {
+        switch (option.option) {
+        case 'c': opts.cacheDir = option.optarg; break;
+        case 'd': debug = true; break;
+        case 'H': opts.host = option.optarg; break;
+        case 'h': console.log(usage); process.exit(0); break;
+        case 'p': opts.port = parseInt(option.optarg, 10); break;
+        case 'r': opts.regex = option.optarg; break;
+        case 'U': opts.backendUrl = option.optarg; break;
+        case 'u': // check for updates
+            require('latest').checkupdate(package, function (ret, msg) {
+                console.log(msg);
+                process.exit(ret);
+            });
+            return;
+        case 'v': console.log(package.version); process.exit(0); break;
+        default: console.error(usage); process.exit(1);
+        }
+    }
+
+    if (!opts.backendUrl) {
+        console.error('url must be specified with `-U <url>` or as FS_CACHE_URL');
+        process.exit(1);
+    }
+
+    if (opts.regex) {
+        opts.regex = new RegExp(opts.regex);
+    }
+
+    // remove trailing slash
+    opts.backendUrl = opts.backendUrl.replace(/\/{0,}$/, '');
+
+    var fsCachingServer = new FsCachingServer(opts);
+
+    fsCachingServer.on('access-log', console.log);
+    if (debug) {
+        fsCachingServer.on('log', console.error);
+    }
+
+    fsCachingServer.start();
 }
 }
 
 
-// given a filename and its stats object (and req and res)
-// stream it
-function streamfile(file, stats, req, res) {
-  var etag = util.format('"%d-%d"', stats.size, stats.mtime.getTime());
-  res.setHeader('Last-Modified', stats.mtime.toUTCString());
-  res.setHeader('Content-Type', mime.lookup(file));
-  res.setHeader('ETag', etag);
-  if (req.headers['if-none-match'] === etag) {
-    // etag matched, end the request
-    res.statusCode = 304;
-    res.end();
-    return;
-  }
-
-  res.setHeader('Content-Length', stats.size);
-  if (req.method === 'HEAD') {
-    res.end();
-    return;
-  }
-
-  var rs = fs.createReadStream(file);
-  rs.pipe(res);
-  rs.on('error', function(e) {
-    res.statusCode = e.code === 'ENOENT' ? 404 : 500;
-    res.end();
-  });
-  res.on('close', rs.destroy.bind(rs));
+if (require.main === module) {
+    main();
+} else {
+    module.exports = FsCachingServer;
+    module.exports.FsCachingServer = FsCachingServer;
 }
 }

File diff suppressed because it is too large
+ 2067 - 0
package-lock.json


+ 10 - 6
package.json

@@ -5,7 +5,7 @@
   "main": "fs-caching-server.js",
   "main": "fs-caching-server.js",
   "preferGlobal": true,
   "preferGlobal": true,
   "scripts": {
   "scripts": {
-    "test": "echo \"Error: no test specified\" && exit 1"
+    "test": "./node_modules/tape/bin/tape tests/*.js"
   },
   },
   "bin": {
   "bin": {
     "fs-caching-server": "./fs-caching-server.js"
     "fs-caching-server": "./fs-caching-server.js"
@@ -26,13 +26,17 @@
   },
   },
   "homepage": "https://github.com/bahamas10/node-fs-caching-server",
   "homepage": "https://github.com/bahamas10/node-fs-caching-server",
   "dependencies": {
   "dependencies": {
-    "access-log": "^0.3.9",
+    "access-log": "^0.4.1",
+    "assert-plus": "^1.0.0",
     "latest": "^0.2.0",
     "latest": "^0.2.0",
     "mime": "^1.3.4",
     "mime": "^1.3.4",
     "mkdirp": "^0.5.0",
     "mkdirp": "^0.5.0",
-    "node-uuid": "^1.4.3",
-    "path-platform": "^0.11.15",
-    "posix-getopt": "^1.1.0",
-    "readable-stream-clone": "^0.0.7"
+    "posix-getopt": "^1.2.0",
+    "readable-stream-clone": "^0.0.7",
+    "uuid": "^8.3.2"
+  },
+  "devDependencies": {
+    "rimraf": "^3.0.2",
+    "tape": "^5.2.2"
   }
   }
 }
 }

+ 1 - 0
smf/manifest.xml

@@ -9,6 +9,7 @@
 		<method_context working_directory='/tmp'>
 		<method_context working_directory='/tmp'>
 			<method_credential user='nobody' group='other'/>
 			<method_credential user='nobody' group='other'/>
 			<method_environment>
 			<method_environment>
+				<envvar name='FS_CACHE_DIR' value='/var/tmp/cache'/> <!-- the cache dir -->
 				<envvar name='FS_CACHE_URL' value='http://pkgsrc.joyent.com'/> <!-- the URL you want to proxy -->
 				<envvar name='FS_CACHE_URL' value='http://pkgsrc.joyent.com'/> <!-- the URL you want to proxy -->
 				<envvar name='FS_CACHE_HOST' value='0.0.0.0'/>
 				<envvar name='FS_CACHE_HOST' value='0.0.0.0'/>
 				<envvar name='FS_CACHE_PORT' value='8080'/>
 				<envvar name='FS_CACHE_PORT' value='8080'/>

+ 449 - 0
tests/all.js

@@ -0,0 +1,449 @@
+/*
+ * Basic caching tests
+ */
+
+var fs = require('fs');
+var http = require('http');
+var path = require('path');
+var url = require('url');
+var util = require('util');
+
+var assert = require('assert-plus');
+var mkdirp = require('mkdirp');
+var rimraf = require('rimraf');
+var test = require('tape');
+
+var lib = require('./lib');
+var FsCachingServer = require('../').FsCachingServer;
+
+var f = util.format;
+var config = lib.readConfig();
+
+var cachingServerURL = f('http://%s:%d', config.cachingServer.host,
+    config.cachingServer.port);
+var backendServerURL = f('http://%s:%d', config.backendServer.host,
+    config.backendServer.port);
+
+var cachingServer;
+var backendServer;
+
+var dir = path.join(__dirname, 'tmp');
+
+/*
+ * wrapper for making web requests to the caching server
+ */
+function cacheRequest(p, opts, cb) {
+    assert.string(p, 'p');
+    assert.object(opts, 'opts');
+    assert.string(opts.method, 'opts.method');
+    assert.func(cb, 'cb');
+
+    var uri = f('%s%s', cachingServerURL, p);
+
+    var o = url.parse(uri);
+    Object.keys(opts).forEach(function (key) {
+        var val = opts[key];
+        o[key] = val;
+    });
+
+    var req = http.request(o, function (res) {
+        var data = '';
+
+        res.setEncoding('utf-8');
+
+        res.on('data', function (d) {
+            data += d;
+        });
+
+        res.on('end', function () {
+            cb(null, data, res);
+        });
+
+        res.once('error', function (err) {
+            cb(err);
+        });
+    });
+
+    req.end();
+}
+
+/*
+ * start the caching frontend
+ */
+test('start cachingServer', function (t) {
+    var opts = {
+        cacheDir: dir,
+        host: config.cachingServer.host,
+        port: config.cachingServer.port,
+        backendUrl: backendServerURL
+    };
+
+    mkdirp.sync(dir);
+    rimraf.sync(dir + '/*');
+    t.pass(f('tmp dir "%s" cleared', dir));
+
+    cachingServer = new FsCachingServer(opts);
+
+    cachingServer.once('start', function () {
+        t.pass('cachingServer started');
+        t.end();
+    });
+
+    if (process.env.NODE_DEBUG) {
+        cachingServer.on('log', console.log);
+    }
+
+    cachingServer.start();
+});
+
+/*
+ * start the web server backend
+ */
+test('start backendServer', function (t) {
+    backendServer = http.createServer(onRequest);
+    backendServer.listen(config.backendServer.port, config.backendServer.host,
+        onListen);
+
+    function onRequest(req, res) {
+        var p = url.parse(req.url).pathname;
+        var s = f('%s request by pid %d at %s\n', p, process.pid, Date.now());
+
+        // handle: /statusCode/<num>
+        var matches;
+        if ((matches = p.match(/^\/statusCode\/([0-9]+)/))) {
+            res.statusCode = parseInt(matches[1], 10);
+            res.end(s);
+            return;
+        }
+
+        switch (p) {
+        case '/301.png':
+            res.statusCode = 301;
+            res.setHeader('Location', '/foo.png');
+            res.end();
+            break;
+        case '/header.png':
+            res.setHeader('x-fun-header', 'woo');
+            res.end();
+        default:
+            res.end(s);
+            break;
+        }
+    }
+
+    function onListen() {
+        t.pass(f('backendServer started on %s', backendServerURL));
+        t.end();
+    }
+});
+
+/*
+ * Basic request that should be cached.
+ */
+test('simple cached request', function (t) {
+    var f = '/hello.png';
+
+    cacheRequest(f, {method: 'GET'}, function (err, webData) {
+        t.error(err, 'GET ' + f);
+
+        cachingServer.onIdle(function () {
+            // check to make sure the cache has this data
+            var fileData = fs.readFileSync(path.join(dir, f), 'utf-8');
+
+            t.equal(webData, fileData, 'file data in sync with web data');
+
+            // request it again to ensure it's correct
+            cacheRequest(f, {method: 'GET'}, function (err, webData2) {
+                t.error(err, 'GET ' + f);
+
+                t.equal(webData, webData2, 'both web requests same data');
+
+                cachingServer.onIdle(function () {
+                    t.end();
+                });
+            });
+        });
+    });
+});
+
+/*
+ * Basic request that should not be cached.
+ */
+test('simple non-cached request', function (t) {
+    var f = '/hello.txt';
+
+    cacheRequest(f, {method: 'GET'}, function (err, webData) {
+        t.error(err, 'GET ' + f);
+
+        cachingServer.onIdle(function () {
+            // check to make sure the cache DOES NOT have this data
+            var file = path.join(dir, f);
+
+            t.throws(function () {
+                fs.statSync(file);
+            }, file + ' should not exist');
+
+            // request it again to ensure the data is different (time difference)
+            cacheRequest(f, {method: 'GET'}, function (err, webData2) {
+                t.error(err, 'GET ' + f);
+
+                t.notEqual(webData, webData2, 'both web requests different data');
+
+                cachingServer.onIdle(function () {
+                    t.end();
+                });
+            });
+        });
+    });
+});
+
+/*
+ * Codes that *should not* be proxied.
+ */
+test('statusCodes without proxy', function (t) {
+    var codes = [301, 302, 403, 404, 500, 501, 502];
+    var idx = 0;
+
+    function go() {
+        var code = codes[idx++];
+        if (!code) {
+            t.end();
+            return;
+        }
+        var uri = f('/statusCode/%d/foo.png', code);
+
+        cacheRequest(uri, {method: 'GET'}, function (err, webData) {
+            t.error(err, 'GET ' + uri);
+            t.equal(webData, '', 'webData should be empty from caching server');
+
+            cachingServer.onIdle(function () {
+                // ensure the file does NOT exist in the cache dir
+                var file = path.join(dir, uri);
+                t.throws(function () {
+                    fs.statSync(file);
+                }, file + ' should not exist');
+
+                go();
+            });
+        });
+    }
+
+    go();
+});
+
+/*
+ * Codes that *should* be proxied.
+ */
+test('statusCodes with proxy', function (t) {
+    var codes = [200];
+    var idx = 0;
+
+    function go() {
+        var code = codes[idx++];
+        if (!code) {
+            t.end();
+            return;
+        }
+        var uri = f('/statusCode/%d/foo.png', code);
+
+        cacheRequest(uri, {method: 'GET'}, function (err, webData) {
+            t.error(err, 'GET ' + uri);
+            t.ok(webData, 'webData should have data from server');
+
+            cachingServer.onIdle(function () {
+                // ensure the file exists with the corect data
+                var file = path.join(dir, uri);
+                var fileData = fs.readFileSync(file, 'utf-8');
+                t.equal(webData, fileData, 'file data in sync with web data');
+
+                go();
+            });
+        });
+    }
+
+    go();
+});
+
+/*
+ * The first request to an item (cache-miss) will result in the request being
+ * proxied directly to the backendServer.  Subsequent requests will not be
+ * proxied and instead will just be handed the cached file without any of the
+ * original headers.
+ */
+test('headers proxied only on first request', function (t) {
+    var uri = '/header.png';
+    var serverHeader = 'x-fun-header';
+
+    // initial request (cache miss) should proxy headers from server
+    cacheRequest(uri, {method: 'GET'}, function (err, webData, res) {
+        t.error(err, 'GET ' + uri);
+
+        var headers = res.headers;
+        var customHeader = headers[serverHeader];
+
+        t.equal(customHeader, 'woo', f('custom header %s seen', serverHeader));
+
+        cachingServer.onIdle(function () {
+            // second request (cache hit) won't remember headers from server
+            cacheRequest(uri, {method: 'GET'}, function (err, webData, res) {
+                t.error(err, 'GET ' + uri);
+
+                var headers = res.headers;
+                var customHeader = headers[serverHeader];
+
+                t.ok(!customHeader, f('custom header %s not seen', serverHeader));
+
+                cachingServer.onIdle(function () {
+                    t.end();
+                });
+            });
+        });
+    });
+});
+
+/*
+ * FsCachingServer handles redirects by specifically choosing to not handle
+ * them.  Instead, the statusCodes and headers from the backendServer will be
+ * sent directly to the caller, and it is up to that caller if they'd like to
+ * follow the redirect.  If the redirects eventually hit a GET or HEAD request
+ * that falls within the 200 range, then it will be cached as normal.
+ */
+test('301 redirect', function (t) {
+    var uri = '/301.png';
+    var redirect = '/foo.png';
+
+    cacheRequest(uri, {method: 'GET'}, function (err, webData, res) {
+        t.error(err, 'GET ' + uri);
+        t.ok(!webData, 'body empty');
+        t.equal(res.statusCode, 301, '301 seen');
+        var headers = res.headers;
+        var loc = headers.location;
+
+        t.equal(loc, redirect, 'location is correct');
+
+        cachingServer.onIdle(function () {
+            t.end();
+        });
+    });
+});
+
+/*
+ * Requesting a directory that exists in the cache should result in a 400.
+ */
+test('GET directory in cache', function (t) {
+    var uri = '/directory.png';
+
+    fs.mkdirSync(path.join(dir, uri));
+
+    cacheRequest(uri, {method: 'GET'}, function (err, webData, res) {
+        t.error(err, 'GET ' + uri);
+        t.ok(!webData, 'body empty');
+        t.equal(res.statusCode, 400, '400 seen');
+
+        cachingServer.onIdle(function () {
+            t.end();
+        });
+    });
+});
+
+/*
+ * Two simulataneous requests for a cache-miss.  This will result in one of the
+ * requests being responsible for downloading the file and getting it streamed
+ * to them live, and the other request being paused until the data is fully
+ * downloaded.
+ *
+ * To simulate this fs.stat will be artifically slown down so both requests
+ * will block before the cache download begins.
+ */
+test('Two simultaneous requests', function (t) {
+    var originalStat = fs.stat.bind(fs);
+
+    fs.stat = function slowStat(f, cb) {
+        setTimeout(function () {
+            originalStat(f, cb);
+        }, 100);
+    };
+
+    var uri = '/simultaneous.png';
+    var todo = 2;
+
+    cacheRequest(uri, {method: 'GET'}, requestOne);
+    setTimeout(function () {
+        cacheRequest(uri, {method: 'GET'}, requestTwo);
+    }, 30);
+
+    var data1;
+    var data2;
+
+    function requestOne(err, data, res) {
+        t.error(err, '1. GET ' + uri);
+        data1 = data;
+
+        finish();
+    }
+
+    function requestTwo(err, data, res) {
+        t.error(err, '2. GET ' + uri);
+        data2 = data;
+
+        finish();
+    }
+
+    function finish() {
+        if (--todo > 0) {
+            return;
+        }
+
+        fs.stat = originalStat;
+        cachingServer.onIdle(function () {
+            t.equal(data1, data2, 'data the same');
+            t.end();
+        });
+    }
+});
+
+/*
+ * HEAD requests should only server cached results and not cache them itself.
+ * i.e. if a HEAD request is seen first it should just be proxied to the
+ * backend directly with no caching.
+ */
+test('HEAD request', function (t) {
+    var uri = '/head-cache-test.png';
+
+    cacheRequest(uri, {method: 'HEAD'}, function (err, data, res) {
+        t.error(err, 'HEAD ' + uri);
+
+        cachingServer.onIdle(function () {
+            // check to make sure the cache DOES NOT have this data
+            var file = path.join(dir, uri);
+
+            t.throws(function () {
+                fs.statSync(file);
+            }, file + ' should not exist');
+
+            t.end();
+        });
+    });
+});
+
+/*
+ * Close the backend HTTP server
+ */
+test('close backendServer', function (t) {
+    backendServer.once('close', function () {
+        t.pass('backendServer closed');
+        t.end();
+    });
+    backendServer.close();
+});
+
+/*
+ * Stop the caching server
+ */
+test('stop cachingServer', function (t) {
+    cachingServer.once('stop', function () {
+        t.pass('cachingServer stopped');
+        t.end();
+    });
+    cachingServer.stop();
+});

+ 10 - 0
tests/dist-config.json

@@ -0,0 +1,10 @@
+{
+    "backendServer": {
+        "host": "127.0.0.1",
+        "port": 8080
+    },
+    "cachingServer": {
+        "host": "127.0.0.1",
+        "port": 8081
+    }
+}

+ 16 - 0
tests/lib/index.js

@@ -0,0 +1,16 @@
+module.exports.readConfig = readConfig;
+
+function readConfig() {
+    var config;
+
+    try {
+        config = require('../config.json');
+    } catch (e) {
+        console.error('failed to read/parse config');
+        console.error('ensure that you cp dist-config.json config.json');
+        console.error(e);
+        process.exit(1);
+    }
+
+    return config;
+}