cache mod compatibility list data & fetch it from the frontend

The backend now saves the parsed mod list to storage, and returns a SAS URI which the frontend uses to fetch it. This should significantly reduce bandwidth usage and response times, by avoiding the repeated backend parsing and data transfers.
This commit is contained in:
Jesse Plamondon-Willard
2025-04-21 14:24:39 -04:00
parent 8e9e8a16c5
commit d8ea53de21
10 changed files with 175 additions and 67 deletions
+3
View File
@@ -18,6 +18,9 @@
* Removed support for very old SMAPI logs.
* You can now download a JSON representation of the parsed log (see the download link at the bottom of the log page).
* Fixed server error if a JSON file contains nested comment syntax.
* Improved mod compatibility list:
* Reduced response times with a new cache and client-side fetch.
* Fixed sort order for mods with non-Latin characters in the name.
* Third-party libraries are now served from `smapi.io` instead of external CDNs.
## 4.2.1
@@ -44,4 +44,15 @@ public class ModCompatibilityInfo
this.UnofficialVersion = unofficialVersion;
this.UnofficialUrl = unofficialUrl;
}
/// <summary>Get whether this mod is compatible with no notes.</summary>
public bool IsDefault()
{
return
this.Status == ModCompatibilityStatus.Ok
&& this.Summary is "use latest version."
&& this.BrokeIn is null
&& this.UnofficialUrl is null
&& this.UnofficialVersion is null;
}
}
@@ -135,7 +135,7 @@ internal class LogParserController : Controller
// upload log
string id = Guid.NewGuid().ToString("N");
UploadResult uploadResult = await this.Storage.SaveAsync($"parsed-{id}", JsonConvert.SerializeObject(log), "application/json");
UploadResult uploadResult = await this.Storage.SaveToJsonAsync($"parsed-{id}", log);
if (!uploadResult.Succeeded)
return this.View("Index", this.GetModel(null, uploadError: uploadResult.UploadError));
+57 -11
View File
@@ -1,11 +1,15 @@
using System;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using Newtonsoft.Json;
using StardewModdingAPI.Toolkit.Utilities;
using StardewModdingAPI.Web.Framework.Caching;
using StardewModdingAPI.Web.Framework.Caching.CompatibilityRepo;
using StardewModdingAPI.Web.Framework.ConfigModels;
using StardewModdingAPI.Web.Framework.Storage;
using StardewModdingAPI.Web.ViewModels;
namespace StardewModdingAPI.Web.Controllers;
@@ -19,51 +23,93 @@ internal class ModsController : Controller
/// <summary>The cache in which to store mod metadata.</summary>
private readonly ICompatibilityCacheRepository Cache;
/// <summary>Provides access to raw data storage.</summary>
private readonly IStorageProvider Storage;
/// <summary>The number of minutes before which compatibility list data should be considered old.</summary>
private readonly int StaleMinutes;
/// <summary>When this server instance last refreshed the mod list cache file.</summary>
private DateTimeOffset LastCacheUpdate = DateTimeOffset.MinValue;
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="cache">The cache in which to store mod metadata.</param>
/// <param name="storage">Provides access to raw data storage.</param>
/// <param name="configProvider">The config settings for mod update checks.</param>
public ModsController(ICompatibilityCacheRepository cache, IOptions<ModCompatibilityListConfig> configProvider)
public ModsController(ICompatibilityCacheRepository cache, IStorageProvider storage, IOptions<ModCompatibilityListConfig> configProvider)
{
ModCompatibilityListConfig config = configProvider.Value;
this.Cache = cache;
this.Storage = storage;
this.StaleMinutes = config.StaleMinutes;
}
/// <summary>Display information for all mods.</summary>
[HttpGet]
[Route("mods")]
public ViewResult Index()
public async Task<ViewResult> Index()
{
return this.View("Index", this.FetchData());
return this.View("Index", await this.BuildModelAsync());
}
/*********
** Private methods
*********/
/// <summary>Asynchronously fetch mod metadata from the compatibility list.</summary>
public ModListModel FetchData()
/// <summary>Build a view model for the current mod compatibility list data.</summary>
private async Task<ModListModel> BuildModelAsync()
{
// fetch cached data
// get metadata
if (!this.Cache.TryGetCacheMetadata(out Cached<CompatibilityListMetadata>? metadata))
return new ModListModel(Array.Empty<ModModel>(), lastUpdated: DateTimeOffset.UtcNow, isStale: true);
return new ModListModel(null, [], lastUpdated: DateTimeOffset.UtcNow, isStale: true);
// fetch data
(string? fetchUri, ModModel[]? overrideData) = await this.FetchDataAsync(metadata);
// build model
return new ModListModel(
mods: this.Cache
.GetMods()
.Select(mod => new ModModel(mod.Data))
.OrderBy(p => Regex.Replace((p.Name ?? "").ToLower(), "[^a-z0-9]", "")), // ignore case, spaces, and special characters when sorting
fetchUri: fetchUri,
mods: overrideData,
lastUpdated: metadata.LastUpdated,
isStale: this.Cache.IsStale(metadata.LastUpdated, this.StaleMinutes)
);
}
/// <summary>Get the cached data to send to the frontend, creating it if needed.</summary>
/// <param name="metadata">The mod list cache metadata.</param>
private async Task<(string? FetchUri, ModModel[]? OverrideData)> FetchDataAsync(Cached<CompatibilityListMetadata> metadata)
{
const string cacheKey = "mod-list";
// fetch file if still fresh
StoredFileInfo? fileInfo = this.LastCacheUpdate >= metadata.LastRequested
? await this.Storage.GetAsync(cacheKey, false)
: null;
// recreate cache file if needed
if (fileInfo?.Success != true)
{
var mods = this.Cache
.GetMods()
.Select(mod => new ModModel(mod.Data))
.OrderBy(p => PathUtilities.CreateSlug(p.Name)?.ToLower() ?? ""); // ignore case, spaces, and special characters when sorting
await this.Storage.SaveToJsonAsync(cacheKey, mods);
this.LastCacheUpdate = DateTimeOffset.UtcNow;
fileInfo = await this.Storage.GetAsync(cacheKey, false);
}
// get info
string? fetchUri = fileInfo.FetchUri;
ModModel[]? overrideData = fileInfo.FetchUri is null && fileInfo.FetchedData != null
? JsonConvert.DeserializeObject<ModModel[]>(fileInfo.FetchedData)
: null;
return (fetchUri, overrideData);
}
}
@@ -13,6 +13,14 @@ internal interface IStorageProvider
/// <returns>Returns metadata about the save attempt.</returns>
Task<UploadResult> SaveAsync(string id, string content, string contentType, bool compress = true);
/// <summary>Serialize a data model to JSON and save it to storage.</summary>
/// <typeparam name="T">The data type to save.</typeparam>
/// <param name="id">The ID with which to save the result.</param>
/// <param name="content">The content to serialize and upload.</param>
/// <param name="compress">Whether to gzip the text.</param>
/// <returns>Returns metadata about the save attempt.</returns>
Task<UploadResult> SaveToJsonAsync<T>(string id, T content, bool compress = true);
/// <summary>Fetch raw text from storage.</summary>
/// <param name="id">The storage ID used to upload the file.</param>
/// <param name="forceRenew">Whether to reset the file expiry.</param>
@@ -8,6 +8,7 @@ using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
using Azure.Storage.Sas;
using Microsoft.Extensions.Options;
using Newtonsoft.Json;
using StardewModdingAPI.Web.Framework.Clients.Pastebin;
using StardewModdingAPI.Web.Framework.Compression;
using StardewModdingAPI.Web.Framework.ConfigModels;
@@ -38,6 +39,12 @@ internal class StorageProvider : IStorageProvider
/// <inheritdoc cref="ApiClientsConfig.AzureBlobTempExpiryAutoRenewalDays"/>
private int AutoRenewalDays => this.ClientsConfig.AzureBlobTempExpiryAutoRenewalDays;
/// <summary>The JSON serializer settings to use when saving JSON data to storage.</summary>
private JsonSerializerSettings DefaultJsonSettings = new()
{
NullValueHandling = NullValueHandling.Ignore
};
/*********
** Public methods
@@ -84,13 +91,19 @@ internal class StorageProvider : IStorageProvider
}
}
/// <inheritdoc />
public Task<UploadResult> SaveToJsonAsync<T>(string id, T content, bool compress = true)
{
string json = JsonConvert.SerializeObject(content, this.DefaultJsonSettings);
return this.SaveAsync(id, json, "application/json", compress);
}
/// <inheritdoc />
public async Task<StoredFileInfo> GetAsync(string id, bool forceRenew, bool forceDownloadContent = false)
{
// fetch from blob storage
bool isBlobStorage = id.StartsWith("parsed-")
? Guid.TryParseExact(id.Substring("parsed-".Length), "N", out _)
: Guid.TryParseExact(id, "N", out _);
bool isBlobStorage = id.StartsWith("parsed-") || id == "mod-list" || Guid.TryParseExact(id, "N", out _);
if (isBlobStorage)
{
// Azure Blob storage
+11 -6
View File
@@ -10,8 +10,11 @@ public class ModListModel
/*********
** Accessors
*********/
/// <summary>The mods to display.</summary>
public ModModel[] Mods { get; }
/// <summary>The URI from which to fetch the mod data.</summary>
public string? FetchUri { get; }
/// <summary>The mods to display instead of the <see cref="FetchUri"/>, if set.</summary>
public ModModel[]? Mods { get; }
/// <summary>When the data was last updated.</summary>
public DateTimeOffset LastUpdated { get; }
@@ -20,19 +23,21 @@ public class ModListModel
public bool IsStale { get; }
/// <summary>Whether the mod metadata is available.</summary>
public bool HasData => this.Mods.Any();
public bool HasData => this.FetchUri != null || this.Mods?.Length > 0;
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="mods">The mods to display.</param>
/// <param name="fetchUri">The URI from which to fetch the mod data.</param>
/// <param name="mods">The mods to display instead of the <paramref name="fetchUri"/>, if set.</param>
/// <param name="lastUpdated">When the data was last updated.</param>
/// <param name="isStale">Whether the data hasn't been updated in a while.</param>
public ModListModel(IEnumerable<ModModel> mods, DateTimeOffset lastUpdated, bool isStale)
public ModListModel(string? fetchUri, IEnumerable<ModModel>? mods, DateTimeOffset lastUpdated, bool isStale)
{
this.Mods = mods.ToArray();
this.FetchUri = fetchUri;
this.Mods = mods?.ToArray();
this.LastUpdated = lastUpdated;
this.IsStale = isStale;
}
+10 -10
View File
@@ -19,13 +19,13 @@ public class ModModel
public string? Name { get; }
/// <summary>The mod's alternative names, if any.</summary>
public string AlternateNames { get; }
public string? AlternateNames { get; }
/// <summary>The mod author's name.</summary>
public string? Author { get; }
/// <summary>The mod author's alternative names, if any.</summary>
public string AlternateAuthors { get; }
public string? AlternateAuthors { get; }
/// <summary>The GitHub repo, if any.</summary>
public string? GitHubRepo { get; }
@@ -33,14 +33,14 @@ public class ModModel
/// <summary>The URL to the mod's source code, if any.</summary>
public string? SourceUrl { get; }
/// <summary>The compatibility status for the stable version of the game.</summary>
public ModCompatibilityModel Compatibility { get; }
/// <summary>The compatibility status for the stable version of the game, or <c>null</c> if it's compatible with no notes.</summary>
public ModCompatibilityModel? Compatibility { get; }
/// <summary>Links to the available mod pages.</summary>
public ModLinkModel[] ModPages { get; }
/// <summary>The human-readable warnings for players about this mod.</summary>
public string[] Warnings { get; }
public string[]? Warnings { get; }
/// <summary>Special notes intended for developers who maintain unofficial updates or submit pull requests.</summary>
public string? DevNote { get; }
@@ -66,7 +66,7 @@ public class ModModel
/// <param name="devNote">Special notes intended for developers who maintain unofficial updates or submit pull requests.</param>
/// <param name="slug">A unique identifier for the mod that can be used in an anchor URL.</param>
[JsonConstructor]
public ModModel(string[] id, string? name, string alternateNames, string author, string alternateAuthors, string gitHubRepo, string sourceUrl, ModCompatibilityModel compatibility, ModLinkModel[] modPages, string[] warnings, string devNote, string slug)
public ModModel(string[] id, string? name, string? alternateNames, string? author, string? alternateAuthors, string? gitHubRepo, string? sourceUrl, ModCompatibilityModel? compatibility, ModLinkModel[] modPages, string[]? warnings, string? devNote, string? slug)
{
this.Id = id;
this.Name = name;
@@ -89,14 +89,14 @@ public class ModModel
// basic info
this.Id = entry.ID.ToArray();
this.Name = entry.Name.FirstOrDefault();
this.AlternateNames = string.Join(", ", entry.Name.Skip(1).ToArray());
this.AlternateNames = entry.Name.Length > 1 ? string.Join(", ", entry.Name.Skip(1)) : null;
this.Author = entry.Author.FirstOrDefault();
this.AlternateAuthors = string.Join(", ", entry.Author.Skip(1).ToArray());
this.AlternateAuthors = entry.Author.Length > 1 ? string.Join(", ", entry.Author.Skip(1)) : null;
this.GitHubRepo = entry.GitHubRepo;
this.SourceUrl = this.GetSourceUrl(entry);
this.Compatibility = new ModCompatibilityModel(entry.Compatibility);
this.Compatibility = !entry.Compatibility.IsDefault() ? new ModCompatibilityModel(entry.Compatibility) : null;
this.ModPages = this.GetModPageUrls(entry).ToArray();
this.Warnings = entry.Warnings;
this.Warnings = entry.Warnings.Length > 1 ? entry.Warnings : null;
this.DevNote = entry.DevNote;
this.Slug = entry.Anchor;
}
+25 -13
View File
@@ -8,23 +8,24 @@
TimeSpan staleAge = DateTimeOffset.UtcNow - Model.LastUpdated;
}
@section Head {
<!-- jQuery -->
@* jQuery *@
<script src="@Url.ContentWithCacheBust("~/Content/lib/jquery/dist/jquery.min.js")"></script>
<!-- jQuery tablesorter -->
@* jQuery tablesorter *@
<script src="@Url.ContentWithCacheBust("~/Content/lib/tablesorter/dist/js/jquery.tablesorter.min.js")"></script>
<!-- Vue -->
@* Vue *@
<script src="@Url.ContentWithCacheBust("~/Content/lib/vue/dist/vue.min.js")"></script>
<!-- page CSS/JS -->
@* page CSS/JS *@
<link rel="stylesheet" href="@Url.ContentWithCacheBust("~/Content/css/mods.css")" />
<script src="@Url.ContentWithCacheBust("~/Content/js/mods.js")"></script>
<script>
$(function() {
var data = @(this.ForJson(Model.Mods));
smapi.modList(data);
smapi.modList({
fetchUri: @this.ForJson(this.Model.FetchUri),
fetchedData: @this.ForJson(this.Model.Mods)
});
});
</script>
}
@@ -35,17 +36,28 @@
}
else
{
@* non-JavaScript error *@
<noscript>
<div class="banner error">
<strong>This page requires JavaScript.</strong><br />
This pages uses JavaScript to render the compatibility list. To view the list, please enable JavaScript or <a href="https://github.com/Pathoschild/SmapiCompatibilityList">view the raw data</a>.
</div>
</noscript>
@if (Model.IsStale)
{
<div class="error">Showing data from @staleAge.Humanize(maxUnit: TimeUnit.Hour, minUnit: TimeUnit.Minute) ago. (Couldn't fetch newer data; the GitHub API may be offline.)</div>
}
<div id="app">
<div id="intro">
<p>This page shows all known SMAPI mods and (incompatible) content packs, whether they work with the latest versions of Stardew Valley and SMAPI, and how to fix them if not. If a mod doesn't work after following the instructions below, check <a href="https://stardewvalleywiki.com/Modding:Player_Guide/Troubleshooting">the troubleshooting guide</a> or <a href="https://stardewvalleywiki.com/Modding:Help">ask for help</a>.</p>
<div id="intro">
<p>This page shows all known SMAPI mods and (incompatible) content packs, whether they work with the latest versions of Stardew Valley and SMAPI, and how to fix them if not. If a mod doesn't work after following the instructions below, check <a href="https://stardewvalleywiki.com/Modding:Player_Guide/Troubleshooting">the troubleshooting guide</a> or <a href="https://stardewvalleywiki.com/Modding:Help">ask for help</a>.</p>
<p>The list is updated regularly (<a href="https://github.com/Pathoschild/SmapiCompatibilityList#readme">you can help update it</a>!). It doesn't include XNB mods (see <a href="https://stardewvalleywiki.com/Modding:Using_XNB_mods"><em>using XNB mods</em> on the wiki</a> instead) or compatible content packs.</p>
</div>
<p>The list is updated regularly (<a href="https://github.com/Pathoschild/SmapiCompatibilityList#readme">you can help update it</a>!). It doesn't include XNB mods (see <a href="https://stardewvalleywiki.com/Modding:Using_XNB_mods"><em>using XNB mods</em> on the wiki</a> instead) or compatible content packs.</p>
</div>
<div v-cloak id="app">
@* loading indicator *@
<div v-if="false" class="loading-indicator"><div></div><div></div><div></div><div></div></div>
<div id="options">
<div>
@@ -97,7 +109,7 @@ else
</td>
<td>
<div v-html="mod.Compatibility.Summary"></div>
<div v-for="(warning, i) in mod.Warnings">⚠ {{warning}}</div>
<div v-if="mod.Warnings" v-for="(warning, i) in mod.Warnings">⚠ {{warning}}</div>
</td>
<td class="mod-broke-in" v-html="mod.Compatibility.BrokeIn" v-show="showAdvanced"></td>
<td v-show="showAdvanced">
+33 -23
View File
@@ -1,8 +1,8 @@
var smapi = smapi || {};
var app;
smapi.modList = function (mods) {
smapi.modList = async function (state) {
// init data
var defaultStats = {
const defaultStats = {
total: 0,
compatible: 0,
workaround: 0,
@@ -14,8 +14,8 @@ smapi.modList = function (mods) {
percentBroken: 0,
percentObsolete: 0
};
var data = {
mods: mods,
const data = {
mods: [],
showAdvanced: false,
visibleMainStats: $.extend({}, defaultStats),
filters: {
@@ -51,6 +51,12 @@ smapi.modList = function (mods) {
search: ""
};
// fetch data
if (state.fetchUri)
data.mods = await $.getJSON(state.fetchUri);
else
data.mods = state.fetchedData ?? [];
// init filters
Object.entries(data.filters).forEach(([groupKey, filterGroup]) => {
filterGroup.label = filterGroup.label || groupKey;
@@ -61,12 +67,16 @@ smapi.modList = function (mods) {
});
// init mods
for (var i = 0; i < data.mods.length; i++) {
var mod = mods[i];
for (let i = 0; i < data.mods.length; i++) {
const mod = data.mods[i];
// set initial visibility
mod.Visible = true;
// set default compatibility
if (!mod.Compatibility)
mod.Compatibility = { "Status": "ok", "Summary": "use latest version." };
// concatenate searchable text
mod.SearchableText = [mod.Name, mod.AlternateNames, mod.Author, mod.AlternateAuthors, mod.Compatibility.Summary, mod.BrokeIn];
if (mod.Compatibility.UnofficialVersion)
@@ -75,18 +85,18 @@ smapi.modList = function (mods) {
}
// init app
var methods = {
const methods = {
/**
* Update the visibility of all mods based on the current search text and filters.
*/
applyFilters: function () {
// get search terms
var words = data.search.toLowerCase().split(" ");
const words = data.search.toLowerCase().split(" ");
// apply criteria
var stats = data.visibleMainStats = $.extend({}, defaultStats);
for (var i = 0; i < data.mods.length; i++) {
var mod = data.mods[i];
const stats = data.visibleMainStats = $.extend({}, defaultStats);
for (let i = 0; i < data.mods.length; i++) {
const mod = data.mods[i];
mod.Visible = true;
// check filters
@@ -107,7 +117,7 @@ smapi.modList = function (mods) {
* Fix the window position for the current hash.
*/
fixHashPosition: function () {
var anchor = location.hash.substring(1);
const anchor = location.hash.substring(1);
if (!anchor)
return;
@@ -116,10 +126,10 @@ smapi.modList = function (mods) {
return;
// else jump to mod by ID
for (var i = 0; i < data.mods.length; i++) {
var mod = data.mods[i];
for (let i = 0; i < data.mods.length; i++) {
const mod = data.mods[i];
for (var n = 0; n < mod.Id.length; n++) {
for (let n = 0; n < mod.Id.length; n++) {
if (anchor.localeCompare(mod.Id[n], undefined, { sensitivity: 'accent' }) === 0) {
if (methods.scrollToRow(mod.Slug))
return;
@@ -134,8 +144,8 @@ smapi.modList = function (mods) {
* @returns {boolean} Whether the target was found and scrolled into view.
*/
scrollToRow: function(id) {
var row = $("#" + id);
var target = row.prev().get(0) || row.get(0); // show previous row if possible, to push the actual row below the sticky header
const row = $("#" + id);
const target = row.prev().get(0) || row.get(0); // show previous row if possible, to push the actual row below the sticky header
target?.scrollIntoView();
return !!target;
},
@@ -147,7 +157,7 @@ smapi.modList = function (mods) {
* @returns {boolean} Whether the mod matches the filters.
*/
matchesFilters: function (mod, searchWords) {
var filters = data.filters;
const filters = data.filters;
// check hash
if (location.hash === "#" + mod.Slug)
@@ -160,12 +170,12 @@ smapi.modList = function (mods) {
return false;
// check status
var mainStatus = mod.Compatibility.Status;
const mainStatus = mod.Compatibility.Status;
if (filters.status.value[mainStatus] && !filters.status.value[mainStatus].value)
return false;
// check download sites
var ignoreSites = [];
const ignoreSites = [];
if (!filters.download.value.chucklefish.value)
ignoreSites.push("Chucklefish");
@@ -179,8 +189,8 @@ smapi.modList = function (mods) {
ignoreSites.push("custom");
if (ignoreSites.length) {
var anyLeft = false;
for (var i = 0; i < mod.ModPages.length; i++) {
let anyLeft = false;
for (let i = 0; i < mod.ModPages.length; i++) {
if (ignoreSites.indexOf(mod.ModPages[i].Text) === -1) {
anyLeft = true;
break;
@@ -192,7 +202,7 @@ smapi.modList = function (mods) {
}
// check search terms
for (var w = 0; w < searchWords.length; w++) {
for (let w = 0; w < searchWords.length; w++) {
if (mod.SearchableText.indexOf(searchWords[w]) === -1)
return false;
}