Skip to content

Put only hard fingerprinted files into import map during publish #47515

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Mar 14, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,9 @@ Copyright (c) .NET Foundation. All rights reserved.

<Target Name="GenerateHtmlImportMapBuildStaticWebAssets" DependsOnTargets="$(GenerateHtmlImportMapBuildStaticWebAssetsDependsOn)">
<WriteImportMapToHtml
Assets="@(_EsModuleCandidate)"
Endpoints="@(_EsModuleCandidateEndpoints)"
IncludeOnlyHardFingerprintedModules="false"
HtmlFiles="@(_HtmlStaticWebAssets)"
OutputPath="$(_BuildImportMapHtmlPath)">
<Output TaskParameter="HtmlCandidates" ItemName="_HtmlCandidates" />
Expand Down Expand Up @@ -165,7 +167,9 @@ Copyright (c) .NET Foundation. All rights reserved.

<Target Name="GenerateHtmlImportMapPublishStaticWebAssets" DependsOnTargets="$(GenerateHtmlImportMapPublishStaticWebAssetsDependsOn)">
<WriteImportMapToHtml
Assets="@(_EsModuleCandidateForPublish)"
Endpoints="@(_EsModuleCandidateForPublishEndpoints)"
IncludeOnlyHardFingerprintedModules="true"
HtmlFiles="@(_HtmlStaticWebAssets)"
OutputPath="$(_PublishImportMapHtmlPath)">
<Output TaskParameter="HtmlCandidates" ItemName="_HtmlPublishCandidates" />
Expand Down
50 changes: 40 additions & 10 deletions src/StaticWebAssetsSdk/Tasks/WriteImportMapToHtml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,15 @@ namespace Microsoft.AspNetCore.StaticWebAssets.Tasks;

public partial class WriteImportMapToHtml : Task
{
[Required]
public ITaskItem[] Assets { get; set; } = [];

[Required]
public ITaskItem[] Endpoints { get; set; } = [];

[Required]
public bool IncludeOnlyHardFingerprintedModules { get; set; }

[Required]
public string OutputPath { get; set; } = string.Empty;

Expand Down Expand Up @@ -73,7 +79,7 @@ public override bool Execute()
outputContent = _assetsRegex.Replace(outputContent, e =>
{
string assetPath = e.Groups[1].Value + e.Groups[3].Value;
string fingerprintedAssetPath = urlMappings.TryGetValue(assetPath, out var value) ? value.Url : assetPath;
string fingerprintedAssetPath = GetFingerprintedAssetPath(urlMappings, assetPath);
Log.LogMessage("Replacing asset '{0}' with fingerprinted version '{1}'", assetPath, fingerprintedAssetPath);
return "\"" + fingerprintedAssetPath + "\"";
});
Expand All @@ -99,25 +105,35 @@ public override bool Execute()
return true;
}

internal static List<ResourceAsset> CreateResourcesFromEndpoints(IEnumerable<StaticWebAssetEndpoint> endpoints)
private string GetFingerprintedAssetPath(Dictionary<string, ResourceAsset> urlMappings, string assetPath)
{
if (urlMappings.TryGetValue(assetPath, out var asset) && (!IncludeOnlyHardFingerprintedModules || asset.IsHardFingerprinted))
{
return asset.Url;
}

return assetPath;
}

internal List<ResourceAsset> CreateResourcesFromEndpoints(IEnumerable<StaticWebAssetEndpoint> endpoints)
{
var resources = new List<ResourceAsset>();

// We are converting a subset of the descriptors to resources and including a subset of the properties exposed by the
// descriptors that are useful for the resources in the context of Blazor. Specifically, we pass in the `label` property
// which contains the human-readable identifier for fingerprinted assets, and the integrity, which can be used to apply
// subresource integrity to things like images, script tags, etc.
foreach (var descriptor in endpoints)
foreach (var endpoint in endpoints)
{
string? label = null;
string? integrity = null;

// If there's a selector this means that this is an alternative representation for a resource, so skip it.
if (descriptor.Selectors?.Length == 0)
if (endpoint.Selectors?.Length == 0)
{
for (var i = 0; i < descriptor.EndpointProperties?.Length; i++)
for (var i = 0; i < endpoint.EndpointProperties?.Length; i++)
{
var property = descriptor.EndpointProperties[i];
var property = endpoint.EndpointProperties[i];
if (property.Name.Equals("label", StringComparison.OrdinalIgnoreCase))
{
label = property.Value;
Expand All @@ -128,27 +144,40 @@ internal static List<ResourceAsset> CreateResourcesFromEndpoints(IEnumerable<Sta
}
}

resources.Add(new ResourceAsset(descriptor.Route, label, integrity));
bool isHardFingerprinted = true;
var asset = Assets.FirstOrDefault(a => a.ItemSpec == endpoint.AssetFile);
if (asset != null)
{
isHardFingerprinted = asset.GetMetadata("RelativePath").Contains("#[.{fingerprint}]!");
}

resources.Add(new ResourceAsset(endpoint.Route, label, integrity, isHardFingerprinted));
}
}

return resources;
}

private static ImportMap CreateImportMapFromResources(List<ResourceAsset> assets)
private ImportMap CreateImportMapFromResources(List<ResourceAsset> assets)
{
Dictionary<string, string>? imports = new();
Dictionary<string, Dictionary<string, string>>? scopes = new(); ;
Dictionary<string, Dictionary<string, string>>? scopes = new();
Dictionary<string, string>? integrity = new();

foreach (var asset in assets)
{
if (IncludeOnlyHardFingerprintedModules && !asset.IsHardFingerprinted)
{
continue;
}

if (asset.Integrity != null)
{
integrity ??= [];
integrity[$"./{asset.Url}"] = asset.Integrity;
}

// Only fingerprinted assets have label
if (asset.Label != null)
{
imports ??= [];
Expand Down Expand Up @@ -178,11 +207,12 @@ private static Dictionary<string, ResourceAsset> GroupResourcesByLabel(List<Reso
}
}

internal sealed class ResourceAsset(string url, string? label, string? integrity)
internal sealed class ResourceAsset(string url, string? label, string? integrity, bool isHardFingerprinted)
{
public string Url { get; } = url;
public string? Label { get; set; } = label;
public string? Integrity { get; set; } = integrity;
public bool IsHardFingerprinted { get; set; } = isHardFingerprinted;
}

internal class ImportMap(Dictionary<string, string> imports, Dictionary<string, Dictionary<string, string>> scopes, Dictionary<string, string> integrity)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,70 +46,105 @@ public void Build_FingerprintsContent_WhenEnabled()
AssertBuildAssets(manifest1, outputPath, intermediateOutputPath);
}

public static TheoryData<string, string, string> WriteImportMapToHtmlData => new TheoryData<string, string, string>
public static TheoryData<string, string, string, bool, bool> WriteImportMapToHtmlData => new TheoryData<string, string, string, bool, bool>
{
{ "VanillaWasm", "main.js", null },
{ "BlazorWasmMinimal", "_framework/blazor.webassembly.js", "_framework/blazor.webassembly#[.{fingerprint}].js" }
{ "VanillaWasm", "main.js", "main#[.{fingerprint}].js", true, true },
{ "VanillaWasm", "main.js", null, false, false },
{ "BlazorWasmMinimal", "_framework/blazor.webassembly.js", "_framework/blazor.webassembly#[.{fingerprint}].js", false, true }
};

[Theory]
[MemberData(nameof(WriteImportMapToHtmlData))]
public void Build_WriteImportMapToHtml(string testAsset, string scriptPath, string scriptPathWithFingerprintPattern)
public void Build_WriteImportMapToHtml(string testAsset, string scriptPath, string scriptPathWithFingerprintPattern, bool fingerprintUserJavascriptAssets, bool expectFingerprintOnScript)
{
ProjectDirectory = CreateAspNetSdkTestAsset(testAsset);
ReplaceStringInIndexHtml(ProjectDirectory, scriptPath, scriptPathWithFingerprintPattern);
FingerprintUserJavascriptAssets(fingerprintUserJavascriptAssets);

var build = CreateBuildCommand(ProjectDirectory);
ExecuteCommand(build, "-p:WriteImportMapToHtml=true").Should().Pass();
ExecuteCommand(build, "-p:WriteImportMapToHtml=true", $"-p:FingerprintUserJavascriptAssets={fingerprintUserJavascriptAssets}").Should().Pass();

var intermediateOutputPath = build.GetIntermediateDirectory(DefaultTfm, "Debug").ToString();
var indexHtmlPath = Directory.EnumerateFiles(Path.Combine(intermediateOutputPath, "staticwebassets", "importmaphtml", "build"), "*.html").Single();
var endpointsManifestPath = Path.Combine(intermediateOutputPath, $"staticwebassets.build.endpoints.json");

AssertImportMapInHtml(indexHtmlPath, endpointsManifestPath, scriptPath);
AssertImportMapInHtml(indexHtmlPath, endpointsManifestPath, scriptPath, expectFingerprintOnScript: expectFingerprintOnScript);
}

[Theory]
[MemberData(nameof(WriteImportMapToHtmlData))]
public void Publish_WriteImportMapToHtml(string testAsset, string scriptPath, string scriptPathWithFingerprintPattern)
public void Publish_WriteImportMapToHtml(string testAsset, string scriptPath, string scriptPathWithFingerprintPattern, bool fingerprintUserJavascriptAssets, bool expectFingerprintOnScript)
{
ProjectDirectory = CreateAspNetSdkTestAsset(testAsset);
ReplaceStringInIndexHtml(ProjectDirectory, scriptPath, scriptPathWithFingerprintPattern);
FingerprintUserJavascriptAssets(fingerprintUserJavascriptAssets);

var projectName = Path.GetFileNameWithoutExtension(Directory.EnumerateFiles(ProjectDirectory.TestRoot, "*.csproj").Single());

var publish = CreatePublishCommand(ProjectDirectory);
ExecuteCommand(publish, "-p:WriteImportMapToHtml=true").Should().Pass();
ExecuteCommand(publish, "-p:WriteImportMapToHtml=true", $"-p:FingerprintUserJavascriptAssets={fingerprintUserJavascriptAssets}").Should().Pass();

var outputPath = publish.GetOutputDirectory(DefaultTfm, "Debug").ToString();
var indexHtmlOutputPath = Path.Combine(outputPath, "wwwroot", "index.html");
var endpointsManifestPath = Path.Combine(outputPath, $"{projectName}.staticwebassets.endpoints.json");

AssertImportMapInHtml(indexHtmlOutputPath, endpointsManifestPath, scriptPath);
AssertImportMapInHtml(indexHtmlOutputPath, endpointsManifestPath, scriptPath, expectFingerprintOnScript: expectFingerprintOnScript);
}

private void ReplaceStringInIndexHtml(TestAsset testAsset, string scriptPath, string scriptPathWithFingerprintPattern)
private void FingerprintUserJavascriptAssets(bool fingerprintUserJavascriptAssets)
{
if (scriptPathWithFingerprintPattern != null)
if (fingerprintUserJavascriptAssets)
{
ProjectDirectory.WithProjectChanges(p =>
{
if (p.Root != null)
{
var itemGroup = new XElement("ItemGroup");
var pattern = new XElement("StaticWebAssetFingerprintPattern");
pattern.SetAttributeValue("Include", "Js");
pattern.SetAttributeValue("Pattern", "*.js");
pattern.SetAttributeValue("Expression", "#[.{fingerprint}]!");
itemGroup.Add(pattern);
p.Root.Add(itemGroup);
}
});
}
}

private void ReplaceStringInIndexHtml(TestAsset testAsset, string sourceValue, string targetValue)
{
if (targetValue != null)
{
var indexHtmlPath = Path.Combine(testAsset.TestRoot, "wwwroot", "index.html");
var indexHtmlContent = File.ReadAllText(indexHtmlPath);
var newIndexHtmlContent = indexHtmlContent.Replace(scriptPath, scriptPathWithFingerprintPattern);
var newIndexHtmlContent = indexHtmlContent.Replace(sourceValue, targetValue);
if (indexHtmlContent == newIndexHtmlContent)
throw new Exception($"Script replacement '{scriptPath}' for '{scriptPathWithFingerprintPattern}' didn't produce any change in '{indexHtmlPath}'");
throw new Exception($"String replacement '{sourceValue}' for '{targetValue}' didn't produce any change in '{indexHtmlPath}'");

File.WriteAllText(indexHtmlPath, newIndexHtmlContent);
}
}

private void AssertImportMapInHtml(string indexHtmlPath, string endpointsManifestPath, string scriptPath)
private void AssertImportMapInHtml(string indexHtmlPath, string endpointsManifestPath, string scriptPath, bool expectFingerprintOnScript = true)
{
var indexHtmlContent = File.ReadAllText(indexHtmlPath);
var endpoints = JsonSerializer.Deserialize<StaticWebAssetEndpointsManifest>(File.ReadAllText(endpointsManifestPath));

var fingerprintedScriptPath = GetFingerprintedPath(scriptPath);
Assert.DoesNotContain($"src=\"{scriptPath}\"", indexHtmlContent);
Assert.Contains($"src=\"{fingerprintedScriptPath}\"", indexHtmlContent);
if (expectFingerprintOnScript)
{
Assert.DoesNotContain($"src=\"{scriptPath}\"", indexHtmlContent);
Assert.Contains($"src=\"{fingerprintedScriptPath}\"", indexHtmlContent);
}
else
{
Assert.Contains(scriptPath, indexHtmlContent);

if (scriptPath != fingerprintedScriptPath)
{
Assert.DoesNotContain(fingerprintedScriptPath, indexHtmlContent);
}
}

Assert.Contains(GetFingerprintedPath("_framework/dotnet.js"), indexHtmlContent);
Assert.Contains(GetFingerprintedPath("_framework/dotnet.native.js"), indexHtmlContent);
Expand Down
3 changes: 0 additions & 3 deletions test/TestAssets/TestProjects/VanillaWasm/VanillaWasm.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,4 @@
<TargetFramework>$(CurrentTargetFramework)</TargetFramework>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<StaticWebAssetFingerprintPattern Include="Js" Pattern="*.js" Expression="#[.{fingerprint}]!" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script type="importmap"></script>
<script type="module" src="main#[.{fingerprint}].js"></script>
<script type="module" src="main.js"></script>

</head>

Expand Down