mirror of
https://github.com/SillyTavern/SillyTavern.git
synced 2025-06-05 21:59:27 +02:00
1057 lines
33 KiB
JavaScript
1057 lines
33 KiB
JavaScript
import path from 'node:path';
|
|
import fs from 'node:fs';
|
|
import http2 from 'node:http2';
|
|
import process from 'node:process';
|
|
import { Readable } from 'node:stream';
|
|
import { createRequire } from 'node:module';
|
|
import { Buffer } from 'node:buffer';
|
|
import { promises as dnsPromise } from 'node:dns';
|
|
import os from 'node:os';
|
|
|
|
import yaml from 'yaml';
|
|
import { sync as commandExistsSync } from 'command-exists';
|
|
import _ from 'lodash';
|
|
import yauzl from 'yauzl';
|
|
import mime from 'mime-types';
|
|
import { default as simpleGit } from 'simple-git';
|
|
import chalk from 'chalk';
|
|
import { LOG_LEVELS } from './constants.js';
|
|
import bytes from 'bytes';
|
|
|
|
/**
|
|
* Parsed config object.
|
|
*/
|
|
let CACHED_CONFIG = null;
|
|
|
|
/**
|
|
* Converts a configuration key to an environment variable key.
|
|
* @param {string} key Configuration key
|
|
* @returns {string} Environment variable key
|
|
* @example keyToEnv('extensions.models.speechToText') // 'SILLYTAVERN_EXTENSIONS_MODELS_SPEECHTOTEXT'
|
|
*/
|
|
export const keyToEnv = (key) => 'SILLYTAVERN_' + String(key).toUpperCase().replace(/\./g, '_');
|
|
|
|
/**
|
|
* Returns the config object from the config.yaml file.
|
|
* @returns {object} Config object
|
|
*/
|
|
export function getConfig() {
|
|
if (CACHED_CONFIG) {
|
|
return CACHED_CONFIG;
|
|
}
|
|
|
|
if (!fs.existsSync('./config.yaml')) {
|
|
console.error(color.red('No config file found. Please create a config.yaml file. The default config file can be found in the /default folder.'));
|
|
console.error(color.red('The program will now exit.'));
|
|
process.exit(1);
|
|
}
|
|
|
|
try {
|
|
const config = yaml.parse(fs.readFileSync(path.join(process.cwd(), './config.yaml'), 'utf8'));
|
|
CACHED_CONFIG = config;
|
|
return config;
|
|
} catch (error) {
|
|
console.error(color.red('FATAL: Failed to read config.yaml. Please check the file for syntax errors.'));
|
|
console.error(error.message);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns the value for the given key from the config object.
|
|
* @param {string} key - Key to get from the config object
|
|
* @param {any} defaultValue - Default value to return if the key is not found
|
|
* @param {'number'|'boolean'|null} typeConverter - Type to convert the value to
|
|
* @returns {any} Value for the given key
|
|
*/
|
|
export function getConfigValue(key, defaultValue = null, typeConverter = null) {
|
|
function _getValue() {
|
|
const envKey = keyToEnv(key);
|
|
if (envKey in process.env) {
|
|
const needsJsonParse = defaultValue && typeof defaultValue === 'object';
|
|
const envValue = process.env[envKey];
|
|
return needsJsonParse ? (tryParse(envValue) ?? defaultValue) : envValue;
|
|
}
|
|
const config = getConfig();
|
|
return _.get(config, key, defaultValue);
|
|
}
|
|
|
|
const value = _getValue();
|
|
switch (typeConverter) {
|
|
case 'number':
|
|
return isNaN(parseFloat(value)) ? defaultValue : parseFloat(value);
|
|
case 'boolean':
|
|
return toBoolean(value);
|
|
default:
|
|
return value;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* THIS FUNCTION IS DEPRECATED AND ONLY EXISTS FOR BACKWARDS COMPATIBILITY. DON'T USE IT.
|
|
* @param {any} _key Unused
|
|
* @param {any} _value Unused
|
|
* @deprecated Configs are read-only. Use environment variables instead.
|
|
*/
|
|
export function setConfigValue(_key, _value) {
|
|
console.trace(color.yellow('setConfigValue is deprecated and should not be used.'));
|
|
}
|
|
|
|
/**
|
|
* Encodes the Basic Auth header value for the given user and password.
|
|
* @param {string} auth username:password
|
|
* @returns {string} Basic Auth header value
|
|
*/
|
|
export function getBasicAuthHeader(auth) {
|
|
const encoded = Buffer.from(`${auth}`).toString('base64');
|
|
return `Basic ${encoded}`;
|
|
}
|
|
|
|
/**
|
|
* Returns the version of the running instance. Get the version from the package.json file and the git revision.
|
|
* Also returns the agent string for the Horde API.
|
|
* @returns {Promise<{agent: string, pkgVersion: string, gitRevision: string | null, gitBranch: string | null, commitDate: string | null, isLatest: boolean}>} Version info object
|
|
*/
|
|
export async function getVersion() {
|
|
let pkgVersion = 'UNKNOWN';
|
|
let gitRevision = null;
|
|
let gitBranch = null;
|
|
let commitDate = null;
|
|
let isLatest = true;
|
|
|
|
try {
|
|
const require = createRequire(import.meta.url);
|
|
const pkgJson = require(path.join(process.cwd(), './package.json'));
|
|
pkgVersion = pkgJson.version;
|
|
if (commandExistsSync('git')) {
|
|
const git = simpleGit();
|
|
const cwd = process.cwd();
|
|
gitRevision = await git.cwd(cwd).revparse(['--short', 'HEAD']);
|
|
gitBranch = await git.cwd(cwd).revparse(['--abbrev-ref', 'HEAD']);
|
|
commitDate = await git.cwd(cwd).show(['-s', '--format=%ci', gitRevision]);
|
|
|
|
const trackingBranch = await git.cwd(cwd).revparse(['--abbrev-ref', '@{u}']);
|
|
|
|
// Might fail, but exception is caught. Just don't run anything relevant after in this block...
|
|
const localLatest = await git.cwd(cwd).revparse(['HEAD']);
|
|
const remoteLatest = await git.cwd(cwd).revparse([trackingBranch]);
|
|
isLatest = localLatest === remoteLatest;
|
|
}
|
|
}
|
|
catch {
|
|
// suppress exception
|
|
}
|
|
|
|
const agent = `SillyTavern:${pkgVersion}:Cohee#1207`;
|
|
return { agent, pkgVersion, gitRevision, gitBranch, commitDate: commitDate?.trim() ?? null, isLatest };
|
|
}
|
|
|
|
/**
|
|
* Delays the current async function by the given amount of milliseconds.
|
|
* @param {number} ms Milliseconds to wait
|
|
* @returns {Promise<void>} Promise that resolves after the given amount of milliseconds
|
|
*/
|
|
export function delay(ms) {
|
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
}
|
|
|
|
/**
|
|
* Generates a random hex string of the given length.
|
|
* @param {number} length String length
|
|
* @returns {string} Random hex string
|
|
* @example getHexString(8) // 'a1b2c3d4'
|
|
*/
|
|
export function getHexString(length) {
|
|
const chars = '0123456789abcdef';
|
|
let result = '';
|
|
for (let i = 0; i < length; i++) {
|
|
result += chars[Math.floor(Math.random() * chars.length)];
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Formats a byte size into a human-readable string with units
|
|
* @param {number} bytes - The size in bytes to format
|
|
* @returns {string} The formatted string (e.g., "1.5 MB")
|
|
*/
|
|
export function formatBytes(bytes) {
|
|
if (bytes === 0) return '0 B';
|
|
|
|
const k = 1024;
|
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
}
|
|
|
|
/**
|
|
* Extracts a file with given extension from an ArrayBuffer containing a ZIP archive.
|
|
* @param {ArrayBufferLike} archiveBuffer Buffer containing a ZIP archive
|
|
* @param {string} fileExtension File extension to look for
|
|
* @returns {Promise<Buffer|null>} Buffer containing the extracted file. Null if the file was not found.
|
|
*/
|
|
export async function extractFileFromZipBuffer(archiveBuffer, fileExtension) {
|
|
return await new Promise((resolve, reject) => yauzl.fromBuffer(Buffer.from(archiveBuffer), { lazyEntries: true }, (err, zipfile) => {
|
|
if (err) reject(err);
|
|
|
|
zipfile.readEntry();
|
|
zipfile.on('entry', (entry) => {
|
|
if (entry.fileName.endsWith(fileExtension) && !entry.fileName.startsWith('__MACOSX')) {
|
|
console.info(`Extracting ${entry.fileName}`);
|
|
zipfile.openReadStream(entry, (err, readStream) => {
|
|
if (err) {
|
|
reject(err);
|
|
} else {
|
|
const chunks = [];
|
|
readStream.on('data', (chunk) => {
|
|
chunks.push(chunk);
|
|
});
|
|
|
|
readStream.on('end', () => {
|
|
const buffer = Buffer.concat(chunks);
|
|
resolve(buffer);
|
|
zipfile.readEntry(); // Continue to the next entry
|
|
});
|
|
}
|
|
});
|
|
} else {
|
|
zipfile.readEntry();
|
|
}
|
|
});
|
|
zipfile.on('end', () => resolve(null));
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Extracts all images from a ZIP archive.
|
|
* @param {string} zipFilePath Path to the ZIP archive
|
|
* @returns {Promise<[string, Buffer][]>} Array of image buffers
|
|
*/
|
|
export async function getImageBuffers(zipFilePath) {
|
|
return new Promise((resolve, reject) => {
|
|
// Check if the zip file exists
|
|
if (!fs.existsSync(zipFilePath)) {
|
|
reject(new Error('File not found'));
|
|
return;
|
|
}
|
|
|
|
const imageBuffers = [];
|
|
|
|
yauzl.open(zipFilePath, { lazyEntries: true }, (err, zipfile) => {
|
|
if (err) {
|
|
reject(err);
|
|
} else {
|
|
zipfile.readEntry();
|
|
zipfile.on('entry', (entry) => {
|
|
const mimeType = mime.lookup(entry.fileName);
|
|
if (mimeType && mimeType.startsWith('image/') && !entry.fileName.startsWith('__MACOSX')) {
|
|
console.info(`Extracting ${entry.fileName}`);
|
|
zipfile.openReadStream(entry, (err, readStream) => {
|
|
if (err) {
|
|
reject(err);
|
|
} else {
|
|
const chunks = [];
|
|
readStream.on('data', (chunk) => {
|
|
chunks.push(chunk);
|
|
});
|
|
|
|
readStream.on('end', () => {
|
|
imageBuffers.push([path.parse(entry.fileName).base, Buffer.concat(chunks)]);
|
|
zipfile.readEntry(); // Continue to the next entry
|
|
});
|
|
}
|
|
});
|
|
} else {
|
|
zipfile.readEntry(); // Continue to the next entry
|
|
}
|
|
});
|
|
|
|
zipfile.on('end', () => {
|
|
resolve(imageBuffers);
|
|
});
|
|
|
|
zipfile.on('error', (err) => {
|
|
reject(err);
|
|
});
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Gets all chunks of data from the given readable stream.
|
|
* @param {any} readableStream Readable stream to read from
|
|
* @returns {Promise<Buffer[]>} Array of chunks
|
|
*/
|
|
export async function readAllChunks(readableStream) {
|
|
return new Promise((resolve, reject) => {
|
|
// Consume the readable stream
|
|
const chunks = [];
|
|
readableStream.on('data', (chunk) => {
|
|
chunks.push(chunk);
|
|
});
|
|
|
|
readableStream.on('end', () => {
|
|
//console.log('Finished reading the stream.');
|
|
resolve(chunks);
|
|
});
|
|
|
|
readableStream.on('error', (error) => {
|
|
console.error('Error while reading the stream:', error);
|
|
reject();
|
|
});
|
|
});
|
|
}
|
|
|
|
function isObject(item) {
|
|
return (item && typeof item === 'object' && !Array.isArray(item));
|
|
}
|
|
|
|
export function deepMerge(target, source) {
|
|
let output = Object.assign({}, target);
|
|
if (isObject(target) && isObject(source)) {
|
|
Object.keys(source).forEach(key => {
|
|
if (isObject(source[key])) {
|
|
if (!(key in target)) {
|
|
Object.assign(output, { [key]: source[key] });
|
|
} else {
|
|
output[key] = deepMerge(target[key], source[key]);
|
|
}
|
|
} else {
|
|
Object.assign(output, { [key]: source[key] });
|
|
}
|
|
});
|
|
}
|
|
return output;
|
|
}
|
|
|
|
export const color = chalk;
|
|
|
|
/**
|
|
* Gets a random UUIDv4 string.
|
|
* @returns {string} A UUIDv4 string
|
|
*/
|
|
export function uuidv4() {
|
|
if ('crypto' in globalThis && 'randomUUID' in globalThis.crypto) {
|
|
return globalThis.crypto.randomUUID();
|
|
}
|
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
|
|
const r = Math.random() * 16 | 0;
|
|
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
|
return v.toString(16);
|
|
});
|
|
}
|
|
|
|
export function humanizedISO8601DateTime(date) {
|
|
let baseDate = typeof date === 'number' ? new Date(date) : new Date();
|
|
let humanYear = baseDate.getFullYear();
|
|
let humanMonth = (baseDate.getMonth() + 1);
|
|
let humanDate = baseDate.getDate();
|
|
let humanHour = (baseDate.getHours() < 10 ? '0' : '') + baseDate.getHours();
|
|
let humanMinute = (baseDate.getMinutes() < 10 ? '0' : '') + baseDate.getMinutes();
|
|
let humanSecond = (baseDate.getSeconds() < 10 ? '0' : '') + baseDate.getSeconds();
|
|
let humanMillisecond = (baseDate.getMilliseconds() < 10 ? '0' : '') + baseDate.getMilliseconds();
|
|
let HumanizedDateTime = (humanYear + '-' + humanMonth + '-' + humanDate + ' @' + humanHour + 'h ' + humanMinute + 'm ' + humanSecond + 's ' + humanMillisecond + 'ms');
|
|
return HumanizedDateTime;
|
|
}
|
|
|
|
export function tryParse(str) {
|
|
try {
|
|
return JSON.parse(str);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Takes a path to a client-accessible file in the data folder and converts it to a relative URL segment that the
|
|
* client can fetch it from. This involves stripping the data root path prefix and always using `/` as the separator.
|
|
* @param {string} root The root directory of the user data folder.
|
|
* @param {string} inputPath The path to be converted.
|
|
* @returns The relative URL path from which the client can access the file.
|
|
*/
|
|
export function clientRelativePath(root, inputPath) {
|
|
if (!inputPath.startsWith(root)) {
|
|
throw new Error('Input path does not start with the root directory');
|
|
}
|
|
|
|
return inputPath.slice(root.length).split(path.sep).join('/');
|
|
}
|
|
|
|
/**
|
|
* Strip the last file extension from a given file name. If there are multiple extensions, only the last is removed.
|
|
* @param {string} filename The file name to remove the extension from.
|
|
* @returns The file name, sans extension
|
|
*/
|
|
export function removeFileExtension(filename) {
|
|
return filename.replace(/\.[^.]+$/, '');
|
|
}
|
|
|
|
export function generateTimestamp() {
|
|
const now = new Date();
|
|
const year = now.getFullYear();
|
|
const month = String(now.getMonth() + 1).padStart(2, '0');
|
|
const day = String(now.getDate()).padStart(2, '0');
|
|
const hours = String(now.getHours()).padStart(2, '0');
|
|
const minutes = String(now.getMinutes()).padStart(2, '0');
|
|
const seconds = String(now.getSeconds()).padStart(2, '0');
|
|
|
|
return `${year}${month}${day}-${hours}${minutes}${seconds}`;
|
|
}
|
|
|
|
/**
|
|
* Remove old backups with the given prefix from a specified directory.
|
|
* @param {string} directory The root directory to remove backups from.
|
|
* @param {string} prefix File prefix to filter backups by.
|
|
* @param {number?} limit Maximum number of backups to keep. If null, the limit is determined by the `backups.common.numberOfBackups` config value.
|
|
*/
|
|
export function removeOldBackups(directory, prefix, limit = null) {
|
|
const MAX_BACKUPS = limit ?? Number(getConfigValue('backups.common.numberOfBackups', 50, 'number'));
|
|
|
|
let files = fs.readdirSync(directory).filter(f => f.startsWith(prefix));
|
|
if (files.length > MAX_BACKUPS) {
|
|
files = files.map(f => path.join(directory, f));
|
|
files.sort((a, b) => fs.statSync(a).mtimeMs - fs.statSync(b).mtimeMs);
|
|
|
|
while (files.length > MAX_BACKUPS) {
|
|
const oldest = files.shift();
|
|
if (!oldest) {
|
|
break;
|
|
}
|
|
|
|
fs.rmSync(oldest);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get a list of images in a directory.
|
|
* @param {string} directoryPath Path to the directory containing the images
|
|
* @param {'name' | 'date'} sortBy Sort images by name or date
|
|
* @returns {string[]} List of image file names
|
|
*/
|
|
export function getImages(directoryPath, sortBy = 'name') {
|
|
function getSortFunction() {
|
|
switch (sortBy) {
|
|
case 'name':
|
|
return Intl.Collator().compare;
|
|
case 'date':
|
|
return (a, b) => fs.statSync(path.join(directoryPath, a)).mtimeMs - fs.statSync(path.join(directoryPath, b)).mtimeMs;
|
|
default:
|
|
return (_a, _b) => 0;
|
|
}
|
|
}
|
|
|
|
return fs
|
|
.readdirSync(directoryPath)
|
|
.filter(file => {
|
|
const type = mime.lookup(file);
|
|
return type && type.startsWith('image/');
|
|
})
|
|
.sort(getSortFunction());
|
|
}
|
|
|
|
/**
|
|
* Pipe a fetch() response to an Express.js Response, including status code.
|
|
* @param {import('node-fetch').Response} from The Fetch API response to pipe from.
|
|
* @param {import('express').Response} to The Express response to pipe to.
|
|
*/
|
|
export function forwardFetchResponse(from, to) {
|
|
let statusCode = from.status;
|
|
let statusText = from.statusText;
|
|
|
|
if (!from.ok) {
|
|
console.warn(`Streaming request failed with status ${statusCode} ${statusText}`);
|
|
}
|
|
|
|
// Avoid sending 401 responses as they reset the client Basic auth.
|
|
// This can produce an interesting artifact as "400 Unauthorized", but it's not out of spec.
|
|
// https://www.rfc-editor.org/rfc/rfc9110.html#name-overview-of-status-codes
|
|
// "The reason phrases listed here are only recommendations -- they can be replaced by local
|
|
// equivalents or left out altogether without affecting the protocol."
|
|
if (statusCode === 401) {
|
|
statusCode = 400;
|
|
}
|
|
|
|
to.statusCode = statusCode;
|
|
to.statusMessage = statusText;
|
|
|
|
if (from.body && to.socket) {
|
|
from.body.pipe(to);
|
|
|
|
to.socket.on('close', function () {
|
|
if (from.body instanceof Readable) from.body.destroy(); // Close the remote stream
|
|
|
|
to.end(); // End the Express response
|
|
});
|
|
|
|
from.body.on('end', function () {
|
|
console.info('Streaming request finished');
|
|
to.end();
|
|
});
|
|
} else {
|
|
to.end();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Makes an HTTP/2 request to the specified endpoint.
|
|
*
|
|
* @deprecated Use `node-fetch` if possible.
|
|
* @param {string} endpoint URL to make the request to
|
|
* @param {string} method HTTP method to use
|
|
* @param {string} body Request body
|
|
* @param {object} headers Request headers
|
|
* @returns {Promise<string>} Response body
|
|
*/
|
|
export function makeHttp2Request(endpoint, method, body, headers) {
|
|
return new Promise((resolve, reject) => {
|
|
try {
|
|
const url = new URL(endpoint);
|
|
const client = http2.connect(url.origin);
|
|
|
|
const req = client.request({
|
|
':method': method,
|
|
':path': url.pathname,
|
|
...headers,
|
|
});
|
|
req.setEncoding('utf8');
|
|
|
|
req.on('response', (headers) => {
|
|
const status = Number(headers[':status']);
|
|
|
|
if (status < 200 || status >= 300) {
|
|
reject(new Error(`Request failed with status ${status}`));
|
|
}
|
|
|
|
let data = '';
|
|
|
|
req.on('data', (chunk) => {
|
|
data += chunk;
|
|
});
|
|
|
|
req.on('end', () => {
|
|
console.debug(data);
|
|
resolve(data);
|
|
});
|
|
});
|
|
|
|
req.on('error', (err) => {
|
|
reject(err);
|
|
});
|
|
|
|
if (body) {
|
|
req.write(body);
|
|
}
|
|
|
|
req.end();
|
|
} catch (e) {
|
|
reject(e);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Adds YAML-serialized object to the object.
|
|
* @param {object} obj Object
|
|
* @param {string} yamlString YAML-serialized object
|
|
* @returns
|
|
*/
|
|
export function mergeObjectWithYaml(obj, yamlString) {
|
|
if (!yamlString) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const parsedObject = yaml.parse(yamlString);
|
|
|
|
if (Array.isArray(parsedObject)) {
|
|
for (const item of parsedObject) {
|
|
if (typeof item === 'object' && item && !Array.isArray(item)) {
|
|
Object.assign(obj, item);
|
|
}
|
|
}
|
|
}
|
|
else if (parsedObject && typeof parsedObject === 'object') {
|
|
Object.assign(obj, parsedObject);
|
|
}
|
|
} catch {
|
|
// Do nothing
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Removes keys from the object by YAML-serialized array.
|
|
* @param {object} obj Object
|
|
* @param {string} yamlString YAML-serialized array
|
|
* @returns {void} Nothing
|
|
*/
|
|
export function excludeKeysByYaml(obj, yamlString) {
|
|
if (!yamlString) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const parsedObject = yaml.parse(yamlString);
|
|
|
|
if (Array.isArray(parsedObject)) {
|
|
parsedObject.forEach(key => {
|
|
delete obj[key];
|
|
});
|
|
} else if (typeof parsedObject === 'object') {
|
|
Object.keys(parsedObject).forEach(key => {
|
|
delete obj[key];
|
|
});
|
|
} else if (typeof parsedObject === 'string') {
|
|
delete obj[parsedObject];
|
|
}
|
|
} catch {
|
|
// Do nothing
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Removes trailing slash and /v1 from a string.
|
|
* @param {string} str Input string
|
|
* @returns {string} Trimmed string
|
|
*/
|
|
export function trimV1(str) {
|
|
return String(str ?? '').replace(/\/$/, '').replace(/\/v1$/, '');
|
|
}
|
|
|
|
/**
|
|
* Simple TTL memory cache.
|
|
*/
|
|
export class Cache {
|
|
/**
|
|
* @param {number} ttl Time to live in milliseconds
|
|
*/
|
|
constructor(ttl) {
|
|
this.cache = new Map();
|
|
this.ttl = ttl;
|
|
}
|
|
|
|
/**
|
|
* Gets a value from the cache.
|
|
* @param {string} key Cache key
|
|
*/
|
|
get(key) {
|
|
const value = this.cache.get(key);
|
|
if (value?.expiry > Date.now()) {
|
|
return value.value;
|
|
}
|
|
|
|
// Cache miss or expired, remove the key
|
|
this.cache.delete(key);
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Sets a value in the cache.
|
|
* @param {string} key Key
|
|
* @param {object} value Value
|
|
*/
|
|
set(key, value) {
|
|
this.cache.set(key, {
|
|
value: value,
|
|
expiry: Date.now() + this.ttl,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Removes a value from the cache.
|
|
* @param {string} key Key
|
|
*/
|
|
remove(key) {
|
|
this.cache.delete(key);
|
|
}
|
|
|
|
/**
|
|
* Clears the cache.
|
|
*/
|
|
clear() {
|
|
this.cache.clear();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Removes color formatting from a text string.
|
|
* @param {string} text Text with color formatting
|
|
* @returns {string} Text without color formatting
|
|
*/
|
|
export function removeColorFormatting(text) {
|
|
// ANSI escape codes for colors are usually in the format \x1b[<codes>m
|
|
return text.replace(/\x1b\[\d{1,2}(;\d{1,2})*m/g, '');
|
|
}
|
|
|
|
/**
|
|
* Gets a separator string repeated n times.
|
|
* @param {number} n Number of times to repeat the separator
|
|
* @returns {string} Separator string
|
|
*/
|
|
export function getSeparator(n) {
|
|
return '='.repeat(n);
|
|
}
|
|
|
|
/**
|
|
* Checks if the string is a valid URL.
|
|
* @param {string} url String to check
|
|
* @returns {boolean} If the URL is valid
|
|
*/
|
|
export function isValidUrl(url) {
|
|
try {
|
|
new URL(url);
|
|
return true;
|
|
} catch (error) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* removes starting `[` or ending `]` from hostname.
|
|
* @param {string} hostname hostname to use
|
|
* @returns {string} hostname plus the modifications
|
|
*/
|
|
export function urlHostnameToIPv6(hostname) {
|
|
if (hostname.startsWith('[')) {
|
|
hostname = hostname.slice(1);
|
|
}
|
|
if (hostname.endsWith(']')) {
|
|
hostname = hostname.slice(0, -1);
|
|
}
|
|
return hostname;
|
|
}
|
|
|
|
/**
|
|
* Test if can resolve a dns name.
|
|
* @param {string} name Domain name to use
|
|
* @param {boolean} useIPv6 If use IPv6
|
|
* @param {boolean} useIPv4 If use IPv4
|
|
* @returns Promise<boolean> If the URL is valid
|
|
*/
|
|
export async function canResolve(name, useIPv6 = true, useIPv4 = true) {
|
|
try {
|
|
let v6Resolved = false;
|
|
let v4Resolved = false;
|
|
|
|
if (useIPv6) {
|
|
try {
|
|
await dnsPromise.resolve6(name);
|
|
v6Resolved = true;
|
|
} catch (error) {
|
|
v6Resolved = false;
|
|
}
|
|
}
|
|
|
|
if (useIPv4) {
|
|
try {
|
|
await dnsPromise.resolve(name);
|
|
v4Resolved = true;
|
|
} catch (error) {
|
|
v4Resolved = false;
|
|
}
|
|
}
|
|
|
|
return v6Resolved || v4Resolved;
|
|
|
|
} catch (error) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks the network interfaces to determine the presence of IPv6 and IPv4 addresses.
|
|
*
|
|
* @typedef {object} IPQueryResult
|
|
* @property {boolean} hasIPv6Any - Whether the computer has any IPv6 address, including (`::1`).
|
|
* @property {boolean} hasIPv4Any - Whether the computer has any IPv4 address, including (`127.0.0.1`).
|
|
* @property {boolean} hasIPv6Local - Whether the computer has local IPv6 address (`::1`).
|
|
* @property {boolean} hasIPv4Local - Whether the computer has local IPv4 address (`127.0.0.1`).
|
|
* @returns {Promise<IPQueryResult>} A promise that resolves to an array containing:
|
|
*/
|
|
export async function getHasIP() {
|
|
let hasIPv6Any = false;
|
|
let hasIPv6Local = false;
|
|
|
|
let hasIPv4Any = false;
|
|
let hasIPv4Local = false;
|
|
|
|
const interfaces = os.networkInterfaces();
|
|
|
|
for (const iface of Object.values(interfaces)) {
|
|
if (iface === undefined) {
|
|
continue;
|
|
}
|
|
|
|
for (const info of iface) {
|
|
if (info.family === 'IPv6') {
|
|
hasIPv6Any = true;
|
|
if (info.address === '::1') {
|
|
hasIPv6Local = true;
|
|
}
|
|
}
|
|
|
|
if (info.family === 'IPv4') {
|
|
hasIPv4Any = true;
|
|
if (info.address === '127.0.0.1') {
|
|
hasIPv4Local = true;
|
|
}
|
|
}
|
|
if (hasIPv6Any && hasIPv4Any && hasIPv6Local && hasIPv4Local) break;
|
|
}
|
|
if (hasIPv6Any && hasIPv4Any && hasIPv6Local && hasIPv4Local) break;
|
|
}
|
|
|
|
return { hasIPv6Any, hasIPv4Any, hasIPv6Local, hasIPv4Local };
|
|
}
|
|
|
|
|
|
/**
|
|
* Converts various JavaScript primitives to boolean values.
|
|
* Handles special case for "true"/"false" strings (case-insensitive)
|
|
*
|
|
* @param {any} value - The value to convert to boolean
|
|
* @returns {boolean} - The boolean representation of the value
|
|
*/
|
|
export function toBoolean(value) {
|
|
// Handle string values case-insensitively
|
|
if (typeof value === 'string') {
|
|
// Trim and convert to lowercase for case-insensitive comparison
|
|
const trimmedLower = value.trim().toLowerCase();
|
|
|
|
// Handle explicit "true"/"false" strings
|
|
if (trimmedLower === 'true') return true;
|
|
if (trimmedLower === 'false') return false;
|
|
}
|
|
|
|
// Handle all other JavaScript values based on their "truthiness"
|
|
return Boolean(value);
|
|
}
|
|
|
|
/**
|
|
* converts string to boolean accepts 'true' or 'false' else it returns the string put in
|
|
* @param {string|null} str Input string or null
|
|
* @returns {boolean|string|null} boolean else original input string or null if input is
|
|
*/
|
|
export function stringToBool(str) {
|
|
if (String(str).trim().toLowerCase() === 'true') return true;
|
|
if (String(str).trim().toLowerCase() === 'false') return false;
|
|
return str;
|
|
}
|
|
|
|
/**
|
|
* Setup the minimum log level
|
|
*/
|
|
export function setupLogLevel() {
|
|
const logLevel = getConfigValue('logging.minLogLevel', LOG_LEVELS.DEBUG, 'number');
|
|
|
|
globalThis.console.debug = logLevel <= LOG_LEVELS.DEBUG ? console.debug : () => { };
|
|
globalThis.console.info = logLevel <= LOG_LEVELS.INFO ? console.info : () => { };
|
|
globalThis.console.warn = logLevel <= LOG_LEVELS.WARN ? console.warn : () => { };
|
|
globalThis.console.error = logLevel <= LOG_LEVELS.ERROR ? console.error : () => { };
|
|
}
|
|
|
|
/**
|
|
* MemoryLimitedMap class that limits the memory usage of string values.
|
|
*/
|
|
export class MemoryLimitedMap {
|
|
/**
|
|
* Creates an instance of MemoryLimitedMap.
|
|
* @param {string} cacheCapacity - Maximum memory usage in human-readable format (e.g., '1 GB').
|
|
*/
|
|
constructor(cacheCapacity) {
|
|
this.maxMemory = bytes.parse(cacheCapacity) ?? 0;
|
|
this.currentMemory = 0;
|
|
this.map = new Map();
|
|
this.queue = [];
|
|
}
|
|
|
|
/**
|
|
* Estimates the memory usage of a string in bytes.
|
|
* Assumes each character occupies 2 bytes (UTF-16).
|
|
* @param {string} str
|
|
* @returns {number}
|
|
*/
|
|
static estimateStringSize(str) {
|
|
return str ? str.length * 2 : 0;
|
|
}
|
|
|
|
/**
|
|
* Adds or updates a key-value pair in the map.
|
|
* If adding the new value exceeds the memory limit, evicts oldest entries.
|
|
* @param {string} key
|
|
* @param {string} value
|
|
*/
|
|
set(key, value) {
|
|
if (this.maxMemory <= 0) {
|
|
return;
|
|
}
|
|
|
|
if (typeof key !== 'string' || typeof value !== 'string') {
|
|
return;
|
|
}
|
|
|
|
const newValueSize = MemoryLimitedMap.estimateStringSize(value);
|
|
|
|
// If the new value itself exceeds the max memory, reject it
|
|
if (newValueSize > this.maxMemory) {
|
|
return;
|
|
}
|
|
|
|
// Check if the key already exists to adjust memory accordingly
|
|
if (this.map.has(key)) {
|
|
const oldValue = this.map.get(key);
|
|
const oldValueSize = MemoryLimitedMap.estimateStringSize(oldValue);
|
|
this.currentMemory -= oldValueSize;
|
|
// Remove the key from its current position in the queue
|
|
const index = this.queue.indexOf(key);
|
|
if (index > -1) {
|
|
this.queue.splice(index, 1);
|
|
}
|
|
}
|
|
|
|
// Evict oldest entries until there's enough space
|
|
while (this.currentMemory + newValueSize > this.maxMemory && this.queue.length > 0) {
|
|
const oldestKey = this.queue.shift();
|
|
const oldestValue = this.map.get(oldestKey);
|
|
const oldestValueSize = MemoryLimitedMap.estimateStringSize(oldestValue);
|
|
this.map.delete(oldestKey);
|
|
this.currentMemory -= oldestValueSize;
|
|
}
|
|
|
|
// After eviction, check again if there's enough space
|
|
if (this.currentMemory + newValueSize > this.maxMemory) {
|
|
return;
|
|
}
|
|
|
|
// Add the new key-value pair
|
|
this.map.set(key, value);
|
|
this.queue.push(key);
|
|
this.currentMemory += newValueSize;
|
|
}
|
|
|
|
/**
|
|
* Retrieves the value associated with the given key.
|
|
* @param {string} key
|
|
* @returns {string | undefined}
|
|
*/
|
|
get(key) {
|
|
return this.map.get(key);
|
|
}
|
|
|
|
/**
|
|
* Checks if the map contains the given key.
|
|
* @param {string} key
|
|
* @returns {boolean}
|
|
*/
|
|
has(key) {
|
|
return this.map.has(key);
|
|
}
|
|
|
|
/**
|
|
* Deletes the key-value pair associated with the given key.
|
|
* @param {string} key
|
|
* @returns {boolean} - Returns true if the key was found and deleted, else false.
|
|
*/
|
|
delete(key) {
|
|
if (!this.map.has(key)) {
|
|
return false;
|
|
}
|
|
const value = this.map.get(key);
|
|
const valueSize = MemoryLimitedMap.estimateStringSize(value);
|
|
this.map.delete(key);
|
|
this.currentMemory -= valueSize;
|
|
|
|
// Remove the key from the queue
|
|
const index = this.queue.indexOf(key);
|
|
if (index > -1) {
|
|
this.queue.splice(index, 1);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Clears all entries from the map.
|
|
*/
|
|
clear() {
|
|
this.map.clear();
|
|
this.queue = [];
|
|
this.currentMemory = 0;
|
|
}
|
|
|
|
/**
|
|
* Returns the number of key-value pairs in the map.
|
|
* @returns {number}
|
|
*/
|
|
size() {
|
|
return this.map.size;
|
|
}
|
|
|
|
/**
|
|
* Returns the current memory usage in bytes.
|
|
* @returns {number}
|
|
*/
|
|
totalMemory() {
|
|
return this.currentMemory;
|
|
}
|
|
|
|
/**
|
|
* Returns an iterator over the keys in the map.
|
|
* @returns {IterableIterator<string>}
|
|
*/
|
|
keys() {
|
|
return this.map.keys();
|
|
}
|
|
|
|
/**
|
|
* Returns an iterator over the values in the map.
|
|
* @returns {IterableIterator<string>}
|
|
*/
|
|
values() {
|
|
return this.map.values();
|
|
}
|
|
|
|
/**
|
|
* Iterates over the map in insertion order.
|
|
* @param {Function} callback - Function to execute for each element.
|
|
*/
|
|
forEach(callback) {
|
|
this.map.forEach((value, key) => {
|
|
callback(value, key, this);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Makes the MemoryLimitedMap iterable.
|
|
* @returns {Iterator} - Iterator over [key, value] pairs.
|
|
*/
|
|
[Symbol.iterator]() {
|
|
return this.map[Symbol.iterator]();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A 'safe' version of `fs.readFileSync()`. Returns the contents of a file if it exists, falling back to a default value if not.
|
|
* @param {string} filePath Path of the file to be read.
|
|
* @param {Parameters<typeof fs.readFileSync>[1]} options Options object to pass through to `fs.readFileSync()` (default: `{ encoding: 'utf-8' }`).
|
|
* @returns The contents at `filePath` if it exists, or `null` if not.
|
|
*/
|
|
export function safeReadFileSync(filePath, options = { encoding: 'utf-8' }) {
|
|
if (fs.existsSync(filePath)) return fs.readFileSync(filePath, options);
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Set the title of the terminal window
|
|
* @param {string} title Desired title for the window
|
|
*/
|
|
export function setWindowTitle(title) {
|
|
if (process.platform === 'win32') {
|
|
process.title = title;
|
|
}
|
|
else {
|
|
process.stdout.write(`\x1b]2;${title}\x1b\x5c`);
|
|
}
|
|
}
|