Skip to content

Commit 7e42239

Browse files
Implement cached schema fetching
Fetches the latest schema from the API on game selection. Uses the 304 response of the API to determine if the remote version has been updated since the last request. Not a huge difference in performance locally but more efficient on the server side.
1 parent 521834e commit 7e42239

3 files changed

Lines changed: 308 additions & 42 deletions

File tree

src/pages/GameSelectionScreen.vue

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ import GameSelectionList from '../components/game-selection/GameSelectionList.vu
9696
import Game from '../model/game/Game';
9797
import { capitalize } from '../utils/StringUtils';
9898
import { StorePlatform as platformLabels } from '../model/platform/StorePlatform';
99+
import { updateLatestEcosystemSchema } from '../r2mm/ecosystem/EcosystemSchema';
99100
100101
101102
const gameSelection = useGameSelectionComposable();
@@ -157,6 +158,9 @@ function selectPlatform() {
157158
onMounted(async () => {
158159
window.app.checkForApplicationUpdates();
159160
await initialize();
161+
void updateLatestEcosystemSchema().catch((error) => {
162+
console.error('Failed to update latest ecosystem schema.', error);
163+
});
160164
});
161165
</script>
162166

src/r2mm/ecosystem/EcosystemSchema.ts

Lines changed: 147 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -12,37 +12,26 @@ import ManagerInformation from "../../_managerinf/ManagerInformation";
1212
import {EcosystemModloaderPackages, EcosystemSupportedGames} from "../../model/schema/ThunderstoreSchema";
1313
import {updateModLoaderExports} from "../installing/profile_installers/ModLoaderVariantRecord";
1414
import LoggerProvider, {LogSeverity} from "../../providers/ror2/logging/LoggerProvider";
15+
import {getAxiosWithTimeouts} from "../../utils/HttpUtils";
16+
import {retry} from "../../utils/Common";
1517

16-
export type VersionedThunderstoreEcosystem = ThunderstoreEcosystem & {version: string};
18+
export type VersionedThunderstoreEcosystem = ThunderstoreEcosystem & {
19+
version: string;
20+
lastModified?: string;
21+
};
1722

18-
async function getMergedEcosystemPath(): Promise<string> {
19-
return path.join(PathResolver.ROOT, "latest-ecosystem-schema.json");
20-
}
21-
22-
export async function updateLatestEcosystemSchema(): Promise<void> {
23-
const latestSchema = await fetchLatestSchema();
24-
await writeLatestEcosystemSchema(latestSchema);
25-
await internalUpdateEcosystemReactives(latestSchema);
26-
}
23+
type LatestSchemaFetchResult =
24+
| {kind: "not-modified"}
25+
| {kind: "fetched", schema: ThunderstoreEcosystem, lastModified?: string}
26+
| {kind: "failed"};
2727

28-
async function writeLatestEcosystemSchema(schema: ThunderstoreEcosystem): Promise<void> {
29-
const asMergedSchema: VersionedThunderstoreEcosystem = {
30-
...schema,
31-
version: ManagerInformation.VERSION.toString(),
32-
};
33-
const writable = JSON.stringify(asMergedSchema);
34-
return FsProvider.instance.writeFile(await getMergedEcosystemPath(), writable);
35-
}
28+
const ECOSYSTEM_DATA_URL = "https://thunderstore.io/api/experimental/schema/dev/latest/";
3629

37-
async function getLastSavedEcosystemSchema(): Promise<VersionedThunderstoreEcosystem> {
38-
const contentBuffer = await FsProvider.instance.readFile(await getMergedEcosystemPath());
39-
const content = contentBuffer.toString("utf8");
40-
const parsedContent = JSON.parse(content);
41-
await validateSchema(parsedContent);
42-
return parsedContent;
30+
async function getMergedEcosystemPath(): Promise<string> {
31+
return path.join(PathResolver.ROOT, "latest-ecosystem-schema.json");
4332
}
4433

45-
async function validateSchema(schema: any): Promise<void> {
34+
function validateSchema(schema: unknown): ThunderstoreEcosystem {
4635
const ajv = new Ajv();
4736
addFormats(ajv);
4837

@@ -52,15 +41,15 @@ async function validateSchema(schema: any): Promise<void> {
5241
if (!isOk) {
5342
throw new R2Error("Schema validation error", ajv.errorsText(validate.errors));
5443
}
44+
45+
return schema as ThunderstoreEcosystem;
5546
}
5647

57-
async function loadBundledSchema(): Promise<ThunderstoreEcosystem> {
58-
await validateSchema(bundledEcosystem);
59-
return bundledEcosystem as ThunderstoreEcosystem;
48+
function loadBundledSchema(): ThunderstoreEcosystem {
49+
return validateSchema(bundledEcosystem);
6050
}
6151

62-
async function fetchLatestSchema(): Promise<ThunderstoreEcosystem> {
63-
// TODO - Implement fetching of latest resource
52+
function createEmptySchema(): ThunderstoreEcosystem {
6453
return {
6554
schemaVersion: "",
6655
communities: {},
@@ -70,16 +59,130 @@ async function fetchLatestSchema(): Promise<ThunderstoreEcosystem> {
7059
};
7160
}
7261

73-
async function resolveCachedEcosystemSchema(): Promise<VersionedThunderstoreEcosystem> {
62+
function mergeSchemas(
63+
bundledSchema: ThunderstoreEcosystem,
64+
latestSchema: ThunderstoreEcosystem
65+
): ThunderstoreEcosystem {
66+
const modloaderMap = new Map(
67+
[...bundledSchema.modloaderPackages, ...latestSchema.modloaderPackages]
68+
.map(pkg => [pkg.packageId, pkg])
69+
);
70+
71+
return {
72+
schemaVersion: latestSchema.schemaVersion,
73+
communities: {
74+
...bundledSchema.communities,
75+
...latestSchema.communities,
76+
},
77+
games: {
78+
...bundledSchema.games,
79+
...latestSchema.games,
80+
},
81+
modloaderPackages: [...modloaderMap.values()],
82+
packageInstallers: {
83+
...bundledSchema.packageInstallers,
84+
...latestSchema.packageInstallers,
85+
},
86+
};
87+
}
88+
89+
async function fetchLatestSchema(
90+
currentSchema: VersionedThunderstoreEcosystem | null
91+
): Promise<LatestSchemaFetchResult> {
92+
const timeout = 5000;
93+
const requestConfig = {
94+
validateStatus: (status: number) => {
95+
if (status === 304) {
96+
return true;
97+
}
98+
return status >= 200 && status < 300;
99+
},
100+
...(currentSchema?.lastModified ? {headers: {"If-Modified-Since": currentSchema.lastModified}} : {}),
101+
};
102+
103+
try {
104+
const axios = getAxiosWithTimeouts(timeout, timeout * 2);
105+
const response = await retry(
106+
() => axios.get(ECOSYSTEM_DATA_URL, requestConfig),
107+
{attempts: 3, interval: 1000, throwLastErrorAsIs: true}
108+
);
109+
const lastModified = typeof response.headers["last-modified"] === "string"
110+
? response.headers["last-modified"]
111+
: undefined;
112+
113+
if (response.status === 304) {
114+
return {kind: "not-modified"};
115+
}
116+
117+
return {
118+
kind: "fetched",
119+
schema: validateSchema(response.data),
120+
...(lastModified ? {lastModified} : {}),
121+
};
122+
} catch (e) {
123+
console.error(e);
124+
return {kind: "failed"};
125+
}
126+
}
127+
128+
export async function updateLatestEcosystemSchema(): Promise<void> {
129+
const bundledSchema = loadBundledSchema();
130+
const currentSchema = await loadSavedEcosystemSchema();
131+
const result = await fetchLatestSchema(currentSchema);
132+
133+
if (result.kind === "not-modified") {
134+
return;
135+
}
136+
137+
if (result.kind === "failed") {
138+
if (currentSchema != null) {
139+
return;
140+
}
141+
142+
await writeLatestEcosystemSchema(bundledSchema);
143+
await internalUpdateEcosystemReactives(bundledSchema);
144+
return;
145+
}
146+
147+
const mergedSchema = mergeSchemas(bundledSchema, result.schema);
148+
await writeLatestEcosystemSchema(mergedSchema, result.lastModified);
149+
await internalUpdateEcosystemReactives(mergedSchema);
150+
}
151+
152+
async function writeLatestEcosystemSchema(
153+
schema: ThunderstoreEcosystem,
154+
lastModified?: string
155+
): Promise<void> {
156+
const asMergedSchema: VersionedThunderstoreEcosystem = {
157+
...schema,
158+
version: ManagerInformation.VERSION.toString(),
159+
...(lastModified != null ? {lastModified} : {}),
160+
};
161+
const writable = JSON.stringify(asMergedSchema);
162+
return FsProvider.instance.writeFile(await getMergedEcosystemPath(), writable);
163+
}
164+
165+
async function readSavedEcosystemSchema(): Promise<VersionedThunderstoreEcosystem> {
166+
const contentBuffer = await FsProvider.instance.readFile(await getMergedEcosystemPath());
167+
const content = contentBuffer.toString("utf8");
168+
const parsedContent = JSON.parse(content);
169+
const {version, lastModified, ...schemaContent} = parsedContent as VersionedThunderstoreEcosystem;
170+
void version;
171+
void lastModified;
172+
validateSchema(schemaContent);
173+
return parsedContent as VersionedThunderstoreEcosystem;
174+
}
175+
176+
async function loadSavedEcosystemSchema(): Promise<VersionedThunderstoreEcosystem | null> {
74177
const mergeFilePath = await getMergedEcosystemPath();
75-
const bundledSchema = async () => ({...(await loadBundledSchema()), version: ManagerInformation.VERSION.toString()});
76178
if (!(await FsProvider.instance.exists(mergeFilePath))) {
77-
return bundledSchema();
179+
return null;
78180
}
181+
79182
try {
80-
let content = await getLastSavedEcosystemSchema();
183+
const content = await readSavedEcosystemSchema();
81184
if (!new VersionNumber(content.version).isEqualTo(ManagerInformation.VERSION)) {
82-
return bundledSchema();
185+
return null;
83186
}
84187
return content;
85188
} catch (e) {
@@ -88,8 +191,16 @@ async function resolveCachedEcosystemSchema(): Promise<VersionedThunderstoreEcos
88191
LogSeverity.ERROR,
89192
`Failed to load cached ecosystem schema, falling back to bundled schema\n${err.message}`
90193
);
91-
return bundledSchema();
194+
return null;
195+
}
196+
}
197+
198+
async function resolveCachedEcosystemSchema(): Promise<ThunderstoreEcosystem> {
199+
const content = await loadSavedEcosystemSchema();
200+
if (content != null) {
201+
return content;
92202
}
203+
return loadBundledSchema();
93204
}
94205

95206
async function internalUpdateEcosystemReactives(schema: ThunderstoreEcosystem): Promise<void> {

0 commit comments

Comments
 (0)