decompress .json.zstd fetched by getSaveData

This commit is contained in:
Altair-sh
2026-09-23 18:24:45 +02:00
parent f1a60a208c
commit 7d64ab4809
6 changed files with 67 additions and 39 deletions
+3 -3
View File
@@ -10,11 +10,11 @@ export const defaultConfig = {
// Size of a save if it is uploaded as uncompressed. The browser packs
// saves as a stream, so these limits are about the backend's disk
// rather than about memory.
maxSizePlainTextBytes: 200 * 1024 * 1024,
maxSizePlainTextMB: 200,
// Archives produced by the game, recognized by the zip magic bytes.
maxSizeCompressedBytes: 60 * 1024 * 1024,
maxSizeCompressedMB: 60,
// read COMPRESSION.md
zstdCompressionLevel: 11,
zstdCompressionLevel: 10,
},
}
+2 -2
View File
@@ -1,5 +1,5 @@
const KILOBYTE = 1024
const MEGABYTE = 1024 * KILOBYTE
export const KILOBYTE = 1024
export const MEGABYTE = 1024 * KILOBYTE
/**
* Formats a byte count for error messages and status lines, e.g. "12.3 MB".
+5 -4
View File
@@ -3,7 +3,7 @@
import type { SaveUploadConfig } from '@/config/AppConfig'
import { GAME_SAVE_ENTRY_NAMES, GameEnum } from '@/config/games'
import { formatBytes } from '@/lib/formatBytes'
import { formatBytes, MEGABYTE } from '@/lib/formatBytes'
import { isZipFile } from './gameZip'
import {
OriginalFileType,
@@ -68,9 +68,10 @@ export async function packSaveForUpload(
onStage: StageCallback
): Promise<PackedSave> {
const isZip = await isZipFile(file)
const sizeLimit = isZip
? config.maxSizeCompressedBytes
: config.maxSizePlainTextBytes
// The limits are configured in megabytes, file sizes are in bytes.
const sizeLimit =
(isZip ? config.maxSizeCompressedMB : config.maxSizePlainTextMB) *
MEGABYTE
if (file.size > sizeLimit) {
const saveKind = isZip ? 'archived' : 'plain text'
+16 -2
View File
@@ -3,6 +3,7 @@ import type { SaveUploadConfig } from '@/config/AppConfig'
import { GameEnum } from '@/config/games'
import { packSaveForUpload, StageCallback } from '@/lib/saveArchive/client'
import { OriginalFileType } from '@/lib/saveArchive/messages'
import { createZstdDecompressStream } from '@/lib/saveArchive/zstdStream'
export interface UploadSaveResponse {
id: string
@@ -86,7 +87,12 @@ export class SaveParserBackendService {
return await response.json()
}
// Fetches the fully parsed data for a save once its parsing is done.
/**
* Fetches the fully parsed data for a save once its parsing is done.
*
* The backend stores and serves that data as zstd compressed json, so the
* body is decompressed on the way in rather than read as text first.
*/
async getSaveData(id: string): Promise<Record<string, unknown>> {
const url = `${this.API_BASE}/getSaveData?id=${encodeURIComponent(id)}`
const response = await fetch(url)
@@ -97,8 +103,16 @@ export class SaveParserBackendService {
(await getBodyTextOrEmpty(response))
)
}
// A body is only missing on a response that cannot carry one, which a
// successful getSaveData never is.
if (!response.body) {
throw new Error('Failed to get parsed save: response has no body')
}
return await response.json()
// Decompresses as the bytes arrive, so the compressed json is never
// held whole; only the parsed object stays in memory.
const json = response.body.pipeThrough(createZstdDecompressStream())
return await new Response(json).json()
}
/**