Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,6 @@ export default class App extends mixins(UtilityMixin) {
// Load settings using the default game before the actual game is selected.
const settings: ManagerSettings = await this.$store.dispatch('resetActiveGame');

this.hookBackgroundUpdateThunderstoreModList();
await this.checkCdnConnection();

InstallationRuleApplicator.apply();
InstallationRules.validate();

Expand Down Expand Up @@ -99,6 +96,8 @@ export default class App extends mixins(UtilityMixin) {
document.documentElement.classList.toggle('html--dark', this.$q.dark.isActive);
});

this.hookBackgroundUpdateThunderstoreModList();
await this.checkCdnConnection();
this.$store.commit('updateModLoaderPackageNames');
this.$store.dispatch('tsMods/updateExclusions');
}
Expand Down
1 change: 1 addition & 0 deletions src/components/mixins/UtilityMixin.vue
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export default class UtilityMixin extends Vue {
*/
async checkCdnConnection() {
try {
await CdnProvider.fetchCdnDefinitions();
await CdnProvider.checkCdnConnection();
} catch (error: unknown) {
if (error instanceof R2Error) {
Expand Down
2 changes: 1 addition & 1 deletion src/model/ThunderstoreVersion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ export default class ThunderstoreVersion {
}

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

public setDownloadUrl(url: string) {
Expand Down
108 changes: 99 additions & 9 deletions src/providers/generic/connection/CdnProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,47 @@ 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"
/**
* Assumptions about the data:
* - There's exactly one CDN with type "main"
* - There are 0..n CDNs with type of "main_alt" and "mirror", each
* - Weights of "main" and "main_alt" CDNs must sum 1
* - Weights of "mirror" CDNs are ignored
*/
interface Cdn {
domain: string;
type: "main" | "main_alt" | "mirror";
weight: number;
}

const isCdnConfig = (obj: unknown): obj is Cdn[] => {
if (!Array.isArray(obj)) {
return false;
}

const cumulativeWeight = obj.reduce((acc, cdn) => {
return cdn.type === "mirror" ? acc : acc + cdn.weight;
}, 0);

if (cumulativeWeight !== 1) {
return false;
}

return obj.every((cdn) => {
return typeof cdn === "object" &&
cdn !== null &&
typeof cdn.domain === "string" &&
typeof cdn.type === "string" &&
typeof cdn.weight === "number" &&
(cdn.type === "main" || cdn.type === "main_alt" || cdn.type === "mirror") &&
(cdn.weight >= 0 && cdn.weight <= 1);
});
}

const CDN_CONFIG_URL = "https://thunderstore.io/api/experimental/cdn-config/prod/latest/";
const FALLBACK_CDNS: Cdn[] = [
{domain: "gcdn.thunderstore.io", type: "main", weight: 1},
{domain: "hcdn-1.hcdn.thunderstore.io", type: "mirror", weight: 0}
]
const TEST_FILE = "healthz";

Expand All @@ -20,16 +58,41 @@ const CONNECTION_ERROR = new R2Error(

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

public static get mainCdn(): Cdn {
return CdnProvider.cdnConfig.find((cdn) => cdn.type === "main")!;
}

// "main_alt" CDNs are used only for downloading packages
// during the gradual rollout process and thus ignored here.
public static get mainAndMirrors(): Cdn[] {
return CdnProvider.cdnConfig.filter((cdn) => cdn.type !== "main_alt");
}

public static get current() {
const i = CDNS.findIndex((cdn) => cdn === CdnProvider.preferredCdn);
const i = CdnProvider.mainAndMirrors.findIndex((cdn) => cdn.domain === CdnProvider.preferredCdn);
return {
label: [-1, 0].includes(i) ? "Main CDN" : `Mirror #${i}`,
url: CdnProvider.preferredCdn
};
}

public static async fetchCdnDefinitions() {
try {
const response = await CdnProvider.axios.get(CDN_CONFIG_URL);

if (!isCdnConfig(response.data)) {
throw new Error("Invalid CDN configuration");
}

CdnProvider.cdnConfig = response.data;
} catch (e) {
CdnProvider.cdnConfig = FALLBACK_CDNS;
}
}

public static async checkCdnConnection() {
const headers = {
"Cache-Control": "no-cache",
Expand All @@ -39,8 +102,8 @@ export default class CdnProvider {
const params = {"disableCache": new Date().getTime()};
let res;

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

try {
res = await CdnProvider.axios.get(url, {headers, params});
Expand All @@ -49,7 +112,7 @@ export default class CdnProvider {
}

if (res.status === 200) {
CdnProvider.preferredCdn = cdn;
CdnProvider.preferredCdn = cdn.domain;
return;
}
};
Expand All @@ -69,13 +132,40 @@ export default class CdnProvider {
: url;
}

// Direct proportion of package download requests from the main CDN
// to the main_alt CDNs.
public static addCdnQueryParameterForPackageDownload(url: string) {
if (CdnProvider.preferredCdn === CdnProvider.mainCdn.domain) {
const cdn = CdnProvider.selectWeightedCdn();
return addOrReplaceSearchParams(url, `cdn=${cdn.domain}`);
}

return CdnProvider.addCdnQueryParameter(url);
}

public static togglePreferredCdn() {
let currentIndex = CDNS.findIndex((cdn) => cdn === CdnProvider.preferredCdn);
const domains = CdnProvider.mainAndMirrors.map((cdn) => cdn.domain);
let currentIndex = domains.findIndex((d) => d === CdnProvider.preferredCdn);

if (currentIndex === -1) {
currentIndex = 0;
}

CdnProvider.preferredCdn = CDNS[currentIndex + 1] || CDNS[0];
CdnProvider.preferredCdn = domains[currentIndex + 1] || domains[0];
}

private static selectWeightedCdn(): Cdn {
const eligibleCdns = CdnProvider.cdnConfig.filter(cdn => cdn.type !== "mirror");
const random = Math.random();
let cumulativeWeight = 0;

for (const cdn of eligibleCdns) {
cumulativeWeight += cdn.weight;
if (random <= cumulativeWeight) {
return cdn;
}
}

return CdnProvider.mainCdn; // Shouldn't happen if weights sum to 1.
}
}
Loading