implemented save file compression in browser

This commit is contained in:
Altair-sh
2026-09-23 17:26:40 +02:00
parent 54ddc11b44
commit a022dd00dc
25 changed files with 1331 additions and 142 deletions
+52 -8
View File
@@ -1,4 +1,8 @@
import { PROXY_PATHS } from '@/app/api/proxy/paths'
import type { SaveUploadConfig } from '@/config/AppConfig'
import { GameEnum } from '@/config/games'
import { packSaveForUpload, StageCallback } from '@/lib/saveArchive/client'
import { OriginalFileType } from '@/lib/saveArchive/messages'
export interface UploadSaveResponse {
id: string
@@ -9,6 +13,10 @@ export interface SaveStatusResponse {
game: string
status: string
uploadDateTime: string
// Shape the save had when it was uploaded, needed to rebuild a file the
// game can load again.
originalFileType: OriginalFileType
originalFileName: string
}
// Tries to read the response body as string, returning an empty string if that fails.
@@ -23,16 +31,34 @@ async function getBodyTextOrEmpty(res: Response): Promise<string> {
export class SaveParserBackendService {
private API_BASE = PROXY_PATHS.saveParserBackend
//TODO: save size limit 30 mb
// Uploads a save file for the given game and returns its assigned id.
async uploadSave(game: string, file: File): Promise<UploadSaveResponse> {
const url = `${this.API_BASE}/uploadSave?game=${encodeURIComponent(game)}`
/**
* Re-compresses a save file and uploads it, returning its assigned id.
*
* Saves are not uploaded as the game wrote them. Plain text and archived
* saves are both packed into a tar inside a zstd frame, which is a lot
* smaller than the game's own zip and cheap for the backend to unpack.
* The shape the file originally had travels along, so it can be rebuilt
* when someone downloads the save later.
*/
async uploadSave(
game: GameEnum,
file: File,
config: SaveUploadConfig,
onStage: StageCallback
): Promise<UploadSaveResponse> {
const save = await packSaveForUpload(game, file, config, onStage)
onStage('uploading')
const url =
`${this.API_BASE}/uploadSave?game=${encodeURIComponent(game)}` +
`&originalFileType=${encodeURIComponent(save.originalFileType)}` +
`&originalFileName=${encodeURIComponent(save.originalFileName)}`
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/octet-stream',
'Content-Type': 'application/zstd',
},
body: file,
body: save.data,
})
if (!response.ok) {
@@ -61,8 +87,8 @@ export class SaveParserBackendService {
}
// Fetches the fully parsed data for a save once its parsing is done.
async getParsedSave(id: string): Promise<Record<string, unknown>> {
const url = `${this.API_BASE}/getParsedSave?id=${encodeURIComponent(id)}`
async getSaveData(id: string): Promise<Record<string, unknown>> {
const url = `${this.API_BASE}/getSaveData?id=${encodeURIComponent(id)}`
const response = await fetch(url)
if (!response.ok) {
@@ -74,6 +100,24 @@ export class SaveParserBackendService {
return await response.json()
}
/**
* Fetches the stored save archive, exactly as it was uploaded. Turning it
* back into a file the game can load is done in the browser.
*/
async getSaveFile(id: string): Promise<Blob> {
const url = `${this.API_BASE}/getSaveFile?id=${encodeURIComponent(id)}`
const response = await fetch(url)
if (!response.ok) {
throw new Error(
`Failed to get save file: ${response.statusText} ` +
(await getBodyTextOrEmpty(response))
)
}
return await response.blob()
}
}
export const saveParserBackendService = new SaveParserBackendService()