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 { 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 { 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 { 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> { 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 { 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()