diff --git a/src/Components/Blazor.sln b/src/Components/Blazor.sln index e31a675408..b739e47354 100644 --- a/src/Components/Blazor.sln +++ b/src/Components/Blazor.sln @@ -47,7 +47,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Blazor EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "WebAssembly.JSInterop", "WebAssembly.JSInterop", "{37FA056D-A7B3-4F72-A8B9-8D3C175E831E}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WebAssembly.JSInterop", "WebAssembly\JSInterop\src\Microsoft.JSInterop.WebAssembly.csproj", "{FBD7C733-200E-4BED-8B31-2610C2263F72}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.JSInterop.WebAssembly", "WebAssembly\JSInterop\src\Microsoft.JSInterop.WebAssembly.csproj", "{FBD7C733-200E-4BED-8B31-2610C2263F72}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Server", "Server", "{7920B09F-8016-49CF-A229-E72D0CECDD17}" EndProject @@ -105,7 +105,13 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Authentication.We EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "DebugProxy", "DebugProxy", "{96DE9B14-D81F-422E-A33A-728BFB9C153A}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.AspNetCore.Components.WebAssembly.DebugProxy", "WebAssembly\DebugProxy\src\Microsoft.AspNetCore.Components.WebAssembly.DebugProxy.csproj", "{8BB1A8BE-F002-40A2-9B8E-439284B21C1C}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Components.WebAssembly.DebugProxy", "WebAssembly\DebugProxy\src\Microsoft.AspNetCore.Components.WebAssembly.DebugProxy.csproj", "{8BB1A8BE-F002-40A2-9B8E-439284B21C1C}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Compression", "Compression", "{710765DD-C2AD-4DBB-A114-14E62F31F463}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Components.WebAssembly.Build.BrotliCompression", "WebAssembly\Compression\src\Microsoft.AspNetCore.Components.WebAssembly.Build.BrotliCompression.csproj", "{DB1DC77D-122E-49E8-AB16-1AC8AEEFEEFF}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Components.WebAssembly.Server.Tests", "WebAssembly\Server\test\Microsoft.AspNetCore.Components.WebAssembly.Server.Tests.csproj", "{72D3D00C-5281-455F-9E19-646EE766009A}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -537,6 +543,30 @@ Global {8BB1A8BE-F002-40A2-9B8E-439284B21C1C}.Release|x64.Build.0 = Release|Any CPU {8BB1A8BE-F002-40A2-9B8E-439284B21C1C}.Release|x86.ActiveCfg = Release|Any CPU {8BB1A8BE-F002-40A2-9B8E-439284B21C1C}.Release|x86.Build.0 = Release|Any CPU + {DB1DC77D-122E-49E8-AB16-1AC8AEEFEEFF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DB1DC77D-122E-49E8-AB16-1AC8AEEFEEFF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DB1DC77D-122E-49E8-AB16-1AC8AEEFEEFF}.Debug|x64.ActiveCfg = Debug|Any CPU + {DB1DC77D-122E-49E8-AB16-1AC8AEEFEEFF}.Debug|x64.Build.0 = Debug|Any CPU + {DB1DC77D-122E-49E8-AB16-1AC8AEEFEEFF}.Debug|x86.ActiveCfg = Debug|Any CPU + {DB1DC77D-122E-49E8-AB16-1AC8AEEFEEFF}.Debug|x86.Build.0 = Debug|Any CPU + {DB1DC77D-122E-49E8-AB16-1AC8AEEFEEFF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DB1DC77D-122E-49E8-AB16-1AC8AEEFEEFF}.Release|Any CPU.Build.0 = Release|Any CPU + {DB1DC77D-122E-49E8-AB16-1AC8AEEFEEFF}.Release|x64.ActiveCfg = Release|Any CPU + {DB1DC77D-122E-49E8-AB16-1AC8AEEFEEFF}.Release|x64.Build.0 = Release|Any CPU + {DB1DC77D-122E-49E8-AB16-1AC8AEEFEEFF}.Release|x86.ActiveCfg = Release|Any CPU + {DB1DC77D-122E-49E8-AB16-1AC8AEEFEEFF}.Release|x86.Build.0 = Release|Any CPU + {72D3D00C-5281-455F-9E19-646EE766009A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {72D3D00C-5281-455F-9E19-646EE766009A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {72D3D00C-5281-455F-9E19-646EE766009A}.Debug|x64.ActiveCfg = Debug|Any CPU + {72D3D00C-5281-455F-9E19-646EE766009A}.Debug|x64.Build.0 = Debug|Any CPU + {72D3D00C-5281-455F-9E19-646EE766009A}.Debug|x86.ActiveCfg = Debug|Any CPU + {72D3D00C-5281-455F-9E19-646EE766009A}.Debug|x86.Build.0 = Debug|Any CPU + {72D3D00C-5281-455F-9E19-646EE766009A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {72D3D00C-5281-455F-9E19-646EE766009A}.Release|Any CPU.Build.0 = Release|Any CPU + {72D3D00C-5281-455F-9E19-646EE766009A}.Release|x64.ActiveCfg = Release|Any CPU + {72D3D00C-5281-455F-9E19-646EE766009A}.Release|x64.Build.0 = Release|Any CPU + {72D3D00C-5281-455F-9E19-646EE766009A}.Release|x86.ActiveCfg = Release|Any CPU + {72D3D00C-5281-455F-9E19-646EE766009A}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -591,6 +621,9 @@ Global {2F105FA7-74DA-4855-9D8E-818DEE1F8D43} = {E4D756A7-A934-4D7F-BC6E-7B95FE4098AB} {96DE9B14-D81F-422E-A33A-728BFB9C153A} = {B29FB58D-FAE5-405E-9695-BCF93582BE9A} {8BB1A8BE-F002-40A2-9B8E-439284B21C1C} = {96DE9B14-D81F-422E-A33A-728BFB9C153A} + {710765DD-C2AD-4DBB-A114-14E62F31F463} = {B29FB58D-FAE5-405E-9695-BCF93582BE9A} + {DB1DC77D-122E-49E8-AB16-1AC8AEEFEEFF} = {710765DD-C2AD-4DBB-A114-14E62F31F463} + {72D3D00C-5281-455F-9E19-646EE766009A} = {7920B09F-8016-49CF-A229-E72D0CECDD17} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {27A36094-AA50-4FFD-ADE6-C055E391F741} diff --git a/src/Components/WebAssembly/Build/src/Microsoft.AspNetCore.Components.WebAssembly.Build.csproj b/src/Components/WebAssembly/Build/src/Microsoft.AspNetCore.Components.WebAssembly.Build.csproj index c6cb9aaf7a..4aa17ecb56 100644 --- a/src/Components/WebAssembly/Build/src/Microsoft.AspNetCore.Components.WebAssembly.Build.csproj +++ b/src/Components/WebAssembly/Build/src/Microsoft.AspNetCore.Components.WebAssembly.Build.csproj @@ -1,4 +1,4 @@ - + $(DefaultNetCoreTargetFramework);net46 @@ -70,4 +70,36 @@ + + <_BrotliToolPathInput Include="..\..\Compression\src\Microsoft.AspNetCore.Components.WebAssembly.Build.BrotliCompression.csproj" /> + <_BrotliToolPathInput Include="..\..\Compression\src\*.cs" /> + <_BrotliToolPathInput Include="..\..\Compression\src\*.runtimeconfig.json" /> + <_BrotliToolPathOutput Include="$(MSBuildThisFileDirectory)bin\$(Configuration)\tools\compression\blazor-brotli.dll" /> + + + + + <_BrotliToolsPath Include="$(MSBuildThisFileDirectory)bin\$(Configuration)\tools\compression\" /> + + + + <_BrotliToolsOutputPath>@(_BrotliToolsPath->'%(FullPath)') + + + + + + + + + + + diff --git a/src/Components/WebAssembly/Build/src/Microsoft.AspNetCore.Components.WebAssembly.Build.nuspec b/src/Components/WebAssembly/Build/src/Microsoft.AspNetCore.Components.WebAssembly.Build.nuspec index 65f6ddb98f..9c114bde9a 100644 --- a/src/Components/WebAssembly/Build/src/Microsoft.AspNetCore.Components.WebAssembly.Build.nuspec +++ b/src/Components/WebAssembly/Build/src/Microsoft.AspNetCore.Components.WebAssembly.Build.nuspec @@ -10,6 +10,8 @@ $CommonFileElements$ + + diff --git a/src/Components/WebAssembly/Build/src/Tasks/BrotliCompressBlazorApplicationFiles.cs b/src/Components/WebAssembly/Build/src/Tasks/BrotliCompressBlazorApplicationFiles.cs new file mode 100644 index 0000000000..fc4a4e6ad9 --- /dev/null +++ b/src/Components/WebAssembly/Build/src/Tasks/BrotliCompressBlazorApplicationFiles.cs @@ -0,0 +1,52 @@ +// 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.IO; +using System.Text; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +namespace Microsoft.AspNetCore.Components.WebAssembly.Build +{ + public class BrotliCompressBlazorApplicationFiles : ToolTask + { + private const string DotNetHostPathEnvironmentName = "DOTNET_HOST_PATH"; + + [Required] + public string ManifestPath { get; set; } + + [Required] + public string BlazorBrotliPath { get; set; } + + private string _dotnetPath; + + private string DotNetPath + { + get + { + if (!string.IsNullOrEmpty(_dotnetPath)) + { + return _dotnetPath; + } + + _dotnetPath = Environment.GetEnvironmentVariable(DotNetHostPathEnvironmentName); + if (string.IsNullOrEmpty(_dotnetPath)) + { + throw new InvalidOperationException($"{DotNetHostPathEnvironmentName} is not set"); + } + + return _dotnetPath; + } + } + + protected override MessageImportance StandardErrorLoggingImportance => MessageImportance.High; + + protected override string ToolName => Path.GetFileName(DotNetPath); + + protected override string GenerateFullPathToTool() => DotNetPath; + + protected override string GenerateCommandLineCommands() => + $"\"{BlazorBrotliPath}\" \"{ManifestPath}\""; + } +} diff --git a/src/Components/WebAssembly/Build/src/Tasks/CompressBlazorApplicationFiles.cs b/src/Components/WebAssembly/Build/src/Tasks/CompressBlazorApplicationFiles.cs deleted file mode 100644 index 12cfaa6f6c..0000000000 --- a/src/Components/WebAssembly/Build/src/Tasks/CompressBlazorApplicationFiles.cs +++ /dev/null @@ -1,32 +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.IO.Compression; -using Microsoft.Build.Framework; -using Microsoft.Build.Utilities; - -namespace Microsoft.AspNetCore.Components.WebAssembly.Build -{ - public class CompressBlazorApplicationFiles : Task - { - [Required] - public ITaskItem StaticWebAsset { get; set; } - - public override bool Execute() - { - var targetCompressionPath = StaticWebAsset.GetMetadata("TargetCompressionPath"); - - Directory.CreateDirectory(Path.GetDirectoryName(targetCompressionPath)); - - using var sourceStream = File.OpenRead(StaticWebAsset.GetMetadata("FullPath")); - using var fileStream = new FileStream(targetCompressionPath, FileMode.Create); - using var stream = new GZipStream(fileStream, CompressionLevel.Optimal); - - sourceStream.CopyTo(stream); - - return true; - } - } -} - diff --git a/src/Components/WebAssembly/Build/src/Tasks/GenerateBlazorBootJson.cs b/src/Components/WebAssembly/Build/src/Tasks/GenerateBlazorBootJson.cs index 126575ccc0..047f08d5dd 100644 --- a/src/Components/WebAssembly/Build/src/Tasks/GenerateBlazorBootJson.cs +++ b/src/Components/WebAssembly/Build/src/Tasks/GenerateBlazorBootJson.cs @@ -117,7 +117,7 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Build var resourceName = GetResourceName(resource); if (!resourceList.ContainsKey(resourceName)) { - resourceList.Add(resourceName, $"sha256-{resource.GetMetadata("FileHash")}"); + resourceList.Add(resourceName, $"sha256-{resource.GetMetadata("Integrity")}"); } } } diff --git a/src/Components/WebAssembly/Build/src/Tasks/GenerateBlazorCompressionManifest.cs b/src/Components/WebAssembly/Build/src/Tasks/GenerateBlazorCompressionManifest.cs new file mode 100644 index 0000000000..e7a7b16ee2 --- /dev/null +++ b/src/Components/WebAssembly/Build/src/Tasks/GenerateBlazorCompressionManifest.cs @@ -0,0 +1,102 @@ +// 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.IO; +using System.Runtime.Serialization; +using System.Runtime.Serialization.Json; +using System.Text; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +namespace Microsoft.AspNetCore.Components.WebAssembly.Build +{ + public class GenerateBlazorCompressionManifest : Task + { + [Required] + public ITaskItem[] FilesToCompress { get; set; } + + [Required] + public string ManifestPath { get; set; } + + public override bool Execute() + { + try + { + WriteCompressionManifest(); + } + catch (Exception ex) + { + Log.LogErrorFromException(ex); + } + + return !Log.HasLoggedErrors; + } + + private void WriteCompressionManifest() + { + var tempFilePath = Path.GetTempFileName(); + + var manifest = new ManifestData(); + var filesToCompress = new List(); + + foreach (var file in FilesToCompress) + { + filesToCompress.Add(new CompressedFile + { + Source = file.GetMetadata("FullPath"), + InputSource = file.GetMetadata("InputSource"), + Target = file.GetMetadata("TargetCompressionPath"), + }); + } + + manifest.FilesToCompress = filesToCompress.ToArray(); + + var serializer = new DataContractJsonSerializer(typeof(ManifestData)); + + using (var tempFile = File.OpenWrite(tempFilePath)) + { + using (var writer = JsonReaderWriterFactory.CreateJsonWriter(tempFile, Encoding.UTF8, ownsStream: false, indent: true)) + { + serializer.WriteObject(writer, manifest); + } + } + + if (!File.Exists(ManifestPath)) + { + File.Move(tempFilePath, ManifestPath); + return; + } + + var originalText = File.ReadAllText(ManifestPath); + var newManifest = File.ReadAllText(tempFilePath); + if (!string.Equals(originalText, newManifest, StringComparison.Ordinal)) + { + // OnlyWriteWhenDifferent + File.Delete(ManifestPath); + File.Move(tempFilePath, ManifestPath); + } + } + + [DataContract] + private class ManifestData + { + [DataMember] + public CompressedFile[] FilesToCompress { get; set; } + } + + [DataContract] + private class CompressedFile + { + [DataMember] + public string Source { get; set; } + + [DataMember] + public string InputSource { get; set; } + + [DataMember] + public string Target { get; set; } + } + } +} diff --git a/src/Components/WebAssembly/Build/src/Tasks/GenerateServiceWorkerAssetsManifest.cs b/src/Components/WebAssembly/Build/src/Tasks/GenerateServiceWorkerAssetsManifest.cs index 95e2027046..9a1885ad0c 100644 --- a/src/Components/WebAssembly/Build/src/Tasks/GenerateServiceWorkerAssetsManifest.cs +++ b/src/Components/WebAssembly/Build/src/Tasks/GenerateServiceWorkerAssetsManifest.cs @@ -36,7 +36,7 @@ namespace Microsoft.AspNetCore.Blazor.Build assets = AssetsWithHashes.Select(item => new AssetsManifestFileEntry { url = item.GetMetadata("AssetUrl"), - hash = $"sha256-{item.GetMetadata("FileHash")}", + hash = $"sha256-{item.GetMetadata("Integrity")}", }).ToArray() }; diff --git a/src/Components/WebAssembly/Build/src/Tasks/GzipCompressBlazorApplicationFiles.cs b/src/Components/WebAssembly/Build/src/Tasks/GzipCompressBlazorApplicationFiles.cs new file mode 100644 index 0000000000..65aeb409f5 --- /dev/null +++ b/src/Components/WebAssembly/Build/src/Tasks/GzipCompressBlazorApplicationFiles.cs @@ -0,0 +1,91 @@ +// 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.IO; +using System.IO.Compression; +using System.Runtime.Serialization; +using System.Runtime.Serialization.Json; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +namespace Microsoft.AspNetCore.Components.WebAssembly.Build +{ + public class GzipCompressBlazorApplicationFiles : Task + { + [Required] + public string ManifestPath { get; set; } + + public override bool Execute() + { + var serializer = new DataContractJsonSerializer(typeof(ManifestData)); + + ManifestData manifest = null; + using (var tempFile = File.OpenRead(ManifestPath)) + { + manifest = (ManifestData)serializer.ReadObject(tempFile); + } + + System.Threading.Tasks.Parallel.ForEach(manifest.FilesToCompress, (file) => + { + var inputPath = file.Source; + var inputSource = file.InputSource; + var targetCompressionPath = file.Target; + + if (!File.Exists(inputSource) || + (File.Exists(targetCompressionPath) && File.GetLastWriteTime(inputSource) > File.GetLastWriteTime(targetCompressionPath))) + { + // Incrementalism. If input source doesn't exist or it exists and is not newer than the expected output, do nothing. + if (!File.Exists(inputSource)) + { + Log.LogMessage($"Skipping '{inputPath}' because '{inputSource}' does not exist."); + } + else + { + Log.LogMessage($"Skipping '{inputPath}' because '{inputSource}' is newer than '{targetCompressionPath}'."); + } + return; + } + + try + { + Directory.CreateDirectory(Path.GetDirectoryName(targetCompressionPath)); + + using var sourceStream = File.OpenRead(inputPath); + using var fileStream = new FileStream(targetCompressionPath, FileMode.Create); + using var stream = new GZipStream(fileStream, CompressionLevel.Optimal); + + sourceStream.CopyTo(stream); + } + catch (Exception e) + { + Log.LogErrorFromException(e); + throw; + } + }); + + return !Log.HasLoggedErrors; + } + + [DataContract] + private class ManifestData + { + [DataMember] + public CompressedFile[] FilesToCompress { get; set; } + } + + [DataContract] + private class CompressedFile + { + [DataMember] + public string Source { get; set; } + + [DataMember] + public string InputSource { get; set; } + + [DataMember] + public string Target { get; set; } + } + } +} + diff --git a/src/Components/WebAssembly/Build/src/targets/Blazor.MonoRuntime.targets b/src/Components/WebAssembly/Build/src/targets/Blazor.MonoRuntime.targets index 9f24026634..5027568d5f 100644 --- a/src/Components/WebAssembly/Build/src/targets/Blazor.MonoRuntime.targets +++ b/src/Components/WebAssembly/Build/src/targets/Blazor.MonoRuntime.targets @@ -6,7 +6,7 @@ + DependsOnTargets="_ResolveBlazorInputs;_ResolveBlazorOutputs;_GenerateBlazorBootJson;_GenerateBlazorBootJsonIntegrity"> @@ -33,6 +33,16 @@ <_BlazorApplicationAssembliesCacheFile>$(_BlazorIntermediateOutputPath)unlinked.output + + + <_DotNetHostDirectory>$(NetCoreRoot) + <_DotNetHostFileName>dotnet + <_DotNetHostFileName Condition=" '$(OS)' == 'Windows_NT' ">dotnet.exe + + <_WebAssemblyBCLFolder Include=" $(ComponentsWebAssemblyBaseClassLibraryPath); @@ -123,6 +133,7 @@ <_BlazorOutputWithTargetPath Include="@(_BlazorJSFile)"> $(_BaseBlazorRuntimeOutputPath)%(FileName)%(Extension) + - - <_DotNetHostDirectory>$(NetCoreRoot) - <_DotNetHostFileName>dotnet - <_DotNetHostFileName Condition=" '$(OS)' == 'Windows_NT' ">dotnet.exe - - - - - - + + + + + + + + - <_BlazorOutputWithTargetPath Include="$(_BlazorBootJsonIntermediateOutputPath)" TargetOutputPath="$(_BaseBlazorRuntimeOutputPath)$(_BlazorBootJsonName)" /> + + <_BlazorBootJsonWithIntegrity Include="@(_BlazorBootJsonWithHash)"> + %(FileHash) + $(IntermediateOutputPath)integrity\$([System.String]::Copy('%(FileHash)').Replace('/','-').Replace('+','_')).hash' + + + <_BlazorOutputWithTargetPath Include="@(_BlazorBootJsonWithIntegrity)" RemoveMetadata="FileHash;FileHashAlgorithm"> + $(_BaseBlazorRuntimeOutputPath)$(_BlazorBootJsonName) + + + + + + + diff --git a/src/Components/WebAssembly/Build/src/targets/Compression.targets b/src/Components/WebAssembly/Build/src/targets/Compression.targets index ed8308809a..348d62481a 100644 --- a/src/Components/WebAssembly/Build/src/targets/Compression.targets +++ b/src/Components/WebAssembly/Build/src/targets/Compression.targets @@ -1,9 +1,10 @@ + <_BlazorBrotliPath>$(_BlazorToolsDir)compression\blazor-brotli.dll $(ResolveCurrentProjectStaticWebAssetsDependsOn); - _CompressBlazorApplicationFiles; + _ResolveBlazorFilesToCompress; @@ -12,16 +13,41 @@ <_BlazorFilesIntermediateOutputPath>$(IntermediateOutputPath)compressed\ + <_GzipCompressionBlazorApplicationFilesManifestPath>$(IntermediateOutputPath)compressed\gzip.manifest.json + <_BrotliCompressionBlazorApplicationFilesManifestPath>$(IntermediateOutputPath)compressed\brotli.manifest.json - <_BlazorFileToCompress Include="@(StaticWebAsset)" Condition="'%(SourceType)' == '' and $([System.String]::Copy('%(RelativePath)').Replace('\','/').StartsWith('_framework/'))" KeepDuplicates="false"> + + <_CompressionCandidate Include="@(StaticWebAsset)" Condition="'%(SourceType)' == '' and $([System.String]::Copy('%(RelativePath)').Replace('\','/').StartsWith('_framework/'))" KeepDuplicates="false" /> + <_CompressionCandidateIntegrity Include="@(_BlazorOutputWithTargetPath->'%(FullPath)')" /> + <_CompressionCandidateWithIntegrity Include="%(Identity)"> + @(_CompressionCandidate->'%(SourceType)') + @(_CompressionCandidate->'%(SourceId)') + @(_CompressionCandidate->'%(ContentRoot)') + @(_CompressionCandidate->'%(BasePath)') + @(_CompressionCandidate->'%(RelativePath)') + @(_CompressionCandidateIntegrity->'%(IntegrityFile)') + + + <_GzipBlazorFileToCompress Include="@(_CompressionCandidateWithIntegrity)"> $(_BlazorFilesIntermediateOutputPath)%(RelativePath).gz %(RelativePath).gz %(RelativePath).gz - $([MSBuild]::NormalizePath('$(_BlazorFilesIntermediateOutputPath)%(RelativePath).hash')) - + + <_GzipBlazorFileToCompress Remove="@(_BlazorFileCompressExclusion)" /> + + <_BrotliBlazorFileToCompress Include="@(_CompressionCandidateWithIntegrity)"> + $(_BlazorFilesIntermediateOutputPath)%(RelativePath).br + %(RelativePath).br + %(RelativePath).br + + <_BrotliBlazorFileToCompress Remove="@(_BlazorFileCompressExclusion)" /> + + <_BlazorFileToCompress Include="@(_GzipBlazorFileToCompress)" /> + <_BlazorFileToCompress Include="@(_BrotliBlazorFileToCompress)" /> + <_BlazorFileToCompress Remove="@(_BlazorFileCompressExclusion)" /> <_CompressedStaticWebAsset Include="@(_BlazorFileToCompress->'%(TargetCompressionPath)')" RemoveMetadata="TargetOutputPath;TargetCompressionPath" /> @@ -30,28 +56,53 @@ - - - + - + + + + + - - - + - + + + + + Name="_BrotliCompressBlazorApplicationFiles" + BeforeTargets="GetCopyToPublishDirectoryItems;_CopyResolvedFilesToPublishPreserveNewest" + DependsOnTargets="ResolveStaticWebAssetsInputs" + Inputs="$(_BrotliCompressionBlazorApplicationFilesManifestPath)" + Outputs="@(_BrotliBlazorFileToCompress->'%(TargetCompressionPath)')"> - + + + + + diff --git a/src/Components/WebAssembly/Build/src/targets/ServiceWorkerAssetsManifest.targets b/src/Components/WebAssembly/Build/src/targets/ServiceWorkerAssetsManifest.targets index 1cac810b55..fcf1b38503 100644 --- a/src/Components/WebAssembly/Build/src/targets/ServiceWorkerAssetsManifest.targets +++ b/src/Components/WebAssembly/Build/src/targets/ServiceWorkerAssetsManifest.targets @@ -1,17 +1,33 @@ + Condition="'$(ServiceWorkerAssetsManifest)' != ''" + BeforeTargets="_ResolveBlazorOutputs;_ResolveBlazorFilesToCompress"> - <_ServiceWorkerAssetsManifestIntermediateOutputPath>$(_BlazorIntermediateOutputPath)serviceworkerassets.js + <_ServiceWorkerAssetsManifestIntermediateOutputPath>$(_BlazorIntermediateOutputPath)$(ServiceWorkerAssetsManifest) + <_ServiceWorkerAssetsManifestFullPath>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/$(_ServiceWorkerAssetsManifestIntermediateOutputPath)')) - <_BlazorOutputWithTargetPath Condition="'$(ServiceWorkerAssetsManifest)' != ''" - Include="$(_ServiceWorkerAssetsManifestIntermediateOutputPath)" - TargetOutputPath="$(_BaseBlazorDistPath)$(ServiceWorkerAssetsManifest)" /> + <_BlazorOutputWithTargetPath + Include="$(_ServiceWorkerAssetsManifestFullPath)" + TargetOutputPath="$(_ServiceWorkerAssetsManifestIntermediateOutputPath)" /> + + <_ManifestStaticWebAsset Include="$(_ServiceWorkerAssetsManifestFullPath)"> + + $(PackageId) + $([MSBuild]::NormalizeDirectory('$(TargetDir)wwwroot\')) + $(StaticWebAssetBasePath) + $([MSBuild]::MakeRelative($([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/$(_BlazorIntermediateOutputPath)')), $(_ServiceWorkerAssetsManifestFullPath))) + + + + <_CompressionCandidate Include="@(_ManifestStaticWebAsset)" /> + <_CompressionCandidateWithIntegrity Include="@(_ManifestStaticWebAsset)"> + $(_ServiceWorkerAssetsManifestFullPath) + + @@ -19,11 +35,11 @@ + Condition="'$(ServiceWorkerAssetsManifest)' != ''" + Inputs="@(ServiceWorkerAssetsManifestItem)" + Outputs="$(_ServiceWorkerAssetsManifestIntermediateOutputPath)" + BeforeTargets="_ComputeManifestIntegrity" + DependsOnTargets="ResolveStaticWebAssetsInputs;_GenerateServiceWorkerIntermediateFiles"> - + + + + + + + + + <_ServiceWorkerManifestWithIntegrity Include="@(_ServiceWorkerManifestWithHash)"> + %(FileHash) + $(IntermediateOutputPath)integrity\$([System.String]::Copy('%(FileHash)').Replace('/','-').Replace('+','_')).hash' + + + + + + + + + + <_ServiceWorkerManifestIntegrityFile>$(IntermediateOutputPath)integrity\$([System.String]::Copy('%(_ServiceWorkerManifestWithIntegrity.FileHash)').Replace('/','-').Replace('+','_')).hash' + + + + <_GzipFileToPatch Include="@(_GzipBlazorFileToCompress)" Condition="'%(Identity)' == '$(_ServiceWorkerAssetsManifestFullPath)'" KeepDuplicates="false"> + $(_ServiceWorkerManifestIntegrityFile) + + + <_GzipBlazorFileToCompress Remove="@(_GzipFileToPatch)" /> + <_GzipBlazorFileToCompress Include="@(_GzipFileToPatch)" /> + + <_BrotliFileToPatch Include="@(_BrotliBlazorFileToCompress)" Condition="'%(Identity)' == '$(_ServiceWorkerAssetsManifestFullPath)'" KeepDuplicates="false"> + $(_ServiceWorkerManifestIntegrityFile) + + + <_BrotliBlazorFileToCompress Remove="@(_BrotliFileToPatch)" /> + <_BrotliBlazorFileToCompress Include="@(_BrotliFileToPatch)" /> + + + + + + + <_ServiceWorkerExclude Include="@(_StaticWebAssetIntegrity)" /> + <_ServiceWorkerItemBase Include="@(ServiceWorkerAssetsManifestItem)" /> + <_ServiceWorkerItemBase Remove="@(_ServiceWorkerExclude)" /> + <_ServiceWorkerItemHash Include="@(ServiceWorkerAssetsManifestItem)" /> + <_ServiceWorkerItemHash Remove="@(_ServiceWorkerItemBase)" /> + <_ServiceWorkerAssetsManifestItemWithHash Include="%(Identity)"> + @(_ServiceWorkerItemHash->'%(AssetUrl)') + @(_StaticWebAssetIntegrity->'%(Integrity)') + + + + + + + + <_StaticWebAssetsWithoutHash Include="@(StaticWebAsset)" Condition="'%(SourceType)' != '' or '%(ContentRoot)' == '$(_BlazorCurrentProjectWWWroot)'" /> + <_StaticWebAssetsWithoutHash Remove="@(_StaticWebAssetIntegrity)" /> - - + + + + + <_StaticWebAssetIntegrity Include="%(_StaticWebAssetHash.Identity)"> + %(_StaticWebAssetHash.FileHash) + + + + + Condition="'$(ServiceWorkerAssetsManifest)' != ''" + DependsOnTargets="_ComputeServiceWorkerAssetsManifestFileHashes"> <_CombinedHashIntermediatePath>$(_BlazorIntermediateOutputPath)serviceworkerhashes.txt @@ -89,7 +176,10 @@ - + + @@ -98,30 +188,45 @@ + Condition="'$(ServiceWorkerAssetsManifest)' != ''" + BeforeTargets="_ResolveBlazorOutputs" + DependsOnTargets="_ComputeServiceWorkerOutputs"> + - <_BlazorOutputWithTargetPath Include="@(_ServiceWorkerIntermediateFile)" /> + <_BlazorFileCompressExclusion Include="@(_ServiceWorkerIntermediateFile->'%(FullPath)')" /> + + <_ServiceWorkerStaticWebAsset Include="@(_ServiceWorkerIntermediateFile->'%(FullPath)')"> + + $(PackageId) + $([MSBuild]::NormalizeDirectory('$(TargetDir)wwwroot\')) + $(StaticWebAssetBasePath) + %(TargetOutputPath) + + + + - + + <_ServiceWorkerIntermediateFile Include="@(ServiceWorker->'$(IntermediateOutputPath)blazor\serviceworkers\%(Identity)')"> %(ServiceWorker.PublishedContent) %(ServiceWorker.Identity) %(ServiceWorker.Identity) - $([System.String]::Copy('%(ServiceWorker.Identity)').Substring(8)) - $([System.String]::Copy('%(ServiceWorker.Identity)').Substring(8)) + $([System.String]::Copy('%(ServiceWorker.Identity)').Substring(8)) + Condition="'$(ServiceWorkerAssetsManifest)' != ''" + Inputs="@(_ServiceWorkerIntermediateFile->'%(ContentSourcePath)'); $(_CombinedHashIntermediatePath)" + Outputs="@(_ServiceWorkerIntermediateFile)" + DependsOnTargets="_ComputeDefaultServiceWorkerAssetsManifestVersion"> + + <_BlazorCurrentProjectWWWroot>$([MSBuild]::NormalizeDirectory('$(MSBuildProjectDirectory)\wwwroot\')) + + - + <_BlazorOutputCandidateAsset Include="@(_BlazorOutputWithTargetPath->'%(FullPath)')"> $(PackageId) $([MSBuild]::NormalizeDirectory('$(TargetDir)wwwroot\')) $(StaticWebAssetBasePath) $([System.String]::Copy('%(_BlazorOutputWithTargetPath.TargetOutputPath)').Replace('\','/')) - + %(_BlazorOutputWithTargetPath.Integrity) + + + <_BlazorOutputCandidateAsset Remove="@(StaticWebAsset)" /> + + <_StaticWebAssetIntegrity Include="@(_BlazorOutputCandidateAsset)" KeepMetadata="Integrity" /> + + - @@ -39,12 +50,11 @@ AfterTargets="CopyFilesToOutputDirectory" Condition="'$(OutputType.ToLowerInvariant())'=='exe'"> - - <_BlazorCurrentProjectWWWroot>$([MSBuild]::NormalizeDirectory('$(MSBuildProjectDirectory)\wwwroot\')) - - - <_BlazorCopyLocalAssets Include="@(StaticWebAsset)" Condition="'%(SourceType)' == '' and '%(ContentRoot)' != '$(_BlazorCurrentProjectWWWroot)'" /> + <_BlazorCopyLocalAssets + Include="@(StaticWebAsset)" + Condition="'%(SourceType)' == '' and '%(ContentRoot)' != '$(_BlazorCurrentProjectWWWroot)' and !$([System.String]::Copy('%(RelativePath)').EndsWith('.br'))" /> + <_BlazorCopyLocalAssets Remove="@(_BlazorCopyLocalExclusion)" /> diff --git a/src/Components/WebAssembly/Build/test/BuildIntegrationTests/BuildCompressionTests.cs b/src/Components/WebAssembly/Build/test/BuildIntegrationTests/BuildCompressionTests.cs index d7581bc874..5c2e8e9c8e 100644 --- a/src/Components/WebAssembly/Build/test/BuildIntegrationTests/BuildCompressionTests.cs +++ b/src/Components/WebAssembly/Build/test/BuildIntegrationTests/BuildCompressionTests.cs @@ -113,6 +113,18 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Build Assert.Equal(frameworkFiles.Length, compressedFiles.Length); Assert.Equal(frameworkFiles, compressedFiles); + + var brotliFiles = Directory.EnumerateFiles( + compressedFilesPath, + "*", + SearchOption.AllDirectories) + .Where(f => Path.GetExtension(f) == ".br") + .Select(f => Path.GetRelativePath(compressedFilesPath, f[0..^3])) + .OrderBy(f => f) + .ToArray(); + + // We don't compress things with brotli at build time + Assert.Empty(brotliFiles); } [Fact] @@ -134,5 +146,112 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Build Assert.False(Directory.Exists(compressedFilesPath)); } + + [Fact] + public async Task Publish_WithLinkerAndCompression_IsIncremental() + { + // Arrange + using var project = ProjectDirectory.Create("blazorhosted", additionalProjects: new[] { "standalone", "razorclasslibrary" }); + var result = await MSBuildProcessManager.DotnetMSBuild(project, target: "publish"); + + Assert.BuildPassed(result); + + var buildOutputDirectory = project.BuildOutputDirectory; + + // Act + var compressedFilesFolder = Path.Combine("..", "standalone", project.IntermediateOutputDirectory, "compressed"); + var thumbPrint = FileThumbPrint.CreateFolderThumbprint(project, compressedFilesFolder); + + // Assert + for (var i = 0; i < 3; i++) + { + result = await MSBuildProcessManager.DotnetMSBuild(project); + Assert.BuildPassed(result); + + var newThumbPrint = FileThumbPrint.CreateFolderThumbprint(project, compressedFilesFolder); + Assert.Equal(thumbPrint.Count, newThumbPrint.Count); + for (var j = 0; j < thumbPrint.Count; j++) + { + Assert.Equal(thumbPrint[j], newThumbPrint[j]); + } + } + } + + [Fact] + public async Task Publish_WithoutLinkerAndCompression_IsIncremental() + { + // Arrange + using var project = ProjectDirectory.Create("blazorhosted", additionalProjects: new[] { "standalone", "razorclasslibrary" }); + var result = await MSBuildProcessManager.DotnetMSBuild(project, target: "publish", args: "/p:BlazorWebAssemblyEnableLinking=false"); + + Assert.BuildPassed(result); + + var buildOutputDirectory = project.BuildOutputDirectory; + + // Act + var compressedFilesFolder = Path.Combine("..", "standalone", project.IntermediateOutputDirectory, "compressed"); + var thumbPrint = FileThumbPrint.CreateFolderThumbprint(project, compressedFilesFolder); + + // Assert + for (var i = 0; i < 3; i++) + { + result = await MSBuildProcessManager.DotnetMSBuild(project, args: "/p:BlazorWebAssemblyEnableLinking=false"); + Assert.BuildPassed(result); + + var newThumbPrint = FileThumbPrint.CreateFolderThumbprint(project, compressedFilesFolder); + Assert.Equal(thumbPrint.Count, newThumbPrint.Count); + for (var j = 0; j < thumbPrint.Count; j++) + { + Assert.Equal(thumbPrint[j], newThumbPrint[j]); + } + } + } + + [Fact] + public async Task Publish_CompressesAllFrameworkFiles() + { + // Arrange + using var project = ProjectDirectory.Create("standalone", additionalProjects: new[] { "razorclasslibrary" }); + var result = await MSBuildProcessManager.DotnetMSBuild(project, target: "publish"); + + Assert.BuildPassed(result); + + var buildOutputDirectory = project.BuildOutputDirectory; + + var extensions = new[] { ".dll", ".js", ".pdb", ".wasm", ".map", ".json" }; + // Act + var compressedFilesPath = Path.Combine( + project.DirectoryPath, + "..", + "standalone", + project.IntermediateOutputDirectory, + "compressed", + "_framework"); + var compressedFiles = Directory.EnumerateFiles( + compressedFilesPath, + "*", + SearchOption.AllDirectories) + .Where(f => Path.GetExtension(f) == ".br") + .Select(f => Path.GetRelativePath(compressedFilesPath, f[0..^3])) + .OrderBy(f => f) + .ToArray(); + + var frameworkFilesPath = Path.Combine( + project.DirectoryPath, + project.BuildOutputDirectory, + "wwwroot", + "_framework"); + var frameworkFiles = Directory.EnumerateFiles( + frameworkFilesPath, + "*", + SearchOption.AllDirectories) + .Where(f => extensions.Contains(Path.GetExtension(f))) + .Select(f => Path.GetRelativePath(frameworkFilesPath, f)) + .OrderBy(f => f) + .ToArray(); + + Assert.Equal(frameworkFiles.Length, compressedFiles.Length); + Assert.Equal(frameworkFiles, compressedFiles); + } } } diff --git a/src/Components/WebAssembly/Build/test/BuildIntegrationTests/ProjectDirectory.cs b/src/Components/WebAssembly/Build/test/BuildIntegrationTests/ProjectDirectory.cs index 2c12d26df0..bddbea89be 100644 --- a/src/Components/WebAssembly/Build/test/BuildIntegrationTests/ProjectDirectory.cs +++ b/src/Components/WebAssembly/Build/test/BuildIntegrationTests/ProjectDirectory.cs @@ -211,5 +211,7 @@ $@" .FirstOrDefault(f => f.Key == key) ?.Value; } + + public override string ToString() => DirectoryPath; } } diff --git a/src/Components/WebAssembly/Build/test/BuildIntegrationTests/PublishIntegrationTest.cs b/src/Components/WebAssembly/Build/test/BuildIntegrationTests/PublishIntegrationTest.cs index d6bd0613ca..faa434efb2 100644 --- a/src/Components/WebAssembly/Build/test/BuildIntegrationTests/PublishIntegrationTest.cs +++ b/src/Components/WebAssembly/Build/test/BuildIntegrationTests/PublishIntegrationTest.cs @@ -11,6 +11,7 @@ using Microsoft.AspNetCore.Blazor.Build; using Xunit; using ResourceHashesByNameDictionary = System.Collections.Generic.Dictionary; using static Microsoft.AspNetCore.Components.WebAssembly.Build.WebAssemblyRuntimePackage; +using System.IO.Compression; namespace Microsoft.AspNetCore.Components.WebAssembly.Build { @@ -99,6 +100,20 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Build serviceWorkerPath: Path.Combine("serviceworkers", "my-service-worker.js"), serviceWorkerContent: "// This is the production service worker", assetsManifestPath: "custom-service-worker-assets.js"); + + VerifyCompression(result, blazorPublishDirectory); + } + + private static void VerifyCompression(MSBuildResult result, string blazorPublishDirectory) + { + var original = Assert.FileExists(result, blazorPublishDirectory, "_framework", "blazor.boot.json"); + var compressed = Assert.FileExists(result, blazorPublishDirectory, "_framework", "blazor.boot.json.br"); + using var brotliStream = new BrotliStream(File.OpenRead(compressed), CompressionMode.Decompress); + using var textReader = new StreamReader(brotliStream); + var uncompressedText = textReader.ReadToEnd(); + var originalText = File.ReadAllText(original); + + Assert.Equal(originalText, uncompressedText); } [Fact] diff --git a/src/Components/WebAssembly/Build/test/GenerateBlazorBootJsonTest.cs b/src/Components/WebAssembly/Build/test/GenerateBlazorBootJsonTest.cs index 40de7e68a2..5b95548d57 100644 --- a/src/Components/WebAssembly/Build/test/GenerateBlazorBootJsonTest.cs +++ b/src/Components/WebAssembly/Build/test/GenerateBlazorBootJsonTest.cs @@ -187,7 +187,7 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Build var mock = new Mock(); mock.Setup(m => m.GetMetadata("BootManifestResourceType")).Returns(type); mock.Setup(m => m.GetMetadata("BootManifestResourceName")).Returns(name); - mock.Setup(m => m.GetMetadata("FileHash")).Returns(fileHash); + mock.Setup(m => m.GetMetadata("Integrity")).Returns(fileHash); if (values != null) { diff --git a/src/Components/WebAssembly/Compression/src/Microsoft.AspNetCore.Components.WebAssembly.Build.BrotliCompression.csproj b/src/Components/WebAssembly/Compression/src/Microsoft.AspNetCore.Components.WebAssembly.Build.BrotliCompression.csproj new file mode 100644 index 0000000000..8c07cb9581 --- /dev/null +++ b/src/Components/WebAssembly/Compression/src/Microsoft.AspNetCore.Components.WebAssembly.Build.BrotliCompression.csproj @@ -0,0 +1,12 @@ + + + + $(DefaultNetCoreTargetFramework) + Exe + blazor-brotli + false + false + 3.1.0 + + + diff --git a/src/Components/WebAssembly/Compression/src/Program.cs b/src/Components/WebAssembly/Compression/src/Program.cs new file mode 100644 index 0000000000..093b2159ef --- /dev/null +++ b/src/Components/WebAssembly/Compression/src/Program.cs @@ -0,0 +1,87 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Text.Json; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Components.WebAssembly.Build.BrotliCompression +{ + class Program + { + private const int _error = -1; + + static async Task Main(string[] args) + { + if (args.Length != 1) + { + Console.Error.WriteLine("Invalid argument count. Usage: 'blazor-brotli <>'"); + return _error; + } + + var manifestPath = args[0]; + if (!File.Exists(manifestPath)) + { + Console.Error.WriteLine($"Manifest '{manifestPath}' does not exist."); + return -1; + } + + using var manifestStream = File.OpenRead(manifestPath); + + var manifest = await JsonSerializer.DeserializeAsync(manifestStream); + var result = 0; + Parallel.ForEach(manifest.FilesToCompress, (file) => + { + var inputPath = file.Source; + var inputSource = file.InputSource; + var targetCompressionPath = file.Target; + + if (!File.Exists(inputSource) || + (File.Exists(targetCompressionPath) && File.GetLastWriteTime(inputSource) > File.GetLastWriteTime(targetCompressionPath))) + { + // Incrementalism. If input source doesn't exist or it exists and is not newer than the expected output, do nothing. + if (!File.Exists(inputSource)) + { + Console.WriteLine($"Skipping '{inputPath}' because '{inputSource}' does not exist."); + } + else + { + Console.WriteLine($"Skipping '{inputPath}' because '{inputSource}' is newer than '{targetCompressionPath}'."); + } + return; + } + + try + { + Directory.CreateDirectory(Path.GetDirectoryName(targetCompressionPath)); + + using var sourceStream = File.OpenRead(inputPath); + using var fileStream = new FileStream(targetCompressionPath, FileMode.Create); + using var stream = new BrotliStream(fileStream, CompressionLevel.Optimal); + + sourceStream.CopyTo(stream); + } + catch (Exception e) + { + Console.Error.WriteLine(e); + result = -1; + } + }); + + return result; + } + + private class ManifestData + { + public CompressedFile[] FilesToCompress { get; set; } + } + + private class CompressedFile + { + public string Source { get; set; } + + public string InputSource { get; set; } + + public string Target { get; set; } + } + } +} diff --git a/src/Components/WebAssembly/Compression/src/runtimeconfig.template.json b/src/Components/WebAssembly/Compression/src/runtimeconfig.template.json new file mode 100644 index 0000000000..f022b7ffce --- /dev/null +++ b/src/Components/WebAssembly/Compression/src/runtimeconfig.template.json @@ -0,0 +1,3 @@ +{ + "rollForwardOnNoCandidateFx": 2 +} \ No newline at end of file diff --git a/src/Components/WebAssembly/Server/src/ComponentsWebAssemblyApplicationBuilderExtensions.cs b/src/Components/WebAssembly/Server/src/ComponentsWebAssemblyApplicationBuilderExtensions.cs index 4e6ab7ec5a..cef223fd72 100644 --- a/src/Components/WebAssembly/Server/src/ComponentsWebAssemblyApplicationBuilderExtensions.cs +++ b/src/Components/WebAssembly/Server/src/ComponentsWebAssemblyApplicationBuilderExtensions.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Net.Mime; +using Microsoft.AspNetCore.Components.WebAssembly.Server; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.StaticFiles; @@ -21,15 +22,6 @@ namespace Microsoft.AspNetCore.Builder /// public static class ComponentsWebAssemblyApplicationBuilderExtensions { - private static readonly HashSet _supportedEncodings = new HashSet(StringSegmentComparer.OrdinalIgnoreCase) - { - "gzip" - }; - - // List of encodings by preference order with their associated extension so that we can easily handle "*". - private static readonly List<(StringSegment encoding, string extension)> _preferredEncodings = - new List<(StringSegment encoding, string extension)>() { ("gzip", ".gz") }; - /// /// Configures the application to serve Blazor WebAssembly framework files from the path . This path must correspond to a referenced Blazor WebAssembly application project. /// @@ -55,11 +47,11 @@ namespace Microsoft.AspNetCore.Builder { context.Response.Headers.Append("Blazor-Environment", webHostEnvironment.EnvironmentName); - // This will invoke the static files middleware plugged-in below. - NegotiateEncoding(context, webHostEnvironment); await next(); }); + subBuilder.UseMiddleware(); + subBuilder.UseStaticFiles(options); }); @@ -84,6 +76,7 @@ namespace Microsoft.AspNetCore.Builder // We unconditionally map pdbs as there will be no pdbs in the output folder for // release builds unless BlazorEnableDebugging is explicitly set to true. AddMapping(contentTypeProvider, ".pdb", MediaTypeNames.Application.Octet); + AddMapping(contentTypeProvider, ".br", MediaTypeNames.Application.Octet); options.ContentTypeProvider = contentTypeProvider; @@ -96,11 +89,12 @@ namespace Microsoft.AspNetCore.Builder fileContext.Context.Response.Headers.Append(HeaderNames.CacheControl, "no-cache"); var requestPath = fileContext.Context.Request.Path; - if (string.Equals(Path.GetExtension(requestPath.Value), ".gz")) + var fileExtension = Path.GetExtension(requestPath.Value); + if (string.Equals(fileExtension, ".gz") || string.Equals(fileExtension, ".br")) { // When we are serving framework files (under _framework/ we perform content negotiation - // on the accept encoding and replace the path with <>.gz if we can serve gzip - // content. + // on the accept encoding and replace the path with <>.gz|br if we can serve gzip or brotli content + // respectively. // Here we simply calculate the original content type by removing the extension and apply it // again. // When we revisit this, we should consider calculating the original content type and storing it @@ -123,80 +117,5 @@ namespace Microsoft.AspNetCore.Builder provider.Mappings.Add(name, mimeType); } } - - private static void NegotiateEncoding(HttpContext context, IWebHostEnvironment webHost) - { - var accept = context.Request.Headers[HeaderNames.AcceptEncoding]; - if (StringValues.IsNullOrEmpty(accept)) - { - return; - } - - if (!StringWithQualityHeaderValue.TryParseList(accept, out var encodings) || encodings.Count == 0) - { - return; - } - - var selectedEncoding = StringSegment.Empty; - var selectedEncodingQuality = .0; - - foreach (var encoding in encodings) - { - var encodingName = encoding.Value; - var quality = encoding.Quality.GetValueOrDefault(1); - - if (quality < double.Epsilon) - { - continue; - } - - if (quality <= selectedEncodingQuality) - { - continue; - } - - if (_supportedEncodings.Contains(encodingName)) - { - selectedEncoding = encodingName; - selectedEncodingQuality = quality; - } - - if (StringSegment.Equals("*", encodingName, StringComparison.Ordinal)) - { - foreach (var candidate in _preferredEncodings) - { - if (ResourceExists(context, webHost, candidate.extension)) - { - selectedEncoding = candidate.encoding; - break; - } - } - - selectedEncodingQuality = quality; - } - - if (StringSegment.Equals("identity", encodingName, StringComparison.OrdinalIgnoreCase)) - { - selectedEncoding = StringSegment.Empty; - selectedEncodingQuality = quality; - } - } - - if (StringSegment.Equals("gzip", selectedEncoding, StringComparison.OrdinalIgnoreCase)) - { - if (ResourceExists(context, webHost, ".gz")) - { - // We only try to serve the pre-compressed file if it's actually there. - context.Request.Path = context.Request.Path + ".gz"; - context.Response.Headers[HeaderNames.ContentEncoding] = "gzip"; - context.Response.Headers.Append(HeaderNames.Vary, HeaderNames.ContentEncoding); - } - } - - return; - } - - private static bool ResourceExists(HttpContext context, IWebHostEnvironment webHost, string extension) => - webHost.WebRootFileProvider.GetFileInfo(context.Request.Path + extension).Exists; } } diff --git a/src/Components/WebAssembly/Server/src/ContentEncodingNegotiator.cs b/src/Components/WebAssembly/Server/src/ContentEncodingNegotiator.cs new file mode 100644 index 0000000000..7caddb2f75 --- /dev/null +++ b/src/Components/WebAssembly/Server/src/ContentEncodingNegotiator.cs @@ -0,0 +1,122 @@ +// 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 Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; +using Microsoft.Net.Http.Headers; + +namespace Microsoft.AspNetCore.Components.WebAssembly.Server +{ + internal class ContentEncodingNegotiator + { + // List of encodings by preference order with their associated extension so that we can easily handle "*". + private static readonly StringSegment[] _preferredEncodings = + new StringSegment[] { "br", "gzip" }; + + private static readonly Dictionary _encodingExtensionMap = new Dictionary(StringSegmentComparer.OrdinalIgnoreCase) + { + ["br"] = ".br", + ["gzip"] = ".gz" + }; + + private readonly RequestDelegate _next; + private readonly IWebHostEnvironment _webHostEnvironment; + + public ContentEncodingNegotiator(RequestDelegate next, IWebHostEnvironment webHostEnvironment) + { + _next = next; + _webHostEnvironment = webHostEnvironment; + } + + public Task InvokeAsync(HttpContext context) + { + NegotiateEncoding(context); + return _next(context); + } + + private void NegotiateEncoding(HttpContext context) + { + var accept = context.Request.Headers[HeaderNames.AcceptEncoding]; + + if (StringValues.IsNullOrEmpty(accept)) + { + return; + } + + if (!StringWithQualityHeaderValue.TryParseList(accept, out var encodings) || encodings.Count == 0) + { + return; + } + + var selectedEncoding = StringSegment.Empty; + var selectedEncodingQuality = .0; + + foreach (var encoding in encodings) + { + var encodingName = encoding.Value; + var quality = encoding.Quality.GetValueOrDefault(1); + + if (quality >= double.Epsilon && quality >= selectedEncodingQuality) + { + if (quality == selectedEncodingQuality) + { + selectedEncoding = PickPreferredEncoding(context, selectedEncoding, encoding); + } + else if (_encodingExtensionMap.TryGetValue(encodingName, out var encodingExtension) && ResourceExists(context, encodingExtension)) + { + selectedEncoding = encodingName; + selectedEncodingQuality = quality; + } + + if (StringSegment.Equals("*", encodingName, StringComparison.Ordinal)) + { + // If we *, pick the first preferrent encoding for which a resource exists. + selectedEncoding = PickPreferredEncoding(context, default, encoding); + selectedEncodingQuality = quality; + } + + if (StringSegment.Equals("identity", encodingName, StringComparison.OrdinalIgnoreCase)) + { + selectedEncoding = StringSegment.Empty; + selectedEncodingQuality = quality; + } + } + } + + if (_encodingExtensionMap.TryGetValue(selectedEncoding, out var extension)) + { + context.Request.Path = context.Request.Path + extension; + context.Response.Headers[HeaderNames.ContentEncoding] = selectedEncoding.Value; + context.Response.Headers.Append(HeaderNames.Vary, HeaderNames.ContentEncoding); + } + + return; + + StringSegment PickPreferredEncoding(HttpContext context, StringSegment selectedEncoding, StringWithQualityHeaderValue encoding) + { + foreach (var preferredEncoding in _preferredEncodings) + { + if (preferredEncoding == selectedEncoding) + { + return selectedEncoding; + } + + if ((preferredEncoding == encoding.Value || encoding.Value == "*") && ResourceExists(context, _encodingExtensionMap[preferredEncoding])) + { + return preferredEncoding; + } + } + + return StringSegment.Empty; + } + } + + private bool ResourceExists(HttpContext context, string extension) => + _webHostEnvironment.WebRootFileProvider.GetFileInfo(context.Request.Path + extension).Exists; + } +} diff --git a/src/Components/WebAssembly/Server/src/Microsoft.AspNetCore.Components.WebAssembly.Server.csproj b/src/Components/WebAssembly/Server/src/Microsoft.AspNetCore.Components.WebAssembly.Server.csproj index b0a60bc9c7..bd773f5e1c 100644 --- a/src/Components/WebAssembly/Server/src/Microsoft.AspNetCore.Components.WebAssembly.Server.csproj +++ b/src/Components/WebAssembly/Server/src/Microsoft.AspNetCore.Components.WebAssembly.Server.csproj @@ -1,4 +1,4 @@ - + $(DefaultNetCoreTargetFramework) @@ -12,6 +12,10 @@ $(NoWarn);NU5100 + + + + diff --git a/src/Components/WebAssembly/Server/test/ContentEncodingNegotiatorTests.cs b/src/Components/WebAssembly/Server/test/ContentEncodingNegotiatorTests.cs new file mode 100644 index 0000000000..5976ef1613 --- /dev/null +++ b/src/Components/WebAssembly/Server/test/ContentEncodingNegotiatorTests.cs @@ -0,0 +1,233 @@ +// 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.Threading.Tasks; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.FileProviders; +using Microsoft.Net.Http.Headers; +using Moq; +using Xunit; + +namespace Microsoft.AspNetCore.Components.WebAssembly.Server.Tests +{ + public class ContentEncodingNegotiatorTests + { + [Fact] + public async Task RespectsAcceptEncodingQuality() + { + var encoding = "gzip;q=0.5, deflate;q=0.3, br;q=0.2"; + var expectedPath = "/_framework/blazor.boot.json.gz"; + var expectedEncoding = "gzip"; + RequestDelegate next = (ctx) => Task.CompletedTask; + + var negotiator = new ContentEncodingNegotiator(next, CreateWebHostEnvironment()); + + var httpContext = new DefaultHttpContext(); + httpContext.Request.Path = "/_framework/blazor.boot.json"; + httpContext.Request.Headers.Append(HeaderNames.AcceptEncoding, encoding); + + await negotiator.InvokeAsync(httpContext); + + Assert.Equal(expectedPath, httpContext.Request.Path); + Assert.True(httpContext.Response.Headers.TryGetValue(HeaderNames.ContentEncoding, out var selectedEncoding)); + Assert.Equal(expectedEncoding, selectedEncoding); + Assert.True(httpContext.Response.Headers.TryGetValue(HeaderNames.Vary, out var varyHeader)); + Assert.Contains(HeaderNames.ContentEncoding, varyHeader.ToArray()); + } + + [Fact] + public async Task RespectsIdentity() + { + var encoding = "gzip;q=0.5, deflate;q=0.3, br;q=0.2, identity"; + var expectedPath = "/_framework/blazor.boot.json"; + RequestDelegate next = (ctx) => Task.CompletedTask; + + var negotiator = new ContentEncodingNegotiator(next, CreateWebHostEnvironment()); + + var httpContext = new DefaultHttpContext(); + httpContext.Request.Path = "/_framework/blazor.boot.json"; + httpContext.Request.Headers.Append(HeaderNames.AcceptEncoding, encoding); + + await negotiator.InvokeAsync(httpContext); + + Assert.Equal(expectedPath, httpContext.Request.Path); + Assert.False(httpContext.Response.Headers.TryGetValue(HeaderNames.ContentEncoding, out var selectedEncoding)); + Assert.False(httpContext.Response.Headers.TryGetValue(HeaderNames.Vary, out var varyHeader)); + } + + [Fact] + public async Task SkipsNonExistingFiles() + { + var encoding = "gzip;q=0.5, deflate;q=0.3, br"; + var expectedPath = "/_framework/blazor.boot.json.gz"; + var expectedEncoding = "gzip"; + RequestDelegate next = (ctx) => Task.CompletedTask; + + var negotiator = new ContentEncodingNegotiator(next, CreateWebHostEnvironment(brotliExists: false)); + + var httpContext = new DefaultHttpContext(); + httpContext.Request.Path = "/_framework/blazor.boot.json"; + httpContext.Request.Headers.Append(HeaderNames.AcceptEncoding, encoding); + + await negotiator.InvokeAsync(httpContext); + + Assert.Equal(expectedPath, httpContext.Request.Path); + Assert.True(httpContext.Response.Headers.TryGetValue(HeaderNames.ContentEncoding, out var selectedEncoding)); + Assert.Equal(expectedEncoding, selectedEncoding); + Assert.True(httpContext.Response.Headers.TryGetValue(HeaderNames.Vary, out var varyHeader)); + Assert.Contains(HeaderNames.ContentEncoding, varyHeader.ToArray()); + } + + [Fact] + public async Task UsesPreferredServerEncodingForEqualQualityValues() + { + var encoding = "gzip, deflate, br"; + var expectedPath = "/_framework/blazor.boot.json.br"; + var expectedEncoding = "br"; + RequestDelegate next = (ctx) => Task.CompletedTask; + + var negotiator = new ContentEncodingNegotiator(next, CreateWebHostEnvironment()); + + var httpContext = new DefaultHttpContext(); + httpContext.Request.Path = "/_framework/blazor.boot.json"; + httpContext.Request.Headers.Append(HeaderNames.AcceptEncoding, encoding); + + await negotiator.InvokeAsync(httpContext); + + Assert.Equal(expectedPath, httpContext.Request.Path); + Assert.True(httpContext.Response.Headers.TryGetValue(HeaderNames.ContentEncoding, out var selectedEncoding)); + Assert.Equal(expectedEncoding, selectedEncoding); + Assert.True(httpContext.Response.Headers.TryGetValue(HeaderNames.Vary, out var varyHeader)); + Assert.Contains(HeaderNames.ContentEncoding, varyHeader.ToArray()); + } + + [Fact] + public async Task SkipNonExistingFilesWhenSearchingForServerPreferencesPreferences() + { + var encoding = "gzip, deflate, br"; + var expectedPath = "/_framework/blazor.boot.json.gz"; + var expectedEncoding = "gzip"; + RequestDelegate next = (ctx) => Task.CompletedTask; + + var negotiator = new ContentEncodingNegotiator(next, CreateWebHostEnvironment(brotliExists: false)); + + var httpContext = new DefaultHttpContext(); + httpContext.Request.Path = "/_framework/blazor.boot.json"; + httpContext.Request.Headers.Append(HeaderNames.AcceptEncoding, encoding); + + await negotiator.InvokeAsync(httpContext); + + Assert.Equal(expectedPath, httpContext.Request.Path); + Assert.True(httpContext.Response.Headers.TryGetValue(HeaderNames.ContentEncoding, out var selectedEncoding)); + Assert.Equal(expectedEncoding, selectedEncoding); + Assert.True(httpContext.Response.Headers.TryGetValue(HeaderNames.Vary, out var varyHeader)); + Assert.Contains(HeaderNames.ContentEncoding, varyHeader.ToArray()); + } + + [Fact] + public async Task AnyUsesServerPreference() + { + var encoding = "*"; + var expectedPath = "/_framework/blazor.boot.json.br"; + var expectedEncoding = "br"; + RequestDelegate next = (ctx) => Task.CompletedTask; + + var negotiator = new ContentEncodingNegotiator(next, CreateWebHostEnvironment()); + + var httpContext = new DefaultHttpContext(); + httpContext.Request.Path = "/_framework/blazor.boot.json"; + httpContext.Request.Headers.Append(HeaderNames.AcceptEncoding, encoding); + + await negotiator.InvokeAsync(httpContext); + + Assert.Equal(expectedPath, httpContext.Request.Path); + Assert.True(httpContext.Response.Headers.TryGetValue(HeaderNames.ContentEncoding, out var selectedEncoding)); + Assert.Equal(expectedEncoding, selectedEncoding); + Assert.True(httpContext.Response.Headers.TryGetValue(HeaderNames.Vary, out var varyHeader)); + Assert.Contains(HeaderNames.ContentEncoding, varyHeader.ToArray()); + } + + [Fact] + public async Task AnySkipsNonExistingFiles() + { + var encoding = "*"; + var expectedPath = "/_framework/blazor.boot.json.gz"; + var expectedEncoding = "gzip"; + RequestDelegate next = (ctx) => Task.CompletedTask; + + var negotiator = new ContentEncodingNegotiator(next, CreateWebHostEnvironment(brotliExists: false)); + + var httpContext = new DefaultHttpContext(); + httpContext.Request.Path = "/_framework/blazor.boot.json"; + httpContext.Request.Headers.Append(HeaderNames.AcceptEncoding, encoding); + + await negotiator.InvokeAsync(httpContext); + + Assert.Equal(expectedPath, httpContext.Request.Path); + Assert.True(httpContext.Response.Headers.TryGetValue(HeaderNames.ContentEncoding, out var selectedEncoding)); + Assert.Equal(expectedEncoding, selectedEncoding); + Assert.True(httpContext.Response.Headers.TryGetValue(HeaderNames.Vary, out var varyHeader)); + Assert.Contains(HeaderNames.ContentEncoding, varyHeader.ToArray()); + } + + [Fact] + public async Task AnyDoesNotPickEncodingIfNoFilesFound() + { + var encoding = "*"; + var expectedPath = "/_framework/blazor.boot.json"; + RequestDelegate next = (ctx) => Task.CompletedTask; + + var negotiator = new ContentEncodingNegotiator(next, CreateWebHostEnvironment(gzipExists: false, brotliExists: false)); + + var httpContext = new DefaultHttpContext(); + httpContext.Request.Path = "/_framework/blazor.boot.json"; + httpContext.Request.Headers.Append(HeaderNames.AcceptEncoding, encoding); + + await negotiator.InvokeAsync(httpContext); + + Assert.Equal(expectedPath, httpContext.Request.Path); + Assert.False(httpContext.Response.Headers.TryGetValue(HeaderNames.ContentEncoding, out var selectedEncoding)); + Assert.False(httpContext.Response.Headers.TryGetValue(HeaderNames.Vary, out var varyHeader)); + } + + [Fact] + public async Task AnyRespectsServerPreference() + { + var encoding = "gzip;q=0.5, *;q=0.8, br;q=0.2"; + var expectedPath = "/_framework/blazor.boot.json.br"; + var expectedEncoding = "br"; + RequestDelegate next = (ctx) => Task.CompletedTask; + + var negotiator = new ContentEncodingNegotiator(next, CreateWebHostEnvironment()); + + var httpContext = new DefaultHttpContext(); + httpContext.Request.Path = "/_framework/blazor.boot.json"; + httpContext.Request.Headers.Append(HeaderNames.AcceptEncoding, encoding); + + await negotiator.InvokeAsync(httpContext); + + Assert.Equal(expectedPath, httpContext.Request.Path); + Assert.True(httpContext.Response.Headers.TryGetValue(HeaderNames.ContentEncoding, out var selectedEncoding)); + Assert.Equal(expectedEncoding, selectedEncoding); + Assert.True(httpContext.Response.Headers.TryGetValue(HeaderNames.Vary, out var varyHeader)); + Assert.Contains(HeaderNames.ContentEncoding, varyHeader.ToArray()); + } + + private static IWebHostEnvironment CreateWebHostEnvironment(bool gzipExists = true, bool brotliExists = true) + { + var gzMock = new Mock(); + gzMock.Setup(m => m.Exists).Returns(gzipExists); + var brMock = new Mock(); + brMock.Setup(m => m.Exists).Returns(brotliExists); + var fileProviderMock = new Mock(); + fileProviderMock.Setup(f => f.GetFileInfo("/_framework/blazor.boot.json.gz")).Returns(gzMock.Object); + fileProviderMock.Setup(f => f.GetFileInfo("/_framework/blazor.boot.json.br")).Returns(brMock.Object); + + var env = new Mock(); + env.Setup(e => e.WebRootFileProvider).Returns(fileProviderMock.Object); + + return env.Object; + } + } +} diff --git a/src/Components/WebAssembly/Server/test/Microsoft.AspNetCore.Components.WebAssembly.Server.Tests.csproj b/src/Components/WebAssembly/Server/test/Microsoft.AspNetCore.Components.WebAssembly.Server.Tests.csproj new file mode 100644 index 0000000000..db1d638a06 --- /dev/null +++ b/src/Components/WebAssembly/Server/test/Microsoft.AspNetCore.Components.WebAssembly.Server.Tests.csproj @@ -0,0 +1,13 @@ + + + + $(DefaultNetCoreTargetFramework) + + true + + + + + + + diff --git a/src/ProjectTemplates/test/BlazorWasmTemplateTest.cs b/src/ProjectTemplates/test/BlazorWasmTemplateTest.cs index 8905f0934e..7b599785a8 100644 --- a/src/ProjectTemplates/test/BlazorWasmTemplateTest.cs +++ b/src/ProjectTemplates/test/BlazorWasmTemplateTest.cs @@ -5,6 +5,8 @@ using System; using System.IO; using System.Linq; using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; using System.Text.Json; using System.Text.RegularExpressions; using System.Threading; @@ -84,6 +86,7 @@ namespace Templates.Test ErrorMessages.GetFailedProcessMessageOrEmpty("Run published project", serverProject, aspNetProcess.Process)); await aspNetProcess.AssertStatusCode("/", HttpStatusCode.OK, "text/html"); + await AssertCompressionFormat(aspNetProcess, "br"); if (BrowserFixture.IsHostAutomationSupported()) { aspNetProcess.VisitInBrowser(Browser); @@ -95,6 +98,22 @@ namespace Templates.Test } } + private static async Task AssertCompressionFormat(AspNetProcess aspNetProcess, string expectedEncoding) + { + var response = await aspNetProcess.SendRequest(() => + { + var request = new HttpRequestMessage(HttpMethod.Get, new Uri(aspNetProcess.ListeningUri, "/_framework/blazor.boot.json")); + // These are the same as chrome + request.Headers.AcceptEncoding.Clear(); + request.Headers.AcceptEncoding.Add(StringWithQualityHeaderValue.Parse("gzip")); + request.Headers.AcceptEncoding.Add(StringWithQualityHeaderValue.Parse("deflate")); + request.Headers.AcceptEncoding.Add(StringWithQualityHeaderValue.Parse("br")); + + return request; + }); + Assert.Equal(expectedEncoding, response.Content.Headers.ContentEncoding.Single()); + } + [Fact] public async Task BlazorWasmStandalonePwaTemplate_Works() { @@ -389,6 +408,8 @@ namespace Templates.Test ErrorMessages.GetFailedProcessMessageOrEmpty("Run built project", project, aspNetProcess.Process)); await aspNetProcess.AssertStatusCode("/", HttpStatusCode.OK, "text/html"); + // We only do brotli precompression for published apps + await AssertCompressionFormat(aspNetProcess, "gzip"); if (BrowserFixture.IsHostAutomationSupported()) { aspNetProcess.VisitInBrowser(Browser); diff --git a/src/ProjectTemplates/test/Helpers/AspNetProcess.cs b/src/ProjectTemplates/test/Helpers/AspNetProcess.cs index 753eb1258a..97cec926b8 100644 --- a/src/ProjectTemplates/test/Helpers/AspNetProcess.cs +++ b/src/ProjectTemplates/test/Helpers/AspNetProcess.cs @@ -240,9 +240,16 @@ namespace Templates.Test.Helpers return RequestWithRetries(client => client.GetAsync(new Uri(ListeningUri, path)), _httpClient); } + internal Task SendRequest(Func requestFactory) + { + return RequestWithRetries(client => client.SendAsync(requestFactory()), _httpClient); + } + + public async Task AssertStatusCode(string requestUrl, HttpStatusCode statusCode, string acceptContentType = null) { - var response = await RequestWithRetries(client => { + var response = await RequestWithRetries(client => + { var request = new HttpRequestMessage( HttpMethod.Get, new Uri(ListeningUri, requestUrl)); diff --git a/src/ProjectTemplates/test/Helpers/Project.cs b/src/ProjectTemplates/test/Helpers/Project.cs index 19f1d46b7f..10dd191bc2 100644 --- a/src/ProjectTemplates/test/Helpers/Project.cs +++ b/src/ProjectTemplates/test/Helpers/Project.cs @@ -120,7 +120,7 @@ namespace Templates.Test.Helpers await effectiveLock.WaitAsync(); try { - var result = ProcessEx.Run(Output, TemplateOutputDir, DotNetMuxer.MuxerPathOrDefault(), $"publish -c Release /bl /nr:false {additionalArgs}", packageOptions); + var result = ProcessEx.Run(Output, TemplateOutputDir, DotNetMuxer.MuxerPathOrDefault(), $"publish -c Release /bl:publish.binlog /nr:false {additionalArgs}", packageOptions); await result.Exited; CaptureBinLogOnFailure(result); return result; @@ -515,10 +515,20 @@ namespace Templates.Test.Helpers { if (result.ExitCode != 0 && !string.IsNullOrEmpty(ArtifactsLogDir)) { - var sourceFile = Path.Combine(TemplateOutputDir, "msbuild.binlog"); - Assert.True(File.Exists(sourceFile), $"Log for '{ProjectName}' not found in '{sourceFile}'."); - var destination = Path.Combine(ArtifactsLogDir, ProjectName + ".binlog"); - File.Move(sourceFile, destination); + var build = Path.Combine(TemplateOutputDir, "msbuild.binlog"); + var publish = Path.Combine(TemplateOutputDir, "publish.binlog"); + Assert.True(File.Exists(build) || File.Exists(publish), $"Log for '{ProjectName}' not found in '{build}'."); + if (File.Exists(build)) + { + var destination = Path.Combine(ArtifactsLogDir, ProjectName + ".binlog"); + File.Move(build, destination); + } + + if (File.Exists(publish)) + { + var destination = Path.Combine(ArtifactsLogDir, ProjectName + ".publish.binlog"); + File.Move(publish, destination); + } } }