Skip to content
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

[Blazor] Consume fingerprinted assets in MVC and Blazor #56045

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
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
19 changes: 19 additions & 0 deletions AspNetCore.sln
Original file line number Diff line number Diff line change
Expand Up @@ -1816,6 +1816,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GetDocumentInsider.Tests",
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GetDocumentSample", "src\Tools\GetDocumentInsider\sample\GetDocumentSample.csproj", "{D8F7091E-A2D1-4E81-BA7C-97EAE392D683}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BlazorUnitedApp.Client", "src\Components\Samples\BlazorUnitedApp.Client\BlazorUnitedApp.Client.csproj", "{757CBDE0-5D0A-4FD8-99F3-6C20BDDD4E63}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -10977,6 +10979,22 @@ Global
{D8F7091E-A2D1-4E81-BA7C-97EAE392D683}.Release|x64.Build.0 = Release|Any CPU
{D8F7091E-A2D1-4E81-BA7C-97EAE392D683}.Release|x86.ActiveCfg = Release|Any CPU
{D8F7091E-A2D1-4E81-BA7C-97EAE392D683}.Release|x86.Build.0 = Release|Any CPU
{757CBDE0-5D0A-4FD8-99F3-6C20BDDD4E63}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{757CBDE0-5D0A-4FD8-99F3-6C20BDDD4E63}.Debug|Any CPU.Build.0 = Debug|Any CPU
{757CBDE0-5D0A-4FD8-99F3-6C20BDDD4E63}.Debug|arm64.ActiveCfg = Debug|Any CPU
{757CBDE0-5D0A-4FD8-99F3-6C20BDDD4E63}.Debug|arm64.Build.0 = Debug|Any CPU
{757CBDE0-5D0A-4FD8-99F3-6C20BDDD4E63}.Debug|x64.ActiveCfg = Debug|Any CPU
{757CBDE0-5D0A-4FD8-99F3-6C20BDDD4E63}.Debug|x64.Build.0 = Debug|Any CPU
{757CBDE0-5D0A-4FD8-99F3-6C20BDDD4E63}.Debug|x86.ActiveCfg = Debug|Any CPU
{757CBDE0-5D0A-4FD8-99F3-6C20BDDD4E63}.Debug|x86.Build.0 = Debug|Any CPU
{757CBDE0-5D0A-4FD8-99F3-6C20BDDD4E63}.Release|Any CPU.ActiveCfg = Release|Any CPU
{757CBDE0-5D0A-4FD8-99F3-6C20BDDD4E63}.Release|Any CPU.Build.0 = Release|Any CPU
{757CBDE0-5D0A-4FD8-99F3-6C20BDDD4E63}.Release|arm64.ActiveCfg = Release|Any CPU
{757CBDE0-5D0A-4FD8-99F3-6C20BDDD4E63}.Release|arm64.Build.0 = Release|Any CPU
{757CBDE0-5D0A-4FD8-99F3-6C20BDDD4E63}.Release|x64.ActiveCfg = Release|Any CPU
{757CBDE0-5D0A-4FD8-99F3-6C20BDDD4E63}.Release|x64.Build.0 = Release|Any CPU
{757CBDE0-5D0A-4FD8-99F3-6C20BDDD4E63}.Release|x86.ActiveCfg = Release|Any CPU
{757CBDE0-5D0A-4FD8-99F3-6C20BDDD4E63}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down Expand Up @@ -11874,6 +11892,7 @@ Global
{9536C284-65B4-4884-BB50-06D629095C3E} = {274100A5-5B2D-4EA2-AC42-A62257FC6BDC}
{6A19D94D-2BC6-4198-BE2E-342688FDBA4B} = {A1B75FC7-A777-4412-A635-D0C9ED8FE7A0}
{D8F7091E-A2D1-4E81-BA7C-97EAE392D683} = {A1B75FC7-A777-4412-A635-D0C9ED8FE7A0}
{757CBDE0-5D0A-4FD8-99F3-6C20BDDD4E63} = {5FE1FBC1-8CE3-4355-9866-44FE1307C5F1}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {3E8720B3-DBDD-498C-B383-2CC32A054E8F}
Expand Down
1 change: 1 addition & 0 deletions src/Components/Components.slnf
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"src\\Components\\QuickGrid\\Microsoft.AspNetCore.Components.QuickGrid.EntityFrameworkAdapter\\src\\Microsoft.AspNetCore.Components.QuickGrid.EntityFrameworkAdapter.csproj",
"src\\Components\\QuickGrid\\Microsoft.AspNetCore.Components.QuickGrid\\src\\Microsoft.AspNetCore.Components.QuickGrid.csproj",
"src\\Components\\Samples\\BlazorServerApp\\BlazorServerApp.csproj",
"src\\Components\\Samples\\BlazorUnitedApp.Client\\BlazorUnitedApp.Client.csproj",
"src\\Components\\Samples\\BlazorUnitedApp\\BlazorUnitedApp.csproj",
"src\\Components\\Server\\src\\Microsoft.AspNetCore.Components.Server.csproj",
"src\\Components\\Server\\test\\Microsoft.AspNetCore.Components.Server.Tests.csproj",
Expand Down
5 changes: 5 additions & 0 deletions src/Components/Components/src/ComponentBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ public ComponentBase()
/// </summary>
protected ComponentPlatform Platform => _renderHandle.Platform;

/// <summary>
/// Gets the <see cref="ResourceAssetCollection"/> for the application.
/// </summary>
protected ResourceAssetCollection Assets => _renderHandle.Assets;

/// <summary>
/// Gets the <see cref="IComponentRenderMode"/> assigned to this component.
/// </summary>
Expand Down
16 changes: 16 additions & 0 deletions src/Components/Components/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#nullable enable
Microsoft.AspNetCore.Components.ComponentBase.Assets.get -> Microsoft.AspNetCore.Components.ResourceAssetCollection!
Microsoft.AspNetCore.Components.ComponentBase.AssignedRenderMode.get -> Microsoft.AspNetCore.Components.IComponentRenderMode?
Microsoft.AspNetCore.Components.ComponentBase.Platform.get -> Microsoft.AspNetCore.Components.ComponentPlatform!
Microsoft.AspNetCore.Components.ComponentPlatform
Expand All @@ -7,6 +8,21 @@ Microsoft.AspNetCore.Components.ComponentPlatform.IsInteractive.get -> bool
Microsoft.AspNetCore.Components.ComponentPlatform.Name.get -> string!
Microsoft.AspNetCore.Components.ExcludeFromInteractiveRoutingAttribute
Microsoft.AspNetCore.Components.ExcludeFromInteractiveRoutingAttribute.ExcludeFromInteractiveRoutingAttribute() -> void
Microsoft.AspNetCore.Components.RenderHandle.Assets.get -> Microsoft.AspNetCore.Components.ResourceAssetCollection!
Microsoft.AspNetCore.Components.RenderHandle.Platform.get -> Microsoft.AspNetCore.Components.ComponentPlatform!
Microsoft.AspNetCore.Components.RenderHandle.RenderMode.get -> Microsoft.AspNetCore.Components.IComponentRenderMode?
Microsoft.AspNetCore.Components.ResourceAsset
Microsoft.AspNetCore.Components.ResourceAsset.Properties.get -> System.Collections.Generic.IReadOnlyList<Microsoft.AspNetCore.Components.ResourceAssetProperty!>?
Microsoft.AspNetCore.Components.ResourceAsset.ResourceAsset(string! url, System.Collections.Generic.IReadOnlyList<Microsoft.AspNetCore.Components.ResourceAssetProperty!>? properties) -> void
Microsoft.AspNetCore.Components.ResourceAsset.Url.get -> string!
Microsoft.AspNetCore.Components.ResourceAssetCollection
Microsoft.AspNetCore.Components.ResourceAssetCollection.IsContentSpecificUrl(string! path) -> bool
Microsoft.AspNetCore.Components.ResourceAssetCollection.ResourceAssetCollection(System.Collections.Generic.IReadOnlyList<Microsoft.AspNetCore.Components.ResourceAsset!>! resources) -> void
Microsoft.AspNetCore.Components.ResourceAssetCollection.this[string! key].get -> string!
Microsoft.AspNetCore.Components.ResourceAssetProperty
Microsoft.AspNetCore.Components.ResourceAssetProperty.Name.get -> string!
Microsoft.AspNetCore.Components.ResourceAssetProperty.ResourceAssetProperty(string! name, string! value) -> void
Microsoft.AspNetCore.Components.ResourceAssetProperty.Value.get -> string!
static readonly Microsoft.AspNetCore.Components.ResourceAssetCollection.Empty -> Microsoft.AspNetCore.Components.ResourceAssetCollection!
virtual Microsoft.AspNetCore.Components.RenderTree.Renderer.Assets.get -> Microsoft.AspNetCore.Components.ResourceAssetCollection!
virtual Microsoft.AspNetCore.Components.RenderTree.Renderer.ComponentPlatform.get -> Microsoft.AspNetCore.Components.ComponentPlatform!
11 changes: 11 additions & 0 deletions src/Components/Components/src/RenderHandle.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,17 @@ public IComponentRenderMode? RenderMode
}
}

/// <summary>
/// Gets the <see cref="ResourceAssetCollection"/> associated with the <see cref="Renderer"/>.
/// </summary>
public ResourceAssetCollection Assets
{
get
{
return _renderer?.Assets ?? throw new InvalidOperationException("No renderer has been initialized.");
}
}

/// <summary>
/// Notifies the renderer that the component should be rendered.
/// </summary>
Expand Down
5 changes: 5 additions & 0 deletions src/Components/Components/src/RenderTree/Renderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,11 @@ protected internal ComponentState GetComponentState(IComponent component)
/// </summary>
protected internal virtual ComponentPlatform ComponentPlatform { get; }

/// <summary>
/// Gets the <see cref="ResourceAssetCollection"/> associated with this <see cref="Renderer"/>.
/// </summary>
protected internal virtual ResourceAssetCollection Assets { get; } = ResourceAssetCollection.Empty;

private async void RenderRootComponentsOnHotReload()
{
// Before re-rendering the root component, also clear any well-known caches in the framework
Expand Down
29 changes: 29 additions & 0 deletions src/Components/Components/src/ResourceAsset.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Diagnostics;
using System.Linq;

namespace Microsoft.AspNetCore.Components;

/// <summary>
/// A resource of the components application, such as a script, stylesheet or image.
/// </summary>
/// <param name="url">The URL of the resource.</param>
/// <param name="properties">The properties associated to this resource.</param>
[DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")]
public sealed class ResourceAsset(string url, IReadOnlyList<ResourceAssetProperty>? properties)
{
/// <summary>
/// Gets the URL that identifies this resource.
/// </summary>
public string Url { get; } = url;

/// <summary>
/// Gets a list of properties associated to this resource.
/// </summary>
public IReadOnlyList<ResourceAssetProperty>? Properties { get; } = properties;

private string GetDebuggerDisplay() =>
$"Url: '{Url}' - Properties: {string.Join(", ", Properties?.Select(p => $"{p.Name} = {p.Value}") ?? [])}";
}
71 changes: 71 additions & 0 deletions src/Components/Components/src/ResourceAssetCollection.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Collections;
using System.Collections.Frozen;

namespace Microsoft.AspNetCore.Components;

/// <summary>
/// Describes a mapping of static assets to their corresponding unique URLs.
/// </summary>
public sealed class ResourceAssetCollection : IReadOnlyList<ResourceAsset>
{
/// <summary>
/// An empty <see cref="ResourceAssetCollection"/>.
/// </summary>
public static readonly ResourceAssetCollection Empty = new([]);

private readonly FrozenDictionary<string, ResourceAsset> _uniqueUrlMappings;
private readonly FrozenSet<string> _contentSpecificUrls;
private readonly IReadOnlyList<ResourceAsset> _resources;

/// <summary>
/// Initializes a new instance of <see cref="ResourceAssetCollection"/>
/// </summary>
/// <param name="resources">The list of resources available.</param>
public ResourceAssetCollection(IReadOnlyList<ResourceAsset> resources)
{
var mappings = new Dictionary<string, ResourceAsset>(StringComparer.OrdinalIgnoreCase);
var contentSpecificUrls = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
_resources = resources;
foreach (var resource in resources)
{
foreach (var property in resource.Properties ?? [])
{
if (property.Name.Equals("label", StringComparison.OrdinalIgnoreCase))
{
if (mappings.TryGetValue(property.Value, out var value))
{
throw new InvalidOperationException($"The static asset '{property.Value}' is already mapped to {value.Url}.");
}
mappings[property.Value] = resource;
contentSpecificUrls.Add(resource.Url);
}
}
}

_uniqueUrlMappings = mappings.ToFrozenDictionary();
_contentSpecificUrls = contentSpecificUrls.ToFrozenSet();
}

/// <summary>
/// Gets the unique content-based URL for the specified static asset.
/// </summary>
/// <param name="key">The asset name.</param>
/// <returns>The unique URL if availabe, the same <paramref name="key"/> if not available.</returns>
public string this[string key] => _uniqueUrlMappings.TryGetValue(key, out var value) ? value.Url : key;

/// <summary>
/// Determines whether the specified path is a content-specific URL.
/// </summary>
/// <param name="path">The path to check.</param>
/// <returns><c>true</c> if the path is a content-specific URL; otherwise, <c>false</c>.</returns>
public bool IsContentSpecificUrl(string path) => _contentSpecificUrls.Contains(path);

// IReadOnlyList<ResourceAsset> implementation
ResourceAsset IReadOnlyList<ResourceAsset>.this[int index] => _resources[index];
int IReadOnlyCollection<ResourceAsset>.Count => _resources.Count;
IEnumerator<ResourceAsset> IEnumerable<ResourceAsset>.GetEnumerator() => _resources.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => _resources.GetEnumerator();
}
22 changes: 22 additions & 0 deletions src/Components/Components/src/ResourceAssetProperty.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Microsoft.AspNetCore.Components;

/// <summary>
/// A resource property.
/// </summary>
/// <param name="name">The name of the property.</param>
/// <param name="value">The value of the property.</param>
public sealed class ResourceAssetProperty(string name, string value)
{
/// <summary>
/// Gets the name of the property.
/// </summary>
public string Name { get; } = name;

/// <summary>
/// Gets the value of the property.
/// </summary>
public string Value { get; } = value;
}
88 changes: 88 additions & 0 deletions src/Components/Components/test/ResourceAssetCollectionTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Microsoft.AspNetCore.Components;

public class ResourceAssetCollectionTest
{
[Fact]
public void CanCreateResourceCollection()
{
// Arrange
var resourceAssetCollection = new ResourceAssetCollection([
new ResourceAsset("image1.jpg",[]),
]);

// Act
var collectionAsReadOnlyList = resourceAssetCollection as IReadOnlyList<ResourceAsset>;

// Assert
Assert.Equal(1, collectionAsReadOnlyList.Count);
Assert.Equal("image1.jpg", collectionAsReadOnlyList[0].Url);
}

[Fact]
public void CanResolveFingerprintedResources()
{
// Arrange
var resourceAssetCollection = new ResourceAssetCollection([
new ResourceAsset(
"image1.fingerprint.jpg",
[new ResourceAssetProperty("label", "image1.jpg")]),
]);

// Act
var resolvedUrl = resourceAssetCollection["image1.jpg"];

// Assert
Assert.Equal("image1.fingerprint.jpg", resolvedUrl);
}

[Fact]
public void ResolvingNoFingerprintedResourcesReturnsSameUrl()
{
// Arrange
var resourceAssetCollection = new ResourceAssetCollection([
new ResourceAsset("image1.jpg",[])]);

// Act
var resolvedUrl = resourceAssetCollection["image1.jpg"];

// Assert
Assert.Equal("image1.jpg", resolvedUrl);
}

[Fact]
public void ResolvingNonExistentResourceReturnsSameUrl()
{
// Arrange
var resourceAssetCollection = new ResourceAssetCollection([
new ResourceAsset("image1.jpg",[])]);

// Act
var resolvedUrl = resourceAssetCollection["image2.jpg"];

// Assert
Assert.Equal("image2.jpg", resolvedUrl);
}

[Fact]
public void CanDetermineContentSpecificUrls()
{
// Arrange
var resourceAssetCollection = new ResourceAssetCollection([
new ResourceAsset("image1.jpg",[]),
new ResourceAsset(
"image2.fingerprint.jpg",
[new ResourceAssetProperty("label", "image2.jpg")]),
]);

// Act
var isContentSpecificUrl1 = resourceAssetCollection.IsContentSpecificUrl("image1.jpg");
var isContentSpecificUrl2 = resourceAssetCollection.IsContentSpecificUrl("image2.fingerprint.jpg");

// Assert
Assert.False(isContentSpecificUrl1);
Assert.True(isContentSpecificUrl2);
}
}
63 changes: 63 additions & 0 deletions src/Components/Endpoints/src/Assets/ImportMap.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.Http;

namespace Microsoft.AspNetCore.Components;

/// <summary>
/// Represents an <c><script type="importmap"></script></c> element that defines the import map for module scripts
/// in the application.
/// </summary>
public sealed class ImportMap : IComponent
{
private RenderHandle _renderHandle;
private bool _firstRender = true;
private ImportMapDefinition? _computedImportMapDefinition;

/// <summary>
/// Gets or sets the <see cref="HttpContext"/> for the component.
/// </summary>
[CascadingParameter] public HttpContext? HttpContext { get; set; } = null;

/// <summary>
/// Gets or sets the import map definition to use for the component. If not set
/// the component will generate the import map based on the assets defined for this
/// application.
/// </summary>
[Parameter]
public ImportMapDefinition? ImportMapDefinition { get; set; }

void IComponent.Attach(RenderHandle renderHandle)
{
_renderHandle = renderHandle;
}

Task IComponent.SetParametersAsync(ParameterView parameters)
{
parameters.SetParameterProperties(this);
if (!_firstRender && ReferenceEquals(ImportMapDefinition, _computedImportMapDefinition))
{
return Task.CompletedTask;
}
else
{
_firstRender = false;
_computedImportMapDefinition = ImportMapDefinition ?? HttpContext?.GetEndpoint()?.Metadata.GetMetadata<ImportMapDefinition>();
if (_computedImportMapDefinition != null)
{
_renderHandle.Render(RenderImportMap);
}
return Task.CompletedTask;
}
}

private void RenderImportMap(RenderTreeBuilder builder)
{
builder.OpenElement(0, "script");
builder.AddAttribute(1, "type", "importmap");
builder.AddMarkupContent(2, _computedImportMapDefinition!.ToJson());
builder.CloseElement();
}
}
Loading
Loading