Skip to content

Commit f53dfd4

Browse files
Implement CDN-based image retrieval
This is basically a four-step approach. The provider: 1. Checks to see if the images are bundled. 2. Checks to see if an instance of the schema is being served on localhost:1337 3. Checks to see if any images have been cached locally. 4. Retrieves the image from the CDN. Notably this also features the fire-once-and-fail-quick CDN check where it checks the connection once during startup and, if it fails, attempts steps 1-3 and then uses a default image.
1 parent 2f80a35 commit f53dfd4

6 files changed

Lines changed: 196 additions & 15 deletions

File tree

src/components/navigation/ManagerActivityBar.vue

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
<div class="activity-bar--left">
44
<div class="activity-bar__group">
55
<button class="activity-bar__context-item" id="game-switch-button" @click.prevent.stop="changeGame">
6-
<img class="game-icon" :src="ProtocolProvider.getPublicAssetUrl(`/images/game_selection/${activeGame.gameImage}`)" alt="Game icon"/>
6+
<img class="game-icon" :src="imageSrc" alt="Game icon"/>
77
<span>{{ activeGame.displayName }}</span>
88
</button>
99
<span class="activity-bar__item-label non-selectable activity-bar__item-divider">/</span>
@@ -32,19 +32,25 @@
3232
</template>
3333

3434
<script lang="ts" setup>
35-
import { computed } from 'vue';
35+
import { computed, ref, watchEffect } from 'vue';
3636
import { useRouter } from 'vue-router';
3737
import { getStore } from '../../providers/generic/store/StoreProvider';
3838
import { State } from '../../store';
3939
import { ActivityDropdown } from '../all';
40-
import ProtocolProvider from '../../providers/generic/protocol/ProtocolProvider';
40+
import GameImageProvider from '../../providers/generic/image/GameImageProvider';
4141
4242
const store = getStore<State>();
4343
const router = useRouter();
4444
4545
const activeGame = computed(() => store.state.activeGame);
4646
const profile = computed(() => store.getters['profile/activeProfile']);
4747
48+
const imageSrc = ref<string>(GameImageProvider.placeholderUrl);
49+
50+
watchEffect(async () => {
51+
imageSrc.value = await GameImageProvider.resolve(activeGame.value.iconUrl);
52+
});
53+
4854
function changeGame() {
4955
router.push('/');
5056
}

src/model/game/Game.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export default class Game {
1313
private readonly _thunderstoreUrl: string;
1414
private readonly _thunderstoreIdentifier: string;
1515
private readonly _storePlatformMetadata: StorePlatformMetadata[];
16-
private readonly _gameImage: string;
16+
private readonly _iconUrl: string;
1717
private readonly _displayMode: GameSelectionDisplayMode;
1818
private readonly _instanceType: GameInstanceType;
1919
private readonly _packageLoader: PackageLoader;
@@ -32,7 +32,7 @@ export default class Game {
3232
tsUrl: string,
3333
tsIdentifier: string,
3434
platforms: StorePlatformMetadata[],
35-
gameImage: string,
35+
iconUrl: string,
3636
displayMode: GameSelectionDisplayMode,
3737
instanceType: GameInstanceType,
3838
packageLoader: PackageLoader,
@@ -49,7 +49,7 @@ export default class Game {
4949
this._thunderstoreIdentifier = tsIdentifier;
5050
this._storePlatformMetadata = platforms;
5151
this._activePlatform = platforms[0];
52-
this._gameImage = gameImage;
52+
this._iconUrl = iconUrl;
5353
this._displayMode = displayMode;
5454
this._instanceType = instanceType;
5555
this._packageLoader = packageLoader;
@@ -109,8 +109,8 @@ export default class Game {
109109
this._activePlatform = platform;
110110
}
111111

112-
get gameImage(): string {
113-
return this._gameImage;
112+
get iconUrl(): string {
113+
return this._iconUrl;
114114
}
115115

116116
get displayMode(): GameSelectionDisplayMode {

src/model/game/GameManager.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export default class GameManager {
3535
game.distributions.map(
3636
(x) => new StorePlatformMetadata(x.platform, x.identifier || undefined)
3737
),
38-
game.meta.iconUrl || "thunderstore-beta.webp",
38+
game.meta.iconUrl ?? "",
3939
game.gameSelectionDisplayMode,
4040
game.gameInstanceType,
4141
game.packageLoader,

src/pages/GameSelectionScreen.vue

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@
150150
</div>
151151
<div class="image is-fullwidth border border--border-box rounded" :class="[{'border--warning warning-shadow': isFavourited(game)}]">
152152
<template v-if="activeTab === GameInstanceType.GAME">
153-
<img :src='getImageHref(`/images/game_selection/${game.gameImage}`)' alt='Mod Logo' class="rounded game-thumbnail"/>
153+
<img :src="imageSrcs[game.settingsIdentifier] ?? GameImageProvider.placeholderUrl" alt='Mod Logo' class="rounded game-thumbnail"/>
154154
</template>
155155
<template v-else>
156156
<h2 style="height: 250px; width: 188px" class="text-center pad pad--sides">{{ game.displayName }}</h2>
@@ -184,11 +184,11 @@ import R2Error from '../model/errors/R2Error';
184184
import { GameInstanceType, GameSelectionDisplayMode, Platform } from '../model/schema/ThunderstoreSchema';
185185
import ProviderUtils from '../providers/generic/ProviderUtils';
186186
import ModalCard from '../components/ModalCard.vue';
187-
import { computed, onMounted, reactive, ref } from 'vue';
187+
import { computed, onMounted, reactive, ref, watch } from 'vue';
188188
import { getStore } from '../providers/generic/store/StoreProvider';
189189
import { State } from '../store';
190190
import { useRouter } from 'vue-router';
191-
import ProtocolProvider from '../providers/generic/protocol/ProtocolProvider';
191+
import GameImageProvider from '../providers/generic/image/GameImageProvider';
192192
import {updateEcosystemReactives, updateLatestEcosystemSchema} from "src/r2mm/ecosystem/EcosystemSchema";
193193
194194
const store = getStore<State>();
@@ -233,9 +233,17 @@ const gameList = computed<Game[]>(() => {
233233
});
234234
});
235235
236-
function getImageHref(image: string) {
237-
return ProtocolProvider.getPublicAssetUrl(image);
238-
}
236+
const imageSrcs = reactive<Record<string, string>>({});
237+
238+
watch(filteredGameList, async (games) => {
239+
for (const game of games) {
240+
if (imageSrcs[game.settingsIdentifier] !== undefined) {
241+
continue;
242+
}
243+
imageSrcs[game.settingsIdentifier] = GameImageProvider.placeholderUrl;
244+
imageSrcs[game.settingsIdentifier] = await GameImageProvider.resolve(game.iconUrl);
245+
}
246+
}, { immediate: true });
239247
240248
function changeTab(tab: GameInstanceType) {
241249
activeTab.value = tab;

src/providers/generic/connection/CdnProvider.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,15 @@ export default class CdnProvider {
6262
: url;
6363
}
6464

65+
public static cdnUrlFor(pathSuffix: string): string | undefined {
66+
if (!CdnProvider.preferredCdn) {
67+
return undefined;
68+
}
69+
const { protocol, host } = CdnProvider.preferredCdn;
70+
const trimmed = pathSuffix.startsWith("/") ? pathSuffix.slice(1) : pathSuffix;
71+
return `${protocol}://${host}/${trimmed}`;
72+
}
73+
6574
public static addCdnQueryParameter(url: string) {
6675
return CdnProvider.preferredCdn
6776
? addOrReplaceSearchParams(url, `cdn=${CdnProvider.preferredCdn.host}`)
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import buffer from '../../node/buffer/buffer';
2+
import path from '../../node/path/path';
3+
import CdnProvider from '../connection/CdnProvider';
4+
import FsProvider from '../file/FsProvider';
5+
import PathResolver from '../../../r2mm/manager/PathResolver';
6+
7+
const BUNDLED_PROTOCOL_PREFIX = "public://images/game_selection/";
8+
const PLACEHOLDER_URL = `${BUNDLED_PROTOCOL_PREFIX}thunderstore-beta.webp`;
9+
const LOCALHOST_DEV_BASE = "http://localhost:1337";
10+
const LOCALHOST_PROBE_TIMEOUT_MS = 2000;
11+
const CDN_FETCH_TIMEOUT_MS = 10000;
12+
const CDN_BREAKER_THRESHOLD = 3;
13+
const CACHE_SUBDIR = "image-cache";
14+
15+
export default class GameImageProvider {
16+
17+
private static cacheRoot: string | undefined;
18+
private static localhostAvailable = false;
19+
private static cdnConsecutiveFailures = 0;
20+
private static cdnBreakerTripped = false;
21+
private static initialised: Promise<void> | undefined;
22+
23+
public static get placeholderUrl(): string {
24+
return PLACEHOLDER_URL;
25+
}
26+
27+
public static async resolve(iconUrl: string): Promise<string> {
28+
await GameImageProvider.ensureInit();
29+
30+
if (!iconUrl) {
31+
return PLACEHOLDER_URL;
32+
}
33+
34+
const bundledUrl = `${BUNDLED_PROTOCOL_PREFIX}${iconUrl}`;
35+
if (await GameImageProvider.urlReachable(bundledUrl)) {
36+
return bundledUrl;
37+
}
38+
39+
if (GameImageProvider.localhostAvailable) {
40+
const localhostUrl = `${LOCALHOST_DEV_BASE}/assets/${iconUrl}`;
41+
if (await GameImageProvider.urlReachable(localhostUrl)) {
42+
return localhostUrl;
43+
}
44+
}
45+
46+
const cachePath = GameImageProvider.cachePathFor(iconUrl);
47+
if (cachePath && await FsProvider.instance.exists(cachePath)) {
48+
return GameImageProvider.fileUrlOf(cachePath);
49+
}
50+
51+
if (cachePath && !GameImageProvider.cdnBreakerTripped) {
52+
const cached = await GameImageProvider.fetchFromCdnAndCache(iconUrl, cachePath);
53+
if (cached) {
54+
return cached;
55+
}
56+
}
57+
58+
return PLACEHOLDER_URL;
59+
}
60+
61+
private static ensureInit(): Promise<void> {
62+
if (!GameImageProvider.initialised) {
63+
GameImageProvider.initialised = GameImageProvider.runInit();
64+
}
65+
return GameImageProvider.initialised;
66+
}
67+
68+
private static async runInit(): Promise<void> {
69+
if (PathResolver.APPDATA_DIR) {
70+
const root = path.join(PathResolver.APPDATA_DIR, CACHE_SUBDIR);
71+
await FsProvider.instance.mkdirs(root);
72+
GameImageProvider.cacheRoot = root;
73+
}
74+
75+
if (import.meta.env.MODE === "development") {
76+
GameImageProvider.localhostAvailable = await GameImageProvider.probeLocalhost();
77+
}
78+
}
79+
80+
private static async probeLocalhost(): Promise<boolean> {
81+
const controller = new AbortController();
82+
const timer = setTimeout(() => controller.abort(), LOCALHOST_PROBE_TIMEOUT_MS);
83+
try {
84+
const res = await fetch(`${LOCALHOST_DEV_BASE}/healthz`, { signal: controller.signal });
85+
return res.ok;
86+
} catch {
87+
return false;
88+
} finally {
89+
clearTimeout(timer);
90+
}
91+
}
92+
93+
private static async urlReachable(url: string): Promise<boolean> {
94+
try {
95+
const res = await fetch(url);
96+
return res.ok;
97+
} catch {
98+
return false;
99+
}
100+
}
101+
102+
private static cachePathFor(iconUrl: string): string | undefined {
103+
if (!GameImageProvider.cacheRoot) {
104+
return undefined;
105+
}
106+
return path.join(GameImageProvider.cacheRoot, ...iconUrl.split("/"));
107+
}
108+
109+
private static fileUrlOf(filePath: string): string {
110+
return `file:///${filePath.replace(/\\/g, "/")}`;
111+
}
112+
113+
private static async fetchFromCdnAndCache(iconUrl: string, cachePath: string): Promise<string | undefined> {
114+
const cdnUrl = CdnProvider.cdnUrlFor(`assets/${iconUrl}`);
115+
if (!cdnUrl) {
116+
return undefined;
117+
}
118+
119+
const controller = new AbortController();
120+
const timer = setTimeout(() => controller.abort(), CDN_FETCH_TIMEOUT_MS);
121+
let res: Response;
122+
try {
123+
res = await fetch(cdnUrl, { signal: controller.signal });
124+
} catch {
125+
GameImageProvider.recordCdnFailure();
126+
return undefined;
127+
} finally {
128+
clearTimeout(timer);
129+
}
130+
131+
if (res.status === 404) {
132+
GameImageProvider.cdnConsecutiveFailures = 0;
133+
return undefined;
134+
}
135+
if (!res.ok) {
136+
GameImageProvider.recordCdnFailure();
137+
return undefined;
138+
}
139+
140+
try {
141+
const arrayBuffer = await res.arrayBuffer();
142+
await FsProvider.instance.mkdirs(path.dirname(cachePath));
143+
await FsProvider.instance.writeFile(cachePath, buffer.from(arrayBuffer));
144+
GameImageProvider.cdnConsecutiveFailures = 0;
145+
return GameImageProvider.fileUrlOf(cachePath);
146+
} catch {
147+
GameImageProvider.recordCdnFailure();
148+
return undefined;
149+
}
150+
}
151+
152+
private static recordCdnFailure(): void {
153+
GameImageProvider.cdnConsecutiveFailures += 1;
154+
if (GameImageProvider.cdnConsecutiveFailures >= CDN_BREAKER_THRESHOLD) {
155+
GameImageProvider.cdnBreakerTripped = true;
156+
}
157+
}
158+
}

0 commit comments

Comments
 (0)