diff --git a/src/BlazorWasmSdk/Targets/Microsoft.NET.Sdk.BlazorWebAssembly.6_0.targets b/src/BlazorWasmSdk/Targets/Microsoft.NET.Sdk.BlazorWebAssembly.6_0.targets index 3fdb67030110..a90625e732e6 100644 --- a/src/BlazorWasmSdk/Targets/Microsoft.NET.Sdk.BlazorWebAssembly.6_0.targets +++ b/src/BlazorWasmSdk/Targets/Microsoft.NET.Sdk.BlazorWebAssembly.6_0.targets @@ -88,11 +88,9 @@ Copyright (c) .NET Foundation. All rights reserved. $(ResolvePublishRelatedStaticWebAssetsDependsOn); - _ReplaceFingerprintedBlazorJsForPublish $(ResolveCompressedFilesForPublishDependsOn); - _ReplaceFingerprintedBlazorJsForPublish @@ -159,65 +157,6 @@ Copyright (c) .NET Foundation. All rights reserved. - - - <_BlazorJSFileNames>;@(_BlazorJSFile->'%(FileName)'); - - - <_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> - _framework/$([System.IO.Path]::GetFileNameWithoutExtension('%(Filename)'))%(Extension) - - - - - - - - - - - <_BlazorJSJSStaticWebAssetFullPath>@(_BlazorJSJSStaticWebAsset->'%(FullPath)') - - - <_BlazorJSJSStaticWebAsset Include="@(StaticWebAsset)" Condition="'%(AssetTraitName)' == 'Content-Encoding' and '%(RelatedAsset)' == '$(_BlazorJSJSStaticWebAssetFullPath)'" /> - - - - - - - - - - - - diff --git a/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAssetPathPattern.cs b/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAssetPathPattern.cs index 23a0f96f6607..5c9b48c486c2 100644 --- a/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAssetPathPattern.cs +++ b/src/StaticWebAssetsSdk/Tasks/Data/StaticWebAssetPathPattern.cs @@ -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(); + } } diff --git a/src/StaticWebAssetsSdk/Tasks/DefineStaticWebAssets.cs b/src/StaticWebAssetsSdk/Tasks/DefineStaticWebAssets.cs index 4d24ce9ed429..9f43c63845d1 100644 --- a/src/StaticWebAssetsSdk/Tasks/DefineStaticWebAssets.cs +++ b/src/StaticWebAssetsSdk/Tasks/DefineStaticWebAssets.cs @@ -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. @@ -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, @@ -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); } @@ -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}'"; diff --git a/test/Microsoft.NET.Sdk.BlazorWebAssembly.Tests/WasmPublishIntegrationTest.cs b/test/Microsoft.NET.Sdk.BlazorWebAssembly.Tests/WasmPublishIntegrationTest.cs index 6b26bd237940..cab0eb56e6e1 100644 --- a/test/Microsoft.NET.Sdk.BlazorWebAssembly.Tests/WasmPublishIntegrationTest.cs +++ b/test/Microsoft.NET.Sdk.BlazorWebAssembly.Tests/WasmPublishIntegrationTest.cs @@ -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[] { diff --git a/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/DiscoverStaticWebAssetsTest.cs b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/DiscoverStaticWebAssetsTest.cs index 5fcd970d8c53..f252022d1338 100644 --- a/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/DiscoverStaticWebAssetsTest.cs +++ b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/DiscoverStaticWebAssetsTest.cs @@ -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(); + var buildEngine = new Mock(); + buildEngine.Setup(e => e.LogErrorEvent(It.IsAny())) + .Callback(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 + { + ["RelativePath"] = relativePath + }) + ], + // No RelativePathPattern, we trigger the branch that synthesizes identity under content root. + FingerprintPatterns = [ new TaskItem("Js", new Dictionary{{"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() { @@ -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'"); diff --git a/test/TestAssets/TestProjects/BlazorWasmReferencedByAspNetCoreServer/BlazorWasmReferencedByAspNetCoreServer.slnx b/test/TestAssets/TestProjects/BlazorWasmReferencedByAspNetCoreServer/BlazorWasmReferencedByAspNetCoreServer.slnx new file mode 100644 index 000000000000..3e2628def731 --- /dev/null +++ b/test/TestAssets/TestProjects/BlazorWasmReferencedByAspNetCoreServer/BlazorWasmReferencedByAspNetCoreServer.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/test/TestAssets/TestProjects/BlazorWasmReferencedByAspNetCoreServer/Client/App.razor b/test/TestAssets/TestProjects/BlazorWasmReferencedByAspNetCoreServer/Client/App.razor new file mode 100644 index 000000000000..f796217a715d --- /dev/null +++ b/test/TestAssets/TestProjects/BlazorWasmReferencedByAspNetCoreServer/Client/App.razor @@ -0,0 +1,6 @@ + + + + + + diff --git a/test/TestAssets/TestProjects/BlazorWasmReferencedByAspNetCoreServer/Client/Client.csproj b/test/TestAssets/TestProjects/BlazorWasmReferencedByAspNetCoreServer/Client/Client.csproj new file mode 100644 index 000000000000..88afc4e9be57 --- /dev/null +++ b/test/TestAssets/TestProjects/BlazorWasmReferencedByAspNetCoreServer/Client/Client.csproj @@ -0,0 +1,16 @@ + + + net10.0 + enable + enable + true + service-worker-assets.js + + + + + + + + + diff --git a/test/TestAssets/TestProjects/BlazorWasmReferencedByAspNetCoreServer/Client/Program.cs b/test/TestAssets/TestProjects/BlazorWasmReferencedByAspNetCoreServer/Client/Program.cs new file mode 100644 index 000000000000..121b10340b73 --- /dev/null +++ b/test/TestAssets/TestProjects/BlazorWasmReferencedByAspNetCoreServer/Client/Program.cs @@ -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"); +builder.RootComponents.Add("head::after"); + +builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }); + +await builder.Build().RunAsync(); diff --git a/test/TestAssets/TestProjects/BlazorWasmReferencedByAspNetCoreServer/Client/_Imports.razor b/test/TestAssets/TestProjects/BlazorWasmReferencedByAspNetCoreServer/Client/_Imports.razor new file mode 100644 index 000000000000..13999df0b5a3 --- /dev/null +++ b/test/TestAssets/TestProjects/BlazorWasmReferencedByAspNetCoreServer/Client/_Imports.razor @@ -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 diff --git a/test/TestAssets/TestProjects/BlazorWasmReferencedByAspNetCoreServer/Client/wwwroot/css/app.css b/test/TestAssets/TestProjects/BlazorWasmReferencedByAspNetCoreServer/Client/wwwroot/css/app.css new file mode 100644 index 000000000000..a87511a380bf --- /dev/null +++ b/test/TestAssets/TestProjects/BlazorWasmReferencedByAspNetCoreServer/Client/wwwroot/css/app.css @@ -0,0 +1,2 @@ +/* minimal css */ +body { font-family: sans-serif; } diff --git a/test/TestAssets/TestProjects/BlazorWasmReferencedByAspNetCoreServer/Client/wwwroot/index.html b/test/TestAssets/TestProjects/BlazorWasmReferencedByAspNetCoreServer/Client/wwwroot/index.html new file mode 100644 index 000000000000..f68fb3fc8ab3 --- /dev/null +++ b/test/TestAssets/TestProjects/BlazorWasmReferencedByAspNetCoreServer/Client/wwwroot/index.html @@ -0,0 +1,33 @@ + + + + + + Client + + + + + + + + + + + + +
+ + + + +
+
+
+ An unhandled error has occurred. + Reload + 🗙 +
+ + + diff --git a/test/TestAssets/TestProjects/BlazorWasmReferencedByAspNetCoreServer/Client/wwwroot/manifest.webmanifest b/test/TestAssets/TestProjects/BlazorWasmReferencedByAspNetCoreServer/Client/wwwroot/manifest.webmanifest new file mode 100644 index 000000000000..85aa02b14543 --- /dev/null +++ b/test/TestAssets/TestProjects/BlazorWasmReferencedByAspNetCoreServer/Client/wwwroot/manifest.webmanifest @@ -0,0 +1,8 @@ +{ + "name": "Client", + "short_name": "Client", + "start_url": "/", + "display": "standalone", + "background_color": "#ffffff", + "description": "Test Blazor WASM referenced by ASP.NET Core Server" +} diff --git a/test/TestAssets/TestProjects/BlazorWasmReferencedByAspNetCoreServer/Client/wwwroot/service-worker.js b/test/TestAssets/TestProjects/BlazorWasmReferencedByAspNetCoreServer/Client/wwwroot/service-worker.js new file mode 100644 index 000000000000..22fcb7e905aa --- /dev/null +++ b/test/TestAssets/TestProjects/BlazorWasmReferencedByAspNetCoreServer/Client/wwwroot/service-worker.js @@ -0,0 +1,2 @@ +self.addEventListener('install', () => self.skipWaiting()); +self.addEventListener('activate', () => clients.claim()); diff --git a/test/TestAssets/TestProjects/BlazorWasmReferencedByAspNetCoreServer/Client/wwwroot/service-worker.published.js b/test/TestAssets/TestProjects/BlazorWasmReferencedByAspNetCoreServer/Client/wwwroot/service-worker.published.js new file mode 100644 index 000000000000..0074cb787624 --- /dev/null +++ b/test/TestAssets/TestProjects/BlazorWasmReferencedByAspNetCoreServer/Client/wwwroot/service-worker.published.js @@ -0,0 +1,3 @@ +// Published service worker placeholder +self.addEventListener('install', () => self.skipWaiting()); +self.addEventListener('activate', () => clients.claim()); diff --git a/test/TestAssets/TestProjects/BlazorWasmReferencedByAspNetCoreServer/Server/Program.cs b/test/TestAssets/TestProjects/BlazorWasmReferencedByAspNetCoreServer/Server/Program.cs new file mode 100644 index 000000000000..068ce63ca79a --- /dev/null +++ b/test/TestAssets/TestProjects/BlazorWasmReferencedByAspNetCoreServer/Server/Program.cs @@ -0,0 +1,16 @@ +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddOpenApi(); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.UseWebAssemblyDebugging(); + app.MapOpenApi(); +} + +app.UseHttpsRedirection(); +app.MapStaticAssets(); +app.MapFallbackToFile("index.html"); +app.Run(); diff --git a/test/TestAssets/TestProjects/BlazorWasmReferencedByAspNetCoreServer/Server/Server.csproj b/test/TestAssets/TestProjects/BlazorWasmReferencedByAspNetCoreServer/Server/Server.csproj new file mode 100644 index 000000000000..0dcd90d3c786 --- /dev/null +++ b/test/TestAssets/TestProjects/BlazorWasmReferencedByAspNetCoreServer/Server/Server.csproj @@ -0,0 +1,14 @@ + + + net10.0 + enable + enable + + + + + + + + +