Skip to content

Commit d28b5e3

Browse files
authored
[StaticWebAssets] Detects pre-compressed assets (#44976)
# Detects pre-compressed assets Detects assets that have been pre-compressed by an external tool to avoid conflicts when we compress our own assets, and they end up on the same path as the existing pre-compressed assets. ## Description We have received feedback that some customers are facing issues upgrading as they are consuming packages that contain pre-compressed assets or are using third-party tools (like webpack) that produce pre-compressed versions of those assets. Our recommendation in these cases is to let the framework handle the compression, as it further optimizes the delivery of those assets. However, this requires changes on their project to disable the compression on the third-party tools / exclude the pre compressed assets or to disable the compression that is done by the framework (a one-line MSBuild change). We, however, recognize that this cause friction during the upgrade process and want to improve the situation by following the most common convention, which is, detecting compressed versions of an asset that live side-by-side with it. (Same path with an extra .gz or .br for the compressed versions) This change detects such scenarios and starts treating the assets in the same way as if they were defined by the framework. Fixes dotnet/aspnetcore#57518 ## Customer Impact Customers with pre compressed files in their web content are forced to either remove those assets from the build or disable compression when they upgrade, as otherwise the build breaks. ## Regression? - [ ] Yes - [X] No It's not a regression because this is a new feature, but it impacts the upgrade flow in this particular scenario. ## Risk - [ ] High - [ ] Medium - [X] Low There is a flag that can be used to turn off compression on the framework, which will prevent any compression related code from running. The new added logic should no-op in most common cases and only ever execute when we detect a pre-compressed asset that was not generated by the framework. ## Verification - [ ] Manual (required) - [X] Automated ## Packaging changes reviewed? - [ ] Yes - [ ] No - [X] N/A ---- ## When servicing release/2.1 - [ ] Make necessary changes in eng/PatchConfig.props
1 parent 1c52603 commit d28b5e3

9 files changed

+2944
-1
lines changed

src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.Compression.targets

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ Copyright (c) .NET Foundation. All rights reserved.
1414

1515
<UsingTask TaskName="Microsoft.AspNetCore.StaticWebAssets.Tasks.BrotliCompress" AssemblyFile="$(StaticWebAssetsSdkBuildTasksAssembly)" />
1616
<UsingTask TaskName="Microsoft.AspNetCore.StaticWebAssets.Tasks.GZipCompress" AssemblyFile="$(StaticWebAssetsSdkBuildTasksAssembly)" />
17+
<UsingTask TaskName="Microsoft.AspNetCore.StaticWebAssets.Tasks.DiscoverPrecompressedAssets" AssemblyFile="$(StaticWebAssetsSdkBuildTasksAssembly)" />
1718
<UsingTask TaskName="Microsoft.AspNetCore.StaticWebAssets.Tasks.ResolveCompressedAssets" AssemblyFile="$(StaticWebAssetsSdkBuildTasksAssembly)" />
1819
<UsingTask TaskName="Microsoft.AspNetCore.StaticWebAssets.Tasks.ApplyCompressionNegotiation" AssemblyFile="$(StaticWebAssetsSdkBuildTasksAssembly)" />
1920

@@ -225,7 +226,7 @@ Copyright (c) .NET Foundation. All rights reserved.
225226
</DefineStaticWebAssets>
226227

227228
<DefineStaticWebAssetEndpoints
228-
CandidateAssets="@(_CompressionBuildStaticWebAsset)"
229+
CandidateAssets="@(_CompressionBuildStaticWebAsset);@(_PrecompressedStaticWebAssets)"
229230
ExistingEndpoints="@(StaticWebAssetEndpoint)"
230231
ContentTypeMappings="@(StaticWebAssetContentTypeMapping)"
231232
>
@@ -276,6 +277,27 @@ Copyright (c) .NET Foundation. All rights reserved.
276277
</Target>
277278

278279
<Target Name="ResolveBuildCompressedStaticWebAssetsConfiguration" DependsOnTargets="ResolveStaticWebAssetsInputs;$(ResolveCompressedFilesDependsOn)">
280+
<!-- There might be assets that are precompressed on packages or that are precompressed by other tools.
281+
In this case, we need to detect those assets, remove them and their endpoints, adjust the asset definition
282+
and recreate the endpoints for those assets as the original ones will not be correct.
283+
-->
284+
<DiscoverPrecompressedAssets CandidateAssets="@(StaticWebAsset)">
285+
<Output TaskParameter="DiscoveredCompressedAssets" ItemName="_PrecompressedStaticWebAssets" />
286+
</DiscoverPrecompressedAssets>
287+
288+
<FilterStaticWebAssetEndpoints Condition="'@(_PrecompressedStaticWebAssets)' != ''"
289+
Endpoints="@(StaticWebAssetEndpoint)"
290+
Assets="@(_PrecompressedStaticWebAssets)"
291+
Filters=""
292+
>
293+
<Output TaskParameter="FilteredEndpoints" ItemName="_PrecompressedEndpointsToRemove" />
294+
</FilterStaticWebAssetEndpoints>
295+
296+
<ItemGroup Condition="'@(_PrecompressedStaticWebAssets)' != ''">
297+
<StaticWebAssetEndpoint Remove="@(_PrecompressedEndpointsToRemove)" />
298+
<StaticWebAsset Remove="@(_PrecompressedStaticWebAssets)" />
299+
<StaticWebAsset Include="@(_PrecompressedStaticWebAssets)" />
300+
</ItemGroup>
279301

280302
<ResolveCompressedAssets
281303
CandidateAssets="@(StaticWebAsset)"
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using Microsoft.Build.Framework;
5+
6+
namespace Microsoft.AspNetCore.StaticWebAssets.Tasks;
7+
8+
public class DiscoverPrecompressedAssets : Task
9+
{
10+
private const string GzipAssetTraitValue = "gzip";
11+
private const string BrotliAssetTraitValue = "br";
12+
13+
public ITaskItem[] CandidateAssets { get; set; }
14+
15+
[Output]
16+
public ITaskItem[] DiscoveredCompressedAssets { get; set; }
17+
18+
public override bool Execute()
19+
{
20+
if (CandidateAssets is null)
21+
{
22+
Log.LogMessage(
23+
MessageImportance.Low,
24+
"Skipping task '{0}' because no candidate assets for compression were specified.",
25+
nameof(ResolveCompressedAssets));
26+
return true;
27+
}
28+
29+
var candidates = CandidateAssets.Select(StaticWebAsset.FromTaskItem).ToArray();
30+
var assetsToUpdate = new List<ITaskItem>();
31+
32+
var candidatesByIdentity = candidates.ToDictionary(asset => asset.Identity, OSPath.PathComparer);
33+
34+
foreach (var candidate in candidates)
35+
{
36+
if (HasCompressionExtension(candidate.RelativePath) &&
37+
// We only care about assets that are not already considered compressed
38+
!IsCompressedAsset(candidate) &&
39+
// The candidate doesn't already have a related asset
40+
string.IsNullOrEmpty(candidate.RelatedAsset))
41+
{
42+
Log.LogMessage(
43+
MessageImportance.Low,
44+
"The asset '{0}' was detected as compressed but it didn't specify a related asset.",
45+
candidate.Identity);
46+
var relatedAsset = FindRelatedAsset(candidate, candidatesByIdentity);
47+
if (relatedAsset is null)
48+
{
49+
Log.LogMessage(
50+
MessageImportance.Low,
51+
"The asset '{0}' was detected as compressed but the related asset with relative path '{1}' was not found.",
52+
candidate.Identity,
53+
Path.GetFileNameWithoutExtension(candidate.RelativePath));
54+
continue;
55+
}
56+
57+
Log.LogMessage(
58+
"The asset '{0}' was detected as compressed and the related asset '{1}' was found.",
59+
candidate.Identity,
60+
relatedAsset.Identity);
61+
UpdateCompressedAsset(candidate, relatedAsset);
62+
assetsToUpdate.Add(candidate.ToTaskItem());
63+
}
64+
}
65+
66+
DiscoveredCompressedAssets = [.. assetsToUpdate];
67+
68+
return !Log.HasLoggedErrors;
69+
}
70+
71+
private StaticWebAsset FindRelatedAsset(StaticWebAsset candidate, IDictionary<string, StaticWebAsset> candidates)
72+
{
73+
// The only pattern that we support is a related asset that lives in the same directory, with the same name,
74+
// but without the compression extension. In any other case we are not going to consider the assets related
75+
// and an error will occur.
76+
var identityWithoutExtension = candidate.Identity.Substring(0, candidate.Identity.Length - 3); // We take advantage we know the extension is .br or .gz.
77+
return candidates.TryGetValue(identityWithoutExtension, out var relatedAsset) ? relatedAsset : null;
78+
}
79+
80+
private bool HasCompressionExtension(string relativePath)
81+
{
82+
return relativePath.EndsWith(".gz", StringComparison.OrdinalIgnoreCase) ||
83+
relativePath.EndsWith(".br", StringComparison.OrdinalIgnoreCase);
84+
}
85+
86+
private static bool IsCompressedAsset(StaticWebAsset asset)
87+
=> string.Equals("Content-Encoding", asset.AssetTraitName, StringComparison.Ordinal);
88+
89+
private void UpdateCompressedAsset(StaticWebAsset asset, StaticWebAsset relatedAsset)
90+
{
91+
string fileExtension;
92+
string assetTraitValue;
93+
94+
if (!asset.RelativePath.EndsWith(".gz", StringComparison.OrdinalIgnoreCase))
95+
{
96+
fileExtension = ".br";
97+
assetTraitValue = BrotliAssetTraitValue;
98+
}
99+
else
100+
{
101+
fileExtension = ".gz";
102+
assetTraitValue = GzipAssetTraitValue;
103+
}
104+
105+
var originalItemSpec = asset.OriginalItemSpec;
106+
var relativePath = relatedAsset.EmbedTokens(relatedAsset.RelativePath);
107+
108+
asset.RelativePath = $"{relativePath}{fileExtension}";
109+
asset.OriginalItemSpec = relatedAsset.Identity;
110+
asset.RelatedAsset = relatedAsset.Identity;
111+
asset.AssetRole = "Alternative";
112+
asset.AssetTraitName = "Content-Encoding";
113+
asset.AssetTraitValue = assetTraitValue;
114+
}
115+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using Microsoft.AspNetCore.StaticWebAssets.Tasks;
5+
using Microsoft.Build.Framework;
6+
using Moq;
7+
8+
namespace Microsoft.NET.Sdk.Razor.Tests;
9+
10+
public class DiscoverPrecompressedAssetsTest
11+
{
12+
public string ItemSpec { get; }
13+
14+
public string OriginalItemSpec { get; }
15+
16+
public string OutputBasePath { get; }
17+
18+
public DiscoverPrecompressedAssetsTest()
19+
{
20+
OutputBasePath = Path.Combine(TestContext.Current.TestExecutionDirectory, nameof(ResolveCompressedAssetsTest));
21+
ItemSpec = Path.Combine(OutputBasePath, Guid.NewGuid().ToString("N") + ".tmp");
22+
OriginalItemSpec = Path.Combine(OutputBasePath, Guid.NewGuid().ToString("N") + ".tmp");
23+
}
24+
25+
[Fact]
26+
public void DiscoversPrecompressedAssetsCorrectly()
27+
{
28+
var errorMessages = new List<string>();
29+
var buildEngine = new Mock<IBuildEngine>();
30+
buildEngine.Setup(e => e.LogErrorEvent(It.IsAny<BuildErrorEventArgs>()))
31+
.Callback<BuildErrorEventArgs>(args => errorMessages.Add(args.Message));
32+
33+
var uncompressedCandidate = new StaticWebAsset
34+
{
35+
Identity = Path.Combine(Environment.CurrentDirectory, "wwwroot", "js", "site.js"),
36+
RelativePath = "js/site#[.{fingerprint}]?.js",
37+
BasePath = "_content/Test",
38+
AssetMode = StaticWebAsset.AssetModes.All,
39+
AssetKind = StaticWebAsset.AssetKinds.All,
40+
AssetMergeSource = string.Empty,
41+
SourceId = "Test",
42+
CopyToOutputDirectory = StaticWebAsset.AssetCopyOptions.Never,
43+
Fingerprint = "uncompressed",
44+
RelatedAsset = string.Empty,
45+
ContentRoot = Path.Combine(Environment.CurrentDirectory,"wwwroot"),
46+
SourceType = StaticWebAsset.SourceTypes.Discovered,
47+
Integrity = "uncompressed-integrity",
48+
AssetRole = StaticWebAsset.AssetRoles.Primary,
49+
AssetMergeBehavior = string.Empty,
50+
AssetTraitValue = string.Empty,
51+
AssetTraitName = string.Empty,
52+
OriginalItemSpec = Path.Combine("wwwroot", "js", "site.js"),
53+
CopyToPublishDirectory = StaticWebAsset.AssetCopyOptions.PreserveNewest
54+
};
55+
56+
var compressedCandidate = new StaticWebAsset
57+
{
58+
Identity = Path.Combine(Environment.CurrentDirectory, "wwwroot", "js", "site.js.gz"),
59+
RelativePath = "js/site.js#[.{fingerprint}]?.gz",
60+
BasePath = "_content/Test",
61+
AssetMode = StaticWebAsset.AssetModes.All,
62+
AssetKind = StaticWebAsset.AssetKinds.All,
63+
AssetMergeSource = string.Empty,
64+
SourceId = "Test",
65+
CopyToOutputDirectory = StaticWebAsset.AssetCopyOptions.Never,
66+
Fingerprint = "compressed",
67+
RelatedAsset = string.Empty,
68+
ContentRoot = Path.Combine(Environment.CurrentDirectory, "wwwroot"),
69+
SourceType = StaticWebAsset.SourceTypes.Discovered,
70+
Integrity = "compressed-integrity",
71+
AssetRole = StaticWebAsset.AssetRoles.Primary,
72+
AssetMergeBehavior = string.Empty,
73+
AssetTraitValue = string.Empty,
74+
AssetTraitName = string.Empty,
75+
OriginalItemSpec = Path.Combine("wwwroot", "js", "site.js.gz"),
76+
CopyToPublishDirectory = StaticWebAsset.AssetCopyOptions.PreserveNewest
77+
};
78+
79+
var task = new DiscoverPrecompressedAssets
80+
{
81+
CandidateAssets = [uncompressedCandidate.ToTaskItem(), compressedCandidate.ToTaskItem()],
82+
BuildEngine = buildEngine.Object
83+
};
84+
85+
var result = task.Execute();
86+
87+
result.Should().BeTrue();
88+
task.DiscoveredCompressedAssets.Should().ContainSingle();
89+
var asset = task.DiscoveredCompressedAssets[0];
90+
asset.ItemSpec.Should().Be(compressedCandidate.Identity);
91+
asset.GetMetadata("RelatedAsset").Should().Be(uncompressedCandidate.Identity);
92+
asset.GetMetadata("OriginalItemSpec").Should().Be(uncompressedCandidate.Identity);
93+
asset.GetMetadata("RelativePath").Should().Be("js/site#[.{fingerprint=uncompressed}]?.js.gz");
94+
asset.GetMetadata("AssetRole").Should().Be("Alternative");
95+
asset.GetMetadata("AssetTraitName").Should().Be("Content-Encoding");
96+
asset.GetMetadata("AssetTraitValue").Should().Be("gzip");
97+
asset.GetMetadata("Fingerprint").Should().Be("compressed");
98+
asset.GetMetadata("Integrity").Should().Be("compressed-integrity");
99+
asset.GetMetadata("CopyToPublishDirectory").Should().Be("PreserveNewest");
100+
asset.GetMetadata("CopyToOutputDirectory").Should().Be("Never");
101+
asset.GetMetadata("AssetMergeSource").Should().Be(string.Empty);
102+
asset.GetMetadata("AssetMergeBehavior").Should().Be(string.Empty);
103+
asset.GetMetadata("AssetKind").Should().Be("All");
104+
asset.GetMetadata("AssetMode").Should().Be("All");
105+
asset.GetMetadata("SourceId").Should().Be("Test");
106+
asset.GetMetadata("SourceType").Should().Be("Discovered");
107+
asset.GetMetadata("ContentRoot").Should().Be(Path.Combine(Environment.CurrentDirectory, $"wwwroot{Path.DirectorySeparatorChar}"));
108+
}
109+
}

test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssets/ResolveCompressedAssetsTest.cs

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using System.Diagnostics.Metrics;
45
using Microsoft.AspNetCore.StaticWebAssets.Tasks;
56
using Microsoft.Build.Framework;
67
using Microsoft.Build.Utilities;
78
using Moq;
89
using NuGet.ContentModel;
10+
using NuGet.Packaging.Core;
911

1012
namespace Microsoft.NET.Sdk.Razor.Tests;
1113

@@ -70,6 +72,74 @@ public void ResolvesExplicitlyProvidedAssets()
7072
task.AssetsToCompress[1].ItemSpec.Should().EndWith(".br");
7173
}
7274

75+
[Fact]
76+
public void InfersPreCompressedAssetsCorrectly()
77+
{
78+
var errorMessages = new List<string>();
79+
var buildEngine = new Mock<IBuildEngine>();
80+
buildEngine.Setup(e => e.LogErrorEvent(It.IsAny<BuildErrorEventArgs>()))
81+
.Callback<BuildErrorEventArgs>(args => errorMessages.Add(args.Message));
82+
83+
var uncompressedCandidate = new StaticWebAsset
84+
{
85+
Identity = Path.Combine(Environment.CurrentDirectory, "wwwroot", "js", "site.js"),
86+
RelativePath = "js/site#[.{fingerprint}]?.js",
87+
BasePath = "_content/Test",
88+
AssetMode = StaticWebAsset.AssetModes.All,
89+
AssetKind = StaticWebAsset.AssetKinds.All,
90+
AssetMergeSource = string.Empty,
91+
SourceId = "Test",
92+
CopyToOutputDirectory = StaticWebAsset.AssetCopyOptions.Never,
93+
Fingerprint = "xtxxf3hu2r",
94+
RelatedAsset = string.Empty,
95+
ContentRoot = Path.Combine(Environment.CurrentDirectory,"wwwroot"),
96+
SourceType = StaticWebAsset.SourceTypes.Discovered,
97+
Integrity = "hRQyftXiu1lLX2P9Ly9xa4gHJgLeR1uGN5qegUobtGo=",
98+
AssetRole = StaticWebAsset.AssetRoles.Primary,
99+
AssetMergeBehavior = string.Empty,
100+
AssetTraitValue = string.Empty,
101+
AssetTraitName = string.Empty,
102+
OriginalItemSpec = Path.Combine("wwwroot", "js", "site.js"),
103+
CopyToPublishDirectory = StaticWebAsset.AssetCopyOptions.PreserveNewest
104+
};
105+
106+
var compressedCandidate = new StaticWebAsset
107+
{
108+
Identity = Path.Combine(Environment.CurrentDirectory, "wwwroot", "js", "site.js.gz"),
109+
RelativePath = "js/site.js#[.{fingerprint}]?.gz",
110+
BasePath = "_content/Test",
111+
AssetMode = StaticWebAsset.AssetModes.All,
112+
AssetKind = StaticWebAsset.AssetKinds.All,
113+
AssetMergeSource = string.Empty,
114+
SourceId = "Test",
115+
CopyToOutputDirectory = StaticWebAsset.AssetCopyOptions.Never,
116+
Fingerprint = "es13vhk42b",
117+
RelatedAsset = string.Empty,
118+
ContentRoot = Path.Combine(Environment.CurrentDirectory, "wwwroot"),
119+
SourceType = StaticWebAsset.SourceTypes.Discovered,
120+
Integrity = "zs5Fd3XI6+g9f4N1SFLVdgghuiqdvq+nETAjTbvVxx4=",
121+
AssetRole = StaticWebAsset.AssetRoles.Primary,
122+
AssetMergeBehavior = string.Empty,
123+
AssetTraitValue = string.Empty,
124+
AssetTraitName = string.Empty,
125+
OriginalItemSpec = Path.Combine("wwwroot", "js", "site.js.gz"),
126+
CopyToPublishDirectory = StaticWebAsset.AssetCopyOptions.PreserveNewest
127+
};
128+
129+
var task = new ResolveCompressedAssets
130+
{
131+
OutputPath = OutputBasePath,
132+
CandidateAssets = [uncompressedCandidate.ToTaskItem(), compressedCandidate.ToTaskItem()],
133+
Formats = "gzip",
134+
BuildEngine = buildEngine.Object
135+
};
136+
137+
var result = task.Execute();
138+
139+
result.Should().BeTrue();
140+
task.AssetsToCompress.Should().HaveCount(0);
141+
}
142+
73143
[Fact]
74144
public void ResolvesAssetsMatchingIncludePattern()
75145
{
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
[
2+
"${ProjectPath}\\AppWithP2PReference\\obj\\Debug\\${Tfm}\\compressed\\_content\\AppWithP2PReference\\AppWithP2PReference#[.{fingerprint=__fingerprint__}]?.styles.css.gz",
3+
"${ProjectPath}\\AppWithP2PReference\\obj\\Debug\\${Tfm}\\scopedcss\\bundle\\AppWithP2PReference.styles.css"
4+
]

0 commit comments

Comments
 (0)