diff --git a/src/Components/WebAssembly/Build/src/Tasks/CompressBlazorApplicationFiles.cs b/src/Components/WebAssembly/Build/src/Tasks/CompressBlazorApplicationFiles.cs
new file mode 100644
index 0000000000..12cfaa6f6c
--- /dev/null
+++ b/src/Components/WebAssembly/Build/src/Tasks/CompressBlazorApplicationFiles.cs
@@ -0,0 +1,32 @@
+// 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/targets/All.props b/src/Components/WebAssembly/Build/src/targets/All.props
index 96c13335b6..8315d3cb40 100644
--- a/src/Components/WebAssembly/Build/src/targets/All.props
+++ b/src/Components/WebAssembly/Build/src/targets/All.props
@@ -10,5 +10,7 @@
true
+
+ true
diff --git a/src/Components/WebAssembly/Build/src/targets/All.targets b/src/Components/WebAssembly/Build/src/targets/All.targets
index c2954b6bf4..6e5e1bdac5 100644
--- a/src/Components/WebAssembly/Build/src/targets/All.targets
+++ b/src/Components/WebAssembly/Build/src/targets/All.targets
@@ -22,5 +22,6 @@
+
diff --git a/src/Components/WebAssembly/Build/src/targets/Compression.targets b/src/Components/WebAssembly/Build/src/targets/Compression.targets
new file mode 100644
index 0000000000..25e8b50443
--- /dev/null
+++ b/src/Components/WebAssembly/Build/src/targets/Compression.targets
@@ -0,0 +1,63 @@
+
+
+
+
+ $(ResolveCurrentProjectStaticWebAssetsDependsOn);
+ _CompressBlazorApplicationFiles;
+
+
+
+
+
+
+
+ <_BlazorFilesIntermediateOutputPath>$(IntermediateOutputPath)compressed\
+
+
+
+
+ <_BlazorFileToCompress Include="@(StaticWebAsset)" Condition="'%(SourceType)' == '' and $([System.String]::Copy('%(RelativePath)').Replace('\','/').StartsWith('_framework/'))" KeepDuplicates="false">
+ $(_BlazorFilesIntermediateOutputPath)%(RelativePath).gz
+ %(RelativePath).gz
+ %(RelativePath).gz
+ %(FullPath)
+
+
+
+ <_BlazorFileToCompress Condition="'$(BlazorLinkOnBuild)' == 'true' and ('%(Extension)' == '.dll' or '%(Extension)' == '.pdb')">
+ $([MSBuild]::NormalizePath('$(_BlazorFilesIntermediateOutputPath)%(RelativePath).hash'))
+
+
+ <_CompressedStaticWebAsset Include="@(_BlazorFileToCompress->'%(TargetCompressionPath)')" RemoveMetadata="TargetOutputPath;TargetCompressionPath" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Components/WebAssembly/Build/test/BuildIntegrationTests/BuildCompressionTests.cs b/src/Components/WebAssembly/Build/test/BuildIntegrationTests/BuildCompressionTests.cs
new file mode 100644
index 0000000000..4d6115c452
--- /dev/null
+++ b/src/Components/WebAssembly/Build/test/BuildIntegrationTests/BuildCompressionTests.cs
@@ -0,0 +1,138 @@
+// 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.Linq;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace Microsoft.AspNetCore.Components.WebAssembly.Build
+{
+ public class BuildCompressionTests
+ {
+ [Fact]
+ public async Task Build_WithLinkerAndCompression_IsIncremental()
+ {
+ // Arrange
+ using var project = ProjectDirectory.Create("standalone");
+ var result = await MSBuildProcessManager.DotnetMSBuild(project);
+
+ Assert.BuildPassed(result);
+
+ var buildOutputDirectory = project.BuildOutputDirectory;
+
+ // Act
+ var compressedFilesFolder = Path.Combine(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 Build_WithoutLinkerAndCompression_IsIncremental()
+ {
+ // Arrange
+ using var project = ProjectDirectory.Create("standalone");
+ var result = await MSBuildProcessManager.DotnetMSBuild(project, args: "/p:BlazorLinkOnBuild=false");
+
+ Assert.BuildPassed(result);
+
+ var buildOutputDirectory = project.BuildOutputDirectory;
+
+ // Act
+ var compressedFilesFolder = Path.Combine(project.IntermediateOutputDirectory, "compressed");
+ var thumbPrint = FileThumbPrint.CreateFolderThumbprint(project, compressedFilesFolder);
+
+ // Assert
+ for (var i = 0; i < 3; i++)
+ {
+ result = await MSBuildProcessManager.DotnetMSBuild(project, args: "/p:BlazorLinkOnBuild=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 Build_CompressesAllFrameworkFiles()
+ {
+ // Arrange
+ using var project = ProjectDirectory.Create("standalone");
+ var result = await MSBuildProcessManager.DotnetMSBuild(project);
+
+ Assert.BuildPassed(result);
+
+ var buildOutputDirectory = project.BuildOutputDirectory;
+
+ var extensions = new[] { ".dll", ".js", ".pdb", ".wasm", ".map", ".json" };
+ // Act
+ var compressedFilesPath = Path.Combine(
+ project.DirectoryPath,
+ project.IntermediateOutputDirectory,
+ "compressed",
+ "_framework");
+ var compressedFiles = Directory.EnumerateFiles(
+ compressedFilesPath,
+ "*",
+ SearchOption.AllDirectories)
+ .Where(f => Path.GetExtension(f) == ".gz")
+ .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);
+ }
+
+ [Fact]
+ public async Task Build_DisabledCompression_DoesNotCompressFiles()
+ {
+ // Arrange
+ using var project = ProjectDirectory.Create("standalone");
+
+ // Act
+ var result = await MSBuildProcessManager.DotnetMSBuild(project, args: "/p:BlazorEnableCompression=false");
+
+ //Assert
+ Assert.BuildPassed(result);
+
+ var compressedFilesPath = Path.Combine(
+ project.DirectoryPath,
+ project.IntermediateOutputDirectory,
+ "compressed");
+
+ Assert.False(Directory.Exists(compressedFilesPath));
+ }
+ }
+}
diff --git a/src/Components/WebAssembly/DevServer/src/Server/Startup.cs b/src/Components/WebAssembly/DevServer/src/Server/Startup.cs
index 6b6c2a91f0..d0f6b273c4 100644
--- a/src/Components/WebAssembly/DevServer/src/Server/Startup.cs
+++ b/src/Components/WebAssembly/DevServer/src/Server/Startup.cs
@@ -28,20 +28,11 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.DevServer.Server
{
services.AddRouting();
- services.AddResponseCompression(options =>
- {
- options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(new[]
- {
- MediaTypeNames.Application.Octet,
- "application/wasm",
- });
- });
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment environment, IConfiguration configuration)
{
app.UseDeveloperExceptionPage();
- app.UseResponseCompression();
EnableConfiguredPathbase(app, configuration);
app.UseBlazorDebugging();
diff --git a/src/Components/WebAssembly/Server/src/ComponentsWebAssemblyApplicationBuilderExtensions.cs b/src/Components/WebAssembly/Server/src/ComponentsWebAssemblyApplicationBuilderExtensions.cs
index eaf19da09e..09493b747b 100644
--- a/src/Components/WebAssembly/Server/src/ComponentsWebAssemblyApplicationBuilderExtensions.cs
+++ b/src/Components/WebAssembly/Server/src/ComponentsWebAssemblyApplicationBuilderExtensions.cs
@@ -2,12 +2,16 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
using System.Net.Mime;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
+using Microsoft.Extensions.Primitives;
using Microsoft.Net.Http.Headers;
namespace Microsoft.AspNetCore.Builder
@@ -17,6 +21,15 @@ 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.
///
@@ -34,7 +47,8 @@ namespace Microsoft.AspNetCore.Builder
var options = CreateStaticFilesOptions(webHostEnvironment.WebRootFileProvider);
- builder.MapWhen(ctx => ctx.Request.Path.StartsWithSegments(pathPrefix, out var rest) && rest.StartsWithSegments("/_framework") && !rest.StartsWithSegments("/_framework/blazor.server.js"),
+ builder.MapWhen(ctx => ctx.Request.Path.StartsWithSegments(pathPrefix, out var rest) && rest.StartsWithSegments("/_framework") &&
+ !rest.StartsWithSegments("/_framework/blazor.server.js"),
subBuilder =>
{
subBuilder.Use(async (ctx, next) =>
@@ -42,6 +56,7 @@ namespace Microsoft.AspNetCore.Builder
// At this point we mapped something from the /_framework
ctx.Response.Headers.Append(HeaderNames.CacheControl, "no-cache");
// This will invoke the static files middleware plugged-in below.
+ NegotiateEncoding(ctx, webHostEnvironment);
await next();
});
@@ -72,6 +87,29 @@ namespace Microsoft.AspNetCore.Builder
options.ContentTypeProvider = contentTypeProvider;
+ // Static files middleware will try to use application/x-gzip as the content
+ // type when serving a file with a gz extension. We need to correct that before
+ // sending the file.
+ options.OnPrepareResponse = fileContext =>
+ {
+ var requestPath = fileContext.Context.Request.Path;
+ if (string.Equals(Path.GetExtension(requestPath.Value), ".gz"))
+ {
+ // 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.
+ // 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
+ // in the request along with the original target path so that we don't have to calculate it here.
+ var originalPath = Path.GetFileNameWithoutExtension(requestPath.Value);
+ if (contentTypeProvider.TryGetContentType(originalPath, out var originalContentType))
+ {
+ fileContext.Context.Response.ContentType = originalContentType;
+ }
+ }
+ };
+
return options;
}
@@ -82,5 +120,80 @@ 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/testassets/HostedInAspNet.Client/HostedInAspNet.Client.csproj b/src/Components/WebAssembly/testassets/HostedInAspNet.Client/HostedInAspNet.Client.csproj
index e99d7656df..37134c58ec 100644
--- a/src/Components/WebAssembly/testassets/HostedInAspNet.Client/HostedInAspNet.Client.csproj
+++ b/src/Components/WebAssembly/testassets/HostedInAspNet.Client/HostedInAspNet.Client.csproj
@@ -1,10 +1,12 @@
-
+
netstandard2.1
Exe
true
3.0
+
+ false
diff --git a/src/Components/WebAssembly/testassets/Wasm.Authentication.Server/Startup.cs b/src/Components/WebAssembly/testassets/Wasm.Authentication.Server/Startup.cs
index f7fdfab434..e5a7d2608e 100644
--- a/src/Components/WebAssembly/testassets/Wasm.Authentication.Server/Startup.cs
+++ b/src/Components/WebAssembly/testassets/Wasm.Authentication.Server/Startup.cs
@@ -1,8 +1,6 @@
-using System.Linq;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
-using Microsoft.AspNetCore.ResponseCompression;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
@@ -38,18 +36,11 @@ namespace Wasm.Authentication.Server
.AddIdentityServerJwt();
services.AddMvc();
- services.AddResponseCompression(opts =>
- {
- opts.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(
- new[] { "application/octet-stream" });
- });
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
- app.UseResponseCompression();
-
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();