|
|
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 crypto from 'node:crypto'; |
|
|
import readline from 'node:readline'; |
|
|
|
|
|
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 bytes from 'bytes'; |
|
|
import { LOG_LEVELS, CHAT_COMPLETION_SOURCES, MEDIA_REQUEST_TYPE } from './constants.js'; |
|
|
import { serverDirectory } from './server-directory.js'; |
|
|
import { sync as writeFileAtomicSync } from 'write-file-atomic'; |
|
|
import { isFirefox } from './express-common.js'; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
let CACHED_CONFIG = null; |
|
|
let CONFIG_PATH = null; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export const keyToEnv = (key) => 'SILLYTAVERN_' + String(key).toUpperCase().replace(/\./g, '_'); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function setConfigFilePath(configFilePath) { |
|
|
if (CONFIG_PATH !== null) { |
|
|
console.error(color.red('Config file path already set. Please restart the server to change the config file path.')); |
|
|
} |
|
|
CONFIG_PATH = path.resolve(configFilePath); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function getConfig() { |
|
|
if (CONFIG_PATH === null) { |
|
|
console.trace(); |
|
|
console.error(color.red('No config file path set. Please set the config file path using setConfigFilePath().')); |
|
|
process.exit(1); |
|
|
} |
|
|
if (CACHED_CONFIG) { |
|
|
return CACHED_CONFIG; |
|
|
} |
|
|
if (!fs.existsSync(CONFIG_PATH)) { |
|
|
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(CONFIG_PATH, '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); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function setConfigValue(_key, _value) { |
|
|
console.trace(color.yellow('setConfigValue is deprecated and should not be used.')); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function getBasicAuthHeader(auth) { |
|
|
const encoded = Buffer.from(`${auth}`).toString('base64'); |
|
|
return `Basic ${encoded}`; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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(serverDirectory, './package.json')); |
|
|
pkgVersion = pkgJson.version; |
|
|
if (commandExistsSync('git')) { |
|
|
const git = simpleGit({ baseDir: serverDirectory }); |
|
|
gitRevision = await git.revparse(['--short', 'HEAD']); |
|
|
gitBranch = await git.revparse(['--abbrev-ref', 'HEAD']); |
|
|
commitDate = await git.show(['-s', '--format=%ci', gitRevision]); |
|
|
|
|
|
const trackingBranch = await git.revparse(['--abbrev-ref', '@{u}']); |
|
|
|
|
|
|
|
|
const localLatest = await git.revparse(['HEAD']); |
|
|
const remoteLatest = await git.revparse([trackingBranch]); |
|
|
isLatest = localLatest === remoteLatest; |
|
|
} |
|
|
} |
|
|
catch { |
|
|
|
|
|
} |
|
|
|
|
|
const agent = `SillyTavern:${pkgVersion}:Cohee#1207`; |
|
|
return { agent, pkgVersion, gitRevision, gitBranch, commitDate: commitDate?.trim() ?? null, isLatest }; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function delay(ms) { |
|
|
return new Promise(resolve => setTimeout(resolve, ms)); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function formatBytes(numBytes) { |
|
|
return bytes.format(numBytes) ?? ''; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export async function extractFileFromZipBuffer(archiveBuffer, fileExtension) { |
|
|
return await new Promise((resolve) => { |
|
|
try { |
|
|
yauzl.fromBuffer(Buffer.from(archiveBuffer), { lazyEntries: true }, (err, zipfile) => { |
|
|
if (err) { |
|
|
console.warn(`Error opening ZIP file: ${err.message}`); |
|
|
return resolve(null); |
|
|
} |
|
|
|
|
|
zipfile.readEntry(); |
|
|
|
|
|
zipfile.on('entry', (entry) => { |
|
|
if (entry.fileName.endsWith(fileExtension) && !entry.fileName.startsWith('__MACOSX')) { |
|
|
zipfile.openReadStream(entry, (err, readStream) => { |
|
|
if (err) { |
|
|
console.warn(`Error opening read stream: ${err.message}`); |
|
|
return zipfile.readEntry(); |
|
|
} else { |
|
|
const chunks = []; |
|
|
readStream.on('data', (chunk) => { |
|
|
chunks.push(chunk); |
|
|
}); |
|
|
|
|
|
readStream.on('end', () => { |
|
|
const buffer = Buffer.concat(chunks); |
|
|
resolve(buffer); |
|
|
zipfile.readEntry(); |
|
|
}); |
|
|
|
|
|
readStream.on('error', (err) => { |
|
|
console.warn(`Error reading stream: ${err.message}`); |
|
|
zipfile.readEntry(); |
|
|
}); |
|
|
} |
|
|
}); |
|
|
} else { |
|
|
zipfile.readEntry(); |
|
|
} |
|
|
}); |
|
|
|
|
|
zipfile.on('error', (err) => { |
|
|
console.warn('ZIP processing error', err); |
|
|
resolve(null); |
|
|
}); |
|
|
|
|
|
zipfile.on('end', () => resolve(null)); |
|
|
}); |
|
|
} catch (error) { |
|
|
console.warn('Failed to process ZIP buffer', error); |
|
|
resolve(null); |
|
|
} |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function normalizeZipEntryPath(entryName) { |
|
|
if (typeof entryName !== 'string') { |
|
|
return null; |
|
|
} |
|
|
|
|
|
let normalized = entryName.replace(/\\/g, '/').trim(); |
|
|
|
|
|
if (!normalized) { |
|
|
return null; |
|
|
} |
|
|
|
|
|
normalized = normalized.replace(/^\.\/+/g, ''); |
|
|
normalized = path.posix.normalize(normalized); |
|
|
|
|
|
if (!normalized || normalized === '.' || normalized.startsWith('..')) { |
|
|
return null; |
|
|
} |
|
|
|
|
|
if (normalized.startsWith('/')) { |
|
|
normalized = normalized.slice(1); |
|
|
} |
|
|
|
|
|
return normalized; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export async function extractFilesFromZipBuffer(archiveBuffer, fileNames) { |
|
|
const targets = new Map(); |
|
|
|
|
|
if (Array.isArray(fileNames)) { |
|
|
for (const fileName of fileNames) { |
|
|
const normalized = normalizeZipEntryPath(fileName); |
|
|
if (normalized && !targets.has(normalized)) { |
|
|
targets.set(normalized, true); |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
if (targets.size === 0) { |
|
|
return new Map(); |
|
|
} |
|
|
|
|
|
return await new Promise((resolve) => { |
|
|
const results = new Map(); |
|
|
|
|
|
try { |
|
|
yauzl.fromBuffer(Buffer.from(archiveBuffer), { lazyEntries: true }, (err, zipfile) => { |
|
|
if (err) { |
|
|
console.warn(`Error opening ZIP file: ${err.message}`); |
|
|
return resolve(results); |
|
|
} |
|
|
|
|
|
let finished = false; |
|
|
const finalize = () => { |
|
|
if (finished) { |
|
|
return; |
|
|
} |
|
|
finished = true; |
|
|
resolve(results); |
|
|
}; |
|
|
|
|
|
zipfile.readEntry(); |
|
|
|
|
|
zipfile.on('entry', (entry) => { |
|
|
const normalizedEntry = normalizeZipEntryPath(entry.fileName); |
|
|
if (!normalizedEntry || !targets.has(normalizedEntry)) { |
|
|
return zipfile.readEntry(); |
|
|
} |
|
|
|
|
|
zipfile.openReadStream(entry, (streamErr, readStream) => { |
|
|
if (streamErr) { |
|
|
console.warn(`Error opening read stream: ${streamErr.message}`); |
|
|
return zipfile.readEntry(); |
|
|
} |
|
|
|
|
|
const chunks = []; |
|
|
readStream.on('data', (chunk) => { |
|
|
chunks.push(chunk); |
|
|
}); |
|
|
|
|
|
readStream.on('end', () => { |
|
|
results.set(normalizedEntry, Buffer.concat(chunks)); |
|
|
targets.delete(normalizedEntry); |
|
|
|
|
|
if (targets.size === 0) { |
|
|
finalize(); |
|
|
} else { |
|
|
zipfile.readEntry(); |
|
|
} |
|
|
}); |
|
|
|
|
|
readStream.on('error', (streamError) => { |
|
|
console.warn(`Error reading stream: ${streamError.message}`); |
|
|
zipfile.readEntry(); |
|
|
}); |
|
|
}); |
|
|
}); |
|
|
|
|
|
zipfile.on('error', (zipError) => { |
|
|
console.warn('ZIP processing error', zipError); |
|
|
finalize(); |
|
|
}); |
|
|
|
|
|
zipfile.on('close', () => { |
|
|
finalize(); |
|
|
}); |
|
|
|
|
|
zipfile.on('end', () => { |
|
|
finalize(); |
|
|
}); |
|
|
}); |
|
|
} catch (error) { |
|
|
console.warn('Failed to process ZIP buffer', error); |
|
|
resolve(results); |
|
|
} |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function ensureDirectory(dirPath) { |
|
|
try { |
|
|
if (!fs.existsSync(dirPath)) { |
|
|
fs.mkdirSync(dirPath, { recursive: true }); |
|
|
} else if (!fs.statSync(dirPath).isDirectory()) { |
|
|
console.warn(`ensureDirectory: Path ${dirPath} exists and is not a directory.`); |
|
|
return false; |
|
|
} |
|
|
return true; |
|
|
} catch (error) { |
|
|
console.error(`ensureDirectory: Failed to prepare directory ${dirPath}`, error); |
|
|
return false; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export async function getImageBuffers(zipFilePath) { |
|
|
return new Promise((resolve, reject) => { |
|
|
|
|
|
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')) { |
|
|
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(); |
|
|
}); |
|
|
} |
|
|
}); |
|
|
} else { |
|
|
zipfile.readEntry(); |
|
|
} |
|
|
}); |
|
|
|
|
|
zipfile.on('end', () => { |
|
|
resolve(imageBuffers); |
|
|
}); |
|
|
|
|
|
zipfile.on('error', (err) => { |
|
|
reject(err); |
|
|
}); |
|
|
} |
|
|
}); |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export async function readAllChunks(readableStream) { |
|
|
return new Promise((resolve, reject) => { |
|
|
|
|
|
const chunks = []; |
|
|
readableStream.on('data', (chunk) => { |
|
|
chunks.push(chunk); |
|
|
}); |
|
|
|
|
|
readableStream.on('end', () => { |
|
|
|
|
|
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; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function uuidv4() { |
|
|
|
|
|
if ('crypto' in globalThis && 'randomUUID' in globalThis.crypto) { |
|
|
return globalThis.crypto.randomUUID(); |
|
|
} |
|
|
|
|
|
if ('randomUUID' in crypto) { |
|
|
return 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 humanizedDateTime(timestamp = Date.now()) { |
|
|
const date = new Date(timestamp); |
|
|
const dt = { |
|
|
year: date.getFullYear(), |
|
|
month: date.getMonth() + 1, |
|
|
day: date.getDate(), |
|
|
hour: date.getHours(), |
|
|
minute: date.getMinutes(), |
|
|
second: date.getSeconds(), |
|
|
millisecond: date.getMilliseconds(), |
|
|
}; |
|
|
for (const key in dt) { |
|
|
const padLength = key === 'millisecond' ? 3 : 2; |
|
|
dt[key] = dt[key].toString().padStart(padLength, '0'); |
|
|
} |
|
|
return `${dt.year}-${dt.month}-${dt.day}@${dt.hour}h${dt.minute}m${dt.second}s${dt.millisecond}ms`; |
|
|
} |
|
|
|
|
|
export function tryParse(str) { |
|
|
try { |
|
|
return JSON.parse(str); |
|
|
} catch { |
|
|
return undefined; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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('/'); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function getUniqueName(name, exists) { |
|
|
let i = 1; |
|
|
let baseName = name; |
|
|
while (exists(name)) { |
|
|
name = `${baseName} (${i})`; |
|
|
i++; |
|
|
} |
|
|
return name; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function sanitizeSafeCharacterReplacements(char) { |
|
|
return '_'; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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}`; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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.unlinkSync(oldest); |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function getImages(directoryPath, sortBy = 'name', type = MEDIA_REQUEST_TYPE.IMAGE) { |
|
|
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, { withFileTypes: true }) |
|
|
.filter(dirent => dirent.isFile()) |
|
|
.map(dirent => dirent.name) |
|
|
.filter(file => { |
|
|
const fileType = mime.lookup(file); |
|
|
if (!fileType) { |
|
|
return false; |
|
|
} |
|
|
if ((type & MEDIA_REQUEST_TYPE.IMAGE) && fileType.startsWith('image/')) { |
|
|
return true; |
|
|
} |
|
|
if ((type & MEDIA_REQUEST_TYPE.VIDEO) && fileType.startsWith('video/')) { |
|
|
return true; |
|
|
} |
|
|
if ((type & MEDIA_REQUEST_TYPE.AUDIO) && fileType.startsWith('audio/')) { |
|
|
return true; |
|
|
} |
|
|
return false; |
|
|
}) |
|
|
.sort(getSortFunction()); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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}`); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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(); |
|
|
|
|
|
to.end(); |
|
|
}); |
|
|
|
|
|
from.body.on('end', function () { |
|
|
console.info('Streaming request finished'); |
|
|
to.end(); |
|
|
}); |
|
|
} else { |
|
|
to.end(); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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); |
|
|
} |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 { |
|
|
|
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 { |
|
|
|
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function trimV1(str) { |
|
|
return String(str ?? '').replace(/\/$/, '').replace(/\/v1$/, ''); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function trimTrailingSlash(str) { |
|
|
return String(str ?? '').replace(/\/$/, ''); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export class Cache { |
|
|
|
|
|
|
|
|
|
|
|
constructor(ttl) { |
|
|
this.cache = new Map(); |
|
|
this.ttl = ttl; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
get(key) { |
|
|
const value = this.cache.get(key); |
|
|
if (value?.expiry > Date.now()) { |
|
|
return value.value; |
|
|
} |
|
|
|
|
|
|
|
|
this.cache.delete(key); |
|
|
return null; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
set(key, value) { |
|
|
this.cache.set(key, { |
|
|
value: value, |
|
|
expiry: Date.now() + this.ttl, |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
remove(key) { |
|
|
this.cache.delete(key); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
clear() { |
|
|
this.cache.clear(); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function removeColorFormatting(text) { |
|
|
|
|
|
return text.replace(/\x1b\[\d{1,2}(;\d{1,2})*m/g, ''); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function getSeparator(n) { |
|
|
return '='.repeat(n); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function isValidUrl(url) { |
|
|
try { |
|
|
new URL(url); |
|
|
return true; |
|
|
} catch (error) { |
|
|
return false; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function urlHostnameToIPv6(hostname) { |
|
|
if (hostname.startsWith('[')) { |
|
|
hostname = hostname.slice(1); |
|
|
} |
|
|
if (hostname.endsWith(']')) { |
|
|
hostname = hostname.slice(0, -1); |
|
|
} |
|
|
return hostname; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 }; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function toBoolean(value) { |
|
|
|
|
|
if (typeof value === 'string') { |
|
|
|
|
|
const trimmedLower = value.trim().toLowerCase(); |
|
|
|
|
|
|
|
|
if (trimmedLower === 'true') return true; |
|
|
if (trimmedLower === 'false') return false; |
|
|
} |
|
|
|
|
|
|
|
|
return Boolean(value); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function stringToBool(str) { |
|
|
if (String(str).trim().toLowerCase() === 'true') return true; |
|
|
if (String(str).trim().toLowerCase() === 'false') return false; |
|
|
return str; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 : () => { }; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export class MemoryLimitedMap { |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
constructor(cacheCapacity) { |
|
|
this.maxMemory = bytes.parse(cacheCapacity) ?? 0; |
|
|
this.currentMemory = 0; |
|
|
this.map = new Map(); |
|
|
this.queue = []; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static estimateStringSize(str) { |
|
|
return str ? str.length * 2 : 0; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
set(key, value) { |
|
|
if (this.maxMemory <= 0) { |
|
|
return; |
|
|
} |
|
|
|
|
|
if (typeof key !== 'string' || typeof value !== 'string') { |
|
|
return; |
|
|
} |
|
|
|
|
|
const newValueSize = MemoryLimitedMap.estimateStringSize(value); |
|
|
|
|
|
|
|
|
if (newValueSize > this.maxMemory) { |
|
|
return; |
|
|
} |
|
|
|
|
|
|
|
|
if (this.map.has(key)) { |
|
|
const oldValue = this.map.get(key); |
|
|
const oldValueSize = MemoryLimitedMap.estimateStringSize(oldValue); |
|
|
this.currentMemory -= oldValueSize; |
|
|
|
|
|
const index = this.queue.indexOf(key); |
|
|
if (index > -1) { |
|
|
this.queue.splice(index, 1); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
if (this.currentMemory + newValueSize > this.maxMemory) { |
|
|
return; |
|
|
} |
|
|
|
|
|
|
|
|
this.map.set(key, value); |
|
|
this.queue.push(key); |
|
|
this.currentMemory += newValueSize; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
get(key) { |
|
|
return this.map.get(key); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
has(key) { |
|
|
return this.map.has(key); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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; |
|
|
|
|
|
|
|
|
const index = this.queue.indexOf(key); |
|
|
if (index > -1) { |
|
|
this.queue.splice(index, 1); |
|
|
} |
|
|
|
|
|
return true; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
clear() { |
|
|
this.map.clear(); |
|
|
this.queue = []; |
|
|
this.currentMemory = 0; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
size() { |
|
|
return this.map.size; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
totalMemory() { |
|
|
return this.currentMemory; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
keys() { |
|
|
return this.map.keys(); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
values() { |
|
|
return this.map.values(); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
forEach(callback) { |
|
|
this.map.forEach((value, key) => { |
|
|
callback(value, key, this); |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
[Symbol.iterator]() { |
|
|
return this.map[Symbol.iterator](); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function safeReadFileSync(filePath, options = { encoding: 'utf-8' }) { |
|
|
if (fs.existsSync(filePath)) return fs.readFileSync(filePath, options); |
|
|
return null; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function setWindowTitle(title) { |
|
|
if (process.platform === 'win32') { |
|
|
process.title = title; |
|
|
} |
|
|
else { |
|
|
process.stdout.write(`\x1b]2;${title}\x1b\x5c`); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function mutateJsonString(jsonString, mutation) { |
|
|
try { |
|
|
const json = JSON.parse(jsonString); |
|
|
mutation(json); |
|
|
return JSON.stringify(json); |
|
|
} catch (error) { |
|
|
console.error('Error parsing or mutating JSON:', error); |
|
|
return jsonString; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function setPermissionsSync(targetPath) { |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function appendWritablePermission(filePath, stats) { |
|
|
const currentMode = stats.mode; |
|
|
const newMode = currentMode | 0o200; |
|
|
if (newMode != currentMode) { |
|
|
fs.chmodSync(filePath, newMode); |
|
|
} |
|
|
} |
|
|
|
|
|
try { |
|
|
const stats = fs.statSync(targetPath); |
|
|
|
|
|
if (stats.isDirectory()) { |
|
|
appendWritablePermission(targetPath, stats); |
|
|
const files = fs.readdirSync(targetPath); |
|
|
|
|
|
files.forEach((file) => { |
|
|
setPermissionsSync(path.join(targetPath, file)); |
|
|
}); |
|
|
} else { |
|
|
appendWritablePermission(targetPath, stats); |
|
|
} |
|
|
} catch (error) { |
|
|
console.error(`Error setting write permissions for ${targetPath}:`, error); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function isPathUnderParent(parentPath, childPath) { |
|
|
const normalizedParent = path.normalize(parentPath); |
|
|
const normalizedChild = path.normalize(childPath); |
|
|
|
|
|
const relativePath = path.relative(normalizedParent, normalizedChild); |
|
|
|
|
|
return !relativePath.startsWith('..') && !path.isAbsolute(relativePath); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function isFileURL(request) { |
|
|
if (typeof request === 'string') { |
|
|
return request.startsWith('file://'); |
|
|
} |
|
|
if (request instanceof URL) { |
|
|
return request.protocol === 'file:'; |
|
|
} |
|
|
if (request instanceof Request) { |
|
|
return request.url.startsWith('file://'); |
|
|
} |
|
|
return false; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function getRequestURL(request) { |
|
|
if (typeof request === 'string') { |
|
|
return request; |
|
|
} |
|
|
if (request instanceof URL) { |
|
|
return request.href; |
|
|
} |
|
|
if (request instanceof Request) { |
|
|
return request.url; |
|
|
} |
|
|
throw new TypeError('Invalid request type'); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function flattenSchema(schema, api) { |
|
|
if (!schema || typeof schema !== 'object') { |
|
|
return schema; |
|
|
} |
|
|
|
|
|
const schemaCopy = structuredClone(schema); |
|
|
const isGoogleApi = [CHAT_COMPLETION_SOURCES.VERTEXAI, CHAT_COMPLETION_SOURCES.MAKERSUITE].includes(api); |
|
|
|
|
|
const definitions = schemaCopy.$defs || {}; |
|
|
delete schemaCopy.$defs; |
|
|
|
|
|
function resolve(obj, parents = []) { |
|
|
if (!obj || typeof obj !== 'object') { |
|
|
return obj; |
|
|
} |
|
|
if (Array.isArray(obj)) { |
|
|
return obj.map(item => resolve(item, parents)); |
|
|
} |
|
|
|
|
|
|
|
|
if (obj.$ref?.startsWith('#/$defs/')) { |
|
|
const defName = obj.$ref.split('/').pop(); |
|
|
if (parents.includes(defName)) return {}; |
|
|
if (definitions[defName]) { |
|
|
return resolve(structuredClone(definitions[defName]), [...parents, defName]); |
|
|
} |
|
|
return {}; |
|
|
} |
|
|
|
|
|
|
|
|
const result = {}; |
|
|
for (const key in obj) { |
|
|
if (!Object.prototype.hasOwnProperty.call(obj, key)) continue; |
|
|
|
|
|
|
|
|
if (isGoogleApi && ['default', 'additionalProperties', 'exclusiveMinimum', 'propertyNames'].includes(key)) { |
|
|
continue; |
|
|
} |
|
|
|
|
|
result[key] = resolve(obj[key], parents); |
|
|
} |
|
|
|
|
|
return result; |
|
|
} |
|
|
|
|
|
const flattenedSchema = resolve(schemaCopy); |
|
|
delete flattenedSchema.$schema; |
|
|
return flattenedSchema; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function tryWriteFileSync(filePath, data) { |
|
|
const directory = path.dirname(filePath); |
|
|
|
|
|
if (!fs.existsSync(directory)) { |
|
|
fs.mkdirSync(directory, { recursive: true }); |
|
|
} |
|
|
writeFileAtomicSync(filePath, data, 'utf8'); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function tryReadFileSync(filePath) { |
|
|
try { |
|
|
if (fs.existsSync(filePath)) { |
|
|
return fs.readFileSync(filePath, 'utf8'); |
|
|
} |
|
|
} catch (error) { |
|
|
console.error(`Error reading ${filePath}: ${error.message}`); |
|
|
} |
|
|
return null; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function tryDeleteFile(filePath) { |
|
|
if (fs.existsSync(filePath)) { |
|
|
fs.unlinkSync(filePath); |
|
|
console.info(`Deleted file: ${filePath}`); |
|
|
return true; |
|
|
} else { |
|
|
console.error(`File not found '${filePath}'`); |
|
|
return false; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function readFirstLine(filePath) { |
|
|
const stream = fs.createReadStream(filePath, { encoding: 'utf8' }); |
|
|
const rl = readline.createInterface({ input: stream }); |
|
|
return new Promise((resolve, reject) => { |
|
|
let resolved = false; |
|
|
rl.on('line', line => { |
|
|
resolved = true; |
|
|
rl.close(); |
|
|
stream.close(); |
|
|
resolve(line); |
|
|
}); |
|
|
|
|
|
rl.on('error', error => { |
|
|
resolved = true; |
|
|
reject(error); |
|
|
}); |
|
|
|
|
|
|
|
|
stream.on('end', () => { |
|
|
if (!resolved) { |
|
|
resolved = true; |
|
|
resolve(''); |
|
|
} |
|
|
}); |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function invalidateFirefoxCache(file, request, response) { |
|
|
const mimeType = isFirefox(request) && mime.lookup(file); |
|
|
if (mimeType && mimeType.startsWith('image/')) { |
|
|
response.setHeader('Cache-Control', 'must-understand, no-store'); |
|
|
} |
|
|
} |
|
|
|