[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.
This commit is contained in:
Javier Calvarro Nelson 2020-02-21 09:32:07 -08:00 committed by GitHub
parent 0541e19ac2
commit c9c06f573d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 353 additions and 20 deletions

View File

@ -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;
}
}
}

View File

@ -10,5 +10,7 @@
<!-- When using IISExpress with a standalone app, there's no point restarting IISExpress after build. It slows things unnecessarily and breaks in-flight HTTP requests. -->
<NoRestartServerOnBuild>true</NoRestartServerOnBuild>
<BlazorEnableCompression Condition="'$(BlazorEnableCompression)' == ''">true</BlazorEnableCompression>
</PropertyGroup>
</Project>

View File

@ -22,5 +22,6 @@
<Import Project="Publish.targets" />
<Import Project="StaticWebAssets.targets" />
<Import Project="ServiceWorkerAssetsManifest.targets" />
<Import Project="Compression.targets" Condition="'$(BlazorEnableCompression)' == 'true'" />
</Project>

View File

@ -0,0 +1,63 @@
<Project>
<PropertyGroup>
<ResolveCurrentProjectStaticWebAssetsDependsOn>
$(ResolveCurrentProjectStaticWebAssetsDependsOn);
_CompressBlazorApplicationFiles;
</ResolveCurrentProjectStaticWebAssetsDependsOn>
</PropertyGroup>
<Target Name="_ResolveBlazorFilesToCompress" AfterTargets="_ResolveBlazorGeneratedAssets">
<PropertyGroup>
<_BlazorFilesIntermediateOutputPath>$(IntermediateOutputPath)compressed\</_BlazorFilesIntermediateOutputPath>
</PropertyGroup>
<MakeDir Directories="$(_BlazorFilesIntermediateOutputPath)" />
<ItemGroup>
<_BlazorFileToCompress Include="@(StaticWebAsset)" Condition="'%(SourceType)' == '' and $([System.String]::Copy('%(RelativePath)').Replace('\','/').StartsWith('_framework/'))" KeepDuplicates="false">
<TargetCompressionPath>$(_BlazorFilesIntermediateOutputPath)%(RelativePath).gz</TargetCompressionPath>
<TargetOutputPath>%(RelativePath).gz</TargetOutputPath>
<RelativePath>%(RelativePath).gz</RelativePath>
<InputSource>%(FullPath)</InputSource>
</_BlazorFileToCompress>
<!-- The linker is not incremental, so to support incremental compression in addition to linking we need to compute the hashes of the dlls and
pdbs, write them to a file when they change and use that as input for the compression task instead. -->
<_BlazorFileToCompress Condition="'$(BlazorLinkOnBuild)' == 'true' and ('%(Extension)' == '.dll' or '%(Extension)' == '.pdb')">
<InputSource>$([MSBuild]::NormalizePath('$(_BlazorFilesIntermediateOutputPath)%(RelativePath).hash'))</InputSource>
</_BlazorFileToCompress>
<_CompressedStaticWebAsset Include="@(_BlazorFileToCompress->'%(TargetCompressionPath)')" RemoveMetadata="TargetOutputPath;TargetCompressionPath" />
<StaticWebAsset Include="@(_CompressedStaticWebAsset->'%(FullPath)')" KeepMetadata="SourceType;SourceId;ContentRoot;BasePath;RelativePath" />
<FileWrites Include="@(_CompressedStaticWebAsset)" />
</ItemGroup>
<GetFileHash Files="@(_BlazorFileToCompress)" Condition="'$(BlazorLinkOnBuild)' == 'true' and ('%(Extension)' == '.dll' or '%(Extension)' == '.pdb')">
<Output TaskParameter="Items" ItemName="_LinkerOutputHashes" />
</GetFileHash>
<WriteLinesToFile Condition="'@(_LinkerOutputHashes)' != ''" Lines="%(_LinkerOutputHashes.FileHash)" File="%(_LinkerOutputHashes.InputSource)" WriteOnlyWhenDifferent="true" Overwrite="true" />
<ItemGroup>
<FileWrites Include="%(_LinkerOutputHashes.InputSource)" />
</ItemGroup>
</Target>
<UsingTask TaskName="CompressBlazorApplicationFiles" AssemblyFile="$(BlazorTasksPath)" />
<Target
Name="_CompressBlazorApplicationFiles"
AfterTargets="_ResolveBlazorFilesToCompress"
Inputs="%(_BlazorFileToCompress.InputSource)"
Outputs="%(_BlazorFileToCompress.TargetCompressionPath)">
<CompressBlazorApplicationFiles StaticWebAsset="@(_BlazorFileToCompress)" />
</Target>
</Project>

View File

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

View File

@ -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();

View File

@ -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
/// </summary>
public static class ComponentsWebAssemblyApplicationBuilderExtensions
{
private static readonly HashSet<StringSegment> _supportedEncodings = new HashSet<StringSegment>(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") };
/// <summary>
/// Configures the application to serve Blazor WebAssembly framework files from the path <paramref name="pathPrefix"/>. This path must correspond to a referenced Blazor WebAssembly application project.
/// </summary>
@ -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 <<original>>.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;
}
}

View File

@ -1,10 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<OutputType>Exe</OutputType>
<ReferenceBlazorBuildLocally>true</ReferenceBlazorBuildLocally>
<RazorLangVersion>3.0</RazorLangVersion>
<!-- Disable compression in this project so that we can validate that it can be disabled -->
<BlazorEnableCompression>false</BlazorEnableCompression>
</PropertyGroup>
<ItemGroup>

View File

@ -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();