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} 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 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} 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} 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[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 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} 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} */ keys() { return this.map.keys(); } /** * Returns an iterator over the values in the map. * @returns {IterableIterator} */ 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[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`); } }