Source: server/request-listener.js

/**
 * MIT license
 * Copyright © 2025 Steve Butler (henspace.com)
 * Permission is hereby granted, free of charge, to any person obtaining a
 * copy of this software and associated documentation files (the “Software”),
 * to deal in the Software without restriction, including without limitation
 * the rights to use, copy, modify, merge, publish, distribute, sublicense,
 * and/or sell copies of the Software, and to permit persons to whom the
 * Software is furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 * 
 * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
 * FROM,OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
 * IN THE SOFTWARE.
 */

/**
 * @module hcjeTools/server/requestListener
 * @description
 * Listener for use with the [HCJE server]{@link module:hcjeTools/server/server}.
 * This module does not need to be used directly. It is used soley by the 
 * [server.js]{@link module:hcjeTools/server/server} script.
 */

import * as fsPromise from 'node:fs/promises';
import * as url from 'node:url';
import * as path from 'node:path';

/**
 * Escape sequences for terminal output. Key/value pairs
 * @type {Object<string, string>}
 * @private
 */
const Escape = {
  BG_BLACK: '\x1b[40m',
  BG_RED: '\x1b[41m',
  BG_WHITE: '\x1b[47m',
  FG_BLACK: '\x1b[30m',
  FG_RED: '\x1b[31m',
  FG_WHITE: '\x1b[37m',
  RESET: '\x1b[0m',
};

/**
 * Enumerated status codes.
 * @enum {number}
 * @private
 */
const StatusCode = {
  OK: 200,
  FORBIDDEN: 403,
  NOT_FOUND: 404,
  SERVER_ERROR: 500,
};

/**
 * @typedef {Object} HeaderInfo
 * @property {string} name
 * @property {string} value
 * @private
 */

/**
 * Header keys for arrays of {@link HeaderInfo} objects.
 * @type {Object<string, HeaderInfo[]> }
 * @private
 */
const HEADERS = {
  css: [{ name: 'Content-Type', value: 'text/css' }],
  csv: [
    { name: 'Content-Type', value: 'text/csv' },
    { name: 'Content-Disposition', value: 'attachment;filename=__filename__' },
  ],
  cur: [{ name: 'Content-Type', value: 'image/x-icon' }],
  gif: [{ name: 'Content-Type', value: 'image/gif' }],
  html: [{ name: 'Content-Type', value: 'text/html' }],
  ico: [{ name: 'Content-Type', value: 'image/x-icon' }],
  jpeg: [{ name: 'Content-Type', value: 'image/jpeg' }],
  jpg: [{ name: 'Content-Type', value: 'image/jpeg' }],
  json: [{ name: 'Content-Type', value: 'application/json' }],
  js: [{ name: 'Content-Type', value: 'text/javascript' }],
  map: [{ name: 'Content-Type', value: 'application/json' }],
  md: [{ name: 'Content-Type', value: 'text/markdown' }],
  mp3: [{ name: 'Content-Type', value: 'audio/mpeg' }],
  mp4: [{ name: 'Content-Type', value: 'video/mp4' }],
  mpeg: [{ name: 'Content-Type', value: 'video/mpeg' }],
  png: [{ name: 'Content-Type', value: 'image/png' }],
  svg: [{ name: 'Content-Type', value: 'image/svg+xml' }],
  ttf: [{ name: 'Content-Type', value: 'font/ttf' }],
  txt: [{ name: 'Content-Type', value: 'text/plain' }],
  woff: [{ name: 'Content-Type', value: 'font/woff' }],
  woff2: [{ name: 'Content-Type', value: 'font/woff2' }],
};

/**
 * Get headers for  a file
 * @param {*} pathname - path to the file.
 * @returns {HeaderInfo[]}
 * @private
 */
function getHeadersForPath(pathname) {
  const extension = path.extname(pathname);
  return HEADERS[extension.substring(1).toLowerCase()];
}

/**
 * @typedef {Object} ResponseDetail
 * @property {number} status - response status code
 * @property {HeaderInfo[]} headers - response headers
 * @property {string | Buffer} content - response content
 * @private
 */

/**
 * Get response details for a plain text message. This is normally used for
 * 404 or 500 messages.
 * @returns {ResponseDetail}
 * @private
 */
function getTextResponseDetails(status, message) {
  console.log(
    `${Escape.BG_RED}${Escape.FG_WHITE}Status: ${status}. Message: ${message}${Escape.RESET}`
  );
  return {
    status: status,
    headers: [{ name: 'Content-Type', value: 'text/plain' }],
    content: message,
  };
}

/**
 * Get details for the response.
 * @param {string} root - the root path
 * @param {URL} requestedUrl - the requested path
 * @returns {ResponseDetail}
 * @private
 */
function getResponseDetail(root, requestedUrl) {
  let detail = {};
  let pathname = decodeURIComponent(requestedUrl.pathname);
  if (pathname === '' || pathname === '/') {
    pathname = 'index.html';
  }
  const fullpath = path.join(root, pathname);
  const relativePath = path.relative(root, fullpath);
  if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
    return Promise.resolve(
      getTextResponseDetails(
        StatusCode.FORBIDDEN,
        `Access to path ${pathname} is forbidden.`
      )
    );
  }
  const filename = path.basename(pathname);
  detail.headers = getHeadersForPath(pathname);
  if (!detail.headers) {
    return Promise.resolve(
      getTextResponseDetails(
        StatusCode.SERVER_ERROR,
        `Filetype for ${filename} is not currently supported by the test server.`
      )
    );
  }
  detail.headers.forEach((header) => {
    header.value.replace('__filename__', filename);
  });

  return fsPromise
    .readFile(fullpath)
    .then((fileContent) => {
      detail.status = StatusCode.OK;
      detail.content = fileContent;
      return detail;
    })
    .catch((error) => {
      return getTextResponseDetails(StatusCode.NOT_FOUND, error.message);
    });
}


/**
 * Function added to the [request]{@link https://nodejs.org/api/http.html#event-request} when a server is created.
 * @typedef {function} RequestListener
 * @param {ClientRequest} request - The [ClientRequest]{@link https://nodejs.org/api/http.html#class-httpclientrequest}
 * @param {ServerResponse} response - The [ServerResponse]{@link https://nodejs.org/api/http.html#class-httpserverresponse}
 */

/**
 * Create a listener function for use with the http server.
 * @param {string} root - Path from where files are served
 * @returns {module:hcjeTools/server/requestListener~RequestListener}
 * @see {@link https://nodejs.org/api/http.html#httpcreateserveroptions-requestlistener}
 * @throws {Error} Thrown if root not defined.
 */
export function createRequestListener(root) {
  if (!root) {
    throw new Error('No root folder provided');
  }
  return (req, res) => {
    const requestedUrl = url.parse(req.url);
    const filename = path.basename(requestedUrl.pathname);
    console.log(
      `Request. path="${requestedUrl.pathname}", filename="${filename}"`
    );
    getResponseDetail(root, requestedUrl).then((detail) => {
      detail.headers.forEach((header) => {
        res.setHeader(header.name, header.value);
      });
      res.writeHead(detail.status);
      res.end(detail.content);
    });
  };
}