feat: optionally gzip large save uploads with fallback (#5259)
* feat: optionally gzip large save uploads with fallback * fix: replace Safari-prone save compression with fflate fallback * refactor: align save upload compression with review feedback * refactor: use compressRequest wrapper for save uploads * Refactor request compression settings * Fix default value * Avoid null in bytes parsing result * fix: switch request compression to fflate gzip * fix: add request compression maxBytes cap and clarify timeout semantics * Refresh package-lock.json * Unify payload limit setting names * Expose compression termination function * Add compression to group chat saves --------- Co-authored-by: Roland4396 <Roland4396@users.noreply.github.com> Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>
This commit is contained in:
@@ -202,6 +202,16 @@ performance:
|
||||
memoryCacheCapacity: '100mb'
|
||||
# Enables disk caching for character cards. Improves performances with large card libraries.
|
||||
useDiskCache: true
|
||||
# Configures gzip compression for client requests with large payloads (e.g. settings or chat saves).
|
||||
requestCompression:
|
||||
# Enable request compression.
|
||||
enabled: false
|
||||
# Minimum payload size to trigger compression. Set to 0 to compress all requests regardless of size.
|
||||
minPayloadSize: '256kb'
|
||||
# Hard upper payload size limit for compression. Set to 0 to allow compression of any size.
|
||||
maxPayloadSize: '8mb'
|
||||
# Timeout for request compression in milliseconds.
|
||||
timeout: 4000
|
||||
|
||||
# CACHE BUSTER CONFIGURATION
|
||||
# IMPORTANT: Requires localhost or a domain with HTTPS, otherwise will not work!
|
||||
|
||||
Generated
+7
@@ -58,6 +58,7 @@
|
||||
"droll": "^0.2.1",
|
||||
"env-paths": "^3.0.0",
|
||||
"express": "^4.21.0",
|
||||
"fflate": "^0.8.2",
|
||||
"form-data": "^4.0.4",
|
||||
"fuse.js": "^7.1.0",
|
||||
"google-translate-api-x": "^10.7.2",
|
||||
@@ -5095,6 +5096,12 @@
|
||||
"node": "^12.20 || >= 14.13"
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.2",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
|
||||
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/file-entry-cache": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
"droll": "^0.2.1",
|
||||
"env-paths": "^3.0.0",
|
||||
"express": "^4.21.0",
|
||||
"fflate": "^0.8.2",
|
||||
"form-data": "^4.0.4",
|
||||
"fuse.js": "^7.1.0",
|
||||
"google-translate-api-x": "^10.7.2",
|
||||
|
||||
@@ -23,6 +23,7 @@ import { toggle as slideToggle } from 'slidetoggle';
|
||||
import chalk from 'chalk';
|
||||
import yaml from 'yaml';
|
||||
import * as chevrotain from 'chevrotain';
|
||||
import { gzipSync, gzip } from 'fflate';
|
||||
|
||||
/**
|
||||
* Expose the libraries to the 'window' object.
|
||||
@@ -102,6 +103,8 @@ export default {
|
||||
chalk,
|
||||
yaml,
|
||||
chevrotain,
|
||||
gzipSync,
|
||||
gzip,
|
||||
};
|
||||
|
||||
export {
|
||||
@@ -127,4 +130,6 @@ export {
|
||||
chalk,
|
||||
yaml,
|
||||
chevrotain,
|
||||
gzipSync,
|
||||
gzip,
|
||||
};
|
||||
|
||||
+8
-3
@@ -285,6 +285,7 @@ import { MacroEnvBuilder } from './scripts/macros/engine/MacroEnvBuilder.js';
|
||||
import { MacroEngine } from './scripts/macros/engine/MacroEngine.js';
|
||||
import { addChatBackupsBrowser } from './scripts/chat-backups.js';
|
||||
import { onboardingExperimentalMacroEngine } from './scripts/macros/engine/MacroDiagnostics.js';
|
||||
import { compressRequest, setRequestCompressionConfig } from './scripts/request-compression.js';
|
||||
|
||||
// API OBJECT FOR EXTERNAL WIRING
|
||||
globalThis.SillyTavern = {
|
||||
@@ -7131,7 +7132,7 @@ async function renamePastChats(oldAvatar, newAvatar, newName) {
|
||||
|
||||
await eventSource.emit(event_types.CHARACTER_RENAMED_IN_PAST_CHAT, currentChat, oldAvatar, newAvatar);
|
||||
|
||||
const saveChatResponse = await fetch('/api/chats/save', {
|
||||
const saveChatRequest = await compressRequest({
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify({
|
||||
@@ -7142,6 +7143,7 @@ async function renamePastChats(oldAvatar, newAvatar, newName) {
|
||||
}),
|
||||
cache: 'no-cache',
|
||||
});
|
||||
const saveChatResponse = await fetch('/api/chats/save', saveChatRequest);
|
||||
|
||||
if (!saveChatResponse.ok) {
|
||||
throw new Error('Could not save chat');
|
||||
@@ -7225,7 +7227,7 @@ export async function saveChat({ chatName, withMetadata, mesId, force = false }
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await fetch('/api/chats/save', {
|
||||
const saveChatRequest = await compressRequest({
|
||||
method: 'POST',
|
||||
cache: 'no-cache',
|
||||
headers: getRequestHeaders(),
|
||||
@@ -7237,6 +7239,7 @@ export async function saveChat({ chatName, withMetadata, mesId, force = false }
|
||||
force: force,
|
||||
}),
|
||||
});
|
||||
const result = await fetch('/api/chats/save', saveChatRequest);
|
||||
|
||||
if (result.ok) {
|
||||
return;
|
||||
@@ -7729,6 +7732,7 @@ export async function getSettings() {
|
||||
|
||||
accountStorage.init(settings?.accountStorage);
|
||||
await setUserControls(data.enable_accounts);
|
||||
setRequestCompressionConfig(data.request_compression);
|
||||
|
||||
// Allow subscribers to mutate settings
|
||||
await eventSource.emit(event_types.SETTINGS_LOADED_BEFORE, settings);
|
||||
@@ -7879,12 +7883,13 @@ export async function saveSettings(loopCounter = 0) {
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await fetch('/api/settings/save', {
|
||||
const saveSettingsRequest = await compressRequest({
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify(payload),
|
||||
cache: 'no-cache',
|
||||
});
|
||||
const result = await fetch('/api/settings/save', saveSettingsRequest);
|
||||
|
||||
if (!result.ok) {
|
||||
throw new Error(`Failed to save settings: ${result.statusText}`);
|
||||
|
||||
@@ -34,6 +34,7 @@ import { commonEnumProviders } from './slash-commands/SlashCommandCommonEnumsPro
|
||||
import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
|
||||
import { createTagMapFromList } from './tags.js';
|
||||
import { renderTemplateAsync } from './templates.js';
|
||||
import { compressRequest } from './request-compression.js';
|
||||
import { t } from './i18n.js';
|
||||
|
||||
import {
|
||||
@@ -386,11 +387,12 @@ export async function convertSoloToGroupChat() {
|
||||
}
|
||||
|
||||
// Save group chat
|
||||
const createChatResponse = await fetch('/api/chats/group/save', {
|
||||
const createChatRequest = await compressRequest({
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify({ id: chatName, chat: [chatHeader, ...groupChat] }),
|
||||
});
|
||||
const createChatResponse = await fetch('/api/chats/group/save', createChatRequest);
|
||||
|
||||
if (!createChatResponse.ok) {
|
||||
console.error('Group chat creation unsuccessful');
|
||||
|
||||
@@ -86,6 +86,7 @@ import { isExternalMediaAllowed } from './chats.js';
|
||||
import { POPUP_TYPE, Popup, callGenericPopup } from './popup.js';
|
||||
import { t } from './i18n.js';
|
||||
import { accountStorage } from './util/AccountStorage.js';
|
||||
import { compressRequest } from './request-compression.js';
|
||||
|
||||
export {
|
||||
selected_group,
|
||||
@@ -633,11 +634,12 @@ async function saveGroupChat(groupId, shouldSaveGroup, force = false) {
|
||||
user_name: 'unused',
|
||||
character_name: 'unused',
|
||||
};
|
||||
const response = await fetch('/api/chats/group/save', {
|
||||
const saveGroupChatRequest = await compressRequest({
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify({ id: chatId, chat: [chatHeader, ...chat], force: force }),
|
||||
});
|
||||
const response = await fetch('/api/chats/group/save', saveGroupChatRequest);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
@@ -728,11 +730,12 @@ export async function renameGroupMember(oldAvatar, newAvatar, newName) {
|
||||
if (hadChanges) {
|
||||
await eventSource.emit(event_types.CHARACTER_RENAMED_IN_PAST_CHAT, messages, oldAvatar, newAvatar);
|
||||
|
||||
const saveChatResponse = await fetch('/api/chats/group/save', {
|
||||
const saveChatRequest = await compressRequest({
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify({ id: chatId, chat: [...messages] }),
|
||||
});
|
||||
const saveChatResponse = await fetch('/api/chats/group/save', saveChatRequest);
|
||||
|
||||
if (!saveChatResponse.ok) {
|
||||
throw new Error('Group member could not be renamed');
|
||||
@@ -2374,11 +2377,12 @@ export async function saveGroupBookmarkChat(groupId, name, metadata, mesId) {
|
||||
|
||||
await editGroup(groupId, true, false);
|
||||
|
||||
const response = await fetch('/api/chats/group/save', {
|
||||
const saveChatRequest = await compressRequest({
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify({ id: name, chat: [chatHeader, ...trimmedChat] }),
|
||||
});
|
||||
const response = await fetch('/api/chats/group/save', saveChatRequest);
|
||||
|
||||
if (!response.ok) {
|
||||
toastr.error(t`Check the server connection and reload the page to prevent data loss.`, t`Group chat could not be saved`);
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { gzip } from '/lib.js';
|
||||
|
||||
/**
|
||||
* @type {RequestCompressionConfig}
|
||||
*
|
||||
* @typedef {Object} RequestCompressionConfig
|
||||
* @property {boolean} enabled Whether request compression is enabled.
|
||||
* @property {number} minPayloadSize Minimum payload size in bytes to trigger compression.
|
||||
* @property {number} maxPayloadSize Hard upper payload size limit for compression.
|
||||
* @property {number} timeout Timeout for request compression in milliseconds.
|
||||
*/
|
||||
const requestCompressionConfig = {
|
||||
enabled: false,
|
||||
minPayloadSize: 0,
|
||||
maxPayloadSize: 0,
|
||||
timeout: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets the configuration for request compression from the server.
|
||||
* @param {RequestCompressionConfig} config Configuration object for request compression
|
||||
*/
|
||||
export function setRequestCompressionConfig(config) {
|
||||
Object.assign(requestCompressionConfig, (config ?? {}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Compresses a Uint8Array using gzip.
|
||||
* @param {Uint8Array<ArrayBuffer>} input Uint8Array to compress
|
||||
* @returns {{ promise: Promise<Uint8Array<ArrayBuffer>>, terminate: () => void }} Gzip-compressed Uint8Array promise and a terminate function.
|
||||
*/
|
||||
function gzipBuffer(input) {
|
||||
let terminate = () => {};
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
try {
|
||||
terminate = gzip(input, (error, compressed) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(new Uint8Array(compressed));
|
||||
});
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
return { promise, terminate };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a promise with a timeout, rejecting if the promise does not settle within the specified time.
|
||||
* Note: timeout does not cancel the underlying compression task; it only stops waiting for it.
|
||||
* @param {Promise<T>} promise Promise to wrap with a timeout
|
||||
* @param {number} timeoutMs Timeout in milliseconds
|
||||
* @param {string} label Used for error message if timeout occurs
|
||||
* @returns {Promise<T>} Resolves with the original promise's value if it settles in time, otherwise rejects with a timeout error
|
||||
* @template T Type of the promise's resolved value
|
||||
*/
|
||||
async function withTimeout(promise, timeoutMs, label) {
|
||||
let timeoutId = null;
|
||||
const timeoutPromise = new Promise((_, reject) => {
|
||||
timeoutId = setTimeout(() => reject(new Error(`${label}_timeout`)), timeoutMs);
|
||||
});
|
||||
|
||||
try {
|
||||
return await Promise.race([promise, timeoutPromise]);
|
||||
} finally {
|
||||
if (timeoutId !== null) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compresses a fetch request using gzip when supported and worthwhile.
|
||||
* Compression is skipped when feature-toggle is disabled, body is too small,
|
||||
* body is not a string, or compression fails/timeouts.
|
||||
*
|
||||
* @param {RequestInit} request fetch request parameters
|
||||
* @returns {Promise<RequestInit>} A request init object that may include gzip-compressed body
|
||||
*/
|
||||
export async function compressRequest(request) {
|
||||
const plainRequest = { ...request };
|
||||
const requestBody = plainRequest?.body;
|
||||
|
||||
if (!requestCompressionConfig.enabled) {
|
||||
return plainRequest;
|
||||
}
|
||||
|
||||
if (!requestBody || typeof requestBody !== 'string') {
|
||||
return plainRequest;
|
||||
}
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
const encodedBody = textEncoder.encode(requestBody);
|
||||
const bodySize = encodedBody.byteLength;
|
||||
const minBytes = Number(requestCompressionConfig.minPayloadSize) || 0;
|
||||
const maxBytes = Number(requestCompressionConfig.maxPayloadSize) || 0;
|
||||
|
||||
if (bodySize < minBytes || (maxBytes > 0 && bodySize > maxBytes)) {
|
||||
return plainRequest;
|
||||
}
|
||||
|
||||
const { promise, terminate } = gzipBuffer(encodedBody);
|
||||
|
||||
try {
|
||||
const compressedBody = await withTimeout(
|
||||
promise,
|
||||
requestCompressionConfig.timeout,
|
||||
'compress_fflate_gzip',
|
||||
);
|
||||
|
||||
if (!compressedBody || compressedBody.byteLength >= bodySize) {
|
||||
return plainRequest;
|
||||
}
|
||||
|
||||
const headers = new Headers(plainRequest.headers ?? {});
|
||||
headers.set('Content-Encoding', 'gzip');
|
||||
|
||||
return {
|
||||
...plainRequest,
|
||||
headers,
|
||||
body: compressedBody,
|
||||
};
|
||||
} catch (error) {
|
||||
terminate();
|
||||
console.warn('Failed to compress request body, using plain request.', error);
|
||||
return plainRequest;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import path from 'node:path';
|
||||
import express from 'express';
|
||||
import _ from 'lodash';
|
||||
import { sync as writeFileAtomicSync } from 'write-file-atomic';
|
||||
import bytes from 'bytes';
|
||||
|
||||
import { SETTINGS_FILE } from '../constants.js';
|
||||
import { getConfigValue, generateTimestamp, removeOldBackups } from '../util.js';
|
||||
@@ -13,6 +14,10 @@ import { getFileNameValidationFunction } from '../middleware/validateFileName.js
|
||||
const ENABLE_EXTENSIONS = !!getConfigValue('extensions.enabled', true, 'boolean');
|
||||
const ENABLE_EXTENSIONS_AUTO_UPDATE = !!getConfigValue('extensions.autoUpdate', true, 'boolean');
|
||||
const ENABLE_ACCOUNTS = !!getConfigValue('enableUserAccounts', false, 'boolean');
|
||||
const ENABLE_REQUEST_COMPRESSION = !!getConfigValue('performance.requestCompression.enabled', false, 'boolean');
|
||||
const REQUEST_COMPRESSION_MIN = bytes.parse(getConfigValue('performance.requestCompression.minPayloadSize', '256kb'));
|
||||
const REQUEST_COMPRESSION_MAX = bytes.parse(getConfigValue('performance.requestCompression.maxPayloadSize', '8mb'));
|
||||
const REQUEST_COMPRESSION_TIMEOUT = Number(getConfigValue('performance.requestCompression.timeout', 3000, 'number'));
|
||||
|
||||
// 10 minutes
|
||||
const AUTOSAVE_INTERVAL = 10 * 60 * 1000;
|
||||
@@ -281,6 +286,12 @@ router.post('/get', (request, response) => {
|
||||
enable_extensions: ENABLE_EXTENSIONS,
|
||||
enable_extensions_auto_update: ENABLE_EXTENSIONS_AUTO_UPDATE,
|
||||
enable_accounts: ENABLE_ACCOUNTS,
|
||||
request_compression: {
|
||||
enabled: ENABLE_REQUEST_COMPRESSION,
|
||||
minPayloadSize: REQUEST_COMPRESSION_MIN || 0,
|
||||
maxPayloadSize: REQUEST_COMPRESSION_MAX || 0,
|
||||
timeout: REQUEST_COMPRESSION_TIMEOUT || 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user