Archive games by using the api service endpoint instead of R2 (#2030)

## Description:

This removes the dependencies on R2, and allows contributors to replay
games without R2 access.

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

evan
This commit is contained in:
evanpelle
2025-09-08 16:36:20 -07:00
committed by GitHub
parent 27837012cf
commit 043462e28a
10 changed files with 63 additions and 161 deletions
+38 -160
View File
@@ -1,186 +1,64 @@
import { S3 } from "@aws-sdk/client-s3";
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
import { AnalyticsRecord, GameID, GameRecord } from "../core/Schemas";
import { replacer } from "../core/Util";
import { GameID, GameRecord, GameRecordSchema, ID } from "../core/Schemas";
import { logger } from "./Logger";
const config = getServerConfigFromServer();
const log = logger.child({ component: "Archive" });
// R2 client configuration
const r2 = new S3({
region: "auto", // R2 ignores region, but it's required by the SDK
endpoint: config.r2Endpoint(),
credentials: {
accessKeyId: config.r2AccessKey(),
secretAccessKey: config.r2SecretKey(),
},
});
const bucket = config.r2Bucket();
const gameFolder = "games";
const analyticsFolder = "analytics";
export async function archive(gameRecord: GameRecord) {
try {
gameRecord.gitCommit = config.gitCommit();
// Archive to R2
await archiveAnalyticsToR2(gameRecord);
// Archive full game if there are turns
if (gameRecord.turns.length > 0) {
log.info(
`${gameRecord.info.gameID}: game has more than zero turns, attempting to write to full game to R2`,
);
await archiveFullGameToR2(gameRecord);
}
} catch (error: unknown) {
// If the error is not an instance of Error, log it as a string
if (!(error instanceof Error)) {
log.error(
`${gameRecord.info.gameID}: Final archive error. Non-Error type: ${String(error)}`,
);
const url = `${config.jwtIssuer()}/game/${gameRecord.info.gameID}`;
const response = await fetch(url, {
method: "POST",
body: JSON.stringify(gameRecord),
headers: {
"Content-Type": "application/json",
"x-api-key": config.apiKey(),
},
});
if (!response.ok) {
log.error(`error archiving game record: ${response.statusText}`, {
gameID: gameRecord.info.gameID,
});
return;
}
const { message, stack, name } = error;
log.error(`${gameRecord.info.gameID}: Final archive error: ${error}`, {
message: message,
stack: stack,
name: name,
...(error && typeof error === "object" ? error : {}),
});
}
}
async function archiveAnalyticsToR2(gameRecord: GameRecord) {
// Create analytics data object
const { info, version, gitCommit, subdomain, domain } = gameRecord;
const analyticsData: AnalyticsRecord = {
info,
version,
gitCommit,
subdomain,
domain,
};
try {
// Store analytics data using just the game ID as the key
const analyticsKey = `${info.gameID}.json`;
await r2.putObject({
Bucket: bucket,
Key: `${analyticsFolder}/${analyticsKey}`,
Body: JSON.stringify(analyticsData, replacer),
ContentType: "application/json",
});
log.info(`${info.gameID}: successfully wrote game analytics to R2`);
} catch (error: unknown) {
// If the error is not an instance of Error, log it as a string
if (!(error instanceof Error)) {
log.error(
`${gameRecord.info.gameID}: Error writing game analytics to R2. Non-Error type: ${String(error)}`,
);
return;
}
const { message, stack, name } = error;
log.error(`${info.gameID}: Error writing game analytics to R2: ${error}`, {
message: message,
stack: stack,
name: name,
...(error && typeof error === "object" ? error : {}),
});
throw error;
}
}
async function archiveFullGameToR2(gameRecord: GameRecord) {
// Create a deep copy to avoid modifying the original
const recordCopy = structuredClone(gameRecord);
// Players may see this so make sure to clear PII
recordCopy.info.players.forEach((p) => {
p.persistentID = "REDACTED";
});
try {
await r2.putObject({
Bucket: bucket,
Key: `${gameFolder}/${recordCopy.info.gameID}`,
Body: JSON.stringify(recordCopy, replacer),
ContentType: "application/json",
});
} catch (error) {
log.error(`error saving game ${gameRecord.info.gameID}`);
throw error;
log.error(`error archiving game record: ${error}`, {
gameID: gameRecord.info.gameID,
});
return;
}
log.info(`${gameRecord.info.gameID}: game record successfully written to R2`);
}
export async function readGameRecord(
gameId: GameID,
): Promise<GameRecord | null> {
try {
// Check if file exists and download in one operation
const response = await r2.getObject({
Bucket: bucket,
Key: `${gameFolder}/${gameId}`, // Fixed - needed to include gameFolder
});
// Parse the response body
if (response.Body === undefined) return null;
const bodyContents = await response.Body.transformToString();
return JSON.parse(bodyContents) as GameRecord;
} catch (error: unknown) {
// If the error is not an instance of Error, log it as a string
if (!(error instanceof Error)) {
log.error(
`${gameId}: Error reading game record from R2. Non-Error type: ${String(error)}`,
);
if (!ID.safeParse(gameId).success) {
log.error(`invalid game ID: ${gameId}`);
return null;
}
const { message, stack, name } = error;
// Log the error for monitoring purposes
log.error(`${gameId}: Error reading game record from R2: ${error}`, {
message: message,
stack: stack,
name: name,
...(error && typeof error === "object" ? error : {}),
const url = `${config.jwtIssuer()}/game/${gameId}`;
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const record = await response.json();
if (!response.ok) {
log.error(`error reading game record: ${response.statusText}`, {
gameID: gameId,
});
return null;
}
return GameRecordSchema.parse(record);
} catch (error) {
log.error(`error reading game record: ${error}`, {
gameID: gameId,
});
// Return null instead of throwing the error
return null;
}
}
export async function gameRecordExists(gameId: GameID): Promise<boolean> {
try {
await r2.headObject({
Bucket: bucket,
Key: `${gameFolder}/${gameId}`, // Fixed - needed to include gameFolder
});
return true;
} catch (error: unknown) {
// If the error is not an instance of Error, log it as a string
if (!(error instanceof Error)) {
log.error(
`${gameId}: Error checking archive existence. Non-Error type: ${String(error)}`,
);
return false;
}
const { message, stack, name } = error;
if (name === "NotFound") {
return false;
}
log.error(`${gameId}: Error checking archive existence: ${error}`, {
message: message,
stack: stack,
name: name,
...(error && typeof error === "object" ? error : {}),
});
return false;
}
}