@bs-core/shell
Version:
The Bamboo Shell
1,252 lines (1,247 loc) • 191 kB
JavaScript
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