File: DefineStaticWebAssetEndpoints.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 Microsoft.Build.Framework;
using Microsoft.AspNetCore.StaticWebAssets.Tasks.Utils;
using Microsoft.Build.Utilities;
 
namespace Microsoft.AspNetCore.StaticWebAssets.Tasks;
 
public class DefineStaticWebAssetEndpoints : Task
{
    [Required]
    public ITaskItem[] CandidateAssets { get; set; }
 
    public ITaskItem[] ExistingEndpoints { get; set; }
 
    [Required]
    public ITaskItem[] ContentTypeMappings { get; set; }
 
    [Output]
    public ITaskItem[] Endpoints { get; set; }
 
    public override bool Execute()
    {
        var existingEndpointsByAssetFile = CreateEndpointsByAssetFile();
        var contentTypeMappings = CreateAdditionalContentTypeMappings();
        var contentTypeProvider = new ContentTypeProvider(contentTypeMappings);
        var endpoints = new List<StaticWebAssetEndpoint>(CandidateAssets.Length);
 
        Parallel.For(
            0,
            CandidateAssets.Length,
            () => new ParallelWorker(
                endpoints,
                new List<StaticWebAssetEndpoint>(512),
                CandidateAssets,
                existingEndpointsByAssetFile,
                Log,
                contentTypeProvider),
            static (i, loop, state) => state.Process(i, loop),
            static worker => worker.Finally());
 
        Endpoints = StaticWebAssetEndpoint.ToTaskItems(endpoints);
 
        return !Log.HasLoggedErrors;
    }
 
    private ContentTypeMapping[] CreateAdditionalContentTypeMappings()
    {
        if (ContentTypeMappings == null || ContentTypeMappings.Length == 0)
        {
            return [];
        }
        var result = new ContentTypeMapping[ContentTypeMappings.Length];
        for (var i = 0; i < ContentTypeMappings.Length; i++)
        {
            var contentTypeMapping = ContentTypeMappings[i];
            result[i] = ContentTypeMapping.FromTaskItem(contentTypeMapping);
        }
        Array.Sort(result, (x, y) => x.Priority.CompareTo(y.Priority));
        return result;
    }
 
    private Dictionary<string, HashSet<string>> CreateEndpointsByAssetFile()
    {
        if (ExistingEndpoints != null && ExistingEndpoints.Length > 0)
        {
            Dictionary<string, HashSet<string>> existingEndpointsByAssetFile = new(ExistingEndpoints.Length, OSPath.PathComparer);
            var assets = new HashSet<string>(CandidateAssets.Length, OSPath.PathComparer);
            foreach (var asset in CandidateAssets)
            {
                assets.Add(asset.ItemSpec);
            }
 
            for (var i = 0; i < ExistingEndpoints.Length; i++)
            {
                var endpointCandidate = ExistingEndpoints[i];
                var assetFile = endpointCandidate.GetMetadata(nameof(StaticWebAssetEndpoint.AssetFile));
                if (!assets.Contains(assetFile))
                {
                    Log.LogMessage(MessageImportance.Low, $"Removing endpoints for asset '{assetFile}' because it no longer exists.");
                    continue;
                }
 
                if (!existingEndpointsByAssetFile.TryGetValue(assetFile, out var set))
                {
                    set = new HashSet<string>(OSPath.PathComparer);
                    existingEndpointsByAssetFile[assetFile] = set;
                }
 
                // Add the route
                set.Add(endpointCandidate.ItemSpec);
            }
 
            return existingEndpointsByAssetFile;
        }
 
        return null;
    }
 
    private readonly struct ParallelWorker(
        List<StaticWebAssetEndpoint> collectedEndpoints,
        List<StaticWebAssetEndpoint> currentEndpoints,
        ITaskItem[] candidateAssets,
        Dictionary<string, HashSet<string>> existingEndpointsByAssetFile,
        TaskLoggingHelper log,
        ContentTypeProvider contentTypeProvider)
    {
        public List<StaticWebAssetEndpoint> CollectedEndpoints { get; } = collectedEndpoints;
        public List<StaticWebAssetEndpoint> CurrentEndpoints { get; } = currentEndpoints;
        public ITaskItem[] CandidateAssets { get; } = candidateAssets;
        public Dictionary<string, HashSet<string>> ExistingEndpointsByAssetFile { get; } = existingEndpointsByAssetFile;
        public TaskLoggingHelper Log { get; } = log;
        public ContentTypeProvider ContentTypeProvider { get; } = contentTypeProvider;
 
        private readonly List<StaticWebAsset.StaticWebAssetResolvedRoute> _resolvedRoutes = new(2);
 
        private void CreateAnAddEndpoints(
            StaticWebAsset asset,
            string length,
            string lastModified,
            StaticWebAssetGlobMatcher.MatchContext matchContext)
        {
            foreach (var (label, route, values) in _resolvedRoutes)
            {
                var (mimeType, cacheSetting) = ResolveContentType(asset, ContentTypeProvider, matchContext, Log);
                var headers = new StaticWebAssetEndpointResponseHeader[5]
                {
                    new()
                    {
                        Name = "Content-Length",
                        Value = length,
                    },
                    new()
                    {
                        Name = "Content-Type",
                        Value = mimeType,
                    },
                    new()
                    {
                        Name = "ETag",
                        Value = $"\"{asset.Integrity}\"",
                    },
                    new()
                    {
                        Name = "Last-Modified",
                        Value = lastModified,
                    },
                    default
                };
 
                if (values.ContainsKey("fingerprint"))
                {
                    // max-age=31536000 is one year in seconds. immutable means that the asset will never change.
                    // max-age is for browsers that do not support immutable.
                    headers[4] = new() { Name = "Cache-Control", Value = "max-age=31536000, immutable" };
                }
                else
                {
                    // Force revalidation on non-fingerprinted assets. We can be more granular here and have rules based on the content type.
                    // These values can later be changed at runtime by modifying the endpoint. For example, it might be safer to cache images
                    // for a longer period of time than scripts or stylesheets.
                    headers[4] = new() { Name = "Cache-Control", Value = !string.IsNullOrEmpty(cacheSetting) ? cacheSetting : "no-cache" };
                }
 
                var properties = new StaticWebAssetEndpointProperty[values.Count + (values.Count > 0 ? 2 : 1)];
                var i = 0;
                foreach (var value in values)
                {
                    properties[i++] = new StaticWebAssetEndpointProperty { Name = value.Key, Value = value.Value };
                }
 
                if (values.Count > 0)
                {
                    // If an endpoint has values from its route replaced, we add a label to the endpoint so that it can be easily identified.
                    // The combination of label and list of values should be unique.
                    // In this way, we can identify an endpoint resource.fingerprint.ext by its label (for example resource.ext) and its values
                    // (fingerprint).
                    properties[i++] = new StaticWebAssetEndpointProperty { Name = "label", Value = label };
                }
 
                // We append the integrity in the format expected by the browser so that it can be opaque to the runtime.
                // If in the future we change it to sha384 or sha512, the runtime will not need to be updated.
                properties[i++] = new StaticWebAssetEndpointProperty { Name = "integrity", Value = $"sha256-{asset.Integrity}" };
 
                    var finalRoute = asset.IsProject() || asset.IsPackage() ? StaticWebAsset.Normalize(Path.Combine(asset.BasePath, route)) : route;
 
                var endpoint = new StaticWebAssetEndpoint()
                {
                    Route = finalRoute,
                    AssetFile = asset.Identity,
                    EndpointProperties = properties,
                    ResponseHeaders = headers
                };
 
                Log.LogMessage(MessageImportance.Low, $"Adding endpoint {endpoint.Route} for asset {asset.Identity}.");
                CurrentEndpoints.Add(endpoint);
            }
        }
 
        private static (string mimeType, string cache) ResolveContentType(StaticWebAsset asset, ContentTypeProvider contentTypeProvider, StaticWebAssetGlobMatcher.MatchContext matchContext, TaskLoggingHelper log)
        {
            var relativePath = asset.ComputePathWithoutTokens(asset.RelativePath);
            matchContext.SetPathAndReinitialize(relativePath);
 
            var mapping = contentTypeProvider.ResolveContentTypeMapping(matchContext, log);
 
            if (mapping.MimeType != null)
            {
                return (mapping.MimeType, mapping.Cache);
            }
 
            log.LogMessage(MessageImportance.Low, $"No match for {relativePath}. Using default content type 'application/octet-stream'");
 
            return ("application/octet-stream", null);
        }
 
        internal void Finally()
        {
            lock (CollectedEndpoints)
            {
                CollectedEndpoints.AddRange(CurrentEndpoints);
            }
        }
 
        internal ParallelWorker Process(int i, ParallelLoopState _)
        {
            var asset = StaticWebAsset.FromTaskItem(CandidateAssets[i]);
            asset.ComputeRoutes(_resolvedRoutes);
            // We extract these from the metadata because we avoid the conversion to their typed version and then back to string.
            var length = CandidateAssets[i].GetMetadata(nameof(StaticWebAsset.FileLength));
            var lastWriteTime = CandidateAssets[i].GetMetadata(nameof(StaticWebAsset.LastWriteTime));
            var matchContext = StaticWebAssetGlobMatcher.CreateMatchContext();
 
            if (ExistingEndpointsByAssetFile != null && ExistingEndpointsByAssetFile.TryGetValue(asset.Identity, out var set))
            {
                for (var j = _resolvedRoutes.Count - 1; j >= 0; j--)
                {
                    var (_, route, _) = _resolvedRoutes[j];
                    // StaticWebAssets has this behavior where the base path for an asset only gets applied if the asset comes from a
                    // package or a referenced project and ignored if it comes from the current project.
                    // When we define the endpoint, we apply the path to the asset as if it was coming from the current project.
                    // If the endpoint is then passed to a referencing project or packaged into a nuget package, the path will be
                    // adjusted at that time.
                    var finalRoute = asset.IsProject() || asset.IsPackage() ? StaticWebAsset.Normalize(Path.Combine(asset.BasePath, route)) : route;
 
                    // Check if the endpoint we are about to define already exists. This can happen during publish as assets defined
                    // during the build will have already defined endpoints and we only want to add new ones.
                    if (set.Contains(finalRoute))
                    {
                        Log.LogMessage(MessageImportance.Low, $"Skipping asset {asset.Identity} because an endpoint for it already exists at {route}.");
                        _resolvedRoutes.RemoveAt(j);
                    }
                }
            }
 
            CreateAnAddEndpoints(asset, length, lastWriteTime, matchContext);
 
            return this;
        }
    }
}