diff --git a/src/components/mixins/UtilityMixin.vue b/src/components/mixins/UtilityMixin.vue index 420b48944..f40317c14 100644 --- a/src/components/mixins/UtilityMixin.vue +++ b/src/components/mixins/UtilityMixin.vue @@ -56,6 +56,15 @@ export default class UtilityMixin extends Vue { } async refreshThunderstoreModList() { + // Don't do background update on index route since the game + // isn't really chosen yet, nor in the splash screen since it + // proactively updates the package list. + const exemptRoutes = ["index", "splash"]; + + if (this.$route.name && exemptRoutes.includes(this.$route.name)) { + return; + } + const response = await ThunderstorePackages.update(GameManager.activeGame); await ApiCacheUtils.storeLastRequest(response.data); await this.$store.dispatch("updateThunderstoreModList", ThunderstorePackages.PACKAGES); diff --git a/src/r2mm/connection/ConnectionProviderImpl.ts b/src/r2mm/connection/ConnectionProviderImpl.ts index ed4aac4b3..aa1fbc285 100644 --- a/src/r2mm/connection/ConnectionProviderImpl.ts +++ b/src/r2mm/connection/ConnectionProviderImpl.ts @@ -6,6 +6,7 @@ import GameManager from '../../model/game/GameManager'; import ConnectionProvider, { DownloadProgressed } from '../../providers/generic/connection/ConnectionProvider'; import LoggerProvider, { LogSeverity } from '../../providers/ror2/logging/LoggerProvider'; import { sleep } from '../../utils/Common'; +import { makeLongRunningGetRequest } from '../../utils/HttpUtils'; export default class ConnectionProviderImpl extends ConnectionProvider { @@ -37,14 +38,10 @@ export default class ConnectionProviderImpl extends ConnectionProvider { } private async getPackagesFromRemote(game: Game, downloadProgressed?: DownloadProgressed) { - const response = await axios.get(game.thunderstoreUrl, { - onDownloadProgress: progress => { - if (downloadProgressed !== undefined) { - downloadProgressed((progress.loaded / progress.total) * 100); - } - }, - timeout: 30000 - }); + const response = await makeLongRunningGetRequest( + game.thunderstoreUrl, + {downloadProgressed} + ) if (isApiResonse(response)) { return response as ApiResponse; diff --git a/src/utils/HttpUtils.ts b/src/utils/HttpUtils.ts index 73d177426..68c93da79 100644 --- a/src/utils/HttpUtils.ts +++ b/src/utils/HttpUtils.ts @@ -1,5 +1,7 @@ import axios from "axios"; +import { DownloadProgressed } from "../providers/generic/connection/ConnectionProvider"; + const newAbortSignal = (timeoutMs: number) => { const abortController = new AbortController(); setTimeout(() => abortController.abort(), timeoutMs); @@ -10,24 +12,98 @@ const newAbortSignal = (timeoutMs: number) => { * 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, + * @param totalTimeout 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) => { +export const getAxiosWithTimeouts = (responseTimeout = 5000, totalTimeout = 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); + config.signal = newAbortSignal(totalTimeout); return config; }); return instance; }; +interface LongRunningRequestOptions { + /** + * Custom function to be called when progress is made. Doesn't work + * properly currently, since the progress percentage can't be + * calculated because the total length of the content isn't known. + */ + downloadProgressed?: DownloadProgressed; + /** + * Time (in ms) the request has to trigger the first download + * progress event. This can be used to timeout early if a connection + * can't be formed at all. Defaults to 30 seconds. + */ + initialTimeout?: number; + /** + * Time (in ms) the request has in total to complete. This can be + * used as a sanity check to prevent infinite requests. Defaults to + * five minutes. + */ + totalTimeout?: number; + /** + * Time (in ms) the request has to trigger subsequent download + * progress events. This can be used to timeout the request if data + * isn't transferred fast enough or at all. Defaults to one minute. + */ + transmissionTimeout?: number; +} + +/** + * Make a GET request with extended timeouts. + * + * Since Axios's support lacks granularity, request timeouts are + * controlled with AbortController and JavaScript timeouts instead. + */ +export const makeLongRunningGetRequest = async ( + url: string, + options: Partial = {} +) => { + const { + downloadProgressed = () => null, + initialTimeout = 30 * 1000, + totalTimeout = 5 * 60 * 1000, + transmissionTimeout = 60 * 1000 + } = options; + + const abortController = new AbortController(); + const abort = () => abortController.abort(); // Set valid this. + const sanityTimeout = setTimeout(abort, totalTimeout); + let rollingTimeout = setTimeout(abort, initialTimeout); + + const onDownloadProgress = (progress: ProgressEvent) => { + clearTimeout(rollingTimeout); + rollingTimeout = setTimeout(abort, transmissionTimeout); + + if (typeof downloadProgressed === "function") { + // TODO: Progress percentage can't be calculated since the + // content length is unknown. Looks like this hasn't worked + // in a while. + downloadProgressed(0); + } + } + + const instance = axios.create({ + onDownloadProgress, + signal: abortController.signal, + }); + + try { + return await instance.get(url); + } finally { + clearTimeout(sanityTimeout); + clearTimeout(rollingTimeout); + } +} + export const isNetworkError = (responseOrError: unknown) => responseOrError instanceof Error && responseOrError.message === "Network Error";