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>

1c5091539ce168ea128d532df4f8b447458aa944

Roland4396 <132747020+Roland4396@users.noreply.github.com>

Signed
9 files changed, +183 -7Showing whitespace changes
default/config.yaml+10 -0
@@ -202,6 +202,16 @@ performance:
202202 memoryCacheCapacity: '100mb'
203203 # Enables disk caching for character cards. Improves performances with large card libraries.
204204 useDiskCache: true
205+ # Configures gzip compression for client requests with large payloads (e.g. settings or chat saves).
206+ requestCompression:
207+ # Enable request compression.
208+ enabled: false
209+ # Minimum payload size to trigger compression. Set to 0 to compress all requests regardless of size.
210+ minPayloadSize: '256kb'
211+ # Hard upper payload size limit for compression. Set to 0 to allow compression of any size.
212+ maxPayloadSize: '8mb'
213+ # Timeout for request compression in milliseconds.
214+ timeout: 4000
205215
206216# CACHE BUSTER CONFIGURATION
207217# IMPORTANT: Requires localhost or a domain with HTTPS, otherwise will not work!
package-lock.json+7 -0
@@ -58,6 +58,7 @@
5858 "droll": "^0.2.1",
5959 "env-paths": "^3.0.0",
6060 "express": "^4.21.0",
61+ "fflate": "^0.8.2",
6162 "form-data": "^4.0.4",
6263 "fuse.js": "^7.1.0",
6364 "google-translate-api-x": "^10.7.2",
@@ -5095,6 +5096,12 @@
50955096 "node": "^12.20 || >= 14.13"
50965097 }
50975098 },
5099+ "node_modules/fflate": {
5100+ "version": "0.8.2",
5101+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
5102+ "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
5103+ "license": "MIT"
5104+ },
50985105 "node_modules/file-entry-cache": {
50995106 "version": "6.0.1",
51005107 "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
package.json+1 -0
@@ -48,6 +48,7 @@
4848 "droll": "^0.2.1",
4949 "env-paths": "^3.0.0",
5050 "express": "^4.21.0",
51+ "fflate": "^0.8.2",
5152 "form-data": "^4.0.4",
5253 "fuse.js": "^7.1.0",
5354 "google-translate-api-x": "^10.7.2",
public/lib.js+5 -0
@@ -23,6 +23,7 @@ import { toggle as slideToggle } from 'slidetoggle';
2323import chalk from 'chalk';
2424import yaml from 'yaml';
2525import * as chevrotain from 'chevrotain';
26+import { gzipSync, gzip } from 'fflate';
2627
2728/**
2829 * Expose the libraries to the 'window' object.
@@ -102,6 +103,8 @@ export default {
102103 chalk,
103104 yaml,
104105 chevrotain,
106+ gzipSync,
107+ gzip,
105108};
106109
107110export {
@@ -127,4 +130,6 @@ export {
127130 chalk,
128131 yaml,
129132 chevrotain,
133+ gzipSync,
134+ gzip,
130135};
public/script.js+8 -3
@@ -285,6 +285,7 @@ import { MacroEnvBuilder } from './scripts/macros/engine/MacroEnvBuilder.js';
285285import { MacroEngine } from './scripts/macros/engine/MacroEngine.js';
286286import { addChatBackupsBrowser } from './scripts/chat-backups.js';
287287import { onboardingExperimentalMacroEngine } from './scripts/macros/engine/MacroDiagnostics.js';
288+import { compressRequest, setRequestCompressionConfig } from './scripts/request-compression.js';
288289
289290// API OBJECT FOR EXTERNAL WIRING
290291globalThis.SillyTavern = {
@@ -7131,7 +7132,7 @@ async function renamePastChats(oldAvatar, newAvatar, newName) {
71317132
71327133 await eventSource.emit(event_types.CHARACTER_RENAMED_IN_PAST_CHAT, currentChat, oldAvatar, newAvatar);
71337134
71347135 const saveChatResponsesaveChatRequest = await fetchcompressRequest('/api/chats/save', {
71357136 method: 'POST',
71367137 headers: getRequestHeaders(),
71377138 body: JSON.stringify({
@@ -7142,6 +7143,7 @@ async function renamePastChats(oldAvatar, newAvatar, newName) {
71427143 }),
71437144 cache: 'no-cache',
71447145 });
7146+ const saveChatResponse = await fetch('/api/chats/save', saveChatRequest);
71457147
71467148 if (!saveChatResponse.ok) {
71477149 throw new Error('Could not save chat');
@@ -7225,7 +7227,7 @@ export async function saveChat({ chatName, withMetadata, mesId, force = false }
72257227 };
72267228
72277229 try {
72287230 const resultsaveChatRequest = await fetchcompressRequest('/api/chats/save', {
72297231 method: 'POST',
72307232 cache: 'no-cache',
72317233 headers: getRequestHeaders(),
@@ -7237,6 +7239,7 @@ export async function saveChat({ chatName, withMetadata, mesId, force = false }
72377239 force: force,
72387240 }),
72397241 });
7242+ const result = await fetch('/api/chats/save', saveChatRequest);
72407243
72417244 if (result.ok) {
72427245 return;
@@ -7729,6 +7732,7 @@ export async function getSettings() {
77297732
77307733 accountStorage.init(settings?.accountStorage);
77317734 await setUserControls(data.enable_accounts);
7735+ setRequestCompressionConfig(data.request_compression);
77327736
77337737 // Allow subscribers to mutate settings
77347738 await eventSource.emit(event_types.SETTINGS_LOADED_BEFORE, settings);
@@ -7879,12 +7883,13 @@ export async function saveSettings(loopCounter = 0) {
78797883 };
78807884
78817885 try {
78827886 const resultsaveSettingsRequest = await fetchcompressRequest('/api/settings/save', {
78837887 method: 'POST',
78847888 headers: getRequestHeaders(),
78857889 body: JSON.stringify(payload),
78867890 cache: 'no-cache',
78877891 });
7892+ const result = await fetch('/api/settings/save', saveSettingsRequest);
78887893
78897894 if (!result.ok) {
78907895 throw new Error(`Failed to save settings: ${result.statusText}`);
public/scripts/bookmarks.js+3 -1
@@ -34,6 +34,7 @@ import { commonEnumProviders } from './slash-commands/SlashCommandCommonEnumsPro
3434import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
3535import { createTagMapFromList } from './tags.js';
3636import { renderTemplateAsync } from './templates.js';
37+import { compressRequest } from './request-compression.js';
3738import { t } from './i18n.js';
3839
3940import {
@@ -386,11 +387,12 @@ export async function convertSoloToGroupChat() {
386387 }
387388
388389 // Save group chat
389390 const createChatResponsecreateChatRequest = await fetchcompressRequest('/api/chats/group/save', {
390391 method: 'POST',
391392 headers: getRequestHeaders(),
392393 body: JSON.stringify({ id: chatName, chat: [chatHeader, ...groupChat] }),
393394 });
395+ const createChatResponse = await fetch('/api/chats/group/save', createChatRequest);
394396
395397 if (!createChatResponse.ok) {
396398 console.error('Group chat creation unsuccessful');
public/scripts/group-chats.js+7 -3
@@ -86,6 +86,7 @@ import { isExternalMediaAllowed } from './chats.js';
8686import { POPUP_TYPE, Popup, callGenericPopup } from './popup.js';
8787import { t } from './i18n.js';
8888import { accountStorage } from './util/AccountStorage.js';
89+import { compressRequest } from './request-compression.js';
8990
9091export {
9192 selected_group,
@@ -633,11 +634,12 @@ async function saveGroupChat(groupId, shouldSaveGroup, force = false) {
633634 user_name: 'unused',
634635 character_name: 'unused',
635636 };
636637 const responsesaveGroupChatRequest = await fetchcompressRequest('/api/chats/group/save', {
637638 method: 'POST',
638639 headers: getRequestHeaders(),
639640 body: JSON.stringify({ id: chatId, chat: [chatHeader, ...chat], force: force }),
640641 });
642+ const response = await fetch('/api/chats/group/save', saveGroupChatRequest);
641643
642644 if (!response.ok) {
643645 const errorData = await response.json();
@@ -728,11 +730,12 @@ export async function renameGroupMember(oldAvatar, newAvatar, newName) {
728730 if (hadChanges) {
729731 await eventSource.emit(event_types.CHARACTER_RENAMED_IN_PAST_CHAT, messages, oldAvatar, newAvatar);
730732
731733 const saveChatResponsesaveChatRequest = await fetchcompressRequest('/api/chats/group/save', {
732734 method: 'POST',
733735 headers: getRequestHeaders(),
734736 body: JSON.stringify({ id: chatId, chat: [...messages] }),
735737 });
738+ const saveChatResponse = await fetch('/api/chats/group/save', saveChatRequest);
736739
737740 if (!saveChatResponse.ok) {
738741 throw new Error('Group member could not be renamed');
@@ -2374,11 +2377,12 @@ export async function saveGroupBookmarkChat(groupId, name, metadata, mesId) {
23742377
23752378 await editGroup(groupId, true, false);
23762379
23772380 const responsesaveChatRequest = await fetchcompressRequest('/api/chats/group/save', {
23782381 method: 'POST',
23792382 headers: getRequestHeaders(),
23802383 body: JSON.stringify({ id: name, chat: [chatHeader, ...trimmedChat] }),
23812384 });
2385+ const response = await fetch('/api/chats/group/save', saveChatRequest);
23822386
23832387 if (!response.ok) {
23842388 toastr.error(t`Check the server connection and reload the page to prevent data loss.`, t`Group chat could not be saved`);
public/scripts/request-compression.js+131 -0
@@ -0,0 +1,131 @@
1+import { gzip } from '/lib.js';
2+
3+/**
4+ * @type {RequestCompressionConfig}
5+ *
6+ * @typedef {Object} RequestCompressionConfig
7+ * @property {boolean} enabled Whether request compression is enabled.
8+ * @property {number} minPayloadSize Minimum payload size in bytes to trigger compression.
9+ * @property {number} maxPayloadSize Hard upper payload size limit for compression.
10+ * @property {number} timeout Timeout for request compression in milliseconds.
11+ */
12+const requestCompressionConfig = {
13+ enabled: false,
14+ minPayloadSize: 0,
15+ maxPayloadSize: 0,
16+ timeout: 0,
17+};
18+
19+/**
20+ * Sets the configuration for request compression from the server.
21+ * @param {RequestCompressionConfig} config Configuration object for request compression
22+ */
23+export function setRequestCompressionConfig(config) {
24+ Object.assign(requestCompressionConfig, (config ?? {}));
25+}
26+
27+/**
28+ * Compresses a Uint8Array using gzip.
29+ * @param {Uint8Array<ArrayBuffer>} input Uint8Array to compress
30+ * @returns {{ promise: Promise<Uint8Array<ArrayBuffer>>, terminate: () => void }} Gzip-compressed Uint8Array promise and a terminate function.
31+ */
32+function gzipBuffer(input) {
33+ let terminate = () => {};
34+ const promise = new Promise((resolve, reject) => {
35+ try {
36+ terminate = gzip(input, (error, compressed) => {
37+ if (error) {
38+ reject(error);
39+ return;
40+ }
41+
42+ resolve(new Uint8Array(compressed));
43+ });
44+ } catch (error) {
45+ reject(error);
46+ }
47+ });
48+ return { promise, terminate };
49+}
50+
51+/**
52+ * Wraps a promise with a timeout, rejecting if the promise does not settle within the specified time.
53+ * Note: timeout does not cancel the underlying compression task; it only stops waiting for it.
54+ * @param {Promise<T>} promise Promise to wrap with a timeout
55+ * @param {number} timeoutMs Timeout in milliseconds
56+ * @param {string} label Used for error message if timeout occurs
57+ * @returns {Promise<T>} Resolves with the original promise's value if it settles in time, otherwise rejects with a timeout error
58+ * @template T Type of the promise's resolved value
59+ */
60+async function withTimeout(promise, timeoutMs, label) {
61+ let timeoutId = null;
62+ const timeoutPromise = new Promise((_, reject) => {
63+ timeoutId = setTimeout(() => reject(new Error(`${label}_timeout`)), timeoutMs);
64+ });
65+
66+ try {
67+ return await Promise.race([promise, timeoutPromise]);
68+ } finally {
69+ if (timeoutId !== null) {
70+ clearTimeout(timeoutId);
71+ }
72+ }
73+}
74+
75+/**
76+ * Compresses a fetch request using gzip when supported and worthwhile.
77+ * Compression is skipped when feature-toggle is disabled, body is too small,
78+ * body is not a string, or compression fails/timeouts.
79+ *
80+ * @param {RequestInit} request fetch request parameters
81+ * @returns {Promise<RequestInit>} A request init object that may include gzip-compressed body
82+ */
83+export async function compressRequest(request) {
84+ const plainRequest = { ...request };
85+ const requestBody = plainRequest?.body;
86+
87+ if (!requestCompressionConfig.enabled) {
88+ return plainRequest;
89+ }
90+
91+ if (!requestBody || typeof requestBody !== 'string') {
92+ return plainRequest;
93+ }
94+
95+ const textEncoder = new TextEncoder();
96+ const encodedBody = textEncoder.encode(requestBody);
97+ const bodySize = encodedBody.byteLength;
98+ const minBytes = Number(requestCompressionConfig.minPayloadSize) || 0;
99+ const maxBytes = Number(requestCompressionConfig.maxPayloadSize) || 0;
100+
101+ if (bodySize < minBytes || (maxBytes > 0 && bodySize > maxBytes)) {
102+ return plainRequest;
103+ }
104+
105+ const { promise, terminate } = gzipBuffer(encodedBody);
106+
107+ try {
108+ const compressedBody = await withTimeout(
109+ promise,
110+ requestCompressionConfig.timeout,
111+ 'compress_fflate_gzip',
112+ );
113+
114+ if (!compressedBody || compressedBody.byteLength >= bodySize) {
115+ return plainRequest;
116+ }
117+
118+ const headers = new Headers(plainRequest.headers ?? {});
119+ headers.set('Content-Encoding', 'gzip');
120+
121+ return {
122+ ...plainRequest,
123+ headers,
124+ body: compressedBody,
125+ };
126+ } catch (error) {
127+ terminate();
128+ console.warn('Failed to compress request body, using plain request.', error);
129+ return plainRequest;
130+ }
131+}
src/endpoints/settings.js+11 -0
@@ -4,6 +4,7 @@ import path from 'node:path';
44import express from 'express';
55import _ from 'lodash';
66import { sync as writeFileAtomicSync } from 'write-file-atomic';
7+import bytes from 'bytes';
78
89import { SETTINGS_FILE } from '../constants.js';
910import { getConfigValue, generateTimestamp, removeOldBackups } from '../util.js';
@@ -13,6 +14,10 @@ import { getFileNameValidationFunction } from '../middleware/validateFileName.js
1314const ENABLE_EXTENSIONS = !!getConfigValue('extensions.enabled', true, 'boolean');
1415const ENABLE_EXTENSIONS_AUTO_UPDATE = !!getConfigValue('extensions.autoUpdate', true, 'boolean');
1516const ENABLE_ACCOUNTS = !!getConfigValue('enableUserAccounts', false, 'boolean');
17+const ENABLE_REQUEST_COMPRESSION = !!getConfigValue('performance.requestCompression.enabled', false, 'boolean');
18+const REQUEST_COMPRESSION_MIN = bytes.parse(getConfigValue('performance.requestCompression.minPayloadSize', '256kb'));
19+const REQUEST_COMPRESSION_MAX = bytes.parse(getConfigValue('performance.requestCompression.maxPayloadSize', '8mb'));
20+const REQUEST_COMPRESSION_TIMEOUT = Number(getConfigValue('performance.requestCompression.timeout', 3000, 'number'));
1621
1722// 10 minutes
1823const AUTOSAVE_INTERVAL = 10 * 60 * 1000;
@@ -281,6 +286,12 @@ router.post('/get', (request, response) => {
281286 enable_extensions: ENABLE_EXTENSIONS,
282287 enable_extensions_auto_update: ENABLE_EXTENSIONS_AUTO_UPDATE,
283288 enable_accounts: ENABLE_ACCOUNTS,
289+ request_compression: {
290+ enabled: ENABLE_REQUEST_COMPRESSION,
291+ minPayloadSize: REQUEST_COMPRESSION_MIN || 0,
292+ maxPayloadSize: REQUEST_COMPRESSION_MAX || 0,
293+ timeout: REQUEST_COMPRESSION_TIMEOUT || 0,
294+ },
284295 });
285296});
286297