using System.Diagnostics; using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Build.Framework; // ReSharper disable once CheckNamespace namespace Iceshrimp.Build.Tasks; public class RewriteStaticAssetManifest : Microsoft.Build.Utilities.Task { public static void FixupFile(string manifestPath) { var parsed = Parse(manifestPath); var @fixed = Fixup(manifestPath, parsed); Write(manifestPath, @fixed); } [System.ComponentModel.DataAnnotations.Required] public required ITaskItem[] ManifestFiles { get; set; } public override bool Execute() { foreach (var item in ManifestFiles) FixupFile(item.ItemSpec); return true; } private static StaticAssetsManifest Parse(string manifestPath) { using var stream = File.OpenRead(manifestPath); using var reader = new StreamReader(stream); var content = reader.ReadToEnd(); // @formatter:off var result = JsonSerializer.Deserialize(content) ?? throw new InvalidOperationException($"The static resources manifest file '{manifestPath}' could not be deserialized."); // @formatter:on return result; } private static void Write(string manifestPath, StaticAssetsManifest manifest) { File.Delete(manifestPath); using var stream = File.OpenWrite(manifestPath); JsonSerializer.Serialize(stream, manifest, new JsonSerializerOptions { WriteIndented = true }); } private static StaticAssetsManifest Fixup(string manifestPath, StaticAssetsManifest manifest) { // Get a list of constrained routes var brotliRoutes = manifest.Endpoints .Where(p => p.Selectors is [{ Name: "Content-Encoding", Value: "br" }]) .ToDictionary(p => p.Route, p => p.ResponseHeaders .FirstOrDefault(i => i.Name == "Content-Length")); // Remove gzip-compressed versions foreach (var endpoint in manifest.Endpoints.ToArray()) { if (!endpoint.Selectors.Any(p => p is { Name: "Content-Encoding", Value: "gzip" })) continue; manifest.Endpoints.Remove(endpoint); } // Rewrite uncompressed versions to reference brotli-compressed asset instead foreach (var endpoint in manifest.Endpoints.ToArray()) { if (endpoint.Selectors.Count > 0) continue; if (!brotliRoutes.TryGetValue(endpoint.AssetPath, out var len)) continue; if (len is null) throw new Exception($"Couldn't find content-length for route ${endpoint.Route}"); var origLen = endpoint.ResponseHeaders.First(p => p.Name == len.Name); endpoint.Properties.Add(new StaticAssetProperty("Uncompressed-Length", origLen.Value)); endpoint.ResponseHeaders.Remove(origLen); endpoint.ResponseHeaders.Add(len); endpoint.AssetPath += ".br"; } // Remove explicit routes manifest.Endpoints.RemoveAll(p => p.Route.EndsWith(".br")); // Clean up endpoints var path = Path.GetDirectoryName(manifestPath) ?? throw new Exception("Invalid path"); manifest.Endpoints.RemoveAll(p => !File.Exists(Path.Combine(path, "wwwroot", p.AssetPath))); return manifest; } private class StaticAssetsManifest { public int Version { get; set; } public string ManifestType { get; set; } = ""; // ReSharper disable once CollectionNeverUpdated.Local public List Endpoints { get; set; } = []; public bool IsBuildManifest() => string.Equals(ManifestType, "Build", StringComparison.OrdinalIgnoreCase); } /// /// The description of a static asset that was generated during the build process. /// [DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")] public sealed class StaticAssetDescriptor { private string? _route; private string? _assetFile; private List _selectors = []; private List _endpointProperties = []; private List _responseHeaders = []; /// /// The route that the asset is served from. /// public required string Route { get => _route ?? throw new InvalidOperationException("Route is required"); set => _route = value; } /// /// The path to the asset file from the wwwroot folder. /// [JsonPropertyName("AssetFile")] public required string AssetPath { get => _assetFile ?? throw new InvalidOperationException("AssetPath is required"); set => _assetFile = value; } /// /// A list of selectors that are used to discriminate between two or more assets with the same route. /// [JsonPropertyName("Selectors")] public List Selectors { get => _selectors; set => _selectors = value; } /// /// A list of properties that are associated with the endpoint. /// [JsonPropertyName("EndpointProperties")] public List Properties { get => _endpointProperties; set => _endpointProperties = value; } /// /// A list of headers to apply to the response when this resource is served. /// [JsonPropertyName("ResponseHeaders")] public List ResponseHeaders { get => _responseHeaders; set => _responseHeaders = value; } private string GetDebuggerDisplay() { return $"Route: {Route} Path: {AssetPath}"; } } /// /// A static asset selector. Selectors are used to discriminate between two or more assets with the same route. /// /// The name associated to the selector. /// The value associated to the selector and used to match against incoming requests. /// The static server quality associated to this selector. [DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")] public sealed class StaticAssetSelector(string name, string value, string quality) { /// /// The name associated to the selector. /// public string Name { get; } = name; /// /// The value associated to the selector and used to match against incoming requests. /// public string Value { get; } = value; /// /// The static asset server quality associated to this selector. Used to break ties when a request matches multiple values /// with the same degree of specificity. /// public string Quality { get; } = quality; private string GetDebuggerDisplay() => $"Name: {Name} Value: {Value} Quality: {Quality}"; } /// /// A property associated with a static asset. /// [DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")] public sealed class StaticAssetProperty(string name, string value) { /// /// The name of the property. /// public string Name { get; } = name; /// /// The value of the property. /// public string Value { get; } = value; private string GetDebuggerDisplay() => $"Name: {Name} Value:{Value}"; } /// /// A response header to apply to the response when a static asset is served. /// /// The name of the header. /// The value of the header. [DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")] public sealed class StaticAssetResponseHeader(string name, string value) { /// /// The name of the header. /// public string Name { get; } = name; /// /// The value of the header. /// public string Value { get; } = value; private string GetDebuggerDisplay() => $"Name: {Name} Value: {Value}"; } }