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:
Roland4396
2026-03-11 05:32:36 +08:00
committed by GitHub
parent dbf9bc1a54
commit 1c5091539c
9 changed files with 183 additions and 7 deletions
+10
View File
@@ -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!
+7
View File
@@ -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",
+1
View File
@@ -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",
+5
View File
@@ -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
View File
@@ -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}`);
+3 -1
View File
@@ -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');
+7 -3
View File
@@ -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`);
+131
View File
@@ -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;
}
}
+11
View File
@@ -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,
},
});
});