Skip to content
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 @@ -88,11 +88,9 @@ Copyright (c) .NET Foundation. All rights reserved.

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

<GeneratePublishWasmBootJsonDependsOn>
Expand Down Expand Up @@ -159,65 +157,6 @@ Copyright (c) .NET Foundation. All rights reserved.
</ItemGroup>
</Target>

<Target Name="_ReplaceFingerprintedBlazorJsForPublish" DependsOnTargets="ProcessPublishFilesForWasm" Condition="'$(WasmBuildingForNestedPublish)' != 'true' and '$(BlazorFingerprintBlazorJs)' == 'true'">
<PropertyGroup>
<_BlazorJSFileNames>;@(_BlazorJSFile->'%(FileName)');</_BlazorJSFileNames>
</PropertyGroup>
<ItemGroup>
<_BlazorJSJSStaticWebAsset Include="@(StaticWebAsset)" Condition="$(_BlazorJSFileNames.Contains(';%(FileName);')) and '%(Extension)' == '.js'" />
<_BlazorJSPublishCandidate Include="%(_BlazorJSJSStaticWebAsset.RelativeDir)%(_BlazorJSJSStaticWebAsset.FileName).%(_BlazorJSJSStaticWebAsset.Fingerprint)%(_BlazorJSJSStaticWebAsset.Extension)" />
<_BlazorJSPublishCandidate Remove="@(_BlazorJSPublishCandidate)" Condition="'%(Extension)' == '.map'" />
<_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="%(_BlazorJSJSStaticWebAsset.ContentRoot)"
BasePath="%(_BlazorJSJSStaticWebAsset.BasePath)"
>
<Output TaskParameter="Assets" ItemName="_BlazorJSJSPublishStaticWebAssets" />
</DefineStaticWebAssets>
<DefineStaticWebAssetEndpoints
CandidateAssets="@(_BlazorJSJSPublishStaticWebAssets)"
ExistingEndpoints="@(StaticWebAssetEndpoint)"
ContentTypeMappings="@(StaticWebAssetContentTypeMapping)"
>
<Output TaskParameter="Endpoints" ItemName="_BlazorJSJSPublishStaticWebAssetsEndpoint" />
</DefineStaticWebAssetEndpoints>
<PropertyGroup>
<_BlazorJSJSStaticWebAssetFullPath>@(_BlazorJSJSStaticWebAsset->'%(FullPath)')</_BlazorJSJSStaticWebAssetFullPath>
</PropertyGroup>
<ItemGroup>
<_BlazorJSJSStaticWebAsset Include="@(StaticWebAsset)" Condition="'%(AssetTraitName)' == 'Content-Encoding' and '%(RelatedAsset)' == '$(_BlazorJSJSStaticWebAssetFullPath)'" />
</ItemGroup>
<FilterStaticWebAssetEndpoints Condition="'@(_BlazorJSJSStaticWebAsset)' != ''"
Endpoints="@(StaticWebAssetEndpoint)"
Assets="@(_BlazorJSJSStaticWebAsset)"
Filters=""
>
<Output TaskParameter="FilteredEndpoints" ItemName="_BlazorJSEndpointsToRemove" />
</FilterStaticWebAssetEndpoints>
<ItemGroup>
<StaticWebAsset Remove="@(_BlazorJSJSStaticWebAsset)" />
<StaticWebAsset Include="@(_BlazorJSJSPublishStaticWebAssets)" />
<StaticWebAssetEndpoint Remove="@(_BlazorJSEndpointsToRemove)" />
<StaticWebAssetEndpoint Include="@(_BlazorJSJSPublishStaticWebAssetsEndpoint)" />
</ItemGroup>
</Target>

<!-- 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
67 changes: 67 additions & 0 deletions src/StaticWebAssetsSdk/Tasks/Data/StaticWebAssetPathPattern.cs
Original file line number Diff line number Diff line change
Expand Up @@ -505,4 +505,71 @@ public override int GetHashCode()
private static bool IsLiteralSegment(StaticWebAssetPathSegment segment) => segment.Parts.Count == 1 && segment.Parts[0].IsLiteral;

internal static string PathWithoutTokens(string path) => Parse(path).ComputePatternLabel();

internal static string ExpandIdentityFileNameForFingerprint(string fileNamePattern, string fingerprint)
{
var pattern = Parse(fileNamePattern);
var sb = new StringBuilder();
foreach (var segment in pattern.Segments)
{
var isLiteral = segment.Parts.Count == 1 && segment.Parts[0].IsLiteral;
if (isLiteral)
{
sb.Append(segment.Parts[0].Name);
continue;
}

if (segment.IsOptional && !segment.IsPreferred)
{
continue; // skip non-preferred optional segments
}

bool missingRequired = false;
foreach (var part in segment.Parts)
{
if (!part.IsLiteral && part.Value.IsEmpty)
{
var tokenName = part.Name.ToString();
if (string.Equals(tokenName, "fingerprint", StringComparison.OrdinalIgnoreCase) && string.IsNullOrEmpty(fingerprint))
{
missingRequired = true;
break;
}
}
}
if (missingRequired)
{
if (!segment.IsOptional)
{
throw new InvalidOperationException($"Token 'fingerprint' not provided for '{fileNamePattern}'.");
}
continue;
}

foreach (var part in segment.Parts)
{
if (part.IsLiteral)
{
sb.Append(part.Name);
}
else if (!part.Value.IsEmpty)
{
sb.Append(part.Value);
}
else
{
var tokenName = part.Name.ToString();
if (string.Equals(tokenName, "fingerprint", StringComparison.OrdinalIgnoreCase))
{
sb.Append(fingerprint);
}
else
{
throw new InvalidOperationException($"Unsupported token '{tokenName}' in '{fileNamePattern}'.");
}
}
}
}
return sb.ToString();
}
}
35 changes: 27 additions & 8 deletions src/StaticWebAssetsSdk/Tasks/DefineStaticWebAssets.cs
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,14 @@ public override bool Execute()
break;
}

// IMPORTANT: Apply fingerprint pattern (which can change the file name) BEFORE computing identity
// for non-Discovered assets so that a synthesized identity incorporates the fingerprint pattern.
if (FingerprintCandidates)
{
matchContext.SetPathAndReinitialize(relativePathCandidate);
relativePathCandidate = StaticWebAsset.Normalize(fingerprintPatternMatcher.AppendFingerprintPattern(matchContext, identity));
}

if (!string.Equals(SourceType, StaticWebAsset.SourceTypes.Discovered, StringComparison.OrdinalIgnoreCase))
{
// We ignore the content root for publish only assets since it doesn't matter.
Expand All @@ -246,16 +254,21 @@ public override bool Execute()

if (computed)
{
// If we synthesized identity and there is a fingerprint placeholder pattern in the file name
// expand it to the concrete fingerprinted file name while keeping RelativePath pattern form.
if (FingerprintCandidates && !string.IsNullOrEmpty(fingerprint))
{
var fileNamePattern = Path.GetFileName(identity);
if (fileNamePattern.Contains("#["))
{
var expanded = StaticWebAssetPathPattern.ExpandIdentityFileNameForFingerprint(fileNamePattern, fingerprint);
identity = Path.Combine(Path.GetDirectoryName(identity) ?? string.Empty, expanded);
}
}
assetsCache.AppendCopyCandidate(hash, candidate.ItemSpec, identity);
}
}

if (FingerprintCandidates)
{
matchContext.SetPathAndReinitialize(relativePathCandidate);
relativePathCandidate = StaticWebAsset.Normalize(fingerprintPatternMatcher.AppendFingerprintPattern(matchContext, identity));
}

var asset = StaticWebAsset.FromProperties(
identity,
sourceId,
Expand Down Expand Up @@ -357,7 +370,13 @@ public override bool Execute()
// Alternatively, we could be explicit here and support ContentRootSubPath to indicate where it needs to go.
var identitySubPath = Path.GetDirectoryName(relativePath);
var itemSpecFileName = Path.GetFileName(candidateFullPath);
var finalIdentity = Path.Combine(normalizedContentRoot, identitySubPath, itemSpecFileName);
var relativeFileName = Path.GetFileName(relativePath);
// If the relative path filename has been modified (e.g. fingerprint pattern appended) use it when synthesizing identity.
if (!string.IsNullOrEmpty(relativeFileName) && !string.Equals(relativeFileName, itemSpecFileName, StringComparison.OrdinalIgnoreCase))
{
itemSpecFileName = relativeFileName;
}
var finalIdentity = Path.Combine(normalizedContentRoot, identitySubPath ?? string.Empty, itemSpecFileName);
Log.LogMessage(MessageImportance.Low, "Identity for candidate '{0}' is '{1}' because it did not start with the content root '{2}'", candidate.ItemSpec, finalIdentity, normalizedContentRoot);
return (finalIdentity, true);
}
Expand Down Expand Up @@ -493,7 +512,7 @@ private void UpdateAssetKindIfNecessary(
{
case (StaticWebAsset.AssetCopyOptions.Never, StaticWebAsset.AssetCopyOptions.Never):
case (not StaticWebAsset.AssetCopyOptions.Never, not StaticWebAsset.AssetCopyOptions.Never):
var errorMessage = "Two assets found targeting the same path with incompatible asset kinds: " + Environment.NewLine +
var errorMessage = "Two assets found targeting the same path with incompatible asset kinds:" + Environment.NewLine +
"'{0}' with kind '{1}'" + Environment.NewLine +
"'{2}' with kind '{3}'" + Environment.NewLine +
"for path '{4}'";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1612,6 +1612,16 @@ public class TestReference
fileInWwwroot.Should().Exist();
}

[RequiresMSBuildVersionTheory("17.12", Reason = "Needs System.Text.Json 8.0.5")]
[InlineData("")]
[InlineData("/p:BlazorFingerprintBlazorJs=false")]
public void Publish_BlazorWasmReferencedByAspNetCoreServer(string publishArg)
{
var testInstance = CreateAspNetSdkTestAsset("BlazorWasmReferencedByAspNetCoreServer");
var publishCommand = CreatePublishCommand(testInstance, "Server");
ExecuteCommand(publishCommand, publishArg).Should().Pass();
}

private void VerifyTypeGranularTrimming(string blazorPublishDirectory)
{
VerifyAssemblyHasTypes(Path.Combine(blazorPublishDirectory, "_framework", "Microsoft.AspNetCore.Components.wasm"), new[] {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,69 @@ public void FingerprintsContentUsingPatternsWhenMoreThanOneExtension(string file
asset.GetMetadata(nameof(StaticWebAsset.OriginalItemSpec)).Should().Be(Path.Combine("wwwroot", fileName));
}

[Fact]
[Trait("Category", "FingerprintIdentity")]
public void ComputesIdentity_UsingFingerprintPattern_ForComputedAssets_WhenIdentityNeedsComputation()
{
// Arrange: simulate a packaged asset (outside content root) with a RelativePath inside the app
var errorMessages = new List<string>();
var buildEngine = new Mock<IBuildEngine>();
buildEngine.Setup(e => e.LogErrorEvent(It.IsAny<BuildErrorEventArgs>()))
.Callback<BuildErrorEventArgs>(args => errorMessages.Add(args.Message));

// Create a physical file to allow fingerprint computation (tests override ResolveFileDetails returning null file otherwise)
var tempRoot = Path.Combine(Path.GetTempPath(), "swafp_identity_test");
var nugetPackagePath = Path.Combine(tempRoot, "microsoft.aspnetcore.components.webassembly", "10.0.0-rc.1.25451.107", "build", "net10.0");
Directory.CreateDirectory(nugetPackagePath);
var assetFileName = "blazor.webassembly.js";
var assetFullPath = Path.Combine(nugetPackagePath, assetFileName);
File.WriteAllText(assetFullPath, "console.log('test');");
// Relative path provided by the item (pre-fingerprinting)
var relativePath = Path.Combine("_framework", assetFileName).Replace('\\', '/');
var contentRoot = Path.Combine("bin", "Release", "net10.0", "wwwroot");

var task = new DefineStaticWebAssets
{
BuildEngine = buildEngine.Object,
// Use default file resolution so the file we created is used for hashing.
TestResolveFileDetails = null,
CandidateAssets =
[
new TaskItem(assetFullPath, new Dictionary<string, string>
{
["RelativePath"] = relativePath
})
],
// No RelativePathPattern, we trigger the branch that synthesizes identity under content root.
FingerprintPatterns = [ new TaskItem("Js", new Dictionary<string,string>{{"Pattern","*.js"},{"Expression","#[.{fingerprint}]!"}})],
FingerprintCandidates = true,
SourceType = "Computed",
SourceId = "Client",
ContentRoot = contentRoot,
BasePath = "/",
AssetKind = StaticWebAsset.AssetKinds.All,
AssetTraitName = "WasmResource",
AssetTraitValue = "boot"
};

// Act
var result = task.Execute();

// Assert
result.Should().BeTrue($"Errors: {Environment.NewLine} {string.Join($"{Environment.NewLine} ", errorMessages)}");
task.Assets.Length.Should().Be(1);
var asset = task.Assets[0];

// RelativePath should still contain the hard fingerprint pattern placeholder (not expanded yet)
asset.GetMetadata(nameof(StaticWebAsset.RelativePath)).Should().Be("_framework/blazor.webassembly#[.{fingerprint}]!.js");

// Identity must contain the ACTUAL fingerprint value in the file name (placeholder expanded)
var actualFingerprint = asset.GetMetadata(nameof(StaticWebAsset.Fingerprint));
actualFingerprint.Should().NotBeNullOrEmpty();
var expectedIdentity = Path.GetFullPath(Path.Combine(contentRoot, "_framework", $"blazor.webassembly.{actualFingerprint}.js"));
asset.ItemSpec.Should().Be(expectedIdentity);
}

[Fact]
public void RespectsItemRelativePathWhenExplicitlySpecified()
{
Expand Down Expand Up @@ -450,7 +513,7 @@ public void FailsDiscoveringAssetsWhenThereIsAConflict(
// Assert
result.Should().Be(false);
errorMessages.Count.Should().Be(1);
errorMessages[0].Should().Be($@"Two assets found targeting the same path with incompatible asset kinds:
errorMessages[0].Should().Be($@"Two assets found targeting the same path with incompatible asset kinds:
'{Path.GetFullPath(Path.Combine("wwwroot", "candidate.js"))}' with kind '{firstKind}'
'{Path.GetFullPath(Path.Combine("wwwroot", "candidate.publish.js"))}' with kind '{secondKind}'
for path 'candidate.js'");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<Solution>
<Project Path="Client/Client.csproj" />
<Project Path="Server/Server.csproj" Id="edd5dc5a-a093-4efa-88a1-f4df05c2da44" />
</Solution>
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<Router AppAssembly="@typeof(App).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData" />
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found>
</Router>
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<OverrideHtmlAssetPlaceholders>true</OverrideHtmlAssetPlaceholders>
<ServiceWorkerAssetsManifest>service-worker-assets.js</ServiceWorkerAssetsManifest>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.0-rc.1.25451.107" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.0-rc.1.25451.107" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<ServiceWorker Include="wwwroot\service-worker.js" PublishedContent="wwwroot\service-worker.published.js" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using Client;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;

var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");

builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });

await builder.Build().RunAsync();
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.AspNetCore.Components.WebAssembly.Http
@using Microsoft.JSInterop
@using Client
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/* minimal css */
body { font-family: sans-serif; }
Loading
Loading