File: ServiceWorker\GenerateServiceWorkerAssetsManifest.cs
Web Access
Project: ..\..\..\src\StaticWebAssetsSdk\Tasks\Microsoft.NET.Sdk.StaticWebAssets.Tasks.csproj (Microsoft.NET.Sdk.StaticWebAssets.Tasks)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
#nullable disable
 
using System.Security.Cryptography;
using System.Text.Encodings.Web;
using System.Text.Json;
using Microsoft.Build.Framework;
 
namespace Microsoft.AspNetCore.StaticWebAssets.Tasks;
 
public partial class GenerateServiceWorkerAssetsManifest : Task
{
    private static readonly JsonSerializerOptions ManifestSerializationOptions = new()
    {
        Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
        WriteIndented = true
    };
 
    [Required]
    public ITaskItem[] Assets { get; set; }
 
    public string Version { get; set; }
 
    [Required]
    public string OutputPath { get; set; }
 
    [Output]
    public string CalculatedVersion { get; set; }
 
    public override bool Execute()
    {
        CalculatedVersion = GenerateAssetManifest();
        return !Log.HasLoggedErrors;
    }
 
    private string GenerateAssetManifest()
    {
        var assets = Assets.Select(a => (StaticWebAsset.FromTaskItem(a), a.GetMetadata("AssetUrl"))).ToArray();
        var entries = new ManifestEntry[Assets.Length];
        for (var i = 0; i < Assets.Length; i++)
        {
            var (asset, url) = assets[i];
            var hash = asset.Integrity;
            entries[i] = new ManifestEntry
            {
                Hash = $"sha256-{hash}",
                Url = url,
            };
        }
 
        Array.Sort(entries, (a, b) =>
        {
            var urlComparison = string.Compare(a.Url, b.Url, StringComparison.Ordinal);
            if (urlComparison != 0)
            {
                return urlComparison;
            }
            return string.Compare(a.Hash, b.Hash, StringComparison.Ordinal);
        });
        var version = !string.IsNullOrEmpty(Version) ? Version : ComputeVersion(entries);
 
        var manifest = new ServiceWorkerManifest
        {
            Version = version,
            Assets = entries,
        };
 
        PersistManifest(manifest);
        return version;
    }
 
    private static string ComputeVersion(ManifestEntry[] assets)
    {
        // If a version isn't specified (which is likely the most common case), construct a Version by combining
        // the file names + hashes of all the inputs.
 
        var combinedHash = string.Join(
            Environment.NewLine,
            assets.OrderBy(f => f.Url, StringComparer.Ordinal).Select(f => f.Hash));
 
        var data = Encoding.UTF8.GetBytes(combinedHash);
#if !NET9_0_OR_GREATER
        using var sha256 = SHA256.Create();
        var bytes = sha256.ComputeHash(data);
        var version = Convert.ToBase64String(bytes).Substring(0, 8);
#else
        var bytes = SHA256.HashData(data);
        var version = Convert.ToBase64String(bytes)[..8];
#endif
 
        return version;
    }
 
    private void PersistManifest(ServiceWorkerManifest manifest)
    {
        var data = JsonSerializer.Serialize(manifest, ManifestSerializationOptions);
        var content = $"self.assetsManifest = {data};{Environment.NewLine}";
        var contentHash = ComputeFileHash(content);
        var fileExists = File.Exists(OutputPath);
        var existingManifestHash = fileExists ? ComputeFileHash(File.ReadAllText(OutputPath)) : "";
 
        if (!fileExists)
        {
            Log.LogMessage(MessageImportance.Low, $"Creating manifest with content hash '{contentHash}' because manifest file '{OutputPath}' does not exist.");
            File.WriteAllText(OutputPath, content);
        }
        else if (!string.Equals(contentHash, existingManifestHash, StringComparison.Ordinal))
        {
            Log.LogMessage(MessageImportance.Low, $"Updating manifest because manifest hash '{contentHash}' is different from existing manifest hash '{existingManifestHash}'.");
            File.WriteAllText(OutputPath, content);
        }
        else
        {
            Log.LogMessage(MessageImportance.Low, $"Skipping manifest updated because manifest hash '{contentHash}' has not changed.");
        }
    }
 
    private static string ComputeFileHash(string contents)
    {
        var data = Encoding.UTF8.GetBytes(contents);
#if !NET9_0_OR_GREATER
        using var sha256 = SHA256.Create();
        var bytes = sha256.ComputeHash(data);
#else
        var bytes = SHA256.HashData(data);
#endif
        return Convert.ToBase64String(bytes);
    }
 
    private sealed class ServiceWorkerManifest
    {
        public string Version { get; set; }
        public ManifestEntry[] Assets { get; set; }
    }
 
    private sealed class ManifestEntry
    {
        public string Hash { get; set; }
        public string Url { get; set; }
    }
}