Blame Raw
Cohee · 51ad27fb · · 1577 lines (49.7 KB)
3 contributors
1import path from 'node:path';
2import fs from 'node:fs';
3import http2 from 'node:http2';
4import process from 'node:process';
5import { Readable } from 'node:stream';
6import { createRequire } from 'node:module';
7import { Buffer } from 'node:buffer';
8import { promises as dnsPromise } from 'node:dns';
9import os from 'node:os';
10import crypto from 'node:crypto';
11import readline from 'node:readline';
12
13import yaml from 'yaml';
14import { sync as commandExistsSync } from 'command-exists';
15import _ from 'lodash';
16import yauzl from 'yauzl';
17import mime from 'mime-types';
18import { default as simpleGit } from 'simple-git';
19import chalk from 'chalk';
20import bytes from 'bytes';
21import { LOG_LEVELS, CHAT_COMPLETION_SOURCES, MEDIA_REQUEST_TYPE } from './constants.js';
22import { serverDirectory } from './server-directory.js';
23import { sync as writeFileAtomicSync } from 'write-file-atomic';
24import { isFirefox } from './express-common.js';
25
26/**
27 * Parsed config object.
28 */
29let CACHED_CONFIG = null;
30let CONFIG_PATH = null;
31
32/**
33 * Converts a configuration key to an environment variable key.
34 * @param {string} key Configuration key
35 * @returns {string} Environment variable key
36 * @example keyToEnv('extensions.models.speechToText') // 'SILLYTAVERN_EXTENSIONS_MODELS_SPEECHTOTEXT'
37 */
38export const keyToEnv = (key) => 'SILLYTAVERN_' + String(key).toUpperCase().replace(/\./g, '_');
39
40/**
41 * Set the config file path.
42 * @param {string} configFilePath Path to the config file
43 */
44export function setConfigFilePath(configFilePath) {
45 if (CONFIG_PATH !== null) {
46 console.error(color.red('Config file path already set. Please restart the server to change the config file path.'));
47 }
48 CONFIG_PATH = path.resolve(configFilePath);
49}
50
51/**
52 * Returns the config object from the config.yaml file.
53 * @returns {object} Config object
54 */
55export function getConfig() {
56 if (CONFIG_PATH === null) {
57 console.trace();
58 console.error(color.red('No config file path set. Please set the config file path using setConfigFilePath().'));
59 process.exit(1);
60 }
61 if (CACHED_CONFIG) {
62 return CACHED_CONFIG;
63 }
64 if (!fs.existsSync(CONFIG_PATH)) {
65 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.'));
66 console.error(color.red('The program will now exit.'));
67 process.exit(1);
68 }
69
70 try {
71 const config = yaml.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
72 CACHED_CONFIG = config;
73 return config;
74 } catch (error) {
75 console.error(color.red('FATAL: Failed to read config.yaml. Please check the file for syntax errors.'));
76 console.error(error.message);
77 process.exit(1);
78 }
79}
80
81/**
82 * Returns the value for the given key from the config object.
83 * @param {string} key - Key to get from the config object
84 * @param {any} defaultValue - Default value to return if the key is not found
85 * @param {'number'|'boolean'|null} typeConverter - Type to convert the value to
86 * @returns {any} Value for the given key
87 */
88export function getConfigValue(key, defaultValue = null, typeConverter = null) {
89 function _getValue() {
90 const envKey = keyToEnv(key);
91 if (envKey in process.env) {
92 const needsJsonParse = defaultValue && typeof defaultValue === 'object';
93 const envValue = process.env[envKey];
94 return needsJsonParse ? (tryParse(envValue) ?? defaultValue) : envValue;
95 }
96 const config = getConfig();
97 return _.get(config, key, defaultValue);
98 }
99
100 const value = _getValue();
101 switch (typeConverter) {
102 case 'number':
103 return isNaN(parseFloat(value)) ? defaultValue : parseFloat(value);
104 case 'boolean':
105 return toBoolean(value);
106 default:
107 return value;
108 }
109}
110
111/**
112 * THIS FUNCTION IS DEPRECATED AND ONLY EXISTS FOR BACKWARDS COMPATIBILITY. DON'T USE IT.
113 * @param {any} _key Unused
114 * @param {any} _value Unused
115 * @deprecated Configs are read-only. Use environment variables instead.
116 */
117export function setConfigValue(_key, _value) {
118 console.trace(color.yellow('setConfigValue is deprecated and should not be used.'));
119}
120
121/**
122 * Encodes the Basic Auth header value for the given user and password.
123 * @param {string} auth username:password
124 * @returns {string} Basic Auth header value
125 */
126export function getBasicAuthHeader(auth) {
127 const encoded = Buffer.from(`${auth}`).toString('base64');
128 return `Basic ${encoded}`;
129}
130
131/**
132 * Returns the version of the running instance. Get the version from the package.json file and the git revision.
133 * Also returns the agent string for the Horde API.
134 * @returns {Promise<{agent: string, pkgVersion: string, gitRevision: string | null, gitBranch: string | null, commitDate: string | null, isLatest: boolean}>} Version info object
135 */
136export async function getVersion() {
137 let pkgVersion = 'UNKNOWN';
138 let gitRevision = null;
139 let gitBranch = null;
140 let commitDate = null;
141 let isLatest = true;
142
143 try {
144 const require = createRequire(import.meta.url);
145 const pkgJson = require(path.join(serverDirectory, './package.json'));
146 pkgVersion = pkgJson.version;
147 if (commandExistsSync('git')) {
148 const git = simpleGit({ baseDir: serverDirectory });
149 gitRevision = await git.revparse(['--short', 'HEAD']);
150 gitBranch = await git.revparse(['--abbrev-ref', 'HEAD']);
151 commitDate = await git.show(['-s', '--format=%ci', gitRevision]);
152
153 const trackingBranch = await git.revparse(['--abbrev-ref', '@{u}']);
154
155 // Might fail, but exception is caught. Just don't run anything relevant after in this block...
156 const localLatest = await git.revparse(['HEAD']);
157 const remoteLatest = await git.revparse([trackingBranch]);
158 isLatest = localLatest === remoteLatest;
159 }
160 } catch {
161 // suppress exception
162 }
163
164 const agent = `SillyTavern:${pkgVersion}:Cohee#1207`;
165 return { agent, pkgVersion, gitRevision, gitBranch, commitDate: commitDate?.trim() ?? null, isLatest };
166}
167
168/**
169 * Delays the current async function by the given amount of milliseconds.
170 * @param {number} ms Milliseconds to wait
171 * @returns {Promise<void>} Promise that resolves after the given amount of milliseconds
172 */
173export function delay(ms) {
174 return new Promise(resolve => setTimeout(resolve, ms));
175}
176
177/**
178 * Generates a random hex string of the given length.
179 * @param {number} length String length
180 * @returns {string} Random hex string
181 * @example getHexString(8) // 'a1b2c3d4'
182 */
183export function getHexString(length) {
184 const chars = '0123456789abcdef';
185 let result = '';
186 for (let i = 0; i < length; i++) {
187 result += chars[Math.floor(Math.random() * chars.length)];
188 }
189 return result;
190}
191
192/**
193 * Formats a byte size into a human-readable string with units
194 * @param {number} numBytes - The size in bytes to format
195 * @returns {string} The formatted string (e.g., "1.5 MB")
196 */
197export function formatBytes(numBytes) {
198 return bytes.format(numBytes) ?? '';
199}
200
201/**
202 * Extracts a file with given extension from an ArrayBuffer containing a ZIP archive.
203 * @param {ArrayBufferLike} archiveBuffer Buffer containing a ZIP archive
204 * @param {string} fileExtension File extension to look for
205 * @returns {Promise<Buffer|null>} Buffer containing the extracted file. Null if the file was not found.
206 */
207export async function extractFileFromZipBuffer(archiveBuffer, fileExtension) {
208 return await new Promise((resolve) => {
209 try {
210 yauzl.fromBuffer(Buffer.from(archiveBuffer), { lazyEntries: true }, (err, zipfile) => {
211 if (err) {
212 console.warn(`Error opening ZIP file: ${err.message}`);
213 return resolve(null);
214 }
215
216 zipfile.readEntry();
217
218 zipfile.on('entry', (entry) => {
219 if (entry.fileName.endsWith(fileExtension) && !entry.fileName.startsWith('__MACOSX')) {
220 zipfile.openReadStream(entry, (err, readStream) => {
221 if (err) {
222 console.warn(`Error opening read stream: ${err.message}`);
223 return zipfile.readEntry();
224 } else {
225 const chunks = [];
226 readStream.on('data', (chunk) => {
227 chunks.push(chunk);
228 });
229
230 readStream.on('end', () => {
231 const buffer = Buffer.concat(chunks);
232 resolve(buffer);
233 zipfile.readEntry(); // Continue to the next entry
234 });
235
236 readStream.on('error', (err) => {
237 console.warn(`Error reading stream: ${err.message}`);
238 zipfile.readEntry();
239 });
240 }
241 });
242 } else {
243 zipfile.readEntry();
244 }
245 });
246
247 zipfile.on('error', (err) => {
248 console.warn('ZIP processing error', err);
249 resolve(null);
250 });
251
252 zipfile.on('end', () => resolve(null));
253 });
254 } catch (error) {
255 console.warn('Failed to process ZIP buffer', error);
256 resolve(null);
257 }
258 });
259}
260
261/**
262 * Normalizes a ZIP entry path for safe extraction.
263 * @param {string} entryName The entry name from the ZIP archive
264 * @returns {string|null} Normalized path or null if invalid
265 */
266export function normalizeZipEntryPath(entryName) {
267 if (typeof entryName !== 'string') {
268 return null;
269 }
270
271 let normalized = entryName.replace(/\\/g, '/').trim();
272
273 if (!normalized) {
274 return null;
275 }
276
277 normalized = normalized.replace(/^\.\/+/g, '');
278 normalized = path.posix.normalize(normalized);
279
280 if (!normalized || normalized === '.' || normalized.startsWith('..')) {
281 return null;
282 }
283
284 if (normalized.startsWith('/')) {
285 normalized = normalized.slice(1);
286 }
287
288 return normalized;
289}
290
291/**
292 * Extracts multiple files from an ArrayBuffer containing a ZIP archive.
293 * @param {ArrayBufferLike} archiveBuffer Buffer containing a ZIP archive
294 * @param {string[]} fileNames Array of file paths to extract
295 * @returns {Promise<Map<string, Buffer>>} Map of normalized paths to their extracted buffers
296 */
297export async function extractFilesFromZipBuffer(archiveBuffer, fileNames) {
298 const targets = new Map();
299
300 if (Array.isArray(fileNames)) {
301 for (const fileName of fileNames) {
302 const normalized = normalizeZipEntryPath(fileName);
303 if (normalized && !targets.has(normalized)) {
304 targets.set(normalized, true);
305 }
306 }
307 }
308
309 if (targets.size === 0) {
310 return new Map();
311 }
312
313 return await new Promise((resolve) => {
314 const results = new Map();
315
316 try {
317 yauzl.fromBuffer(Buffer.from(archiveBuffer), { lazyEntries: true }, (err, zipfile) => {
318 if (err) {
319 console.warn(`Error opening ZIP file: ${err.message}`);
320 return resolve(results);
321 }
322
323 let finished = false;
324 const finalize = () => {
325 if (finished) {
326 return;
327 }
328 finished = true;
329 resolve(results);
330 };
331
332 zipfile.readEntry();
333
334 zipfile.on('entry', (entry) => {
335 const normalizedEntry = normalizeZipEntryPath(entry.fileName);
336 if (!normalizedEntry || !targets.has(normalizedEntry)) {
337 return zipfile.readEntry();
338 }
339
340 zipfile.openReadStream(entry, (streamErr, readStream) => {
341 if (streamErr) {
342 console.warn(`Error opening read stream: ${streamErr.message}`);
343 return zipfile.readEntry();
344 }
345
346 const chunks = [];
347 readStream.on('data', (chunk) => {
348 chunks.push(chunk);
349 });
350
351 readStream.on('end', () => {
352 results.set(normalizedEntry, Buffer.concat(chunks));
353 targets.delete(normalizedEntry);
354
355 if (targets.size === 0) {
356 finalize();
357 } else {
358 zipfile.readEntry();
359 }
360 });
361
362 readStream.on('error', (streamError) => {
363 console.warn(`Error reading stream: ${streamError.message}`);
364 zipfile.readEntry();
365 });
366 });
367 });
368
369 zipfile.on('error', (zipError) => {
370 console.warn('ZIP processing error', zipError);
371 finalize();
372 });
373
374 zipfile.on('close', () => {
375 finalize();
376 });
377
378 zipfile.on('end', () => {
379 finalize();
380 });
381 });
382 } catch (error) {
383 console.warn('Failed to process ZIP buffer', error);
384 resolve(results);
385 }
386 });
387}
388
389/**
390 * Ensures a directory exists, creating it if necessary.
391 * @param {string} dirPath Path to the directory
392 * @returns {boolean} True if the directory exists or was created, false on error
393 */
394export function ensureDirectory(dirPath) {
395 try {
396 if (!fs.existsSync(dirPath)) {
397 fs.mkdirSync(dirPath, { recursive: true });
398 } else if (!fs.statSync(dirPath).isDirectory()) {
399 console.warn(`ensureDirectory: Path ${dirPath} exists and is not a directory.`);
400 return false;
401 }
402 return true;
403 } catch (error) {
404 console.error(`ensureDirectory: Failed to prepare directory ${dirPath}`, error);
405 return false;
406 }
407}
408
409/**
410 * Extracts all images from a ZIP archive.
411 * @param {string} zipFilePath Path to the ZIP archive
412 * @returns {Promise<[string, Buffer][]>} Array of image buffers
413 */
414export async function getImageBuffers(zipFilePath) {
415 return new Promise((resolve, reject) => {
416 // Check if the zip file exists
417 if (!fs.existsSync(zipFilePath)) {
418 reject(new Error('File not found'));
419 return;
420 }
421
422 const imageBuffers = [];
423
424 yauzl.open(zipFilePath, { lazyEntries: true }, (err, zipfile) => {
425 if (err) {
426 reject(err);
427 } else {
428 zipfile.readEntry();
429 zipfile.on('entry', (entry) => {
430 const mimeType = mime.lookup(entry.fileName);
431 if (mimeType && mimeType.startsWith('image/') && !entry.fileName.startsWith('__MACOSX')) {
432 zipfile.openReadStream(entry, (err, readStream) => {
433 if (err) {
434 reject(err);
435 } else {
436 const chunks = [];
437 readStream.on('data', (chunk) => {
438 chunks.push(chunk);
439 });
440
441 readStream.on('end', () => {
442 imageBuffers.push([path.parse(entry.fileName).base, Buffer.concat(chunks)]);
443 zipfile.readEntry(); // Continue to the next entry
444 });
445 }
446 });
447 } else {
448 zipfile.readEntry(); // Continue to the next entry
449 }
450 });
451
452 zipfile.on('end', () => {
453 resolve(imageBuffers);
454 });
455
456 zipfile.on('error', (err) => {
457 reject(err);
458 });
459 }
460 });
461 });
462}
463
464/**
465 * Gets all chunks of data from the given readable stream.
466 * @param {any} readableStream Readable stream to read from
467 * @returns {Promise<Buffer[]>} Array of chunks
468 */
469export async function readAllChunks(readableStream) {
470 return new Promise((resolve, reject) => {
471 // Consume the readable stream
472 const chunks = [];
473 readableStream.on('data', (chunk) => {
474 chunks.push(chunk);
475 });
476
477 readableStream.on('end', () => {
478 //console.log('Finished reading the stream.');
479 resolve(chunks);
480 });
481
482 readableStream.on('error', (error) => {
483 console.error('Error while reading the stream:', error);
484 reject();
485 });
486 });
487}
488
489function isObject(item) {
490 return (item && typeof item === 'object' && !Array.isArray(item));
491}
492
493export function deepMerge(target, source) {
494 let output = Object.assign({}, target);
495 if (isObject(target) && isObject(source)) {
496 Object.keys(source).forEach(key => {
497 if (isObject(source[key])) {
498 if (!(key in target)) {
499 Object.assign(output, { [key]: source[key] });
500 } else {
501 output[key] = deepMerge(target[key], source[key]);
502 }
503 } else {
504 Object.assign(output, { [key]: source[key] });
505 }
506 });
507 }
508 return output;
509}
510
511export const color = chalk;
512
513/**
514 * Gets a random UUIDv4 string.
515 * @returns {string} A UUIDv4 string
516 */
517export function uuidv4() {
518 // Node v16.7.0+
519 if ('crypto' in globalThis && 'randomUUID' in globalThis.crypto) {
520 return globalThis.crypto.randomUUID();
521 }
522 // Node v14.17.0+
523 if ('randomUUID' in crypto) {
524 return crypto.randomUUID();
525 }
526 // Very insecure UUID generator, but it's better than nothing.
527 return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
528 const r = Math.random() * 16 | 0;
529 const v = c === 'x' ? r : (r & 0x3 | 0x8);
530 return v.toString(16);
531 });
532}
533
534/**
535 * Gets a humanized date time string from a given timestamp.
536 * @param {number} timestamp Timestamp in milliseconds
537 * @returns {string} Humanized date time string in the format `YYYY-MM-DD@HHhMMmSSsMSms`
538 */
539export function humanizedDateTime(timestamp = Date.now()) {
540 const date = new Date(timestamp);
541 const dt = {
542 year: date.getFullYear(),
543 month: date.getMonth() + 1,
544 day: date.getDate(),
545 hour: date.getHours(),
546 minute: date.getMinutes(),
547 second: date.getSeconds(),
548 millisecond: date.getMilliseconds(),
549 };
550 for (const key in dt) {
551 const padLength = key === 'millisecond' ? 3 : 2;
552 dt[key] = dt[key].toString().padStart(padLength, '0');
553 }
554 return `${dt.year}-${dt.month}-${dt.day}@${dt.hour}h${dt.minute}m${dt.second}s${dt.millisecond}ms`;
555}
556
557export function tryParse(str) {
558 try {
559 return JSON.parse(str);
560 } catch {
561 return undefined;
562 }
563}
564
565/**
566 * Takes a path to a client-accessible file in the data folder and converts it to a relative URL segment that the
567 * client can fetch it from. This involves stripping the data root path prefix and always using `/` as the separator.
568 * @param {string} root The root directory of the user data folder.
569 * @param {string} inputPath The path to be converted.
570 * @returns The relative URL path from which the client can access the file.
571 */
572export function clientRelativePath(root, inputPath) {
573 if (!inputPath.startsWith(root)) {
574 throw new Error('Input path does not start with the root directory');
575 }
576
577 return inputPath.slice(root.length).split(path.sep).join('/');
578}
579
580/**
581 * Returns a name that is unique among the names that exist.
582 * @param {string} baseName The name to check.
583 * @param {{ (name: string): boolean; }} exists Function to check if name exists.
584 * @param {Object} [options] The options.
585 * @param {((baseName: string, i: number) => string)|null} [options.nameBuilder=null] Function to build the name.
586 * Starts with the index provided by `startIndex` (default is 1). If not provided, uses "${baseName} (${i})".
587 * @param {number} [options.maxTries=1000] The maximum number of tries to find a unique name. Default is 1000.
588 * @param {number} [options.startIndex=1] The index to start with when building the name. Default is 1.
589 * When set to 0, the intention is to also check if the basename (without applied index) is free.
590 * @returns {string|null} A unique name. Null if no unique name could be found in `maxTries`.
591 */
592export function getUniqueName(baseName, exists, { nameBuilder = null, maxTries = 1000, startIndex = 1 } = {}) {
593 nameBuilder ??= (baseName, i) => i === 0 ? baseName : `${baseName} (${i})`;
594 let i = startIndex;
595 let name;
596 while (i < maxTries + startIndex) {
597 name = nameBuilder(baseName, i);
598 if (!exists(name)) {
599 return name;
600 }
601 i++;
602 }
603 return null;
604}
605
606/**
607 * Provides safe replacements for characters in filenames. Intended for use with sanitize() from the sanitize-filename package.
608 * @param {string} char Character to sanitize
609 * @returns {string} Safe replacement character
610 */
611export function sanitizeSafeCharacterReplacements(char) {
612 return '_';
613}
614
615/**
616 * Strip the last file extension from a given file name. If there are multiple extensions, only the last is removed.
617 * @param {string} filename The file name to remove the extension from.
618 * @returns The file name, sans extension
619 */
620export function removeFileExtension(filename) {
621 return filename.replace(/\.[^.]+$/, '');
622}
623
624export function generateTimestamp() {
625 const now = new Date();
626 const year = now.getFullYear();
627 const month = String(now.getMonth() + 1).padStart(2, '0');
628 const day = String(now.getDate()).padStart(2, '0');
629 const hours = String(now.getHours()).padStart(2, '0');
630 const minutes = String(now.getMinutes()).padStart(2, '0');
631 const seconds = String(now.getSeconds()).padStart(2, '0');
632
633 return `${year}${month}${day}-${hours}${minutes}${seconds}`;
634}
635
636/**
637 * Remove old backups with the given prefix from a specified directory.
638 * @param {string} directory The root directory to remove backups from.
639 * @param {string} prefix File prefix to filter backups by.
640 * @param {number?} limit Maximum number of backups to keep. If null, the limit is determined by the `backups.common.numberOfBackups` config value.
641 */
642export function removeOldBackups(directory, prefix, limit = null) {
643 const MAX_BACKUPS = limit ?? Number(getConfigValue('backups.common.numberOfBackups', 50, 'number'));
644
645 let files = fs.readdirSync(directory).filter(f => f.startsWith(prefix));
646 if (files.length > MAX_BACKUPS) {
647 files = files.map(f => path.join(directory, f));
648 files.sort((a, b) => fs.statSync(a).mtimeMs - fs.statSync(b).mtimeMs);
649
650 while (files.length > MAX_BACKUPS) {
651 const oldest = files.shift();
652 if (!oldest) {
653 break;
654 }
655
656 fs.unlinkSync(oldest);
657 }
658 }
659}
660
661/**
662 * Get a list of images in a directory.
663 * @param {string} directoryPath Path to the directory containing the images
664 * @param {'name' | 'date'} sortBy Sort images by name or date
665 * @param {number} type Bitwise flag representing media types to include
666 * @returns {string[]} List of image file names
667 */
668export function getImages(directoryPath, sortBy = 'name', type = MEDIA_REQUEST_TYPE.IMAGE) {
669 function getSortFunction() {
670 switch (sortBy) {
671 case 'name':
672 return Intl.Collator().compare;
673 case 'date':
674 return (a, b) => fs.statSync(path.join(directoryPath, a)).mtimeMs - fs.statSync(path.join(directoryPath, b)).mtimeMs;
675 default:
676 return (_a, _b) => 0;
677 }
678 }
679
680 return fs
681 .readdirSync(directoryPath, { withFileTypes: true })
682 .filter(dirent => dirent.isFile())
683 .map(dirent => dirent.name)
684 .filter(file => {
685 const fileType = mime.lookup(file);
686 if (!fileType) {
687 return false;
688 }
689 if ((type & MEDIA_REQUEST_TYPE.IMAGE) && fileType.startsWith('image/')) {
690 return true;
691 }
692 if ((type & MEDIA_REQUEST_TYPE.VIDEO) && fileType.startsWith('video/')) {
693 return true;
694 }
695 if ((type & MEDIA_REQUEST_TYPE.AUDIO) && fileType.startsWith('audio/')) {
696 return true;
697 }
698 return false;
699 })
700 .sort(getSortFunction());
701}
702
703/**
704 * Pipe a fetch() response to an Express.js Response, including status code.
705 * @param {import('node-fetch').Response} from The Fetch API response to pipe from.
706 * @param {import('express').Response} to The Express response to pipe to.
707 * @returns {Promise<void>}
708 */
709export async function forwardFetchResponse(from, to) {
710 let statusCode = from.status;
711 let statusText = from.statusText;
712
713 // Avoid sending 401 responses as they reset the client Basic auth.
714 // This can produce an interesting artifact as "400 Unauthorized", but it's not out of spec.
715 // https://www.rfc-editor.org/rfc/rfc9110.html#name-overview-of-status-codes
716 // "The reason phrases listed here are only recommendations -- they can be replaced by local
717 // equivalents or left out altogether without affecting the protocol."
718 if (statusCode === 401) {
719 statusCode = 400;
720 }
721
722 to.statusCode = statusCode;
723 to.statusMessage = statusText;
724
725 if (!from.ok) {
726 try {
727 const rawErrorText = await from.text();
728 const detail = rawErrorText || 'Unknown error occurred';
729
730 console.warn(`Streaming request failed with status ${from.status} ${statusText}: ${detail}`);
731 to.end(rawErrorText, 'utf-8');
732 } catch {
733 console.warn(`Streaming request failed with status ${from.status} ${statusText}: Unknown error occurred`);
734 to.end();
735 }
736
737 return;
738 }
739
740 if (from.body && to.socket) {
741 from.body.pipe(to);
742
743 to.socket.on('close', function () {
744 if (from.body instanceof Readable) from.body.destroy(); // Close the remote stream
745
746 to.end(); // End the Express response
747 });
748
749 from.body.on('end', function () {
750 console.info('Streaming request finished');
751 to.end();
752 });
753 } else {
754 to.end();
755 }
756}
757
758/**
759 * Makes an HTTP/2 request to the specified endpoint.
760 *
761 * @deprecated Use `node-fetch` if possible.
762 * @param {string} endpoint URL to make the request to
763 * @param {string} method HTTP method to use
764 * @param {string} body Request body
765 * @param {object} headers Request headers
766 * @returns {Promise<string>} Response body
767 */
768export function makeHttp2Request(endpoint, method, body, headers) {
769 return new Promise((resolve, reject) => {
770 try {
771 const url = new URL(endpoint);
772 const client = http2.connect(url.origin);
773
774 const req = client.request({
775 ':method': method,
776 ':path': url.pathname,
777 ...headers,
778 });
779 req.setEncoding('utf8');
780
781 req.on('response', (headers) => {
782 const status = Number(headers[':status']);
783
784 if (status < 200 || status >= 300) {
785 reject(new Error(`Request failed with status ${status}`));
786 }
787
788 let data = '';
789
790 req.on('data', (chunk) => {
791 data += chunk;
792 });
793
794 req.on('end', () => {
795 console.debug(data);
796 resolve(data);
797 });
798 });
799
800 req.on('error', (err) => {
801 reject(err);
802 });
803
804 if (body) {
805 req.write(body);
806 }
807
808 req.end();
809 } catch (e) {
810 reject(e);
811 }
812 });
813}
814
815/**
816 * Adds YAML-serialized object to the object.
817 * @param {object} obj Object
818 * @param {string} yamlString YAML-serialized object
819 * @returns
820 */
821export function mergeObjectWithYaml(obj, yamlString) {
822 if (!yamlString) {
823 return;
824 }
825
826 try {
827 const parsedObject = yaml.parse(yamlString);
828
829 if (Array.isArray(parsedObject)) {
830 for (const item of parsedObject) {
831 if (typeof item === 'object' && item && !Array.isArray(item)) {
832 Object.assign(obj, item);
833 }
834 }
835 } else if (parsedObject && typeof parsedObject === 'object') {
836 Object.assign(obj, parsedObject);
837 }
838 } catch {
839 // Do nothing
840 }
841}
842
843/**
844 * Removes keys from the object by YAML-serialized array.
845 * @param {object} obj Object
846 * @param {string} yamlString YAML-serialized array
847 * @returns {void} Nothing
848 */
849export function excludeKeysByYaml(obj, yamlString) {
850 if (!yamlString) {
851 return;
852 }
853
854 try {
855 const parsedObject = yaml.parse(yamlString);
856
857 if (Array.isArray(parsedObject)) {
858 parsedObject.forEach(key => {
859 delete obj[key];
860 });
861 } else if (typeof parsedObject === 'object') {
862 Object.keys(parsedObject).forEach(key => {
863 delete obj[key];
864 });
865 } else if (typeof parsedObject === 'string') {
866 delete obj[parsedObject];
867 }
868 } catch {
869 // Do nothing
870 }
871}
872
873/**
874 * Removes trailing slash and /v1 from a string.
875 * @param {string} str Input string
876 * @returns {string} Trimmed string
877 */
878export function trimV1(str) {
879 return String(str ?? '').replace(/\/$/, '').replace(/\/v1$/, '');
880}
881
882/**
883 * Removes trailing slash from a string.
884 * @param {string} str Input string
885 * @returns {string} String with trailing slash removed
886 */
887export function trimTrailingSlash(str) {
888 return String(str ?? '').replace(/\/$/, '');
889}
890
891/**
892 * Simple TTL memory cache.
893 */
894export class Cache {
895 /**
896 * @param {number} ttl Time to live in milliseconds
897 */
898 constructor(ttl) {
899 this.cache = new Map();
900 this.ttl = ttl;
901 }
902
903 /**
904 * Gets a value from the cache.
905 * @param {string} key Cache key
906 */
907 get(key) {
908 const value = this.cache.get(key);
909 if (value?.expiry > Date.now()) {
910 return value.value;
911 }
912
913 // Cache miss or expired, remove the key
914 this.cache.delete(key);
915 return null;
916 }
917
918 /**
919 * Sets a value in the cache.
920 * @param {string} key Key
921 * @param {object} value Value
922 */
923 set(key, value) {
924 this.cache.set(key, {
925 value: value,
926 expiry: Date.now() + this.ttl,
927 });
928 }
929
930 /**
931 * Removes a value from the cache.
932 * @param {string} key Key
933 */
934 remove(key) {
935 this.cache.delete(key);
936 }
937
938 /**
939 * Clears the cache.
940 */
941 clear() {
942 this.cache.clear();
943 }
944}
945
946/**
947 * Removes color formatting from a text string.
948 * @param {string} text Text with color formatting
949 * @returns {string} Text without color formatting
950 */
951export function removeColorFormatting(text) {
952 // ANSI escape codes for colors are usually in the format \x1b[<codes>m
953 return text.replace(/\x1b\[\d{1,2}(;\d{1,2})*m/g, '');
954}
955
956/**
957 * Gets a separator string repeated n times.
958 * @param {number} n Number of times to repeat the separator
959 * @returns {string} Separator string
960 */
961export function getSeparator(n) {
962 return '='.repeat(n);
963}
964
965/**
966 * Checks if the string is a valid URL.
967 * @param {string} url String to check
968 * @returns {boolean} If the URL is valid
969 */
970export function isValidUrl(url) {
971 try {
972 new URL(url);
973 return true;
974 } catch (error) {
975 return false;
976 }
977}
978
979/**
980 * removes starting `[` or ending `]` from hostname.
981 * @param {string} hostname hostname to use
982 * @returns {string} hostname plus the modifications
983 */
984export function urlHostnameToIPv6(hostname) {
985 if (hostname.startsWith('[')) {
986 hostname = hostname.slice(1);
987 }
988 if (hostname.endsWith(']')) {
989 hostname = hostname.slice(0, -1);
990 }
991 return hostname;
992}
993
994/**
995 * Test if can resolve a dns name.
996 * @param {string} name Domain name to use
997 * @param {boolean} useIPv6 If use IPv6
998 * @param {boolean} useIPv4 If use IPv4
999 * @returns Promise<boolean> If the URL is valid
1000 */
1001export async function canResolve(name, useIPv6 = true, useIPv4 = true) {
1002 try {
1003 let v6Resolved = false;
1004 let v4Resolved = false;
1005
1006 if (useIPv6) {
1007 try {
1008 await dnsPromise.resolve6(name);
1009 v6Resolved = true;
1010 } catch (error) {
1011 v6Resolved = false;
1012 }
1013 }
1014
1015 if (useIPv4) {
1016 try {
1017 await dnsPromise.resolve(name);
1018 v4Resolved = true;
1019 } catch (error) {
1020 v4Resolved = false;
1021 }
1022 }
1023
1024 return v6Resolved || v4Resolved;
1025 } catch (error) {
1026 return false;
1027 }
1028}
1029
1030/**
1031 * Checks the network interfaces to determine the presence of IPv6 and IPv4 addresses.
1032 *
1033 * @typedef {object} IPQueryResult
1034 * @property {boolean} hasIPv6Any - Whether the computer has any IPv6 address, including (`::1`).
1035 * @property {boolean} hasIPv4Any - Whether the computer has any IPv4 address, including (`127.0.0.1`).
1036 * @property {boolean} hasIPv6Local - Whether the computer has local IPv6 address (`::1`).
1037 * @property {boolean} hasIPv4Local - Whether the computer has local IPv4 address (`127.0.0.1`).
1038 * @returns {Promise<IPQueryResult>} A promise that resolves to an array containing:
1039 */
1040export async function getHasIP() {
1041 let hasIPv6Any = false;
1042 let hasIPv6Local = false;
1043
1044 let hasIPv4Any = false;
1045 let hasIPv4Local = false;
1046
1047 const interfaces = os.networkInterfaces();
1048
1049 for (const iface of Object.values(interfaces)) {
1050 if (iface === undefined) {
1051 continue;
1052 }
1053
1054 for (const info of iface) {
1055 if (info.family === 'IPv6') {
1056 hasIPv6Any = true;
1057 if (info.address === '::1') {
1058 hasIPv6Local = true;
1059 }
1060 }
1061
1062 if (info.family === 'IPv4') {
1063 hasIPv4Any = true;
1064 if (info.address === '127.0.0.1') {
1065 hasIPv4Local = true;
1066 }
1067 }
1068 if (hasIPv6Any && hasIPv4Any && hasIPv6Local && hasIPv4Local) break;
1069 }
1070 if (hasIPv6Any && hasIPv4Any && hasIPv6Local && hasIPv4Local) break;
1071 }
1072
1073 return { hasIPv6Any, hasIPv4Any, hasIPv6Local, hasIPv4Local };
1074}
1075
1076
1077/**
1078 * Converts various JavaScript primitives to boolean values.
1079 * Handles special case for "true"/"false" strings (case-insensitive)
1080 *
1081 * @param {any} value - The value to convert to boolean
1082 * @returns {boolean} - The boolean representation of the value
1083 */
1084export function toBoolean(value) {
1085 // Handle string values case-insensitively
1086 if (typeof value === 'string') {
1087 // Trim and convert to lowercase for case-insensitive comparison
1088 const trimmedLower = value.trim().toLowerCase();
1089
1090 // Handle explicit "true"/"false" strings
1091 if (trimmedLower === 'true') return true;
1092 if (trimmedLower === 'false') return false;
1093 }
1094
1095 // Handle all other JavaScript values based on their "truthiness"
1096 return Boolean(value);
1097}
1098
1099/**
1100 * converts string to boolean accepts 'true' or 'false' else it returns the string put in
1101 * @param {string|null} str Input string or null
1102 * @returns {boolean|string|null} boolean else original input string or null if input is
1103 */
1104export function stringToBool(str) {
1105 if (String(str).trim().toLowerCase() === 'true') return true;
1106 if (String(str).trim().toLowerCase() === 'false') return false;
1107 return str;
1108}
1109
1110/**
1111 * Setup the minimum log level
1112 */
1113export function setupLogLevel() {
1114 const logLevel = getConfigValue('logging.minLogLevel', LOG_LEVELS.DEBUG, 'number');
1115
1116 globalThis.console.debug = logLevel <= LOG_LEVELS.DEBUG ? console.debug : () => { };
1117 globalThis.console.info = logLevel <= LOG_LEVELS.INFO ? console.info : () => { };
1118 globalThis.console.warn = logLevel <= LOG_LEVELS.WARN ? console.warn : () => { };
1119 globalThis.console.error = logLevel <= LOG_LEVELS.ERROR ? console.error : () => { };
1120}
1121
1122/**
1123 * MemoryLimitedMap class that limits the memory usage of string values.
1124 */
1125export class MemoryLimitedMap {
1126 /**
1127 * Creates an instance of MemoryLimitedMap.
1128 * @param {string} cacheCapacity - Maximum memory usage in human-readable format (e.g., '1 GB').
1129 */
1130 constructor(cacheCapacity) {
1131 this.maxMemory = bytes.parse(cacheCapacity) ?? 0;
1132 this.currentMemory = 0;
1133 this.map = new Map();
1134 this.queue = [];
1135 }
1136
1137 /**
1138 * Estimates the memory usage of a string in bytes.
1139 * Assumes each character occupies 2 bytes (UTF-16).
1140 * @param {string} str
1141 * @returns {number}
1142 */
1143 static estimateStringSize(str) {
1144 return str ? str.length * 2 : 0;
1145 }
1146
1147 /**
1148 * Adds or updates a key-value pair in the map.
1149 * If adding the new value exceeds the memory limit, evicts oldest entries.
1150 * @param {string} key
1151 * @param {string} value
1152 */
1153 set(key, value) {
1154 if (this.maxMemory <= 0) {
1155 return;
1156 }
1157
1158 if (typeof key !== 'string' || typeof value !== 'string') {
1159 return;
1160 }
1161
1162 const newValueSize = MemoryLimitedMap.estimateStringSize(value);
1163
1164 // If the new value itself exceeds the max memory, reject it
1165 if (newValueSize > this.maxMemory) {
1166 return;
1167 }
1168
1169 // Check if the key already exists to adjust memory accordingly
1170 if (this.map.has(key)) {
1171 const oldValue = this.map.get(key);
1172 const oldValueSize = MemoryLimitedMap.estimateStringSize(oldValue);
1173 this.currentMemory -= oldValueSize;
1174 // Remove the key from its current position in the queue
1175 const index = this.queue.indexOf(key);
1176 if (index > -1) {
1177 this.queue.splice(index, 1);
1178 }
1179 }
1180
1181 // Evict oldest entries until there's enough space
1182 while (this.currentMemory + newValueSize > this.maxMemory && this.queue.length > 0) {
1183 const oldestKey = this.queue.shift();
1184 const oldestValue = this.map.get(oldestKey);
1185 const oldestValueSize = MemoryLimitedMap.estimateStringSize(oldestValue);
1186 this.map.delete(oldestKey);
1187 this.currentMemory -= oldestValueSize;
1188 }
1189
1190 // After eviction, check again if there's enough space
1191 if (this.currentMemory + newValueSize > this.maxMemory) {
1192 return;
1193 }
1194
1195 // Add the new key-value pair
1196 this.map.set(key, value);
1197 this.queue.push(key);
1198 this.currentMemory += newValueSize;
1199 }
1200
1201 /**
1202 * Retrieves the value associated with the given key.
1203 * @param {string} key
1204 * @returns {string | undefined}
1205 */
1206 get(key) {
1207 return this.map.get(key);
1208 }
1209
1210 /**
1211 * Checks if the map contains the given key.
1212 * @param {string} key
1213 * @returns {boolean}
1214 */
1215 has(key) {
1216 return this.map.has(key);
1217 }
1218
1219 /**
1220 * Deletes the key-value pair associated with the given key.
1221 * @param {string} key
1222 * @returns {boolean} - Returns true if the key was found and deleted, else false.
1223 */
1224 delete(key) {
1225 if (!this.map.has(key)) {
1226 return false;
1227 }
1228 const value = this.map.get(key);
1229 const valueSize = MemoryLimitedMap.estimateStringSize(value);
1230 this.map.delete(key);
1231 this.currentMemory -= valueSize;
1232
1233 // Remove the key from the queue
1234 const index = this.queue.indexOf(key);
1235 if (index > -1) {
1236 this.queue.splice(index, 1);
1237 }
1238
1239 return true;
1240 }
1241
1242 /**
1243 * Clears all entries from the map.
1244 */
1245 clear() {
1246 this.map.clear();
1247 this.queue = [];
1248 this.currentMemory = 0;
1249 }
1250
1251 /**
1252 * Returns the number of key-value pairs in the map.
1253 * @returns {number}
1254 */
1255 size() {
1256 return this.map.size;
1257 }
1258
1259 /**
1260 * Returns the current memory usage in bytes.
1261 * @returns {number}
1262 */
1263 totalMemory() {
1264 return this.currentMemory;
1265 }
1266
1267 /**
1268 * Returns an iterator over the keys in the map.
1269 * @returns {IterableIterator<string>}
1270 */
1271 keys() {
1272 return this.map.keys();
1273 }
1274
1275 /**
1276 * Returns an iterator over the values in the map.
1277 * @returns {IterableIterator<string>}
1278 */
1279 values() {
1280 return this.map.values();
1281 }
1282
1283 /**
1284 * Iterates over the map in insertion order.
1285 * @param {Function} callback - Function to execute for each element.
1286 */
1287 forEach(callback) {
1288 this.map.forEach((value, key) => {
1289 callback(value, key, this);
1290 });
1291 }
1292
1293 /**
1294 * Makes the MemoryLimitedMap iterable.
1295 * @returns {Iterator} - Iterator over [key, value] pairs.
1296 */
1297 [Symbol.iterator]() {
1298 return this.map[Symbol.iterator]();
1299 }
1300}
1301
1302/**
1303 * A 'safe' version of `fs.readFileSync()`. Returns the contents of a file if it exists, falling back to a default value if not.
1304 * @param {string} filePath Path of the file to be read.
1305 * @param {Parameters<typeof fs.readFileSync>[1]} options Options object to pass through to `fs.readFileSync()` (default: `{ encoding: 'utf-8' }`).
1306 * @returns The contents at `filePath` if it exists, or `null` if not.
1307 */
1308export function safeReadFileSync(filePath, options = { encoding: 'utf-8' }) {
1309 if (fs.existsSync(filePath)) return fs.readFileSync(filePath, options);
1310 return null;
1311}
1312
1313/**
1314 * Set the title of the terminal window
1315 * @param {string} title Desired title for the window
1316 */
1317export function setWindowTitle(title) {
1318 if (process.platform === 'win32') {
1319 process.title = title;
1320 } else {
1321 process.stdout.write(`\x1b]2;${title}\x1b\x5c`);
1322 }
1323}
1324
1325/**
1326 * Parses a JSON string and applies a mutation function to the parsed object.
1327 * @param {string} jsonString JSON string to parse
1328 * @param {function(any): void} mutation Mutation function to apply to the parsed JSON object
1329 * @returns {string} Mutated JSON string
1330 */
1331export function mutateJsonString(jsonString, mutation) {
1332 try {
1333 const json = JSON.parse(jsonString);
1334 mutation(json);
1335 return JSON.stringify(json);
1336 } catch (error) {
1337 console.error('Error parsing or mutating JSON:', error);
1338 return jsonString;
1339 }
1340}
1341
1342/**
1343 * Sets the permissions of a file or directory to be writable.
1344 * @param {string} targetPath Path to the file or directory
1345 */
1346export function setPermissionsSync(targetPath) {
1347 /**
1348 * Appends writable permission to the file mode.
1349 * @param {string} filePath Path to the file
1350 * @param {fs.Stats} stats File stats
1351 */
1352 function appendWritablePermission(filePath, stats) {
1353 const currentMode = stats.mode;
1354 const newMode = currentMode | 0o200;
1355 if (newMode != currentMode) {
1356 fs.chmodSync(filePath, newMode);
1357 }
1358 }
1359
1360 try {
1361 const stats = fs.statSync(targetPath);
1362
1363 if (stats.isDirectory()) {
1364 appendWritablePermission(targetPath, stats);
1365 const files = fs.readdirSync(targetPath);
1366
1367 files.forEach((file) => {
1368 setPermissionsSync(path.join(targetPath, file));
1369 });
1370 } else {
1371 appendWritablePermission(targetPath, stats);
1372 }
1373 } catch (error) {
1374 console.error(`Error setting write permissions for ${targetPath}:`, error);
1375 }
1376}
1377
1378/**
1379 * Checks if a child path is under a parent path.
1380 * @param {string} parentPath Parent path
1381 * @param {string} childPath Child path
1382 * @returns {boolean} Returns true if the child path is under the parent path, false otherwise
1383 */
1384export function isPathUnderParent(parentPath, childPath) {
1385 const normalizedParent = path.normalize(parentPath);
1386 const normalizedChild = path.normalize(childPath);
1387
1388 const relativePath = path.relative(normalizedParent, normalizedChild);
1389
1390 return relativePath !== '..' && !relativePath.startsWith('..' + path.sep) && !path.isAbsolute(relativePath);
1391}
1392
1393/**
1394 * Checks if the given request is a file URL.
1395 * @param {string | URL | Request} request The request to check
1396 * @return {boolean} Returns true if the request is a file URL, false otherwise
1397 */
1398export function isFileURL(request) {
1399 if (typeof request === 'string') {
1400 return request.startsWith('file://');
1401 }
1402 if (request instanceof URL) {
1403 return request.protocol === 'file:';
1404 }
1405 if (request instanceof Request) {
1406 return request.url.startsWith('file://');
1407 }
1408 return false;
1409}
1410
1411/**
1412 * Gets the URL from the request.
1413 * @param {string | URL | Request} request The request to get the URL from
1414 * @return {string} The URL of the request
1415 */
1416export function getRequestURL(request) {
1417 if (typeof request === 'string') {
1418 return request;
1419 }
1420 if (request instanceof URL) {
1421 return request.href;
1422 }
1423 if (request instanceof Request) {
1424 return request.url;
1425 }
1426 throw new TypeError('Invalid request type');
1427}
1428
1429/**
1430 * Flattens and simplifies a JSON schema to be compatible with the strict requirements
1431 * of Google's Generative AI API.
1432 * @param {object} schema The JSON schema to process.
1433 * @param {string} api The API source.
1434 * @returns {object} The flattened and simplified schema.
1435 */
1436export function flattenSchema(schema, api) {
1437 if (!schema || typeof schema !== 'object') {
1438 return schema;
1439 }
1440
1441 const schemaCopy = structuredClone(schema);
1442 const isGoogleApi = [CHAT_COMPLETION_SOURCES.VERTEXAI, CHAT_COMPLETION_SOURCES.MAKERSUITE].includes(api);
1443
1444 const definitions = schemaCopy.$defs || {};
1445 delete schemaCopy.$defs;
1446
1447 function resolve(obj, parents = []) {
1448 if (!obj || typeof obj !== 'object') {
1449 return obj;
1450 }
1451 if (Array.isArray(obj)) {
1452 return obj.map(item => resolve(item, parents));
1453 }
1454
1455 // 1. Resolve $refs first
1456 if (obj.$ref?.startsWith('#/$defs/')) {
1457 const defName = obj.$ref.split('/').pop();
1458 if (parents.includes(defName)) return {}; // Prevent infinite recursion
1459 if (definitions[defName]) {
1460 return resolve(structuredClone(definitions[defName]), [...parents, defName]);
1461 }
1462 return {}; // Broken reference
1463 }
1464
1465 // 2. Process the object's properties
1466 const result = {};
1467 for (const key in obj) {
1468 if (!Object.prototype.hasOwnProperty.call(obj, key)) continue;
1469
1470 // For Google, filter unsupported top-level keywords
1471 if (isGoogleApi && ['default', 'additionalProperties', 'exclusiveMinimum', 'propertyNames'].includes(key)) {
1472 continue;
1473 }
1474
1475 result[key] = resolve(obj[key], parents);
1476 }
1477
1478 return result;
1479 }
1480
1481 const flattenedSchema = resolve(schemaCopy);
1482 delete flattenedSchema.$schema;
1483 return flattenedSchema;
1484}
1485
1486/**
1487 * Writes to a file, creating it's parent directories if needed.
1488 * @param {string} filePath
1489 * @param {string} data
1490 */
1491export function tryWriteFileSync(filePath, data) {
1492 const directory = path.dirname(filePath);
1493 //Ensure the directory exists.
1494 if (!fs.existsSync(directory)) {
1495 fs.mkdirSync(directory, { recursive: true });
1496 }
1497 writeFileAtomicSync(filePath, data, 'utf8');
1498}
1499
1500/**
1501* Attempts to read a file as utf8.
1502* @param {string} filePath
1503* @returns {string|null}
1504*/
1505export function tryReadFileSync(filePath) {
1506 try {
1507 if (fs.existsSync(filePath)) {
1508 return fs.readFileSync(filePath, 'utf8');
1509 }
1510 } catch (error) {
1511 console.error(`Error reading ${filePath}: ${error.message}`);
1512 }
1513 return null;
1514}
1515
1516/**
1517* Attempts to delete a file.
1518* @param {string} filePath Target file.
1519* @returns {boolean} Returns true if the file was found and deleted.
1520*/
1521export function tryDeleteFile(filePath) {
1522 if (fs.existsSync(filePath)) {
1523 fs.unlinkSync(filePath);
1524 console.info(`Deleted file: ${filePath}`);
1525 return true;
1526 } else {
1527 console.error(`File not found '${filePath}'`);
1528 return false;
1529 }
1530}
1531
1532/**
1533 * Reads the first line of a file asynchronously.
1534 * @param {string} filePath Path to the file
1535 * @returns {Promise<string>} The first line of the file
1536 */
1537export function readFirstLine(filePath) {
1538 const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
1539 const rl = readline.createInterface({ input: stream });
1540 return new Promise((resolve, reject) => {
1541 let resolved = false;
1542 rl.on('line', line => {
1543 resolved = true;
1544 rl.close();
1545 stream.close();
1546 resolve(line);
1547 });
1548
1549 rl.on('error', error => {
1550 resolved = true;
1551 reject(error);
1552 });
1553
1554 // Handle empty files
1555 stream.on('end', () => {
1556 if (!resolved) {
1557 resolved = true;
1558 resolve('');
1559 }
1560 });
1561 });
1562}
1563
1564/**
1565 * If the file is an image, and the request's user agent matches Firefox, then the response's headers are set to invalidate the cache.
1566 * Without this, Firefox ignores updated images even after a refresh.
1567 * https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control
1568 * @param {string} file File path
1569 * @param {import('express').Request} request Request object
1570 * @param {import('express').Response} response Response object
1571 */
1572export function invalidateFirefoxCache(file, request, response) {
1573 const mimeType = isFirefox(request) && mime.lookup(file);
1574 if (mimeType && mimeType.startsWith('image/')) {
1575 response.setHeader('Cache-Control', 'must-understand, no-store');
1576 }
1577}