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 -7Ignore whitespace
default/config.yaml+10 -0
@@ -202,6 +202,16 @@ performance:
202 memoryCacheCapacity: '100mb'202 memoryCacheCapacity: '100mb'
203 # Enables disk caching for character cards. Improves performances with large card libraries.203 # Enables disk caching for character cards. Improves performances with large card libraries.
204 useDiskCache: true204 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
206# CACHE BUSTER CONFIGURATION216# CACHE BUSTER CONFIGURATION
207# IMPORTANT: Requires localhost or a domain with HTTPS, otherwise will not work!217# IMPORTANT: Requires localhost or a domain with HTTPS, otherwise will not work!
package-lock.json+7 -0
@@ -58,6 +58,7 @@
58 "droll": "^0.2.1",58 "droll": "^0.2.1",
59 "env-paths": "^3.0.0",59 "env-paths": "^3.0.0",
60 "express": "^4.21.0",60 "express": "^4.21.0",
61 "fflate": "^0.8.2",
61 "form-data": "^4.0.4",62 "form-data": "^4.0.4",
62 "fuse.js": "^7.1.0",63 "fuse.js": "^7.1.0",
63 "google-translate-api-x": "^10.7.2",64 "google-translate-api-x": "^10.7.2",
@@ -5095,6 +5096,12 @@
5095 "node": "^12.20 || >= 14.13"5096 "node": "^12.20 || >= 14.13"
5096 }5097 }
5097 },5098 },
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 },
5098 "node_modules/file-entry-cache": {5105 "node_modules/file-entry-cache": {
5099 "version": "6.0.1",5106 "version": "6.0.1",
5100 "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",5107 "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
package.json+1 -0
@@ -48,6 +48,7 @@
48 "droll": "^0.2.1",48 "droll": "^0.2.1",
49 "env-paths": "^3.0.0",49 "env-paths": "^3.0.0",
50 "express": "^4.21.0",50 "express": "^4.21.0",
51 "fflate": "^0.8.2",
51 "form-data": "^4.0.4",52 "form-data": "^4.0.4",
52 "fuse.js": "^7.1.0",53 "fuse.js": "^7.1.0",
53 "google-translate-api-x": "^10.7.2",54 "google-translate-api-x": "^10.7.2",
public/lib.js+5 -0
@@ -23,6 +23,7 @@ import { toggle as slideToggle } from 'slidetoggle';
23import chalk from 'chalk';23import chalk from 'chalk';
24import yaml from 'yaml';24import yaml from 'yaml';
25import * as chevrotain from 'chevrotain';25import * as chevrotain from 'chevrotain';
26import { gzipSync, gzip } from 'fflate';
2627
27/**28/**
28 * Expose the libraries to the 'window' object.29 * Expose the libraries to the 'window' object.
@@ -102,6 +103,8 @@ export default {
102 chalk,103 chalk,
103 yaml,104 yaml,
104 chevrotain,105 chevrotain,
106 gzipSync,
107 gzip,
105};108};
106109
107export {110export {
@@ -127,4 +130,6 @@ export {
127 chalk,130 chalk,
128 yaml,131 yaml,
129 chevrotain,132 chevrotain,
133 gzipSync,
134 gzip,
130};135};
public/script.js+8 -3
@@ -285,6 +285,7 @@ import { MacroEnvBuilder } from './scripts/macros/engine/MacroEnvBuilder.js';
285import { MacroEngine } from './scripts/macros/engine/MacroEngine.js';285import { MacroEngine } from './scripts/macros/engine/MacroEngine.js';
286import { addChatBackupsBrowser } from './scripts/chat-backups.js';286import { addChatBackupsBrowser } from './scripts/chat-backups.js';
287import { onboardingExperimentalMacroEngine } from './scripts/macros/engine/MacroDiagnostics.js';287import { onboardingExperimentalMacroEngine } from './scripts/macros/engine/MacroDiagnostics.js';
288import { compressRequest, setRequestCompressionConfig } from './scripts/request-compression.js';
288289
289// API OBJECT FOR EXTERNAL WIRING290// API OBJECT FOR EXTERNAL WIRING
290globalThis.SillyTavern = {291globalThis.SillyTavern = {
@@ -7131,7 +7132,7 @@ async function renamePastChats(oldAvatar, newAvatar, newName) {
71317132
7132 await eventSource.emit(event_types.CHARACTER_RENAMED_IN_PAST_CHAT, currentChat, oldAvatar, newAvatar);7133 await eventSource.emit(event_types.CHARACTER_RENAMED_IN_PAST_CHAT, currentChat, oldAvatar, newAvatar);
71337134
7134 const saveChatResponse = await fetch('/api/chats/save', {7135 const saveChatRequest = await compressRequest({
7135 method: 'POST',7136 method: 'POST',
7136 headers: getRequestHeaders(),7137 headers: getRequestHeaders(),
7137 body: JSON.stringify({7138 body: JSON.stringify({
@@ -7142,6 +7143,7 @@ async function renamePastChats(oldAvatar, newAvatar, newName) {
7142 }),7143 }),
7143 cache: 'no-cache',7144 cache: 'no-cache',
7144 });7145 });
7146 const saveChatResponse = await fetch('/api/chats/save', saveChatRequest);
71457147
7146 if (!saveChatResponse.ok) {7148 if (!saveChatResponse.ok) {
7147 throw new Error('Could not save chat');7149 throw new Error('Could not save chat');
@@ -7225,7 +7227,7 @@ export async function saveChat({ chatName, withMetadata, mesId, force = false }
7225 };7227 };
72267228
7227 try {7229 try {
7228 const result = await fetch('/api/chats/save', {7230 const saveChatRequest = await compressRequest({
7229 method: 'POST',7231 method: 'POST',
7230 cache: 'no-cache',7232 cache: 'no-cache',
7231 headers: getRequestHeaders(),7233 headers: getRequestHeaders(),
@@ -7237,6 +7239,7 @@ export async function saveChat({ chatName, withMetadata, mesId, force = false }
7237 force: force,7239 force: force,
7238 }),7240 }),
7239 });7241 });
7242 const result = await fetch('/api/chats/save', saveChatRequest);
72407243
7241 if (result.ok) {7244 if (result.ok) {
7242 return;7245 return;
@@ -7729,6 +7732,7 @@ export async function getSettings() {
77297732
7730 accountStorage.init(settings?.accountStorage);7733 accountStorage.init(settings?.accountStorage);
7731 await setUserControls(data.enable_accounts);7734 await setUserControls(data.enable_accounts);
7735 setRequestCompressionConfig(data.request_compression);
77327736
7733 // Allow subscribers to mutate settings7737 // Allow subscribers to mutate settings
7734 await eventSource.emit(event_types.SETTINGS_LOADED_BEFORE, settings);7738 await eventSource.emit(event_types.SETTINGS_LOADED_BEFORE, settings);
@@ -7879,12 +7883,13 @@ export async function saveSettings(loopCounter = 0) {
7879 };7883 };
78807884
7881 try {7885 try {
7882 const result = await fetch('/api/settings/save', {7886 const saveSettingsRequest = await compressRequest({
7883 method: 'POST',7887 method: 'POST',
7884 headers: getRequestHeaders(),7888 headers: getRequestHeaders(),
7885 body: JSON.stringify(payload),7889 body: JSON.stringify(payload),
7886 cache: 'no-cache',7890 cache: 'no-cache',
7887 });7891 });
7892 const result = await fetch('/api/settings/save', saveSettingsRequest);
78887893
7889 if (!result.ok) {7894 if (!result.ok) {
7890 throw new Error(`Failed to save settings: ${result.statusText}`);7895 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
34import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';34import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
35import { createTagMapFromList } from './tags.js';35import { createTagMapFromList } from './tags.js';
36import { renderTemplateAsync } from './templates.js';36import { renderTemplateAsync } from './templates.js';
37import { compressRequest } from './request-compression.js';
37import { t } from './i18n.js';38import { t } from './i18n.js';
3839
39import {40import {
@@ -386,11 +387,12 @@ export async function convertSoloToGroupChat() {
386 }387 }
387388
388 // Save group chat389 // Save group chat
389 const createChatResponse = await fetch('/api/chats/group/save', {390 const createChatRequest = await compressRequest({
390 method: 'POST',391 method: 'POST',
391 headers: getRequestHeaders(),392 headers: getRequestHeaders(),
392 body: JSON.stringify({ id: chatName, chat: [chatHeader, ...groupChat] }),393 body: JSON.stringify({ id: chatName, chat: [chatHeader, ...groupChat] }),
393 });394 });
395 const createChatResponse = await fetch('/api/chats/group/save', createChatRequest);
394396
395 if (!createChatResponse.ok) {397 if (!createChatResponse.ok) {
396 console.error('Group chat creation unsuccessful');398 console.error('Group chat creation unsuccessful');
public/scripts/group-chats.js+7 -3
@@ -86,6 +86,7 @@ import { isExternalMediaAllowed } from './chats.js';
86import { POPUP_TYPE, Popup, callGenericPopup } from './popup.js';86import { POPUP_TYPE, Popup, callGenericPopup } from './popup.js';
87import { t } from './i18n.js';87import { t } from './i18n.js';
88import { accountStorage } from './util/AccountStorage.js';88import { accountStorage } from './util/AccountStorage.js';
89import { compressRequest } from './request-compression.js';
8990
90export {91export {
91 selected_group,92 selected_group,
@@ -633,11 +634,12 @@ async function saveGroupChat(groupId, shouldSaveGroup, force = false) {
633 user_name: 'unused',634 user_name: 'unused',
634 character_name: 'unused',635 character_name: 'unused',
635 };636 };
636 const response = await fetch('/api/chats/group/save', {637 const saveGroupChatRequest = await compressRequest({
637 method: 'POST',638 method: 'POST',
638 headers: getRequestHeaders(),639 headers: getRequestHeaders(),
639 body: JSON.stringify({ id: chatId, chat: [chatHeader, ...chat], force: force }),640 body: JSON.stringify({ id: chatId, chat: [chatHeader, ...chat], force: force }),
640 });641 });
642 const response = await fetch('/api/chats/group/save', saveGroupChatRequest);
641643
642 if (!response.ok) {644 if (!response.ok) {
643 const errorData = await response.json();645 const errorData = await response.json();
@@ -728,11 +730,12 @@ export async function renameGroupMember(oldAvatar, newAvatar, newName) {
728 if (hadChanges) {730 if (hadChanges) {
729 await eventSource.emit(event_types.CHARACTER_RENAMED_IN_PAST_CHAT, messages, oldAvatar, newAvatar);731 await eventSource.emit(event_types.CHARACTER_RENAMED_IN_PAST_CHAT, messages, oldAvatar, newAvatar);
730732
731 const saveChatResponse = await fetch('/api/chats/group/save', {733 const saveChatRequest = await compressRequest({
732 method: 'POST',734 method: 'POST',
733 headers: getRequestHeaders(),735 headers: getRequestHeaders(),
734 body: JSON.stringify({ id: chatId, chat: [...messages] }),736 body: JSON.stringify({ id: chatId, chat: [...messages] }),
735 });737 });
738 const saveChatResponse = await fetch('/api/chats/group/save', saveChatRequest);
736739
737 if (!saveChatResponse.ok) {740 if (!saveChatResponse.ok) {
738 throw new Error('Group member could not be renamed');741 throw new Error('Group member could not be renamed');
@@ -2374,11 +2377,12 @@ export async function saveGroupBookmarkChat(groupId, name, metadata, mesId) {
23742377
2375 await editGroup(groupId, true, false);2378 await editGroup(groupId, true, false);
23762379
2377 const response = await fetch('/api/chats/group/save', {2380 const saveChatRequest = await compressRequest({
2378 method: 'POST',2381 method: 'POST',
2379 headers: getRequestHeaders(),2382 headers: getRequestHeaders(),
2380 body: JSON.stringify({ id: name, chat: [chatHeader, ...trimmedChat] }),2383 body: JSON.stringify({ id: name, chat: [chatHeader, ...trimmedChat] }),
2381 });2384 });
2385 const response = await fetch('/api/chats/group/save', saveChatRequest);
23822386
2383 if (!response.ok) {2387 if (!response.ok) {
2384 toastr.error(t`Check the server connection and reload the page to prevent data loss.`, t`Group chat could not be saved`);2388 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 @@
1import { 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 */
12const 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 */
23export 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 */
32function 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 */
60async 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 */
83export 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';
4import express from 'express';4import express from 'express';
5import _ from 'lodash';5import _ from 'lodash';
6import { sync as writeFileAtomicSync } from 'write-file-atomic';6import { sync as writeFileAtomicSync } from 'write-file-atomic';
7import bytes from 'bytes';
78
8import { SETTINGS_FILE } from '../constants.js';9import { SETTINGS_FILE } from '../constants.js';
9import { getConfigValue, generateTimestamp, removeOldBackups } from '../util.js';10import { getConfigValue, generateTimestamp, removeOldBackups } from '../util.js';
@@ -13,6 +14,10 @@ import { getFileNameValidationFunction } from '../middleware/validateFileName.js
13const ENABLE_EXTENSIONS = !!getConfigValue('extensions.enabled', true, 'boolean');14const ENABLE_EXTENSIONS = !!getConfigValue('extensions.enabled', true, 'boolean');
14const ENABLE_EXTENSIONS_AUTO_UPDATE = !!getConfigValue('extensions.autoUpdate', true, 'boolean');15const ENABLE_EXTENSIONS_AUTO_UPDATE = !!getConfigValue('extensions.autoUpdate', true, 'boolean');
15const ENABLE_ACCOUNTS = !!getConfigValue('enableUserAccounts', false, 'boolean');16const ENABLE_ACCOUNTS = !!getConfigValue('enableUserAccounts', false, 'boolean');
17const ENABLE_REQUEST_COMPRESSION = !!getConfigValue('performance.requestCompression.enabled', false, 'boolean');
18const REQUEST_COMPRESSION_MIN = bytes.parse(getConfigValue('performance.requestCompression.minPayloadSize', '256kb'));
19const REQUEST_COMPRESSION_MAX = bytes.parse(getConfigValue('performance.requestCompression.maxPayloadSize', '8mb'));
20const REQUEST_COMPRESSION_TIMEOUT = Number(getConfigValue('performance.requestCompression.timeout', 3000, 'number'));
1621
17// 10 minutes22// 10 minutes
18const AUTOSAVE_INTERVAL = 10 * 60 * 1000;23const AUTOSAVE_INTERVAL = 10 * 60 * 1000;
@@ -281,6 +286,12 @@ router.post('/get', (request, response) => {
281 enable_extensions: ENABLE_EXTENSIONS,286 enable_extensions: ENABLE_EXTENSIONS,
282 enable_extensions_auto_update: ENABLE_EXTENSIONS_AUTO_UPDATE,287 enable_extensions_auto_update: ENABLE_EXTENSIONS_AUTO_UPDATE,
283 enable_accounts: ENABLE_ACCOUNTS,288 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 },
284 });295 });
285});296});
286297