[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:
parent
0541e19ac2
commit
c9c06f573d
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -22,5 +22,6 @@
|
|||
<Import Project="Publish.targets" />
|
||||
<Import Project="StaticWebAssets.targets" />
|
||||
<Import Project="ServiceWorkerAssetsManifest.targets" />
|
||||
<Import Project="Compression.targets" Condition="'$(BlazorEnableCompression)' == 'true'" />
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue