Files
paradox-save-parser-frontend/src/services/SaveParserBackendService.ts
T
2026-09-23 18:24:45 +02:00

138 lines
4.7 KiB
TypeScript

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'
import { createZstdDecompressStream } from '@/lib/saveArchive/zstdStream'
export interface UploadSaveResponse {
id: string
}
export interface SaveStatusResponse {
id: string
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.
async function getBodyTextOrEmpty(res: Response): Promise<string> {
try {
return await res.text()
} catch {
return ''
}
}
export class SaveParserBackendService {
private API_BASE = PROXY_PATHS.saveParserBackend
/**
* 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/zstd',
},
body: save.data,
})
if (!response.ok) {
throw new Error(
`Failed to upload save: ${response.statusText} ` +
(await getBodyTextOrEmpty(response))
)
}
return await response.json()
}
// Fetches the current parsing status of a previously uploaded save.
async getSaveStatus(id: string): Promise<SaveStatusResponse> {
const url = `${this.API_BASE}/getSaveStatus?id=${encodeURIComponent(id)}`
const response = await fetch(url)
if (!response.ok) {
throw new Error(
`Failed to get save status: ${response.statusText} ` +
(await getBodyTextOrEmpty(response))
)
}
return await response.json()
}
/**
* 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)
if (!response.ok) {
throw new Error(
`Failed to get parsed save: ${response.statusText} ` +
(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')
}
// 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()
}
/**
* 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()