update devDependencies, add jsdoc lint, fix lint issues
[squeep-api-dingus] / lib / dingus.js
index d4af0e102d63d576cbe1cc71956ee7165bff9b6f..dfddaff577312857bdc695820ed768ab680d11bd 100644 (file)
@@ -6,10 +6,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,19 +39,21 @@ const defaultOptions = {
   querystring,
 };
 
+const cookieSplitRE = /; */;
+
 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);
@@ -71,6 +77,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...
@@ -82,6 +89,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)) {
@@ -93,7 +101,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, '?');
@@ -106,21 +115,26 @@ 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);
   }
 
 
   /**
    * 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 
+   * 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 request
+   * @param {http.ServerResponse} res response
+   * @param {object} ctx context
+   * @returns {string} id added request
    */
   static tagContext(req, res, ctx) {
     const requestId = common.requestId();
@@ -139,7 +153,8 @@ class Dingus {
 
   /**
    * 
-   * @param {http.ClientRequest} req 
+   * @param {http.ClientRequest} req request
+   * @returns {string} first forwarded header ip
    */
   _getAddress(req) {
     // TODO: RFC7239 Forwarded support
@@ -153,7 +168,8 @@ class Dingus {
 
   /**
    * 
-   * @param {http.ClientRequest} req 
+   * @param {http.ClientRequest} req request
+   * @returns {string} first forwarded header protocol
    */
   _getProtocol(req) {
     // TODO: RFC7239 Forwarded support
@@ -164,10 +180,10 @@ class Dingus {
 
 
   /**
-   * 
-   * @param {http.ClientRequest} req 
-   * @param {http.ServerResponse} res 
-   * @param {object} ctx 
+   * Sets ctx.clientAddress and ctx.clientProtocol.
+   * @param {http.ClientRequest} req request
+   * @param {http.ServerResponse} res response
+   * @param {object} ctx context
    */
   clientAddressContext(req, res, ctx) {
     ctx.clientAddress = this._getAddress(req);
@@ -175,23 +191,49 @@ class Dingus {
   }
 
 
+  /**
+   * Sets ctx.cookie from Cookie header.
+   * @param {http.ClientRequest} req request
+   * @param {http.ServerResponse} res response
+   * @param {object} ctx context
+   */
+  static ingestCookie(req, res, ctx) {
+    ctx.cookie = {};
+    req.getHeader(Enum.Header.Cookie)?.split(cookieSplitRE).forEach((cookie) => {
+      const [ name, value ] = common.splitFirst(cookie, '=', null).map((x) => {
+        try {
+          return decodeURIComponent(x.trim());
+        } catch (e) {
+          return x;
+        }
+      });
+      if (name && !(name in ctx.cookie)) {
+        const isQuoted = value?.startsWith('"') && value.endsWith('"');
+        ctx.cookie[name] = isQuoted ? value.slice(1, -1) : value; // eslint-disable-line security/detect-object-injection
+      }
+    });
+  }
+
+
   /**
    * Called before every request handler.
-   * @param {http.ClientRequest} req 
-   * @param {http.ServerResponse} res 
-   * @param {object} ctx 
+   * Sets tracking identifiers and client information on ctx.
+   * @param {http.ClientRequest} req request
+   * @param {http.ServerResponse} res response
+   * @param {object} ctx context
    */
   async preHandler(req, res, ctx) {
-    Dingus.tagContext(req, res, ctx);
+    this.constructor.tagContext(req, res, ctx);
     this.clientAddressContext(req, res, ctx);
+    this.constructor.ingestCookie(req, res, ctx);
   }
 
 
   /**
    * 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) {
@@ -205,9 +247,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) {
@@ -231,10 +273,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') {
@@ -259,10 +301,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');
@@ -295,11 +337,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');
@@ -321,9 +363,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);
@@ -339,8 +382,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);
@@ -350,10 +393,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');
 
@@ -380,9 +423,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');
@@ -411,12 +455,14 @@ class Dingus {
 
   /**
    * Read and parse request body data.
-   * @param {http.ClientRequest} req
-   * @param {http.ServerResponse} res
-   * @param {object} ctx
-   * @param {object}
-   * @param {Boolean} .parseEmptyBody
-   * @param {Boolean} .persistRawBody
+   * Sets ctx.parsedBody, and optionally ctx.rawBody.
+   * @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);
@@ -432,8 +478,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);
@@ -443,8 +490,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);
@@ -454,10 +502,11 @@ 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');
@@ -475,9 +524,9 @@ class Dingus {
 
 
   /**
-   * Inserts an encoding
-   * @param {http.ServerResponse} res
-   * @param {string} encoding
+   * Inserts an encoding into Content-Encoding header.
+   * @param {http.ServerResponse} res response
+   * @param {string} encoding encoding
    */
   static addEncodingHeader(res, encoding) {
     const existingEncodings = res.getHeader(Enum.Header.ContentEncoding);
@@ -490,7 +539,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');
@@ -514,14 +564,15 @@ 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');
-    this.logger.debug(_scope, 'called', { directory, fileName });
 
+    let added = false;
     const metaPrefix = '.';
     const metaSuffix = '.meta';
     const metaFileName = `${metaPrefix}${fileName}${metaSuffix}`;
@@ -529,7 +580,7 @@ class Dingus {
 
     const [stat, data] = await this._readFileInfo(metaFilePath);
     if (!stat) {
-      return;
+      return added;
     }
 
     const lineBreakRE = /\r\n|\n|\r/;
@@ -542,19 +593,22 @@ class Dingus {
         const result = headerParseRE.exec(line);
         const { groups: header } = result;
         res.setHeader(header.name, header.value);
+        added = true;
       }
     });
+    return added;
   }
 
 
   /**
    * 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');
@@ -636,7 +690,7 @@ class Dingus {
     res.setHeader(Enum.Header.CacheControl, 'public');
 
     if (this.staticMetadata) {
-      await this._serveFileMetaHeaders(res, directory, fileName);
+      ctx.metaHeaders = await this._serveFileMetaHeaders(res, directory, fileName);
     }
 
     this.logger.debug(_scope, 'serving file', { filePath, contentType });
@@ -646,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) {
@@ -672,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');
@@ -699,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);
@@ -715,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);
@@ -730,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);
@@ -741,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);
@@ -752,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);
@@ -763,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);