From 6f9bb959392a53500fbda3a16e92dcb8255569bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20M=C3=A4ki?= Date: Mon, 18 Dec 2023 12:28:59 +0200 Subject: [PATCH 1/8] Add helpers for dealing with timeouts when using Axios The implementation is "backported" from Thunderstore Mod Manager since it's now needed in r2mm too. Refs TS-2003 --- src/utils/HttpUtils.ts | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 src/utils/HttpUtils.ts diff --git a/src/utils/HttpUtils.ts b/src/utils/HttpUtils.ts new file mode 100644 index 000000000..73d177426 --- /dev/null +++ b/src/utils/HttpUtils.ts @@ -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); From bd64b188cc3b62eb992d847cb0f5aed03d122d7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20M=C3=A4ki?= Date: Mon, 18 Dec 2023 17:33:34 +0200 Subject: [PATCH 2/8] Add helpers for dealing with URLs Refs TS-2003 --- src/utils/UrlUtils.ts | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 src/utils/UrlUtils.ts diff --git a/src/utils/UrlUtils.ts b/src/utils/UrlUtils.ts new file mode 100644 index 000000000..bd7481586 --- /dev/null +++ b/src/utils/UrlUtils.ts @@ -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; +}; From 66be94ad49c195492d3f87d7dbcbeea36bf5c76c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20M=C3=A4ki?= Date: Mon, 18 Dec 2023 17:39:25 +0200 Subject: [PATCH 3/8] Add CdnProvider A class containing static methods intended to provide support for multiple Thunderstore CDNs: - Check connection to CDNs and remember which one works - Provide a method for replacing the CDN host from URLs. This can be used when the mod manager uses the URL directly. - Provide a method for augmenting an URL with a query parameter denoting the preferred CDN. This can be used when download happens via a redirect from Thunderstore API, although it requires that the API endpoint has been set up to deal with the query parameter. Refs TS-2003 --- .../generic/connection/CdnProvider.ts | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 src/providers/generic/connection/CdnProvider.ts diff --git a/src/providers/generic/connection/CdnProvider.ts b/src/providers/generic/connection/CdnProvider.ts new file mode 100644 index 000000000..44803a798 --- /dev/null +++ b/src/providers/generic/connection/CdnProvider.ts @@ -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; + } +} From a9812456625de7f9c261d364aa9c5d30d80f7750 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20M=C3=A4ki?= Date: Wed, 13 Dec 2023 16:44:49 +0200 Subject: [PATCH 4/8] Move showError from App to UtilityMixin This is required so that also the UtilityMixin can show the error modal in the future. Refs TS-2003 --- src/App.vue | 19 ------------------- src/components/mixins/UtilityMixin.vue | 17 +++++++++++++++++ 2 files changed, 17 insertions(+), 19 deletions(-) diff --git a/src/App.vue b/src/App.vue index 55804234a..b7fd125a8 100644 --- a/src/App.vue +++ b/src/App.vue @@ -26,7 +26,6 @@ From c0da53898b397d6b01c98b07b7871fed679534ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20M=C3=A4ki?= Date: Thu, 14 Dec 2023 13:01:46 +0200 Subject: [PATCH 6/8] Add support for alternative CDN when downloading package icons Since the icon URLs for non-installed packages are read from the PackageVersion metadata and downloaded by the browser, brute force replacement of the domain is required. Refs TS-2003 --- src/components/views/OnlineModList.vue | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/components/views/OnlineModList.vue b/src/components/views/OnlineModList.vue index 6fa0a4f8f..44523979f 100644 --- a/src/components/views/OnlineModList.vue +++ b/src/components/views/OnlineModList.vue @@ -2,7 +2,7 @@
Date: Thu, 14 Dec 2023 13:09:31 +0200 Subject: [PATCH 7/8] Add support for alternative CDN when importing shared profiles Since the actual download URL is returned by the Thunderstore API as a redirect, signal the API with a query parameter that an alternative CDN is preferred. Refs TS-2003 --- src/r2mm/profiles/ProfilesClient.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/r2mm/profiles/ProfilesClient.ts b/src/r2mm/profiles/ProfilesClient.ts index 0d18c6b63..c27c4151d 100644 --- a/src/r2mm/profiles/ProfilesClient.ts +++ b/src/r2mm/profiles/ProfilesClient.ts @@ -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}/`; @@ -40,7 +41,10 @@ async function createProfile(payload: string): Promise> { - return await Axios.get(getProfileUrl(profileImportCode)); + const url = CdnProvider.addCdnQueryParameter( + getProfileUrl(profileImportCode) + ); + return await Axios.get(url); } export const ProfileApiClient = { From 0555b3883fe71aacfa16759abbcacb4d75cf75be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20M=C3=A4ki?= Date: Thu, 14 Dec 2023 14:18:56 +0200 Subject: [PATCH 8/8] Add support for alternative CDN when downloading packages Since the actual download URL is returned by the Thunderstore API as a redirect, signal the API with a query parameter that an alternative CDN is preferred. Refs TS-2003 --- src/model/ThunderstoreVersion.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/model/ThunderstoreVersion.ts b/src/model/ThunderstoreVersion.ts index ac8bdea25..586fcb740 100644 --- a/src/model/ThunderstoreVersion.ts +++ b/src/model/ThunderstoreVersion.ts @@ -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 { @@ -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; } -} \ No newline at end of file +}