Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support alternative Thunderstore CDN #1130

Merged
merged 8 commits into from
Dec 19, 2023
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);
MythicManiac marked this conversation as resolved.
Show resolved Hide resolved
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;
};