UNPKG

@bs-core/shell

Version:
1,252 lines (1,247 loc) 191 kB
import * as fs from 'node:fs'; import * as util from 'node:util'; import * as readline from 'node:readline'; import * as crypto from 'node:crypto'; import { performance as performance$1 } from 'node:perf_hooks'; import * as stream from 'node:stream'; import { Readable } from 'node:stream'; import * as path from 'node:path'; import * as streams from 'node:stream/promises'; import { pipeline } from 'node:stream/promises'; import * as zlib from 'node:zlib'; import * as http from 'node:http'; import * as https from 'node:https'; import * as os from 'node:os'; /** * Config manager module. Provides functions to retrieve config values from various sources like * CLI, environment variables, and env file. Includes utility functions to handle config value * lookup, type conversion, error handling etc. */ // imports here // Config consts here // The env var that contains the name of the .env file const CFG_ENV_FILE = "ENV_FILE"; // The env var that contains the name of the cfg file const CFG_CFG_FILE = "CFG_FILE"; // Private variables here // Stores the parsed contents of the .env file as key-value pairs let _envFileStore; // Stores the parsed contents of the cfg file as an object let _cfgFileStore; // Stores the messages generated during configuration. // NOTE: This is used because the configuration manager is used by Logger // and it becomes a chicken/egg situation when initialising the Logger let _messageStore; /** * Enumeration of supported configuration value types. * Can be used when retrieving a config value to specify the expected type. */ var ConfigType; (function (ConfigType) { ConfigType["String"] = "String"; ConfigType["Number"] = "Number"; ConfigType["Boolean"] = "Boolean"; ConfigType["Object"] = "Object"; ConfigType["Array"] = "Array"; })(ConfigType || (ConfigType = {})); /** * Represents an error that occurred while retrieving a config value */ class ConfigError { message; constructor(message) { this.message = message; } } // Private methods here /** * Converts a string value to the specified configuration type. * * NOTE: This is not used for the config files so we dont check * for Object or Array types. * * @param value - The string value to convert. * @param type - The expected configuration type. * @returns The converted value as a number, string, or boolean. */ function convertValue(value, type) { //Check the type switch (type) { case ConfigType.Number: return parseInt(value); case ConfigType.Boolean: // Only accept Y or TRUE (case insensitive) to mean true if (value.toUpperCase() === "Y" || value.toUpperCase() === "TRUE") { return true; } // Everything else is false return false; default: // All that is left is String and this is already a string! return value; } } /** * Checks the command line arguments for a configuration value matching the * specified configuration key. * * @param config - The configuration key to look for in the command line arguments. * @param type - The expected type of the configuration value. * @param options - Additional options for configuring the behavior of the function. * @returns The configuration value from the command line arguments, converted to the specified type, or `null` if the configuration value is not found. */ function checkCli(config, type, options) { // Ignore the first 2 params (node bin and executable file) let cliParams = process.argv.slice(2); // The convention used for config params on the command line is: // Convert to lowercase, replace '_' with '-' and prepend "--" let cliParam = `--${config.toLowerCase().replaceAll("_", "-")}`; // Command line flags are just prepended with a '-' let cmdLineFlag = options.cmdLineFlag !== undefined ? `-${options.cmdLineFlag}` : ""; // If the param is assigned a value on the cli it has the format: // --param=value // otherwise the format is and it implies true: // --parm or -flag let regExp; if (options.cmdLineFlag === undefined) { // No flag specified so only look for the param and an assigned value regExp = new RegExp(`^${cliParam}=(.+)$`); } else { // Look for param and an assigned value or cmd line flag regExp = new RegExp(`^${cliParam}=(.+)$|^${cliParam}$|^${cmdLineFlag}$`); } let value; // Step through each cli params until you find a match for (let i = 0; i < cliParams.length; i++) { let match = cliParams[i].match(regExp); let paramOrFlag; if (match === null) { // There was no match so look at the next param continue; } // If a value was supplied then match[1] will contain a value if (match[1] !== undefined) { paramOrFlag = match[0]; value = match[1]; } else { paramOrFlag = match[0]; // The presence of the flag/param without a value implies a true value value = "Y"; } // Check if we can or should log that we found it // NOTE: If we log it we want to indicate is was found on the CLI if (!options.silent) { _messageStore.add(`CLI parameter/flag (${paramOrFlag}) = (${options.redact ? "redacted" : value})`); } // We are done so break out of the loop break; } // Return null if we have no value if (value === undefined) { return null; } return convertValue(value, type); } /** * Retrieves a configuration value from an environment variable. * * @param config - The configuration key to retrieve from the environment. * @param type - The expected type of the configuration value. * @param options - Additional options for configuring the behavior of the function. * @returns The configuration value converted to the specified type, or `null` if the environment variable is not set. */ function checkEnvVar(config, type, options) { // NOTE: Always convert to upper case for env vars let evar = config.toUpperCase(); let value = process.env[evar]; // Return null if we have no value if (value === undefined) { return null; } // If we are here then we found it, now lets check if we can or should // log that we found it // NOTE: If we log it we want to indicate is was found in an env var if (!options.silent) { _messageStore.add(`Env var (${evar}) = (${options.redact ? "redacted" : value})`); } return convertValue(value, type); } /** * Retrieves a configuration value from an environment file store. * * @param config - The configuration key to retrieve from the environment file. * @param type - The expected type of the configuration value. * @param options - Additional options for configuring the behavior of the function. * @returns The configuration value converted to the specified type, or `null` if the configuration is not found in the environment file. */ function checkEnvFile(config, type, options) { // NOTE: Always convert to upper case when checking the env file store let evar = config.toUpperCase(); let value = _envFileStore.get(evar); // Return null if we have no value if (value === undefined) { return null; } // If we are here then we found it, now lets check if we can or should // log that we found it // NOTE: If we log it we want to indicate it was found in the env file if (!options.silent) { _messageStore.add(`Env var from env file (${evar}) = (${options.redact ? "redacted" : value})`); } return convertValue(value, type); } /** * Retrieves a configuration value from a configuration file store. * * @param config - The configuration key to retrieve from the configuration file. * @param options - Additional options for configuring the behavior of the function. * @returns The configuration value, or `null` if the configuration is not found in the configuration file. */ function checkCfgFile(config, options) { let value = _cfgFileStore.get(config); // Return null if we have no value if (value === undefined) { return null; } // If we are here then we found it, now lets check if we can or should // log that we found it // NOTE: If we log it we want to indicate it was found in the cfg file if (!options.silent) { _messageStore.add(`Config from cfg file (${config}) = (${options.redact ? "redacted" : JSON.stringify(value)})`); } // No need for any convertions, just return the value return value; } /** * Retrieves a configuration value from various sources, with the following precedence: * 1. Command-line arguments * 2. Environment variables * 3. Environment file (.env) * 4. Configuration file * * If the configuration value is not found in any of these sources, a default value can be provided. * * @param config - The configuration key to retrieve. * @param type - The expected type of the configuration value. * @param defaultVal - The default value to use if the configuration is not found. * @param configOptions - Additional options for configuring the behavior of the function. * @returns The configuration value, or the default value if the configuration is not found. * @throws {ConfigError} If the configuration is required and not found. */ function get(config, type, defaultVal, configOptions) { // Set up the defaults if not provided let options = { silent: false, redact: false, ...configOptions, }; // Check the CLI first, i.e. CLI has higher precedence then env vars // of the cfg file let value = checkCli(config, type, options); if (value !== null) { return value; } // OK it's not in the CLI so lets check the env vars, env var has higher // precedence then the .env file value = checkEnvVar(config, type, options); if (value !== null) { return value; } // OK it's not in the env vars either so check the env file store value = checkEnvFile(config, type, options); if (value !== null) { return value; } // OK it's not in the env file store either so check the cfg store value = checkCfgFile(config, options); if (value !== null) { return value; } // If we are here then the value was not found - use default provided // NOTE: The default SHOULD have the correct type so do not do a conversion if (defaultVal === undefined) { // If the default was not provided then the config WAS required. In this // scenario we need to throw an error throw new ConfigError(`Config parameter (${config}) not found!`); } // Lets check if we can or should log the default value // NOTE: If we log it we want to indicate is the default value if (!options.silent) { _messageStore.add(`Default value used for (${config}) = (${options.redact ? "redacted" : defaultVal})`); } return defaultVal; } /** * Reads the contents of the specified .env file and adds the key-value * pairs to the _envFileStore. * * @param envFile - The path to the .env file to read. * @throws {ConfigError} If an error occurs while reading the .env file. */ function parseEnvFile(envFile) { let lines = []; try { _messageStore.add(`Reading config info from .env file (${envFile})`); // Read env file and split it into lines ... let contents = fs.readFileSync(envFile, "utf8"); // ... makes sure if works for DOS and linux files! lines = contents.split(/\r?\n/); } catch (e) { throw new ConfigError(`The following error occured when trying to open the .env file (${envFile}) - (${e})`); } // Iterate through each line for (let line of lines) { // If the line is commented out or blank then skip it if (line.length === 0 || line.startsWith("#")) { continue; } // Don't use split() here because the value may contain an "=" let index = line.indexOf("="); // Check if there was an equal in the line - if not then skip this line if (index === -1) { continue; } // Get the key/value pair - make sure to trim them as well let key = line.slice(0, index).trim(); let value = line.slice(index + 1).trim(); // Check if the value is delimited with single or double quotes if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { // Strip them away value = value.slice(1, value.length - 1); } // Stick it in the env file store // NOTE: Make key upper case to match env vars conventions _envFileStore.set(key.toUpperCase(), value); _messageStore.add(`Added (${key.toUpperCase()}) to the env file store`); } } /** * Reads the contents of a cfg file and adds the key-value pairs to the * configuration store. * * @param cfgFile - The path to the configuration file to read. * @throws {ConfigError} If an error occurs while reading or parsing the configuration file. */ function readCfgFile(cfgFile) { let contents; try { _messageStore.add(`Reading config info from cfg file (${cfgFile})`); // Read the cfg file contents = fs.readFileSync(cfgFile, "utf8"); } catch (e) { throw new ConfigError(`The following error occured when trying to open the cfg file (${cfgFile}) - (${e})`); } try { _messageStore.add("Adding the cfg file contents to the cfg store"); _cfgFileStore = new Map(Object.entries(JSON.parse(contents))); } catch (e) { throw new ConfigError(`The following error occured when trying to add (${contents}) to the cfg store - (${e})`); } } /** * Initializes the configuration manager by setting up the necessary stores and * parsing any specified environment and configuration files. * * The function first initializes the `_envFileStore`, `_cfgFileStore`, and * `_messageStore` stores. It then checks if a `.env` file has been specified * in the configuration and, if so, calls the `parseEnvFile` function to parse * the contents of the file and add the key-value pairs to the `_envFileStore`. * * Next, the function checks if a configuration file has been specified in the * configuration and, if so, calls the `readCfgFile` function to read the * contents of the file and add the key-value pairs to the `_cfgFileStore`. * * This function is typically called during the initialization of the * application to ensure that the configuration manager is properly set up and * ready to use. */ function init$1() { // Initialise the stores _envFileStore = new Map(); _cfgFileStore = new Map(); _messageStore = new Set(); // Check if the user has specified a .env file let envFile = configMan.getStr(CFG_ENV_FILE, ""); if (envFile.length > 0) { parseEnvFile(envFile); } else if (fs.existsSync(".env")) { envFile = ".env"; _messageStore.add(`Defaulting the env-file to ${envFile}`); parseEnvFile(envFile); } else { _messageStore.add("No .env file specified or found"); } // Check if the user has specified a cfg file // NOTE: The cfg file config CAN be specified in the .env file since it // has already been parsed let cfgFile = configMan.getStr(CFG_CFG_FILE, ""); if (cfgFile.length > 0) { readCfgFile(cfgFile); } else { _messageStore.add("No cfg file specified"); } } // Public methods here /** * Provides a set of functions for retrieving configuration values from various * sources, including environment variables and configuration files. * * The `configMan` object is a frozen object that contains the following methods: * * - `getStr(config: string, defaultVal?: string, options?: ConfigOptions): string` * - Retrieves a string configuration value with a default value if not set. * - `getBool(config: string, defaultVal?: boolean, options?: ConfigOptions): boolean` * - Retrieves a boolean configuration value with a default value if not set. * - `getNum(config: string, defaultVal?: number, options?: ConfigOptions): number` * - Retrieves a number configuration value with a default value if not set. * - `getMessages(): IterableIterator<[string, string]>` * - Retrieves an iterator over the key-value pairs of the message store. * - `clearMessages(): void` * - Clears all messages stored in the message store.` * * NOTE: Freezing the object prevents modifications to the exported API. */ const configMan = Object.freeze({ /** * Retrieves a string configuration value with a default value if not set. * * @param config - The name of the config parameter to retrieve. * @param defaultVal - A default value if the config is not set. NOTE: This must be of the correct type. * @param configOptions - The config options. * @returns The string configuration value, or the default value if not set. */ getStr: (config, defaultVal, options) => { return get(config, ConfigType.String, defaultVal, options); }, /** * Retrieves a boolean configuration value with a default value if not set. * * @param config - The name of the config parameter to retrieve. * @param defaultVal - A default value if the config is not set. NOTE: This must be of the correct type. * @param configOptions - The config options. * @returns The boolean configuration value, or the default value if not set. */ getBool: (config, defaultVal, options) => { return get(config, ConfigType.Boolean, defaultVal, options); }, /** * Retrieves a number configuration value with a default value if not set. * * @param config - The name of the config parameter to retrieve. * @param defaultVal - A default value if the config is not set. NOTE: This must be of the correct type. * @param configOptions - The config options. * @returns The number configuration value, or the default value if not set. */ getNum: (config, defaultVal, options) => { return get(config, ConfigType.Number, defaultVal, options); }, /** * Retrieves an object configuration value with a default value if not set. * * @param config - The name of the config parameter to retrieve. * @param defaultVal - A default value if the config is not set. NOTE: This must be of the correct type. * @param configOptions - The config options. * @returns The object configuration value, or the default value if not set. */ getObject: (config, defaultVal, options) => { return get(config, ConfigType.Object, defaultVal, options); }, /** * Retrieves an array configuration value with a default value if not set. * * @param config - The name of the config parameter to retrieve. * @param defaultVal - A default value if the config is not set. NOTE: This must be of the correct type. * @param configOptions - The config options. * @returns The array configuration value, or the default value if not set. */ getArray: (config, defaultVal, options) => { return get(config, ConfigType.Array, defaultVal, options); }, /** * Retrieves an iterator over the key-value pairs of the message store. * * @returns An iterator over the key-value pairs of the message store. */ getMessages: () => { return _messageStore.entries(); }, /** * Clears all messages stored in the message store. */ clearMessages: () => { _messageStore.clear(); }, }); // Time to kick this puppy! init$1(); // imports here // Config consts here const CFG_LOG_LEVEL = "LOG_LEVEL"; const CFG_LOG_TIMESTAMP = "LOG_TIMESTAMP"; const CFG_LOG_TIMESTAMP_LOCALE = "LOG_TIMESTAMP_LOCALE"; const CFG_LOG_TIMESTAMP_TZ = "LOG_TIMESTAMP_TZ"; // Types here var LogLevel; (function (LogLevel) { LogLevel[LogLevel["COMPLETE_SILENCE"] = 0] = "COMPLETE_SILENCE"; LogLevel[LogLevel["QUIET"] = 100] = "QUIET"; LogLevel[LogLevel["INFO"] = 200] = "INFO"; LogLevel[LogLevel["START_UP"] = 250] = "START_UP"; LogLevel[LogLevel["DEBUG"] = 300] = "DEBUG"; LogLevel[LogLevel["TRACE"] = 400] = "TRACE"; })(LogLevel || (LogLevel = {})); // Logger class here class Logger { // Private properties here _name; _timestamp; _timestampLocale; _timestampTz; _logLevel; _formatter; // Private methods here /** * Generates a timestamp string to prefix log messages. * Returns an empty string if timestamps are disabled. Otherwise returns * the formatted timestamp string. */ timestamp() { // If we are not supposed to generate timestamps then return nothing if (!this._timestamp) { return ""; } let now = new Date(); // Check if we should formet the time in ISO format if (this._timestampLocale === "ISO") { // Make sure to add a trailing space! return `${now.toISOString()} `; } // Make sure to add a trailing space! return `${this._formatter.format(now)} `; } convertLevel(level) { let logLevel; switch (level.toUpperCase()) { case "": // This is in case it is not set logLevel = LogLevel.INFO; break; case "SILENT": logLevel = LogLevel.COMPLETE_SILENCE; break; case "QUIET": logLevel = LogLevel.QUIET; break; case "INFO": logLevel = LogLevel.INFO; break; case "STARTUP": logLevel = LogLevel.START_UP; break; case "DEBUG": logLevel = LogLevel.DEBUG; break; case "TRACE": logLevel = LogLevel.TRACE; break; default: throw new Error(`Log Level (${level}) is unknown.`); } return logLevel; } // constructor here constructor(name) { this._name = name; this._timestamp = configMan.getBool(CFG_LOG_TIMESTAMP, false); this._timestampLocale = configMan.getStr(CFG_LOG_TIMESTAMP_LOCALE, "ISO"); this._timestampTz = configMan.getStr(CFG_LOG_TIMESTAMP_TZ, "UTC"); this._logLevel = this.convertLevel(configMan.getStr(CFG_LOG_LEVEL, "")); this._formatter = new Intl.DateTimeFormat(this._timestampLocale, { timeZone: this._timestampTz, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false, fractionalSecondDigits: 3, }); // Now get the messages from the confgiMan for display let messages = configMan.getMessages(); for (const message of messages) { this.startupMsg("Logger", message[0]); } configMan.clearMessages(); } fatal(...args) { // fatals are always logged let msg = util.format(`${this.timestamp()}FATAL: ${this._name}: ${args[0]}`, ...args.slice(1)); console.error(msg); } error(...args) { // errors are always logged unless level = LOG_COMPLETE_SILENCE if (this._logLevel > LogLevel.COMPLETE_SILENCE) { let msg = util.format(`${this.timestamp()}ERROR: ${this._name}: ${args[0]}`, ...args.slice(1)); console.error(msg); } } warn(...args) { // warnings are always logged unless level = LOG_COMPLETE_SILENCE if (this._logLevel > LogLevel.COMPLETE_SILENCE) { let msg = util.format(`${this.timestamp()}WARN: ${this._name}: ${args[0]}`, ...args.slice(1)); console.warn(msg); } } info(...args) { if (this._logLevel >= LogLevel.INFO) { let msg = util.format(`${this.timestamp()}INFO: ${this._name}: ${args[0]}`, ...args.slice(1)); console.info(msg); } } startupMsg(...args) { if (this._logLevel >= LogLevel.START_UP) { let msg = util.format(`${this.timestamp()}STARTUP: ${this._name}: ${args[0]}`, ...args.slice(1)); console.info(msg); } } shutdownMsg(...args) { if (this._logLevel >= LogLevel.START_UP) { let msg = util.format(`${this.timestamp()}SHUTDOWN: ${this._name}: ${args[0]}`, ...args.slice(1)); console.info(msg); } } debug(...args) { if (this._logLevel >= LogLevel.DEBUG) { let msg = util.format(`${this.timestamp()}DEBUG: ${this._name}: ${args[0]}`, ...args.slice(1)); console.info(msg); } } trace(...args) { if (this._logLevel >= LogLevel.TRACE) { let msg = util.format(`${this.timestamp()}TRACE: ${this._name}: ${args[0]}`, ...args.slice(1)); console.info(msg); } } force(...args) { // forces are always logged even if level == LOG_COMPLETE_SILENCE let msg = util.format(`${this.timestamp()}FORCED: ${this._name}: ${args[0]}`, ...args.slice(1)); console.error(msg); } setLevel(level) { this._logLevel = level; } } // imports here // Consts here const SECRET_IV_SIZE = 12; const SECRET_KEY_SIZE = 32; const SECRET_ALGO = "aes-256-gcm"; const sleep = async (durationInSeconds) => { // Convert duration to ms let ms = Math.round(durationInSeconds * 1000); return new Promise((resolve) => { setTimeout(resolve, ms); }); }; const question = async (ask, questionOptions) => { let input = process.stdin; let output = process.stdout; let options = { muteAnswer: false, muteChar: "*", ...questionOptions, }; return new Promise((resolve) => { let rl = readline.createInterface({ input, output, }); if (options.muteAnswer) { input.on("keypress", () => { // get the number of characters entered so far: var len = rl.line.length; if (options.muteChar.length === 0) { // move cursor back one since we will always be at the start readline.moveCursor(output, -1, 0); // clear everything to the right of the cursor readline.clearLine(output, 1); } else { // move cursor back to the beginning of the input readline.moveCursor(output, -len, 0); // clear everything to the right of the cursor readline.clearLine(output, 1); // If there is a muteChar then replace the original input with it for (var i = 0; i < len; i++) { // In case the user passes a string just use the 1st char output.write(options.muteChar[0]); } } }); } // Insert a space after the question for convience rl.question(`${ask} `, (answer) => { resolve(answer); rl.close(); }); }); }; const encryptSecret = (secret, key, outputEncoding = "base64", // used for strings and Buffers inputEncoding = "utf-8") => { // Make sure the key len is 32 - this is a requirement if (key.length !== SECRET_KEY_SIZE) { return null; } // Check if the secret is a buffer or string const isBuffer = Buffer.isBuffer(secret); // Create a random IV const iv = crypto.randomBytes(SECRET_IV_SIZE); // Create a new cipher object const cipher = crypto.createCipheriv(SECRET_ALGO, key, iv); // Encrypt the secret - check if it is a string or Buffer let cipherText = isBuffer ? cipher.update(secret, undefined, outputEncoding) // No inputEncoding for Buffers : cipher.update(secret, inputEncoding, outputEncoding); cipherText += cipher.final(outputEncoding); // Generate the authentication tag const tag = cipher.getAuthTag(); const encrypted = { isBuffer, iv: iv.toString(outputEncoding), // convert the Buffer to string authTag: tag.toString(outputEncoding), // convert the Buffer to string cipherText, }; return encrypted; }; const decryptSecret = (secret, key, inputEncoding = "base64", // outputEncoding from encryptSecret outputEncoding = "utf-8") => { // Make sure the key len is 32 - this is a requirement if (key.length !== SECRET_KEY_SIZE) { return null; } // Create a decipher object const iv = Buffer.from(secret.iv, inputEncoding); const decipher = crypto.createDecipheriv(SECRET_ALGO, key, iv); // Set the authentication tag const tag = Buffer.from(secret.authTag, inputEncoding); decipher.setAuthTag(tag); let decrypted = null; // Decrypt the secret try { // Check if the secret is a Buffer or a string if (secret.isBuffer) { decrypted = Buffer.concat([ decipher.update(secret.cipherText, inputEncoding), // no outputEncoding for Buffers decipher.final(), // no outputEncoding for Buffers ]); } else { decrypted = decipher.update(secret.cipherText, inputEncoding, outputEncoding) + decipher.final(outputEncoding); } } catch (_) { } return decrypted; }; // NOTE: To use this with endpoints using self signed certs add this env var // NODE_TLS_REJECT_UNAUTHORIZED=0 // imports here // Misc consts here const LOG_TAG = "request"; // Module private variables here const _logger$1 = new Logger(LOG_TAG); // Error classes here class ReqAborted { timedOut; message; constructor(timedOut, message) { this.timedOut = timedOut; this.message = message; } } class ReqError { status; message; constructor(status, message) { this.status = status; this.message = message; } } // Private methods here async function callFetch(origin, path, options, body) { // Build the url let url = `${origin}${path}`; // And add the query string if one has been provided if (options.searchParams !== undefined) { url += `?${new URLSearchParams(options.searchParams)}`; } let results; let timeoutTimer; let attempt = 0; do { // Increment attempt attempt += 1; // Create an AbortController and use a timer if no signal has been provided if (options.signal === undefined) { const controller = new AbortController(); options.signal = controller.signal; // Start your engines! timeoutTimer = setTimeout(() => { controller.abort(); }, options.timeoutS * 1000); } results = await fetch(url, { method: options.method, headers: options.headers, body, keepalive: options.keepalive, cache: options.cache, credentials: options.credentials, mode: options.mode, redirect: options.redirect, referrer: options.referrer, referrerPolicy: options.referrerPolicy, signal: options.signal, }).catch((e) => { // OK, there was an error - check if the request was aborted if (e.name === "AbortError") { // If there was a timeoutTimer then the req must have timed out if (timeoutTimer !== undefined) { // NOTE: We DO NOT need to clear the timer since it already fired throw new ReqAborted(true, `Request timeout out after ${options.timeoutS} seconds`); } throw new ReqAborted(false, "Request aborted"); } // Check if we started a timer and if so stop it - it hasnt expired yet if (timeoutTimer !== undefined) { clearTimeout(timeoutTimer); } // We don't know what the error is so pass it up the chain throw e; }); // Check if we started a timer and if so stop it - it hasnt expired yet if (timeoutTimer !== undefined) { clearTimeout(timeoutTimer); } // Check if the fetch failed if (!results.ok) { // Figure out if we wait and try again or throw an error const status = results.status; if (attempt < options.retries + 1 && // Still attempts left (note mac attempts are numRetries + 1) (status >= 500 || // Server error status === 408 || // Req timeout status === 429) // Too many requests ) { // We can go again, now we need to introduce a delay // Use baseDelay as a multiple of the attempts and some random jitter const delay = options.baseDelayMs * 2 ** attempt; const jitter = Math.random() * delay * 0.5; const totalDelay = Math.round(delay + jitter); _logger$1.trace("Call to (%s) failed. Retrying in (%d) ms.", url, totalDelay); // Now just sleep for the delay await sleep(totalDelay / 1000); } else { // If we are here we are not going to retry // So, get the last error and throw it let message = await results.text(); throw new ReqError(status, message.length === 0 ? results.statusText : message); } } // Loop if the results are NOT OK } while (!results.ok); // We can only be here if everything was OK return results; } async function handleResponseData(results) { // No point worrying if the body is JSON at first, because we know its text const body = await results.text(); // If the body exists then check if it is JSON if (body.length > 0) { // Check if the content type is JSON const contentType = results.headers.get("content-type"); if (contentType?.startsWith("application/json")) { return JSON.parse(body); } } // If we are here, the body wasnt JSON so just return the text return body; } // Public methods here let request = async (origin, path, reqOptions) => { // We need to remember the start time const startTime = performance$1.now(); _logger$1.trace("Request for origin (%s) path (%s)", origin, path); // Set the default values let options = { method: "GET", timeoutS: 60, retries: 3, baseDelayMs: 500, keepalive: true, handleResponse: true, cache: "no-store", mode: "cors", credentials: "include", redirect: "follow", referrerPolicy: "no-referrer", ...reqOptions, }; // Make sure the headers is set to something for later if (options.headers === undefined) { options.headers = {}; } // If a bearer token is provided then add a Bearer auth header if (options.bearerToken !== undefined) { options.headers.Authorization = `Bearer ${options.bearerToken}`; } // If the basic auth creds are provided add a Basic auth header if (options.auth !== undefined) { let token = Buffer.from(`${options.auth.username}:${options.auth.password}`).toString("base64"); options.headers.Authorization = `Basic ${token}`; } let payloadBody; // Automatically stringify and set the header if this is a JSON payload // BUT dont do it for GETs and DELETE since they can have no body if (options.body !== undefined && options.method !== "GET" && options.method !== "DELETE") { // Rem an array is an object to! if (typeof options.body === "object") { // Add the content-type if it hasn't been provided if (options.headers?.["content-type"] === undefined) { options.headers["content-type"] = "application/json; charset=utf-8"; } payloadBody = JSON.stringify(options.body); } else { payloadBody = options.body; } } // Call fetch let response = await callFetch(origin, path, options, payloadBody); // Build the response let res = { statusCode: response.status, headers: response.headers, body: undefined, // set to undefined for now responseTime: 0, }; // Check if we should handle the response for the user if (options.handleResponse) { // Yes, so handle and set the body res.body = await handleResponseData(response).catch((e) => { const msg = `Error handling response data for (${origin}) (${path}) - (${e}))`; throw new Error(msg); }); } else { // No, so set the response res.response = response; } // Don't forget to set the response time res.responseTime = Math.round(performance$1.now() - startTime); _logger$1.trace("Request for origin (%s) path (%s) took (%i) ms", origin, path, res.responseTime); return res; }; // Classes here class HttpError { status; message; constructor(status, message = "Achtung Baby!") { this.status = status; this.message = message; } } class HttpRedirect { statusCode; location; message; constructor(statusCode = 302, location, message = "") { this.statusCode = statusCode; this.location = location; this.message = message; } } // This method enhances an existing IncomingMessage to be a ServerRequest const enhanceIncomingMessage = (req) => { // Treat req like a ServerRequest (which it is minus some props and methods) let enhancedReq = req; // Now set all of the props that make it a ServerRequest // Check for a valid url or "new URL()" to throw // NOTE: I can not figure out a good way to tell if the req is HTTP or HTTPS // but I dont think it matters here unless someone needs the protocol prop enhancedReq.validUrl = URL.canParse(req.url, `https://${req.headers.host}`); // If URL is valid check to see if the it has any encodings that arent valid if (enhancedReq.validUrl) { try { // This will not throw if everything is good decodeURIComponent(req.url); } catch { // if we are here then the URL encoding is not valid so flag it enhancedReq.validUrl = false; } } // If the URL is not valid then we should return now if (!enhancedReq.validUrl) { return enhancedReq; } // If we are here we know the URL is valid so it is safe to use it enhancedReq.urlObj = new URL(req.url, `https://${req.headers.host}`); enhancedReq.params = {}; enhancedReq.handled = false; // By default we will compress the response enhancedReq.compressResponse = true; // By default we shld check all the different types of routes enhancedReq.checkApiRoutes = true; enhancedReq.checkSsrRoutes = true; enhancedReq.checkStaticFiles = true; // By default the req handler is expected to handle the 404 enhancedReq.handle404 = true; // Now set all of the public methods that make it a ServerRequest enhancedReq.getCookie = (cookieName) => { // Get the cookie header and spilt it up by cookies - // NOTE: cookies are separated by semi colons let cookies = enhancedReq.headers.cookie?.split(";"); if (cookies === undefined) { // Nothing to do so just return return null; } // Loop through the cookies for (let cookie of cookies) { // Split the cookie up into a key value pair // NOTE: key/value is separated by an equals sign and has leading spaces let [name, value] = cookie.trim().split("="); // Make sure it was a validly formatted cookie if (value === undefined) { // It is not a valid cookie so skip it continue; } // Check if we found the cookie if (name === cookieName) { // Return the cookie value return value; } } return null; }; enhancedReq.setServerTimingHeader = (value) => { enhancedReq.headers["Server-Timing"] = value; }; enhancedReq.getBearerToken = () => { const authzHeader = req.headers["authorization"]; // The authz header MUST be a string if (typeof authzHeader !== "string") { return null; } // This is the length of "Bearer " in the authZ header // NOTE: Must include the space at the end! const LEN_OF_BEARER = 7; if (authzHeader.length > LEN_OF_BEARER && authzHeader.slice(0, LEN_OF_BEARER).toLowerCase().startsWith("bearer ")) { return authzHeader.slice(LEN_OF_BEARER); } return null; }; return enhancedReq; }; // This method enhances an existing ServerResponse to be a ServerResponse const enhanceServerResponse = (res) => { // Treat res like a ServerResponse (which it is minus the props and methods) let enhancedRes = res; // Now set all of the props that make it a ServerRequest // NOTE: enhancedRes will be created at the same time as ServerRequest enhancedRes.receiveTime = performance$1.now(); enhancedRes.redirected = false; enhancedRes.latencyMetricName = "latency"; enhancedRes.serverTimingsMetrics = []; enhancedRes.enhancedReq = enhancedRes.req; // Now set all of the public methods that make it a ServerResponse enhancedRes.setCookies = (cookies) => { let setCookiesValue = []; // Check for exiting cookies and add them to the setCookiesValue array let existing = enhancedRes.getHeader("Set-Cookie"); if (typeof existing === "string") { setCookiesValue.push(existing); } else if (Array.isArray(existing)) { setCookiesValue = existing; } // Loop through each cookie and build the cookie values for (let cookie of cookies) { // Set the cookie value first let value = `${cookie.name}=${cookie.value}`; // if there is a maxAge then set it - NOTE: put ";" first if (cookie.maxAge !== undefined) { value += `; Max-Age=${cookie.maxAge}`; } // If there is a path then set it or use default path of "/" - NOTE: put ";" first if (cookie.path !== undefined) { value += `; Path=${cookie.path}`; } else { value += `; Path=/`; } // If httpOnly is indicated then add it - NOTE: put ";" first if (cookie.httpOnly === true) { value += "; HttpOnly"; } // If secure is indicated set then add it - NOTE: put ";" first if (cookie.secure === true) { value += "; Secure"; } // If sameSite has been provided then add it - NOTE: put ";" first if (cookie.sameSite !== undefined) { value += `; SameSite=${cookie.sameSite}`; } // If domain has been provided then add it - NOTE: put ";" first if (cookie.domain !== undefined) { value += `; Domain=${cookie.domain}`; } // Save the cookie setCookiesValue.push(value); } // Finally set the cookie/s in the response header enhancedRes.setHeader("Set-Cookie", setCookiesValue); }; enhancedRes.clearCookies = (cookies, domain) => { let httpCookies = []; for (let cookie of cookies) { // To clear a cookie - set value to empty string and max age to -1 httpCookies.push({ name: cookie, value: "", maxAge: -1, domain }); } enhancedRes.setCookies(httpCookies); }; enhancedRes.setServerTimingHeader = () => { let serverTimingHeaders = []; // Check if the req has a Server-Timing header. This is not normal but I // want to something like a forwardAuth server to be able to add it's // metrics to the response header if (enhancedRes?.req?.headers["server-timing"] !== undefined) { const reqTimings = enhancedRes.req.headers["server-timing"]; // Check if there are multiple headers if (Array.isArray(reqTimings)) { // If so then since this is the first just use it as the headers array serverTimingHeaders = reqTimings; } else { // If not then just add it to the array serverTimingHeaders.push(reqTimings); } } let serverTimingValue = ""; // Add each additional metric added to the res next so they are in // the order they were added for (let metric of enhancedRes.serverTimingsMetrics) { // Check if we have a string or a metric object if (typeof metric === "string") { // The string version is already formatted so just add to the array serverTimingHeaders.push(metric); continue; } // If we are here then we have a metric object so add the name serverTimingValue += metric.name; // Check if there is an optional duration if (metric.duration !== undefined) { serverTimingValue += `;dur=${metric.duration}`; } // Check if there is an optional description if (metric.description !== undefined) { serverTimingValue += `;desc="${metric.description}"`; } serverTimingValue += ", "; } // Finally add the total latency for the endpoint to the array const latency = Math.round(performance$1.now() - enhancedRes.receiveTime); serverTimingValue += `${enhancedRes.latencyMetricName};dur=${latency}`; serverTimingHeaders.push(serverTimingValue); // Of course don't forget to set the header!! enhancedRes.setHeader("Server-Timing", serverTimingHeaders); }; enhancedRes.redirect = (location, statusCode = 302, message = "") => { enhancedRes.redirected = true; let htmlMessage = message.length > 0 ? message : `Redirected to <a href="${location}">here</a>`; // Write a little something something for good measure enhancedRes.body = ` <html> <body> <p>${htmlMessage}</p> </body> </html>`; enhancedRes.setHeader("Content-Type", "text/html; charset=utf-8"); enhancedRes.setHeader("Location", location); enhancedRes.statusCode = statusCode; }; enhancedRes.addServerTimingMetric = (name, duration, description) => { // This adds a metric to the Server-Timing header for this response enhancedRes.serverTimingsMetrics.push({ name, duration, description }); }; enhancedRes.addServerTimingHeader = (header) => { // This adds a complete Server-Timing header to this response enhancedRes.serverTimingsMetrics.push(header); }; return enhancedRes; }; class WebRequest extends Request { req; res; constructor(req, res) { // Get the headers so we can pass them on const headers = new Headers(); for (const [name, value] of Object.entries(req.headers)) { // Skip headers with no values if (value === undefined) { continue; } // Value can be an array so we need to handle that if (Array.isArray(value)) { // Append each value as the header name for (const item of value) { headers.append(name, item); } } else { headers.append(name, value); } } // Set the basic options for the Request let opts = { method: req.method, headers, }; // Check if there is a body that we should pass as part of the Request // First check the req type - only pass body for POST, PUT and PATCH reqs if (req.method === "POST" || req.method === "PUT" || req.method === "PATCH") { // Next check the is content indicated by either: // 1 - content length exists and is not "0" OR // 2 - content length doesnt exist and the transfer encoding is "chunked" const contentLength = req.headers["content-length"]; const transferEncoding = req.headers["transfer-encoding"]; if ((contentLength !== undefined && contentLength !== "0") || (contentLength === undefined && transferEncoding === "chunked")) { // If we are here we want to pass the body opts.body = Readable.toWeb(req); opts.duplex = "half"; } } // Now call the super constructor to create the Request // NOTE: Request is a Web standard and doesnt use Buffers so convert req.body super(req.urlObj, opts); // Make sure to tack on the nodeJs req/res this.req = req; this.res = r