Skip to content

Commit 483e533

Browse files
authored
Merge pull request ebkr#1130 from ebkr/support-alternative-cdn
Support alternative Thunderstore CDN
2 parents 2c3442a + 0555b38 commit 483e533

File tree

8 files changed

+185
-23
lines changed

8 files changed

+185
-23
lines changed

src/App.vue

Lines changed: 1 addition & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@
2626
<script lang="ts">
2727
import Component, { mixins } from 'vue-class-component';
2828
import 'bulma-steps/dist/js/bulma-steps.min.js';
29-
import R2Error from './model/errors/R2Error';
3029
import ManagerSettings from './r2mm/manager/ManagerSettings';
3130
import ProfileProvider from './providers/ror2/model_implementation/ProfileProvider';
3231
import ProfileImpl from './r2mm/model_implementation/ProfileImpl';
@@ -68,33 +67,16 @@ import UtilityMixin from './components/mixins/UtilityMixin.vue';
6867
6968
@Component
7069
export default class App extends mixins(UtilityMixin) {
71-
72-
private errorMessage: string = '';
73-
private errorStack: string = '';
74-
private errorSolution: string = '';
7570
private settings: ManagerSettings | null = null;
76-
7771
private visible: boolean = false;
7872
79-
showError(error: R2Error) {
80-
this.errorMessage = error.name;
81-
this.errorStack = error.message;
82-
this.errorSolution = error.solution;
83-
LoggerProvider.instance.Log(LogSeverity.ERROR, `[${error.name}]: ${error.message}`);
84-
}
85-
86-
closeErrorModal() {
87-
this.errorMessage = '';
88-
this.errorStack = '';
89-
this.errorSolution = '';
90-
}
91-
9273
async created() {
9374
// Use as default game for settings load.
9475
GameManager.activeGame = GameManager.unsetGame();
9576
9677
this.hookThunderstoreModListRefresh();
9778
this.hookProfileModListRefresh();
79+
await this.checkCdnConnection();
9880
9981
const settings = await ManagerSettings.getSingleton(GameManager.activeGame);
10082
this.settings = settings;

src/components/mixins/UtilityMixin.vue

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,33 @@ import Component from 'vue-class-component';
55
import R2Error from '../../model/errors/R2Error';
66
import GameManager from '../../model/game/GameManager';
77
import Profile from '../../model/Profile';
8+
import CdnProvider from '../../providers/generic/connection/CdnProvider';
9+
import LoggerProvider, { LogSeverity } from '../../providers/ror2/logging/LoggerProvider';
810
import ThunderstorePackages from '../../r2mm/data/ThunderstorePackages';
911
import ProfileModList from '../../r2mm/mods/ProfileModList';
1012
import ApiCacheUtils from '../../utils/ApiCacheUtils';
1113
1214
@Component
1315
export default class UtilityMixin extends Vue {
16+
private errorMessage: string = '';
17+
private errorStack: string = '';
18+
private errorSolution: string = '';
1419
readonly REFRESH_INTERVAL = 5 * 60 * 1000;
1520
private tsRefreshFailed = false;
1621
22+
showError(error: R2Error) {
23+
this.errorMessage = error.name;
24+
this.errorStack = error.message;
25+
this.errorSolution = error.solution;
26+
LoggerProvider.instance.Log(LogSeverity.ERROR, `[${error.name}]: ${error.message}`);
27+
}
28+
29+
closeErrorModal() {
30+
this.errorMessage = '';
31+
this.errorStack = '';
32+
this.errorSolution = '';
33+
}
34+
1735
hookProfileModListRefresh() {
1836
setInterval(this.refreshProfileModList, this.REFRESH_INTERVAL);
1937
}
@@ -64,5 +82,21 @@ export default class UtilityMixin extends Vue {
6482
6583
this.tsRefreshFailed = false;
6684
}
85+
86+
/**
87+
* Set internal state of CdnProvider to prefer a mirror CDN if the
88+
* main CDN is unreachable.
89+
*/
90+
async checkCdnConnection() {
91+
try {
92+
await CdnProvider.checkCdnConnection();
93+
} catch (error: unknown) {
94+
if (error instanceof R2Error) {
95+
this.showError(error);
96+
} else {
97+
console.error(error);
98+
}
99+
}
100+
}
67101
}
68102
</script>

src/components/views/OnlineModList.vue

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
<div>
33
<ExpandableCard
44
v-for='(key, index) in pagedModList' :key="`online-${key.getFullName()}-${index}-${settings.getContext().global.expandedCards}`"
5-
:image="key.getVersions()[0].getIcon()"
5+
:image="getImageUrl(key)"
66
:id="index"
77
:description="key.getVersions()[0].getDescription()"
88
:funkyMode="funkyMode"
@@ -70,6 +70,7 @@ import DownloadModModal from './DownloadModModal.vue';
7070
import ManifestV2 from '../../model/ManifestV2';
7171
import R2Error from '../../model/errors/R2Error';
7272
import DonateButton from '../../components/buttons/DonateButton.vue';
73+
import CdnProvider from '../../providers/generic/connection/CdnProvider';
7374
7475
@Component({
7576
components: {
@@ -122,6 +123,12 @@ export default class OnlineModList extends Vue {
122123
return mod.getCategories().join(", ");
123124
}
124125
126+
getImageUrl(tsMod: ThunderstoreMod): string {
127+
return CdnProvider.replaceCdnHost(
128+
tsMod.getVersions()[0].getIcon()
129+
);
130+
}
131+
125132
emitError(error: R2Error) {
126133
this.$emit('error', error);
127134
}

src/model/ThunderstoreVersion.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import Mod from './Mod';
22
import VersionNumber from './VersionNumber';
33
import ReactiveObjectConverterInterface from './safety/ReactiveObjectConverter';
4+
import CdnProvider from '../providers/generic/connection/CdnProvider';
45

56
export default class ThunderstoreVersion extends Mod implements ReactiveObjectConverterInterface {
67

@@ -35,10 +36,10 @@ export default class ThunderstoreVersion extends Mod implements ReactiveObjectCo
3536
}
3637

3738
public getDownloadUrl(): string {
38-
return this.downloadUrl;
39+
return CdnProvider.addCdnQueryParameter(this.downloadUrl);
3940
}
4041

4142
public setDownloadUrl(url: string) {
4243
this.downloadUrl = url;
4344
}
44-
}
45+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import R2Error from '../../../model/errors/R2Error';
2+
import { getAxiosWithTimeouts } from '../../../utils/HttpUtils';
3+
import { addOrReplaceSearchParams, replaceHost } from '../../../utils/UrlUtils';
4+
5+
const CDNS = [
6+
"gcdn.thunderstore.io",
7+
"hcdn-1.hcdn.thunderstore.io"
8+
]
9+
const TEST_FILE = "healthz";
10+
11+
const CONNECTION_ERROR = new R2Error(
12+
"Can't reach content delivery networks",
13+
`All Thunderstore CDNs seem to be currently unreachable from
14+
this computer. You can still use the mod manager, but
15+
downloading mods will not work.`,
16+
`Test another internet connection, if available. For example
17+
using a VPN or connecting to a mobile hotspot might solve the
18+
issue.`
19+
);
20+
21+
export default class CdnProvider {
22+
private static axios = getAxiosWithTimeouts(5000, 5000);
23+
private static preferredCdn = "";
24+
25+
public static async checkCdnConnection() {
26+
const headers = {
27+
"Cache-Control": "no-cache",
28+
"Pragma": "no-cache",
29+
"Expires": "0",
30+
};
31+
const params = {"disableCache": new Date().getTime()};
32+
let res;
33+
34+
for await (const cdn of CDNS) {
35+
const url = `https://${cdn}/${TEST_FILE}`;
36+
37+
try {
38+
res = await CdnProvider.axios.get(url, {headers, params});
39+
} catch (e) {
40+
continue;
41+
}
42+
43+
if (res.status === 200) {
44+
CdnProvider.preferredCdn = cdn;
45+
return;
46+
}
47+
};
48+
49+
throw CONNECTION_ERROR;
50+
}
51+
52+
public static replaceCdnHost(url: string) {
53+
return CdnProvider.preferredCdn
54+
? replaceHost(url, CdnProvider.preferredCdn)
55+
: url;
56+
}
57+
58+
public static addCdnQueryParameter(url: string) {
59+
return CdnProvider.preferredCdn
60+
? addOrReplaceSearchParams(url, `cdn=${CdnProvider.preferredCdn}`)
61+
: url;
62+
}
63+
}

src/r2mm/profiles/ProfilesClient.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import Axios, { AxiosResponse } from 'axios';
22
import R2Error from '../../model/errors/R2Error';
3+
import CdnProvider from '../../providers/generic/connection/CdnProvider';
34

45
const getProfileUrl = (profileImportCode: string): string => {
56
return `https://thunderstore.io/api/experimental/legacyprofile/get/${profileImportCode}/`;
@@ -40,7 +41,10 @@ async function createProfile(payload: string): Promise<AxiosResponse<{ key: stri
4041
}
4142

4243
async function getProfile(profileImportCode: string): Promise<AxiosResponse<string>> {
43-
return await Axios.get(getProfileUrl(profileImportCode));
44+
const url = CdnProvider.addCdnQueryParameter(
45+
getProfileUrl(profileImportCode)
46+
);
47+
return await Axios.get(url);
4448
}
4549

4650
export const ProfileApiClient = {

src/utils/HttpUtils.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import axios from "axios";
2+
3+
const newAbortSignal = (timeoutMs: number) => {
4+
const abortController = new AbortController();
5+
setTimeout(() => abortController.abort(), timeoutMs);
6+
return abortController.signal;
7+
};
8+
9+
/**
10+
* Return Axios instance with timeouts enabled.
11+
* @param responseTimeout Time (in ms) the server has to generate a
12+
* response once a connection is established. Defaults to 5 seconds.
13+
* @param connectionTimeout Time (in ms) the request has in total,
14+
* including opening the connection and receiving the response.
15+
* Defaults to 10 seconds.
16+
* @returns AxiosInstance
17+
*/
18+
export const getAxiosWithTimeouts = (responseTimeout = 5000, connectionTimeout = 10000) => {
19+
const instance = axios.create({timeout: responseTimeout});
20+
21+
// Use interceptors to have a fresh abort signal for each request,
22+
// so the instance can be shared by multiple requests.
23+
instance.interceptors.request.use((config) => {
24+
config.signal = newAbortSignal(connectionTimeout);
25+
return config;
26+
});
27+
28+
return instance;
29+
};
30+
31+
export const isNetworkError = (responseOrError: unknown) =>
32+
responseOrError instanceof Error && responseOrError.message === "Network Error";
33+
34+
/**
35+
* Is the Error thrown by Axios request caused by a response timeout?
36+
*/
37+
export const isResponseTimeout = (error: unknown) =>
38+
error instanceof Error && /timeout of (\d+)ms exceeded/i.test(error.message);

src/utils/UrlUtils.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/**
2+
* Append given search parameters to an URL which may or may not already
3+
* have search parameters.
4+
*
5+
* Existing search parameters with a key present in the new parameters
6+
* will be overwritten.
7+
*/
8+
export const addOrReplaceSearchParams = (url: string, paramString: string) => {
9+
const newUrl = new URL(url);
10+
newUrl.search = new URLSearchParams(
11+
Object.assign(
12+
{},
13+
Object.fromEntries(newUrl.searchParams),
14+
Object.fromEntries(new URLSearchParams(paramString))
15+
)
16+
).toString();
17+
return newUrl.href;
18+
}
19+
20+
/**
21+
* Replace URL host, i.e. the domain and the port number.
22+
*
23+
* @param url e.g. "https://thunderstore.io/foo"
24+
* @param domainAndPort e.g. "thunderstore.dev" or "thunderstore.dev:8080"
25+
* @returns e.g. "https://thunderstore.dev:8080/foo"
26+
*/
27+
export const replaceHost = (url: string, domainAndPort: string) => {
28+
const newValues = domainAndPort.split(":");
29+
const newUrl = new URL(url);
30+
newUrl.hostname = newValues[0];
31+
newUrl.port = newValues[1] || "";
32+
return newUrl.href;
33+
};

0 commit comments

Comments
 (0)