From c9c06f573db363f6fa46d3973ade55749ffaf1da Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Fri, 21 Feb 2020 09:32:07 -0800 Subject: [PATCH] [Blazor] Adds support for statically pre-compressing assets using gzip (#19157) * Adds a task to perform gzip compession. * Gzips framework files incrementally * Serves pre-compressed versions of the framework files when possible. --- .../Tasks/CompressBlazorApplicationFiles.cs | 32 ++++ .../WebAssembly/Build/src/targets/All.props | 2 + .../WebAssembly/Build/src/targets/All.targets | 1 + .../Build/src/targets/Compression.targets | 63 ++++++++ .../BuildCompressionTests.cs | 138 ++++++++++++++++++ .../DevServer/src/Server/Startup.cs | 9 -- ...WebAssemblyApplicationBuilderExtensions.cs | 115 ++++++++++++++- .../HostedInAspNet.Client.csproj | 4 +- .../Wasm.Authentication.Server/Startup.cs | 9 -- 9 files changed, 353 insertions(+), 20 deletions(-) create mode 100644 src/Components/WebAssembly/Build/src/Tasks/CompressBlazorApplicationFiles.cs create mode 100644 src/Components/WebAssembly/Build/src/targets/Compression.targets create mode 100644 src/Components/WebAssembly/Build/test/BuildIntegrationTests/BuildCompressionTests.cs 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();