Backported refactor of the chats endpoint from `feat/chat-tree.` (#4870) * Backported cleanup and refactor of chats endpoint from `feat/chat-tree`. * https://github.com/SillyTavern/SillyTavern/pull/4870#discussion_r2599941781 * Error message. https://github.com/SillyTavern/SillyTavern/pull/4870#discussion_r2599941796 https://github.com/SillyTavern/SillyTavern/pull/4870#discussion_r2599941797 * chatIntegritySlug https://github.com/SillyTavern/SillyTavern/pull/4870#discussion_r2599958991 * /save should not hang on empty body.chat. https://github.com/SillyTavern/SillyTavern/pull/4870#discussion_r2599959001 https://github.com/SillyTavern/SillyTavern/pull/4870#discussion_r2599958997 * When the backup directory does not exist, log an error instead of silently failing. * chatData should be an array. https://github.com/SillyTavern/SillyTavern/pull/4870#discussion_r2599990728 * Skip body check. https://github.com/SillyTavern/SillyTavern/pull/4870#discussion_r2599987542 * Change integrity warning to debug. https://github.com/SillyTavern/SillyTavern/pull/4870#discussion_r2600005665 * Return null on error in `tryReadFileSync`. * Update warning message for missing chat file to clarify potential emptiness * Rename IntegrityMismatch to IntegrityMismatchError for clarity and consistency * Fix a type error and formatting * Improve debug messages and documentation for integrity checks in chat saving * Add newline before file writing function for improved readability * Improve debug message formatting for integrity validation in checkChatIntegrity function --------- Co-authored-by: user <user@exmaple.com> Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>
Signed| @@ -16,6 +16,10 @@ import { | ||
| 16 | 16 | generateTimestamp, |
| 17 | 17 | removeOldBackups, |
| 18 | 18 | formatBytes, |
| 19 | + tryWriteFileSync, | |
| 20 | + tryReadFileSync, | |
| 21 | + tryDeleteFile, | |
| 22 | + readFirstLine, | |
| 19 | 23 | } from '../util.js'; |
| 20 | 24 | |
| 21 | 25 | const isBackupEnabled = !!getConfigValue('backups.chat.enabled', true, 'boolean'); |
| @@ -27,43 +31,43 @@ export const CHAT_BACKUPS_PREFIX = 'chat_'; | ||
| 27 | 31 | |
| 28 | 32 | /** |
| 29 | 33 | * Saves a chat to the backups directory. |
| 30 | 34 | * @param {string} directory The user's backupsbackup directory. |
| 31 | 35 | * @param {string} name The name of the chat. |
| 32 | 36 | * @param {string} chatdata The serialized chat to save. |
| 37 | + * @param {string} backupPrefix The file prefix. Typically CHAT_BACKUPS_PREFIX. | |
| 38 | + * @returns | |
| 33 | 39 | */ |
| 34 | 40 | function backupChat(directory, name, chatdata, backupPrefix = CHAT_BACKUPS_PREFIX) { |
| 35 | 41 | try { |
| 36 | 42 | if (!isBackupEnabled || !fs.existsSync(directory)) { return; } |
| 37 | - return; | |
| 43 | + if (!fs.existsSync(directory)) { | |
| 44 | + console.error(`The chat couldn't be backed up because no directory exists at ${directory}!`); | |
| 38 | 45 | } |
| 39 | - | |
| 40 | 46 | // replace non-alphanumeric characters with underscores |
| 41 | 47 | name = sanitize(name).replace(/[^a-z0-9]/gi, '_').toLowerCase(); |
| 42 | 48 | |
| 43 | 49 | const backupFile = path.join(directory, `${CHAT_BACKUPS_PREFIXbackupPrefix}${name}_${generateTimestamp()}.jsonl`); |
| 44 | - writeFileAtomicSync(backupFile, chat, 'utf-8'); | |
| 45 | - | |
| 46 | - removeOldBackups(directory, `${CHAT_BACKUPS_PREFIX}${name}_`); | |
| 47 | 50 | |
| 51 | + tryWriteFileSync(backupFile, data); | |
| 52 | + removeOldBackups(directory, `${backupPrefix}${name}_`); | |
| 48 | 53 | if (isNaN(maxTotalChatBackups) || maxTotalChatBackups < 0) { |
| 49 | 54 | return; |
| 50 | 55 | } |
| 51 | - | |
| 56 | + removeOldBackups(directory, backupPrefix, maxTotalChatBackups); | |
| 52 | - removeOldBackups(directory, CHAT_BACKUPS_PREFIX, maxTotalChatBackups); | |
| 53 | 57 | } catch (err) { |
| 54 | 58 | console.error(`Could not backup chat for ${name}`, err); |
| 55 | 59 | } |
| 56 | 60 | } |
| 57 | 61 | |
| 58 | 62 | /** |
| 59 | 63 | * @type {Map<string, import('lodash').DebouncedFunc<function(string, string, string):typeof voidbackupChat>>} |
| 60 | 64 | */ |
| 61 | 65 | const backupFunctions = new Map(); |
| 62 | 66 | |
| 63 | 67 | /** |
| 64 | 68 | * Gets a backup function for a user. |
| 65 | 69 | * @param {string} handle User handle |
| 66 | 70 | * @returns {function(string, string, string):typeof voidbackupChat} Backup function |
| 67 | 71 | */ |
| 68 | 72 | function getBackupFunction(handle) { |
| 69 | 73 | if (!backupFunctions.has(handle)) { |
| @@ -304,38 +308,6 @@ function importRisuChat(userName, characterName, jsonData) { | ||
| 304 | 308 | } |
| 305 | 309 | |
| 306 | 310 | /** |
| 307 | - * Reads the first line of a file asynchronously. | |
| 308 | - * @param {string} filePath Path to the file | |
| 309 | - * @returns {Promise<string>} The first line of the file | |
| 310 | - */ | |
| 311 | -function readFirstLine(filePath) { | |
| 312 | - const stream = fs.createReadStream(filePath, { encoding: 'utf8' }); | |
| 313 | - const rl = readline.createInterface({ input: stream }); | |
| 314 | - return new Promise((resolve, reject) => { | |
| 315 | - let resolved = false; | |
| 316 | - rl.on('line', line => { | |
| 317 | - resolved = true; | |
| 318 | - rl.close(); | |
| 319 | - stream.close(); | |
| 320 | - resolve(line); | |
| 321 | - }); | |
| 322 | - | |
| 323 | - rl.on('error', error => { | |
| 324 | - resolved = true; | |
| 325 | - reject(error); | |
| 326 | - }); | |
| 327 | - | |
| 328 | - // Handle empty files | |
| 329 | - stream.on('end', () => { | |
| 330 | - if (!resolved) { | |
| 331 | - resolved = true; | |
| 332 | - resolve(''); | |
| 333 | - } | |
| 334 | - }); | |
| 335 | - }); | |
| 336 | -} | |
| 337 | - | |
| 338 | -/** | |
| 339 | 311 | * Checks if the chat being saved has the same integrity as the one being loaded. |
| 340 | 312 | * @param {string} filePath Path to the chat file |
| 341 | 313 | * @param {string} integritySlug Integrity slug |
| @@ -354,6 +326,7 @@ async function checkChatIntegrity(filePath, integritySlug) { | ||
| 354 | 326 | |
| 355 | 327 | // If the chat has no integrity metadata, assume it's intact |
| 356 | 328 | if (!chatIntegrity) { |
| 329 | + console.debug(`File "${filePath}" does not have integrity metadata matching "${integritySlug}". The integrity validation has been skipped.`); | |
| 357 | 330 | return true; |
| 358 | 331 | } |
| 359 | 332 | |
| @@ -439,30 +412,85 @@ export async function getChatInfo(pathToFile, additionalData = {}, withMetadata | ||
| 439 | 412 | |
| 440 | 413 | export const router = express.Router(); |
| 441 | 414 | |
| 415 | +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error | |
| 416 | +class IntegrityMismatchError extends Error { | |
| 417 | + constructor(...params) { | |
| 418 | + // Pass remaining arguments (including vendor specific ones) to parent constructor | |
| 419 | + super(...params); | |
| 420 | + // Maintains proper stack trace for where our error was thrown (non-standard) | |
| 421 | + if (Error.captureStackTrace) { | |
| 422 | + Error.captureStackTrace(this, IntegrityMismatchError); | |
| 423 | + } | |
| 424 | + this.date = new Date(); | |
| 425 | + } | |
| 426 | +} | |
| 427 | + | |
| 428 | +/** | |
| 429 | + * Tries to save the chat data to a file, performing an integrity check if required. | |
| 430 | + * @param {Array} chatData The chat array to save. | |
| 431 | + * @param {string} filePath Target file path for the data. | |
| 432 | + * @param {boolean} skipIntegrityCheck If undefined, the chat's integrity will not be checked. | |
| 433 | + * @param {string} handle The users handle, passed to getBackupFunction. | |
| 434 | + * @param {string} cardName Passed to backupChat. | |
| 435 | + * @param {string} backupDirectory Passed to backupChat. | |
| 436 | + */ | |
| 437 | +export async function trySaveChat(chatData, filePath, skipIntegrityCheck = false, handle, cardName, backupDirectory) { | |
| 438 | + const jsonlData = chatData?.map(m => JSON.stringify(m)).join('\n'); | |
| 439 | + | |
| 440 | + const doIntegrityCheck = (checkIntegrity && !skipIntegrityCheck); | |
| 441 | + const chatIntegritySlug = doIntegrityCheck ? chatData?.[0]?.chat_metadata?.integrity : undefined; | |
| 442 | + | |
| 443 | + if (chatIntegritySlug && !await checkChatIntegrity(filePath, chatIntegritySlug)) { | |
| 444 | + throw new IntegrityMismatchError(`Chat integrity check failed for "${filePath}". The expected integrity slug was "${chatIntegritySlug}".`); | |
| 445 | + } | |
| 446 | + tryWriteFileSync(filePath, jsonlData); | |
| 447 | + getBackupFunction(handle)(backupDirectory, cardName, jsonlData); | |
| 448 | +} | |
| 449 | + | |
| 442 | 450 | router.post('/save', validateAvatarUrlMiddleware, async function (request, response) { |
| 443 | 451 | try { |
| 444 | 452 | const directoryNamehandle = String(request.body.avatar_url)user.replace('profile.png', '')handle; |
| 453 | + const cardName = String(request.body.avatar_url).replace('.png', ''); | |
| 445 | 454 | const chatData = request.body.chat; |
| 446 | - const jsonlData = chatData.map(JSON.stringify).join('\n'); | |
| 455 | + const chatFileName = `${String(request.body.file_name)}.jsonl`; | |
| 447 | - const fileName = `${String(request.body.file_name)}.jsonl`; | |
| 456 | + const chatFilePath = path.join(request.user.directories.chats, cardName, sanitize(chatFileName)); | |
| 448 | - const filePath = path.join(request.user.directories.chats, directoryName, sanitize(fileName)); | |
| 457 | + | |
| 449 | 458 | if (checkIntegrity && !request.bodyArray.forceisArray(chatData)) { |
| 450 | - const integritySlug = chatData?.[0]?.chat_metadata?.integrity; | |
| 459 | + await trySaveChat(chatData, chatFilePath, request.body.force, handle, cardName, request.user.directories.backups); | |
| 451 | - const isIntact = await checkChatIntegrity(filePath, integritySlug); | |
| 460 | + return response.send({ ok: true }); | |
| 452 | - if (!isIntact) { | |
| 461 | + } else { | |
| 453 | - console.error(`Chat integrity check failed for ${filePath}`); | |
| 462 | + return response.status(400).send({ error: 'The request\'s body.chat is not an array.' }); | |
| 454 | - return response.status(400).send({ error: 'integrity' }); | |
| 455 | - } | |
| 456 | 463 | } |
| 457 | - writeFileAtomicSync(filePath, jsonlData, 'utf8'); | |
| 458 | - getBackupFunction(request.user.profile.handle)(request.user.directories.backups, directoryName, jsonlData); | |
| 459 | - return response.send({ result: 'ok' }); | |
| 460 | 464 | } catch (error) { |
| 465 | + if (error instanceof IntegrityMismatchError) { | |
| 466 | + console.error(error.message); | |
| 467 | + return response.status(400).send({ error: 'integrity' }); | |
| 468 | + } | |
| 461 | 469 | console.error(error); |
| 462 | - return response.send(error); | |
| 470 | + return response.status(500).send({ error: 'An error has occurred, see the console logs for more information.' }); | |
| 463 | 471 | } |
| 464 | 472 | }); |
| 465 | 473 | |
| 474 | +/** | |
| 475 | + * Gets the chat as an object. | |
| 476 | + * @param {string} chatFilePath The full chat file path. | |
| 477 | + * @returns {Array}} If the chatFilePath cannot be read, this will return []. | |
| 478 | + */ | |
| 479 | +export function getChatData(chatFilePath) { | |
| 480 | + let chatData = []; | |
| 481 | + | |
| 482 | + const chatJSON = tryReadFileSync(chatFilePath) ?? ''; | |
| 483 | + if (chatJSON.length > 0) { | |
| 484 | + const lines = chatJSON.split('\n'); | |
| 485 | + // Iterate through the array of strings and parse each line as JSON | |
| 486 | + chatData = lines.map(line => tryParse(line)).filter(x => x); | |
| 487 | + } else { | |
| 488 | + console.warn(`File not found: ${chatFilePath}. The chat does not exist or is empty.`); | |
| 489 | + } | |
| 490 | + | |
| 491 | + return chatData; | |
| 492 | +} | |
| 493 | + | |
| 466 | 494 | router.post('/get', validateAvatarUrlMiddleware, function (request, response) { |
| 467 | 495 | try { |
| 468 | 496 | const dirName = String(request.body.avatar_url).replace('.png', ''); |
| @@ -479,20 +507,10 @@ router.post('/get', validateAvatarUrlMiddleware, function (request, response) { | ||
| 479 | 507 | return response.send({}); |
| 480 | 508 | } |
| 481 | 509 | |
| 482 | 510 | const fileNamechatFileName = `${String(request.body.file_name)}.jsonl`; |
| 483 | 511 | const filePathchatFilePath = path.join(directoryPath, sanitize(fileNamechatFileName)); |
| 484 | - const chatFileExists = fs.existsSync(filePath); | |
| 485 | 512 | |
| 486 | - if (!chatFileExists) { | |
| 513 | + return response.send(getChatData(chatFilePath)); | |
| 487 | - return response.send({}); | |
| 488 | - } | |
| 489 | - | |
| 490 | - const data = fs.readFileSync(filePath, 'utf8'); | |
| 491 | - const lines = data.split('\n'); | |
| 492 | - | |
| 493 | - // Iterate through the array of strings and parse each line as JSON | |
| 494 | - const jsonData = lines.map((l) => { try { return JSON.parse(l); } catch (_) { return; } }).filter(x => x); | |
| 495 | - return response.send(jsonData); | |
| 496 | 514 | } catch (error) { |
| 497 | 515 | console.error(error); |
| 498 | 516 | return response.send({}); |
| @@ -536,18 +554,15 @@ router.post('/delete', validateAvatarUrlMiddleware, function (request, response) | ||
| 536 | 554 | } |
| 537 | 555 | |
| 538 | 556 | const dirName = String(request.body.avatar_url).replace('.png', ''); |
| 539 | 557 | const fileNamechatFileName = String(request.body.chatfile); |
| 540 | 558 | const filePathchatFilePath = path.join(request.user.directories.chats, dirName, sanitize(fileNamechatFileName)); |
| 541 | - const chatFileExists = fs.existsSync(filePath); | |
| 559 | + //Return success if the file was deleted. | |
| 542 | - | |
| 560 | + if (tryDeleteFile(chatFilePath)) { | |
| 543 | - if (!chatFileExists) { | |
| 561 | + return response.send({ ok: true }); | |
| 544 | - console.error(`Chat file not found '${filePath}'`); | |
| 562 | + } else { | |
| 563 | + console.error('The chat file was not deleted.'); | |
| 545 | 564 | return response.sendStatus(400); |
| 546 | 565 | } |
| 547 | - | |
| 548 | - fs.unlinkSync(filePath); | |
| 549 | - console.info(`Deleted chat file: ${filePath}`); | |
| 550 | - return response.send('ok'); | |
| 551 | 566 | } catch (error) { |
| 552 | 567 | console.error(error); |
| 553 | 568 | return response.sendStatus(500); |
| @@ -745,67 +760,58 @@ router.post('/group/get', (request, response) => { | ||
| 745 | 760 | } |
| 746 | 761 | |
| 747 | 762 | const id = request.body.id; |
| 748 | 763 | const pathToFilechatFilePath = path.join(request.user.directories.groupChats, `${id}.jsonl`); |
| 749 | - | |
| 750 | - if (fs.existsSync(pathToFile)) { | |
| 751 | - const data = fs.readFileSync(pathToFile, 'utf8'); | |
| 752 | - const lines = data.split('\n'); | |
| 753 | 764 | |
| 754 | - // Iterate through the array of strings and parse each line as JSON | |
| 765 | + return response.send(getChatData(chatFilePath)); | |
| 755 | - const jsonData = lines.map(line => tryParse(line)).filter(x => x); | |
| 756 | - return response.send(jsonData); | |
| 757 | - } else { | |
| 758 | - return response.send([]); | |
| 759 | - } | |
| 760 | 766 | }); |
| 761 | 767 | |
| 762 | 768 | router.post('/group/delete', (request, response) => { |
| 769 | + try { | |
| 763 | 770 | if (!request.body || !request.body.id) { |
| 764 | 771 | return response.sendStatus(400); |
| 765 | 772 | } |
| 766 | 773 | |
| 767 | 774 | const id = request.body.id; |
| 768 | 775 | const pathToFilechatFilePath = path.join(request.user.directories.groupChats, `${id}.jsonl`); |
| 769 | 776 | |
| 770 | - if (fs.existsSync(pathToFile)) { | |
| 777 | + //Return success if the file was deleted. | |
| 771 | - fs.unlinkSync(pathToFile); | |
| 778 | + if (tryDeleteFile(chatFilePath)) { | |
| 772 | 779 | return response.send({ ok: true }); |
| 780 | + } else { | |
| 781 | + console.error('The group chat file was not deleted.\''); | |
| 782 | + return response.sendStatus(400); | |
| 783 | + } | |
| 784 | + } catch (error) { | |
| 785 | + console.error(error); | |
| 786 | + return response.sendStatus(500); | |
| 773 | 787 | } |
| 774 | - | |
| 775 | - return response.send({ error: true }); | |
| 776 | 788 | }); |
| 777 | 789 | |
| 778 | 790 | router.post('/group/save', async function (request, response) => { |
| 779 | 791 | try { |
| 780 | 792 | if (!request.body || !request.body.id) { |
| 781 | 793 | return response.sendStatus(400); |
| 782 | 794 | } |
| 783 | 795 | |
| 784 | 796 | const id = request.body.id; |
| 785 | 797 | const filePathhandle = path.join(request.user.directories.groupChats, sanitize(`${id}profile.jsonl`))handle; |
| 786 | - | |
| 798 | + const chatFilePath = path.join(request.user.directories.groupChats, sanitize(`${id}.jsonl`)); | |
| 787 | - if (!fs.existsSync(request.user.directories.groupChats)) { | |
| 788 | - fs.mkdirSync(request.user.directories.groupChats, { recursive: true }); | |
| 789 | - } | |
| 790 | - | |
| 791 | 799 | const chatData = request.body.chat; |
| 792 | - const jsonlData = chatData.map(JSON.stringify).join('\n'); | |
| 793 | 800 | |
| 794 | 801 | if (checkIntegrity && !request.bodyArray.forceisArray(chatData)) { |
| 795 | - const integritySlug = chatData?.[0]?.chat_metadata?.integrity; | |
| 802 | + await trySaveChat(chatData, chatFilePath, request.body.force, handle, String(id), request.user.directories.backups); | |
| 796 | - const isIntact = await checkChatIntegrity(filePath, integritySlug); | |
| 803 | + return response.send({ ok: true }); | |
| 797 | - if (!isIntact) { | |
| 798 | - console.error(`Chat integrity check failed for ${filePath}`); | |
| 799 | - return response.status(400).send({ error: 'integrity' }); | |
| 800 | 804 | } |
| 805 | + else { | |
| 806 | + return response.status(400).send({ error: 'The request\'s body.chat is not an array.' }); | |
| 801 | 807 | } |
| 802 | - | |
| 803 | - writeFileAtomicSync(filePath, jsonlData, 'utf8'); | |
| 804 | - getBackupFunction(request.user.profile.handle)(request.user.directories.backups, String(id), jsonlData); | |
| 805 | - return response.send({ ok: true }); | |
| 806 | 808 | } catch (error) { |
| 809 | + if (error instanceof IntegrityMismatchError) { | |
| 810 | + console.error(error.message); | |
| 811 | + return response.status(400).send({ error: 'integrity' }); | |
| 812 | + } | |
| 807 | 813 | console.error(error); |
| 808 | 814 | return response.status(500).send({ error: true'An error has occurred, see the console logs for more information.' }); |
| 809 | 815 | } |
| 810 | 816 | }); |
| 811 | 817 | |
| @@ -877,10 +883,8 @@ router.post('/search', validateAvatarUrlMiddleware, function (request, response) | ||
| 877 | 883 | |
| 878 | 884 | // Search logic |
| 879 | 885 | for (const chatFile of chatFiles) { |
| 880 | 886 | const data = fs.readFileSyncgetChatData(chatFile.path, 'utf8'); |
| 881 | 887 | const messages = data.splitfilter(x => x && typeof x.mes === '\nstring'); |
| 882 | - .map(line => { try { return JSON.parse(line); } catch (_) { return null; } }) | |
| 883 | - .filter(x => x && typeof x.mes === 'string'); | |
| 884 | 888 | |
| 885 | 889 | if (query && messages.length === 0) { |
| 886 | 890 | continue; |
| @@ -8,6 +8,7 @@ import { Buffer } from 'node:buffer'; | ||
| 8 | 8 | import { promises as dnsPromise } from 'node:dns'; |
| 9 | 9 | import os from 'node:os'; |
| 10 | 10 | import crypto from 'node:crypto'; |
| 11 | +import readline from 'node:readline'; | |
| 11 | 12 | |
| 12 | 13 | import yaml from 'yaml'; |
| 13 | 14 | import { sync as commandExistsSync } from 'command-exists'; |
| @@ -19,6 +20,7 @@ import chalk from 'chalk'; | ||
| 19 | 20 | import bytes from 'bytes'; |
| 20 | 21 | import { LOG_LEVELS, CHAT_COMPLETION_SOURCES, MEDIA_REQUEST_TYPE } from './constants.js'; |
| 21 | 22 | import { serverDirectory } from './server-directory.js'; |
| 23 | +import { sync as writeFileAtomicSync } from 'write-file-atomic'; | |
| 22 | 24 | import { isFirefox } from './express-common.js'; |
| 23 | 25 | |
| 24 | 26 | /** |
| @@ -1464,6 +1466,84 @@ export function flattenSchema(schema, api) { | ||
| 1464 | 1466 | } |
| 1465 | 1467 | |
| 1466 | 1468 | /** |
| 1469 | + * Writes to a file, creating it's parent directories if needed. | |
| 1470 | + * @param {string} filePath | |
| 1471 | + * @param {string} data | |
| 1472 | + */ | |
| 1473 | +export function tryWriteFileSync(filePath, data) { | |
| 1474 | + const directory = path.dirname(filePath); | |
| 1475 | + //Ensure the directory exists. | |
| 1476 | + if (!fs.existsSync(directory)) { | |
| 1477 | + fs.mkdirSync(directory, { recursive: true }); | |
| 1478 | + } | |
| 1479 | + writeFileAtomicSync(filePath, data, 'utf8'); | |
| 1480 | +} | |
| 1481 | + | |
| 1482 | +/** | |
| 1483 | +* Attempts to read a file as utf8. | |
| 1484 | +* @param {string} filePath | |
| 1485 | +* @returns {string|null} | |
| 1486 | +*/ | |
| 1487 | +export function tryReadFileSync(filePath) { | |
| 1488 | + try { | |
| 1489 | + if (fs.existsSync(filePath)) { | |
| 1490 | + return fs.readFileSync(filePath, 'utf8'); | |
| 1491 | + } | |
| 1492 | + } catch (error) { | |
| 1493 | + console.error(`Error reading ${filePath}: ${error.message}`); | |
| 1494 | + } | |
| 1495 | + return null; | |
| 1496 | +} | |
| 1497 | + | |
| 1498 | +/** | |
| 1499 | +* Attempts to delete a file. | |
| 1500 | +* @param {string} filePath Target file. | |
| 1501 | +* @returns {boolean} Returns true if the file was found and deleted. | |
| 1502 | +*/ | |
| 1503 | +export function tryDeleteFile(filePath) { | |
| 1504 | + if (fs.existsSync(filePath)) { | |
| 1505 | + fs.unlinkSync(filePath); | |
| 1506 | + console.info(`Deleted file: ${filePath}`); | |
| 1507 | + return true; | |
| 1508 | + } else { | |
| 1509 | + console.error(`File not found '${filePath}'`); | |
| 1510 | + return false; | |
| 1511 | + } | |
| 1512 | +} | |
| 1513 | + | |
| 1514 | +/** | |
| 1515 | + * Reads the first line of a file asynchronously. | |
| 1516 | + * @param {string} filePath Path to the file | |
| 1517 | + * @returns {Promise<string>} The first line of the file | |
| 1518 | + */ | |
| 1519 | +export function readFirstLine(filePath) { | |
| 1520 | + const stream = fs.createReadStream(filePath, { encoding: 'utf8' }); | |
| 1521 | + const rl = readline.createInterface({ input: stream }); | |
| 1522 | + return new Promise((resolve, reject) => { | |
| 1523 | + let resolved = false; | |
| 1524 | + rl.on('line', line => { | |
| 1525 | + resolved = true; | |
| 1526 | + rl.close(); | |
| 1527 | + stream.close(); | |
| 1528 | + resolve(line); | |
| 1529 | + }); | |
| 1530 | + | |
| 1531 | + rl.on('error', error => { | |
| 1532 | + resolved = true; | |
| 1533 | + reject(error); | |
| 1534 | + }); | |
| 1535 | + | |
| 1536 | + // Handle empty files | |
| 1537 | + stream.on('end', () => { | |
| 1538 | + if (!resolved) { | |
| 1539 | + resolved = true; | |
| 1540 | + resolve(''); | |
| 1541 | + } | |
| 1542 | + }); | |
| 1543 | + }); | |
| 1544 | +} | |
| 1545 | + | |
| 1546 | +/** | |
| 1467 | 1547 | * 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. |
| 1468 | 1548 | * Without this, Firefox ignores updated images even after a refresh. |
| 1469 | 1549 | * https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control |