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