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);
+ }
}
}