initial release v1.0.0
authorJustin Wind <justin.wind+git@gmail.com>
Wed, 16 Sep 2020 20:36:56 +0000 (13:36 -0700)
committerJustin Wind <justin.wind+git@gmail.com>
Sun, 1 Aug 2021 19:17:34 +0000 (12:17 -0700)
29 files changed:
.eslintrc.json [new file with mode: 0644]
.gitignore [new file with mode: 0644]
.nycrc.json [new file with mode: 0644]
README.md [new file with mode: 0644]
index.js [new file with mode: 0644]
lib/common.js [new file with mode: 0644]
lib/content-negotiation.js [new file with mode: 0644]
lib/dingus.js [new file with mode: 0644]
lib/enum.js [new file with mode: 0644]
lib/errors.js [new file with mode: 0644]
lib/mime-helper.js [new file with mode: 0644]
lib/patches/incoming-message.js [new file with mode: 0644]
lib/patches/index.js [new file with mode: 0644]
lib/router.js [new file with mode: 0644]
lib/template/error-html.js [new file with mode: 0644]
lib/template/index.js [new file with mode: 0644]
package-lock.json [new file with mode: 0644]
package.json [new file with mode: 0644]
test/index.js [new file with mode: 0644]
test/lib/common.js [new file with mode: 0644]
test/lib/content-negotiation.js [new file with mode: 0644]
test/lib/dingus.js [new file with mode: 0644]
test/lib/enum.js [new file with mode: 0644]
test/lib/mime-helper.js [new file with mode: 0644]
test/lib/patches/incoming-message.js [new file with mode: 0644]
test/lib/router.js [new file with mode: 0644]
test/test-data/.example [new file with mode: 0644]
test/test-data/example.html [new file with mode: 0644]
test/test-data/example.html.gz [new file with mode: 0644]

diff --git a/.eslintrc.json b/.eslintrc.json
new file mode 100644 (file)
index 0000000..7602b33
--- /dev/null
@@ -0,0 +1,82 @@
+{
+  "env": {
+    "browser": false,
+    "es6": true,
+    "node": true
+  },
+  "extends": [
+    "eslint:recommended",
+    "plugin:node/recommended",
+    "plugin:security/recommended",
+    "plugin:sonarjs/recommended"
+  ],
+  "parserOptions": {
+    "ecmaVersion": 2018
+  },
+  "plugins": [
+    "node",
+    "security",
+    "sonarjs"
+  ],
+  "rules": {
+    "array-element-newline": [
+      "error",
+      "consistent"
+    ],
+    "arrow-parens": [
+      "error",
+      "always"
+    ],
+    "arrow-spacing": [
+      "error",
+      {
+        "after": true,
+        "before": true
+      }
+    ],
+    "block-scoped-var": "error",
+    "block-spacing": "error",
+    "brace-style": "error",
+    "callback-return": "error",
+    "camelcase": "error",
+    "capitalized-comments": "warn",
+    "class-methods-use-this": "error",
+    "comma-dangle": [
+      "error",
+      "always-multiline"
+    ],
+    "comma-spacing": [
+      "error",
+      {
+        "after": true,
+        "before": false
+      }
+    ],
+    "comma-style": [
+      "error",
+      "last"
+    ],
+    "sonarjs/cognitive-complexity": "warn",
+    "keyword-spacing": "error",
+    "linebreak-style": [
+      "error",
+      "unix"
+    ],
+    "no-unused-vars": [
+      "error", {
+        "varsIgnorePattern": "^_"
+      }
+    ],
+    "object-curly-spacing": [
+      "error",
+      "always"
+    ],
+    "prefer-const": "error",
+    "quotes": [
+      "error",
+      "single"
+    ],
+    "strict": "error",
+    "vars-on-top": "error"
+  }
+}
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..defafb1
--- /dev/null
@@ -0,0 +1,4 @@
+coverage
+node_modules
+.nyc_output
+.vscode
diff --git a/.nycrc.json b/.nycrc.json
new file mode 100644 (file)
index 0000000..9633f6d
--- /dev/null
@@ -0,0 +1,8 @@
+{
+  "check-coverage": true,
+  "functions": 100,
+  "lines": 100,
+  "per-file": true,
+  "reporter": ["lcov", "text"],
+  "statements": 100
+}
diff --git a/README.md b/README.md
new file mode 100644 (file)
index 0000000..37d9afb
--- /dev/null
+++ b/README.md
@@ -0,0 +1,31 @@
+# Another In-House API Server Frameworklet
+
+I just wanted a basic little API server for toy projects, without having to park a container-ship of modules under it.
+
+This is in no way intended to replace any mature, full-featured framework.  It is spartan in some aspects, brings some unexpected baggage in others, makes some questionable design decisions, has the occasional opinion, and is likely somewhat idiosyncratic from an outside perspective.
+The primary design goals are:
+- self-contained: as few external dependencies as feasible
+- not-infinitely-extensible: only does the things it needs to do as dictated by the projects it is used in
+
+## Getting Started
+
+Construct it with a console-level-compatible logger capable of doing something meaningful with calls like level(scopeString, messageString, dataObject).
+
+Within the server request handler:
+* dispatch(req, res) makes things go.
+
+Within the application implementation:
+* on(method, urlPath, handler) declares a thing to do when a request matches.
+* preHandler(req, res, ctx) can be overridden to do something to every request before it is handled.
+
+Handled content types can be extended by overriding:
+* parseBody(contentType, ctx) for incoming types.
+* renderError(contentType, err) for outgoing types.
+
+Within your handlers:
+* setResponseType(responseTypes, req, res, ctx) can be called to negotiate content types.
+* async ingestBody(req, res, ctx) will parse request body data.
+* throw an Error.ResponseError with an Enum.ErrorResponse for a simple status code with optional details, when something goes awry.
+
+Parameters and metadata are set in each request context.
+
diff --git a/index.js b/index.js
new file mode 100644 (file)
index 0000000..dcf66e8
--- /dev/null
+++ b/index.js
@@ -0,0 +1,13 @@
+'use strict';
+
+const Dingus = require('./lib/dingus');
+const common = require('./lib/common');
+const Enum = require('./lib/enum');
+const Errors = require('./lib/errors');
+
+module.exports = {
+  common,
+  Dingus,
+  Enum,
+  Errors,
+};
diff --git a/lib/common.js b/lib/common.js
new file mode 100644 (file)
index 0000000..284427a
--- /dev/null
@@ -0,0 +1,257 @@
+/* eslint-disable security/detect-object-injection */
+'use strict';
+
+/**
+ * Utility and miscellaneous functions.
+ */
+
+const path = require('path');
+const crypto = require('crypto');
+const uuid = require('uuid');
+const Enum = require('./enum');
+
+/**
+ * Return a function which combines a part of the filename with a scope, for use in logging.
+ * @param {string} filename
+ */
+const fileScope = (filename) => {
+  let fScope = path.basename(filename, '.js');
+  if (fScope === 'index') {
+    fScope = path.basename(path.dirname(filename));
+  }
+  return (scope) => `${fScope}:${scope}`;
+}
+
+/**
+ * Simple ETag from data.
+ * @param {string} filePath 
+ * @param {object} fileStat 
+ * @param {*} fileData 
+ */
+const generateETag = (_filePath, fileStat, fileData) => {
+  const hash = crypto.createHash('sha256');
+  if (fileStat && fileStat.mtimeMs) {
+    hash.update(fileStat.mtimeMs.toString());
+  }
+  hash.update(fileData);
+  const digest = hash.digest('base64').replace('=', '');
+  return `"${digest}"`;
+};
+
+/**
+ * @param {object} obj 
+ * @param {string} prop 
+ * @param {*} def 
+ */
+const get = (obj, prop, def) => obj && prop && (prop in obj) ? obj[prop] : def;
+
+/**
+ * @param {http.ClientRequest} req 
+ * @param {http.ServerResponse} res 
+ * @param {object} ctx 
+ */
+const handlerLogData = (req, res, ctx) => ({
+  req: requestLogData(req),
+  res: responseLogData(res),
+  ctx,
+});
+
+/**
+ * 
+ * @param {http.ClientRequest} req 
+ * @param {Number} modifiedTimeMs 
+ * @param {string} eTag 
+ */
+const isClientCached = (req, modifiedTimeMs, eTag) => {
+  let clientCached = false;
+
+  const ifModifiedSince = req.getHeader(Enum.Header.IfModifiedSince);
+  if (ifModifiedSince) {
+    const ifModifiedSinceMs = Date.parse(ifModifiedSince);
+    if (modifiedTimeMs < ifModifiedSinceMs) {
+      clientCached = true;
+    }
+  }
+
+  const ifNoneMatch = req.getHeader(Enum.Header.IfNoneMatch);
+  if (ifNoneMatch) {
+    const matches = ifNoneMatch.split(',').map((m) => m.trim());
+    if (matches.includes(eTag)
+    ||  (ifNoneMatch === '*' && eTag)) {
+      clientCached = true;
+    } else {
+      // If if-none-matched header is present, it takes precedence over modified-since.
+      clientCached = false;
+    }
+  }
+
+  return clientCached;
+};
+
+/**
+ * Shallow merge for enums, to be called by derived constructor.
+ * Expects only one-level deep, is not recursive!
+ * @param {Object} origEnum
+ * @param {Object} additionalEnum
+ */
+const mergeEnum = (origEnum, additionalEnum) => {
+  for (const e of Object.keys(additionalEnum)) {
+    if (typeof additionalEnum[e] === 'object') {
+      if (! (e in origEnum)) {
+        origEnum[e] = {};
+      }
+      Object.assign(origEnum[e], additionalEnum[e]);
+    } else {
+      origEnum[e] = additionalEnum[e];
+    }
+  }
+  return origEnum;
+};
+
+/**
+ * Isolate the general category of an http status code.
+ * @param {Number} statusCode
+ * @returns {Number}
+ */
+const httpStatusCodeClass = (statusCode) => Math.floor(statusCode / 100);
+
+const _isObject = (obj) => obj && typeof obj === 'object';
+const _isArray = (obj) => Array.isArray(obj);
+/**
+ * Return a new object with all objects combined.
+ * @param  {...any} objects
+ * @returns
+ */
+const mergeDeep = (...objects) => {
+  return objects.reduce((acc, obj) => {
+    const objectProperties = [...Object.getOwnPropertyNames(obj), ...Object.getOwnPropertySymbols(obj)];
+    objectProperties.forEach((k) => {
+      const aVal = acc[k];
+      const oVal = obj[k];
+      if (_isArray(oVal)) {
+        acc[k] = (_isArray(aVal) ? aVal : []).concat(oVal);
+      } else if (_isObject(oVal)) {
+        acc[k] = mergeDeep(_isObject(aVal) ? aVal : {}, oVal);
+      } else {
+        acc[k] = oVal;
+      }
+    });
+    return acc;
+  }, {});
+};
+
+
+/**
+ * Return a new object with selected props.
+ * @param {Object} obj
+ * @param {string[]} props
+ */
+const pick = (obj, props) => {
+  const picked = {};
+  props.forEach((prop) => {
+    if (prop in obj) {
+      picked[prop] = obj[prop];
+    }
+  });
+  return picked;
+};
+
+/**
+ * Return a subset of a request object, suitable for logging.
+ * @param {http.ClientRequest} req 
+ */
+const requestLogData = (req) => {
+  return pick(req, [
+    'method',
+    'url',
+    'httpVersion',
+    'headers',
+    'trailers',
+  ]);
+};
+
+
+/**
+ * Return a subset of a response object, suitable for logging.
+ * @param {http.ServerResponse} res 
+ */
+const responseLogData = (res) => {
+  const response = pick(res, [
+    'statusCode',
+    'statusMessage',
+  ]);
+  response.headers = res.getHeaders();
+  return response;
+};
+
+
+/**
+ * Store updates to defaultOptions, but no new properties.
+ * @param {Object} target 
+ * @param {Object} defaultOptions 
+ * @param {Object} options 
+ */
+const setOptions = (target, defaultOptions, options) => {
+  Object.assign(target, defaultOptions, pick(options, Object.keys(defaultOptions)));
+};
+
+/**
+ * Return a list of source split at first delimiter.
+ * @param {string} src 
+ * @param {string} delimiter 
+ * @param {string} fill trailing stand-in if no delimiter in src
+ */
+const splitFirst = (src, delimiter, fill) => {
+  const idx = src.indexOf(delimiter);
+  if (idx >= 0) {
+    return [ src.slice(0, idx), src.slice(idx + 1) ];
+  } else {
+    return [ src, fill ];
+  }
+};
+
+/**
+ * Generate a new request identifier.
+ * @returns {String}
+ */
+const requestId = () => {
+  return uuid.v1();
+};
+
+const nop = () => { /**/ };
+const nullLogger = {
+  error: nop,
+  warn: nop,
+  info: nop,
+  log: nop,
+  debug: nop,
+};
+
+const ensureLoggerLevels = (logger = {}) => {
+  for (const level in nullLogger) {
+    if (! (level in logger)) {
+      logger[level] = nullLogger[level];
+    }
+  }
+  return logger;
+};
+
+module.exports = {
+  fileScope,
+  generateETag,
+  get,
+  handlerLogData,
+  isClientCached,
+  httpStatusCodeClass,
+  mergeDeep,
+  mergeEnum,
+  nop,
+  nullLogger,
+  ensureLoggerLevels,
+  pick,
+  requestId,
+  requestLogData,
+  responseLogData,
+  setOptions,
+  splitFirst,
+};
diff --git a/lib/content-negotiation.js b/lib/content-negotiation.js
new file mode 100644 (file)
index 0000000..1425852
--- /dev/null
@@ -0,0 +1,163 @@
+'use strict';
+
+const common = require('./common');
+const Enum = require('./enum');
+
+
+// A weight value smaller than the allowed resolution, for minute wildcard de-preferencing.
+const WeightIota = 0.0001;
+
+class ContentNegotiation {
+
+  /**
+   * Convert accept clause string to object.
+   * Adjust weight based on wildcards, to prefer literal matches.
+   * @param {string} clause 
+   */
+  static _unpackAcceptClause(clause) {
+    let params = clause.split(';');
+    const type = params.shift().trim();
+    if (type) {
+      let weight = 1.0;
+      params = params.reduce((acc, param) => {
+        const [p, v] = common.splitFirst(param, '=').map((x) => x && x.trim());
+        if (p && v) {
+          if (p === 'q') {
+            weight = Number(v);
+            // Enforce max precision from spec, so that we can...
+            weight = Number(weight.toFixed(3));
+            // De-tune the quality slightly for wildcards.
+            const blur = (type.split('*').length - 1) * WeightIota;
+            if (weight) {
+              weight -= blur;
+            }
+          } else {
+            // eslint-disable-next-line security/detect-object-injection
+            acc[p] = v;
+          }
+        }
+        return acc;
+      }, {});
+      return { type, weight, params };
+    }
+  }
+
+  /**
+   * Split an accept field into clauses, return list of clauses sorted by heaviest weights first.
+   * @param {string} acceptHeader 
+   */
+  static _acceptClauses(acceptHeader) {
+    const clauses = (acceptHeader||'').split(',').map((clause) => ContentNegotiation._unpackAcceptClause(clause)).filter((clause) => clause);
+    return clauses.sort((a, b) => b.weight - a.weight);
+  }
+
+  /**
+   * Check if an Accept-able Content-Type matches a fixed Content-Type.
+   * (Allows for '*' fields in Accept-able type.)
+   * @param {string} pattern 
+   * @param {string} type 
+   */
+  static _matchType(acceptableType, fixedType) {
+    acceptableType = common.splitFirst(acceptableType, '/', '*');
+    fixedType = common.splitFirst(fixedType, '/', '*');
+    for (let i = 0; i < acceptableType.length; i++) {
+      // eslint-disable-next-line security/detect-object-injection
+      const v = acceptableType[i];
+      // eslint-disable-next-line security/detect-object-injection
+      const f = fixedType[i];
+      if (v !== f && v !== '*') {
+        return false;
+      }
+    }
+    return true;
+  }
+
+  /**
+   * Return the best match between available and acceptable types.
+   * @param {string[]} acceptableTypes
+   * @param {string} acceptHeader
+   */
+  static accept(acceptableTypes, acceptHeader) {
+    const validTypesQuality = {};
+    if (!acceptHeader) {
+      return acceptableTypes[0];
+    }
+    // For each type offered in the header, from heaviest to lightest...
+    ContentNegotiation._acceptClauses(acceptHeader).forEach((a) => {
+      // Consider each supported type...
+      acceptableTypes.forEach((t) => {
+        // Remember the heaviest weighting if it matches.
+        if (ContentNegotiation._matchType(a.type, t)
+        // eslint-disable-next-line security/detect-object-injection
+        &&  (!(t in validTypesQuality) || validTypesQuality[t] < a.weight)) {
+          // eslint-disable-next-line security/detect-object-injection
+          validTypesQuality[t] = a.weight;
+        }
+      });
+    });
+    return Object.keys(validTypesQuality).reduce((acc, cur) => {
+      // eslint-disable-next-line security/detect-object-injection
+      if (acc === undefined && validTypesQuality[cur] !== 0.0) {
+        return cur;
+      }
+      // eslint-disable-next-line security/detect-object-injection
+      return validTypesQuality[acc] < validTypesQuality[cur] ? cur : acc;
+    }, undefined);
+  }
+
+
+  /**
+   * Return all viable matches between acceptable and requested encodings, ordered by highest preference first.
+   * TODO: sort equal q-values by server-preference rather than header order
+   * @param {string[]} acceptableEncodings e.g. ['br', 'gzip'] in order of server preference
+   * @param {string} acceptHeader 
+   */
+  static preferred(acceptableEncodings, acceptHeader) {
+    const Identity = Enum.EncodingType.Identity;
+    const Any = '*';
+
+    // Don't munge caller's list.
+    acceptableEncodings = [...acceptableEncodings];
+
+    // Server is always capable of identity encoding.
+    if (!(acceptableEncodings.includes(Identity))) {
+      acceptableEncodings.push(Identity);
+    }
+
+    const acceptClauses = ContentNegotiation._acceptClauses(acceptHeader);
+
+    // Add identity as fallback clause if an Any type was not explicitly mentioned.
+    const acceptTypes = acceptClauses.map((enc) => enc.type);
+    if (!(acceptTypes.includes(Any)) && !(acceptTypes.includes(Identity))) {
+      acceptClauses.push({
+        type: Identity,
+        weight: WeightIota,
+        params: [],
+      });
+    }
+
+    // Explicitly forbidden encodings will not be considered.
+    const forbidden = acceptClauses.filter((enc) => enc.weight == 0).map((enc) => enc.type);
+
+    acceptableEncodings = acceptableEncodings.filter((enc) => {
+      // If * is forbidden, don't allow identity encoding.
+      const fallbackIsForbidden = forbidden.includes(Any) && enc === Identity && !acceptTypes.includes(Identity);
+
+      const typeIsForbidden = forbidden.includes(enc.type);
+      return !typeIsForbidden && !fallbackIsForbidden;
+    });
+
+    // Strip forbidden and unsupported from working set.
+    const allowedClauses = acceptClauses.filter((enc) => {
+      const isAllowed = enc.weight > 0;
+      const isSupported = enc.type === Any || acceptableEncodings.includes(enc.type);
+      return isAllowed && isSupported;
+    });
+
+    // Only the types.
+    return allowedClauses.map((enc) => enc.type);
+  }
+
+}
+
+module.exports = ContentNegotiation;
diff --git a/lib/dingus.js b/lib/dingus.js
new file mode 100644 (file)
index 0000000..c8e4909
--- /dev/null
@@ -0,0 +1,630 @@
+/* eslint-disable security/detect-object-injection */
+'use strict';
+
+/**
+ * A very minimal API server framework.
+ * Just a self-contained router and some request glue.
+ */
+
+require('./patches');
+const { promises: fsPromises } = require('fs');
+const path = require('path');
+const querystring = require('querystring');
+const common = require('./common');
+const ContentNegotiation = require('./content-negotiation');
+const Enum = require('./enum');
+const { DingusError, ResponseError } = require('./errors');
+const { extensionToMime } = require('./mime-helper');
+const Router = require('./router');
+const Template = require('./template');
+
+// For logging.
+const _fileScope = common.fileScope(__filename);
+
+const defaultOptions = {
+  ignoreTrailingSlash: false,
+  proxyPrefix: '',
+  strictAccept: true,
+  selfBaseUrl: '',
+  trustProxy: true,
+  querystring,
+};
+
+class Dingus {
+  /**
+   * @param {Object} logger object which implements logging methods
+   * @param {Object} options
+   * @param {Boolean} options.ignoreTrailingSlash 
+   * @param {string} options.proxyPrefix leading part of url path to strip
+   * @param {Boolean} options.strictAccept whether to error on unsupported Accept type
+   * @param {string} options.selfBaseUrl for constructing links
+   * @param {Boolean} options.trustProxy trust some header data to be provided by proxy
+   * @param {Object} options.querystring alternate qs parser to use
+   */
+  constructor(logger = common.nullLogger, options = {}) {
+    common.setOptions(this, defaultOptions, options);
+
+    this.router = new Router(options);
+
+    if (!this.proxyPrefix) {
+      this._stripPrefix = (p) => p;
+    }
+
+    this.responseTypes = [
+      Enum.ContentType.TextHTML,
+      Enum.ContentType.TextPlain,
+      Enum.ContentType.ApplicationJson,
+    ];
+
+    this.logger = logger;
+    common.ensureLoggerLevels(this.logger);
+  }
+
+
+  /**
+   * Resolve relative and empty paths in url
+   * @param {string} p path
+   */
+  _normalizePath(p) {
+    const pathNorm = path.normalize(p); // This isn't perfectly correct, but it's easy...
+    return this._stripPrefix(pathNorm);
+  }
+
+
+  /**
+   * Remove a leading portion of url path
+   * N.B. This method gets obliterated if there is no prefix defined at construction
+   * @param {string} p path
+   */
+  _stripPrefix(p) {
+    if (p.startsWith(this.proxyPrefix)) {
+      return p.slice(this.proxyPrefix.length);
+    }
+    return p;
+  }
+
+
+  /**
+   * Returns the path part, and querystring object, from a request url.
+   * @param {string} url 
+   */
+  _splitUrl(url) {
+    const [ p, qs ] = common.splitFirst(url, '?');
+    return {
+      pathPart: this._normalizePath(p),
+      queryParams: this.querystring.parse(qs),
+    };
+  }
+
+
+  /**
+   * Insert a new path handler
+   * @param {string} method 
+   * @param {string} urlPath 
+   * @param {fn} handler 
+   */
+  on(method, urlPath, handler) {
+    this.router.on(method, urlPath, handler);
+  }
+
+
+  /**
+   * Common header tagging for all requests.
+   * Add our own identifier, and persist any external transit identifiers.
+   * @param {http.ClientRequest} req 
+   * @param {http.ServerResponse} res 
+   * @param {object} ctx 
+   */
+  static tagContext(req, res, ctx) {
+    const requestId = common.requestId();
+    ctx.requestId = requestId;
+    res.setHeader(Enum.Header.RequestId, requestId);
+    [Enum.Header.XRequestId, Enum.Header.XCorrelationId].forEach((h) => {
+      const v = req.getHeader(h);
+      if (v) {
+        ctx[h.replace(/-/g, '')] = v;
+        res.setHeader(h, v);
+      }
+    });
+    return requestId;
+  }
+
+
+  /**
+   * 
+   * @param {http.ClientRequest} req 
+   */
+  _getAddress(req) {
+    // TODO: RFC7239 Forwarded support
+    const address = (this.trustProxy && req && req.getHeader(Enum.Header.XForwardedFor)) ||
+      (this.trustProxy && req && req.getHeader(Enum.Header.XRealIP)) ||
+      (req && req.connection && req.connection.remoteAddress) ||
+      '';
+    return address.split(/\s*,\s*/u)[0];
+  }
+
+
+  /**
+   * 
+   * @param {http.ClientRequest} req 
+   */
+  _getProtocol(req) {
+    // TODO: RFC7239 Forwarded support
+    const protocol = (this.trustProxy && req && req.getHeader(Enum.Header.XForwardedProto)) ||
+      ((req && req.connection && req.connection.encrypted) ? 'https' : 'http');
+    return protocol.split(/\s*,\s*/u)[0];
+  }
+
+
+  /**
+   * 
+   * @param {http.ClientRequest} req 
+   * @param {http.ServerResponse} res 
+   * @param {object} ctx 
+   */
+  clientAddressContext(req, res, ctx) {
+    ctx.clientAddress = this._getAddress(req);
+    ctx.clientProtocol = this._getProtocol(req);
+  }
+
+
+  /**
+   * Called before every request handler.
+   * @param {http.ClientRequest} req 
+   * @param {http.ServerResponse} res 
+   * @param {object} ctx 
+   */
+  async preHandler(req, res, ctx) {
+    Dingus.tagContext(req, res, ctx);
+    this.clientAddressContext(req, res, ctx);
+  }
+
+
+  /**
+   * Helper for collecting chunks as array of buffers.
+   * @param {Buffer[]} chunks 
+   * @param {string|Buffer} chunk 
+   * @param {string} encoding 
+   */
+  static pushBufChunk(chunks, chunk, encoding = 'utf8') {
+    if (chunk) {
+      if (typeof chunk === 'string') {
+        chunk = Buffer.from(chunk, encoding);
+      }
+      chunks.push(chunk);
+    }
+  }
+
+
+  /**
+   * Sets ctx.responseBody and calls handler upon res.end().
+   * @param {http.ClientRequest} req 
+   * @param {http.ServerResponse} res 
+   * @param {object} ctx 
+   * @param {*} handler fn(req, res, ctx)
+   */
+  static setEndBodyHandler(req, res, ctx, handler) {
+    const origWrite = res.write.bind(res);
+    const origEnd = res.end.bind(res);
+    const chunks = [];
+    res.write = function (chunk, encoding, ...rest) {
+      Dingus.pushBufChunk(chunks, chunk, encoding);
+      return origWrite(chunk, encoding, ...rest);
+    };
+    res.end = function (data, encoding, ...rest) {
+      Dingus.pushBufChunk(chunks, data, encoding);
+      ctx.responseBody = Buffer.concat(chunks);
+      handler(req, res, ctx);
+      return origEnd(data, encoding, ...rest);
+    };
+  }
+
+
+  /**
+   * Intercept writes for head requests, do not send to client,
+   * but send length, and make body available in context.
+   * @param {http.ClientRequest} req 
+   * @param {http.ServerResponse} res 
+   * @param {object} ctx 
+   */
+  static setHeadHandler(req, res, ctx) {
+    if (req.method === 'HEAD') {
+      const origEnd = res.end.bind(res);
+      const chunks = [];
+      res.write = function (chunk, encoding) {
+        Dingus.pushBufChunk(chunks, chunk, encoding);
+        // No call to original res.write.
+      };
+      res.end = function (data, encoding, ...rest) {
+        Dingus.pushBufChunk(chunks, data, encoding);
+        ctx.responseBody = Buffer.concat(chunks);
+        res.setHeader(Enum.Header.ContentLength, Buffer.byteLength(ctx.responseBody));
+        return origEnd(undefined, encoding, ...rest);
+      };
+    }
+  }
+
+
+  /**
+   * Dispatch the handler for a request
+   * @param {http.ClientRequest} req 
+   * @param {http.ServerResponse} res 
+   * @param {object} ctx 
+   */
+  async dispatch(req, res, ctx = {}) {
+    const _scope = _fileScope('dispatch');
+
+    const { pathPart, queryParams } = this._splitUrl(req.url);
+    ctx.queryParams = queryParams;
+
+    let handler;
+    try {
+      handler = this.router.lookup(req.method, pathPart, ctx);
+    } catch (e) {
+      if (e instanceof DingusError) {
+        switch (e.message) {
+          case 'NoPath':
+            handler = this.handlerNotFound.bind(this);
+            break;
+          case 'NoMethod':
+            handler = this.handlerMethodNotAllowed.bind(this);
+            break;
+          default:
+            this.logger.error(_scope, 'unknown dingus error', { error: e });
+            handler = this.handlerInternalServerError.bind(this);
+          }
+      } else if (e instanceof URIError) {
+        handler = this.handlerBadRequest.bind(this);
+      } else {
+        this.logger.error(_scope, 'lookup failure', { error: e });
+        handler = this.handlerInternalServerError.bind(this);
+      }
+    }
+
+    try {
+      await this.preHandler(req, res, ctx);
+      return await handler(req, res, ctx);
+    } catch (e) {
+      ctx.error = e;
+      this.sendErrorResponse(e, req, res, ctx);
+    }
+  }
+
+
+  /**
+   * Return normalized type, without any parameters.
+   * @param {http.ClientRequest} req
+   * @returns {string}
+   */
+  static getRequestContentType(req) {
+    const contentType = req.getHeader(Enum.Header.ContentType);
+    return (contentType || '').split(';')[0].trim().toLowerCase();
+  }
+
+
+  /**
+   * Parse rawBody from ctx as contentType into parsedBody.
+   * @param {string} contentType 
+   * @param {object} ctx 
+   */
+  parseBody(contentType, ctx) {
+    const _scope = _fileScope('parseBody');
+
+    switch (contentType) {
+      case Enum.ContentType.ApplicationForm:
+        ctx.parsedBody = this.querystring.parse(ctx.rawBody);
+        break;
+
+      case Enum.ContentType.ApplicationJson:
+        try {
+          ctx.parsedBody = JSON.parse(ctx.rawBody);
+        } catch (e) {
+          this.logger.debug(_scope, 'JSON parse failed', { requestId: ctx.requestId, error: e });
+          throw new ResponseError(Enum.ErrorResponse.BadRequest, e.message);
+        }
+        break;
+
+      default:
+        this.logger.debug(_scope, 'unhandled content-type', { requestId: ctx.requestId, contentType });
+        throw new ResponseError(Enum.ErrorResponse.UnsupportedMediaType);
+    }
+  }
+
+
+  /**
+   * Return all body data from a request.
+   * @param {http.ClientRequest} req
+   */
+  async bodyData(req) {
+    const _scope = _fileScope('bodyData');
+    return new Promise((resolve, reject) => {
+      const body = [];
+      req.on('data', (chunk) => body.push(chunk));
+      req.on('end', () => resolve(Buffer.concat(body).toString()));
+      req.on('error', (e) => {
+        this.logger.error(_scope, 'failed', { error: e });
+        reject(e);
+      });
+    });
+  }
+
+
+  /**
+   * Read and parse request body data.
+   * @param {http.ClientRequest} req
+   * @param {http.ServerResponse} res
+   * @param {object} ctx
+   */
+  async ingestBody(req, res, ctx) {
+    ctx.rawBody = await this.bodyData(req);
+    const contentType = Dingus.getRequestContentType(req);
+    this.parseBody(contentType, ctx);
+  }
+
+
+  /**
+   * Return the best matching response type.
+   * @param {string[]} responseTypes 
+   * @param {http.ClientRequest} req 
+   */
+  static getResponseContentType(responseTypes, req) {
+    const acceptHeader = req.getHeader(Enum.Header.Accept);
+    return ContentNegotiation.accept(responseTypes, acceptHeader);
+  }
+
+
+  /**
+   * Returns a list of the most-preferred content encodings for the response.
+   * @param {string[]} responseEncodings
+   * @param {http.ClientRequest} req 
+   */
+  static getResponseEncoding(responseEncodings, req) {
+    const acceptEncodingHeader = req.getHeader(Enum.Header.AcceptEncoding);
+    return ContentNegotiation.preferred(responseEncodings, acceptEncodingHeader);
+  }
+
+
+  /**
+   * Set the best content type for the response.
+   * @param {string[]} responseTypes default first
+   * @param {http.ClientRequest} req 
+   * @param {http.ServerResponse} res 
+   * @param {object} ctx 
+   */
+  setResponseType(responseTypes, req, res, ctx) {
+    const _scope = _fileScope('setResponseType');
+    ctx.responseType = Dingus.getResponseContentType(responseTypes, req);
+    if (!ctx.responseType) {
+      if (this.strictAccept) {
+        this.logger.debug(_scope, 'unhandled strict accept', { requestId: req.requestId });
+        throw new ResponseError(Enum.ErrorResponse.NotAcceptable);
+      } else {
+        ctx.responseType = responseTypes[0];
+      }
+    }
+    res.setHeader(Enum.Header.ContentType, ctx.responseType);
+  }
+
+
+  /**
+   * Inserts an encoding
+   * @param {http.ServerResponse} res
+   * @param {string} encoding
+   */
+  static addEncodingHeader(res, encoding) {
+    const existingEncodings = res.getHeader(Enum.Header.ContentEncoding);
+    if (existingEncodings) {
+      encoding = `${encoding}, ${existingEncodings}`;
+    }
+    res.setHeader(Enum.Header.ContentEncoding, encoding);
+  }
+
+
+  /**
+   * Attempt to fetch both data and metadata for a file.
+   * @param {string} filePath 
+   */
+  async _readFileInfo(filePath) {
+    const _scope = _fileScope('_readFileInfo');
+    let result;
+    try {
+      // eslint-disable-next-line security/detect-non-literal-fs-filename
+      const stat = fsPromises.stat(filePath);
+      // eslint-disable-next-line security/detect-non-literal-fs-filename
+      const data = fsPromises.readFile(filePath);
+      result = await Promise.all([stat, data]);
+    } catch (e) {
+      if (['ENOENT', 'EACCES', 'EISDIR', 'ENAMETOOLONG', 'EINVAL'].includes(e.code)) {
+        return [null, null];
+      }
+      this.logger.error(_scope, 'fs error', { error: e, filePath });
+      throw e;
+    }
+    return result;
+  }
+
+
+  /**
+   * Serve a file from a directory, with rudimentary cache awareness.
+   * This will also serve pre-encoded variations if available and requested.
+   * @param {http.ClientRequest} req 
+   * @param {http.ServerResponse} res 
+   * @param {object} ctx 
+   * @param {string} directory 
+   * @param {string} fileName 
+   */
+  async serveFile(req, res, ctx, directory, fileName) {
+    const _scope = _fileScope('serveFile');
+    this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx });
+
+    // Normalize the supplied path, as encoded path-navigation may have been (maliciously) present.
+    fileName = path.normalize(fileName);
+
+    // We will not deal with any subdirs, nor any dot-files.
+    // (Note that we could not deal with subdirs even if we wanted, due to simple router matching scheme.)
+    if (fileName.indexOf(path.sep) >= 0
+    ||  fileName.charAt(0) === '.') {
+      this.logger.debug(_scope, 'rejected filename', { fileName });
+      return this.handlerNotFound(req, res, ctx);
+    }
+
+    const filePath = path.join(directory, fileName);
+
+    // File must exist, before any alternate static encodings will be considered.
+    let [stat, data] = await this._readFileInfo(filePath);
+    if (!stat) {
+      return this.handlerNotFound(req, res, ctx);
+    }
+
+    // If encodings were requested, check for static versions to serve.
+    // Update stat and data if matching version is found.
+    ctx.availableEncodings = Dingus.getResponseEncoding(Object.values(Enum.EncodingType), req);
+    if (ctx.availableEncodings.length === 0) {
+      // Identity encoding was specifically denied, and nothing else available.
+      this.logger.debug(_scope, 'no suitable encodings', { ctx });
+      return this.handlerMethodNotAllowed(req, res, ctx);
+    }
+    for (const encoding of ctx.availableEncodings) {
+      if (encoding === Enum.EncodingType.Identity) {
+        break;
+      }
+      const suffix = Enum.EncodingTypeSuffix[encoding];
+      if (suffix) {
+        const encodedFilePath = `${filePath}${suffix}`;
+        const [ encodedStat, encodedData ] = await this._readFileInfo(encodedFilePath);
+        if (encodedStat) {
+          ([ stat, data ] = [ encodedStat, encodedData ]);
+          ctx.selectedEncoding = encoding;
+          Dingus.addEncodingHeader(res, encoding);
+          res.setHeader(Enum.Header.Vary, Enum.Header.AcceptEncoding);
+          this.logger.debug(_scope, 'serving encoded version', { ctx, encodedFilePath });
+        }
+        break;
+      }
+    }
+
+    const lastModifiedDate = new Date(stat.mtimeMs);
+    res.setHeader(Enum.Header.LastModified, lastModifiedDate.toGMTString());
+
+    const eTag = common.generateETag(filePath, stat, data);
+    res.setHeader(Enum.Header.ETag, eTag);
+
+    if (common.isClientCached(req, stat.mtimeMs, eTag)) {
+      this.logger.debug(_scope, 'client cached file', { filePath });
+      res.statusCode = 304; // Not Modified
+      res.end();
+      return;
+    }
+
+    // Set the type based on extension of un-encoded filename.
+    const ext = path.extname(filePath).slice(1); // Drop the dot
+    const contentType = extensionToMime(ext);
+    res.setHeader(Enum.Header.ContentType, contentType);
+
+    // We presume static files are relatively cacheable.
+    res.setHeader(Enum.Header.CacheControl, 'public');
+
+    this.logger.debug(_scope, 'serving file', { filePath, contentType });
+    res.end(data);
+  }
+
+
+  /**
+   * Return a content-type appropriate rendering of an errorResponse object.
+   * @param {string} type content-type of response
+   * @param {object} err either an Error object, or an error response
+   * @param {number} err.statusCode
+   * @param {string} err.errorMessage
+   * @param {string|string[]} err.details
+   */
+  // eslint-disable-next-line class-methods-use-this
+  renderError(contentType, err) {
+    switch (contentType) {
+      case Enum.ContentType.ApplicationJson:
+        return JSON.stringify(err);
+
+      case Enum.ContentType.TextHTML:
+        return Template.errorHTML(err);
+
+      case Enum.ContentType.TextPlain:
+      default:
+        return [err.errorMessage, err.details].join('\r\n');
+    }
+  }
+
+
+  /**
+   * Send an error response.  Terminal.
+   * Logs any non-error-response errors as such.
+   * @param {object} err either an Error object, or an error response
+   * @param {http.ClientRequest} req 
+   * @param {http.ServerResponse} res 
+   * @param {object} ctx 
+   */
+  sendErrorResponse(err, req, res, ctx) {
+    const _scope = _fileScope('sendErrorResponse');
+    let body;
+
+    // Default to a content type if one is not yet present
+    if (!res.hasHeader(Enum.Header.ContentType)) {
+      res.setHeader(Enum.Header.ContentType, Enum.ContentType.TextPlain);
+    }
+
+    if (err && err.statusCode) {
+      res.statusCode = err.statusCode;
+      body = this.renderError(res.getHeader(Enum.Header.ContentType), err);
+      this.logger.debug(_scope, 'handler error', { err, ...common.handlerLogData(req, res, ctx) });
+    } else {
+      res.statusCode = 500;
+      body = this.renderError(res.getHeader(Enum.Header.ContentType), Enum.ErrorResponse.InternalServerError);
+      this.logger.error(_scope, 'handler exception', { err, ...common.handlerLogData(req, res, ctx) });
+    }
+    res.end(body);
+  }
+
+
+  /**
+   * @param {http.ClientRequest} req
+   * @param {http.ServerResponse} res
+   * @param {object} ctx
+   */
+  async handlerMethodNotAllowed(req, res, ctx) {
+    this.setResponseType(this.responseTypes, req, res, ctx);
+    throw new ResponseError(Enum.ErrorResponse.MethodNotAllowed);
+  }
+
+
+  /**
+   * @param {http.ClientRequest} req
+   * @param {http.ServerResponse} res
+   * @param {object} ctx
+   */
+  async handlerNotFound(req, res, ctx) {
+    this.setResponseType(this.responseTypes, req, res, ctx);
+    throw new ResponseError(Enum.ErrorResponse.NotFound);
+  }
+
+
+  /**
+   * @param {http.ClientRequest} req
+   * @param {http.ServerResponse} res
+   * @param {object} ctx
+   */
+   async handlerBadRequest(req, res, ctx) {
+    this.setResponseType(this.responseTypes, req, res, ctx);
+    throw new ResponseError(Enum.ErrorResponse.BadRequest);
+  }
+
+  /**
+   * @param {http.ClientRequest} req
+   * @param {http.ServerResponse} res
+   * @param {object} ctx
+   */
+   async handlerInternalServerError(req, res, ctx) {
+    this.setResponseType(this.responseTypes, req, res, ctx);
+    throw new ResponseError(Enum.ErrorResponse.InternalServerError);
+  }
+
+}
+
+module.exports = Dingus;
diff --git a/lib/enum.js b/lib/enum.js
new file mode 100644 (file)
index 0000000..83e5094
--- /dev/null
@@ -0,0 +1,104 @@
+'use strict';
+
+
+const ContentType = {
+  TextHTML: 'text/html',
+  TextPlain: 'text/plain',
+  ApplicationForm: 'application/x-www-form-urlencoded',
+  ApplicationJson: 'application/json',
+};
+
+// Supported encoding types
+const EncodingType = {
+  Brotli: 'br',
+  Gzip: 'gzip',
+  XGzip: 'x-gzip',
+  Identity: 'identity',
+};
+
+// Filename suffixes for serving pre-encoded files
+const EncodingTypeSuffix = {
+  [EncodingType.Brotli]: '.br',
+  [EncodingType.Gzip]: '.gz',
+  [EncodingType.XGzip]: '.gz',
+};
+
+const ErrorResponse = {
+  BadRequest: {
+    statusCode: 400,
+    errorMessage: 'Bad Request',
+  },
+  Unauthorized: {
+    statusCode: 401,
+    errorMessage: 'Unauthorized',
+  },
+  Forbidden: {
+    statusCode: 403,
+    errorMessage: 'Forbidden',
+  },
+  ReallyForbidden: {
+    statusCode: 403,
+    errorMessage: 'F̦̩̫̼͔̫͓̃ͤ̈̆̀͑o̖̟͙̫̯̗̳̽ͦ̆́ͨr̩͉̰̗͉b̬̂͘į̟̬̓d͂͗҉̟͈̜͙ͅd͎̜̺̝͇͑̒̋̾ë̴̳̺͓̦̘́ͮ̈́ǹ͈̦̫̙',
+  },
+  NotFound: {
+    statusCode: 404,
+    errorMessage: 'Not Found',
+  },
+  MethodNotAllowed: {
+    statusCode: 405,
+    errorMessage: 'Method Not Allowed',
+  },
+  NotAcceptable: {
+    statusCode: 406,
+    errorMessage: 'Not Acceptable',
+  },
+  Gone: {
+    statusCode: 410,
+    errorMessage: 'Gone',
+  },
+  UnsupportedMediaType: {
+    statusCode: 415,
+    errorMessage: 'Unsupported Media Type',
+  },
+  InternalServerError: {
+    statusCode: 500,
+    errorMessage: 'Internal Server Error',
+  },
+};
+
+const ErrorResponseDefaultProxy = new Proxy(ErrorResponse, {
+  get: (target, property) => {
+    // eslint-disable-next-line security/detect-object-injection
+    return (property in target) ? target[property] : {
+      errorMessage: `undefined error response '${property}'`,
+    };
+  },
+});
+
+const Header = {
+  Accept: 'Accept',
+  AcceptEncoding: 'Accept-Encoding',
+  CacheControl: 'Cache-Control',
+  ContentEncoding: 'Content-Encoding',
+  ContentLength: 'Content-Length',
+  ContentType: 'Content-Type',
+  ETag: 'ETag',
+  IfModifiedSince: 'If-Modified-Since',
+  IfNoneMatch: 'If-None-Match',
+  LastModified: 'Last-Modified',
+  RequestId: 'Request-ID',
+  Vary: 'Vary',
+  XCorrelationId: 'X-Correlation-ID',
+  XForwardedFor: 'X-Forwarded-For',
+  XForwardedProto: 'X-Forwarded-Proto',
+  XRealIP: 'X-Real-IP',
+  XRequestId: 'X-Request-ID',
+};
+
+module.exports = {
+  ContentType,
+  EncodingType,
+  EncodingTypeSuffix,
+  ErrorResponse: ErrorResponseDefaultProxy,
+  Header,
+};
diff --git a/lib/errors.js b/lib/errors.js
new file mode 100644 (file)
index 0000000..ed5b151
--- /dev/null
@@ -0,0 +1,28 @@
+'use strict';
+
+class DingusError extends Error {
+  constructor(...args) {
+    super(...args);
+    Error.captureStackTrace(DingusError);
+  }
+
+  get name() {
+    return this.constructor.name;
+  }
+}
+
+class ResponseError extends DingusError {
+  constructor(errorResponse, details) {
+    super(errorResponse.errorMessage);
+    Object.assign(this, {
+      ...errorResponse,
+      ...(details && { details }),
+    });
+    delete this.stack; // No need for stacktrace here.
+  }
+}
+
+module.exports = {
+  DingusError,
+  ResponseError,
+};
diff --git a/lib/mime-helper.js b/lib/mime-helper.js
new file mode 100644 (file)
index 0000000..086dc6a
--- /dev/null
@@ -0,0 +1,28 @@
+/* eslint-disable security/detect-object-injection */
+'use strict';
+
+const mimeDb = require('mime-db');
+
+const defaultType = 'application/octet-stream';
+const extension = {};
+for (const [mimeType, entry] of Object.entries(mimeDb)) {
+  if (entry.extensions) {
+    entry.extensions.forEach((ext) => {
+      if (!(ext in extension)) {
+        extension[ext] = [];
+      }
+      extension[ext].push(mimeType);
+    });
+  }
+}
+
+/**
+ * Return a suitable type for a file extension.
+ * @param {string} ext file extension
+ * @param {string} def type to return if no match
+ */
+const extensionToMime = (ext, def = defaultType) => extension[ext] && extension[ext][extension[ext].length - 1] || def;
+
+module.exports = {
+  extensionToMime,
+};
diff --git a/lib/patches/incoming-message.js b/lib/patches/incoming-message.js
new file mode 100644 (file)
index 0000000..894ca65
--- /dev/null
@@ -0,0 +1,17 @@
+'use strict';
+const { IncomingMessage } = require('http');
+
+/**
+ * Welp, here we are, already into the crazy.
+ * Per documentation (https://nodejs.org/docs/latest-v12.x/api/http.html#http_request_getheader_name)
+ * this should exist, yet (as of 12.18.4) it does not.  So let us change this pitch up, and patch
+*/
+/* istanbul ignore else */
+if (typeof IncomingMessage.getHeader !== 'function') {
+  IncomingMessage.prototype.getHeader = function (name) {
+    if (typeof name !== 'string') {
+      throw new TypeError('\'name\' must be a string');
+    }
+    return this.headers && this.headers[name.toLowerCase()];
+  };
+}
diff --git a/lib/patches/index.js b/lib/patches/index.js
new file mode 100644 (file)
index 0000000..dcf4726
--- /dev/null
@@ -0,0 +1,3 @@
+'use strict';
+
+require('./incoming-message');
diff --git a/lib/router.js b/lib/router.js
new file mode 100644 (file)
index 0000000..92a8500
--- /dev/null
@@ -0,0 +1,206 @@
+/* eslint-disable security/detect-object-injection */
+'use strict';
+
+/**
+ * A very simple router.
+ */
+
+const { METHODS: httpMethods } = require('http');
+const common = require('./common');
+const { DingusError } = require('./errors');
+
+// Internal identifiers for route entries.
+const METHODS = Symbol('METHODS');
+const PARAM = Symbol('PARAM');
+
+const defaultOptions = {
+  ignoreTrailingSlash: false,
+  paramPrefix: ':',
+};
+
+class Router {
+  /**
+   * @param {Object} options
+   * @param {Boolean} options.ignoreTrailingSlash 
+   * @param {Boolean} options.paramPrefix 
+   */
+  constructor(options = {}) {
+    common.setOptions(this, defaultOptions, options);
+
+    // Keep lists of paths to match, indexed by path length.
+    this.pathsByLength = {
+      1: [],
+    };
+
+    this.METHODS = METHODS;
+    this.PARAM = PARAM;
+  }
+
+
+  /**
+   * Prepare a path for insertion into search list.
+   * A searchable path is a list of path parts, with a property of method handlers.
+   * @param {string} pathDefinition 
+   */
+  _pathDefinitionToPathMatch(pathDefinition) {
+    const pathMatch = pathDefinition.split('/').map((p) => p.startsWith(this.paramPrefix) ? { [PARAM]: p.slice(this.paramPrefix.length) } : p);
+    if (this.ignoreTrailingSlash
+    &&  pathMatch[pathMatch.length - 1] === '') {
+      pathMatch.pop();
+    }
+    pathMatch[METHODS] = {};
+    pathMatch.forEach((p) => Object.freeze(p));
+    Object.freeze(pathMatch);
+    return pathMatch;
+  }
+
+
+  /**
+   * Compare checkPath to fixedPath, no param substitution, params must match.
+   * @param {*} fixedPath 
+   * @param {*} checkPath 
+   */
+  static _pathCompareExact(fixedPath, checkPath) {
+    if (fixedPath.length !== checkPath.length) {
+      return false;
+    }
+    for (let i = 0; i < fixedPath.length; i++) {
+      const fixedPart = fixedPath[i];
+      const checkPart = checkPath[i];
+      if (typeof fixedPart === 'object' && typeof checkPart === 'object') {
+        if (fixedPart[PARAM] !== checkPart[PARAM]) {
+          return false;
+        }
+      } else if (fixedPart !== checkPart) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+
+  /**
+   * Compare checkPath to fixedPath, populating params.
+   * @param {*} fixedPath 
+   * @param {*} checkPath 
+   * @param {*} returnParams 
+   */
+  static _pathCompareParam(fixedPath, checkPath, returnParams = {}) {
+    const params = {};
+
+    if (fixedPath.length !== checkPath.length) {
+      return false;
+    }
+    for (let i = 0; i < fixedPath.length; i++) {
+      const fixedPart = fixedPath[i];
+      const checkPart = checkPath[i];
+      if (typeof fixedPart === 'object') {
+        params[fixedPart[PARAM]] = checkPart;
+      } else if (fixedPart !== checkPart) {
+        return false;
+      }
+    }
+    Object.assign(returnParams, params);
+    return true;
+  }
+
+
+  /**
+   * Search for an existing path, return matched path and path parameters.
+   * @param {Array} matchParts 
+   */
+  _pathFind(matchParts) {
+    const result = {
+      pathParams: {},
+      matchedPath: undefined,
+    };
+    const pathsByLength = this.pathsByLength[matchParts.length];
+    if (pathsByLength) {
+      for (const p of pathsByLength) {
+        if (Router._pathCompareParam(p, matchParts, result.pathParams)) {
+          result.matchedPath = p;
+          break;
+        }
+      }
+    }
+    return result;
+  }
+
+
+  /**
+   * Return a matching path, no param substitution, params must match
+   * @param {*} matchParts 
+   */
+  _pathFindExact(matchParts) {
+    const pathsByLength = this.pathsByLength[matchParts.length];
+    if (pathsByLength) {
+      for (const p of pathsByLength) {
+        if (Router._pathCompareExact(p, matchParts)) {
+          return p;
+        }
+      }
+    }
+    return undefined;
+  }
+
+
+  /**
+   * Insert a new path handler.
+   * @param {string|string[]} methods
+   * @param {string} urlPath 
+   * @param {fn} handler 
+   */
+  on(methods, urlPath, handler) {
+    const matchParts = this._pathDefinitionToPathMatch(urlPath);
+    let existingPath = this._pathFindExact(matchParts);
+    if (!existingPath) {
+      existingPath = matchParts;
+      if (!(matchParts.length in this.pathsByLength)) {
+        this.pathsByLength[matchParts.length] = [];
+      }
+      this.pathsByLength[matchParts.length].push(existingPath);
+    }
+    if (!Array.isArray(methods)) {
+      methods = [methods];
+    }
+    methods.forEach((method) => {
+      if (!httpMethods.includes(method) && method !== '*') {
+        throw new DingusError(`invalid method '${method}'`);
+      }
+      existingPath[METHODS][method] = handler;
+    });
+  }
+
+
+  /**
+   * Return a matching handler for a request, sets path parameters on context.
+   * @param {string} method 
+   * @param {string[]} urlPath
+   * @param {object} ctx 
+   */
+  lookup(method, urlPath, ctx = {}) {
+    const pathParts = urlPath.split('/').map((part) => decodeURIComponent(part));
+    if (this.ignoreTrailingSlash
+    &&  pathParts[pathParts.length - 1] === '') {
+      pathParts.pop();
+    }  
+    const { matchedPath, pathParams } = this._pathFind(pathParts);
+    ctx.params = pathParams;
+    if (matchedPath) {
+      ctx.matchedPath = matchedPath;
+      if (method in matchedPath[METHODS]) {
+        return matchedPath[METHODS][method];
+      }
+      if ('*' in matchedPath[METHODS]) {
+        return matchedPath[METHODS]['*'];
+      }
+      throw new DingusError('NoMethod');
+    }
+    ctx.unmatchedPath = pathParts;
+    throw new DingusError('NoPath');
+  }
+
+
+}
+
+module.exports = Router;
diff --git a/lib/template/error-html.js b/lib/template/error-html.js
new file mode 100644 (file)
index 0000000..fb82ce6
--- /dev/null
@@ -0,0 +1,15 @@
+'use strict';
+
+module.exports = (err) => {
+  const detailsArr = Array.isArray(err.details) ? err.details : [ err.details ];
+  return `<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <title>${err.statusCode} ${err.errorMessage}</title>
+  </head>
+  <body>
+    <h1>${err.errorMessage}</h1>` +
+    (!err.details ? '' : detailsArr.map((d) => `\n    <p>${d}</p>`).join('')) + `
+  </body>
+</html>`;
+};
\ No newline at end of file
diff --git a/lib/template/index.js b/lib/template/index.js
new file mode 100644 (file)
index 0000000..75c07d5
--- /dev/null
@@ -0,0 +1,5 @@
+'use strict';
+
+module.exports = {
+  errorHTML: require('./error-html'),
+};
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
new file mode 100644 (file)
index 0000000..e32d0ce
--- /dev/null
@@ -0,0 +1,2902 @@
+{
+  "name": "@squeep/api-dingus",
+  "version": "1.0.0",
+  "lockfileVersion": 1,
+  "requires": true,
+  "dependencies": {
+    "@babel/code-frame": {
+      "version": "7.10.4",
+      "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz",
+      "integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==",
+      "dev": true,
+      "requires": {
+        "@babel/highlight": "^7.10.4"
+      }
+    },
+    "@babel/core": {
+      "version": "7.11.6",
+      "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.11.6.tgz",
+      "integrity": "sha512-Wpcv03AGnmkgm6uS6k8iwhIwTrcP0m17TL1n1sy7qD0qelDu4XNeW0dN0mHfa+Gei211yDaLoEe/VlbXQzM4Bg==",
+      "dev": true,
+      "requires": {
+        "@babel/code-frame": "^7.10.4",
+        "@babel/generator": "^7.11.6",
+        "@babel/helper-module-transforms": "^7.11.0",
+        "@babel/helpers": "^7.10.4",
+        "@babel/parser": "^7.11.5",
+        "@babel/template": "^7.10.4",
+        "@babel/traverse": "^7.11.5",
+        "@babel/types": "^7.11.5",
+        "convert-source-map": "^1.7.0",
+        "debug": "^4.1.0",
+        "gensync": "^1.0.0-beta.1",
+        "json5": "^2.1.2",
+        "lodash": "^4.17.19",
+        "resolve": "^1.3.2",
+        "semver": "^5.4.1",
+        "source-map": "^0.5.0"
+      },
+      "dependencies": {
+        "semver": {
+          "version": "5.7.1",
+          "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
+          "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
+          "dev": true
+        }
+      }
+    },
+    "@babel/generator": {
+      "version": "7.11.6",
+      "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.11.6.tgz",
+      "integrity": "sha512-DWtQ1PV3r+cLbySoHrwn9RWEgKMBLLma4OBQloPRyDYvc5msJM9kvTLo1YnlJd1P/ZuKbdli3ijr5q3FvAF3uA==",
+      "dev": true,
+      "requires": {
+        "@babel/types": "^7.11.5",
+        "jsesc": "^2.5.1",
+        "source-map": "^0.5.0"
+      }
+    },
+    "@babel/helper-function-name": {
+      "version": "7.10.4",
+      "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.10.4.tgz",
+      "integrity": "sha512-YdaSyz1n8gY44EmN7x44zBn9zQ1Ry2Y+3GTA+3vH6Mizke1Vw0aWDM66FOYEPw8//qKkmqOckrGgTYa+6sceqQ==",
+      "dev": true,
+      "requires": {
+        "@babel/helper-get-function-arity": "^7.10.4",
+        "@babel/template": "^7.10.4",
+        "@babel/types": "^7.10.4"
+      }
+    },
+    "@babel/helper-get-function-arity": {
+      "version": "7.10.4",
+      "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.10.4.tgz",
+      "integrity": "sha512-EkN3YDB+SRDgiIUnNgcmiD361ti+AVbL3f3Henf6dqqUyr5dMsorno0lJWJuLhDhkI5sYEpgj6y9kB8AOU1I2A==",
+      "dev": true,
+      "requires": {
+        "@babel/types": "^7.10.4"
+      }
+    },
+    "@babel/helper-member-expression-to-functions": {
+      "version": "7.11.0",
+      "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.11.0.tgz",
+      "integrity": "sha512-JbFlKHFntRV5qKw3YC0CvQnDZ4XMwgzzBbld7Ly4Mj4cbFy3KywcR8NtNctRToMWJOVvLINJv525Gd6wwVEx/Q==",
+      "dev": true,
+      "requires": {
+        "@babel/types": "^7.11.0"
+      }
+    },
+    "@babel/helper-module-imports": {
+      "version": "7.10.4",
+      "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.10.4.tgz",
+      "integrity": "sha512-nEQJHqYavI217oD9+s5MUBzk6x1IlvoS9WTPfgG43CbMEeStE0v+r+TucWdx8KFGowPGvyOkDT9+7DHedIDnVw==",
+      "dev": true,
+      "requires": {
+        "@babel/types": "^7.10.4"
+      }
+    },
+    "@babel/helper-module-transforms": {
+      "version": "7.11.0",
+      "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.11.0.tgz",
+      "integrity": "sha512-02EVu8COMuTRO1TAzdMtpBPbe6aQ1w/8fePD2YgQmxZU4gpNWaL9gK3Jp7dxlkUlUCJOTaSeA+Hrm1BRQwqIhg==",
+      "dev": true,
+      "requires": {
+        "@babel/helper-module-imports": "^7.10.4",
+        "@babel/helper-replace-supers": "^7.10.4",
+        "@babel/helper-simple-access": "^7.10.4",
+        "@babel/helper-split-export-declaration": "^7.11.0",
+        "@babel/template": "^7.10.4",
+        "@babel/types": "^7.11.0",
+        "lodash": "^4.17.19"
+      }
+    },
+    "@babel/helper-optimise-call-expression": {
+      "version": "7.10.4",
+      "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.10.4.tgz",
+      "integrity": "sha512-n3UGKY4VXwXThEiKrgRAoVPBMqeoPgHVqiHZOanAJCG9nQUL2pLRQirUzl0ioKclHGpGqRgIOkgcIJaIWLpygg==",
+      "dev": true,
+      "requires": {
+        "@babel/types": "^7.10.4"
+      }
+    },
+    "@babel/helper-replace-supers": {
+      "version": "7.10.4",
+      "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.10.4.tgz",
+      "integrity": "sha512-sPxZfFXocEymYTdVK1UNmFPBN+Hv5mJkLPsYWwGBxZAxaWfFu+xqp7b6qWD0yjNuNL2VKc6L5M18tOXUP7NU0A==",
+      "dev": true,
+      "requires": {
+        "@babel/helper-member-expression-to-functions": "^7.10.4",
+        "@babel/helper-optimise-call-expression": "^7.10.4",
+        "@babel/traverse": "^7.10.4",
+        "@babel/types": "^7.10.4"
+      }
+    },
+    "@babel/helper-simple-access": {
+      "version": "7.10.4",
+      "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.10.4.tgz",
+      "integrity": "sha512-0fMy72ej/VEvF8ULmX6yb5MtHG4uH4Dbd6I/aHDb/JVg0bbivwt9Wg+h3uMvX+QSFtwr5MeItvazbrc4jtRAXw==",
+      "dev": true,
+      "requires": {
+        "@babel/template": "^7.10.4",
+        "@babel/types": "^7.10.4"
+      }
+    },
+    "@babel/helper-split-export-declaration": {
+      "version": "7.11.0",
+      "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.11.0.tgz",
+      "integrity": "sha512-74Vejvp6mHkGE+m+k5vHY93FX2cAtrw1zXrZXRlG4l410Nm9PxfEiVTn1PjDPV5SnmieiueY4AFg2xqhNFuuZg==",
+      "dev": true,
+      "requires": {
+        "@babel/types": "^7.11.0"
+      }
+    },
+    "@babel/helper-validator-identifier": {
+      "version": "7.10.4",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.10.4.tgz",
+      "integrity": "sha512-3U9y+43hz7ZM+rzG24Qe2mufW5KhvFg/NhnNph+i9mgCtdTCtMJuI1TMkrIUiK7Ix4PYlRF9I5dhqaLYA/ADXw==",
+      "dev": true
+    },
+    "@babel/helpers": {
+      "version": "7.10.4",
+      "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.10.4.tgz",
+      "integrity": "sha512-L2gX/XeUONeEbI78dXSrJzGdz4GQ+ZTA/aazfUsFaWjSe95kiCuOZ5HsXvkiw3iwF+mFHSRUfJU8t6YavocdXA==",
+      "dev": true,
+      "requires": {
+        "@babel/template": "^7.10.4",
+        "@babel/traverse": "^7.10.4",
+        "@babel/types": "^7.10.4"
+      }
+    },
+    "@babel/highlight": {
+      "version": "7.10.4",
+      "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.10.4.tgz",
+      "integrity": "sha512-i6rgnR/YgPEQzZZnbTHHuZdlE8qyoBNalD6F+q4vAFlcMEcqmkoG+mPqJYJCo63qPf74+Y1UZsl3l6f7/RIkmA==",
+      "dev": true,
+      "requires": {
+        "@babel/helper-validator-identifier": "^7.10.4",
+        "chalk": "^2.0.0",
+        "js-tokens": "^4.0.0"
+      },
+      "dependencies": {
+        "chalk": {
+          "version": "2.4.2",
+          "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
+          "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
+          "dev": true,
+          "requires": {
+            "ansi-styles": "^3.2.1",
+            "escape-string-regexp": "^1.0.5",
+            "supports-color": "^5.3.0"
+          }
+        }
+      }
+    },
+    "@babel/parser": {
+      "version": "7.11.5",
+      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.11.5.tgz",
+      "integrity": "sha512-X9rD8qqm695vgmeaQ4fvz/o3+Wk4ZzQvSHkDBgpYKxpD4qTAUm88ZKtHkVqIOsYFFbIQ6wQYhC6q7pjqVK0E0Q==",
+      "dev": true
+    },
+    "@babel/template": {
+      "version": "7.10.4",
+      "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.10.4.tgz",
+      "integrity": "sha512-ZCjD27cGJFUB6nmCB1Enki3r+L5kJveX9pq1SvAUKoICy6CZ9yD8xO086YXdYhvNjBdnekm4ZnaP5yC8Cs/1tA==",
+      "dev": true,
+      "requires": {
+        "@babel/code-frame": "^7.10.4",
+        "@babel/parser": "^7.10.4",
+        "@babel/types": "^7.10.4"
+      }
+    },
+    "@babel/traverse": {
+      "version": "7.11.5",
+      "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.11.5.tgz",
+      "integrity": "sha512-EjiPXt+r7LiCZXEfRpSJd+jUMnBd4/9OUv7Nx3+0u9+eimMwJmG0Q98lw4/289JCoxSE8OolDMNZaaF/JZ69WQ==",
+      "dev": true,
+      "requires": {
+        "@babel/code-frame": "^7.10.4",
+        "@babel/generator": "^7.11.5",
+        "@babel/helper-function-name": "^7.10.4",
+        "@babel/helper-split-export-declaration": "^7.11.0",
+        "@babel/parser": "^7.11.5",
+        "@babel/types": "^7.11.5",
+        "debug": "^4.1.0",
+        "globals": "^11.1.0",
+        "lodash": "^4.17.19"
+      },
+      "dependencies": {
+        "globals": {
+          "version": "11.12.0",
+          "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
+          "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==",
+          "dev": true
+        }
+      }
+    },
+    "@babel/types": {
+      "version": "7.11.5",
+      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.11.5.tgz",
+      "integrity": "sha512-bvM7Qz6eKnJVFIn+1LPtjlBFPVN5jNDc1XmN15vWe7Q3DPBufWWsLiIvUu7xW87uTG6QoggpIDnUgLQvPheU+Q==",
+      "dev": true,
+      "requires": {
+        "@babel/helper-validator-identifier": "^7.10.4",
+        "lodash": "^4.17.19",
+        "to-fast-properties": "^2.0.0"
+      }
+    },
+    "@eslint/eslintrc": {
+      "version": "0.4.3",
+      "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-0.4.3.tgz",
+      "integrity": "sha512-J6KFFz5QCYUJq3pf0mjEcCJVERbzv71PUIDczuh9JkwGEzced6CO5ADLHB1rbf/+oPBtoPfMYNOpGDzCANlbXw==",
+      "dev": true,
+      "requires": {
+        "ajv": "^6.12.4",
+        "debug": "^4.1.1",
+        "espree": "^7.3.0",
+        "globals": "^13.9.0",
+        "ignore": "^4.0.6",
+        "import-fresh": "^3.2.1",
+        "js-yaml": "^3.13.1",
+        "minimatch": "^3.0.4",
+        "strip-json-comments": "^3.1.1"
+      }
+    },
+    "@humanwhocodes/config-array": {
+      "version": "0.5.0",
+      "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.5.0.tgz",
+      "integrity": "sha512-FagtKFz74XrTl7y6HCzQpwDfXP0yhxe9lHLD1UZxjvZIcbyRz8zTFF/yYNfSfzU414eDwZ1SrO0Qvtyf+wFMQg==",
+      "dev": true,
+      "requires": {
+        "@humanwhocodes/object-schema": "^1.2.0",
+        "debug": "^4.1.1",
+        "minimatch": "^3.0.4"
+      }
+    },
+    "@humanwhocodes/object-schema": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.0.tgz",
+      "integrity": "sha512-wdppn25U8z/2yiaT6YGquE6X8sSv7hNMWSXYSSU1jGv/yd6XqjXgTDJ8KP4NgjTXfJ3GbRjeeb8RTV7a/VpM+w==",
+      "dev": true
+    },
+    "@istanbuljs/load-nyc-config": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
+      "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==",
+      "dev": true,
+      "requires": {
+        "camelcase": "^5.3.1",
+        "find-up": "^4.1.0",
+        "get-package-type": "^0.1.0",
+        "js-yaml": "^3.13.1",
+        "resolve-from": "^5.0.0"
+      },
+      "dependencies": {
+        "find-up": {
+          "version": "4.1.0",
+          "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+          "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+          "dev": true,
+          "requires": {
+            "locate-path": "^5.0.0",
+            "path-exists": "^4.0.0"
+          }
+        },
+        "locate-path": {
+          "version": "5.0.0",
+          "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+          "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+          "dev": true,
+          "requires": {
+            "p-locate": "^4.1.0"
+          }
+        },
+        "p-limit": {
+          "version": "2.3.0",
+          "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+          "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+          "dev": true,
+          "requires": {
+            "p-try": "^2.0.0"
+          }
+        },
+        "p-locate": {
+          "version": "4.1.0",
+          "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+          "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+          "dev": true,
+          "requires": {
+            "p-limit": "^2.2.0"
+          }
+        },
+        "resolve-from": {
+          "version": "5.0.0",
+          "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
+          "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
+          "dev": true
+        }
+      }
+    },
+    "@istanbuljs/schema": {
+      "version": "0.1.2",
+      "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.2.tgz",
+      "integrity": "sha512-tsAQNx32a8CoFhjhijUIhI4kccIAgmGhy8LZMZgGfmXcpMbPRUqn5LWmgRttILi6yeGmBJd2xsPkFMs0PzgPCw==",
+      "dev": true
+    },
+    "@sinonjs/commons": {
+      "version": "1.8.3",
+      "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.3.tgz",
+      "integrity": "sha512-xkNcLAn/wZaX14RPlwizcKicDk9G3F8m2nU3L7Ukm5zBgTwiT0wsoFAHx9Jq56fJA1z/7uKGtCRu16sOUCLIHQ==",
+      "dev": true,
+      "requires": {
+        "type-detect": "4.0.8"
+      }
+    },
+    "@sinonjs/fake-timers": {
+      "version": "7.1.2",
+      "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-7.1.2.tgz",
+      "integrity": "sha512-iQADsW4LBMISqZ6Ci1dupJL9pprqwcVFTcOsEmQOEhW+KLCVn/Y4Jrvg2k19fIHCp+iFprriYPTdRcQR8NbUPg==",
+      "dev": true,
+      "requires": {
+        "@sinonjs/commons": "^1.7.0"
+      }
+    },
+    "@sinonjs/samsam": {
+      "version": "6.0.2",
+      "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-6.0.2.tgz",
+      "integrity": "sha512-jxPRPp9n93ci7b8hMfJOFDPRLFYadN6FSpeROFTR4UNF4i5b+EK6m4QXPO46BDhFgRy1JuS87zAnFOzCUwMJcQ==",
+      "dev": true,
+      "requires": {
+        "@sinonjs/commons": "^1.6.0",
+        "lodash.get": "^4.4.2",
+        "type-detect": "^4.0.8"
+      }
+    },
+    "@sinonjs/text-encoding": {
+      "version": "0.7.1",
+      "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.1.tgz",
+      "integrity": "sha512-+iTbntw2IZPb/anVDbypzfQa+ay64MW0Zo8aJ8gZPWMMK6/OubMVb6lUPMagqjOPnmtauXnFCACVl3O7ogjeqQ==",
+      "dev": true
+    },
+    "@types/color-name": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz",
+      "integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==",
+      "dev": true
+    },
+    "@ungap/promise-all-settled": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz",
+      "integrity": "sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q==",
+      "dev": true
+    },
+    "acorn": {
+      "version": "7.4.1",
+      "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz",
+      "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==",
+      "dev": true
+    },
+    "acorn-jsx": {
+      "version": "5.3.2",
+      "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+      "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+      "dev": true
+    },
+    "aggregate-error": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz",
+      "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==",
+      "dev": true,
+      "requires": {
+        "clean-stack": "^2.0.0",
+        "indent-string": "^4.0.0"
+      }
+    },
+    "ajv": {
+      "version": "6.12.6",
+      "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
+      "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+      "dev": true,
+      "requires": {
+        "fast-deep-equal": "^3.1.1",
+        "fast-json-stable-stringify": "^2.0.0",
+        "json-schema-traverse": "^0.4.1",
+        "uri-js": "^4.2.2"
+      }
+    },
+    "ansi-colors": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz",
+      "integrity": "sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==",
+      "dev": true
+    },
+    "ansi-regex": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz",
+      "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==",
+      "dev": true
+    },
+    "ansi-styles": {
+      "version": "3.2.1",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
+      "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
+      "dev": true,
+      "requires": {
+        "color-convert": "^1.9.0"
+      }
+    },
+    "anymatch": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz",
+      "integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==",
+      "dev": true,
+      "requires": {
+        "normalize-path": "^3.0.0",
+        "picomatch": "^2.0.4"
+      }
+    },
+    "append-transform": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/append-transform/-/append-transform-2.0.0.tgz",
+      "integrity": "sha512-7yeyCEurROLQJFv5Xj4lEGTy0borxepjFv1g22oAdqFu//SrAlDl1O1Nxx15SH1RoliUml6p8dwJW9jvZughhg==",
+      "dev": true,
+      "requires": {
+        "default-require-extensions": "^3.0.0"
+      }
+    },
+    "archy": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz",
+      "integrity": "sha1-+cjBN1fMHde8N5rHeyxipcKGjEA=",
+      "dev": true
+    },
+    "argparse": {
+      "version": "1.0.10",
+      "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
+      "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
+      "dev": true,
+      "requires": {
+        "sprintf-js": "~1.0.2"
+      }
+    },
+    "astral-regex": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz",
+      "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==",
+      "dev": true
+    },
+    "balanced-match": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz",
+      "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=",
+      "dev": true
+    },
+    "binary-extensions": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
+      "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
+      "dev": true
+    },
+    "brace-expansion": {
+      "version": "1.1.11",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
+      "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+      "dev": true,
+      "requires": {
+        "balanced-match": "^1.0.0",
+        "concat-map": "0.0.1"
+      }
+    },
+    "braces": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
+      "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
+      "dev": true,
+      "requires": {
+        "fill-range": "^7.0.1"
+      }
+    },
+    "browser-stdout": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz",
+      "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==",
+      "dev": true
+    },
+    "buffer-from": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz",
+      "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==",
+      "dev": true
+    },
+    "caching-transform": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/caching-transform/-/caching-transform-4.0.0.tgz",
+      "integrity": "sha512-kpqOvwXnjjN44D89K5ccQC+RUrsy7jB/XLlRrx0D7/2HNcTPqzsb6XgYoErwko6QsV184CA2YgS1fxDiiDZMWA==",
+      "dev": true,
+      "requires": {
+        "hasha": "^5.0.0",
+        "make-dir": "^3.0.0",
+        "package-hash": "^4.0.0",
+        "write-file-atomic": "^3.0.0"
+      }
+    },
+    "callsites": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+      "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+      "dev": true
+    },
+    "camelcase": {
+      "version": "5.3.1",
+      "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
+      "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
+      "dev": true
+    },
+    "chalk": {
+      "version": "4.1.2",
+      "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+      "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+      "dev": true,
+      "requires": {
+        "ansi-styles": "^4.1.0",
+        "supports-color": "^7.1.0"
+      },
+      "dependencies": {
+        "ansi-styles": {
+          "version": "4.3.0",
+          "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+          "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+          "dev": true,
+          "requires": {
+            "color-convert": "^2.0.1"
+          }
+        },
+        "color-convert": {
+          "version": "2.0.1",
+          "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+          "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+          "dev": true,
+          "requires": {
+            "color-name": "~1.1.4"
+          }
+        },
+        "color-name": {
+          "version": "1.1.4",
+          "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+          "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+          "dev": true
+        },
+        "has-flag": {
+          "version": "4.0.0",
+          "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+          "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+          "dev": true
+        },
+        "supports-color": {
+          "version": "7.2.0",
+          "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+          "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+          "dev": true,
+          "requires": {
+            "has-flag": "^4.0.0"
+          }
+        }
+      }
+    },
+    "chokidar": {
+      "version": "3.5.2",
+      "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.2.tgz",
+      "integrity": "sha512-ekGhOnNVPgT77r4K/U3GDhu+FQ2S8TnK/s2KbIGXi0SZWuwkZ2QNyfWdZW+TVfn84DpEP7rLeCt2UI6bJ8GwbQ==",
+      "dev": true,
+      "requires": {
+        "anymatch": "~3.1.2",
+        "braces": "~3.0.2",
+        "fsevents": "~2.3.2",
+        "glob-parent": "~5.1.2",
+        "is-binary-path": "~2.1.0",
+        "is-glob": "~4.0.1",
+        "normalize-path": "~3.0.0",
+        "readdirp": "~3.6.0"
+      }
+    },
+    "clean-stack": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
+      "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==",
+      "dev": true
+    },
+    "cliui": {
+      "version": "7.0.4",
+      "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz",
+      "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==",
+      "dev": true,
+      "requires": {
+        "string-width": "^4.2.0",
+        "strip-ansi": "^6.0.0",
+        "wrap-ansi": "^7.0.0"
+      }
+    },
+    "color-convert": {
+      "version": "1.9.3",
+      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
+      "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
+      "dev": true,
+      "requires": {
+        "color-name": "1.1.3"
+      }
+    },
+    "color-name": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
+      "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=",
+      "dev": true
+    },
+    "commondir": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
+      "integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=",
+      "dev": true
+    },
+    "concat-map": {
+      "version": "0.0.1",
+      "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+      "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=",
+      "dev": true
+    },
+    "concat-stream": {
+      "version": "1.6.2",
+      "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
+      "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==",
+      "dev": true,
+      "requires": {
+        "buffer-from": "^1.0.0",
+        "inherits": "^2.0.3",
+        "readable-stream": "^2.2.2",
+        "typedarray": "^0.0.6"
+      }
+    },
+    "convert-source-map": {
+      "version": "1.7.0",
+      "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.7.0.tgz",
+      "integrity": "sha512-4FJkXzKXEDB1snCFZlLP4gpC3JILicCpGbzG9f9G7tGqGCzETQ2hWPrcinA9oU4wtf2biUaEH5065UnMeR33oA==",
+      "dev": true,
+      "requires": {
+        "safe-buffer": "~5.1.1"
+      },
+      "dependencies": {
+        "safe-buffer": {
+          "version": "5.1.2",
+          "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+          "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+          "dev": true
+        }
+      }
+    },
+    "core-util-is": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
+      "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=",
+      "dev": true
+    },
+    "cross-spawn": {
+      "version": "7.0.3",
+      "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
+      "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
+      "dev": true,
+      "requires": {
+        "path-key": "^3.1.0",
+        "shebang-command": "^2.0.0",
+        "which": "^2.0.1"
+      }
+    },
+    "debug": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz",
+      "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==",
+      "dev": true,
+      "requires": {
+        "ms": "^2.1.1"
+      }
+    },
+    "decamelize": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
+      "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=",
+      "dev": true
+    },
+    "deep-is": {
+      "version": "0.1.3",
+      "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz",
+      "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=",
+      "dev": true
+    },
+    "default-require-extensions": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-3.0.0.tgz",
+      "integrity": "sha512-ek6DpXq/SCpvjhpFsLFRVtIxJCRw6fUR42lYMVZuUMK7n8eMz4Uh5clckdBjEpLhn/gEBZo7hDJnJcwdKLKQjg==",
+      "dev": true,
+      "requires": {
+        "strip-bom": "^4.0.0"
+      }
+    },
+    "diff": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/diff/-/diff-5.0.0.tgz",
+      "integrity": "sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==",
+      "dev": true
+    },
+    "doctrine": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
+      "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
+      "dev": true,
+      "requires": {
+        "esutils": "^2.0.2"
+      }
+    },
+    "emoji-regex": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+      "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+      "dev": true
+    },
+    "enquirer": {
+      "version": "2.3.6",
+      "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.3.6.tgz",
+      "integrity": "sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==",
+      "dev": true,
+      "requires": {
+        "ansi-colors": "^4.1.1"
+      }
+    },
+    "es6-error": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
+      "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==",
+      "dev": true
+    },
+    "escalade": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
+      "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==",
+      "dev": true
+    },
+    "escape-string-regexp": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
+      "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=",
+      "dev": true
+    },
+    "eslint": {
+      "version": "7.32.0",
+      "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.32.0.tgz",
+      "integrity": "sha512-VHZ8gX+EDfz+97jGcgyGCyRia/dPOd6Xh9yPv8Bl1+SoaIwD+a/vlrOmGRUyOYu7MwUhc7CxqeaDZU13S4+EpA==",
+      "dev": true,
+      "requires": {
+        "@babel/code-frame": "7.12.11",
+        "@eslint/eslintrc": "^0.4.3",
+        "@humanwhocodes/config-array": "^0.5.0",
+        "ajv": "^6.10.0",
+        "chalk": "^4.0.0",
+        "cross-spawn": "^7.0.2",
+        "debug": "^4.0.1",
+        "doctrine": "^3.0.0",
+        "enquirer": "^2.3.5",
+        "escape-string-regexp": "^4.0.0",
+        "eslint-scope": "^5.1.1",
+        "eslint-utils": "^2.1.0",
+        "eslint-visitor-keys": "^2.0.0",
+        "espree": "^7.3.1",
+        "esquery": "^1.4.0",
+        "esutils": "^2.0.2",
+        "fast-deep-equal": "^3.1.3",
+        "file-entry-cache": "^6.0.1",
+        "functional-red-black-tree": "^1.0.1",
+        "glob-parent": "^5.1.2",
+        "globals": "^13.6.0",
+        "ignore": "^4.0.6",
+        "import-fresh": "^3.0.0",
+        "imurmurhash": "^0.1.4",
+        "is-glob": "^4.0.0",
+        "js-yaml": "^3.13.1",
+        "json-stable-stringify-without-jsonify": "^1.0.1",
+        "levn": "^0.4.1",
+        "lodash.merge": "^4.6.2",
+        "minimatch": "^3.0.4",
+        "natural-compare": "^1.4.0",
+        "optionator": "^0.9.1",
+        "progress": "^2.0.0",
+        "regexpp": "^3.1.0",
+        "semver": "^7.2.1",
+        "strip-ansi": "^6.0.0",
+        "strip-json-comments": "^3.1.0",
+        "table": "^6.0.9",
+        "text-table": "^0.2.0",
+        "v8-compile-cache": "^2.0.3"
+      },
+      "dependencies": {
+        "@babel/code-frame": {
+          "version": "7.12.11",
+          "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.11.tgz",
+          "integrity": "sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw==",
+          "dev": true,
+          "requires": {
+            "@babel/highlight": "^7.10.4"
+          }
+        },
+        "escape-string-regexp": {
+          "version": "4.0.0",
+          "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+          "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+          "dev": true
+        },
+        "eslint-visitor-keys": {
+          "version": "2.1.0",
+          "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz",
+          "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==",
+          "dev": true
+        }
+      }
+    },
+    "eslint-plugin-es": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-es/-/eslint-plugin-es-3.0.1.tgz",
+      "integrity": "sha512-GUmAsJaN4Fc7Gbtl8uOBlayo2DqhwWvEzykMHSCZHU3XdJ+NSzzZcVhXh3VxX5icqQ+oQdIEawXX8xkR3mIFmQ==",
+      "dev": true,
+      "requires": {
+        "eslint-utils": "^2.0.0",
+        "regexpp": "^3.0.0"
+      }
+    },
+    "eslint-plugin-node": {
+      "version": "11.1.0",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-node/-/eslint-plugin-node-11.1.0.tgz",
+      "integrity": "sha512-oUwtPJ1W0SKD0Tr+wqu92c5xuCeQqB3hSCHasn/ZgjFdA9iDGNkNf2Zi9ztY7X+hNuMib23LNGRm6+uN+KLE3g==",
+      "dev": true,
+      "requires": {
+        "eslint-plugin-es": "^3.0.0",
+        "eslint-utils": "^2.0.0",
+        "ignore": "^5.1.1",
+        "minimatch": "^3.0.4",
+        "resolve": "^1.10.1",
+        "semver": "^6.1.0"
+      },
+      "dependencies": {
+        "ignore": {
+          "version": "5.1.8",
+          "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.1.8.tgz",
+          "integrity": "sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw==",
+          "dev": true
+        },
+        "semver": {
+          "version": "6.3.0",
+          "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
+          "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+          "dev": true
+        }
+      }
+    },
+    "eslint-plugin-security": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-security/-/eslint-plugin-security-1.4.0.tgz",
+      "integrity": "sha512-xlS7P2PLMXeqfhyf3NpqbvbnW04kN8M9NtmhpR3XGyOvt/vNKS7XPXT5EDbwKW9vCjWH4PpfQvgD/+JgN0VJKA==",
+      "dev": true,
+      "requires": {
+        "safe-regex": "^1.1.0"
+      }
+    },
+    "eslint-plugin-sonarjs": {
+      "version": "0.9.1",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-sonarjs/-/eslint-plugin-sonarjs-0.9.1.tgz",
+      "integrity": "sha512-KKFofk1LPjGHWeAZijYWv32c/C4mz+OAeBNVxhxHu1hknrTOhu415MWC8qKdAdsmOlBPShs9evM4mI1o7MNMhw==",
+      "dev": true
+    },
+    "eslint-scope": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
+      "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==",
+      "dev": true,
+      "requires": {
+        "esrecurse": "^4.3.0",
+        "estraverse": "^4.1.1"
+      }
+    },
+    "eslint-utils": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.1.0.tgz",
+      "integrity": "sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==",
+      "dev": true,
+      "requires": {
+        "eslint-visitor-keys": "^1.1.0"
+      }
+    },
+    "eslint-visitor-keys": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz",
+      "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==",
+      "dev": true
+    },
+    "espree": {
+      "version": "7.3.1",
+      "resolved": "https://registry.npmjs.org/espree/-/espree-7.3.1.tgz",
+      "integrity": "sha512-v3JCNCE64umkFpmkFGqzVKsOT0tN1Zr+ueqLZfpV1Ob8e+CEgPWa+OxCoGH3tnhimMKIaBm4m/vaRpJ/krRz2g==",
+      "dev": true,
+      "requires": {
+        "acorn": "^7.4.0",
+        "acorn-jsx": "^5.3.1",
+        "eslint-visitor-keys": "^1.3.0"
+      }
+    },
+    "esprima": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
+      "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
+      "dev": true
+    },
+    "esquery": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.4.0.tgz",
+      "integrity": "sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w==",
+      "dev": true,
+      "requires": {
+        "estraverse": "^5.1.0"
+      },
+      "dependencies": {
+        "estraverse": {
+          "version": "5.2.0",
+          "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.2.0.tgz",
+          "integrity": "sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ==",
+          "dev": true
+        }
+      }
+    },
+    "esrecurse": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+      "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+      "dev": true,
+      "requires": {
+        "estraverse": "^5.2.0"
+      },
+      "dependencies": {
+        "estraverse": {
+          "version": "5.2.0",
+          "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.2.0.tgz",
+          "integrity": "sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ==",
+          "dev": true
+        }
+      }
+    },
+    "estraverse": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
+      "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
+      "dev": true
+    },
+    "esutils": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+      "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+      "dev": true
+    },
+    "fast-deep-equal": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+      "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+      "dev": true
+    },
+    "fast-json-stable-stringify": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+      "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+      "dev": true
+    },
+    "fast-levenshtein": {
+      "version": "2.0.6",
+      "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+      "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=",
+      "dev": true
+    },
+    "file-entry-cache": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
+      "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==",
+      "dev": true,
+      "requires": {
+        "flat-cache": "^3.0.4"
+      }
+    },
+    "fill-range": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
+      "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
+      "dev": true,
+      "requires": {
+        "to-regex-range": "^5.0.1"
+      }
+    },
+    "find-cache-dir": {
+      "version": "3.3.1",
+      "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.1.tgz",
+      "integrity": "sha512-t2GDMt3oGC/v+BMwzmllWDuJF/xcDtE5j/fCGbqDD7OLuJkj0cfh1YSA5VKPvwMeLFLNDBkwOKZ2X85jGLVftQ==",
+      "dev": true,
+      "requires": {
+        "commondir": "^1.0.1",
+        "make-dir": "^3.0.2",
+        "pkg-dir": "^4.1.0"
+      }
+    },
+    "find-up": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+      "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+      "dev": true,
+      "requires": {
+        "locate-path": "^6.0.0",
+        "path-exists": "^4.0.0"
+      }
+    },
+    "flat": {
+      "version": "5.0.2",
+      "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz",
+      "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==",
+      "dev": true
+    },
+    "flat-cache": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz",
+      "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==",
+      "dev": true,
+      "requires": {
+        "flatted": "^3.1.0",
+        "rimraf": "^3.0.2"
+      }
+    },
+    "flatted": {
+      "version": "3.2.2",
+      "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.2.tgz",
+      "integrity": "sha512-JaTY/wtrcSyvXJl4IMFHPKyFur1sE9AUqc0QnhOaJ0CxHtAoIV8pYDzeEfAaNEtGkOfq4gr3LBFmdXW5mOQFnA==",
+      "dev": true
+    },
+    "foreground-child": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-2.0.0.tgz",
+      "integrity": "sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA==",
+      "dev": true,
+      "requires": {
+        "cross-spawn": "^7.0.0",
+        "signal-exit": "^3.0.2"
+      }
+    },
+    "fromentries": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/fromentries/-/fromentries-1.2.1.tgz",
+      "integrity": "sha512-Xu2Qh8yqYuDhQGOhD5iJGninErSfI9A3FrriD3tjUgV5VbJFeH8vfgZ9HnC6jWN80QDVNQK5vmxRAmEAp7Mevw==",
+      "dev": true
+    },
+    "fs.realpath": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+      "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=",
+      "dev": true
+    },
+    "fsevents": {
+      "version": "2.3.2",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+      "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+      "dev": true,
+      "optional": true
+    },
+    "functional-red-black-tree": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz",
+      "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=",
+      "dev": true
+    },
+    "gensync": {
+      "version": "1.0.0-beta.1",
+      "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.1.tgz",
+      "integrity": "sha512-r8EC6NO1sngH/zdD9fiRDLdcgnbayXah+mLgManTaIZJqEC1MZstmnox8KpnI2/fxQwrp5OpCOYWLp4rBl4Jcg==",
+      "dev": true
+    },
+    "get-caller-file": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+      "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+      "dev": true
+    },
+    "get-package-type": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
+      "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==",
+      "dev": true
+    },
+    "glob": {
+      "version": "7.1.6",
+      "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz",
+      "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==",
+      "dev": true,
+      "requires": {
+        "fs.realpath": "^1.0.0",
+        "inflight": "^1.0.4",
+        "inherits": "2",
+        "minimatch": "^3.0.4",
+        "once": "^1.3.0",
+        "path-is-absolute": "^1.0.0"
+      }
+    },
+    "glob-parent": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+      "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+      "dev": true,
+      "requires": {
+        "is-glob": "^4.0.1"
+      }
+    },
+    "globals": {
+      "version": "13.10.0",
+      "resolved": "https://registry.npmjs.org/globals/-/globals-13.10.0.tgz",
+      "integrity": "sha512-piHC3blgLGFjvOuMmWZX60f+na1lXFDhQXBf1UYp2fXPXqvEUbOhNwi6BsQ0bQishwedgnjkwv1d9zKf+MWw3g==",
+      "dev": true,
+      "requires": {
+        "type-fest": "^0.20.2"
+      },
+      "dependencies": {
+        "type-fest": {
+          "version": "0.20.2",
+          "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
+          "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
+          "dev": true
+        }
+      }
+    },
+    "graceful-fs": {
+      "version": "4.2.4",
+      "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.4.tgz",
+      "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==",
+      "dev": true
+    },
+    "growl": {
+      "version": "1.10.5",
+      "resolved": "https://registry.npmjs.org/growl/-/growl-1.10.5.tgz",
+      "integrity": "sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA==",
+      "dev": true
+    },
+    "has-flag": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
+      "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=",
+      "dev": true
+    },
+    "hasha": {
+      "version": "5.2.0",
+      "resolved": "https://registry.npmjs.org/hasha/-/hasha-5.2.0.tgz",
+      "integrity": "sha512-2W+jKdQbAdSIrggA8Q35Br8qKadTrqCTC8+XZvBWepKDK6m9XkX6Iz1a2yh2KP01kzAR/dpuMeUnocoLYDcskw==",
+      "dev": true,
+      "requires": {
+        "is-stream": "^2.0.0",
+        "type-fest": "^0.8.0"
+      }
+    },
+    "he": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
+      "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
+      "dev": true
+    },
+    "html-escaper": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
+      "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
+      "dev": true
+    },
+    "ignore": {
+      "version": "4.0.6",
+      "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz",
+      "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==",
+      "dev": true
+    },
+    "import-fresh": {
+      "version": "3.3.0",
+      "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
+      "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
+      "dev": true,
+      "requires": {
+        "parent-module": "^1.0.0",
+        "resolve-from": "^4.0.0"
+      }
+    },
+    "imurmurhash": {
+      "version": "0.1.4",
+      "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+      "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=",
+      "dev": true
+    },
+    "indent-string": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+      "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+      "dev": true
+    },
+    "inflight": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+      "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
+      "dev": true,
+      "requires": {
+        "once": "^1.3.0",
+        "wrappy": "1"
+      }
+    },
+    "inherits": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+      "dev": true
+    },
+    "is-binary-path": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+      "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+      "dev": true,
+      "requires": {
+        "binary-extensions": "^2.0.0"
+      }
+    },
+    "is-extglob": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+      "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=",
+      "dev": true
+    },
+    "is-fullwidth-code-point": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+      "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+      "dev": true
+    },
+    "is-glob": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz",
+      "integrity": "sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==",
+      "dev": true,
+      "requires": {
+        "is-extglob": "^2.1.1"
+      }
+    },
+    "is-number": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+      "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+      "dev": true
+    },
+    "is-plain-obj": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz",
+      "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==",
+      "dev": true
+    },
+    "is-stream": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.0.tgz",
+      "integrity": "sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw==",
+      "dev": true
+    },
+    "is-typedarray": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
+      "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=",
+      "dev": true
+    },
+    "is-unicode-supported": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz",
+      "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==",
+      "dev": true
+    },
+    "is-windows": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz",
+      "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==",
+      "dev": true
+    },
+    "isarray": {
+      "version": "0.0.1",
+      "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
+      "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=",
+      "dev": true
+    },
+    "isexe": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+      "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=",
+      "dev": true
+    },
+    "istanbul-lib-coverage": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.0.0.tgz",
+      "integrity": "sha512-UiUIqxMgRDET6eR+o5HbfRYP1l0hqkWOs7vNxC/mggutCMUIhWMm8gAHb8tHlyfD3/l6rlgNA5cKdDzEAf6hEg==",
+      "dev": true
+    },
+    "istanbul-lib-hook": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/istanbul-lib-hook/-/istanbul-lib-hook-3.0.0.tgz",
+      "integrity": "sha512-Pt/uge1Q9s+5VAZ+pCo16TYMWPBIl+oaNIjgLQxcX0itS6ueeaA+pEfThZpH8WxhFgCiEb8sAJY6MdUKgiIWaQ==",
+      "dev": true,
+      "requires": {
+        "append-transform": "^2.0.0"
+      }
+    },
+    "istanbul-lib-instrument": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-4.0.3.tgz",
+      "integrity": "sha512-BXgQl9kf4WTCPCCpmFGoJkz/+uhvm7h7PFKUYxh7qarQd3ER33vHG//qaE8eN25l07YqZPpHXU9I09l/RD5aGQ==",
+      "dev": true,
+      "requires": {
+        "@babel/core": "^7.7.5",
+        "@istanbuljs/schema": "^0.1.2",
+        "istanbul-lib-coverage": "^3.0.0",
+        "semver": "^6.3.0"
+      },
+      "dependencies": {
+        "semver": {
+          "version": "6.3.0",
+          "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
+          "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+          "dev": true
+        }
+      }
+    },
+    "istanbul-lib-processinfo": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/istanbul-lib-processinfo/-/istanbul-lib-processinfo-2.0.2.tgz",
+      "integrity": "sha512-kOwpa7z9hme+IBPZMzQ5vdQj8srYgAtaRqeI48NGmAQ+/5yKiHLV0QbYqQpxsdEF0+w14SoB8YbnHKcXE2KnYw==",
+      "dev": true,
+      "requires": {
+        "archy": "^1.0.0",
+        "cross-spawn": "^7.0.0",
+        "istanbul-lib-coverage": "^3.0.0-alpha.1",
+        "make-dir": "^3.0.0",
+        "p-map": "^3.0.0",
+        "rimraf": "^3.0.0",
+        "uuid": "^3.3.3"
+      },
+      "dependencies": {
+        "rimraf": {
+          "version": "3.0.2",
+          "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
+          "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+          "dev": true,
+          "requires": {
+            "glob": "^7.1.3"
+          }
+        },
+        "uuid": {
+          "version": "3.4.0",
+          "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz",
+          "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==",
+          "dev": true
+        }
+      }
+    },
+    "istanbul-lib-report": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz",
+      "integrity": "sha512-wcdi+uAKzfiGT2abPpKZ0hSU1rGQjUQnLvtY5MpQ7QCTahD3VODhcu4wcfY1YtkGaDD5yuydOLINXsfbus9ROw==",
+      "dev": true,
+      "requires": {
+        "istanbul-lib-coverage": "^3.0.0",
+        "make-dir": "^3.0.0",
+        "supports-color": "^7.1.0"
+      },
+      "dependencies": {
+        "has-flag": {
+          "version": "4.0.0",
+          "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+          "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+          "dev": true
+        },
+        "supports-color": {
+          "version": "7.2.0",
+          "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+          "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+          "dev": true,
+          "requires": {
+            "has-flag": "^4.0.0"
+          }
+        }
+      }
+    },
+    "istanbul-lib-source-maps": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.0.tgz",
+      "integrity": "sha512-c16LpFRkR8vQXyHZ5nLpY35JZtzj1PQY1iZmesUbf1FZHbIupcWfjgOXBY9YHkLEQ6puz1u4Dgj6qmU/DisrZg==",
+      "dev": true,
+      "requires": {
+        "debug": "^4.1.1",
+        "istanbul-lib-coverage": "^3.0.0",
+        "source-map": "^0.6.1"
+      },
+      "dependencies": {
+        "source-map": {
+          "version": "0.6.1",
+          "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+          "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+          "dev": true
+        }
+      }
+    },
+    "istanbul-reports": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.0.2.tgz",
+      "integrity": "sha512-9tZvz7AiR3PEDNGiV9vIouQ/EAcqMXFmkcA1CDFTwOB98OZVDL0PH9glHotf5Ugp6GCOTypfzGWI/OqjWNCRUw==",
+      "dev": true,
+      "requires": {
+        "html-escaper": "^2.0.0",
+        "istanbul-lib-report": "^3.0.0"
+      }
+    },
+    "js-tokens": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+      "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+      "dev": true
+    },
+    "js-yaml": {
+      "version": "3.14.0",
+      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.0.tgz",
+      "integrity": "sha512-/4IbIeHcD9VMHFqDR/gQ7EdZdLimOvW2DdcxFjdyyZ9NsbS+ccrXqVWDtab/lRl5AlUqmpBx8EhPaWR+OtY17A==",
+      "dev": true,
+      "requires": {
+        "argparse": "^1.0.7",
+        "esprima": "^4.0.0"
+      }
+    },
+    "jsesc": {
+      "version": "2.5.2",
+      "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz",
+      "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==",
+      "dev": true
+    },
+    "json-schema-traverse": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+      "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+      "dev": true
+    },
+    "json-stable-stringify-without-jsonify": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+      "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=",
+      "dev": true
+    },
+    "json5": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/json5/-/json5-2.1.3.tgz",
+      "integrity": "sha512-KXPvOm8K9IJKFM0bmdn8QXh7udDh1g/giieX0NLCaMnb4hEiVFqnop2ImTXCc5e0/oHz3LTqmHGtExn5hfMkOA==",
+      "dev": true,
+      "requires": {
+        "minimist": "^1.2.5"
+      }
+    },
+    "just-extend": {
+      "version": "4.2.1",
+      "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-4.2.1.tgz",
+      "integrity": "sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==",
+      "dev": true
+    },
+    "levn": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+      "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+      "dev": true,
+      "requires": {
+        "prelude-ls": "^1.2.1",
+        "type-check": "~0.4.0"
+      }
+    },
+    "locate-path": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+      "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+      "dev": true,
+      "requires": {
+        "p-locate": "^5.0.0"
+      }
+    },
+    "lodash": {
+      "version": "4.17.21",
+      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+      "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+      "dev": true
+    },
+    "lodash.clonedeep": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
+      "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=",
+      "dev": true
+    },
+    "lodash.flattendeep": {
+      "version": "4.4.0",
+      "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz",
+      "integrity": "sha1-+wMJF/hqMTTlvJvsDWngAT3f7bI=",
+      "dev": true
+    },
+    "lodash.get": {
+      "version": "4.4.2",
+      "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
+      "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=",
+      "dev": true
+    },
+    "lodash.merge": {
+      "version": "4.6.2",
+      "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
+      "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
+      "dev": true
+    },
+    "lodash.truncate": {
+      "version": "4.4.2",
+      "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz",
+      "integrity": "sha1-WjUNoLERO4N+z//VgSy+WNbq4ZM=",
+      "dev": true
+    },
+    "log-symbols": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz",
+      "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==",
+      "dev": true,
+      "requires": {
+        "chalk": "^4.1.0",
+        "is-unicode-supported": "^0.1.0"
+      }
+    },
+    "lru-cache": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+      "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+      "dev": true,
+      "requires": {
+        "yallist": "^4.0.0"
+      }
+    },
+    "make-dir": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
+      "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
+      "dev": true,
+      "requires": {
+        "semver": "^6.0.0"
+      },
+      "dependencies": {
+        "semver": {
+          "version": "6.3.0",
+          "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
+          "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+          "dev": true
+        }
+      }
+    },
+    "mime-db": {
+      "version": "1.49.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.49.0.tgz",
+      "integrity": "sha512-CIc8j9URtOVApSFCQIF+VBkX1RwXp/oMMOrqdyXSBXq5RWNEsRfyj1kiRnQgmNXmHxPoFIxOroKA3zcU9P+nAA=="
+    },
+    "minimatch": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
+      "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
+      "dev": true,
+      "requires": {
+        "brace-expansion": "^1.1.7"
+      }
+    },
+    "minimist": {
+      "version": "1.2.5",
+      "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
+      "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==",
+      "dev": true
+    },
+    "mocha": {
+      "version": "9.0.3",
+      "resolved": "https://registry.npmjs.org/mocha/-/mocha-9.0.3.tgz",
+      "integrity": "sha512-hnYFrSefHxYS2XFGtN01x8un0EwNu2bzKvhpRFhgoybIvMaOkkL60IVPmkb5h6XDmUl4IMSB+rT5cIO4/4bJgg==",
+      "dev": true,
+      "requires": {
+        "@ungap/promise-all-settled": "1.1.2",
+        "ansi-colors": "4.1.1",
+        "browser-stdout": "1.3.1",
+        "chokidar": "3.5.2",
+        "debug": "4.3.1",
+        "diff": "5.0.0",
+        "escape-string-regexp": "4.0.0",
+        "find-up": "5.0.0",
+        "glob": "7.1.7",
+        "growl": "1.10.5",
+        "he": "1.2.0",
+        "js-yaml": "4.1.0",
+        "log-symbols": "4.1.0",
+        "minimatch": "3.0.4",
+        "ms": "2.1.3",
+        "nanoid": "3.1.23",
+        "serialize-javascript": "6.0.0",
+        "strip-json-comments": "3.1.1",
+        "supports-color": "8.1.1",
+        "which": "2.0.2",
+        "wide-align": "1.1.3",
+        "workerpool": "6.1.5",
+        "yargs": "16.2.0",
+        "yargs-parser": "20.2.4",
+        "yargs-unparser": "2.0.0"
+      },
+      "dependencies": {
+        "argparse": {
+          "version": "2.0.1",
+          "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+          "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+          "dev": true
+        },
+        "debug": {
+          "version": "4.3.1",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz",
+          "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==",
+          "dev": true,
+          "requires": {
+            "ms": "2.1.2"
+          },
+          "dependencies": {
+            "ms": {
+              "version": "2.1.2",
+              "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+              "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
+              "dev": true
+            }
+          }
+        },
+        "escape-string-regexp": {
+          "version": "4.0.0",
+          "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+          "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+          "dev": true
+        },
+        "glob": {
+          "version": "7.1.7",
+          "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz",
+          "integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==",
+          "dev": true,
+          "requires": {
+            "fs.realpath": "^1.0.0",
+            "inflight": "^1.0.4",
+            "inherits": "2",
+            "minimatch": "^3.0.4",
+            "once": "^1.3.0",
+            "path-is-absolute": "^1.0.0"
+          }
+        },
+        "has-flag": {
+          "version": "4.0.0",
+          "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+          "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+          "dev": true
+        },
+        "js-yaml": {
+          "version": "4.1.0",
+          "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
+          "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
+          "dev": true,
+          "requires": {
+            "argparse": "^2.0.1"
+          }
+        },
+        "ms": {
+          "version": "2.1.3",
+          "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+          "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+          "dev": true
+        },
+        "supports-color": {
+          "version": "8.1.1",
+          "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
+          "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
+          "dev": true,
+          "requires": {
+            "has-flag": "^4.0.0"
+          }
+        }
+      }
+    },
+    "ms": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+      "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
+      "dev": true
+    },
+    "nanoid": {
+      "version": "3.1.23",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.23.tgz",
+      "integrity": "sha512-FiB0kzdP0FFVGDKlRLEQ1BgDzU87dy5NnzjeW9YZNt+/c3+q82EQDUwniSAUxp/F0gFNI1ZhKU1FqYsMuqZVnw==",
+      "dev": true
+    },
+    "natural-compare": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+      "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=",
+      "dev": true
+    },
+    "nise": {
+      "version": "5.1.0",
+      "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.0.tgz",
+      "integrity": "sha512-W5WlHu+wvo3PaKLsJJkgPup2LrsXCcm7AWwyNZkUnn5rwPkuPBi3Iwk5SQtN0mv+K65k7nKKjwNQ30wg3wLAQQ==",
+      "dev": true,
+      "requires": {
+        "@sinonjs/commons": "^1.7.0",
+        "@sinonjs/fake-timers": "^7.0.4",
+        "@sinonjs/text-encoding": "^0.7.1",
+        "just-extend": "^4.0.2",
+        "path-to-regexp": "^1.7.0"
+      }
+    },
+    "node-preload": {
+      "version": "0.2.1",
+      "resolved": "https://registry.npmjs.org/node-preload/-/node-preload-0.2.1.tgz",
+      "integrity": "sha512-RM5oyBy45cLEoHqCeh+MNuFAxO0vTFBLskvQbOKnEE7YTTSN4tbN8QWDIPQ6L+WvKsB/qLEGpYe2ZZ9d4W9OIQ==",
+      "dev": true,
+      "requires": {
+        "process-on-spawn": "^1.0.0"
+      }
+    },
+    "normalize-path": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+      "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+      "dev": true
+    },
+    "nyc": {
+      "version": "15.1.0",
+      "resolved": "https://registry.npmjs.org/nyc/-/nyc-15.1.0.tgz",
+      "integrity": "sha512-jMW04n9SxKdKi1ZMGhvUTHBN0EICCRkHemEoE5jm6mTYcqcdas0ATzgUgejlQUHMvpnOZqGB5Xxsv9KxJW1j8A==",
+      "dev": true,
+      "requires": {
+        "@istanbuljs/load-nyc-config": "^1.0.0",
+        "@istanbuljs/schema": "^0.1.2",
+        "caching-transform": "^4.0.0",
+        "convert-source-map": "^1.7.0",
+        "decamelize": "^1.2.0",
+        "find-cache-dir": "^3.2.0",
+        "find-up": "^4.1.0",
+        "foreground-child": "^2.0.0",
+        "get-package-type": "^0.1.0",
+        "glob": "^7.1.6",
+        "istanbul-lib-coverage": "^3.0.0",
+        "istanbul-lib-hook": "^3.0.0",
+        "istanbul-lib-instrument": "^4.0.0",
+        "istanbul-lib-processinfo": "^2.0.2",
+        "istanbul-lib-report": "^3.0.0",
+        "istanbul-lib-source-maps": "^4.0.0",
+        "istanbul-reports": "^3.0.2",
+        "make-dir": "^3.0.0",
+        "node-preload": "^0.2.1",
+        "p-map": "^3.0.0",
+        "process-on-spawn": "^1.0.0",
+        "resolve-from": "^5.0.0",
+        "rimraf": "^3.0.0",
+        "signal-exit": "^3.0.2",
+        "spawn-wrap": "^2.0.0",
+        "test-exclude": "^6.0.0",
+        "yargs": "^15.0.2"
+      },
+      "dependencies": {
+        "ansi-styles": {
+          "version": "4.2.1",
+          "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.1.tgz",
+          "integrity": "sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA==",
+          "dev": true,
+          "requires": {
+            "@types/color-name": "^1.1.1",
+            "color-convert": "^2.0.1"
+          }
+        },
+        "cliui": {
+          "version": "6.0.0",
+          "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
+          "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
+          "dev": true,
+          "requires": {
+            "string-width": "^4.2.0",
+            "strip-ansi": "^6.0.0",
+            "wrap-ansi": "^6.2.0"
+          }
+        },
+        "color-convert": {
+          "version": "2.0.1",
+          "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+          "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+          "dev": true,
+          "requires": {
+            "color-name": "~1.1.4"
+          }
+        },
+        "color-name": {
+          "version": "1.1.4",
+          "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+          "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+          "dev": true
+        },
+        "emoji-regex": {
+          "version": "8.0.0",
+          "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+          "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+          "dev": true
+        },
+        "find-up": {
+          "version": "4.1.0",
+          "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+          "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+          "dev": true,
+          "requires": {
+            "locate-path": "^5.0.0",
+            "path-exists": "^4.0.0"
+          }
+        },
+        "is-fullwidth-code-point": {
+          "version": "3.0.0",
+          "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+          "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+          "dev": true
+        },
+        "locate-path": {
+          "version": "5.0.0",
+          "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+          "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+          "dev": true,
+          "requires": {
+            "p-locate": "^4.1.0"
+          }
+        },
+        "p-limit": {
+          "version": "2.3.0",
+          "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+          "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+          "dev": true,
+          "requires": {
+            "p-try": "^2.0.0"
+          }
+        },
+        "p-locate": {
+          "version": "4.1.0",
+          "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+          "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+          "dev": true,
+          "requires": {
+            "p-limit": "^2.2.0"
+          }
+        },
+        "resolve-from": {
+          "version": "5.0.0",
+          "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
+          "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
+          "dev": true
+        },
+        "rimraf": {
+          "version": "3.0.2",
+          "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
+          "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+          "dev": true,
+          "requires": {
+            "glob": "^7.1.3"
+          }
+        },
+        "string-width": {
+          "version": "4.2.0",
+          "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz",
+          "integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==",
+          "dev": true,
+          "requires": {
+            "emoji-regex": "^8.0.0",
+            "is-fullwidth-code-point": "^3.0.0",
+            "strip-ansi": "^6.0.0"
+          }
+        },
+        "wrap-ansi": {
+          "version": "6.2.0",
+          "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
+          "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
+          "dev": true,
+          "requires": {
+            "ansi-styles": "^4.0.0",
+            "string-width": "^4.1.0",
+            "strip-ansi": "^6.0.0"
+          }
+        },
+        "yargs": {
+          "version": "15.4.1",
+          "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
+          "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
+          "dev": true,
+          "requires": {
+            "cliui": "^6.0.0",
+            "decamelize": "^1.2.0",
+            "find-up": "^4.1.0",
+            "get-caller-file": "^2.0.1",
+            "require-directory": "^2.1.1",
+            "require-main-filename": "^2.0.0",
+            "set-blocking": "^2.0.0",
+            "string-width": "^4.2.0",
+            "which-module": "^2.0.0",
+            "y18n": "^4.0.0",
+            "yargs-parser": "^18.1.2"
+          }
+        },
+        "yargs-parser": {
+          "version": "18.1.3",
+          "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
+          "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
+          "dev": true,
+          "requires": {
+            "camelcase": "^5.0.0",
+            "decamelize": "^1.2.0"
+          }
+        }
+      }
+    },
+    "once": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+      "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
+      "dev": true,
+      "requires": {
+        "wrappy": "1"
+      }
+    },
+    "optionator": {
+      "version": "0.9.1",
+      "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz",
+      "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==",
+      "dev": true,
+      "requires": {
+        "deep-is": "^0.1.3",
+        "fast-levenshtein": "^2.0.6",
+        "levn": "^0.4.1",
+        "prelude-ls": "^1.2.1",
+        "type-check": "^0.4.0",
+        "word-wrap": "^1.2.3"
+      }
+    },
+    "os-shim": {
+      "version": "0.1.3",
+      "resolved": "https://registry.npmjs.org/os-shim/-/os-shim-0.1.3.tgz",
+      "integrity": "sha1-a2LDeRz3kJ6jXtRuF2WLtBfLORc=",
+      "dev": true
+    },
+    "p-limit": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+      "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+      "dev": true,
+      "requires": {
+        "yocto-queue": "^0.1.0"
+      }
+    },
+    "p-locate": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+      "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+      "dev": true,
+      "requires": {
+        "p-limit": "^3.0.2"
+      }
+    },
+    "p-map": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz",
+      "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==",
+      "dev": true,
+      "requires": {
+        "aggregate-error": "^3.0.0"
+      }
+    },
+    "p-try": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
+      "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
+      "dev": true
+    },
+    "package-hash": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/package-hash/-/package-hash-4.0.0.tgz",
+      "integrity": "sha512-whdkPIooSu/bASggZ96BWVvZTRMOFxnyUG5PnTSGKoJE2gd5mbVNmR2Nj20QFzxYYgAXpoqC+AiXzl+UMRh7zQ==",
+      "dev": true,
+      "requires": {
+        "graceful-fs": "^4.1.15",
+        "hasha": "^5.0.0",
+        "lodash.flattendeep": "^4.4.0",
+        "release-zalgo": "^1.0.0"
+      }
+    },
+    "parent-module": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+      "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+      "dev": true,
+      "requires": {
+        "callsites": "^3.0.0"
+      }
+    },
+    "path-exists": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+      "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+      "dev": true
+    },
+    "path-is-absolute": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+      "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=",
+      "dev": true
+    },
+    "path-key": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+      "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+      "dev": true
+    },
+    "path-parse": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz",
+      "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==",
+      "dev": true
+    },
+    "path-to-regexp": {
+      "version": "1.8.0",
+      "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz",
+      "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==",
+      "dev": true,
+      "requires": {
+        "isarray": "0.0.1"
+      }
+    },
+    "picomatch": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.0.tgz",
+      "integrity": "sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw==",
+      "dev": true
+    },
+    "pkg-dir": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz",
+      "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==",
+      "dev": true,
+      "requires": {
+        "find-up": "^4.0.0"
+      },
+      "dependencies": {
+        "find-up": {
+          "version": "4.1.0",
+          "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+          "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+          "dev": true,
+          "requires": {
+            "locate-path": "^5.0.0",
+            "path-exists": "^4.0.0"
+          }
+        },
+        "locate-path": {
+          "version": "5.0.0",
+          "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+          "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+          "dev": true,
+          "requires": {
+            "p-locate": "^4.1.0"
+          }
+        },
+        "p-limit": {
+          "version": "2.3.0",
+          "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+          "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+          "dev": true,
+          "requires": {
+            "p-try": "^2.0.0"
+          }
+        },
+        "p-locate": {
+          "version": "4.1.0",
+          "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+          "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+          "dev": true,
+          "requires": {
+            "p-limit": "^2.2.0"
+          }
+        }
+      }
+    },
+    "pre-commit": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/pre-commit/-/pre-commit-1.2.2.tgz",
+      "integrity": "sha1-287g7p3nI15X95xW186UZBpp7sY=",
+      "dev": true,
+      "requires": {
+        "cross-spawn": "^5.0.1",
+        "spawn-sync": "^1.0.15",
+        "which": "1.2.x"
+      },
+      "dependencies": {
+        "cross-spawn": {
+          "version": "5.1.0",
+          "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz",
+          "integrity": "sha1-6L0O/uWPz/b4+UUQoKVUu/ojVEk=",
+          "dev": true,
+          "requires": {
+            "lru-cache": "^4.0.1",
+            "shebang-command": "^1.2.0",
+            "which": "^1.2.9"
+          }
+        },
+        "lru-cache": {
+          "version": "4.1.5",
+          "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz",
+          "integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==",
+          "dev": true,
+          "requires": {
+            "pseudomap": "^1.0.2",
+            "yallist": "^2.1.2"
+          }
+        },
+        "shebang-command": {
+          "version": "1.2.0",
+          "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz",
+          "integrity": "sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=",
+          "dev": true,
+          "requires": {
+            "shebang-regex": "^1.0.0"
+          }
+        },
+        "shebang-regex": {
+          "version": "1.0.0",
+          "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz",
+          "integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=",
+          "dev": true
+        },
+        "which": {
+          "version": "1.2.14",
+          "resolved": "https://registry.npmjs.org/which/-/which-1.2.14.tgz",
+          "integrity": "sha1-mofEN48D6CfOyvGs31bHNsAcFOU=",
+          "dev": true,
+          "requires": {
+            "isexe": "^2.0.0"
+          }
+        },
+        "yallist": {
+          "version": "2.1.2",
+          "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz",
+          "integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=",
+          "dev": true
+        }
+      }
+    },
+    "prelude-ls": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+      "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+      "dev": true
+    },
+    "process-nextick-args": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+      "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
+      "dev": true
+    },
+    "process-on-spawn": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/process-on-spawn/-/process-on-spawn-1.0.0.tgz",
+      "integrity": "sha512-1WsPDsUSMmZH5LeMLegqkPDrsGgsWwk1Exipy2hvB0o/F0ASzbpIctSCcZIK1ykJvtTJULEH+20WOFjMvGnCTg==",
+      "dev": true,
+      "requires": {
+        "fromentries": "^1.2.0"
+      }
+    },
+    "progress": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
+      "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
+      "dev": true
+    },
+    "pseudomap": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz",
+      "integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM=",
+      "dev": true
+    },
+    "punycode": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
+      "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==",
+      "dev": true
+    },
+    "randombytes": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
+      "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
+      "dev": true,
+      "requires": {
+        "safe-buffer": "^5.1.0"
+      }
+    },
+    "readable-stream": {
+      "version": "2.3.7",
+      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
+      "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
+      "dev": true,
+      "requires": {
+        "core-util-is": "~1.0.0",
+        "inherits": "~2.0.3",
+        "isarray": "~1.0.0",
+        "process-nextick-args": "~2.0.0",
+        "safe-buffer": "~5.1.1",
+        "string_decoder": "~1.1.1",
+        "util-deprecate": "~1.0.1"
+      },
+      "dependencies": {
+        "isarray": {
+          "version": "1.0.0",
+          "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+          "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=",
+          "dev": true
+        },
+        "safe-buffer": {
+          "version": "5.1.2",
+          "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+          "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+          "dev": true
+        }
+      }
+    },
+    "readdirp": {
+      "version": "3.6.0",
+      "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+      "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+      "dev": true,
+      "requires": {
+        "picomatch": "^2.2.1"
+      }
+    },
+    "regexpp": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.1.0.tgz",
+      "integrity": "sha512-ZOIzd8yVsQQA7j8GCSlPGXwg5PfmA1mrq0JP4nGhh54LaKN3xdai/vHUDu74pKwV8OxseMS65u2NImosQcSD0Q==",
+      "dev": true
+    },
+    "release-zalgo": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/release-zalgo/-/release-zalgo-1.0.0.tgz",
+      "integrity": "sha1-CXALflB0Mpc5Mw5TXFqQ+2eFFzA=",
+      "dev": true,
+      "requires": {
+        "es6-error": "^4.0.1"
+      }
+    },
+    "require-directory": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+      "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=",
+      "dev": true
+    },
+    "require-from-string": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+      "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+      "dev": true
+    },
+    "require-main-filename": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
+      "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
+      "dev": true
+    },
+    "resolve": {
+      "version": "1.17.0",
+      "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.17.0.tgz",
+      "integrity": "sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w==",
+      "dev": true,
+      "requires": {
+        "path-parse": "^1.0.6"
+      }
+    },
+    "resolve-from": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+      "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+      "dev": true
+    },
+    "ret": {
+      "version": "0.1.15",
+      "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz",
+      "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==",
+      "dev": true
+    },
+    "rimraf": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
+      "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+      "dev": true,
+      "requires": {
+        "glob": "^7.1.3"
+      }
+    },
+    "safe-buffer": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+      "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+      "dev": true
+    },
+    "safe-regex": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz",
+      "integrity": "sha1-QKNmnzsHfR6UPURinhV91IAjvy4=",
+      "dev": true,
+      "requires": {
+        "ret": "~0.1.10"
+      }
+    },
+    "semver": {
+      "version": "7.3.5",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz",
+      "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==",
+      "dev": true,
+      "requires": {
+        "lru-cache": "^6.0.0"
+      }
+    },
+    "serialize-javascript": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz",
+      "integrity": "sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==",
+      "dev": true,
+      "requires": {
+        "randombytes": "^2.1.0"
+      }
+    },
+    "set-blocking": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
+      "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=",
+      "dev": true
+    },
+    "shebang-command": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+      "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+      "dev": true,
+      "requires": {
+        "shebang-regex": "^3.0.0"
+      }
+    },
+    "shebang-regex": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+      "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+      "dev": true
+    },
+    "signal-exit": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz",
+      "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==",
+      "dev": true
+    },
+    "sinon": {
+      "version": "11.1.2",
+      "resolved": "https://registry.npmjs.org/sinon/-/sinon-11.1.2.tgz",
+      "integrity": "sha512-59237HChms4kg7/sXhiRcUzdSkKuydDeTiamT/jesUVHshBgL8XAmhgFo0GfK6RruMDM/iRSij1EybmMog9cJw==",
+      "dev": true,
+      "requires": {
+        "@sinonjs/commons": "^1.8.3",
+        "@sinonjs/fake-timers": "^7.1.2",
+        "@sinonjs/samsam": "^6.0.2",
+        "diff": "^5.0.0",
+        "nise": "^5.1.0",
+        "supports-color": "^7.2.0"
+      },
+      "dependencies": {
+        "has-flag": {
+          "version": "4.0.0",
+          "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+          "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+          "dev": true
+        },
+        "supports-color": {
+          "version": "7.2.0",
+          "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+          "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+          "dev": true,
+          "requires": {
+            "has-flag": "^4.0.0"
+          }
+        }
+      }
+    },
+    "slice-ansi": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz",
+      "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==",
+      "dev": true,
+      "requires": {
+        "ansi-styles": "^4.0.0",
+        "astral-regex": "^2.0.0",
+        "is-fullwidth-code-point": "^3.0.0"
+      },
+      "dependencies": {
+        "ansi-styles": {
+          "version": "4.3.0",
+          "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+          "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+          "dev": true,
+          "requires": {
+            "color-convert": "^2.0.1"
+          }
+        },
+        "color-convert": {
+          "version": "2.0.1",
+          "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+          "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+          "dev": true,
+          "requires": {
+            "color-name": "~1.1.4"
+          }
+        },
+        "color-name": {
+          "version": "1.1.4",
+          "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+          "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+          "dev": true
+        }
+      }
+    },
+    "source-map": {
+      "version": "0.5.7",
+      "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+      "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=",
+      "dev": true
+    },
+    "spawn-sync": {
+      "version": "1.0.15",
+      "resolved": "https://registry.npmjs.org/spawn-sync/-/spawn-sync-1.0.15.tgz",
+      "integrity": "sha1-sAeZVX63+wyDdsKdROih6mfldHY=",
+      "dev": true,
+      "requires": {
+        "concat-stream": "^1.4.7",
+        "os-shim": "^0.1.2"
+      }
+    },
+    "spawn-wrap": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/spawn-wrap/-/spawn-wrap-2.0.0.tgz",
+      "integrity": "sha512-EeajNjfN9zMnULLwhZZQU3GWBoFNkbngTUPfaawT4RkMiviTxcX0qfhVbGey39mfctfDHkWtuecgQ8NJcyQWHg==",
+      "dev": true,
+      "requires": {
+        "foreground-child": "^2.0.0",
+        "is-windows": "^1.0.2",
+        "make-dir": "^3.0.0",
+        "rimraf": "^3.0.0",
+        "signal-exit": "^3.0.2",
+        "which": "^2.0.1"
+      },
+      "dependencies": {
+        "rimraf": {
+          "version": "3.0.2",
+          "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
+          "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+          "dev": true,
+          "requires": {
+            "glob": "^7.1.3"
+          }
+        }
+      }
+    },
+    "sprintf-js": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
+      "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=",
+      "dev": true
+    },
+    "string-width": {
+      "version": "4.2.2",
+      "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.2.tgz",
+      "integrity": "sha512-XBJbT3N4JhVumXE0eoLU9DCjcaF92KLNqTmFCnG1pf8duUxFGwtP6AD6nkjw9a3IdiRtL3E2w3JDiE/xi3vOeA==",
+      "dev": true,
+      "requires": {
+        "emoji-regex": "^8.0.0",
+        "is-fullwidth-code-point": "^3.0.0",
+        "strip-ansi": "^6.0.0"
+      }
+    },
+    "string_decoder": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+      "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+      "dev": true,
+      "requires": {
+        "safe-buffer": "~5.1.0"
+      },
+      "dependencies": {
+        "safe-buffer": {
+          "version": "5.1.2",
+          "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+          "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+          "dev": true
+        }
+      }
+    },
+    "strip-ansi": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz",
+      "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==",
+      "dev": true,
+      "requires": {
+        "ansi-regex": "^5.0.0"
+      }
+    },
+    "strip-bom": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz",
+      "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==",
+      "dev": true
+    },
+    "strip-json-comments": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+      "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
+      "dev": true
+    },
+    "supports-color": {
+      "version": "5.5.0",
+      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
+      "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
+      "dev": true,
+      "requires": {
+        "has-flag": "^3.0.0"
+      }
+    },
+    "table": {
+      "version": "6.7.1",
+      "resolved": "https://registry.npmjs.org/table/-/table-6.7.1.tgz",
+      "integrity": "sha512-ZGum47Yi6KOOFDE8m223td53ath2enHcYLgOCjGr5ngu8bdIARQk6mN/wRMv4yMRcHnCSnHbCEha4sobQx5yWg==",
+      "dev": true,
+      "requires": {
+        "ajv": "^8.0.1",
+        "lodash.clonedeep": "^4.5.0",
+        "lodash.truncate": "^4.4.2",
+        "slice-ansi": "^4.0.0",
+        "string-width": "^4.2.0",
+        "strip-ansi": "^6.0.0"
+      },
+      "dependencies": {
+        "ajv": {
+          "version": "8.6.2",
+          "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.6.2.tgz",
+          "integrity": "sha512-9807RlWAgT564wT+DjeyU5OFMPjmzxVobvDFmNAhY+5zD6A2ly3jDp6sgnfyDtlIQ+7H97oc/DGCzzfu9rjw9w==",
+          "dev": true,
+          "requires": {
+            "fast-deep-equal": "^3.1.1",
+            "json-schema-traverse": "^1.0.0",
+            "require-from-string": "^2.0.2",
+            "uri-js": "^4.2.2"
+          }
+        },
+        "json-schema-traverse": {
+          "version": "1.0.0",
+          "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+          "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+          "dev": true
+        }
+      }
+    },
+    "test-exclude": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
+      "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==",
+      "dev": true,
+      "requires": {
+        "@istanbuljs/schema": "^0.1.2",
+        "glob": "^7.1.4",
+        "minimatch": "^3.0.4"
+      }
+    },
+    "text-table": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
+      "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=",
+      "dev": true
+    },
+    "to-fast-properties": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
+      "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=",
+      "dev": true
+    },
+    "to-regex-range": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+      "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+      "dev": true,
+      "requires": {
+        "is-number": "^7.0.0"
+      }
+    },
+    "type-check": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+      "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+      "dev": true,
+      "requires": {
+        "prelude-ls": "^1.2.1"
+      }
+    },
+    "type-detect": {
+      "version": "4.0.8",
+      "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",
+      "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==",
+      "dev": true
+    },
+    "type-fest": {
+      "version": "0.8.1",
+      "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz",
+      "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==",
+      "dev": true
+    },
+    "typedarray": {
+      "version": "0.0.6",
+      "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
+      "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=",
+      "dev": true
+    },
+    "typedarray-to-buffer": {
+      "version": "3.1.5",
+      "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz",
+      "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==",
+      "dev": true,
+      "requires": {
+        "is-typedarray": "^1.0.0"
+      }
+    },
+    "uri-js": {
+      "version": "4.4.1",
+      "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+      "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+      "dev": true,
+      "requires": {
+        "punycode": "^2.1.0"
+      }
+    },
+    "util-deprecate": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+      "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=",
+      "dev": true
+    },
+    "uuid": {
+      "version": "8.3.2",
+      "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
+      "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="
+    },
+    "v8-compile-cache": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz",
+      "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==",
+      "dev": true
+    },
+    "which": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+      "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+      "dev": true,
+      "requires": {
+        "isexe": "^2.0.0"
+      }
+    },
+    "which-module": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz",
+      "integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=",
+      "dev": true
+    },
+    "wide-align": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.3.tgz",
+      "integrity": "sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==",
+      "dev": true,
+      "requires": {
+        "string-width": "^1.0.2 || 2"
+      },
+      "dependencies": {
+        "ansi-regex": {
+          "version": "3.0.0",
+          "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz",
+          "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=",
+          "dev": true
+        },
+        "is-fullwidth-code-point": {
+          "version": "2.0.0",
+          "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz",
+          "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=",
+          "dev": true
+        },
+        "string-width": {
+          "version": "2.1.1",
+          "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz",
+          "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==",
+          "dev": true,
+          "requires": {
+            "is-fullwidth-code-point": "^2.0.0",
+            "strip-ansi": "^4.0.0"
+          }
+        },
+        "strip-ansi": {
+          "version": "4.0.0",
+          "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz",
+          "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=",
+          "dev": true,
+          "requires": {
+            "ansi-regex": "^3.0.0"
+          }
+        }
+      }
+    },
+    "word-wrap": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
+      "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==",
+      "dev": true
+    },
+    "workerpool": {
+      "version": "6.1.5",
+      "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.1.5.tgz",
+      "integrity": "sha512-XdKkCK0Zqc6w3iTxLckiuJ81tiD/o5rBE/m+nXpRCB+/Sq4DqkfXZ/x0jW02DG1tGsfUGXbTJyZDP+eu67haSw==",
+      "dev": true
+    },
+    "wrap-ansi": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+      "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+      "dev": true,
+      "requires": {
+        "ansi-styles": "^4.0.0",
+        "string-width": "^4.1.0",
+        "strip-ansi": "^6.0.0"
+      },
+      "dependencies": {
+        "ansi-styles": {
+          "version": "4.3.0",
+          "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+          "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+          "dev": true,
+          "requires": {
+            "color-convert": "^2.0.1"
+          }
+        },
+        "color-convert": {
+          "version": "2.0.1",
+          "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+          "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+          "dev": true,
+          "requires": {
+            "color-name": "~1.1.4"
+          }
+        },
+        "color-name": {
+          "version": "1.1.4",
+          "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+          "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+          "dev": true
+        }
+      }
+    },
+    "wrappy": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+      "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
+      "dev": true
+    },
+    "write-file-atomic": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz",
+      "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==",
+      "dev": true,
+      "requires": {
+        "imurmurhash": "^0.1.4",
+        "is-typedarray": "^1.0.0",
+        "signal-exit": "^3.0.2",
+        "typedarray-to-buffer": "^3.1.5"
+      }
+    },
+    "y18n": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz",
+      "integrity": "sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==",
+      "dev": true
+    },
+    "yallist": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+      "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+      "dev": true
+    },
+    "yargs": {
+      "version": "16.2.0",
+      "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz",
+      "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==",
+      "dev": true,
+      "requires": {
+        "cliui": "^7.0.2",
+        "escalade": "^3.1.1",
+        "get-caller-file": "^2.0.5",
+        "require-directory": "^2.1.1",
+        "string-width": "^4.2.0",
+        "y18n": "^5.0.5",
+        "yargs-parser": "^20.2.2"
+      },
+      "dependencies": {
+        "y18n": {
+          "version": "5.0.8",
+          "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+          "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+          "dev": true
+        }
+      }
+    },
+    "yargs-parser": {
+      "version": "20.2.4",
+      "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.4.tgz",
+      "integrity": "sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA==",
+      "dev": true
+    },
+    "yargs-unparser": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz",
+      "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==",
+      "dev": true,
+      "requires": {
+        "camelcase": "^6.0.0",
+        "decamelize": "^4.0.0",
+        "flat": "^5.0.2",
+        "is-plain-obj": "^2.1.0"
+      },
+      "dependencies": {
+        "camelcase": {
+          "version": "6.2.0",
+          "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.2.0.tgz",
+          "integrity": "sha512-c7wVvbw3f37nuobQNtgsgG9POC9qMbNuMQmTCqZv23b6MIz0fcYpBiOlv9gEN/hdLdnZTDQhg6e9Dq5M1vKvfg==",
+          "dev": true
+        },
+        "decamelize": {
+          "version": "4.0.0",
+          "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz",
+          "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==",
+          "dev": true
+        }
+      }
+    },
+    "yocto-queue": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+      "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+      "dev": true
+    }
+  }
+}
diff --git a/package.json b/package.json
new file mode 100644 (file)
index 0000000..4202607
--- /dev/null
@@ -0,0 +1,37 @@
+{
+  "name": "@squeep/api-dingus",
+  "version": "1.0.0",
+  "description": "A minimal API server framework",
+  "main": "index.js",
+  "scripts": {
+    "coverage": "nyc --all npm test",
+    "eslint": "eslint index.js lib",
+    "test": "mocha --recursive"
+  },
+  "pre-commit": [
+    "coverage"
+  ],
+  "engines": {
+    "node": ">=12"
+  },
+  "repository": {
+    "type": "git",
+    "url": "https://git.squeep.com/squeep-api-dingus/"
+  },
+  "author": "Justin Wind <jwind-npm@squeep.com>",
+  "license": "ISC",
+  "dependencies": {
+    "mime-db": "^1.49.0",
+    "uuid": "^8.3.2"
+  },
+  "devDependencies": {
+    "eslint": "^7.32.0",
+    "eslint-plugin-node": "^11.1.0",
+    "eslint-plugin-security": "^1.4.0",
+    "eslint-plugin-sonarjs": "^0.9.1",
+    "mocha": "^9.0.3",
+    "nyc": "^15.1.0",
+    "pre-commit": "^1.2.2",
+    "sinon": "^11.1.2"
+  }
+}
diff --git a/test/index.js b/test/index.js
new file mode 100644 (file)
index 0000000..6075304
--- /dev/null
@@ -0,0 +1,14 @@
+/* eslint-disable capitalized-comments */
+/* eslint-env mocha */
+'use strict';
+
+const assert = require('assert');
+const sinon = require('sinon'); // eslint-disable-line node/no-unpublished-require
+const apiDingus = require('..');
+
+
+describe('apiDingus', function () {
+  it('covers', function () {
+    assert(apiDingus);
+  });
+}); // apiDingus
diff --git a/test/lib/common.js b/test/lib/common.js
new file mode 100644 (file)
index 0000000..b802003
--- /dev/null
@@ -0,0 +1,381 @@
+/* eslint-disable capitalized-comments */
+/* eslint-env mocha */
+'use strict';
+
+const assert = require('assert');
+const sinon = require('sinon'); // eslint-disable-line node/no-unpublished-require
+const common = require('../../lib/common');
+
+
+describe('common', function () {
+
+  describe('fileScope', function () {
+    it('names a file path', function () {
+      const filename = 'lib/foo/bar.js';
+      const result = common.fileScope(filename)('baz');
+      assert.strictEqual(result, 'bar:baz');
+    });
+    it('names an index path', function () {
+      const filename = 'lib/foo/index.js';
+      const result = common.fileScope(filename)('baz');
+      assert.strictEqual(result, 'foo:baz');
+    });
+  }); // fileScope
+
+  describe('generateETag', function () {
+    it('generates a tag from data', function () {
+      const expected = '"RHUvNyculE/SyROjU0LqzN0arxibrlBnazAashP8UGE"';
+      const data = 'example data';
+      const result = common.generateETag(undefined, undefined, data);
+      assert.strictEqual(result, expected);
+    });
+    it('generates a tag from data and stats', function () {
+      const expected = '"mtI0qCyqXsZVfX0Pgi+G6mQM10y9yVyi1NZejXSYttk"';
+      const stat = {
+        mtimeMs: 1600113038270.2375,
+      };
+      const data = 'example data';
+      const result = common.generateETag(undefined, stat, data);
+      assert.strictEqual(result, expected);
+    });
+  }); // generateETag
+
+  describe('get', function () {
+    const def = 'default';
+    it('covers missing obj', function () {
+      const result = common.get(undefined, undefined, def);
+      assert.strictEqual(result, def);
+    });
+    it('covers missing prop', function () {
+      const result = common.get({}, undefined, def);
+      assert.strictEqual(result, def);
+    });
+    it('covers default', function () {
+      const result = common.get({ k: 'v' }, 'x', def);
+      assert.strictEqual(result, def);
+    });
+    it('covers prop', function () {
+      const result = common.get({ k: 'v' }, 'k', def);
+      assert.strictEqual(result, 'v');
+    });
+  }); // get
+
+  describe('isClientCached', function () {
+    let req, modifiedTimeMs, eTag;
+    const blueMoon = 'Sat, 31 Oct 2020 07:51:00 GMT';
+    const blueMoonMs = 1604130660000;
+    const oneDayMs = 86400000;
+    const anotherETag = '"RHUvNyculE/SyROjU0LqzN0arxibrlBnazAashP8UGE"';
+    beforeEach(function () {
+      req = {
+        getHeader: sinon.stub(),
+      };
+      modifiedTimeMs = 0;
+      eTag = '"mtI0qCyqXsZVfX0Pgi+G6mQM10y9yVyi1NZejXSYttk"';
+    });
+    it('no headers, not cached', function () {
+      const result = common.isClientCached(req, modifiedTimeMs, eTag);
+      assert.strictEqual(result, false);
+    });
+    it('modified header, not cached', function () {
+      req.getHeader.onCall(0).returns(blueMoon);
+      modifiedTimeMs = blueMoonMs + oneDayMs;
+      const result = common.isClientCached(req, modifiedTimeMs, eTag);
+      assert.strictEqual(result, false);
+    });
+    it('modified header, cached', function () {
+      req.getHeader.onCall(0).returns(blueMoon);
+      modifiedTimeMs = blueMoonMs - oneDayMs;
+      const result = common.isClientCached(req, modifiedTimeMs, eTag);
+      assert.strictEqual(result, true);
+    });
+    it('match header, not matched', function () {
+      req.getHeader.onCall(1).returns(anotherETag);
+      const result = common.isClientCached(req, modifiedTimeMs, eTag);
+      assert.strictEqual(result, false);
+    });
+    it('match header, matched', function () {
+      req.getHeader.onCall(1).returns(`${anotherETag}, ${eTag}, ${anotherETag}`);
+      const result = common.isClientCached(req, modifiedTimeMs, eTag);
+      assert.strictEqual(result, true);
+    });
+    it('match header any, matched', function () {
+      req.getHeader.onCall(1).returns('*');
+      const result = common.isClientCached(req, modifiedTimeMs, eTag);
+      assert.strictEqual(result, true);
+    });
+    it('modified header cached, match header not matched, not cached', function () {
+      req.getHeader.onCall(0).returns(blueMoon);
+      modifiedTimeMs = blueMoonMs - oneDayMs;
+      req.getHeader.onCall(1).returns(`${anotherETag}, ${anotherETag}`);
+      const result = common.isClientCached(req, modifiedTimeMs, eTag);
+      assert.strictEqual(result, false);
+    });
+  }); // iscClientCached
+
+  describe('pick', function () {
+    it('picks', function () {
+      const srcObj = {
+        a: 1,
+        b: 2,
+        c: 3,
+      };
+      const result = common.pick(srcObj, ['a', 'c', 'd']);
+      assert.deepStrictEqual(result, {
+        'a': 1,
+        'c': 3,
+      });
+    });
+  }); // pick
+
+  describe('requestLogData', function () {
+    it('gives data', function () {
+      const req = {
+        method: 'GET',
+        somethingElse: 'blah',
+      };
+      const result = common.requestLogData(req);
+      assert.deepStrictEqual(result, {
+        method: 'GET',
+      });
+    });
+  }); // requestLogData
+
+  describe('responseLogData', function () {
+    it('gives data', function () {
+      const res = {
+        getHeaders: () => ({}),
+        statusCode: 200,
+        blah: 'blah',
+      };
+      const result = common.responseLogData(res);
+      assert.deepStrictEqual(result, {
+        headers: {},
+        statusCode: 200,
+      });
+    });
+  }); // responseLogData
+
+  describe('setOptions', function () {
+    it('sets options', function () {
+      const expected = {
+        keyOne: 'default',
+        keyTwo: 'option',
+        keyThree: 'default',
+      };
+      const target = {};
+      const defaultOptions = {
+        keyOne: 'default',
+        keyTwo: 'default',
+        keyThree: 'default',
+      };
+      const options = {
+        keyTwo: 'option',
+        keyFour: 'option',
+      };
+      common.setOptions(target, defaultOptions, options);
+      assert.deepStrictEqual(target, expected);
+    });
+  }); // setOptions
+
+  describe('splitFirst', function () {
+    it('splits', function () {
+      const expected = ['foo', 'awoo'];
+      const src = 'foo?awoo';
+      const delim = '?';
+      const fill = 'fill';
+      const result = common.splitFirst(src, delim, fill);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('fills', function () {
+      const expected = ['foo', 'fill'];
+      const src = 'foo';
+      const delim = '?';
+      const fill = 'fill';
+      const result = common.splitFirst(src, delim, fill);
+      assert.deepStrictEqual(result, expected);
+    });
+  }); // splitFirst
+
+  describe('mergeEnum', function () {
+    it('merges enums', function () {
+      const origEnum = {
+        ContentType: {
+          TextHTML: 'text/html',
+        },
+        ErrorResponse: {
+          BadRequest: {
+            statusCode: 400,
+            errorMessage: 'Bad Request',
+          },
+        },
+      };
+      const newEnum = {
+        ContentType: {
+          TextPlain: 'text/plain',
+        },
+        ErrorResponse: {
+          BadResponse: {
+            statusCode: 401,
+            errorMessage: 'Unauthorized',
+          },
+        },
+        Header: {
+          Accept: 'accept',
+        },
+        Value: 'value',
+      };
+      const mergedEnum = {
+        ContentType: {
+          TextHTML: 'text/html',
+          TextPlain: 'text/plain',
+        },
+        ErrorResponse: {
+          BadRequest: {
+            statusCode: 400,
+            errorMessage: 'Bad Request',
+          },
+          BadResponse: {
+            statusCode: 401,
+            errorMessage: 'Unauthorized',
+          },
+        },
+        Header: {
+          Accept: 'accept',
+        },
+        Value: 'value',
+      };
+      const result = common.mergeEnum(origEnum, newEnum);
+      assert.deepStrictEqual(result, mergedEnum);
+    });
+  }); // mergeEnum
+
+  describe('requestId', function () {
+    it('returns a string', function () {
+      const id = common.requestId();
+      assert.strictEqual(typeof id, 'string');
+      assert(id.length > 0);
+    });
+  }); // requestId
+
+
+  describe('ensureLoggerLevels', function () {
+    it('adds missing levels', function () {
+      const result = common.ensureLoggerLevels();
+      assert.deepStrictEqual(result, common.nullLogger);
+    });
+  }); // ensureLoggerLevels
+
+  describe('httpStatusCodeClass', function () {
+    it('works', function () {
+      for (const [statusCode, statusClassExpected] of Object.entries({
+        200: 2,
+        304: 3,
+        404: 4,
+        500: 5,
+      })) {
+        const statusClass = common.httpStatusCodeClass(statusCode);
+        assert.strictEqual(statusClass, statusClassExpected);
+      }
+    });
+  }); // ensureLoggerLevels
+
+  describe('mergeDeep', function () {
+    it('simple merge', function () {
+      const o1 = {
+        foo1: 'one',
+      };
+      const o2 = {
+        foo2: 'two',
+      };
+      const o3 = {
+        foo3: 'three',
+      };
+      const expected = {
+        foo1: 'one',
+        foo2: 'two',
+        foo3: 'three',
+      };
+      const result = common.mergeDeep(o1, o2, o3);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('deep merge', function () {
+      const o1 = {
+        foo: {
+          quux: 1,
+        },
+        bar: 'baz',
+      };
+      const o2 = {
+        foo: {
+          myList: ['fancy'],
+          gleep: {
+            fraz: 2,
+          },
+        },
+        blah: 'frop',
+      };
+      const o3 = {
+        foo: {
+          myList: ['pants'],
+          gleep: {
+            troz: 'poit',
+          },
+          narf: 3,
+        },
+        bar: 'yup',
+      };
+      const expected = {
+        foo: {
+          myList: ['fancy', 'pants'],
+          gleep: {
+            fraz: 2,
+            troz: 'poit',
+          },
+          narf: 3,
+          quux: 1,
+        },
+        bar: 'yup',
+        blah: 'frop',
+      };
+      const result = common.mergeDeep(o1, o2, o3);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('merged object is distinct', function () {
+      const o1 = {
+        foo: {
+          bar: 3,
+        },
+      };
+      const o2 = {
+        baz: {
+          quux: 4,
+        },
+      };
+      const expected = {
+        foo: {
+          bar: 3,
+        },
+        baz: {
+          quux: 4,
+        },
+      };
+      const result = common.mergeDeep(o1, o2);
+      assert.deepStrictEqual(result, expected);
+      const expected2 = {
+        foo: {
+          bar: 5,
+        },
+        baz: {
+          quux: 5,
+        },
+      };
+      result.foo.bar = 5;
+      result.baz.quux = 5;
+      assert.deepStrictEqual(result, expected2);
+      assert.strictEqual(o1.foo.bar, 3);
+      assert.strictEqual(o2.baz.quux, 4);
+    });
+  }); // mergeDeep
+
+});
diff --git a/test/lib/content-negotiation.js b/test/lib/content-negotiation.js
new file mode 100644 (file)
index 0000000..79a73aa
--- /dev/null
@@ -0,0 +1,207 @@
+/* eslint-disable capitalized-comments */
+/* eslint-env mocha */
+'use strict';
+
+const assert = require('assert');
+const ContentNegotiation = require('../../lib/content-negotiation');
+const Enum = require('../../lib/enum');
+
+const TYPES = {
+  Any: '*/*',
+  ImageAny: 'image/*',
+  ImagePng: 'image/png',
+  TextAny: 'text/*',
+  TextHtml: 'text/html',
+  TextPlain: 'text/plain',
+  AppJson: 'application/json',
+};
+
+describe('ContentNegotiation', function () {
+  describe('_unpackAcceptClause', function () {
+    it('handles bare type', function () {
+      const clause = TYPES.TextPlain;
+      const expected = {
+        type: TYPES.TextPlain,
+        weight: 1.0,
+        params: {},
+      };
+      const result = ContentNegotiation._unpackAcceptClause(clause);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('handles weighted type', function () {
+      const clause = ` ${TYPES.TextPlain}; q=0.2`;
+      const expected = {
+        type: TYPES.TextPlain,
+        weight: 0.2,
+        params: {},
+      };
+      const result = ContentNegotiation._unpackAcceptClause(clause);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('handles type with extension parameters', function () {
+      const clause = `${TYPES.TextPlain}; format=flowed;charset=UTF-8`;
+      const expected = {
+        type: TYPES.TextPlain,
+        weight: 1.0,
+        params: {
+          format: 'flowed',
+          charset: 'UTF-8',
+        },
+      };
+      const result = ContentNegotiation._unpackAcceptClause(clause);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('handles type with params and weight', function () {
+      const clause = `${TYPES.AppJson};charset=UTF-8;q=0.123;ext=local`;
+      const expected = {
+        type: TYPES.AppJson,
+        weight: 0.123,
+        params: {
+          charset: 'UTF-8',
+          ext: 'local',
+        },
+      };
+      const result = ContentNegotiation._unpackAcceptClause(clause);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('ignores empty type', function () {
+      const clause = '';
+      const expected = undefined;
+      const result = ContentNegotiation._unpackAcceptClause(clause);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('ignores weird param', function () {
+      const clause = `${TYPES.AppJson};blah`;
+      const expected = {
+        type: TYPES.AppJson,
+        weight: 1.0,
+        params: {},
+      };
+      const result = ContentNegotiation._unpackAcceptClause(clause);
+      assert.deepStrictEqual(result, expected);
+    });
+  }); // _unpackAcceptClause
+
+  describe('_acceptClauses', function () {
+    it('orders multiple types properly', function () {
+      const acceptHeader = 'text/*;q=0.3, text/html;q=0.7, text/html;level=1,text/html;level=2;q=0.4, */*;q=0.5';
+      const expected = [
+        { type: TYPES.TextHtml, weight: 1.0, params: { 'level': '1' } },
+        { type: TYPES.TextHtml, weight: 0.7, params: {} },
+        { type: TYPES.Any, weight: 0.4998, params: {} }, // N.B. Implementation detail
+        { type: TYPES.TextHtml, weight: 0.4, params: { 'level': '2' } },
+        { type: TYPES.TextAny, weight: 0.2999, params: {} },  // N.B. Implementation detail
+      ];
+      const result = ContentNegotiation._acceptClauses(acceptHeader);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('does the expected', function () {
+      const acceptHeader = 'text/*;q=0.5, text/plain;q=0.7, */*;q=0.2';
+      const expected = [
+        { type: TYPES.TextPlain, weight: 0.7, params: {} },
+        { type: TYPES.TextAny, weight: 0.4999, params: {} }, // N.B. Implementation detail
+        { type: TYPES.Any, weight: 0.1998, params: {} }, // N.B. Implementation detail
+      ];
+      const result = ContentNegotiation._acceptClauses(acceptHeader);
+      assert.deepStrictEqual(result, expected);
+    });
+    it('covers missing data', function () {
+      const result = ContentNegotiation._acceptClauses(undefined);
+      assert.deepStrictEqual(result, []);
+    });
+  }); // _acceptClauses
+
+  describe('_matchType', function () {
+    it('matches simmple', function () {
+      const result = ContentNegotiation._matchType(TYPES.TextPlain, TYPES.TextPlain);
+      assert.strictEqual(result, true);
+    });
+    it('does not match simmple', function () {
+      const result = ContentNegotiation._matchType(TYPES.TextPlain, TYPES.TextHtml);
+      assert.strictEqual(result, false);
+    });
+    it('matches subtype wildcard', function () {
+      const result = ContentNegotiation._matchType(TYPES.ImageAny, TYPES.ImagePng);
+      assert.strictEqual(result, true);
+    });
+    it('does not match subtype wildcard', function () {
+      const result = ContentNegotiation._matchType(TYPES.ImageAny, TYPES.TextPlain);
+      assert.strictEqual(result, false);
+    });
+    it('matches any wildcard', function () {
+      const result = ContentNegotiation._matchType(TYPES.Any, TYPES.AppJson);
+      assert.strictEqual(result, true);
+    });
+    it('matches main type', function () {
+      const result = ContentNegotiation._matchType('image', TYPES.ImagePng);
+      assert.strictEqual(result, true);
+    });
+    it('does not match main type', function () {
+      const result = ContentNegotiation._matchType('image', TYPES.AppJson);
+      assert.strictEqual(result, false);
+    });
+  }); // _matchType
+
+  describe('accept', function () {
+    it('accepts anything', function () {
+      const types = [ TYPES.AppJson, TYPES.TextPlain ];
+      const acceptHeader = undefined;
+      const result = ContentNegotiation.accept(types, acceptHeader);
+      assert.strictEqual(result, TYPES.AppJson);
+    });
+
+    it('picks the most acceptible type', function () {
+      const types = [ TYPES.AppJson, TYPES.TextPlain ];
+      const acceptHeader = 'text/*;q=0.5, text/html;q=0.6, text/plain;q=0.7, */*;q=0.2, image/png;q=0.0, image/gif;q=0.0';
+      const result = ContentNegotiation.accept(types, acceptHeader);
+      assert.strictEqual(result, TYPES.TextPlain);
+    });
+    it('picks the most acceptible type', function () {
+      const types = [ TYPES.AppJson, TYPES.TextPlain ];
+      const acceptHeader = 'image/png;q=0.0, image/gif;q=0.0, text/html;q=0.6, text/*;q=0.5, text/plain;q=0.7, */*;q=0.7';
+      const result = ContentNegotiation.accept(types, acceptHeader);
+      assert.strictEqual(result, TYPES.TextPlain);
+    });
+    it('refuses to pick a zero-weight type', function () {
+      const types = [ TYPES.AppJson, TYPES.TextPlain ];
+      const acceptHeader = 'application/*;q=0.0, unhandled/type;q=0.7, none/none;q=0.2';
+      const result = ContentNegotiation.accept(types, acceptHeader);
+      assert.strictEqual(result, undefined);
+    });
+    it('picks least-wild match', function () {
+      const types = [ TYPES.TextHtml, TYPES.TextPlain ];
+      const acceptHeader = '*/*;q=0.5, text/plain;q=0.5, text/*;q=0.5';
+      const result = ContentNegotiation.accept(types, acceptHeader);
+      assert.strictEqual(result, TYPES.TextPlain);
+    });
+  });  // accept
+
+  describe('preferred', function () {
+    it('falls back to identity', function () {
+      const encodings = [];
+      const header = 'br, gzip';
+      const result = ContentNegotiation.preferred(encodings, header);
+      assert.deepStrictEqual(result, [Enum.EncodingType.Identity]);
+    });
+    it('picks appropriate', function () {
+      const encodings = ['br', 'identity'];
+      const header = 'lz4, br, gzip;q=.5';
+      const result = ContentNegotiation.preferred(encodings, header);
+      assert.deepStrictEqual(result, [Enum.EncodingType.Brotli, Enum.EncodingType.Identity]);
+    });
+    it('respects forbidding', function () {
+      const encodings = ['br'];
+      const header = 'gzip, *;q=0';
+      const result = ContentNegotiation.preferred(encodings, header);
+      assert.deepStrictEqual(result, []);
+    });
+    it('respects identity', function () {
+      const encodings = ['br'];
+      const header = 'gzip, identity;q=0.1, *;q=0';
+      const result = ContentNegotiation.preferred(encodings, header);
+      assert.deepStrictEqual(result, [Enum.EncodingType.Identity]);
+    });
+  }); // preferred
+
+}); // ContentNegotiation
+
diff --git a/test/lib/dingus.js b/test/lib/dingus.js
new file mode 100644 (file)
index 0000000..a1070a5
--- /dev/null
@@ -0,0 +1,873 @@
+/* eslint-disable capitalized-comments */
+/* eslint-env mocha */
+'use strict';
+
+const assert = require('assert');
+const sinon = require('sinon'); // eslint-disable-line node/no-unpublished-require
+const fs = require('fs');
+
+const Dingus = require('../../lib/dingus');
+const { DingusError } = require('../../lib/errors');
+const Enum = require('../../lib/enum');
+
+const noExpectedException = 'did not get expected exception';
+
+describe('Dingus', function () {
+  const dingus = new Dingus();
+
+  afterEach(function () {
+    sinon.restore();
+  });
+
+  describe('constructor', function () {
+    it('covers', function () {
+      const d = new Dingus({}, {});
+      assert(d);
+      assert('log' in d.logger);
+    });
+  }); // constructor
+
+  describe('_normalizePath', function () {
+    it('returns normal path', function () {
+      const p = '/a/b/c';
+      const r = dingus._normalizePath(p);
+      assert.strictEqual(r, p);
+    });
+    it('returns normal path', function () {
+      const p = '////a///b/./bar/..///c';
+      const expected = '/a/b/c'
+      const r = dingus._normalizePath(p);
+      assert.strictEqual(r, expected);
+    });
+  }); // _normalizePath
+
+  describe('_splitUrl', function () {
+    const nullObject = Object.create(null);
+
+    it('splits a simple path', function () {
+      const p = '/a/b/c';
+      const expected = {
+        pathPart: p,
+        queryParams: nullObject,
+      };
+      const r = dingus._splitUrl(p);
+      assert.deepStrictEqual(r, expected);
+    });
+    it('splits a path with trailing slash preserved', function () {
+      const p = '/a/b/c/';
+      const expected = {
+        pathPart: p,
+        queryParams: nullObject,
+      };
+      const r = dingus._splitUrl(p);
+      assert.deepStrictEqual(r, expected);
+    });
+    it('splits a path with trailing slash ignored', function () {
+      const p = '/a/b/c/';
+      const expected = {
+        pathPart: p,
+        queryParams: nullObject,
+      };
+      dingus.ignoreTrailingSlash = true;
+      const r = dingus._splitUrl(p);
+      assert.deepStrictEqual(r, expected);
+    });
+    it('splits a path with empty query string', function () {
+      const p = '/a/b/c?';
+      const expected = {
+        pathPart: '/a/b/c',
+        queryParams: nullObject,
+      };
+      const r = dingus._splitUrl(p);
+      assert.deepStrictEqual(r, expected);
+    });
+    it('splits a path with query string', function () {
+      const p = '/a/b/c?x=1&y=2&z';
+      const expected = {
+        pathPart: '/a/b/c',
+        queryParams: Object.assign(Object.create(null), {
+          x: '1',
+          y: '2',
+          z: '', // Subjective Editorial: disagree with the default querystring parser behavior here: null would be better than empty string, esp as result is null-prototyped object.
+        }),
+      };
+      const r = dingus._splitUrl(p);
+      assert.deepStrictEqual(r, expected);
+    });
+  }); // _splitUrl
+
+  describe('tagContext', function () {
+    let req, res, ctx;
+    beforeEach(function () {
+      req = {
+        getHeader: sinon.stub(),
+        setHeader: sinon.stub(),
+      };
+      res = {
+        getHeader: sinon.stub(),
+        setHeader: sinon.stub(),
+      };
+      ctx = {};
+    });
+    it ('sets id in context', function () {
+      const result = Dingus.tagContext(req, res, ctx);
+      assert.strictEqual(ctx.requestId, result);
+      assert(res.setHeader.called);
+    });
+    it ('sets provided header', function () {
+      req.getHeader.onCall(0).returns('abc'); // X-Request-ID
+      const result = Dingus.tagContext(req, res, ctx);
+      assert.strictEqual(ctx.requestId, result);
+      assert.strictEqual(res.setHeader.getCall(0).args[0], 'Request-ID');
+      assert.strictEqual(res.setHeader.getCall(1).args[0], 'X-Request-ID');
+      assert.strictEqual(res.setHeader.getCall(1).args[1], 'abc');
+      assert.strictEqual(res.setHeader.callCount, 2);
+    });
+  }); // tagContext
+
+  describe('clientAddressContext', function () {
+    let req, res, ctx;
+    let _tp;
+    before(function () {
+      _tp = dingus.trustProxy;
+    });
+    after(function () {
+      dingus.trustProxy = _tp;
+    });
+    beforeEach(function () {
+      req = {
+        getHeader: sinon.stub(),
+        setHeader: sinon.stub(),
+        connection: {},
+      };
+      res = {
+        getHeader: sinon.stub(),
+        setHeader: sinon.stub(),
+      };
+      ctx = {};
+    });
+    it ('covers untrusted proxy', function () {
+      dingus.trustProxy = false;
+      const expected = {
+        clientAddress: '',
+        clientProtocol: 'http',
+      }
+      dingus.clientAddressContext(req, res, ctx);
+      assert.deepStrictEqual(ctx, expected);
+      assert(!req.getHeader.called);
+    });
+    it ('covers missing', function () {
+      dingus.trustProxy = true;
+      const expected = {
+        clientAddress: '::1',
+        clientProtocol: 'https',
+      }
+      req.connection.remoteAddress = '::1';
+      req.connection.encrypted = true;
+      dingus.clientAddressContext(req, res, ctx);
+      assert(req.getHeader.called);
+      assert.deepStrictEqual(ctx, expected);
+    });
+  }); // clientAddressContext
+
+  describe('getRequestContentType', function () {
+    let req;
+    beforeEach(function () {
+      req = {
+        getHeader: sinon.stub(),
+        setHeader: sinon.stub(),
+      };
+    });
+    it('handles missing header', function () {
+      const result = Dingus.getRequestContentType(req);
+      assert.strictEqual(result, '');
+    });
+    it('parses simple type', function () {
+      req.getHeader.onCall(0).returns(Enum.ContentType.ApplicationJson);
+      const result = Dingus.getRequestContentType(req);
+      assert.strictEqual(result, Enum.ContentType.ApplicationJson);
+    });
+    it('parses complex type', function () {
+      req.getHeader.onCall(0).returns('application/json ; charset=UTF-8');
+      const result = Dingus.getRequestContentType(req);
+      assert.strictEqual(result, Enum.ContentType.ApplicationJson);
+    });
+  }); // getRequestContentType
+
+  describe('setResponseContentType', function () {
+    let req, responseTypes;
+    beforeEach(function () {
+      responseTypes = [];
+      req = {
+        setHeader: sinon.stub(),
+        getHeader: sinon.stub(),
+      };
+    });
+    it('handles missing header', function () {
+      const result = Dingus.getResponseContentType(responseTypes, req);
+      assert.strictEqual(result, undefined);
+    });
+    it('behaves as expected', function () {
+      responseTypes.push(Enum.ContentType.ApplicationJson);
+      req.getHeader.onCall(0).returns('text, image/png;q=0.5, application/*;q=0.2, audio;q=0.1');
+      const result = Dingus.getResponseContentType(responseTypes, req);
+      assert.strictEqual(result, Enum.ContentType.ApplicationJson);
+    });
+  }); // setResponseContentType
+
+  describe('on', function () {
+    let stubOn;
+    beforeEach(function () {
+      stubOn = sinon.stub(dingus.router, 'on');
+    });
+    it('covers', function () {
+      dingus.on('GET', '/', () => {});
+      assert(stubOn.called);
+    });
+  });
+
+  describe('setEndBodyHandler', function () {
+    let req, res, ctx, handler, origEnd, origWrite;
+    beforeEach(function () {
+      origEnd = sinon.stub();
+      origWrite = sinon.stub();
+      req = {};
+      res = {
+        write: origWrite,
+        end: origEnd,
+      };
+      ctx = {};
+      handler = sinon.stub();
+    });
+    it('collects body and handles', function () {
+      Dingus.setEndBodyHandler(req, res, ctx, handler);
+      res.write(Buffer.from('foo'));
+      res.write('baz');
+      res.write();
+      res.end('quux');
+      assert(origWrite.called);
+      assert(origEnd.called);
+      assert.deepStrictEqual(ctx.responseBody, Buffer.from('foobazquux'));
+      assert(handler.called);
+    });
+  }); // setEndBodyHandler
+
+  describe('setHeadHandler', function () {
+    let req, res, ctx, origEnd, origWrite;
+    beforeEach(function () {
+      origEnd = sinon.stub();
+      origWrite = sinon.stub();
+      req = {
+        method: 'HEAD',
+      };
+      res = {
+        end: origEnd,
+        write: origWrite,
+        setHeader: sinon.stub(),
+      };
+      ctx = {};
+    });
+    it('collects body without writing', function () {
+      Dingus.setHeadHandler(req, res, ctx);
+      res.write(Buffer.from('foo'));
+      res.write('baz');
+      res.write();
+      res.end('quux');
+      assert(!origWrite.called);
+      assert(origEnd.called);
+      assert.deepStrictEqual(ctx.responseBody, Buffer.from('foobazquux'));
+    });
+    it('ignores non-head method', function () {
+      req.method = 'GET';
+      Dingus.setHeadHandler(req, res, ctx);
+      res.write(Buffer.from('foo'));
+      res.end('bar');
+      assert(origWrite.called);
+      assert(origEnd.called);
+    });
+  }); // setHeadHandler
+
+  describe('addEncodingHeader', function () {
+    let res, encoding;
+    beforeEach(function () {
+      res = {
+        _headers: {},
+        // eslint-disable-next-line security/detect-object-injection
+        getHeader: (h) => res._headers[h],
+        // eslint-disable-next-line security/detect-object-injection
+        setHeader: (h, v) => res._headers[h] = v,
+      };
+    });
+    it('adds', function () {
+      encoding = 'gzip';
+      Dingus.addEncodingHeader(res, encoding);
+      assert.strictEqual(res._headers[Enum.Header.ContentEncoding], 'gzip');
+    });
+    it('extends', function () {
+      encoding = 'utf8';
+      Dingus.addEncodingHeader(res, encoding);
+      assert.strictEqual(res._headers[Enum.Header.ContentEncoding], 'utf8');
+      encoding = 'gzip';
+      Dingus.addEncodingHeader(res, encoding);
+      assert.strictEqual(res._headers[Enum.Header.ContentEncoding], 'gzip, utf8');
+    });
+  }); // addEncodingHeader
+
+  describe('dispatch', function () {
+    let pathsByLengthOrig;
+    let req, res, ctx;
+    let stubHandler;
+
+    beforeEach(function () {
+      req = {
+        url: '/',
+        method: 'GET',
+        setHeader: sinon.stub(),
+        getHeader: sinon.stub(),
+      };
+      res = {
+        statusCode: 200,
+        end: sinon.stub(),
+        setHeader: sinon.stub(),
+        hasHeader: sinon.stub(),
+        getHeader: sinon.stub(),
+        getHeaders: sinon.stub(),
+      };
+      ctx = {};
+      pathsByLengthOrig = dingus.pathsByLength;
+      sinon.spy(dingus, 'handlerMethodNotAllowed');
+      sinon.spy(dingus, 'handlerNotFound');
+      sinon.spy(dingus, 'handlerBadRequest');
+      sinon.spy(dingus, 'handlerInternalServerError');
+      stubHandler = sinon.stub();
+    });
+    afterEach(function () {
+      dingus.pathsByLength = pathsByLengthOrig;
+    });
+
+    it('calls handler', async function () {
+      const urlPath = '/:id';
+      const method = 'GET';
+      dingus.on(method, urlPath, stubHandler);
+      req.url = '/abc';
+      req.method = method;
+
+      await dingus.dispatch(req, res, ctx);
+      assert(stubHandler.called);
+      assert(!dingus.handlerMethodNotAllowed.called);
+      assert(!dingus.handlerNotFound.called);
+    });
+    it('calls handler without context', async function () {
+      const urlPath = '/:id';
+      const method = 'GET';
+      dingus.on(method, urlPath, stubHandler);
+      req.url = '/abc';
+      req.method = method;
+
+      await dingus.dispatch(req, res);
+      assert(stubHandler.called);
+      assert(!dingus.handlerMethodNotAllowed.called);
+      assert(!dingus.handlerNotFound.called);
+    });
+    it('calls fallback handler', async function () {
+      const urlPath = '/abc/:id';
+      const method = 'GET';
+      dingus.on('*', urlPath, stubHandler);
+      req.url = '/abc/def';
+      req.method = method;
+
+      await dingus.dispatch(req, res, ctx);
+      assert(stubHandler.called);
+      assert(!dingus.handlerMethodNotAllowed.called);
+      assert(!dingus.handlerNotFound.called);
+    });
+    it('handles error in handler', async function () {
+      const urlPath = '/:id';
+      const method = 'GET';
+      dingus.on(method, urlPath, stubHandler);
+      req.url = '/abc';
+      req.method = method;
+      stubHandler.rejects(new Error('blah'));
+
+      await dingus.dispatch(req, res, ctx);
+      assert(stubHandler.called);
+      assert(!dingus.handlerMethodNotAllowed.called);
+      assert(!dingus.handlerNotFound.called);
+    });
+    it('calls unsupported method', async function () {
+      const urlPath = '/:id';
+      const method = 'POST';
+      dingus.on('GET', urlPath, stubHandler);
+      req.url = '/abc';
+      req.method = method;
+
+      await dingus.dispatch(req, res, ctx);
+      assert(!stubHandler.called);
+      assert(dingus.handlerMethodNotAllowed.called);
+      assert(!dingus.handlerNotFound.called);
+    });
+    it('does not lookup nonexistent path', async function () {
+      req.url = '/foo/bar';
+      req.method = 'GET';
+
+      await dingus.dispatch(req, res, ctx);
+      assert(!stubHandler.called);
+      assert(!dingus.handlerMethodNotAllowed.called);
+      assert(dingus.handlerNotFound.called);
+    });
+    it('covers unhandled dingus exception', async function () {
+      const expectedException = new DingusError('blah');
+      sinon.stub(dingus.router, 'lookup').throws(expectedException);
+
+      await dingus.dispatch(req, res, ctx);
+      assert(!stubHandler.called);
+      assert(dingus.handlerInternalServerError.called);
+    });
+    it('covers other exception', async function () {
+      const expectedException = new Error('blah');
+      sinon.stub(dingus.router, 'lookup').throws(expectedException);
+
+      await dingus.dispatch(req, res, ctx);
+      assert(!stubHandler.called);
+      assert(dingus.handlerInternalServerError.called);
+    });
+    it('covers bad uri', async function () {
+      req.url = '/%f';
+
+      await dingus.dispatch(req, res, ctx);
+      assert(dingus.handlerBadRequest.called);
+    });
+
+  }); // dispatch
+
+  describe('parseBody', function () {
+    let ctx;
+    beforeEach(function () {
+      ctx = {};
+    });
+    it('does not parse unknown type', function () {
+      try {
+        dingus.parseBody('unknown/type', ctx);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.strictEqual(e.statusCode, 415);
+      }
+    });
+    it('parses json', function () {
+      const src = { foo: 'bar' };
+      ctx.rawBody = JSON.stringify(src);
+      dingus.parseBody(Enum.ContentType.ApplicationJson, ctx);
+      assert.deepStrictEqual(ctx.parsedBody, src);
+    });
+    it('handles unparsable json', function () {
+      ctx.rawBody = 'not json';
+      try {
+        dingus.parseBody(Enum.ContentType.ApplicationJson, ctx);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.strictEqual(e.statusCode, 400);
+      }
+    });
+    it('parses form', function () {
+      const expected = Object.assign(Object.create(null), {
+        foo: 'bar',
+      });
+      ctx.rawBody = 'foo=bar';
+      dingus.parseBody('application/x-www-form-urlencoded', ctx);
+      assert.deepStrictEqual(ctx.parsedBody, expected);
+    });
+
+  }); // parseBody
+
+  describe('bodyData', function () {
+    let res, resEvents;
+    beforeEach(function () {
+      resEvents = {};
+      res = {
+        // eslint-disable-next-line security/detect-object-injection
+        on: (ev, fn) => resEvents[ev] = fn,
+      };
+    });
+    it('provides data', async function () {
+      const p = dingus.bodyData(res);
+      resEvents['data'](Buffer.from('foo'));
+      resEvents['data'](Buffer.from('bar'));
+      resEvents['end']();
+      const result = await p;
+      assert.strictEqual(result, 'foobar');
+    });
+    it('handles error', async function () {
+      const p = dingus.bodyData(res);
+      resEvents['error']('foo');
+      try {
+        await p;
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.strictEqual(e, 'foo');
+      }
+    });
+  }); // bodyData
+
+  describe('ingestBody', function () {
+    it('covers', async function () {
+      const req = {};
+      const res = {};
+      const ctx = {};
+      sinon.stub(dingus, 'bodyData').resolves('{"foo":"bar"}')
+      sinon.stub(Dingus, 'getRequestContentType').returns(Enum.ContentType.ApplicationJson);
+      await dingus.ingestBody(req, res, ctx);
+      assert.deepStrictEqual(ctx.parsedBody, { foo: 'bar' });
+    });
+  }); // ingestBody
+
+  describe('setResponseType', function () {
+    let req, res, ctx;
+    let _sa; // Preserve strictAccept
+    before(function () {
+      _sa = dingus.strictAccept;
+    });
+    after(function () {
+      dingus.strictAccept = _sa;
+    });
+    beforeEach(function () {
+      ctx = {};
+      req = {};
+      res = {
+        setHeader: sinon.stub(),
+      };
+      sinon.stub(Dingus, 'getResponseContentType').returns();
+    });
+    it('rejects missing', function () {
+      dingus.strictAccept = true;
+      try {
+        dingus.setResponseType(['my/type'], req, res, ctx);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert.strictEqual(e.statusCode, 406, 'did not get expected status code');
+      }
+    });
+    it('accepts missing', function () {
+      dingus.strictAccept = false;
+      dingus.setResponseType(['my/type'], req, res, ctx);
+      assert.strictEqual(ctx.responseType, 'my/type');
+      });
+
+  }); // setResponseType
+
+  describe('_readFileInfo', function () {
+    let stat, data, statRes, dataRes, filename;
+    beforeEach(function () {
+      sinon.stub(fs.promises, 'stat');
+      sinon.stub(fs.promises, 'readFile');
+      statRes = {
+        mtimeMs:1612553697186,
+      };
+      dataRes = 'data';
+      filename = 'dummy.txt';
+    });
+    it('succeeds', async function () {
+      fs.promises.stat.resolves(statRes);
+      fs.promises.readFile.resolves('data');
+      [stat, data] = await dingus._readFileInfo(filename);
+      assert.deepStrictEqual(stat, statRes);
+      assert.deepStrictEqual(data, dataRes);
+    });
+    it('returns null for non-existant file', async function () {
+      const noEnt = {
+        code: 'ENOENT',
+      };
+      fs.promises.stat.rejects(noEnt);
+      fs.promises.readFile.rejects(noEnt);
+      [stat, data] = await dingus._readFileInfo(filename);
+      assert.strictEqual(stat, null);
+      assert.strictEqual(data, null);
+    });
+    it('throws unexpected error', async function () {
+      const expectedException = new Error('blah');
+      fs.promises.stat.rejects(expectedException);
+      await assert.rejects(async () => {
+        await dingus._readFileInfo(filename);
+      }, expectedException);
+    });
+  }); // _readFileInfo
+
+  describe('serveFile', function () {
+    const path = require('path');
+    let ctx, req, res, directory, fileName, filestats;
+    beforeEach(function () {
+      directory = path.join(__dirname, '..', 'test-data');
+      fileName = 'example.html';
+      ctx = {};
+      req = {
+        _headers: {
+          [Enum.Header.Accept]: undefined,
+          [Enum.Header.IfModifiedSince]: undefined,
+          [Enum.Header.AcceptEncoding]: undefined,
+          [Enum.Header.IfNoneMatch]: undefined,
+        },
+        getHeader: (header) => {
+          if (header in req._headers) {
+            // eslint-disable-next-line security/detect-object-injection
+            return req._headers[header];
+          }
+          assert.fail(`unexpected getHeader ${header}`);
+        },
+      };
+      res = {
+        end: sinon.stub(),
+        getHeader: sinon.stub(),
+        getHeaders: sinon.stub(),
+        hasHeader: sinon.stub().returns(true),
+        setHeader: sinon.stub(),
+      };
+      filestats = {
+        dev: 39,
+        mode: 33188,
+        nlink: 1,
+        uid: 1002,
+        gid: 1002,
+        rdev: 0,
+        blksize: 512,
+        ino: 897653,
+        size: 8,
+        blocks: 17,
+        atimeMs: 1613253436842.815,
+        mtimeMs: 1603485933192.8610,
+        ctimeMs: 1603485933192.8610,
+        birthtimeMs: 0,
+        atime: '2021-02-13T21:57:16.843Z',
+        mtime: '2020-10-23T13:45:33.193Z',
+        ctime: '2020-10-23T13:45:33.193Z',
+        birthtime: '1970-01-01T00:00:00.000Z',
+      };
+      sinon.stub(dingus, 'handlerNotFound');
+      sinon.stub(fs.promises, 'stat').resolves(filestats);
+      sinon.spy(fs.promises, 'readFile');
+    });
+    it('serves a file', async function () {
+      await dingus.serveFile(req, res, ctx, directory, fileName);
+      assert(fs.promises.readFile.called);
+      assert(!dingus.handlerNotFound.called);
+    });
+    it('does not serve dot-file', async function () {
+      fileName = '.example';
+      await dingus.serveFile(req, res, ctx, directory, fileName);
+      assert(!fs.promises.readFile.called);
+      assert(dingus.handlerNotFound.called);
+    });
+    it('does not serve encoded navigation', async function () {
+      fileName = '/example.html';
+      await dingus.serveFile(req, res, ctx, directory, fileName);
+      assert(!fs.promises.readFile.called);
+      assert(dingus.handlerNotFound.called);
+    });
+    it('does not serve missing file', async function () {
+      fileName = 'no-file.here';
+      await dingus.serveFile(req, res, ctx, directory, fileName);
+      assert(dingus.handlerNotFound.called);
+    });
+    it('covers fs error', async function () {
+      const expectedException = new Error('blah');
+      fs.promises.stat.restore();
+      sinon.stub(fs.promises, 'stat').rejects(expectedException);
+      try {
+        await dingus.serveFile(req, res, ctx, directory, fileName);
+        assert.fail('should have thrown');
+      } catch (e) {
+        assert.strictEqual(e, expectedException);
+      }
+    });
+    it('caches by modified', async function () {
+      req._headers[Enum.Header.IfModifiedSince] = 'Fri, 23 Oct 2020 23:11:16 GMT';
+      await dingus.serveFile(req, res, ctx, directory, fileName);
+      assert.strictEqual(res.statusCode, 304);
+    });
+    it('does not cache old modified', async function () {
+      req._headers[Enum.Header.IfModifiedSince] = 'Fri, 23 Oct 2020 01:11:16 GMT';
+      await dingus.serveFile(req, res, ctx, directory, fileName);
+      assert.notStrictEqual(res.statusCode, 304);
+      assert(!dingus.handlerNotFound.called);
+    });
+    it('caches ETag match', async function () {
+      req._headers[Enum.Header.IfNoneMatch] = '"zPPQVfXV36sgXq4fRLdsm+7rRMb8IUfb/eJ6N6mnwWs"';
+      await dingus.serveFile(req, res, ctx, directory, fileName);
+      assert.strictEqual(res.statusCode, 304);
+    });
+    it('does not cache ETag non-match', async function () {
+      req._headers[Enum.Header.IfNoneMatch] = '"foo", "bar"';
+      await dingus.serveFile(req, res, ctx, directory, fileName);
+      assert.notStrictEqual(res.statusCode, 304);
+      assert(!dingus.handlerNotFound.called);
+    });
+    it('handles no possible encodings', async function () {
+      req._headers[Enum.Header.AcceptEncoding] = '*;q=0';
+      await assert.rejects(async () => {
+        await dingus.serveFile(req, res, ctx, directory, fileName);
+      }, {
+        name: 'ResponseError',
+      });
+    });
+    it('handles a valid encoding', async function () {
+      req._headers[Enum.Header.AcceptEncoding] = 'gzip';
+      await dingus.serveFile(req, res, ctx, directory, fileName);
+      assert(res.end.called);
+    });
+    it('handles a valid encoding among others', async function () {
+      req._headers[Enum.Header.AcceptEncoding] = 'flarp, br, gzip';
+      fs.promises.stat.restore();
+      sinon.stub(fs.promises, 'stat')
+        .onCall(0).resolves(filestats) // identity file
+        .onCall(1).resolves(null) // br encoding
+        .onCall(2).resolves(filestats); // gzip encoding
+      await dingus.serveFile(req, res, ctx, directory, fileName);
+      assert(res.end.called);
+    });
+  }); // serveFile
+
+  describe('renderError', function () {
+    let err;
+    beforeEach(function () {
+      err = {
+        statusCode: '200',
+        errorMessage: 'OK',
+        details: 'hunkydorey',
+      };
+    });
+    it('renders unknown type', function () {
+      const contentType = 'unknown/type';
+      const result = dingus.renderError(contentType, err);
+      assert.deepStrictEqual(result, 'OK\r\nhunkydorey');
+    });
+    it('renders text', function () {
+      const contentType = 'text/plain';
+      const result = dingus.renderError(contentType, err);
+      assert.deepStrictEqual(result, 'OK\r\nhunkydorey');
+    });
+    it('renders json', function () {
+      const contentType = Enum.ContentType.ApplicationJson;
+      const result = dingus.renderError(contentType, err);
+      assert.deepStrictEqual(result, JSON.stringify(err));
+    });
+    it('renders html without details', function () {
+      err = {
+        statusCode: '201',
+        errorMessage: 'Created',
+      };
+      const contentType = 'text/html';
+      const result = dingus.renderError(contentType, err);
+      assert.deepStrictEqual(result, `<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <title>${err.statusCode} ${err.errorMessage}</title>
+  </head>
+  <body>
+    <h1>${err.errorMessage}</h1>
+  </body>
+</html>`);
+    });
+    it('renders html', function () {
+      const contentType = 'text/html';
+      const result = dingus.renderError(contentType, err);
+      assert.deepStrictEqual(result, `<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <title>${err.statusCode} ${err.errorMessage}</title>
+  </head>
+  <body>
+    <h1>${err.errorMessage}</h1>
+    <p>${err.details}</p>
+  </body>
+</html>`);
+    });
+    it('renders html, multiple details', function () {
+      const contentType = 'text/html';
+      err.details = ['one detail', 'two detail'];
+      const result = dingus.renderError(contentType, err);
+      assert.deepStrictEqual(result, `<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <title>${err.statusCode} ${err.errorMessage}</title>
+  </head>
+  <body>
+    <h1>${err.errorMessage}</h1>
+    <p>one detail</p>
+    <p>two detail</p>
+  </body>
+</html>`);
+    });
+  }); // renderError
+
+  describe('sendErrorResponse', function () {
+    let ctx, req, res;
+    beforeEach(function () {
+      ctx = {};
+      req = {};
+      res = {
+        end: sinon.stub(),
+        getHeader: sinon.stub(),
+        getHeaders: sinon.stub(),
+        hasHeader: sinon.stub().returns(true),
+        setHeader: sinon.stub(),
+      };
+      sinon.stub(dingus, 'renderError');
+    });
+    it('covers', function () {
+      const err = {
+        statusCode: 444,
+      };
+      dingus.sendErrorResponse(err, req, res, ctx);
+      assert(res.end.called);
+    });
+  }); // sendErrorResponse
+
+  describe('proxyPrefix', function () {
+    let req, res, ctx, stubHandler, pfxDingus;
+    const pfx = '/pfx';
+
+    beforeEach(function () {
+      pfxDingus = new Dingus(console, { proxyPrefix: pfx });
+      req = {
+        setHeader: sinon.stub(),
+        getHeader: sinon.stub(),
+      };
+      res = {
+        statusCode: 200,
+        end: sinon.stub(),
+        setHeader: sinon.stub(),
+        getHeader: sinon.stub(),
+      };
+      ctx = {};
+      sinon.stub(pfxDingus, 'handlerMethodNotAllowed');
+      sinon.stub(pfxDingus, 'handlerNotFound');
+      stubHandler = sinon.stub();
+    });
+    afterEach(function () {
+      sinon.restore();
+    });
+
+    it('handles prefixed route', async function () {
+      const urlPath = '/:id';
+      const method = 'GET';
+      pfxDingus.on(method, urlPath, stubHandler);
+      req.url = pfx + '/abc';
+      req.method = method;
+
+      await pfxDingus.dispatch(req, res, ctx);
+      assert(stubHandler.called);
+      assert(!pfxDingus.handlerMethodNotAllowed.called);
+      assert(!pfxDingus.handlerNotFound.called);
+    });
+    it('does not handle prefixed route', async function () {
+      const urlPath = '/:id';
+      const method = 'GET';
+      pfxDingus.on(method, urlPath, stubHandler);
+      req.url = '/wrongpfx/abc';
+      req.method = method;
+
+      await pfxDingus.dispatch(req, res, ctx);
+      assert(!stubHandler.called);
+      assert(!pfxDingus.handlerMethodNotAllowed.called);
+      assert(pfxDingus.handlerNotFound.called);
+    });
+  }); // proxyPrefix
+});
\ No newline at end of file
diff --git a/test/lib/enum.js b/test/lib/enum.js
new file mode 100644 (file)
index 0000000..0a86ec5
--- /dev/null
@@ -0,0 +1,21 @@
+/* eslint-disable capitalized-comments */
+/* eslint-env mocha */
+'use strict';
+
+const assert = require('assert');
+const Enum = require('../../lib/enum');
+
+
+describe('enum', function () {
+
+  describe('ErrorResponse', function () {
+    it('covers default', function () {
+      const result = Enum.ErrorResponse.notPresent;
+      const expected = {
+        errorMessage: 'undefined error response \'notPresent\'',
+      };
+      assert.deepStrictEqual(result, expected);
+    });
+  }); // ErrorResponse
+
+});
diff --git a/test/lib/mime-helper.js b/test/lib/mime-helper.js
new file mode 100644 (file)
index 0000000..b3e0ba3
--- /dev/null
@@ -0,0 +1,28 @@
+/* eslint-disable capitalized-comments */
+/* eslint-env mocha */
+'use strict';
+
+const assert = require('assert');
+const MimeHelper = require('../../lib/mime-helper');
+
+describe('ext-to-mime', function () {
+
+  describe('extensionToMime', function () {
+    it('returns default for unknown extension', function () {
+      const expected = 'application/octet-stream';
+      const result = MimeHelper.extensionToMime('unknown_extension');
+      assert.strictEqual(result, expected);
+    });
+    it('returns overridden default', function () {
+      const expected = 'extension/unknown';
+      const result = MimeHelper.extensionToMime('unknown_extension', expected);
+      assert.strictEqual(result, expected);
+    });
+    it('returns expected type', function () {
+      const expected = 'text/plain';
+      const result = MimeHelper.extensionToMime('txt');
+      assert.strictEqual(result, expected);
+    });
+  }); // extensionToMime
+
+});
diff --git a/test/lib/patches/incoming-message.js b/test/lib/patches/incoming-message.js
new file mode 100644 (file)
index 0000000..057c4b6
--- /dev/null
@@ -0,0 +1,37 @@
+/* eslint-disable capitalized-comments */
+/* eslint-env mocha */
+'use strict';
+
+const assert = require('assert');
+const { IncomingMessage } = require('http');
+require('../../../lib/patches/incoming-message');
+
+describe('IncomingMessage.getHeader', function () {
+  let im;
+  beforeEach(function () {
+    im = new IncomingMessage();
+  });
+  it('requires string arg', function () {
+    try {
+      im.getHeader(1);
+      assert.fail('did not get expected exception');
+    } catch (e) {
+      assert.strictEqual(e.name, 'TypeError');
+    }
+  });
+  it('is callable', function () {
+    const name = 'x-header';
+    const value = undefined;
+    const result = im.getHeader(name);
+    assert.strictEqual(result, value);
+  });
+  it('returns a header', function () {
+    const name = 'x-header';
+    const value = 'foo';
+    // eslint-disable-next-line security/detect-object-injection
+    im.headers[name] = value;
+    const result = im.getHeader(name);
+    assert.strictEqual(result, value);
+  });
+});
+
diff --git a/test/lib/router.js b/test/lib/router.js
new file mode 100644 (file)
index 0000000..f4c54a9
--- /dev/null
@@ -0,0 +1,352 @@
+/* eslint-disable capitalized-comments */
+/* eslint-env mocha */
+'use strict';
+
+const assert = require('assert');
+const sinon = require('sinon'); // eslint-disable-line node/no-unpublished-require
+const Router = require('../../lib/router');
+const { DingusError } = require('../../lib/errors');
+
+const noExpectedException = 'did not get expected exception';
+
+describe('Router', function () {
+  const router = new Router();
+  let _its; // Save and restore ignoreTrailingSlash
+
+  beforeEach(function () {
+    _its = router.ignoreTrailingSlash;
+  });
+
+  afterEach(function () {
+    sinon.restore();
+    router.ignoreTrailingSlash = _its;
+  });
+
+  describe('_pathDefinitionToPathMatch', function () {
+    it('defines a simple path', function () {
+      const p = '/a/b/c';
+      const expected = ['', 'a', 'b', 'c'];
+      expected[router.METHODS] = {};
+      const r = router._pathDefinitionToPathMatch(p);
+      assert.deepStrictEqual(r, expected);
+    });
+    it('defines a path with parameter', function () {
+      const p = '/a/b/:c';
+      const expected = ['', 'a', 'b', { [router.PARAM]: 'c' }];
+      expected[router.METHODS] = {};
+      const r = router._pathDefinitionToPathMatch(p);
+      assert.deepStrictEqual(r, expected);
+    });
+    it('defines a path with trailing slash', function () {
+      router.ignoreTrailingSlash = true;
+      const p = '/a/b/:c/';
+      const expected = ['', 'a', 'b', { [router.PARAM]: 'c' }];
+      expected[router.METHODS] = {};
+      const r = router._pathDefinitionToPathMatch(p);
+      assert.deepStrictEqual(r, expected);
+    });
+  }); // _pathDefinitionToPathMatch
+
+  describe('_pathCompareExact', function () {
+    let fixedPath, checkPath;
+
+    it('compares static paths which match', function () {
+      fixedPath = router._pathDefinitionToPathMatch('/a/b/c');
+      checkPath = router._pathDefinitionToPathMatch('/a/b/c');
+      const r = Router._pathCompareExact(fixedPath, checkPath);
+      assert.strictEqual(r, true);
+    });
+    it('compares static paths which do not match', function () {
+      fixedPath = router._pathDefinitionToPathMatch('/a/b/c');
+      checkPath = router._pathDefinitionToPathMatch('/a/b/d');
+      const r = Router._pathCompareExact(fixedPath, checkPath);
+      assert.strictEqual(r, false);
+    });
+    it('compares unequal static paths', function () {
+      fixedPath = router._pathDefinitionToPathMatch('/a/b/c');
+      checkPath = router._pathDefinitionToPathMatch('/a/b');
+      const r = Router._pathCompareExact(fixedPath, checkPath);
+      assert.strictEqual(r, false);
+    });
+    it('compares param paths which match', function () {
+      fixedPath = router._pathDefinitionToPathMatch('/a/:b/c');
+      checkPath = router._pathDefinitionToPathMatch('/a/:b/c');
+      const r = Router._pathCompareExact(fixedPath, checkPath);
+      assert.strictEqual(r, true);
+    });
+    it('compares param paths which do not match', function () {
+      fixedPath = router._pathDefinitionToPathMatch('/a/:b/c');
+      checkPath = router._pathDefinitionToPathMatch('/a/:g/c');
+      const r = Router._pathCompareExact(fixedPath, checkPath);
+      assert.strictEqual(r, false);
+    });
+  }); // _pathCompareExact
+
+  describe('_pathCompareParam', function () {
+    let fixedPath, checkPath;
+
+    it('compares static paths which match', function () {
+      const params = {};
+      const expectedParams = {};
+      fixedPath = router._pathDefinitionToPathMatch('/a/b/c');
+      checkPath = router._pathDefinitionToPathMatch('/a/b/c');
+      const r = Router._pathCompareParam(fixedPath, checkPath);
+      assert.strictEqual(r, true);
+      assert.deepStrictEqual(params, expectedParams);
+    });
+    it('compares static paths which do not match', function () {
+      const params = {};
+      const expectedParams = {};
+      fixedPath = router._pathDefinitionToPathMatch('/a/b/c');
+      checkPath = router._pathDefinitionToPathMatch('/a/b/d');
+      const r = Router._pathCompareParam(fixedPath, checkPath, params);
+      assert.strictEqual(r, false);
+      assert.deepStrictEqual(params, expectedParams);
+    });
+    it('compares unequal static paths', function () {
+      const params = {};
+      const expectedParams = {};
+      fixedPath = router._pathDefinitionToPathMatch('/a/b/c');
+      checkPath = router._pathDefinitionToPathMatch('/a/b');
+      const r = Router._pathCompareParam(fixedPath, checkPath, params);
+      assert.strictEqual(r, false);
+      assert.deepStrictEqual(params, expectedParams);
+    });
+    it('compares param paths which match', function () {
+      const params = {};
+      const expectedParams = {
+        b: 'bar',
+      };
+      fixedPath = router._pathDefinitionToPathMatch('/a/:b/c');
+      checkPath = router._pathDefinitionToPathMatch('/a/bar/c');
+      const r = Router._pathCompareParam(fixedPath, checkPath, params);
+      assert.strictEqual(r, true);
+      assert.deepStrictEqual(params, expectedParams);
+    });
+    it('compares multi param path which match', function () {
+      const params = {};
+      const expectedParams = {
+        b: 'gaz',
+        c: '123',
+      };
+      fixedPath = router._pathDefinitionToPathMatch('/a/:b/:c');
+      checkPath = router._pathDefinitionToPathMatch('/a/gaz/123');
+      const r = Router._pathCompareParam(fixedPath, checkPath, params);
+      assert.strictEqual(r, true);
+      assert.deepStrictEqual(params, expectedParams);
+    });
+  }); // _pathCompareParam
+
+  describe('_pathFind / _pathFindExact', function () {
+    let pathsByLengthOrig;
+
+    beforeEach(function () {
+      pathsByLengthOrig = router.pathsByLength;
+      router.pathsByLength = {
+        2: [ router._pathDefinitionToPathMatch('/:id') ],
+        3: [ router._pathDefinitionToPathMatch('/a/b') ],
+      };
+    });
+    afterEach(function () {
+      router.pathsByLength = pathsByLengthOrig;
+    });
+  
+    describe('_pathFind', function () {
+      it('finds a path', function () {
+        const pathParts = ['', '46123f1e-bdca-40ee-9f62-93ad38647aa1'];
+        const { matchedPath, pathParams } = router._pathFind(pathParts);
+        assert.strictEqual(matchedPath, router.pathsByLength[2][0]);
+        assert.deepStrictEqual(pathParams, { id: pathParts[1] });
+      });
+      it('does not find a path', function () {
+        const pathParts = ['', 'flarp', 'baz'];
+        const { matchedPath } = router._pathFind(pathParts);
+        assert.strictEqual(matchedPath, undefined);
+      });
+    }); // _pathFind
+
+    describe('_pathFindExact', function () {
+      it('finds a path', function () {
+        const pathParts = ['', { [router.PARAM]: 'id' }];
+        const r = router._pathFindExact(pathParts);
+        assert.strictEqual(r, router.pathsByLength[2][0]);
+      });
+      it('does not find a path', function () {
+        const pathParts = ['', 'flarp', 'baz'];
+        const r = router._pathFindExact(pathParts);
+        assert.strictEqual(r, undefined);
+      });
+    }); // _pathFindExact
+
+  }); // _pathFind / _pathFindExact
+
+  describe('on', function () {
+    let pathsByLengthOrig;
+    const stubHandler = () => {};
+
+    beforeEach(function () {
+      pathsByLengthOrig = router.pathsByLength;
+      router.pathsByLength = {
+        2: [ router._pathDefinitionToPathMatch('/:id') ],
+      };
+    });
+    afterEach(function () {
+      router.pathsByLength = pathsByLengthOrig;
+    });
+  
+    it('adds new path', function () {
+      const urlPath = '/a/:id';
+      const expected = router._pathDefinitionToPathMatch(urlPath);
+      expected[router.METHODS]['GET'] = stubHandler;
+      router.on('GET', urlPath, stubHandler);
+      assert.deepStrictEqual(router.pathsByLength[3][0], expected);
+    });
+
+    it('adds new method to path', function () {
+      const urlPath = '/a/:id';
+      const expected = router._pathDefinitionToPathMatch(urlPath);
+      expected[router.METHODS]['GET'] = stubHandler;
+      expected[router.METHODS]['POST'] = stubHandler;
+      router.on('GET', urlPath, stubHandler);
+
+      router.on('POST', urlPath, stubHandler);
+      assert.deepStrictEqual(router.pathsByLength[3][0], expected);
+    });
+
+    it('add some more paths', function () {
+      let urlPath = '/a/b/c/d';
+      const expected = router._pathDefinitionToPathMatch(urlPath);
+      expected[router.METHODS]['GET'] = stubHandler;
+      router.on('GET', urlPath, stubHandler);
+      urlPath = '/a/b/x/y';
+      router.on('GET', urlPath, stubHandler);
+
+      assert.strictEqual(router.pathsByLength[5].length, 2);
+    });
+
+    it('adds multiple methods', function () {
+      const urlPath = '/:id';
+      const expected = router._pathDefinitionToPathMatch(urlPath);
+      expected[router.METHODS]['GET'] = stubHandler;
+      expected[router.METHODS]['HEAD'] = stubHandler;
+
+      router.on(['GET', 'HEAD'], urlPath, stubHandler);
+      assert.deepStrictEqual(router.pathsByLength[2][0], expected);
+    });
+
+    it('adds new wildcard path', function () {
+      const urlPath = '/a/:id';
+      const expected = router._pathDefinitionToPathMatch(urlPath);
+      expected[router.METHODS]['*'] = stubHandler;
+      router.on('*', urlPath, stubHandler);
+      assert.deepStrictEqual(router.pathsByLength[3][0], expected);
+    });
+
+    it('fails to add unknown method path', function () {
+      const urlPath = '/a/:id';
+      try {
+        router.on('FLARP', urlPath, stubHandler);
+        assert.fail('expected an exception');
+      } catch (e) {
+        assert.strictEqual(e.name, 'DingusError');
+        assert.strictEqual(e.message, 'invalid method \'FLARP\'');
+      }
+    });
+  }); // on
+
+  describe('lookup', function () {
+    let pathsByLengthOrig;
+    let ctx;
+    let stubHandler;
+
+    beforeEach(function () {
+      ctx = {};
+      pathsByLengthOrig = router.pathsByLength;
+      stubHandler = sinon.stub();
+    });
+    afterEach(function () {
+      router.pathsByLength = pathsByLengthOrig;
+    });
+
+    it('finds handler', function () {
+      const urlPath = '/:id';
+      const method = 'GET';
+      router.on(method, urlPath, stubHandler);
+      const path = '/abc';
+
+      const handler = router.lookup(method, path, ctx);
+      assert.strictEqual(handler, stubHandler);
+    });
+    it('does not find handler with trailing slash', function () {
+      router.ignoreTrailingSlash = false;
+      const urlPath = '/:id';
+      const method = 'GET';
+      router.on(method, urlPath, stubHandler);
+      const path = '/abc/';
+
+      try {
+        router.lookup(method, path, ctx);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DingusError);
+        assert.strictEqual(e.message, 'NoPath');
+      }
+    });
+    it('finds handler', function () {
+      router.ignoreTrailingSlash = true;
+      const urlPath = '/:id';
+      const method = 'GET';
+      router.on(method, urlPath, stubHandler);
+      const path = '/abc/';
+
+      const handler = router.lookup(method, path, ctx);
+      assert.strictEqual(handler, stubHandler);
+    });
+    it('finds handler without context', async function () {
+      const urlPath = '/:id';
+      const method = 'GET';
+      router.on(method, urlPath, stubHandler);
+      const path = '/abc';
+
+      const handler = router.lookup(method, path);
+      assert.strictEqual(handler, stubHandler);
+    });
+    it('finds fallback handler', async function () {
+      const urlPath = '/abc/:id';
+      const method = 'GET';
+      router.on('*', urlPath, stubHandler);
+      const path = '/abc/def';
+
+      const handler = router.lookup(method, path, ctx);
+      assert.strictEqual(handler, stubHandler);
+    });
+    it('calls unsupported method', async function () {
+      const urlPath = '/:id';
+      const method = 'POST';
+      router.on('GET', urlPath, stubHandler);
+      const path = '/abc';
+
+      try {
+        router.lookup(method, path, ctx);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DingusError);
+        assert.strictEqual(e.message, 'NoMethod');
+      }
+    });
+    it('does not lookup nonexistant path', async function () {
+      const path = '/foo/bar';
+      const method = 'GET';
+
+      try {
+        router.lookup(method, path, ctx);
+        assert.fail(noExpectedException);
+      } catch (e) {
+        assert(e instanceof DingusError);
+        assert.strictEqual(e.message, 'NoPath');
+      }
+    });
+
+  }); // lookup
+
+});
diff --git a/test/test-data/.example b/test/test-data/.example
new file mode 100644 (file)
index 0000000..33a9488
--- /dev/null
@@ -0,0 +1 @@
+example
diff --git a/test/test-data/example.html b/test/test-data/example.html
new file mode 100644 (file)
index 0000000..33a9488
--- /dev/null
@@ -0,0 +1 @@
+example
diff --git a/test/test-data/example.html.gz b/test/test-data/example.html.gz
new file mode 100644 (file)
index 0000000..63eee7d
Binary files /dev/null and b/test/test-data/example.html.gz differ