Merge branch 'v2.1-dev' as v2.1.2
[squeep-api-dingus] / lib / dingus.js
index f060c18f00d251f610a97929fdce663c9f01de89..c53e469808b26e2049d3dcd2428dbd66f50272c9 100644 (file)
@@ -1,4 +1,3 @@
-/* eslint-disable security/detect-object-injection */
 'use strict';
 
 /**
@@ -6,10 +5,14 @@
  * Just a self-contained router and some request glue.
  */
 
+/**
+ * @typedef {import('node:http')} http
+ */
+
 require('./patches');
-const { promises: fsPromises } = require('fs');
-const path = require('path');
-const querystring = require('querystring');
+const { promises: fsPromises } = require('node:fs');
+const path = require('node:path');
+const querystring = require('node:querystring');
 const common = require('./common');
 const ContentNegotiation = require('./content-negotiation');
 const Enum = require('./enum');
@@ -35,21 +38,24 @@ const defaultOptions = {
   querystring,
 };
 
-const cookieSplitRE = /; */;
+const cookieSplitRE = /\s*;\s*/;
+const headerParseRE = /^(?<name>[^:]+): +(?<value>.*)$/;
+const lineBreakRE = /\r\n|\n|\r/;
+const proxySplitRE = /\s*,\s*/u;
 
 class Dingus {
   /**
-   * @param {Object} logger object which implements logging methods
-   * @param {Object} options
-   * @param {Boolean} options.ignoreTrailingSlash requests for '/foo/' will match a '/foo' route
+   * @param {object} logger console-like object which implements logging methods
+   * @param {object} options options
+   * @param {boolean} options.ignoreTrailingSlash requests for '/foo/' will match a '/foo' route
    * @param {string} options.proxyPrefix leading part of url path to strip
-   * @param {Boolean} options.strictAccept whether to error on unsupported Accept type
+   * @param {boolean} options.strictAccept whether to error on unsupported Accept type
    * @param {string} options.selfBaseUrl for constructing links
-   * @param {Boolean} options.staticMetadata serve static headers with static files
-   * @param {Boolean} options.trustProxy trust some header data to be provided by proxy
-   * @param {Boolean} options.intrinsicHeadMethod handle HEAD requests automatically if not specified as a route method
-   * @param {Boolean} options.intrinsicHeadPersistBody include un-sent body on ctx for automatic HEAD requests
-   * @param {Object} options.querystring alternate qs parser to use
+   * @param {boolean} options.staticMetadata serve static headers with static files
+   * @param {boolean} options.trustProxy trust some header data to be provided by proxy
+   * @param {boolean} options.intrinsicHeadMethod handle HEAD requests automatically if not specified as a route method
+   * @param {boolean} options.intrinsicHeadPersistBody include un-sent body on ctx for automatic HEAD requests
+   * @param {object} options.querystring alternate qs parser to use
    */
   constructor(logger = console, options = {}) {
     common.setOptions(this, defaultOptions, options);
@@ -73,6 +79,7 @@ class Dingus {
   /**
    * Resolve relative and empty paths in url
    * @param {string} p path
+   * @returns {string} normalized path
    */
   _normalizePath(p) {
     const pathNorm = path.normalize(p); // This isn't perfectly correct, but it's easy...
@@ -84,6 +91,7 @@ class Dingus {
    * 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
+   * @returns {string} de-prefixed path
    */
   _stripPrefix(p) {
     if (p.startsWith(this.proxyPrefix)) {
@@ -95,7 +103,8 @@ class Dingus {
 
   /**
    * Returns the path part, and querystring object, from a request url.
-   * @param {string} url 
+   * @param {string} url url
+   * @returns {object} path string and query params
    */
   _splitUrl(url) {
     const [ p, qs ] = common.splitFirst(url, '?');
@@ -108,12 +117,13 @@ class Dingus {
 
   /**
    * Insert a new path handler
-   * @param {string} method 
-   * @param {string} urlPath 
-   * @param {fn} handler 
+   * @param {string|string[]} methods methods to match for this path, '*' allowed
+   * @param {string} urlPath request path to match
+   * @param {Function} handler handler to invoke on match
+   * @param {any[]} handlerArgs additional arguments for handler
    */
-  on(method, urlPath, handler, ...handlerArgs) {
-    this.router.on(method, urlPath, handler, handlerArgs);
+  on(methods, urlPath, handler, ...handlerArgs) {
+    this.router.on(methods, urlPath, handler, handlerArgs);
   }
 
 
@@ -123,9 +133,10 @@ class Dingus {
    * Sets requestId on ctx to a new uuid.
    * If X-Request-Id or X-Correlation-Id exist on incoming headers, sets them
    * on outgoing headers and sets on ctx.
-   * @param {http.ClientRequest} req 
-   * @param {http.ServerResponse} res 
-   * @param {object} ctx 
+   * @param {http.ClientRequest} req request
+   * @param {http.ServerResponse} res response
+   * @param {object} ctx context
+   * @returns {string} id added request
    */
   static tagContext(req, res, ctx) {
     const requestId = common.requestId();
@@ -144,7 +155,8 @@ class Dingus {
 
   /**
    * 
-   * @param {http.ClientRequest} req 
+   * @param {http.ClientRequest} req request
+   * @returns {string} first forwarded header ip
    */
   _getAddress(req) {
     // TODO: RFC7239 Forwarded support
@@ -152,27 +164,28 @@ class Dingus {
       (this.trustProxy && req?.getHeader(Enum.Header.XRealIP)) ||
       (req?.connection?.remoteAddress) ||
       '';
-    return address.split(/\s*,\s*/u)[0];
+    return address.split(proxySplitRE)[0];
   }
 
 
   /**
    * 
-   * @param {http.ClientRequest} req 
+   * @param {http.ClientRequest} req request
+   * @returns {string} first forwarded header protocol
    */
   _getProtocol(req) {
     // TODO: RFC7239 Forwarded support
     const protocol = (this.trustProxy && req?.getHeader(Enum.Header.XForwardedProto)) ||
       ((req?.connection?.encrypted) ? 'https' : 'http');
-    return protocol.split(/\s*,\s*/u)[0];
+    return protocol.split(proxySplitRE)[0];
   }
 
 
   /**
    * Sets ctx.clientAddress and ctx.clientProtocol.
-   * @param {http.ClientRequest} req 
-   * @param {http.ServerResponse} res 
-   * @param {object} ctx 
+   * @param {http.ClientRequest} req request
+   * @param {http.ServerResponse} res response
+   * @param {object} ctx context
    */
   clientAddressContext(req, res, ctx) {
     ctx.clientAddress = this._getAddress(req);
@@ -182,9 +195,9 @@ class Dingus {
 
   /**
    * Sets ctx.cookie from Cookie header.
-   * @param {http.ClientRequest} req 
-   * @param {http.ServerResponse} res 
-   * @param {object} ctx 
+   * @param {http.ClientRequest} req request
+   * @param {http.ServerResponse} res response
+   * @param {object} ctx context
    */
   static ingestCookie(req, res, ctx) {
     ctx.cookie = {};
@@ -192,7 +205,7 @@ class Dingus {
       const [ name, value ] = common.splitFirst(cookie, '=', null).map((x) => {
         try {
           return decodeURIComponent(x.trim());
-        } catch (e) {
+        } catch (e) { // eslint-disable-line no-unused-vars
           return x;
         }
       });
@@ -207,9 +220,9 @@ class Dingus {
   /**
    * Called before every request handler.
    * Sets tracking identifiers and client information on ctx.
-   * @param {http.ClientRequest} req
-   * @param {http.ServerResponse} res
-   * @param {object} ctx
+   * @param {http.ClientRequest} req request
+   * @param {http.ServerResponse} res response
+   * @param {object} ctx context
    */
   async preHandler(req, res, ctx) {
     this.constructor.tagContext(req, res, ctx);
@@ -220,9 +233,9 @@ class Dingus {
 
   /**
    * Helper for collecting chunks as array of buffers.
-   * @param {Buffer[]} chunks 
-   * @param {string|Buffer} chunk 
-   * @param {string} encoding 
+   * @param {Buffer[]} chunks array to collect chunks in
+   * @param {string|Buffer} chunk chunk to collect
+   * @param {string} encoding chunk encoding
    */
   static pushBufChunk(chunks, chunk, encoding = 'utf8') {
     if (chunk) {
@@ -236,9 +249,9 @@ class Dingus {
 
   /**
    * Sets ctx.responseBody and calls handler upon res.end().
-   * @param {http.ClientRequest} req 
-   * @param {http.ServerResponse} res 
-   * @param {object} ctx 
+   * @param {http.ClientRequest} req request
+   * @param {http.ServerResponse} res response
+   * @param {object} ctx context
    * @param {*} handler fn(req, res, ctx)
    */
   static setEndBodyHandler(req, res, ctx, handler) {
@@ -262,10 +275,10 @@ class Dingus {
    * Intercept writes for head requests, do not send to client,
    * but send length, and make body available in context.
    * N.B. If persisted, ctx.responseBody will be a raw buffer, be aware when logging.
-   * @param {http.ClientRequest} req 
-   * @param {http.ServerResponse} res 
-   * @param {object} ctx 
-   * @param {Boolean} persistResponseBody
+   * @param {http.ClientRequest} req request
+   * @param {http.ServerResponse} res response
+   * @param {object} ctx context
+   * @param {boolean} persistResponseBody store body buffer on context
    */
   static setHeadHandler(req, res, ctx, persistResponseBody = false) {
     if (req.method === 'HEAD') {
@@ -290,10 +303,10 @@ class Dingus {
 
   /**
    * Resolve the handler to invoke for a request.
-   * @param {http.ClientRequest} req
-   * @param {http.ServerResponse} res
-   * @param {object} ctx
-   * @returns {object}
+   * @param {http.ClientRequest} req request
+   * @param {http.ServerResponse} res response
+   * @param {object} ctx context
+   * @returns {object} handler and args
    */
   _determineHandler(req, res, ctx) {
     const _scope = _fileScope('_determineHandler');
@@ -326,11 +339,11 @@ class Dingus {
 
   /**
    * For intrinsic HEAD requests, resolve the handler to invoke.
-   * @param {http.ClientRequest} req
-   * @param {http.ServerResponse} res
-   * @param {object} ctx
-   * @param {string} pathPart
-   * @returns {object}
+   * @param {http.ClientRequest} req request
+   * @param {http.ServerResponse} res response
+   * @param {object} ctx context
+   * @param {string} pathPart router path
+   * @returns {object} hander and handler args
    */
   _determineHeadHandler(req, res, ctx, pathPart) {
     const _scope = _fileScope('_determineHeadHandler');
@@ -352,9 +365,10 @@ class Dingus {
 
   /**
    * Dispatch the handler for a request
-   * @param {http.ClientRequest} req
-   * @param {http.ServerResponse} res
-   * @param {object} ctx
+   * @param {http.ClientRequest} req request
+   * @param {http.ServerResponse} res response
+   * @param {object} ctx context
+   * @returns {Promise<void>} handler promise
    */
   async dispatch(req, res, ctx = {}) {
     const { handler, handlerArgs } = this._determineHandler(req, res, ctx);
@@ -370,8 +384,8 @@ class Dingus {
 
   /**
    * Return normalized type, without any parameters.
-   * @param {http.ClientRequest} req
-   * @returns {string}
+   * @param {http.ClientRequest} req request
+   * @returns {string} bare content type
    */
   static getRequestContentType(req) {
     const contentType = req.getHeader(Enum.Header.ContentType);
@@ -381,10 +395,10 @@ class Dingus {
 
   /**
    * Parse rawBody as contentType into ctx.parsedBody.
-   * @param {string} contentType
-   * @param {object} ctx
-   * @param {string|buffer} rawBody
-  */
+   * @param {string} contentType type
+   * @param {object} ctx context
+   * @param {string|Buffer} rawBody body data
+   */
   parseBody(contentType, ctx, rawBody) {
     const _scope = _fileScope('parseBody');
 
@@ -411,9 +425,10 @@ class Dingus {
 
   /**
    * Return all body data from a request.
-   * @param {http.ClientRequest} req
-   * @param {Number=} maximumBodySize
-   * @param {Boolean=} toString
+   * @param {http.ClientRequest} req request
+   * @param {number=} maximumBodySize bytes
+   * @param {boolean=} toString whether to convert body data buffer to string
+   * @returns {Promise<Buffer|string>} body data
    */
   async bodyData(req, maximumBodySize, toString = true) {
     const _scope = _fileScope('bodyData');
@@ -443,12 +458,13 @@ class Dingus {
   /**
    * Read and parse request body data.
    * Sets ctx.parsedBody, and optionally ctx.rawBody.
-   * @param {http.ClientRequest} req
-   * @param {http.ServerResponse} res
-   * @param {object} ctx
-   * @param {object}
-   * @param {Boolean} .parseEmptyBody
-   * @param {Boolean} .persistRawBody
+   * @param {http.ClientRequest} req request
+   * @param {http.ServerResponse} res response
+   * @param {object} ctx context
+   * @param {object} options options
+   * @param {boolean} options.parseEmptyBody attempt to parse an empty body, results may vary by encoding
+   * @param {boolean} options.persistRawBody store raw body buffer on context
+   * @param {number=} options.maximumBodySize bytes
    */
   async ingestBody(req, res, ctx, { parseEmptyBody = true, persistRawBody = false, maximumBodySize } = {}) {
     const rawBody = await this.bodyData(req, maximumBodySize);
@@ -464,8 +480,9 @@ class Dingus {
 
   /**
    * Return the best matching response type.
-   * @param {string[]} responseTypes 
-   * @param {http.ClientRequest} req 
+   * @param {string[]} responseTypes available content types
+   * @param {http.ClientRequest} req request
+   * @returns {string} preferred content type
    */
   static getResponseContentType(responseTypes, req) {
     const acceptHeader = req.getHeader(Enum.Header.Accept);
@@ -475,8 +492,9 @@ class Dingus {
 
   /**
    * Returns a list of the most-preferred content encodings for the response.
-   * @param {string[]} responseEncodings
-   * @param {http.ClientRequest} req 
+   * @param {string[]} responseEncodings available encodings
+   * @param {http.ClientRequest} req request
+   * @returns {string} preferred encoding
    */
   static getResponseEncoding(responseEncodings, req) {
     const acceptEncodingHeader = req.getHeader(Enum.Header.AcceptEncoding);
@@ -488,9 +506,9 @@ class Dingus {
    * Set the best content type for the response.
    * Sets ctx.responseType, and Content-Type header.
    * @param {string[]} responseTypes default first
-   * @param {http.ClientRequest} req 
-   * @param {http.ServerResponse} res 
-   * @param {object} ctx 
+   * @param {http.ClientRequest} req request
+   * @param {http.ServerResponse} res response
+   * @param {object} ctx context
    */
   setResponseType(responseTypes, req, res, ctx) {
     const _scope = _fileScope('setResponseType');
@@ -509,8 +527,8 @@ class Dingus {
 
   /**
    * Inserts an encoding into Content-Encoding header.
-   * @param {http.ServerResponse} res
-   * @param {string} encoding
+   * @param {http.ServerResponse} res response
+   * @param {string} encoding encoding
    */
   static addEncodingHeader(res, encoding) {
     const existingEncodings = res.getHeader(Enum.Header.ContentEncoding);
@@ -523,7 +541,8 @@ class Dingus {
 
   /**
    * Attempt to fetch both data and metadata for a file.
-   * @param {string} filePath 
+   * @param {string} filePath path to file
+   * @returns {Promise<[object=, object=]>} array with file contents and stats
    */
   async _readFileInfo(filePath) {
     const _scope = _fileScope('_readFileInfo');
@@ -547,9 +566,10 @@ class Dingus {
 
   /**
    * Potentially add additional headers from static file meta-file.
-   * @param {http.ServerResponse} res
-   * @param {string} directory
+   * @param {http.ServerResponse} res response
+   * @param {string} directory path to file
    * @param {string} fileName - already normalized and filtered
+   * @returns {boolean} if meta headers were added
    */
   async _serveFileMetaHeaders(res, directory, fileName) {
     const _scope = _fileScope('_serveFileMetaHeaders');
@@ -565,11 +585,9 @@ class Dingus {
       return added;
     }
 
-    const lineBreakRE = /\r\n|\n|\r/;
     const lines = data.toString().split(lineBreakRE);
     common.unfoldHeaderLines(lines);
 
-    const headerParseRE = /^(?<name>[^:]+): +(?<value>.*)$/;
     lines.forEach((line) => {
       if (line) {
         const result = headerParseRE.exec(line);
@@ -585,11 +603,12 @@ class Dingus {
   /**
    * 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 
+   * @param {http.ClientRequest} req request
+   * @param {http.ServerResponse} res response
+   * @param {object} ctx context
+   * @param {string} directory path to file
+   * @param {string} fileName file name
+   * @returns {Promise<void>=} serves file or renders status code
    */
   async serveFile(req, res, ctx, directory, fileName) {
     const _scope = _fileScope('serveFile');
@@ -632,7 +651,7 @@ class Dingus {
       if (encoding === Enum.EncodingType.Identity) {
         break;
       }
-      const suffix = Enum.EncodingTypeSuffix[encoding];
+      const suffix = Enum.EncodingTypeSuffix[encoding]; // eslint-disable-line security/detect-object-injection
       if (!suffix) {
         this.logger.error(_scope, 'supported encoding missing mapped suffix', { ctx, encoding });
         continue;
@@ -681,11 +700,12 @@ class Dingus {
 
   /**
    * Return a content-type appropriate rendering of an errorResponse object.
-   * @param {string} type content-type of response
+   * @param {string} contentType 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
+   * @param {number} err.statusCode htttp status code
+   * @param {string} err.errorMessage http status message
+   * @param {string|string[]} err.details additional details to include
+   * @returns {string} rendered err
    */
   // eslint-disable-next-line class-methods-use-this
   renderError(contentType, err) {
@@ -707,9 +727,9 @@ class Dingus {
    * 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 
+   * @param {http.ClientRequest} req request
+   * @param {http.ServerResponse} res response
+   * @param {object} ctx context
    */
   sendErrorResponse(err, req, res, ctx) {
     const _scope = _fileScope('sendErrorResponse');
@@ -734,10 +754,10 @@ class Dingus {
 
 
   /**
-   * @param {http.ClientRequest} req
-   * @param {http.ServerResponse} res
-   * @param {object} ctx
-   * @param {String} file - override ctx.params.file
+   * @param {http.ClientRequest} req request
+   * @param {http.ServerResponse} res response
+   * @param {object} ctx context
+   * @param {string} file - override ctx.params.file
    */
   async handlerGetStaticFile(req, res, ctx, file) {
     Dingus.setHeadHandler(req, res, ctx);
@@ -750,12 +770,12 @@ class Dingus {
 
 
   /**
-   * @param {http.ClientRequest} req
-   * @param {http.ServerResponse} res
-   * @param {Object} ctx
-   * @param {String} newPath
-   * @param {Number} statusCode
-  */
+   * @param {http.ClientRequest} req request
+   * @param {http.ServerResponse} res response
+   * @param {object} ctx context
+   * @param {string} newPath url to redirect to
+   * @param {number=} statusCode status code to use for redirect, default 307
+   */
   async handlerRedirect(req, res, ctx, newPath, statusCode = 307) {
     this.setResponseType(this.responseTypes, req, res, ctx);
     res.setHeader(Enum.Header.Location, newPath);
@@ -765,9 +785,9 @@ class Dingus {
 
 
   /**
-   * @param {http.ClientRequest} req
-   * @param {http.ServerResponse} res
-   * @param {object} ctx
+   * @param {http.ClientRequest} req request
+   * @param {http.ServerResponse} res response
+   * @param {object} ctx context
    */
   async handlerMethodNotAllowed(req, res, ctx) {
     this.setResponseType(this.responseTypes, req, res, ctx);
@@ -776,9 +796,9 @@ class Dingus {
 
 
   /**
-   * @param {http.ClientRequest} req
-   * @param {http.ServerResponse} res
-   * @param {object} ctx
+   * @param {http.ClientRequest} req request
+   * @param {http.ServerResponse} res response
+   * @param {object} ctx context
    */
   async handlerNotFound(req, res, ctx) {
     this.setResponseType(this.responseTypes, req, res, ctx);
@@ -787,9 +807,9 @@ class Dingus {
 
 
   /**
-   * @param {http.ClientRequest} req
-   * @param {http.ServerResponse} res
-   * @param {object} ctx
+   * @param {http.ClientRequest} req request
+   * @param {http.ServerResponse} res response
+   * @param {object} ctx context
    */
   async handlerBadRequest(req, res, ctx) {
     this.setResponseType(this.responseTypes, req, res, ctx);
@@ -798,9 +818,9 @@ class Dingus {
 
 
   /**
-   * @param {http.ClientRequest} req
-   * @param {http.ServerResponse} res
-   * @param {object} ctx
+   * @param {http.ClientRequest} req request
+   * @param {http.ServerResponse} res response
+   * @param {object} ctx context
    */
   async handlerInternalServerError(req, res, ctx) {
     this.setResponseType(this.responseTypes, req, res, ctx);