138 lines
4.7 KiB
TypeScript
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()
|