Cache assemblies and wasm using content hashes (#18859)
This commit is contained in:
parent
e0fe30ce56
commit
4628dfb005
|
|
@ -2,12 +2,14 @@
|
|||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Runtime.Serialization.Json;
|
||||
using System.Text;
|
||||
using Microsoft.Build.Framework;
|
||||
using Microsoft.Build.Utilities;
|
||||
using ResourceHashesByNameDictionary = System.Collections.Generic.Dictionary<string, string>;
|
||||
|
||||
namespace Microsoft.AspNetCore.Blazor.Build
|
||||
{
|
||||
|
|
@ -17,53 +19,107 @@ namespace Microsoft.AspNetCore.Blazor.Build
|
|||
public string AssemblyPath { get; set; }
|
||||
|
||||
[Required]
|
||||
public ITaskItem[] References { get; set; }
|
||||
public ITaskItem[] Resources { get; set; }
|
||||
|
||||
[Required]
|
||||
public bool DebugBuild { get; set; }
|
||||
|
||||
[Required]
|
||||
public bool LinkerEnabled { get; set; }
|
||||
|
||||
[Required]
|
||||
public bool CacheBootResources { get; set; }
|
||||
|
||||
[Required]
|
||||
public string OutputPath { get; set; }
|
||||
|
||||
public override bool Execute()
|
||||
{
|
||||
var entryAssemblyName = AssemblyName.GetAssemblyName(AssemblyPath).Name;
|
||||
var assemblies = References.Select(GetUriPath).OrderBy(c => c, StringComparer.Ordinal).ToArray();
|
||||
|
||||
using var fileStream = File.Create(OutputPath);
|
||||
WriteBootJson(fileStream, entryAssemblyName, assemblies, LinkerEnabled);
|
||||
var entryAssemblyName = AssemblyName.GetAssemblyName(AssemblyPath).Name;
|
||||
|
||||
return true;
|
||||
|
||||
static string GetUriPath(ITaskItem item)
|
||||
try
|
||||
{
|
||||
var outputPath = item.GetMetadata("RelativeOutputPath");
|
||||
if (string.IsNullOrEmpty(outputPath))
|
||||
{
|
||||
outputPath = Path.GetFileName(item.ItemSpec);
|
||||
}
|
||||
|
||||
return outputPath.Replace('\\', '/');
|
||||
WriteBootJson(fileStream, entryAssemblyName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.LogErrorFromException(ex);
|
||||
}
|
||||
|
||||
return !Log.HasLoggedErrors;
|
||||
}
|
||||
|
||||
internal static void WriteBootJson(Stream stream, string entryAssemblyName, string[] assemblies, bool linkerEnabled)
|
||||
// Internal for tests
|
||||
internal void WriteBootJson(Stream output, string entryAssemblyName)
|
||||
{
|
||||
var data = new BootJsonData
|
||||
var result = new BootJsonData
|
||||
{
|
||||
entryAssembly = entryAssemblyName,
|
||||
assemblies = assemblies,
|
||||
linkerEnabled = linkerEnabled,
|
||||
cacheBootResources = CacheBootResources,
|
||||
debugBuild = DebugBuild,
|
||||
linkerEnabled = LinkerEnabled,
|
||||
resources = new Dictionary<ResourceType, ResourceHashesByNameDictionary>()
|
||||
};
|
||||
|
||||
var serializer = new DataContractJsonSerializer(typeof(BootJsonData));
|
||||
serializer.WriteObject(stream, data);
|
||||
// Build a two-level dictionary of the form:
|
||||
// - BootResourceType (e.g., "assembly")
|
||||
// - UriPath (e.g., "System.Text.Json.dll")
|
||||
// - ContentHash (e.g., "4548fa2e9cf52986")
|
||||
if (Resources != null)
|
||||
{
|
||||
foreach (var resource in Resources)
|
||||
{
|
||||
var resourceTypeMetadata = resource.GetMetadata("BootResourceType");
|
||||
if (!Enum.TryParse<ResourceType>(resourceTypeMetadata, out var resourceType))
|
||||
{
|
||||
throw new NotSupportedException($"Unsupported BootResourceType metadata value: {resourceTypeMetadata}");
|
||||
}
|
||||
|
||||
if (!result.resources.TryGetValue(resourceType, out var resourceList))
|
||||
{
|
||||
resourceList = new ResourceHashesByNameDictionary();
|
||||
result.resources.Add(resourceType, resourceList);
|
||||
}
|
||||
|
||||
var resourceFileRelativePath = GetResourceFileRelativePath(resource);
|
||||
if (!resourceList.ContainsKey(resourceFileRelativePath))
|
||||
{
|
||||
resourceList.Add(resourceFileRelativePath, $"sha256-{resource.GetMetadata("FileHash")}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var serializer = new DataContractJsonSerializer(typeof(BootJsonData), new DataContractJsonSerializerSettings
|
||||
{
|
||||
UseSimpleDictionaryFormat = true
|
||||
});
|
||||
|
||||
using var writer = JsonReaderWriterFactory.CreateJsonWriter(output, Encoding.UTF8, ownsStream: false, indent: true);
|
||||
serializer.WriteObject(writer, result);
|
||||
}
|
||||
|
||||
private static string GetResourceFileRelativePath(ITaskItem item)
|
||||
{
|
||||
// The build targets use RelativeOutputPath in the case of satellite assemblies, which
|
||||
// will have relative paths like "fr\\SomeAssembly.resources.dll". If RelativeOutputPath
|
||||
// is specified, we want to use all of it.
|
||||
var outputPath = item.GetMetadata("RelativeOutputPath");
|
||||
|
||||
if (string.IsNullOrEmpty(outputPath))
|
||||
{
|
||||
// If RelativeOutputPath was not specified, we assume the item will be placed at the
|
||||
// root of whatever directory is used for its resource type (e.g., assemblies go in _bin)
|
||||
outputPath = Path.GetFileName(item.ItemSpec);
|
||||
}
|
||||
|
||||
return outputPath.Replace('\\', '/');
|
||||
}
|
||||
|
||||
#pragma warning disable IDE1006 // Naming Styles
|
||||
/// <summary>
|
||||
/// Defines the structure of a Blazor boot JSON file
|
||||
/// </summary>
|
||||
#pragma warning disable IDE1006 // Naming Styles
|
||||
public class BootJsonData
|
||||
{
|
||||
/// <summary>
|
||||
|
|
@ -72,15 +128,39 @@ namespace Microsoft.AspNetCore.Blazor.Build
|
|||
public string entryAssembly { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the closure of assemblies to be loaded by Blazor WASM. This includes the application entry assembly.
|
||||
/// Gets the set of resources needed to boot the application. This includes the transitive
|
||||
/// closure of .NET assemblies (including the entrypoint assembly), the dotnet.wasm file,
|
||||
/// and any PDBs to be loaded.
|
||||
///
|
||||
/// Within <see cref="ResourceHashesByNameDictionary"/>, dictionary keys are resource names,
|
||||
/// and values are SHA-256 hashes formatted in prefixed base-64 style (e.g., 'sha256-abcdefg...')
|
||||
/// as used for subresource integrity checking.
|
||||
/// </summary>
|
||||
public string[] assemblies { get; set; }
|
||||
public Dictionary<ResourceType, ResourceHashesByNameDictionary> resources { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value that determines whether to enable caching of the <see cref="resources"/>
|
||||
/// inside a CacheStorage instance within the browser.
|
||||
/// </summary>
|
||||
public bool cacheBootResources { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value that determines if this is a debug build.
|
||||
/// </summary>
|
||||
public bool debugBuild { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value that determines if the linker is enabled.
|
||||
/// </summary>
|
||||
public bool linkerEnabled { get; set; }
|
||||
}
|
||||
|
||||
public enum ResourceType
|
||||
{
|
||||
assembly,
|
||||
pdb,
|
||||
wasm
|
||||
}
|
||||
#pragma warning restore IDE1006 // Naming Styles
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,26 +52,6 @@
|
|||
<Target
|
||||
Name="PrepareBlazorOutputs"
|
||||
DependsOnTargets="_ResolveBlazorInputs;_ResolveBlazorOutputs;_GenerateBlazorBootJson">
|
||||
|
||||
<ItemGroup>
|
||||
<MonoWasmFile Include="$(DotNetWebAssemblyRuntimePath)*" />
|
||||
<BlazorJSFile Include="$(BlazorJSPath)" />
|
||||
<BlazorJSFile Include="$(BlazorJSMapPath)" Condition="Exists('$(BlazorJSMapPath)')" />
|
||||
|
||||
<BlazorOutputWithTargetPath Include="@(MonoWasmFile)">
|
||||
<TargetOutputPath>$(BlazorRuntimeWasmOutputPath)%(FileName)%(Extension)</TargetOutputPath>
|
||||
</BlazorOutputWithTargetPath>
|
||||
<BlazorOutputWithTargetPath Include="@(BlazorJSFile)">
|
||||
<TargetOutputPath>$(BaseBlazorRuntimeOutputPath)%(FileName)%(Extension)</TargetOutputPath>
|
||||
</BlazorOutputWithTargetPath>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Label="Static content supplied by NuGet packages">
|
||||
<_BlazorPackageContentOutput Include="@(BlazorPackageContentFile)" Condition="%(SourcePackage) != ''">
|
||||
<TargetOutputPath>$(BaseBlazorPackageContentOutputPath)%(SourcePackage)\%(RecursiveDir)\%(Filename)%(Extension)</TargetOutputPath>
|
||||
</_BlazorPackageContentOutput>
|
||||
<BlazorOutputWithTargetPath Include="@(_BlazorPackageContentOutput)" />
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
|
||||
<Target Name="_ResolveBlazorInputs" DependsOnTargets="ResolveReferences;ResolveRuntimePackAssets">
|
||||
|
|
@ -128,6 +108,11 @@
|
|||
Message="Unrecongnized value for BlazorLinkOnBuild: '$(BlazorLinkOnBuild)'. Valid values are 'true' or 'false'."
|
||||
Condition="'$(BlazorLinkOnBuild)' != 'true' AND '$(BlazorLinkOnBuild)' != 'false'" />
|
||||
|
||||
<!--
|
||||
These are the items calculated as the closure of the runtime assemblies, either by calling the linker
|
||||
or by calling our custom ResolveBlazorRuntimeDependencies task if the linker was disabled. Other than
|
||||
satellite assemblies, this should include all assemblies needed to run the application.
|
||||
-->
|
||||
<ItemGroup>
|
||||
<!--
|
||||
ReferenceCopyLocalPaths includes all files that are part of the build out with CopyLocalLockFileAssemblies on.
|
||||
|
|
@ -146,6 +131,49 @@
|
|||
<BlazorRuntimeFile>true</BlazorRuntimeFile>
|
||||
<TargetOutputPath>$(BlazorRuntimeBinOutputPath)%(FileName)%(Extension)</TargetOutputPath>
|
||||
<RelativeOutputPath>%(FileName)%(Extension)</RelativeOutputPath>
|
||||
</BlazorOutputWithTargetPath>
|
||||
</ItemGroup>
|
||||
|
||||
<!--
|
||||
We need to know at build time (not publish time) whether or not to include pdbs in the
|
||||
blazor.boot.json file, so this is controlled by the BlazorEnableDebugging flag, whose
|
||||
default value is determined by the build configuration.
|
||||
-->
|
||||
<ItemGroup Condition="'$(BlazorEnableDebugging)' != 'true'">
|
||||
<BlazorOutputWithTargetPath Remove="@(BlazorOutputWithTargetPath)" Condition="'%(Extension)' == '.pdb'" />
|
||||
</ItemGroup>
|
||||
|
||||
<!--
|
||||
The following itemgroup attempts to extend the set to include satellite assemblies.
|
||||
The mechanism behind this (or whether it's correct) is a bit unclear so
|
||||
https://github.com/dotnet/aspnetcore/issues/18951 tracks the need for follow-up.
|
||||
-->
|
||||
<ItemGroup>
|
||||
<!--
|
||||
ReferenceCopyLocalPaths includes all files that are part of the build out with CopyLocalLockFileAssemblies on.
|
||||
Remove assemblies that are inputs to calculating the assembly closure. Instead use the resolved outputs, since it is the minimal set.
|
||||
-->
|
||||
<_BlazorCopyLocalPaths Include="@(ReferenceCopyLocalPaths)" Condition="'%(Extension)' == '.dll'" />
|
||||
<_BlazorCopyLocalPaths Remove="@(_BlazorManagedRuntimeAssemby)" Condition="'%(Extension)' == '.dll'" />
|
||||
|
||||
<BlazorOutputWithTargetPath Include="@(_BlazorCopyLocalPaths)">
|
||||
<BlazorRuntimeFile>true</BlazorRuntimeFile>
|
||||
<TargetOutputPath>$(BlazorRuntimeBinOutputPath)%(_BlazorCopyLocalPaths.DestinationSubDirectory)%(FileName)%(Extension)</TargetOutputPath>
|
||||
<RelativeOutputPath>%(_BlazorCopyLocalPaths.DestinationSubDirectory)%(FileName)%(Extension)</RelativeOutputPath>
|
||||
</BlazorOutputWithTargetPath>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<MonoWasmFile Include="$(DotNetWebAssemblyRuntimePath)*" />
|
||||
<BlazorJSFile Include="$(BlazorJSPath)" />
|
||||
<BlazorJSFile Include="$(BlazorJSMapPath)" Condition="Exists('$(BlazorJSMapPath)')" />
|
||||
|
||||
<BlazorOutputWithTargetPath Include="@(MonoWasmFile)">
|
||||
<TargetOutputPath>$(BlazorRuntimeWasmOutputPath)%(FileName)%(Extension)</TargetOutputPath>
|
||||
<BlazorRuntimeFile>true</BlazorRuntimeFile>
|
||||
</BlazorOutputWithTargetPath>
|
||||
<BlazorOutputWithTargetPath Include="@(BlazorJSFile)">
|
||||
<TargetOutputPath>$(BaseBlazorRuntimeOutputPath)%(FileName)%(Extension)</TargetOutputPath>
|
||||
</BlazorOutputWithTargetPath>
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
|
|
@ -267,7 +295,7 @@
|
|||
|
||||
<ItemGroup>
|
||||
<_LinkerResult Include="$(BlazorIntermediateLinkerOutputPath)*.dll" />
|
||||
<_LinkerResult Include="$(BlazorIntermediateLinkerOutputPath)*.pdb" Condition="'$(BlazorEnableDebugging)' == 'true'" />
|
||||
<_LinkerResult Include="$(BlazorIntermediateLinkerOutputPath)*.pdb" />
|
||||
</ItemGroup>
|
||||
|
||||
<WriteLinesToFile File="$(_BlazorLinkerOutputCache)" Lines="@(_LinkerResult)" Overwrite="true" />
|
||||
|
|
@ -318,13 +346,27 @@
|
|||
Inputs="$(MSBuildAllProjects);@(BlazorOutputWithTargetPath)"
|
||||
Outputs="$(BlazorBootJsonIntermediateOutputPath)">
|
||||
<ItemGroup>
|
||||
<_BlazorRuntimeFile Include="@(BlazorOutputWithTargetPath->WithMetadataValue('BlazorRuntimeFile', 'true'))" />
|
||||
<_BlazorBootResource Include="@(BlazorOutputWithTargetPath->WithMetadataValue('BlazorRuntimeFile', 'true'))" />
|
||||
<_BlazorBootResource BootResourceType="assembly" Condition="'%(Extension)' == '.dll'" />
|
||||
<_BlazorBootResource BootResourceType="pdb" Condition="'%(Extension)' == '.pdb'" />
|
||||
<_BlazorBootResource BootResourceType="wasm" Condition="'%(Extension)' == '.wasm'" />
|
||||
</ItemGroup>
|
||||
|
||||
<GetFileHash Files="@(_BlazorBootResource->HasMetadata('BootResourceType'))" Algorithm="SHA256" HashEncoding="base64">
|
||||
<Output TaskParameter="Items" ItemName="_BlazorBootResourceWithHash" />
|
||||
</GetFileHash>
|
||||
|
||||
<PropertyGroup>
|
||||
<_IsDebugBuild>false</_IsDebugBuild>
|
||||
<_IsDebugBuild Condition="'$(Configuration)' == 'Debug'">true</_IsDebugBuild>
|
||||
<BlazorCacheBootResources Condition="'$(BlazorCacheBootResources)' == ''">true</BlazorCacheBootResources>
|
||||
</PropertyGroup>
|
||||
<GenerateBlazorBootJson
|
||||
AssemblyPath="@(IntermediateAssembly)"
|
||||
References="@(_BlazorRuntimeFile)"
|
||||
Resources="@(_BlazorBootResourceWithHash)"
|
||||
DebugBuild="$(_IsDebugBuild)"
|
||||
LinkerEnabled="$(BlazorLinkOnBuild)"
|
||||
CacheBootResources="$(BlazorCacheBootResources)"
|
||||
OutputPath="$(BlazorBootJsonIntermediateOutputPath)" />
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
|||
|
|
@ -1,41 +0,0 @@
|
|||
// Copyright (c) .NET Foundation. All rights reserved.
|
||||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Xunit;
|
||||
|
||||
namespace Microsoft.AspNetCore.Blazor.Build
|
||||
{
|
||||
public class BootJsonWriterTest
|
||||
{
|
||||
[Fact]
|
||||
public async Task ProducesJsonReferencingAssemblyAndDependencies()
|
||||
{
|
||||
// Arrange/Act
|
||||
var assemblyReferences = new string[] { "MyApp.EntryPoint.dll", "System.Abc.dll", "MyApp.ClassLib.dll", };
|
||||
using var stream = new MemoryStream();
|
||||
|
||||
// Act
|
||||
GenerateBlazorBootJson.WriteBootJson(
|
||||
stream,
|
||||
"MyApp.Entrypoint.dll",
|
||||
assemblyReferences,
|
||||
linkerEnabled: true);
|
||||
|
||||
// Assert
|
||||
stream.Position = 0;
|
||||
using var parsedContent = await JsonDocument.ParseAsync(stream);
|
||||
var rootElement = parsedContent.RootElement;
|
||||
Assert.Equal("MyApp.Entrypoint.dll", rootElement.GetProperty("entryAssembly").GetString());
|
||||
var assembliesElement = rootElement.GetProperty("assemblies");
|
||||
Assert.Equal(assemblyReferences.Length, assembliesElement.GetArrayLength());
|
||||
for (var i = 0; i < assemblyReferences.Length; i++)
|
||||
{
|
||||
Assert.Equal(assemblyReferences[i], assembliesElement[i].GetString());
|
||||
}
|
||||
Assert.True(rootElement.GetProperty("linkerEnabled").GetBoolean());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
// Copyright (c) .NET Foundation. All rights reserved.
|
||||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||
|
||||
using System.IO;
|
||||
using System.Runtime.Serialization.Json;
|
||||
using Microsoft.Build.Framework;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
using BootJsonData = Microsoft.AspNetCore.Blazor.Build.GenerateBlazorBootJson.BootJsonData;
|
||||
using ResourceType = Microsoft.AspNetCore.Blazor.Build.GenerateBlazorBootJson.ResourceType;
|
||||
|
||||
namespace Microsoft.AspNetCore.Blazor.Build
|
||||
{
|
||||
public class GenerateBlazorBootJsonTest
|
||||
{
|
||||
[Fact]
|
||||
public void GroupsResourcesByType()
|
||||
{
|
||||
// Arrange
|
||||
var taskInstance = new GenerateBlazorBootJson
|
||||
{
|
||||
AssemblyPath = "MyApp.Entrypoint.dll",
|
||||
Resources = new[]
|
||||
{
|
||||
CreateResourceTaskItem(
|
||||
ResourceType.assembly,
|
||||
itemSpec: Path.Combine("dir", "My.Assembly1.ext"), // Can specify item spec
|
||||
relativeOutputPath: null,
|
||||
fileHash: "abcdefghikjlmnopqrstuvwxyz"),
|
||||
|
||||
CreateResourceTaskItem(
|
||||
ResourceType.assembly,
|
||||
itemSpec: "Ignored",
|
||||
relativeOutputPath: Path.Combine("dir", "My.Assembly2.ext2"), // Can specify relative path
|
||||
fileHash: "012345678901234567890123456789"),
|
||||
|
||||
CreateResourceTaskItem(
|
||||
ResourceType.pdb,
|
||||
itemSpec: "SomePdb.pdb",
|
||||
relativeOutputPath: null,
|
||||
fileHash: "pdbhashpdbhashpdbhash"),
|
||||
|
||||
CreateResourceTaskItem(
|
||||
ResourceType.wasm,
|
||||
itemSpec: "some-wasm-file",
|
||||
relativeOutputPath: null,
|
||||
fileHash: "wasmhashwasmhashwasmhash")
|
||||
}
|
||||
};
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
|
||||
// Act
|
||||
taskInstance.WriteBootJson(stream, "MyEntrypointAssembly");
|
||||
|
||||
// Assert
|
||||
var parsedContent = ParseBootData(stream);
|
||||
Assert.Equal("MyEntrypointAssembly", parsedContent.entryAssembly);
|
||||
Assert.Collection(parsedContent.resources.Keys,
|
||||
resourceListKey =>
|
||||
{
|
||||
var resources = parsedContent.resources[resourceListKey];
|
||||
Assert.Equal(ResourceType.assembly, resourceListKey);
|
||||
Assert.Equal(2, resources.Count);
|
||||
Assert.Equal("sha256-abcdefghikjlmnopqrstuvwxyz", resources["My.Assembly1.ext"]);
|
||||
Assert.Equal("sha256-012345678901234567890123456789", resources["dir/My.Assembly2.ext2"]); // For relative paths, we preserve the whole relative path, but use URL-style separators
|
||||
},
|
||||
resourceListKey =>
|
||||
{
|
||||
var resources = parsedContent.resources[resourceListKey];
|
||||
Assert.Equal(ResourceType.pdb, resourceListKey);
|
||||
Assert.Single(resources);
|
||||
Assert.Equal("sha256-pdbhashpdbhashpdbhash", resources["SomePdb.pdb"]);
|
||||
},
|
||||
resourceListKey =>
|
||||
{
|
||||
var resources = parsedContent.resources[resourceListKey];
|
||||
Assert.Equal(ResourceType.wasm, resourceListKey);
|
||||
Assert.Single(resources);
|
||||
Assert.Equal("sha256-wasmhashwasmhashwasmhash", resources["some-wasm-file"]);
|
||||
});
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void CanSpecifyCacheBootResources(bool flagValue)
|
||||
{
|
||||
// Arrange
|
||||
var taskInstance = new GenerateBlazorBootJson { CacheBootResources = flagValue };
|
||||
using var stream = new MemoryStream();
|
||||
|
||||
// Act
|
||||
taskInstance.WriteBootJson(stream, "MyEntrypointAssembly");
|
||||
|
||||
// Assert
|
||||
var parsedContent = ParseBootData(stream);
|
||||
Assert.Equal(flagValue, parsedContent.cacheBootResources);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void CanSpecifyDebugBuild(bool flagValue)
|
||||
{
|
||||
// Arrange
|
||||
var taskInstance = new GenerateBlazorBootJson { DebugBuild = flagValue };
|
||||
using var stream = new MemoryStream();
|
||||
|
||||
// Act
|
||||
taskInstance.WriteBootJson(stream, "MyEntrypointAssembly");
|
||||
|
||||
// Assert
|
||||
var parsedContent = ParseBootData(stream);
|
||||
Assert.Equal(flagValue, parsedContent.debugBuild);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void CanSpecifyLinkerEnabled(bool flagValue)
|
||||
{
|
||||
// Arrange
|
||||
var taskInstance = new GenerateBlazorBootJson { LinkerEnabled = flagValue };
|
||||
using var stream = new MemoryStream();
|
||||
|
||||
// Act
|
||||
taskInstance.WriteBootJson(stream, "MyEntrypointAssembly");
|
||||
|
||||
// Assert
|
||||
var parsedContent = ParseBootData(stream);
|
||||
Assert.Equal(flagValue, parsedContent.linkerEnabled);
|
||||
}
|
||||
|
||||
private static BootJsonData ParseBootData(Stream stream)
|
||||
{
|
||||
stream.Position = 0;
|
||||
var serializer = new DataContractJsonSerializer(
|
||||
typeof(BootJsonData),
|
||||
new DataContractJsonSerializerSettings { UseSimpleDictionaryFormat = true });
|
||||
return (BootJsonData)serializer.ReadObject(stream);
|
||||
}
|
||||
|
||||
private static ITaskItem CreateResourceTaskItem(ResourceType type, string itemSpec, string relativeOutputPath, string fileHash)
|
||||
{
|
||||
var mock = new Mock<ITaskItem>();
|
||||
mock.Setup(m => m.ItemSpec).Returns(itemSpec);
|
||||
mock.Setup(m => m.GetMetadata("BootResourceType")).Returns(type.ToString());
|
||||
mock.Setup(m => m.GetMetadata("RelativeOutputPath")).Returns(relativeOutputPath);
|
||||
mock.Setup(m => m.GetMetadata("FileHash")).Returns(fileHash);
|
||||
return mock.Object;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
// Copyright (c) .NET Foundation. All rights reserved.
|
||||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace HostedInAspNet.Server
|
||||
{
|
||||
public class RequestLog
|
||||
{
|
||||
private List<string> _requestPaths = new List<string>();
|
||||
|
||||
public IReadOnlyCollection<string> RequestPaths => _requestPaths;
|
||||
|
||||
public void AddRequest(HttpRequest request)
|
||||
{
|
||||
_requestPaths.Add(request.Path);
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
_requestPaths.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -14,11 +14,19 @@ namespace HostedInAspNet.Server
|
|||
// For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940
|
||||
public void ConfigureServices(IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<RequestLog>();
|
||||
}
|
||||
|
||||
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
|
||||
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
|
||||
public void Configure(IApplicationBuilder app, IWebHostEnvironment env, RequestLog requestLog)
|
||||
{
|
||||
app.Use((context, next) =>
|
||||
{
|
||||
// This is used by E2E tests to verify that the correct resources were fetched
|
||||
requestLog.AddRequest(context.Request);
|
||||
return next();
|
||||
});
|
||||
|
||||
if (env.IsDevelopment())
|
||||
{
|
||||
app.UseDeveloperExceptionPage();
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -7,6 +7,7 @@ import { SharedMemoryRenderBatch } from './Rendering/RenderBatch/SharedMemoryRen
|
|||
import { Pointer } from './Platform/Platform';
|
||||
import { shouldAutoStart } from './BootCommon';
|
||||
import { setEventDispatcher } from './Rendering/RendererEventDispatcher';
|
||||
import { WebAssemblyResourceLoader } from './Platform/WebAssemblyResourceLoader';
|
||||
|
||||
let started = false;
|
||||
|
||||
|
|
@ -36,39 +37,16 @@ async function boot(options?: any): Promise<void> {
|
|||
);
|
||||
});
|
||||
|
||||
// Fetch the boot JSON file
|
||||
const bootConfig = await fetchBootConfigAsync();
|
||||
|
||||
if (!bootConfig.linkerEnabled) {
|
||||
console.info('Blazor is running in dev mode without IL stripping. To make the bundle size significantly smaller, publish the application or see https://go.microsoft.com/fwlink/?linkid=870414');
|
||||
}
|
||||
|
||||
// Determine the URLs of the assemblies we want to load, then begin fetching them all
|
||||
const loadAssemblyUrls = bootConfig.assemblies
|
||||
.map(filename => `_framework/_bin/${filename}`);
|
||||
|
||||
// Fetch the resources and prepare the Mono runtime
|
||||
const resourceLoader = await WebAssemblyResourceLoader.initAsync();
|
||||
try {
|
||||
await platform.start(loadAssemblyUrls);
|
||||
await platform.start(resourceLoader);
|
||||
} catch (ex) {
|
||||
throw new Error(`Failed to start platform. Reason: ${ex}`);
|
||||
}
|
||||
|
||||
// Start up the application
|
||||
platform.callEntryPoint(bootConfig.entryAssembly);
|
||||
}
|
||||
|
||||
async function fetchBootConfigAsync() {
|
||||
// Later we might make the location of this configurable (e.g., as an attribute on the <script>
|
||||
// element that's importing this file), but currently there isn't a use case for that.
|
||||
const bootConfigResponse = await fetch('_framework/blazor.boot.json', { method: 'Get', credentials: 'include' });
|
||||
return bootConfigResponse.json() as Promise<BootJsonData>;
|
||||
}
|
||||
|
||||
// Keep in sync with BootJsonData in Microsoft.AspNetCore.Blazor.Build
|
||||
interface BootJsonData {
|
||||
entryAssembly: string;
|
||||
assemblies: string[];
|
||||
linkerEnabled: boolean;
|
||||
platform.callEntryPoint(resourceLoader.bootConfig.entryAssembly);
|
||||
}
|
||||
|
||||
window['Blazor'].start = boot;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { getAssemblyNameFromUrl, getFileNameFromUrl } from '../Url';
|
||||
import { WebAssemblyResourceLoader } from '../WebAssemblyResourceLoader';
|
||||
|
||||
const currentBrowserIsChrome = (window as any).chrome
|
||||
&& navigator.userAgent.indexOf('Edge') < 0; // Edge pretends to be Chrome
|
||||
|
|
@ -9,10 +9,7 @@ export function hasDebuggingEnabled() {
|
|||
return hasReferencedPdbs && currentBrowserIsChrome;
|
||||
}
|
||||
|
||||
export function attachDebuggerHotkey(loadAssemblyUrls: string[]) {
|
||||
hasReferencedPdbs = loadAssemblyUrls
|
||||
.some(url => /\.pdb$/.test(getFileNameFromUrl(url)));
|
||||
|
||||
export function attachDebuggerHotkey(resourceLoader: WebAssemblyResourceLoader) {
|
||||
// Use the combination shift+alt+D because it isn't used by the major browsers
|
||||
// for anything else by default
|
||||
const altKeyName = navigator.platform.match(/^Mac/i) ? 'Cmd' : 'Alt';
|
||||
|
|
@ -20,6 +17,8 @@ export function attachDebuggerHotkey(loadAssemblyUrls: string[]) {
|
|||
console.info(`Debugging hotkey: Shift+${altKeyName}+D (when application has focus)`);
|
||||
}
|
||||
|
||||
hasReferencedPdbs = !!resourceLoader.bootConfig.resources.pdb;
|
||||
|
||||
// Even if debugging isn't enabled, we register the hotkey so we can report why it's not enabled
|
||||
document.addEventListener('keydown', evt => {
|
||||
if (evt.shiftKey && (evt.metaKey || evt.altKey) && evt.code === 'KeyD') {
|
||||
|
|
|
|||
|
|
@ -1,17 +1,18 @@
|
|||
import { System_Object, System_String, System_Array, Pointer, Platform } from '../Platform';
|
||||
import { getFileNameFromUrl } from '../Url';
|
||||
import { attachDebuggerHotkey, hasDebuggingEnabled } from './MonoDebugger';
|
||||
import { showErrorNotification } from '../../BootErrors';
|
||||
import { WebAssemblyResourceLoader, LoadingResource } from '../WebAssemblyResourceLoader';
|
||||
|
||||
let mono_string_get_utf8: (managedString: System_String) => Mono.Utf8Ptr;
|
||||
let mono_wasm_add_assembly: (name: string, heapAddress: number, length: number) => void;
|
||||
const appBinDirName = 'appBinDir';
|
||||
const uint64HighOrderShift = Math.pow(2, 32);
|
||||
const maxSafeNumberHighPart = Math.pow(2, 21) - 1; // The high-order int32 from Number.MAX_SAFE_INTEGER
|
||||
|
||||
export const monoPlatform: Platform = {
|
||||
start: function start(loadAssemblyUrls: string[]) {
|
||||
start: function start(resourceLoader: WebAssemblyResourceLoader) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
attachDebuggerHotkey(loadAssemblyUrls);
|
||||
attachDebuggerHotkey(resourceLoader);
|
||||
|
||||
// dotnet.js assumes the existence of this
|
||||
window['Browser'] = {
|
||||
|
|
@ -22,7 +23,7 @@ export const monoPlatform: Platform = {
|
|||
// For compatibility with macOS Catalina, we have to assign a temporary value to window.Module
|
||||
// before we start loading the WebAssembly files
|
||||
addGlobalModuleScriptTagsToDocument(() => {
|
||||
window['Module'] = createEmscriptenModuleInstance(loadAssemblyUrls, resolve, reject);
|
||||
window['Module'] = createEmscriptenModuleInstance(resourceLoader, resolve, reject);
|
||||
addScriptTagsToDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -140,9 +141,9 @@ function addGlobalModuleScriptTagsToDocument(callback: () => void) {
|
|||
document.body.appendChild(scriptElem);
|
||||
}
|
||||
|
||||
function createEmscriptenModuleInstance(loadAssemblyUrls: string[], onReady: () => void, onError: (reason?: any) => void) {
|
||||
function createEmscriptenModuleInstance(resourceLoader: WebAssemblyResourceLoader, onReady: () => void, onError: (reason?: any) => void) {
|
||||
const resources = resourceLoader.bootConfig.resources;
|
||||
const module = {} as typeof Module;
|
||||
const wasmBinaryFile = '_framework/wasm/dotnet.wasm';
|
||||
const suppressMessages = ['DEBUGGING ENABLED'];
|
||||
|
||||
module.print = line => (suppressMessages.indexOf(line) < 0 && console.log(`WASM: ${line}`));
|
||||
|
|
@ -155,55 +156,48 @@ function createEmscriptenModuleInstance(loadAssemblyUrls: string[], onReady: ()
|
|||
module.postRun = [];
|
||||
module.preloadPlugins = [];
|
||||
|
||||
module.locateFile = fileName => {
|
||||
switch (fileName) {
|
||||
case 'dotnet.wasm': return wasmBinaryFile;
|
||||
default: return fileName;
|
||||
}
|
||||
// Override the mechanism for fetching the main wasm file so we can connect it to our cache
|
||||
module.instantiateWasm = (imports, successCallback): WebAssembly.Exports => {
|
||||
(async () => {
|
||||
let compiledInstance: WebAssembly.Instance;
|
||||
try {
|
||||
const dotnetWasmResourceName = 'dotnet.wasm';
|
||||
const dotnetWasmResource = await resourceLoader.loadResource(
|
||||
/* name */ dotnetWasmResourceName,
|
||||
/* url */ `_framework/wasm/${dotnetWasmResourceName}`,
|
||||
/* hash */ resourceLoader.bootConfig.resources.wasm[dotnetWasmResourceName]);
|
||||
compiledInstance = await compileWasmModule(dotnetWasmResource, imports);
|
||||
} catch (ex) {
|
||||
module.printErr(ex);
|
||||
throw ex;
|
||||
}
|
||||
successCallback(compiledInstance);
|
||||
})();
|
||||
return []; // No exports
|
||||
};
|
||||
|
||||
module.preRun.push(() => {
|
||||
// By now, emscripten should be initialised enough that we can capture these methods for later use
|
||||
const mono_wasm_add_assembly = Module.cwrap('mono_wasm_add_assembly', null, [
|
||||
'string',
|
||||
'number',
|
||||
'number',
|
||||
]);
|
||||
|
||||
mono_wasm_add_assembly = Module.cwrap('mono_wasm_add_assembly', null, ['string', 'number', 'number']);
|
||||
mono_string_get_utf8 = Module.cwrap('mono_wasm_string_get_utf8', 'number', ['number']);
|
||||
|
||||
MONO.loaded_files = [];
|
||||
|
||||
loadAssemblyUrls.forEach(url => {
|
||||
const filename = getFileNameFromUrl(url);
|
||||
const runDependencyId = `blazor:${filename}`;
|
||||
addRunDependency(runDependencyId);
|
||||
asyncLoad(url).then(
|
||||
data => {
|
||||
const heapAddress = Module._malloc(data.length);
|
||||
const heapMemory = new Uint8Array(Module.HEAPU8.buffer, heapAddress, data.length);
|
||||
heapMemory.set(data);
|
||||
mono_wasm_add_assembly(filename, heapAddress, data.length);
|
||||
MONO.loaded_files.push(toAbsoluteUrl(url));
|
||||
removeRunDependency(runDependencyId);
|
||||
},
|
||||
errorInfo => {
|
||||
// If it's a 404 on a .pdb, we don't want to block the app from starting up.
|
||||
// We'll just skip that file and continue (though the 404 is logged in the console).
|
||||
// This happens if you build a Debug build but then run in Production environment.
|
||||
const isPdb404 = errorInfo instanceof XMLHttpRequest
|
||||
&& errorInfo.status === 404
|
||||
&& filename.match(/\.pdb$/);
|
||||
if (!isPdb404) {
|
||||
onError(errorInfo);
|
||||
}
|
||||
removeRunDependency(runDependencyId);
|
||||
}
|
||||
);
|
||||
});
|
||||
// Fetch the assemblies and PDBs in the background, telling Mono to wait until they are loaded
|
||||
resourceLoader.loadResources(resources.assembly, filename => `_framework/_bin/${filename}`)
|
||||
.forEach(addResourceAsAssembly);
|
||||
if (resources.pdb) {
|
||||
resourceLoader.loadResources(resources.pdb, filename => `_framework/_bin/${filename}`)
|
||||
.forEach(addResourceAsAssembly);
|
||||
}
|
||||
});
|
||||
|
||||
module.postRun.push(() => {
|
||||
if (resourceLoader.bootConfig.debugBuild && resourceLoader.bootConfig.cacheBootResources) {
|
||||
resourceLoader.logToConsole();
|
||||
}
|
||||
resourceLoader.purgeUnusedCacheEntriesAsync(); // Don't await - it's fine to run in background
|
||||
|
||||
MONO.mono_wasm_setenv("MONO_URI_DOTNETRELATIVEORABSOLUTE", "true");
|
||||
const load_runtime = Module.cwrap('mono_wasm_load_runtime', null, ['string', 'number']);
|
||||
load_runtime(appBinDirName, hasDebuggingEnabled() ? 1 : 0);
|
||||
|
|
@ -213,6 +207,29 @@ function createEmscriptenModuleInstance(loadAssemblyUrls: string[], onReady: ()
|
|||
});
|
||||
|
||||
return module;
|
||||
|
||||
async function addResourceAsAssembly(dependency: LoadingResource) {
|
||||
const runDependencyId = `blazor:${dependency.name}`;
|
||||
Module.addRunDependency(runDependencyId);
|
||||
|
||||
try {
|
||||
// Wait for the data to be loaded and verified
|
||||
const dataBuffer = await dependency.response.then(r => r.arrayBuffer());
|
||||
|
||||
// Load it into the Mono runtime
|
||||
const data = new Uint8Array(dataBuffer);
|
||||
const heapAddress = Module._malloc(data.length);
|
||||
const heapMemory = new Uint8Array(Module.HEAPU8.buffer, heapAddress, data.length);
|
||||
heapMemory.set(data);
|
||||
mono_wasm_add_assembly(dependency.name, heapAddress, data.length);
|
||||
MONO.loaded_files.push(toAbsoluteUrl(dependency.url));
|
||||
} catch (errorInfo) {
|
||||
onError(errorInfo);
|
||||
return;
|
||||
}
|
||||
|
||||
Module.removeRunDependency(runDependencyId);
|
||||
}
|
||||
}
|
||||
|
||||
const anchorTagForAbsoluteUrlConversions = document.createElement('a');
|
||||
|
|
@ -221,24 +238,6 @@ function toAbsoluteUrl(possiblyRelativeUrl: string) {
|
|||
return anchorTagForAbsoluteUrlConversions.href;
|
||||
}
|
||||
|
||||
function asyncLoad(url: string) {
|
||||
return new Promise<Uint8Array>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', url, /* async: */ true);
|
||||
xhr.responseType = 'arraybuffer';
|
||||
xhr.onload = function xhr_onload() {
|
||||
if (xhr.status == 200 || xhr.status == 0 && xhr.response) {
|
||||
const asm = new Uint8Array(xhr.response);
|
||||
resolve(asm);
|
||||
} else {
|
||||
reject(xhr);
|
||||
}
|
||||
};
|
||||
xhr.onerror = reject;
|
||||
xhr.send(undefined);
|
||||
});
|
||||
}
|
||||
|
||||
function getArrayDataPointer<T>(array: System_Array<T>): number {
|
||||
return <number><any>array + 12; // First byte from here is length, then following bytes are entries
|
||||
}
|
||||
|
|
@ -287,3 +286,25 @@ function attachInteropInvoker(): void {
|
|||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function compileWasmModule(wasmResource: LoadingResource, imports: any): Promise<WebAssembly.Instance> {
|
||||
// This is the same logic as used in emscripten's generated js. We can't use emscripten's js because
|
||||
// it doesn't provide any method for supplying a custom response provider, and we want to integrate
|
||||
// with our resource loader cache.
|
||||
|
||||
if (typeof WebAssembly['instantiateStreaming'] === 'function') {
|
||||
try {
|
||||
const streamingResult = await WebAssembly['instantiateStreaming'](wasmResource.response, imports);
|
||||
return streamingResult.instance;
|
||||
}
|
||||
catch (ex) {
|
||||
console.info('Streaming compilation failed. Falling back to ArrayBuffer instantiation. ', ex);
|
||||
}
|
||||
}
|
||||
|
||||
// If that's not available or fails (e.g., due to incorrect content-type header),
|
||||
// fall back to ArrayBuffer instantiation
|
||||
const arrayBuffer = await wasmResource.response.then(r => r.arrayBuffer());
|
||||
const arrayBufferResult = await WebAssembly.instantiate(arrayBuffer, imports);
|
||||
return arrayBufferResult.instance;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,10 +13,6 @@ declare namespace Module {
|
|||
function mono_bind_static_method(fqn: string): BoundStaticMethod;
|
||||
}
|
||||
|
||||
// Emscripten declares these globals
|
||||
declare const addRunDependency: any;
|
||||
declare const removeRunDependency: any;
|
||||
|
||||
declare namespace Mono {
|
||||
interface Utf8Ptr { Utf8Ptr__DO_NOT_IMPLEMENT: any }
|
||||
interface StackSaveHandle { StackSaveHandle__DO_NOT_IMPLEMENT: any }
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { WebAssemblyResourceLoader } from "./WebAssemblyResourceLoader";
|
||||
|
||||
export interface Platform {
|
||||
start(loadAssemblyUrls: string[]): Promise<void>;
|
||||
start(resourceLoader: WebAssemblyResourceLoader): Promise<void>;
|
||||
|
||||
callEntryPoint(assemblyName: string): void;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
export function getFileNameFromUrl(url: string) {
|
||||
// This could also be called "get last path segment from URL", but the primary
|
||||
// use case is to extract things that look like filenames
|
||||
const lastSegment = url.substring(url.lastIndexOf('/') + 1);
|
||||
const queryStringStartPos = lastSegment.indexOf('?');
|
||||
return queryStringStartPos < 0 ? lastSegment : lastSegment.substring(0, queryStringStartPos);
|
||||
}
|
||||
|
||||
export function getAssemblyNameFromUrl(url: string) {
|
||||
return getFileNameFromUrl(url).replace(/\.dll$/, '');
|
||||
}
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
import { toAbsoluteUri } from '../Services/NavigationManager';
|
||||
const networkFetchCacheMode = 'no-cache';
|
||||
|
||||
export class WebAssemblyResourceLoader {
|
||||
private usedCacheKeys: { [key: string]: boolean } = {};
|
||||
private networkLoads: { [name: string]: LoadLogEntry } = {};
|
||||
private cacheLoads: { [name: string]: LoadLogEntry } = {};
|
||||
|
||||
static async initAsync(): Promise<WebAssemblyResourceLoader> {
|
||||
const bootConfigResponse = await fetch('_framework/blazor.boot.json', {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
cache: networkFetchCacheMode
|
||||
});
|
||||
|
||||
// Define a separate cache for each base href, so we're isolated from any other
|
||||
// Blazor application running on the same origin. We need this so that we're free
|
||||
// to purge from the cache anything we're not using and don't let it keep growing,
|
||||
// since we don't want to be worst offenders for space usage.
|
||||
const relativeBaseHref = document.baseURI.substring(document.location.origin.length);
|
||||
const cacheName = `blazor-resources-${relativeBaseHref}`;
|
||||
return new WebAssemblyResourceLoader(
|
||||
await bootConfigResponse.json(),
|
||||
await caches.open(cacheName));
|
||||
}
|
||||
|
||||
constructor (public readonly bootConfig: BootJsonData, private cache: Cache)
|
||||
{
|
||||
}
|
||||
|
||||
loadResources(resources: ResourceList, url: (name: string) => string): LoadingResource[] {
|
||||
return Object.keys(resources)
|
||||
.map(name => this.loadResource(name, url(name), resources[name]));
|
||||
}
|
||||
|
||||
loadResource(name: string, url: string, contentHash: string): LoadingResource {
|
||||
// Setting 'cacheBootResources' to false bypasses the entire cache flow, including integrity checking.
|
||||
// This gives developers an easy opt-out if they don't like anything about the default cache mechanism.
|
||||
const response = this.bootConfig.cacheBootResources
|
||||
? this.loadResourceWithCaching(name, url, contentHash)
|
||||
: fetch(url, { cache: networkFetchCacheMode });
|
||||
return { name, url, response };
|
||||
}
|
||||
|
||||
logToConsole() {
|
||||
const cacheLoadsEntries = Object.values(this.cacheLoads);
|
||||
const networkLoadsEntries = Object.values(this.networkLoads);
|
||||
const cacheResponseBytes = countTotalBytes(cacheLoadsEntries);
|
||||
const networkResponseBytes = countTotalBytes(networkLoadsEntries);
|
||||
const totalResponseBytes = cacheResponseBytes + networkResponseBytes;
|
||||
const linkerDisabledWarning = this.bootConfig.linkerEnabled ? '%c' : '\n%cThis application was built with linking (tree shaking) disabled. Published applications will be significantly smaller.';
|
||||
|
||||
console.groupCollapsed(`%cblazor%c Loaded ${toDataSizeString(totalResponseBytes)} resources${linkerDisabledWarning}`, 'background: purple; color: white; padding: 1px 3px; border-radius: 3px;', 'font-weight: bold;', 'font-weight: normal;');
|
||||
|
||||
if (cacheLoadsEntries.length) {
|
||||
console.groupCollapsed(`Loaded ${toDataSizeString(cacheResponseBytes)} resources from cache`);
|
||||
console.table(this.cacheLoads);
|
||||
console.groupEnd();
|
||||
}
|
||||
|
||||
if (networkLoadsEntries.length) {
|
||||
console.groupCollapsed(`Loaded ${toDataSizeString(networkResponseBytes)} resources from network`);
|
||||
console.table(this.networkLoads);
|
||||
console.groupEnd();
|
||||
}
|
||||
|
||||
console.groupEnd();
|
||||
}
|
||||
|
||||
async purgeUnusedCacheEntriesAsync() {
|
||||
// We want to keep the cache small because, even though the browser will evict entries if it
|
||||
// gets too big, we don't want to be considered problematic by the end user viewing storage stats
|
||||
const cachedRequests = await this.cache.keys();
|
||||
const deletionPromises = cachedRequests.map(async cachedRequest => {
|
||||
if (!(cachedRequest.url in this.usedCacheKeys)) {
|
||||
await this.cache.delete(cachedRequest);
|
||||
}
|
||||
});
|
||||
|
||||
return Promise.all(deletionPromises);
|
||||
}
|
||||
|
||||
private async loadResourceWithCaching(name: string, url: string, contentHash: string) {
|
||||
// Since we are going to cache the response, we require there to be a content hash for integrity
|
||||
// checking. We don't want to cache bad responses. There should always be a hash, because the build
|
||||
// process generates this data.
|
||||
if (!contentHash || contentHash.length === 0) {
|
||||
throw new Error('Content hash is required');
|
||||
}
|
||||
|
||||
const cacheKey = toAbsoluteUri(`${url}.${contentHash}`);
|
||||
this.usedCacheKeys[cacheKey] = true;
|
||||
|
||||
const cachedResponse = await this.cache.match(cacheKey);
|
||||
if (cachedResponse) {
|
||||
// It's in the cache.
|
||||
const responseBytes = parseInt(cachedResponse.headers.get('content-length') || '0');
|
||||
this.cacheLoads[name] = { responseBytes };
|
||||
return cachedResponse;
|
||||
} else {
|
||||
// It's not in the cache. Fetch from network.
|
||||
const networkResponse = await fetch(url, { cache: networkFetchCacheMode, integrity: contentHash });
|
||||
this.addToCacheAsync(name, cacheKey, networkResponse); // Don't await - add to cache in background
|
||||
return networkResponse;
|
||||
}
|
||||
}
|
||||
|
||||
private async addToCacheAsync(name: string, cacheKey: string, response: Response) {
|
||||
// We have to clone in order to put this in the cache *and* not prevent other code from
|
||||
// reading the original response stream.
|
||||
const responseData = await response.clone().arrayBuffer();
|
||||
|
||||
// Now is an ideal moment to capture the performance stats for the request, since it
|
||||
// only just completed and is most likely to still be in the buffer. However this is
|
||||
// only done on a 'best effort' basis. Even if we do receive an entry, some of its
|
||||
// properties may be blanked out if it was a CORS request.
|
||||
const performanceEntry = getPerformanceEntry(response.url);
|
||||
const responseBytes = (performanceEntry && performanceEntry.encodedBodySize) || undefined;
|
||||
this.networkLoads[name] = { responseBytes };
|
||||
|
||||
// Add to cache as a custom response object so we can track extra data such as responseBytes
|
||||
// We can't rely on the server sending content-length (ASP.NET Core doesn't by default)
|
||||
await this.cache.put(cacheKey, new Response(responseData, {
|
||||
headers: {
|
||||
'content-type': response.headers.get('content-type') || '',
|
||||
'content-length': (responseBytes || response.headers.get('content-length') || '').toString()
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
function countTotalBytes(loads: LoadLogEntry[]) {
|
||||
return loads.reduce((prev, item) => prev + (item.responseBytes || 0), 0);
|
||||
}
|
||||
|
||||
function toDataSizeString(byteCount: number) {
|
||||
return `${(byteCount / (1024*1024)).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
function getPerformanceEntry(url: string): PerformanceResourceTiming | undefined {
|
||||
if (typeof performance !== 'undefined') {
|
||||
return performance.getEntriesByName(url)[0] as PerformanceResourceTiming;
|
||||
}
|
||||
}
|
||||
|
||||
// Keep in sync with bootJsonData in Microsoft.AspNetCore.Blazor.Build
|
||||
interface BootJsonData {
|
||||
readonly entryAssembly: string;
|
||||
readonly resources: ResourceGroups;
|
||||
readonly debugBuild: boolean;
|
||||
readonly linkerEnabled: boolean;
|
||||
readonly cacheBootResources: boolean;
|
||||
}
|
||||
|
||||
interface ResourceGroups {
|
||||
readonly wasm: ResourceList;
|
||||
readonly assembly: ResourceList;
|
||||
readonly pdb?: ResourceList;
|
||||
}
|
||||
|
||||
interface LoadLogEntry {
|
||||
responseBytes: number | undefined;
|
||||
}
|
||||
|
||||
export interface LoadingResource {
|
||||
name: string;
|
||||
url: string;
|
||||
response: Promise<Response>;
|
||||
}
|
||||
|
||||
type ResourceList = { [name: string]: string };
|
||||
|
|
@ -110,7 +110,7 @@ async function notifyLocationChanged(interceptedLink: boolean) {
|
|||
}
|
||||
|
||||
let testAnchor: HTMLAnchorElement;
|
||||
function toAbsoluteUri(relativeUri: string) {
|
||||
export function toAbsoluteUri(relativeUri: string) {
|
||||
testAnchor = testAnchor || document.createElement('a');
|
||||
testAnchor.href = relativeUri;
|
||||
return testAnchor.href;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,152 @@
|
|||
// Copyright (c) .NET Foundation. All rights reserved.
|
||||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using HostedInAspNet.Server;
|
||||
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure;
|
||||
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures;
|
||||
using Microsoft.AspNetCore.E2ETesting;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using OpenQA.Selenium;
|
||||
using OpenQA.Selenium.Support.UI;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace Microsoft.AspNetCore.Components.E2ETest.Tests
|
||||
{
|
||||
public class BootResourceCachingTest
|
||||
: ServerTestBase<AspNetSiteServerFixture>
|
||||
{
|
||||
// The cache name is derived from the application's base href value (in this case, '/')
|
||||
private const string CacheName = "blazor-resources-/";
|
||||
|
||||
public BootResourceCachingTest(
|
||||
BrowserFixture browserFixture,
|
||||
AspNetSiteServerFixture serverFixture,
|
||||
ITestOutputHelper output)
|
||||
: base(browserFixture, serverFixture, output)
|
||||
{
|
||||
serverFixture.BuildWebHostMethod = HostedInAspNet.Server.Program.BuildWebHost;
|
||||
}
|
||||
|
||||
public override Task InitializeAsync()
|
||||
{
|
||||
return base.InitializeAsync(Guid.NewGuid().ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CachesResourcesAfterFirstLoad()
|
||||
{
|
||||
// On the first load, we have to fetch everything
|
||||
Navigate("/");
|
||||
WaitUntilLoaded();
|
||||
var initialResourcesRequested = GetAndClearRequestedPaths();
|
||||
Assert.NotEmpty(initialResourcesRequested.Where(path => path.EndsWith("/blazor.boot.json")));
|
||||
Assert.NotEmpty(initialResourcesRequested.Where(path => path.EndsWith("/dotnet.wasm")));
|
||||
Assert.NotEmpty(initialResourcesRequested.Where(path => path.EndsWith(".js")));
|
||||
Assert.NotEmpty(initialResourcesRequested.Where(path => path.EndsWith(".dll")));
|
||||
|
||||
// On subsequent loads, we skip the items referenced from blazor.boot.json
|
||||
// which includes .dll files and dotnet.wasm
|
||||
Navigate("about:blank");
|
||||
Navigate("/");
|
||||
WaitUntilLoaded();
|
||||
var subsequentResourcesRequested = GetAndClearRequestedPaths();
|
||||
Assert.NotEmpty(initialResourcesRequested.Where(path => path.EndsWith("/blazor.boot.json")));
|
||||
Assert.Empty(subsequentResourcesRequested.Where(path => path.EndsWith("/dotnet.wasm")));
|
||||
Assert.NotEmpty(subsequentResourcesRequested.Where(path => path.EndsWith(".js")));
|
||||
Assert.Empty(subsequentResourcesRequested.Where(path => path.EndsWith(".dll")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IncrementallyUpdatesCache()
|
||||
{
|
||||
// Perform a first load to populate the cache
|
||||
Navigate("/");
|
||||
WaitUntilLoaded();
|
||||
var cacheEntryUrls1 = GetCacheEntryUrls();
|
||||
var cacheEntryForMsCorLib = cacheEntryUrls1.Single(url => url.Contains("/mscorlib.dll"));
|
||||
var cacheEntryForDotNetWasm = cacheEntryUrls1.Single(url => url.Contains("/dotnet.wasm"));
|
||||
var cacheEntryForDotNetWasmWithChangedHash = cacheEntryForDotNetWasm.Replace(".sha256-", ".sha256-different");
|
||||
|
||||
// Remove some items we do need, and add an item we don't need
|
||||
RemoveCacheEntry(cacheEntryForMsCorLib);
|
||||
RemoveCacheEntry(cacheEntryForDotNetWasm);
|
||||
AddCacheEntry(cacheEntryForDotNetWasmWithChangedHash, "ignored content");
|
||||
var cacheEntryUrls2 = GetCacheEntryUrls();
|
||||
Assert.DoesNotContain(cacheEntryForMsCorLib, cacheEntryUrls2);
|
||||
Assert.DoesNotContain(cacheEntryForDotNetWasm, cacheEntryUrls2);
|
||||
Assert.Contains(cacheEntryForDotNetWasmWithChangedHash, cacheEntryUrls2);
|
||||
|
||||
// On the next load, we'll fetch only the items we need (not things already cached)
|
||||
GetAndClearRequestedPaths();
|
||||
Navigate("about:blank");
|
||||
Navigate("/");
|
||||
WaitUntilLoaded();
|
||||
var subsequentResourcesRequested = GetAndClearRequestedPaths();
|
||||
Assert.Collection(subsequentResourcesRequested.Where(url => url.Contains(".dll")),
|
||||
requestedDll => Assert.Contains("/mscorlib.dll", requestedDll));
|
||||
Assert.Collection(subsequentResourcesRequested.Where(url => url.Contains(".wasm")),
|
||||
requestedDll => Assert.Contains("/dotnet.wasm", requestedDll));
|
||||
|
||||
// We also update the cache (add new items, remove unnecessary items)
|
||||
var cacheEntryUrls3 = GetCacheEntryUrls();
|
||||
Assert.Contains(cacheEntryForMsCorLib, cacheEntryUrls3);
|
||||
Assert.Contains(cacheEntryForDotNetWasm, cacheEntryUrls3);
|
||||
Assert.DoesNotContain(cacheEntryForDotNetWasmWithChangedHash, cacheEntryUrls3);
|
||||
}
|
||||
|
||||
private IReadOnlyCollection<string> GetCacheEntryUrls()
|
||||
{
|
||||
var js = @"
|
||||
(async function(cacheName, completedCallback) {
|
||||
const cache = await caches.open(cacheName);
|
||||
const keys = await cache.keys();
|
||||
const urls = keys.map(r => r.url);
|
||||
completedCallback(urls);
|
||||
}).apply(null, arguments)";
|
||||
var jsExecutor = (IJavaScriptExecutor)Browser;
|
||||
var result = (IEnumerable<object>)jsExecutor.ExecuteAsyncScript(js, CacheName);
|
||||
return result.Cast<string>().ToList();
|
||||
}
|
||||
|
||||
private void RemoveCacheEntry(string url)
|
||||
{
|
||||
var js = @"
|
||||
(async function(cacheName, urlToRemove, completedCallback) {
|
||||
const cache = await caches.open(cacheName);
|
||||
await cache.delete(urlToRemove);
|
||||
completedCallback();
|
||||
}).apply(null, arguments)";
|
||||
((IJavaScriptExecutor)Browser).ExecuteAsyncScript(js, CacheName, url);
|
||||
}
|
||||
|
||||
private void AddCacheEntry(string url, string content)
|
||||
{
|
||||
var js = @"
|
||||
(async function(cacheName, urlToAdd, contentToAdd, completedCallback) {
|
||||
const cache = await caches.open(cacheName);
|
||||
await cache.put(urlToAdd, new Response(contentToAdd));
|
||||
completedCallback();
|
||||
}).apply(null, arguments)";
|
||||
((IJavaScriptExecutor)Browser).ExecuteAsyncScript(js, CacheName, url, content);
|
||||
}
|
||||
|
||||
private IReadOnlyCollection<string> GetAndClearRequestedPaths()
|
||||
{
|
||||
var requestLog = _serverFixture.Host.Services.GetRequiredService<RequestLog>();
|
||||
var result = requestLog.RequestPaths.ToList();
|
||||
requestLog.Clear();
|
||||
return result;
|
||||
}
|
||||
|
||||
private void WaitUntilLoaded()
|
||||
{
|
||||
new WebDriverWait(Browser, TimeSpan.FromSeconds(30)).Until(
|
||||
driver => driver.FindElement(By.TagName("h1")).Text == "Hello, world!");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -70,12 +70,38 @@ namespace Microsoft.AspNetCore.E2ETesting
|
|||
browser.Dispose();
|
||||
}
|
||||
|
||||
await DeleteBrowserUserProfileDirectoriesAsync();
|
||||
}
|
||||
|
||||
private async Task DeleteBrowserUserProfileDirectoriesAsync()
|
||||
{
|
||||
foreach (var context in _browsers.Keys)
|
||||
{
|
||||
var userProfileDirectory = UserProfileDirectory(context);
|
||||
if (!string.IsNullOrEmpty(userProfileDirectory) && Directory.Exists(userProfileDirectory))
|
||||
{
|
||||
Directory.Delete(userProfileDirectory, recursive: true);
|
||||
var attemptCount = 0;
|
||||
while (true)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(userProfileDirectory, recursive: true);
|
||||
break;
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
attemptCount++;
|
||||
if (attemptCount < 5)
|
||||
{
|
||||
Console.WriteLine($"Failed to delete browser profile directory '{userProfileDirectory}': '{ex}'. Will retry.");
|
||||
await Task.Delay(2000);
|
||||
}
|
||||
else
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue