@bs-core/shell
Version:
The Bamboo Shell
1,285 lines (1,280 loc) • 171 kB
JavaScript
import * as fs from 'node:fs';
import * as util from 'node:util';
import { performance } from 'node:perf_hooks';
import * as crypto from 'node:crypto';
import * as path from 'node:path';
import * as zlib from 'node:zlib';
import { pipeline } from 'node:stream/promises';
import * as stream from 'node:stream';
import * as fsPromises from 'node:fs/promises';
import * as http from 'node:http';
import * as https from 'node:https';
import * as os from 'node:os';
import * as readline from 'node:readline';
/**
* 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 {
_messageStore.add("No .env file specified");
}
// 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;
// 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();
if (this._timestampLocale === "ISO") {
// Make sure to add a trailing space!
return `${now.toISOString()} `;
}
// Make sure to add a trailing space!
return `${now.toLocaleString(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,
})} `;
}
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, ""));
// 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;
}
}
// 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 timeoutTimer;
// Create an AbortController if a timeout has been provided
if (options.timeout) {
const controller = new AbortController();
// NOTE: this will overwrite a signal if one has been provided
options.signal = controller.signal;
timeoutTimer = setTimeout(() => {
controller.abort();
}, options.timeout * 1000);
}
let 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) => {
// Check if the request was aborted
if (e.name === "AbortError") {
// If timeout was set then the req must have timed out
if (options.timeout) {
throw new ReqAborted(true, `Request timeout out after ${options.timeout} seconds`);
}
throw new ReqAborted(false, "Request aborted");
}
// Need to check if we started a timeout
if (timeoutTimer !== undefined) {
clearTimeout(timeoutTimer);
}
// We don't know what the error is so pass it back
throw e;
});
// Need to check if we started a timeout
if (timeoutTimer !== undefined) {
clearTimeout(timeoutTimer);
}
// We will throw an error if the response is not 2XX
if (!results.ok) {
let message = await results.text();
throw new ReqError(results.status, message.length === 0 ? results.statusText : message);
}
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.now();
_logger$1.trace("Request for origin (%s) path (%s)", origin, path);
// Set the default values
let options = {
method: "GET",
timeout: 0,
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.now() - startTime);
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 the props and methods)
let enhancedReq = req;
// Now set all of the props that make it a ServerRequest
enhancedReq.validUrl = true;
// Best to put this in a try/catch because if the req.url = // this will
// throw an error as will a handful of other cases
try {
// 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.urlObj = new URL(req.url, `https://${req.headers.host}`);
}
catch (e) {
// Mark this as an invalid URL
enhancedReq.validUrl = false;
}
if (!enhancedReq.validUrl) {
return enhancedReq;
}
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;
};
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.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.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;
};
const contentTypes = {
"123": "application/vnd.lotus-1-2-3",
"1km": "application/vnd.1000minds.decision-model+xml",
"3dml": "text/vnd.in3d.3dml",
"3ds": "image/x-3ds",
"3g2": "video/3gpp2",
"3gp": "video/3gpp",
"3gpp": "video/3gpp",
"3mf": "model/3mf",
"7z": "application/x-7z-compressed",
"disposition-notification": "message/disposition-notification",
"n-gage": "application/vnd.nokia.n-gage.symbian.install",
"sfd-hdstx": "application/vnd.hydrostatix.sof-data",
"vbox-extpack": "application/x-virtualbox-vbox-extpack",
aab: "application/x-authorware-bin",
aac: "audio/x-aac",
aam: "application/x-authorware-map",
aas: "application/x-authorware-seg",
abw: "application/x-abiword",
ac: "application/vnd.nokia.n-gage.ac+xml",
acc: "application/vnd.americandynamics.acc",
ace: "application/x-ace-compressed",
acu: "application/vnd.acucobol",
acutc: "application/vnd.acucorp",
adp: "audio/adpcm",
adts: "audio/aac",
aep: "application/vnd.audiograph",
afm: "application/x-font-type1",
afp: "application/vnd.ibm.modcap",
age: "application/vnd.age",
ahead: "application/vnd.ahead.space",
ai: "application/postscript",
aif: "audio/x-aiff",
aifc: "audio/x-aiff",
aiff: "audio/x-aiff",
air: "application/vnd.adobe.air-application-installer-package+zip",
ait: "application/vnd.dvb.ait",
ami: "application/vnd.amiga.ami",
aml: "application/automationml-aml+xml",
amlx: "application/automationml-amlx+zip",
amr: "audio/amr",
apk: "application/vnd.android.package-archive",
apng: "image/apng",
appcache: "text/cache-manifest",
appinstaller: "application/appinstaller",
application: "application/x-ms-application",
appx: "application/appx",
appxbundle: "application/appxbundle",
apr: "application/vnd.lotus-approach",
arc: "application/x-freearc",
arj: "application/x-arj",
asc: "application/pgp-signature",
asf: "video/x-ms-asf",
asm: "text/x-asm",
aso: "application/vnd.accpac.simply.aso",
asx: "video/x-ms-asf",
atc: "application/vnd.acucorp",
atom: "application/atom+xml",
atomcat: "application/atomcat+xml",
atomdeleted: "application/atomdeleted+xml",
atomsvc: "application/atomsvc+xml",
atx: "application/vnd.antix.game-component",
au: "audio/basic",
avci: "image/avci",
avcs: "image/avcs",
avi: "video/x-msvideo",
avif: "image/avif",
aw: "application/applixware",
azf: "application/vnd.airzip.filesecure.azf",
azs: "application/vnd.airzip.filesecure.azs",
azv: "image/vnd.airzip.accelerator.azv",
azw: "application/vnd.amazon.ebook",
b16: "image/vnd.pco.b16",
bat: "application/x-msdownload",
bcpio: "application/x-bcpio",
bdf: "application/x-font-bdf",
bdm: "application/vnd.syncml.dm+wbxml",
bdoc: "application/x-bdoc",
bed: "application/vnd.realvnc.bed",
bh2: "application/vnd.fujitsu.oasysprs",
bin: "application/octet-stream",
blb: "application/x-blorb",
blorb: "application/x-blorb",
bmi: "application/vnd.bmi",
bmml: "application/vnd.balsamiq.bmml+xml",
bmp: "image/x-ms-bmp",
book: "application/vnd.framemaker",
box: "application/vnd.previewsystems.box",
boz: "application/x-bzip2",
bpk: "application/octet-stream",
bsp: "model/vnd.valve.source.compiled-map",
btf: "image/prs.btif",
btif: "image/prs.btif",
buffer: "application/octet-stream",
bz2: "application/x-bzip2",
bz: "application/x-bzip",
c11amc: "application/vnd.cluetrust.cartomobile-config",
c11amz: "application/vnd.cluetrust.cartomobile-config-pkg",
c4d: "application/vnd.clonk.c4group",
c4f: "application/vnd.clonk.c4group",
c4g: "application/vnd.clonk.c4group",
c4p: "application/vnd.clonk.c4group",
c4u: "application/vnd.clonk.c4group",
c: "text/x-c",
cab: "application/vnd.ms-cab-compressed",
caf: "audio/x-caf",
cap: "application/vnd.tcpdump.pcap",
car: "application/vnd.curl.car",
cat: "application/vnd.ms-pki.seccat",
cb7: "application/x-cbr",
cba: "application/x-cbr",
cbr: "application/x-cbr",
cbt: "application/x-cbr",
cbz: "application/x-cbr",
cc: "text/x-c",
cco: "application/x-cocoa",
cct: "application/x-director",
ccxml: "application/ccxml+xml",
cdbcmsg: "application/vnd.contact.cmsg",
cdf: "application/x-netcdf",
cdfx: "application/cdfx+xml",
cdkey: "application/vnd.mediastation.cdkey",
cdmia: "application/cdmi-capability",
cdmic: "application/cdmi-container",
cdmid: "application/cdmi-domain",
cdmio: "application/cdmi-object",
cdmiq: "application/cdmi-queue",
cdx: "chemical/x-cdx",
cdxml: "application/vnd.chemdraw+xml",
cdy: "application/vnd.cinderella",
cer: "application/pkix-cert",
cfs: "application/x-cfs-compressed",
cgm: "image/cgm",
chat: "application/x-chat",
chm: "application/vnd.ms-htmlhelp",
chrt: "application/vnd.kde.kchart",
cif: "chemical/x-cif",
cii: "application/vnd.anser-web-certificate-issue-initiation",
cil: "application/vnd.ms-artgalry",
cjs: "application/node",
cla: "application/vnd.claymore",
class: "application/java-vm",
cld: "model/vnd.cld",
clkk: "application/vnd.crick.clicker.keyboard",
clkp: "application/vnd.crick.clicker.palette",
clkt: "application/vnd.crick.clicker.template",
clkw: "application/vnd.crick.clicker.wordbank",
clkx: "application/vnd.crick.clicker",
clp: "application/x-msclip",
cmc: "application/vnd.cosmocaller",
cmdf: "chemical/x-cmdf",
cml: "chemical/x-cml",
cmp: "application/vnd.yellowriver-custom-menu",
cmx: "image/x-cmx",
cod: "application/vnd.rim.cod",
coffee: "text/coffeescript",
com: "application/x-msdownload",
conf: "text/plain",
cpio: "application/x-cpio",
cpl: "application/cpl+xml",
cpp: "text/x-c",
cpt: "application/mac-compactpro",
crd: "application/x-mscardfile",
crl: "application/pkix-crl",
crt: "application/x-x509-ca-cert",
crx: "application/x-chrome-extension",
cryptonote: "application/vnd.rig.cryptonote",
csh: "application/x-csh",
csl: "application/vnd.citationstyles.style+xml",
csml: "chemical/x-csml",
csp: "application/vnd.commonspace",
css: "text/css",
cst: "application/x-director",
csv: "text/csv",
cu: "application/cu-seeme",
curl: "text/vnd.curl",
cwl: "application/cwl",
cww: "application/prs.cww",
cxt: "application/x-director",
cxx: "text/x-c",
dae: "model/vnd.collada+xml",
daf: "application/vnd.mobius.daf",
dart: "application/vnd.dart",
dataless: "application/vnd.fdsn.seed",
davmount: "application/davmount+xml",
dbf: "application/vnd.dbf",
dbk: "application/docbook+xml",
dcr: "application/x-director",
dcurl: "text/vnd.curl.dcurl",
dd2: "application/vnd.oma.dd2+xml",
ddd: "application/vnd.fujixerox.ddd",
ddf: "application/vnd.syncml.dmddf+xml",
dds: "image/vnd.ms-dds",
deb: "application/x-debian-package",
def: "text/plain",
deploy: "application/octet-stream",
der: "application/x-x509-ca-cert",
dfac: "application/vnd.dreamfactory",
dgc: "application/x-dgc-compressed",
dib: "image/bmp",
dic: "text/x-c",
dir: "application/x-director",
dis: "application/vnd.mobius.dis",
dist: "application/octet-stream",
distz: "application/octet-stream",
djv: "image/vnd.djvu",
djvu: "image/vnd.djvu",
dll: "application/x-msdownload",
dmg: "application/x-apple-diskimage",
dmp: "application/vnd.tcpdump.pcap",
dms: "application/octet-stream",
dna: "application/vnd.dna",
doc: "application/msword",
docm: "application/vnd.ms-word.document.macroenabled.12",
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
dot: "application/msword",
dotm: "application/vnd.ms-word.template.macroenabled.12",
dotx: "application/vnd.openxmlformats-officedocument.wordprocessingml.template",
dp: "application/vnd.osgi.dp",
dpg: "application/vnd.dpgraph",
dpx: "image/dpx",
dra: "audio/vnd.dra",
drle: "image/dicom-rle",
dsc: "text/prs.lines.tag",
dssc: "application/dssc+der",
dtb: "application/x-dtbook+xml",
dtd: "application/xml-dtd",
dts: "audio/vnd.dts",
dtshd: "audio/vnd.dts.hd",
dump: "application/octet-stream",
dvb: "video/vnd.dvb.file",
dvi: "application/x-dvi",
dwd: "application/atsc-dwd+xml",
dwf: "model/vnd.dwf",
dwg: "image/vnd.dwg",
dxf: "image/vnd.dxf",
dxp: "application/vnd.spotfire.dxp",
dxr: "application/x-director",
ear: "application/java-archive",
ecelp4800: "audio/vnd.nuera.ecelp4800",
ecelp7470: "audio/vnd.nuera.ecelp7470",
ecelp9600: "audio/vnd.nuera.ecelp9600",
ecma: "application/ecmascript",
edm: "application/vnd.novadigm.edm",
edx: "application/vnd.novadigm.edx",
efif: "application/vnd.picsel",
ei6: "application/vnd.pg.osasli",
elc: "application/octet-stream",
emf: "image/emf",
eml: "message/rfc822",
emma: "application/emma+xml",
emotionml: "application/emotionml+xml",
emz: "application/x-msmetafile",
eol: "audio/vnd.digital-winds",
eot: "application/vnd.ms-fontobject",
eps: "application/postscript",
epub: "application/epub+zip",
es3: "applicati