Przeglądaj źródła

Massive rewrite - v1.0.0 (#7)

see release notes in the readme
Dave Eddy 4 lat temu
rodzic
commit
586bfea910
9 zmienionych plików z 3285 dodań i 293 usunięć
  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
 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
 ------------
 
-    [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
 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.
 
-Example
--------
+### Example
 
 This will create a caching proxy that fronts Joyent's pkgsrc servers
 
     $ 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
-    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 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
 pkgsrc.joyent.com.
 
-Usage
------
+### Usage
 
     $ fs-caching-server -h
     usage: fs-caching-server [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
       -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
       -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, --updates             check npm for available updates
       -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
 -------
 

+ 571 - 276
fs-caching-server.js

@@ -7,323 +7,618 @@
  * License: MIT
  */
 
+var events = require('events');
 var fs = require('fs');
 var http = require('http');
+var https = require('https');
+var path = require('path');
 var url = require('url');
 var util = require('util');
 
 var accesslog = require('access-log');
-var getopt = require('posix-getopt');
+var assert = require('assert-plus');
 var mime = require('mime');
 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) {
-  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;
-      }
-
-      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 {
-      // 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();
-      });
+        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;
 }

Plik diff jest za duży
+ 2067 - 0
package-lock.json


+ 10 - 6
package.json

@@ -5,7 +5,7 @@
   "main": "fs-caching-server.js",
   "preferGlobal": true,
   "scripts": {
-    "test": "echo \"Error: no test specified\" && exit 1"
+    "test": "./node_modules/tape/bin/tape tests/*.js"
   },
   "bin": {
     "fs-caching-server": "./fs-caching-server.js"
@@ -26,13 +26,17 @@
   },
   "homepage": "https://github.com/bahamas10/node-fs-caching-server",
   "dependencies": {
-    "access-log": "^0.3.9",
+    "access-log": "^0.4.1",
+    "assert-plus": "^1.0.0",
     "latest": "^0.2.0",
     "mime": "^1.3.4",
     "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_credential user='nobody' group='other'/>
 			<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_HOST' value='0.0.0.0'/>
 				<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;
+}