Skip to content

Commit

Permalink
Merge pull request ebkr#1130 from ebkr/support-alternative-cdn
Browse files Browse the repository at this point in the history
Support alternative Thunderstore CDN
  • Loading branch information
anttimaki authored Dec 19, 2023
2 parents 2c3442a + 0555b38 commit 483e533
Show file tree
Hide file tree
Showing 8 changed files with 185 additions and 23 deletions.
20 changes: 1 addition & 19 deletions src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@
<script lang="ts">
import Component, { mixins } from 'vue-class-component';
import 'bulma-steps/dist/js/bulma-steps.min.js';
import R2Error from './model/errors/R2Error';
import ManagerSettings from './r2mm/manager/ManagerSettings';
import ProfileProvider from './providers/ror2/model_implementation/ProfileProvider';
import ProfileImpl from './r2mm/model_implementation/ProfileImpl';
Expand Down Expand Up @@ -68,33 +67,16 @@ import UtilityMixin from './components/mixins/UtilityMixin.vue';
@Component
export default class App extends mixins(UtilityMixin) {
private errorMessage: string = '';
private errorStack: string = '';
private errorSolution: string = '';
private settings: ManagerSettings | null = null;
private visible: boolean = false;
showError(error: R2Error) {
this.errorMessage = error.name;
this.errorStack = error.message;
this.errorSolution = error.solution;
LoggerProvider.instance.Log(LogSeverity.ERROR, `[${error.name}]: ${error.message}`);
}
closeErrorModal() {
this.errorMessage = '';
this.errorStack = '';
this.errorSolution = '';
}
async created() {
// Use as default game for settings load.
GameManager.activeGame = GameManager.unsetGame();
this.hookThunderstoreModListRefresh();
this.hookProfileModListRefresh();
await this.checkCdnConnection();
const settings = await ManagerSettings.getSingleton(GameManager.activeGame);
this.settings = settings;
Expand Down
34 changes: 34 additions & 0 deletions src/components/mixins/UtilityMixin.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,33 @@ import Component from 'vue-class-component';
import R2Error from '../../model/errors/R2Error';
import GameManager from '../../model/game/GameManager';
import Profile from '../../model/Profile';
import CdnProvider from '../../providers/generic/connection/CdnProvider';
import LoggerProvider, { LogSeverity } from '../../providers/ror2/logging/LoggerProvider';
import ThunderstorePackages from '../../r2mm/data/ThunderstorePackages';
import ProfileModList from '../../r2mm/mods/ProfileModList';
import ApiCacheUtils from '../../utils/ApiCacheUtils';
@Component
export default class UtilityMixin extends Vue {
private errorMessage: string = '';
private errorStack: string = '';
private errorSolution: string = '';
readonly REFRESH_INTERVAL = 5 * 60 * 1000;
private tsRefreshFailed = false;
showError(error: R2Error) {
this.errorMessage = error.name;
this.errorStack = error.message;
this.errorSolution = error.solution;
LoggerProvider.instance.Log(LogSeverity.ERROR, `[${error.name}]: ${error.message}`);
}
closeErrorModal() {
this.errorMessage = '';
this.errorStack = '';
this.errorSolution = '';
}
hookProfileModListRefresh() {
setInterval(this.refreshProfileModList, this.REFRESH_INTERVAL);
}
Expand Down Expand Up @@ -64,5 +82,21 @@ export default class UtilityMixin extends Vue {
this.tsRefreshFailed = false;
}
/**
* Set internal state of CdnProvider to prefer a mirror CDN if the
* main CDN is unreachable.
*/
async checkCdnConnection() {
try {
await CdnProvider.checkCdnConnection();
} catch (error: unknown) {
if (error instanceof R2Error) {
this.showError(error);
} else {
console.error(error);
}
}
}
}
</script>
9 changes: 8 additions & 1 deletion src/components/views/OnlineModList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<div>
<ExpandableCard
v-for='(key, index) in pagedModList' :key="`online-${key.getFullName()}-${index}-${settings.getContext().global.expandedCards}`"
:image="key.getVersions()[0].getIcon()"
:image="getImageUrl(key)"
:id="index"
:description="key.getVersions()[0].getDescription()"
:funkyMode="funkyMode"
Expand Down Expand Up @@ -70,6 +70,7 @@ import DownloadModModal from './DownloadModModal.vue';
import ManifestV2 from '../../model/ManifestV2';
import R2Error from '../../model/errors/R2Error';
import DonateButton from '../../components/buttons/DonateButton.vue';
import CdnProvider from '../../providers/generic/connection/CdnProvider';
@Component({
components: {
Expand Down Expand Up @@ -122,6 +123,12 @@ export default class OnlineModList extends Vue {
return mod.getCategories().join(", ");
}
getImageUrl(tsMod: ThunderstoreMod): string {
return CdnProvider.replaceCdnHost(
tsMod.getVersions()[0].getIcon()
);
}
emitError(error: R2Error) {
this.$emit('error', error);
}
Expand Down
5 changes: 3 additions & 2 deletions src/model/ThunderstoreVersion.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Mod from './Mod';
import VersionNumber from './VersionNumber';
import ReactiveObjectConverterInterface from './safety/ReactiveObjectConverter';
import CdnProvider from '../providers/generic/connection/CdnProvider';

export default class ThunderstoreVersion extends Mod implements ReactiveObjectConverterInterface {

Expand Down Expand Up @@ -35,10 +36,10 @@ export default class ThunderstoreVersion extends Mod implements ReactiveObjectCo
}

public getDownloadUrl(): string {
return this.downloadUrl;
return CdnProvider.addCdnQueryParameter(this.downloadUrl);
}

public setDownloadUrl(url: string) {
this.downloadUrl = url;
}
}
}
63 changes: 63 additions & 0 deletions src/providers/generic/connection/CdnProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import R2Error from '../../../model/errors/R2Error';
import { getAxiosWithTimeouts } from '../../../utils/HttpUtils';
import { addOrReplaceSearchParams, replaceHost } from '../../../utils/UrlUtils';

const CDNS = [
"gcdn.thunderstore.io",
"hcdn-1.hcdn.thunderstore.io"
]
const TEST_FILE = "healthz";

const CONNECTION_ERROR = new R2Error(
"Can't reach content delivery networks",
`All Thunderstore CDNs seem to be currently unreachable from
this computer. You can still use the mod manager, but
downloading mods will not work.`,
`Test another internet connection, if available. For example
using a VPN or connecting to a mobile hotspot might solve the
issue.`
);

export default class CdnProvider {
private static axios = getAxiosWithTimeouts(5000, 5000);
private static preferredCdn = "";

public static async checkCdnConnection() {
const headers = {
"Cache-Control": "no-cache",
"Pragma": "no-cache",
"Expires": "0",
};
const params = {"disableCache": new Date().getTime()};
let res;

for await (const cdn of CDNS) {
const url = `https://${cdn}/${TEST_FILE}`;

try {
res = await CdnProvider.axios.get(url, {headers, params});
} catch (e) {
continue;
}

if (res.status === 200) {
CdnProvider.preferredCdn = cdn;
return;
}
};

throw CONNECTION_ERROR;
}

public static replaceCdnHost(url: string) {
return CdnProvider.preferredCdn
? replaceHost(url, CdnProvider.preferredCdn)
: url;
}

public static addCdnQueryParameter(url: string) {
return CdnProvider.preferredCdn
? addOrReplaceSearchParams(url, `cdn=${CdnProvider.preferredCdn}`)
: url;
}
}
6 changes: 5 additions & 1 deletion src/r2mm/profiles/ProfilesClient.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Axios, { AxiosResponse } from 'axios';
import R2Error from '../../model/errors/R2Error';
import CdnProvider from '../../providers/generic/connection/CdnProvider';

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

async function getProfile(profileImportCode: string): Promise<AxiosResponse<string>> {
return await Axios.get(getProfileUrl(profileImportCode));
const url = CdnProvider.addCdnQueryParameter(
getProfileUrl(profileImportCode)
);
return await Axios.get(url);
}

export const ProfileApiClient = {
Expand Down
38 changes: 38 additions & 0 deletions src/utils/HttpUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import axios from "axios";

const newAbortSignal = (timeoutMs: number) => {
const abortController = new AbortController();
setTimeout(() => abortController.abort(), timeoutMs);
return abortController.signal;
};

/**
* Return Axios instance with timeouts enabled.
* @param responseTimeout Time (in ms) the server has to generate a
* response once a connection is established. Defaults to 5 seconds.
* @param connectionTimeout Time (in ms) the request has in total,
* including opening the connection and receiving the response.
* Defaults to 10 seconds.
* @returns AxiosInstance
*/
export const getAxiosWithTimeouts = (responseTimeout = 5000, connectionTimeout = 10000) => {
const instance = axios.create({timeout: responseTimeout});

// Use interceptors to have a fresh abort signal for each request,
// so the instance can be shared by multiple requests.
instance.interceptors.request.use((config) => {
config.signal = newAbortSignal(connectionTimeout);
return config;
});

return instance;
};

export const isNetworkError = (responseOrError: unknown) =>
responseOrError instanceof Error && responseOrError.message === "Network Error";

/**
* Is the Error thrown by Axios request caused by a response timeout?
*/
export const isResponseTimeout = (error: unknown) =>
error instanceof Error && /timeout of (\d+)ms exceeded/i.test(error.message);
33 changes: 33 additions & 0 deletions src/utils/UrlUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* Append given search parameters to an URL which may or may not already
* have search parameters.
*
* Existing search parameters with a key present in the new parameters
* will be overwritten.
*/
export const addOrReplaceSearchParams = (url: string, paramString: string) => {
const newUrl = new URL(url);
newUrl.search = new URLSearchParams(
Object.assign(
{},
Object.fromEntries(newUrl.searchParams),
Object.fromEntries(new URLSearchParams(paramString))
)
).toString();
return newUrl.href;
}

/**
* Replace URL host, i.e. the domain and the port number.
*
* @param url e.g. "https://thunderstore.io/foo"
* @param domainAndPort e.g. "thunderstore.dev" or "thunderstore.dev:8080"
* @returns e.g. "https://thunderstore.dev:8080/foo"
*/
export const replaceHost = (url: string, domainAndPort: string) => {
const newValues = domainAndPort.split(":");
const newUrl = new URL(url);
newUrl.hostname = newValues[0];
newUrl.port = newValues[1] || "";
return newUrl.href;
};

0 comments on commit 483e533

Please sign in to comment.