Skip to content

Allow to fingerprint Blazor.js #46988

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 9 commits into from
Mar 3, 2025
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ Copyright (c) .NET Foundation. All rights reserved.
<HttpActivityPropagationSupport Condition="'$(HttpActivityPropagationSupport)' == ''">false</HttpActivityPropagationSupport>
<DebuggerSupport Condition="'$(DebuggerSupport)' == '' and '$(Configuration)' != 'Debug'">false</DebuggerSupport>
<BlazorCacheBootResources Condition="'$(BlazorCacheBootResources)' == ''">true</BlazorCacheBootResources>
<BlazorFingerprintBlazorJs Condition="'$(BlazorFingerprintBlazorJs)' == '' and '$(WriteImportMapToHtml)' == 'true'">true</BlazorFingerprintBlazorJs>

<_TargetingNETBefore80>$([MSBuild]::VersionLessThan('$(TargetFrameworkVersion)', '8.0'))</_TargetingNETBefore80>
<_TargetingNET80OrLater>$([MSBuild]::VersionGreaterThanOrEquals('$(TargetFrameworkVersion)', '8.0'))</_TargetingNET80OrLater>
Expand Down Expand Up @@ -83,6 +84,15 @@ Copyright (c) .NET Foundation. All rights reserved.
GenerateBuildBlazorBootExtensionJson;
</GenerateBuildWasmBootJsonDependsOn>

<ResolvePublishRelatedStaticWebAssetsDependsOn>
$(ResolvePublishRelatedStaticWebAssetsDependsOn);
_ReplaceFingerprintedBlazorJsForPublish
</ResolvePublishRelatedStaticWebAssetsDependsOn>
<ResolveCompressedFilesForPublishDependsOn>
$(ResolveCompressedFilesForPublishDependsOn);
_ReplaceFingerprintedBlazorJsForPublish
</ResolveCompressedFilesForPublishDependsOn>

<GeneratePublishWasmBootJsonDependsOn>
$(GeneratePublishWasmBootJsonDependsOn);
GeneratePublishBlazorBootExtensionJson;
Expand All @@ -104,6 +114,7 @@ Copyright (c) .NET Foundation. All rights reserved.
<_BlazorJsFile>
<RelativePath>_framework/%(Filename)%(Extension)</RelativePath>
</_BlazorJsFile>
<_BlazorJSFingerprintPattern Include="Js" Pattern="*.js" Expression="#[.{fingerprint}]!" />

<!-- A missing blazor.webassembly.js is our packaging error. Produce an error so it's discovered early. -->
<Error
Expand All @@ -114,6 +125,8 @@ Copyright (c) .NET Foundation. All rights reserved.

<DefineStaticWebAssets
CandidateAssets="@(_BlazorJSFile)"
FingerprintCandidates="$(BlazorFingerprintBlazorJs)"
FingerprintPatterns="@(_BlazorJSFingerprintPattern)"
SourceId="$(PackageId)"
SourceType="Computed"
AssetKind="All"
Expand Down Expand Up @@ -143,6 +156,61 @@ Copyright (c) .NET Foundation. All rights reserved.
</ItemGroup>
</Target>

<Target Name="_ReplaceFingerprintedBlazorJsForPublish" DependsOnTargets="ProcessPublishFilesForWasm" Condition="'$(WasmBuildingForNestedPublish)' != 'true' and '$(BlazorFingerprintBlazorJs)' == 'true'">
<ItemGroup>
<_BlazorJSStaticWebAsset Include="@(StaticWebAsset)" Condition="'%(FileName)' == '%(_BlazorJSFile.FileName)'" />
<_BlazorJSPublishCandidate Include="%(_BlazorJSStaticWebAsset.RelativeDir)%(_BlazorJSStaticWebAsset.FileName).%(_BlazorJSStaticWebAsset.Fingerprint)%(_BlazorJSStaticWebAsset.Extension)" />
<_BlazorJSPublishCandidate>
<RelativePath>_framework/$([System.IO.Path]::GetFileNameWithoutExtension('%(Filename)'))%(Extension)</RelativePath>
</_BlazorJSPublishCandidate>
</ItemGroup>

<DefineStaticWebAssets
CandidateAssets="@(_BlazorJSPublishCandidate)"
FingerprintCandidates="true"
FingerprintPatterns="@(_BlazorJSFingerprintPattern)"
SourceId="$(PackageId)"
SourceType="Computed"
AssetKind="All"
AssetMergeSource="$(StaticWebAssetMergeTarget)"
AssetRole="Primary"
AssetTraitName="WasmResource"
AssetTraitValue="boot"
CopyToOutputDirectory="Never"
CopyToPublishDirectory="PreserveNewest"
ContentRoot="%(_BlazorJSStaticWebAsset.ContentRoot)"
BasePath="%(_BlazorJSStaticWebAsset.BasePath)"
>
<Output TaskParameter="Assets" ItemName="_BlazorJSPublishStaticWebAssets" />
</DefineStaticWebAssets>
<DefineStaticWebAssetEndpoints
CandidateAssets="@(_BlazorJSPublishStaticWebAssets)"
ExistingEndpoints="@(StaticWebAssetEndpoint)"
ContentTypeMappings="@(StaticWebAssetContentTypeMapping)"
>
<Output TaskParameter="Endpoints" ItemName="_BlazorJSPublishStaticWebAssetsEndpoint" />
</DefineStaticWebAssetEndpoints>
<PropertyGroup>
<_BlazorJSStaticWebAssetFullPath>@(_BlazorJSStaticWebAsset->'%(FullPath)')</_BlazorJSStaticWebAssetFullPath>
</PropertyGroup>
<ItemGroup>
<_BlazorJSStaticWebAsset Include="@(StaticWebAsset)" Condition="'%(AssetTraitName)' == 'Content-Encoding' and '%(RelatedAsset)' == '$(_BlazorJSStaticWebAssetFullPath)'" />
</ItemGroup>
<FilterStaticWebAssetEndpoints Condition="'@(_BlazorJSStaticWebAsset)' != ''"
Endpoints="@(StaticWebAssetEndpoint)"
Assets="@(_BlazorJSStaticWebAsset)"
Filters=""
>
<Output TaskParameter="FilteredEndpoints" ItemName="_BlazorJSEndpointsToRemove" />
</FilterStaticWebAssetEndpoints>
<ItemGroup>
<StaticWebAsset Remove="@(_BlazorJSStaticWebAsset)" />
<StaticWebAsset Include="@(_BlazorJSPublishStaticWebAssets)" />
<StaticWebAssetEndpoint Remove="@(_BlazorJSEndpointsToRemove)" />
<StaticWebAssetEndpoint Include="@(_BlazorJSPublishStaticWebAssetsEndpoint)" />
</ItemGroup>
</Target>

Comment on lines +159 to +213
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This part is confusing me a bit, do we rewrite blazor.js in any way during this whole process? If not, I'm not sure why this is needed at all.

Copy link
Member Author

@maraf maraf Feb 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's the same issue we have with runtime assets. If we do hard fingerprint during build, the asset to promote for publish has identity without fingerprint (bin/Debug/wwwroot/_framework/blazor.js), but the file on disk has fingerprint (bin/Debug/wwwroot/_framework/blazor.abcd.js) and thus it complains that the file doesn't exist.

This is fixing it by creating an asset that has fingerprint in identity.

Maybe we can do soft fingerprint during build and hard only during publish. Any ideas?

<!-- Just print a message here, static web assets takes care of all the copying -->
<Target Name="_BlazorCopyFilesToOutputDirectory" AfterTargets="CopyFilesToOutputDirectory">
<Message Importance="High" Text="$(MSBuildProjectName) (Blazor output) -&gt; $(TargetDir)wwwroot" Condition="'$(CopyBuildOutputToOutputDirectory)' == 'true' and '$(SkipCopyBuildProduct)'!='true'" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,17 +46,18 @@ public void Build_FingerprintsContent_WhenEnabled()
AssertBuildAssets(manifest1, outputPath, intermediateOutputPath);
}

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

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

var build = CreateBuildCommand(ProjectDirectory);
ExecuteCommand(build, "-p:WriteImportMapToHtml=true").Should().Pass();
Expand All @@ -65,44 +66,56 @@ public void Build_WriteImportMapToHtml(string testAsset, bool assetMainJs)
var indexHtmlPath = Directory.EnumerateFiles(Path.Combine(intermediateOutputPath, "staticwebassets", "importmaphtml", "build"), "*.html").Single();
var endpointsManifestPath = Path.Combine(intermediateOutputPath, $"staticwebassets.build.endpoints.json");

AssertImportMapInHtml(indexHtmlPath, endpointsManifestPath, assetMainJs);
AssertImportMapInHtml(indexHtmlPath, endpointsManifestPath, scriptPath);
}

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

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

var publish = CreatePublishCommand(ProjectDirectory);
ExecuteCommand(publish, "-p:WriteImportMapToHtml=true").Should().Pass();

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

AssertImportMapInHtml(indexHtmlPath, endpointsManifestPath, assetMainJs);
AssertImportMapInHtml(indexHtmlOutputPath, endpointsManifestPath, scriptPath);
}

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

File.WriteAllText(indexHtmlPath, newIndexHtmlContent);
}
}

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

if (assetMainJs)
{
var mainJs = GetFingerprintedPath("main.js");
Assert.DoesNotContain("src=\"main.js\"", indexHtmlContent);
Assert.Contains($"src=\"{mainJs}\"", indexHtmlContent);
}
var fingerprintedScriptPath = GetFingerprintedPath(scriptPath);
Assert.DoesNotContain($"src=\"{scriptPath}\"", indexHtmlContent);
Assert.Contains($"src=\"{fingerprintedScriptPath}\"", indexHtmlContent);

Assert.Contains(GetFingerprintedPath("_framework/dotnet.js"), indexHtmlContent);
Assert.Contains(GetFingerprintedPath("_framework/dotnet.native.js"), indexHtmlContent);
Assert.Contains(GetFingerprintedPath("_framework/dotnet.runtime.js"), indexHtmlContent);

string GetFingerprintedPath(string route)
=> endpoints.Endpoints.FirstOrDefault(e => e.Route == route && e.Selectors.Length == 0)?.AssetFile ?? throw new Exception($"Missing endpoint for file '{route}'");
=> endpoints.Endpoints.FirstOrDefault(e => e.Route == route && e.Selectors.Length == 0)?.AssetFile ?? throw new Exception($"Missing endpoint for file '{route}' in '{endpointsManifestPath}'");
}
}