File: GenerateWasmBootJson.cs
Web Access
Project: src\src\tasks\Microsoft.NET.Sdk.WebAssembly.Pack.Tasks\Microsoft.NET.Sdk.WebAssembly.Pack.Tasks.csproj (Microsoft.NET.Sdk.WebAssembly.Pack.Tasks)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.Serialization;
using System.Runtime.Serialization.Json;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Xml.Linq;
using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;
using ResourceHashesByNameDictionary = System.Collections.Generic.Dictionary<string, string>;
 
namespace Microsoft.NET.Sdk.WebAssembly;
 
public class GenerateWasmBootJson : Task
{
    private static readonly string[] jiterpreterOptions = [
        "jiterpreter-traces-enabled",
        "jiterpreter-interp-entry-enabled",
        "jiterpreter-jit-call-enabled"
    ];
 
    [Required]
    public string AssemblyPath { get; set; }
 
    [Required]
    public ITaskItem[] Resources { get; set; }
 
    [Required]
    public bool DebugBuild { get; set; }
 
    public string DebugLevel { get; set; }
 
    [Required]
    public bool LinkerEnabled { get; set; }
 
    [Required]
    public bool CacheBootResources { get; set; }
 
    public bool LoadFullICUData { get; set; }
 
    public bool IsHybridGlobalization { get; set; }
 
    public bool LoadCustomIcuData { get; set; }
 
    public string InvariantGlobalization { get; set; }
 
    public ITaskItem[] ConfigurationFiles { get; set; }
 
    public ITaskItem[] Extensions { get; set; }
 
    public string StartupMemoryCache { get; set; }
 
    public string Jiterpreter { get; set; }
 
    public string RuntimeOptions { get; set; }
 
    [Required]
    public string TargetFrameworkVersion { get; set; }
 
    public ITaskItem[] ModuleAfterConfigLoaded { get; set; }
 
    public ITaskItem[] ModuleAfterRuntimeReady { get; set; }
 
    [Required]
    public string OutputPath { get; set; }
 
    public ITaskItem[] LazyLoadedAssemblies { get; set; }
 
    public bool IsPublish { get; set; }
 
    public bool IsAot { get; set; }
 
    public bool IsMultiThreaded { get; set; }
 
    public override bool Execute()
    {
        using var fileStream = File.Create(OutputPath);
        var entryAssemblyName = AssemblyName.GetAssemblyName(AssemblyPath).Name;
 
        try
        {
            WriteBootJson(fileStream, entryAssemblyName);
        }
        catch (Exception ex)
        {
            Log.LogError(ex.ToString());
        }
 
        return !Log.HasLoggedErrors;
    }
 
    // Internal for tests
    public void WriteBootJson(Stream output, string entryAssemblyName)
    {
        var helper = new BootJsonBuilderHelper(Log, DebugLevel, IsMultiThreaded, IsPublish);
 
        var result = new BootJsonData
        {
            resources = new ResourcesData(),
            startupMemoryCache = helper.ParseOptionalBool(StartupMemoryCache),
        };
 
        if (IsTargeting80OrLater())
        {
            result.mainAssemblyName = entryAssemblyName;
            result.globalizationMode = GetGlobalizationMode().ToString().ToLowerInvariant();
 
            if (CacheBootResources)
                result.cacheBootResources = CacheBootResources;
 
            if (LinkerEnabled)
                result.linkerEnabled = LinkerEnabled;
        }
        else
        {
            result.cacheBootResources = CacheBootResources;
            result.linkerEnabled = LinkerEnabled;
            result.config = new();
            result.debugBuild = DebugBuild;
            result.entryAssembly = entryAssemblyName;
            result.icuDataMode = GetGlobalizationMode();
        }
 
        if (!string.IsNullOrEmpty(RuntimeOptions))
        {
            string[] runtimeOptions = RuntimeOptions.Split(' ');
            result.runtimeOptions = runtimeOptions;
        }
 
        bool? jiterpreter = helper.ParseOptionalBool(Jiterpreter);
        if (jiterpreter != null)
        {
            var runtimeOptions = result.runtimeOptions?.ToHashSet() ?? new HashSet<string>(3);
            foreach (var jiterpreterOption in jiterpreterOptions)
            {
                if (jiterpreter == true)
                {
                    if (!runtimeOptions.Contains($"--no-{jiterpreterOption}"))
                        runtimeOptions.Add($"--{jiterpreterOption}");
                }
                else
                {
                    if (!runtimeOptions.Contains($"--{jiterpreterOption}"))
                        runtimeOptions.Add($"--no-{jiterpreterOption}");
                }
            }
 
            result.runtimeOptions = runtimeOptions.ToArray();
        }
 
        string[] moduleAfterConfigLoadedFullPaths = ModuleAfterConfigLoaded?.Select(s => s.GetMetadata("FullPath")).ToArray() ?? Array.Empty<string>();
        string[] moduleAfterRuntimeReadyFullPaths = ModuleAfterRuntimeReady?.Select(s => s.GetMetadata("FullPath")).ToArray() ?? Array.Empty<string>();
 
        // Build a two-level dictionary of the form:
        // - assembly:
        //   - UriPath (e.g., "System.Text.Json.dll")
        //     - ContentHash (e.g., "4548fa2e9cf52986")
        // - runtime:
        //   - UriPath (e.g., "dotnet.js")
        //     - ContentHash (e.g., "3448f339acf512448")
        if (Resources != null)
        {
            var remainingLazyLoadAssemblies = new List<ITaskItem>(LazyLoadedAssemblies ?? Array.Empty<ITaskItem>());
            var resourceData = result.resources;
            foreach (var resource in Resources)
            {
                ResourceHashesByNameDictionary resourceList = null;
 
                string behavior = null;
                var fileName = resource.GetMetadata("FileName");
                var fileExtension = resource.GetMetadata("Extension");
                var assetTraitName = resource.GetMetadata("AssetTraitName");
                var assetTraitValue = resource.GetMetadata("AssetTraitValue");
                var resourceName = Path.GetFileName(resource.GetMetadata("RelativePath"));
 
                if (TryGetLazyLoadedAssembly(resourceName, out var lazyLoad))
                {
                    Log.LogMessage(MessageImportance.Low, "Candidate '{0}' is defined as a lazy loaded assembly.", resource.ItemSpec);
                    remainingLazyLoadAssemblies.Remove(lazyLoad);
                    resourceData.lazyAssembly ??= new ResourceHashesByNameDictionary();
                    resourceList = resourceData.lazyAssembly;
                }
                else if (string.Equals("Culture", assetTraitName, StringComparison.OrdinalIgnoreCase))
                {
                    Log.LogMessage(MessageImportance.Low, "Candidate '{0}' is defined as satellite assembly with culture '{1}'.", resource.ItemSpec, assetTraitValue);
                    resourceData.satelliteResources ??= new Dictionary<string, ResourceHashesByNameDictionary>(StringComparer.OrdinalIgnoreCase);
 
                    if (!IsTargeting80OrLater())
                        resourceName = assetTraitValue + "/" + resourceName;
 
                    if (!resourceData.satelliteResources.TryGetValue(assetTraitValue, out resourceList))
                    {
                        resourceList = new ResourceHashesByNameDictionary();
                        resourceData.satelliteResources.Add(assetTraitValue, resourceList);
                    }
                }
                else if (string.Equals("symbol", assetTraitValue, StringComparison.OrdinalIgnoreCase))
                {
                    if (TryGetLazyLoadedAssembly($"{fileName}.dll", out _) || TryGetLazyLoadedAssembly($"{fileName}{Utils.WebcilInWasmExtension}", out _))
                    {
                        Log.LogMessage(MessageImportance.Low, "Candidate '{0}' is defined as a lazy loaded symbols file.", resource.ItemSpec);
                        resourceData.lazyAssembly ??= new ResourceHashesByNameDictionary();
                        resourceList = resourceData.lazyAssembly;
                    }
                    else
                    {
                        if (IsTargeting90OrLater() && (IsAot || helper.IsCoreAssembly(resourceName)))
                        {
                            Log.LogMessage(MessageImportance.Low, "Candidate '{0}' is defined as core symbols file.", resource.ItemSpec);
                            resourceData.corePdb ??= new ResourceHashesByNameDictionary();
                            resourceList = resourceData.corePdb;
                        }
                        else
                        {
                            Log.LogMessage(MessageImportance.Low, "Candidate '{0}' is defined as symbols file.", resource.ItemSpec);
                            resourceData.pdb ??= new ResourceHashesByNameDictionary();
                            resourceList = resourceData.pdb;
                        }
                    }
                }
                else if (string.Equals("runtime", assetTraitValue, StringComparison.OrdinalIgnoreCase))
                {
                    if (IsTargeting90OrLater() && (IsAot || helper.IsCoreAssembly(resourceName)))
                    {
                        Log.LogMessage(MessageImportance.Low, "Candidate '{0}' is defined as core assembly.", resource.ItemSpec);
                        resourceList = resourceData.coreAssembly;
                    }
                    else
                    {
                        Log.LogMessage(MessageImportance.Low, "Candidate '{0}' is defined as an app assembly.", resource.ItemSpec);
                        resourceList = resourceData.assembly;
                    }
                }
                else if (string.Equals(assetTraitName, "WasmResource", StringComparison.OrdinalIgnoreCase) &&
                        string.Equals(assetTraitValue, "native", StringComparison.OrdinalIgnoreCase))
                {
                    Log.LogMessage(MessageImportance.Low, "Candidate '{0}' is defined as a native application resource.", resource.ItemSpec);
 
                    if (IsTargeting80OrLater())
                    {
                        resourceList = helper.GetNativeResourceTargetInBootConfig(result, resourceName);
                    }
                    else
                    {
                        if (fileName.StartsWith("dotnet", StringComparison.OrdinalIgnoreCase) && string.Equals(fileExtension, ".wasm", StringComparison.OrdinalIgnoreCase))
                        {
                            behavior = "dotnetwasm";
                        }
 
                        resourceList = resourceData.runtime ??= new();
                    }
                }
                else if (string.Equals("JSModule", assetTraitName, StringComparison.OrdinalIgnoreCase) &&
                    string.Equals(assetTraitValue, "JSLibraryModule", StringComparison.OrdinalIgnoreCase))
                {
                    Log.LogMessage(MessageImportance.Low, "Candidate '{0}' is defined as a library initializer resource.", resource.ItemSpec);
 
                    var targetPath = resource.GetMetadata("TargetPath");
                    Debug.Assert(!string.IsNullOrEmpty(targetPath), "Target path for '{0}' must exist.", resource.ItemSpec);
 
                    resourceList = resourceData.libraryInitializers ??= new ResourceHashesByNameDictionary();
                    AddResourceToList(resource, resourceList, targetPath);
 
                    if (IsTargeting80OrLater())
                    {
                        if (moduleAfterConfigLoadedFullPaths.Contains(resource.ItemSpec))
                        {
                            resourceList = resourceData.modulesAfterConfigLoaded ??= new();
                        }
                        else if (moduleAfterRuntimeReadyFullPaths.Contains(resource.ItemSpec))
                        {
                            resourceList = resourceData.modulesAfterRuntimeReady ??= new();
                        }
                        else if (File.Exists(resource.ItemSpec))
                        {
                            string fileContent = File.ReadAllText(resource.ItemSpec);
                            if (fileContent.Contains("onRuntimeConfigLoaded") || fileContent.Contains("beforeStart") || fileContent.Contains("afterStarted"))
                                resourceList = resourceData.modulesAfterConfigLoaded ??= new();
                            else
                                resourceList = resourceData.modulesAfterRuntimeReady ??= new();
                        }
                        else
                        {
                            resourceList = resourceData.modulesAfterConfigLoaded ??= new();
                        }
 
                        string newTargetPath = "../" + targetPath; // This needs condition once WasmRuntimeAssetsLocation is supported in Wasm SDK
                        AddResourceToList(resource, resourceList, newTargetPath);
                    }
 
                    continue;
                }
                else if (string.Equals("WasmResource", assetTraitName, StringComparison.OrdinalIgnoreCase) &&
                            assetTraitValue.StartsWith("extension:", StringComparison.OrdinalIgnoreCase))
                {
                    Log.LogMessage(MessageImportance.Low, "Candidate '{0}' is defined as an extension resource '{1}'.", resource.ItemSpec, assetTraitValue);
                    var extensionName = assetTraitValue.Substring("extension:".Length);
                    resourceData.extensions ??= new();
                    if (!resourceData.extensions.TryGetValue(extensionName, out resourceList))
                    {
                        resourceList = new();
                        resourceData.extensions[extensionName] = resourceList;
                    }
                    var targetPath = resource.GetMetadata("TargetPath");
                    Debug.Assert(!string.IsNullOrEmpty(targetPath), "Target path for '{0}' must exist.", resource.ItemSpec);
                    AddResourceToList(resource, resourceList, targetPath);
                    continue;
                }
                else
                {
                    Log.LogMessage(MessageImportance.Low, "Skipping resource '{0}' since it doesn't belong to a defined category.", resource.ItemSpec);
                    // This should include items such as XML doc files, which do not need to be recorded in the manifest.
                    continue;
                }
 
                if (resourceList != null)
                {
                    AddResourceToList(resource, resourceList, resourceName);
                }
 
                if (!string.IsNullOrEmpty(behavior))
                {
                    resourceData.runtimeAssets ??= new Dictionary<string, AdditionalAsset>();
                    AddToAdditionalResources(resource, resourceData.runtimeAssets, resourceName, behavior);
                }
            }
 
            if (remainingLazyLoadAssemblies.Count > 0)
            {
                const string message = "Unable to find '{0}' to be lazy loaded later. Confirm that project or " +
                    "package references are included and the reference is used in the project.";
 
                Log.LogError(
                    subcategory: null,
                    errorCode: "BLAZORSDK1001",
                    helpKeyword: null,
                    file: null,
                    lineNumber: 0,
                    columnNumber: 0,
                    endLineNumber: 0,
                    endColumnNumber: 0,
                    message: message,
                    string.Join(";", LazyLoadedAssemblies.Select(a => a.ItemSpec)));
 
                return;
            }
        }
 
        if (IsTargeting80OrLater())
        {
            result.debugLevel = helper.GetDebugLevel(result.resources?.pdb?.Count > 0);
        }
 
        if (ConfigurationFiles != null)
        {
            foreach (var configFile in ConfigurationFiles)
            {
                string configUrl = Path.GetFileName(configFile.ItemSpec);
                if (IsTargeting80OrLater())
                {
                    result.appsettings ??= new();
 
                    configUrl = "../" + configUrl; // This needs condition once WasmRuntimeAssetsLocation is supported in Wasm SDK
                    result.appsettings.Add(configUrl);
                }
                else
                {
                    result.config.Add(configUrl);
                }
            }
        }
 
        var jsonOptions = new JsonSerializerOptions()
        {
            DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
            Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
            WriteIndented = true
        };
 
        if (Extensions != null && Extensions.Length > 0)
        {
            result.extensions = new Dictionary<string, Dictionary<string, object>>();
            foreach (var configExtension in Extensions)
            {
                var key = configExtension.GetMetadata("key");
                using var fs = File.OpenRead(configExtension.ItemSpec);
                var config = JsonSerializer.Deserialize<Dictionary<string, object>>(fs, jsonOptions);
                result.extensions[key] = config;
            }
        }
 
        helper.ComputeResourcesHash(result);
        JsonSerializer.Serialize(output, result, jsonOptions);
 
        void AddResourceToList(ITaskItem resource, ResourceHashesByNameDictionary resourceList, string resourceKey)
        {
            if (!resourceList.ContainsKey(resourceKey))
            {
                Log.LogMessage(MessageImportance.Low, "Added resource '{0}' to the manifest.", resource.ItemSpec);
                resourceList.Add(resourceKey, $"sha256-{resource.GetMetadata("FileHash")}");
            }
        }
    }
 
    private GlobalizationMode GetGlobalizationMode()
    {
        if (string.Equals(InvariantGlobalization, "true", StringComparison.OrdinalIgnoreCase))
            return GlobalizationMode.Invariant;
        else if (IsHybridGlobalization)
            return GlobalizationMode.Hybrid;
        else if (LoadFullICUData)
            return GlobalizationMode.All;
        else if (LoadCustomIcuData)
            return GlobalizationMode.Custom;
 
        return GlobalizationMode.Sharded;
    }
 
    private void AddToAdditionalResources(ITaskItem resource, Dictionary<string, AdditionalAsset> additionalResources, string resourceName, string behavior)
    {
        if (!additionalResources.ContainsKey(resourceName))
        {
            Log.LogMessage(MessageImportance.Low, "Added resource '{0}' to the list of additional assets in the manifest.", resource.ItemSpec);
            additionalResources.Add(resourceName, new AdditionalAsset
            {
                hash = $"sha256-{resource.GetMetadata("FileHash")}",
                behavior = behavior
            });
        }
    }
 
    private bool TryGetLazyLoadedAssembly(string fileName, out ITaskItem lazyLoadedAssembly)
    {
        return (lazyLoadedAssembly = LazyLoadedAssemblies?.SingleOrDefault(a => a.ItemSpec == fileName)) != null;
    }
 
    private Version? parsedTargetFrameworkVersion;
    private static readonly Version version80 = new Version(8, 0);
    private static readonly Version version90 = new Version(9, 0);
 
    private bool IsTargeting80OrLater()
        => IsTargetingVersionOrLater(version80);
 
    private bool IsTargeting90OrLater()
        => IsTargetingVersionOrLater(version90);
 
    private bool IsTargetingVersionOrLater(Version version)
    {
        if (parsedTargetFrameworkVersion == null)
        {
            string tfv = TargetFrameworkVersion;
            if (tfv.StartsWith("v"))
                tfv = tfv.Substring(1);
 
            parsedTargetFrameworkVersion = Version.Parse(tfv);
        }
 
        return parsedTargetFrameworkVersion >= version;
    }
}