Просмотр исходного кода

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

* Adds support for using fingerprinted assets in Blazor and MVC.
* Fingerprinted assets are registered as additional endpoints to the system.
* Static Assets exposes the descriptors so that they can be consumed and used by Blazor and MVC/Razor pages.
* Helper methods `WithStaticAssets` map specific MapControllers/RazorPages/RazorComponents invocations to a given `MapStaticAsset` invocation and expose a `ResourceAssetCollection` as endpoint metadata that can be later on used by the system to change the way markup is rendered.
* There's built-in support in all MVC Tag helpers (URLTagHelper, ImageTagHelper, LinkTagHelper, ScriptTagHelper).
* There's support for JavaScript import maps in the ScriptTagHelper to render a JavaScript import map that maps JS modules to their fingerprinted URLs.
* `ComponentBase` exposes an `Assets` property that lets Blazor components consume the fingerprinted names of a given asset.
* When webassembly is in use, an additional couple of endpoints is registered exposing the mapping to webassembly as an additional JS Module that webassembly apps can use to consume the mapping from client code.
* Caching is disabled during development for all assets to avoid issues and can be re-enabled through a configuration property.
* Similarly, integrity is not exposed during development to avoid issues where users change a file on the fly while the app is running.
Javier Calvarro Nelson 1 год назад
Родитель
Сommit
c283d05ce0
100 измененных файлов с 3570 добавлено и 276 удалено
  1. 19 0
      AspNetCore.sln
  2. 1 0
      src/Components/Components.slnf
  3. 5 0
      src/Components/Components/src/ComponentBase.cs
  4. 16 0
      src/Components/Components/src/PublicAPI.Unshipped.txt
  5. 11 0
      src/Components/Components/src/RenderHandle.cs
  6. 5 0
      src/Components/Components/src/RenderTree/Renderer.cs
  7. 29 0
      src/Components/Components/src/ResourceAsset.cs
  8. 71 0
      src/Components/Components/src/ResourceAssetCollection.cs
  9. 22 0
      src/Components/Components/src/ResourceAssetProperty.cs
  10. 88 0
      src/Components/Components/test/ResourceAssetCollectionTest.cs
  11. 63 0
      src/Components/Endpoints/src/Assets/ImportMap.cs
  12. 214 0
      src/Components/Endpoints/src/Assets/ImportMapDefinition.cs
  13. 22 0
      src/Components/Endpoints/src/Assets/ImportMapSerializerContext.cs
  14. 2 0
      src/Components/Endpoints/src/Builder/RazorComponentDataSourceOptions.cs
  15. 29 10
      src/Components/Endpoints/src/Builder/RazorComponentEndpointDataSource.cs
  16. 5 16
      src/Components/Endpoints/src/Builder/RazorComponentEndpointDataSourceFactory.cs
  17. 1 1
      src/Components/Endpoints/src/Builder/RazorComponentEndpointFactory.cs
  18. 23 6
      src/Components/Endpoints/src/Builder/RazorComponentsEndpointConventionBuilder.cs
  19. 31 0
      src/Components/Endpoints/src/Builder/RazorComponentsEndpointConventionBuilderExtensions.cs
  20. 11 2
      src/Components/Endpoints/src/Builder/RazorComponentsEndpointRouteBuilderExtensions.cs
  21. 0 1
      src/Components/Endpoints/src/Builder/RenderModeEndpointProvider.cs
  22. 63 0
      src/Components/Endpoints/src/Builder/ResourceCollectionConvention.cs
  23. 293 0
      src/Components/Endpoints/src/Builder/ResourceCollectionUrlEndpoint.cs
  24. 9 0
      src/Components/Endpoints/src/Builder/ResourceCollectionUrlMetadata.cs
  25. 2 0
      src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs
  26. 3 0
      src/Components/Endpoints/src/Microsoft.AspNetCore.Components.Endpoints.csproj
  27. 15 0
      src/Components/Endpoints/src/PublicAPI.Unshipped.txt
  28. 28 2
      src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs
  29. 201 0
      src/Components/Endpoints/test/Assets/ImportMapDefinitionTest.cs
  30. 294 0
      src/Components/Endpoints/test/Builder/RazorComponentsEndpointConventionBuilderExtensionsTest.cs
  31. 38 0
      src/Components/Endpoints/test/Builder/TestFileProvider/TestDirectoryContent.cs
  32. 32 0
      src/Components/Endpoints/test/Builder/TestFileProvider/TestFileChangeToken.cs
  33. 42 0
      src/Components/Endpoints/test/Builder/TestFileProvider/TestFileInfo.cs
  34. 179 0
      src/Components/Endpoints/test/Builder/TestFileProvider/TestFileProvider.cs
  35. 1 1
      src/Components/Endpoints/test/EndpointHtmlRendererTest.cs
  36. 1 0
      src/Components/Endpoints/test/HotReloadServiceTests.cs
  37. 393 0
      src/Components/Endpoints/test/ImportMapTest.cs
  38. 5 0
      src/Components/Endpoints/test/Microsoft.AspNetCore.Components.Endpoints.Tests.csproj
  39. 3 1
      src/Components/Endpoints/test/RazorComponentEndpointFactoryTest.cs
  40. 17 0
      src/Components/Endpoints/test/TestApplication.staticwebassets.endpoints.json
  41. 17 0
      src/Components/Endpoints/test/TestManifests/Test.staticwebassets.endpoints.json
  42. 19 0
      src/Components/Samples/BlazorUnitedApp.Client/BlazorUnitedApp.Client.csproj
  43. 1 0
      src/Components/Samples/BlazorUnitedApp.Client/HelloWorld.razor
  44. 8 0
      src/Components/Samples/BlazorUnitedApp.Client/Program.cs
  45. 41 0
      src/Components/Samples/BlazorUnitedApp.Client/Properties/launchSettings.json
  46. 9 0
      src/Components/Samples/BlazorUnitedApp.Client/_Imports.razor
  47. 7 5
      src/Components/Samples/BlazorUnitedApp/App.razor
  48. 28 0
      src/Components/Samples/BlazorUnitedApp/BlazorUnitedApp.csproj
  49. 4 0
      src/Components/Samples/BlazorUnitedApp/Pages/WebAssemblyComponent.razor
  50. 3 1
      src/Components/Samples/BlazorUnitedApp/Program.cs
  51. 3 0
      src/Components/Samples/BlazorUnitedApp/Properties/launchSettings.json
  52. 5 0
      src/Components/Samples/BlazorUnitedApp/Shared/NavMenu.razor
  53. 1 0
      src/Components/Samples/BlazorUnitedApp/appsettings.Development.json
  54. 4 2
      src/Components/Server/src/Circuits/CircuitFactory.cs
  55. 2 1
      src/Components/Server/src/Circuits/ICircuitFactory.cs
  56. 6 1
      src/Components/Server/src/Circuits/RemoteRenderer.cs
  57. 3 2
      src/Components/Server/src/ComponentHub.cs
  58. 10 1
      src/Components/Server/test/Circuits/ComponentHubTest.cs
  59. 66 0
      src/Components/Shared/src/ResourceCollectionProvider.cs
  60. 3 0
      src/Components/WebAssembly/DevServer/src/Server/Program.cs
  61. 5 3
      src/Components/WebAssembly/DevServer/src/Server/Startup.cs
  62. 0 2
      src/Components/WebAssembly/Server/src/Builder/WebAssemblyComponentsEndpointOptions.cs
  63. 5 20
      src/Components/WebAssembly/Server/src/Builder/WebAssemblyRazorComponentsEndpointConventionBuilderExtensions.cs
  64. 52 0
      src/Components/WebAssembly/Server/src/ComponentWebAssemblyConventions.cs
  65. 0 88
      src/Components/WebAssembly/Server/src/ComponentsWebAssemblyStaticAssetsEndpointConventionBuilderExtensions.cs
  66. 83 0
      src/Components/WebAssembly/Server/src/WebAssemblyEndpointProvider.cs
  67. 0 82
      src/Components/WebAssembly/Server/src/WebAssemblyRazorComponentsBuilderExtensions.cs
  68. 3 2
      src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHost.cs
  69. 1 0
      src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs
  70. 1 0
      src/Components/WebAssembly/WebAssembly/src/Microsoft.AspNetCore.Components.WebAssembly.csproj
  71. 6 1
      src/Components/WebAssembly/WebAssembly/src/Rendering/WebAssemblyRenderer.cs
  72. 63 0
      src/Components/test/E2ETest/ServerRenderingTests/ResourceCollectionTest.cs
  73. 0 1
      src/Components/test/testassets/Components.TestServer/Components.TestServer.csproj
  74. 44 0
      src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/ResourceCollection/Index.razor
  75. 4 0
      src/Components/test/testassets/Components.TestServer/RemoteAuthenticationStartup.cs
  76. 2 0
      src/Components/test/testassets/Components.TestServer/appsettings.json
  77. 4 0
      src/Components/test/testassets/Components.TestServer/wwwroot/Index.mjs
  78. 22 0
      src/Components/test/testassets/TestContentPackage/ResourceCollectionSample.razor
  79. 1 1
      src/Framework/App.Ref/src/CompatibilitySuppressions.xml
  80. 1 1
      src/Framework/App.Runtime/src/CompatibilitySuppressions.xml
  81. 29 4
      src/Http/Routing/src/Matching/NegotiationMatcherPolicy.cs
  82. 1 0
      src/Http/WebUtilities/src/PublicAPI.Unshipped.txt
  83. 2 0
      src/Mvc/Mvc.Core/src/Builder/ControllerActionEndpointConventionBuilder.cs
  84. 19 1
      src/Mvc/Mvc.Core/src/Builder/ControllerEndpointRouteBuilderExtensions.cs
  85. 41 0
      src/Mvc/Mvc.Razor/src/TagHelpers/UrlResolutionTagHelper.cs
  86. 88 3
      src/Mvc/Mvc.Razor/test/TagHelpers/UrlResolutionTagHelperTest.cs
  87. 2 0
      src/Mvc/Mvc.RazorPages/src/Builder/PageActionEndpointConventionBuilder.cs
  88. 53 0
      src/Mvc/Mvc.RazorPages/src/Builder/PageActionEndpointConventionBuilderResourceCollectionExtensions.cs
  89. 8 1
      src/Mvc/Mvc.RazorPages/src/Builder/RazorPagesEndpointRouteBuilderExtensions.cs
  90. 5 1
      src/Mvc/Mvc.RazorPages/src/Infrastructure/PageActionEndpointDataSourceFactory.cs
  91. 2 0
      src/Mvc/Mvc.RazorPages/src/PublicAPI.Unshipped.txt
  92. 287 0
      src/Mvc/Mvc.RazorPages/test/Builder/PageActionEndpointConventionBuilderResourceCollectionExtensionsTest.cs
  93. 7 1
      src/Mvc/Mvc.RazorPages/test/Microsoft.AspNetCore.Mvc.RazorPages.Test.csproj
  94. 17 0
      src/Mvc/Mvc.RazorPages/test/TestApplication.staticwebassets.endpoints.json
  95. 17 0
      src/Mvc/Mvc.RazorPages/test/TestManifests/Test.staticwebassets.endpoints.json
  96. 22 1
      src/Mvc/Mvc.TagHelpers/src/ImageTagHelper.cs
  97. 25 7
      src/Mvc/Mvc.TagHelpers/src/LinkTagHelper.cs
  98. 4 0
      src/Mvc/Mvc.TagHelpers/src/PublicAPI.Unshipped.txt
  99. 62 0
      src/Mvc/Mvc.TagHelpers/src/ResourceCollectionUtilities.cs
  100. 55 3
      src/Mvc/Mvc.TagHelpers/src/ScriptTagHelper.cs

+ 19 - 0
AspNetCore.sln

@@ -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
@@ -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
@@ -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}

+ 1 - 0
src/Components/Components.slnf

@@ -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",

+ 5 - 0
src/Components/Components/src/ComponentBase.cs

@@ -47,6 +47,11 @@ public abstract class ComponentBase : IComponent, IHandleEvent, IHandleAfterRend
     /// </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>

+ 16 - 0
src/Components/Components/src/PublicAPI.Unshipped.txt

@@ -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
@@ -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 - 0
src/Components/Components/src/RenderHandle.cs

@@ -73,6 +73,17 @@ public readonly struct RenderHandle
         }
     }
 
+    /// <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>

+ 5 - 0
src/Components/Components/src/RenderTree/Renderer.cs

@@ -158,6 +158,11 @@ public abstract partial class Renderer : IDisposable, IAsyncDisposable
     /// </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

+ 29 - 0
src/Components/Components/src/ResourceAsset.cs

@@ -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 - 0
src/Components/Components/src/ResourceAssetCollection.cs

@@ -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 - 0
src/Components/Components/src/ResourceAssetProperty.cs

@@ -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 - 0
src/Components/Components/test/ResourceAssetCollectionTest.cs

@@ -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 - 0
src/Components/Endpoints/src/Assets/ImportMap.cs

@@ -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();
+    }
+}

+ 214 - 0
src/Components/Endpoints/src/Assets/ImportMapDefinition.cs

@@ -0,0 +1,214 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Linq;
+using System.Text.Json;
+
+namespace Microsoft.AspNetCore.Components;
+
+/// <summary>
+/// Represents the contents of a <c><script type="importmap"></script></c> element that defines the import map
+/// for module scripts in the application.
+/// </summary>
+/// <remarks>
+/// The import map is a JSON object that defines the mapping of module import specifiers to URLs.
+/// <see cref="ImportMapDefinition"/> instances are expensive to create, so it is recommended to cache them if
+/// you are creating an additional instance.
+/// </remarks>
+public sealed class ImportMapDefinition
+{
+    private Dictionary<string, string>? _imports;
+    private Dictionary<string, IReadOnlyDictionary<string, string>>? _scopes;
+    private Dictionary<string, string>? _integrity;
+    private string? _json;
+
+    /// <summary>
+    /// Initializes a new instance of <see cref="ImportMapDefinition"/>."/> with the specified imports, scopes, and integrity.
+    /// </summary>
+    /// <param name="imports">The unscoped imports defined in the import map.</param>
+    /// <param name="scopes">The scoped imports defined in the import map.</param>
+    /// <param name="integrity">The integrity for the imports defined in the import map.</param>
+    /// <remarks>
+    /// The <paramref name="imports"/>, <paramref name="scopes"/>, and <paramref name="integrity"/> parameters
+    /// will be copied into the new instance. The original collections will not be modified.
+    /// </remarks>
+    public ImportMapDefinition(
+        IReadOnlyDictionary<string, string>? imports,
+        IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>>? scopes,
+        IReadOnlyDictionary<string, string>? integrity)
+    {
+        _imports = imports?.ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
+        _integrity = integrity?.ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
+        _scopes = scopes?.ToDictionary(
+            kvp => kvp.Key,
+            kvp => kvp.Value.ToDictionary(scopeKvp => scopeKvp.Key, scopeKvp => scopeKvp.Value) as IReadOnlyDictionary<string, string>);
+    }
+
+    private ImportMapDefinition()
+    {
+    }
+
+    /// <summary>
+    /// Creates an import map from a <see cref="ResourceAssetCollection"/>.
+    /// </summary>
+    /// <param name="assets">The collection of assets to create the import map from.</param>
+    /// <returns>The import map.</returns>
+    public static ImportMapDefinition FromResourceCollection(ResourceAssetCollection assets)
+    {
+        var importMap = new ImportMapDefinition();
+        foreach (var asset in assets)
+        {
+            if (!(asset.Url.EndsWith(".mjs", StringComparison.OrdinalIgnoreCase) ||
+                asset.Url.EndsWith(".js", StringComparison.OrdinalIgnoreCase)) ||
+                asset.Properties == null)
+            {
+                continue;
+            }
+
+            var (integrity, label) = GetAssetProperties(asset);
+            if (integrity != null)
+            {
+                importMap._integrity ??= [];
+                importMap._integrity[asset.Url] = integrity;
+            }
+
+            if (label != null)
+            {
+                importMap._imports ??= [];
+                importMap._imports[$"./{label}"] = $"./{asset.Url}";
+            }
+        }
+
+        return importMap;
+    }
+
+    private static (string? integrity, string? label) GetAssetProperties(ResourceAsset asset)
+    {
+        string? integrity = null;
+        string? label = null;
+        for (var i = 0; i < asset.Properties!.Count; i++)
+        {
+            var property = asset.Properties[i];
+            if (string.Equals(property.Name, "integrity", StringComparison.OrdinalIgnoreCase))
+            {
+                integrity = property.Value;
+            }
+            else if (string.Equals(property.Name, "label", StringComparison.OrdinalIgnoreCase))
+            {
+                label = property.Value;
+            }
+
+            if (integrity != null && label != null)
+            {
+                return (integrity, label);
+            }
+        }
+
+        return (integrity, label);
+    }
+
+    /// <summary>
+    /// Combines one or more import maps into a single import map.
+    /// </summary>
+    /// <param name="sources">The list of import maps to combine.</param>
+    /// <returns>
+    /// A new import map that is the combination of all the input import maps with their
+    /// entries applied in order.
+    /// </returns>
+    public static ImportMapDefinition Combine(params ImportMapDefinition[] sources)
+    {
+        var importMap = new ImportMapDefinition();
+        foreach (var item in sources)
+        {
+            if (item.Imports != null)
+            {
+                importMap._imports ??= [];
+                foreach (var (key, value) in item.Imports)
+                {
+                    importMap._imports[key] = value;
+                }
+            }
+
+            if (item.Scopes != null)
+            {
+                importMap._scopes ??= [];
+                foreach (var (key, value) in item.Scopes)
+                {
+                    if (importMap._scopes.TryGetValue(key, out var existingScope) && existingScope != null)
+                    {
+                        foreach (var (scopeKey, scopeValue) in value)
+                        {
+                            ((Dictionary<string, string>)importMap._scopes[key])[scopeKey] = scopeValue;
+                        }
+                    }
+                    else
+                    {
+                        importMap._scopes[key] = new Dictionary<string, string>(value);
+                    }
+                }
+            }
+
+            if (item.Integrity != null)
+            {
+                importMap._integrity ??= [];
+                foreach (var (key, value) in item.Integrity)
+                {
+                    importMap._integrity[key] = value;
+                }
+            }
+        }
+
+        return importMap;
+    }
+
+    // Example:
+    // "imports": {
+    //   "triangle": "./module/shapes/triangle.js",
+    //   "pentagram": "https://example.com/shapes/pentagram.js"
+    // }
+    /// <summary>
+    /// Gets the unscoped imports defined in the import map.
+    /// </summary>
+    public IReadOnlyDictionary<string, string>? Imports { get => _imports; }
+
+    // Example:
+    // {
+    //   "imports": {
+    //     "triangle": "./module/shapes/triangle.js"
+    //   },
+    //   "scopes": {
+    //     "/modules/myshapes/": {
+    //       "triangle": "https://example.com/modules/myshapes/triangle.js"
+    //     }
+    //   }
+    // }
+    /// <summary>
+    /// Gets the scoped imports defined in the import map.
+    /// </summary>
+    public IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>>? Scopes { get => _scopes; }
+
+    // Example:
+    // <script type="importmap">
+    // {
+    //   "imports": {
+    //     "triangle": "./module/shapes/triangle.js"
+    //   },
+    //   "integrity": {
+    //     "./module/shapes/triangle.js": "sha256-..."
+    //   }
+    // }
+    // </script>
+    /// <summary>
+    /// Gets the integrity properties defined in the import map.
+    /// </summary>
+    public IReadOnlyDictionary<string, string>? Integrity { get => _integrity; }
+
+    internal string ToJson()
+    {
+        _json ??= JsonSerializer.Serialize(this, ImportMapSerializerContext.CustomEncoder.Options);
+        return _json;
+    }
+
+    /// <inheritdoc />
+    public override string ToString() => ToJson();
+}

+ 22 - 0
src/Components/Endpoints/src/Assets/ImportMapSerializerContext.cs

@@ -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.
+
+using System.Text.Encodings.Web;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Microsoft.AspNetCore.Components;
+
+[JsonSerializable(typeof(ImportMapDefinition))]
+internal partial class ImportMapSerializerContext : JsonSerializerContext
+{
+    private static ImportMapSerializerContext? _customEncoder;
+
+    public static ImportMapSerializerContext CustomEncoder => _customEncoder ??= new(new JsonSerializerOptions
+    {
+        WriteIndented = true,
+        Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
+        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+    });
+}

+ 2 - 0
src/Components/Endpoints/src/Builder/RazorComponentDataSourceOptions.cs

@@ -28,5 +28,7 @@ internal class RazorComponentDataSourceOptions
                 _ => throw new InvalidOperationException($"Unknown render mode: {obj}"),
             });
 
+    public string? ManifestPath { get; set; }
+
     internal ISet<IComponentRenderMode> ConfiguredRenderModes { get; } = new HashSet<IComponentRenderMode>(RenderModeComparer);
 }

+ 29 - 10
src/Components/Endpoints/src/Builder/RazorComponentEndpointDataSource.cs

@@ -1,6 +1,7 @@
 // Licensed to the .NET Foundation under one or more agreements.
 // The .NET Foundation licenses this file to you under the MIT license.
 
+using System.Buffers;
 using System.Diagnostics;
 using System.Diagnostics.CodeAnalysis;
 using System.Linq;
@@ -17,11 +18,12 @@ namespace Microsoft.AspNetCore.Components.Endpoints;
 internal class RazorComponentEndpointDataSource<[DynamicallyAccessedMembers(Component)] TRootComponent> : EndpointDataSource
 {
     private readonly object _lock = new();
-    private readonly List<Action<EndpointBuilder>> _conventions = new();
-    private readonly List<Action<EndpointBuilder>> _finallyConventions = new();
+    private readonly List<Action<EndpointBuilder>> _conventions = [];
+    private readonly List<Action<EndpointBuilder>> _finallyConventions = [];
     private readonly RazorComponentDataSourceOptions _options = new();
     private readonly ComponentApplicationBuilder _builder;
-    private readonly IApplicationBuilder _applicationBuilder;
+    private readonly IEndpointRouteBuilder _endpointRouteBuilder;
+    private readonly ResourceCollectionResolver _resourceCollectionResolver;
     private readonly RenderModeEndpointProvider[] _renderModeEndpointProviders;
     private readonly RazorComponentEndpointFactory _factory;
     private readonly HotReloadService? _hotReloadService;
@@ -44,7 +46,8 @@ internal class RazorComponentEndpointDataSource<[DynamicallyAccessedMembers(Comp
         HotReloadService? hotReloadService = null)
     {
         _builder = builder;
-        _applicationBuilder = endpointRouteBuilder.CreateApplicationBuilder();
+        _endpointRouteBuilder = endpointRouteBuilder;
+        _resourceCollectionResolver = new ResourceCollectionResolver(endpointRouteBuilder);
         _renderModeEndpointProviders = renderModeEndpointProviders.ToArray();
         _factory = factory;
         _hotReloadService = hotReloadService;
@@ -99,32 +102,48 @@ internal class RazorComponentEndpointDataSource<[DynamicallyAccessedMembers(Comp
 
     private void UpdateEndpoints()
     {
+        const string ResourceCollectionKey = "__ResourceCollectionKey";
+
         lock (_lock)
         {
             var endpoints = new List<Endpoint>();
             var context = _builder.Build();
             var configuredRenderModesMetadata = new ConfiguredRenderModesMetadata(
-                Options.ConfiguredRenderModes.ToArray());
+                [.. Options.ConfiguredRenderModes]);
+
+            var endpointContext = new RazorComponentEndpointUpdateContext(endpoints, _options);
+
+            DefaultBuilder.OnBeforeCreateEndpoints(endpointContext);
 
             foreach (var definition in context.Pages)
             {
-                _factory.AddEndpoints(endpoints, typeof(TRootComponent), definition, _conventions, _finallyConventions, configuredRenderModesMetadata);
+                _factory.AddEndpoints(
+                    endpoints,
+                    typeof(TRootComponent),
+                    definition,
+                    _conventions,
+                    _finallyConventions,
+                    configuredRenderModesMetadata);
             }
 
-            ICollection<IComponentRenderMode> renderModes = Options.ConfiguredRenderModes;
+            // Extract the endpoint collection from any of the endpoints
+            var resourceCollection = endpoints.Count > 0 ? endpoints[^1].Metadata.GetMetadata<ResourceAssetCollection>() : null;
 
+            ICollection<IComponentRenderMode> renderModes = Options.ConfiguredRenderModes;
             foreach (var renderMode in renderModes)
             {
                 var found = false;
                 foreach (var provider in _renderModeEndpointProviders)
                 {
+                    var builder = _endpointRouteBuilder.CreateApplicationBuilder();
+                    builder.Properties[ResourceCollectionKey] = resourceCollection;
                     if (provider.Supports(renderMode))
                     {
                         found = true;
                         RenderModeEndpointProvider.AddEndpoints(
                             endpoints,
                             typeof(TRootComponent),
-                            provider.GetEndpointBuilders(renderMode, _applicationBuilder.New()),
+                            provider.GetEndpointBuilders(renderMode, builder),
                             renderMode,
                             _conventions,
                             _finallyConventions);
@@ -145,14 +164,14 @@ internal class RazorComponentEndpointDataSource<[DynamicallyAccessedMembers(Comp
             _changeToken = new CancellationChangeToken(_cancellationTokenSource.Token);
             oldCancellationTokenSource?.Cancel();
             oldCancellationTokenSource?.Dispose();
-            if (_hotReloadService is { MetadataUpdateSupported : true })
+            if (_hotReloadService is { MetadataUpdateSupported: true })
             {
                 _disposableChangeToken?.Dispose();
                 _disposableChangeToken = SetDisposableChangeTokenAction(ChangeToken.OnChange(_hotReloadService.GetChangeToken, UpdateEndpoints));
             }
         }
     }
- 
+
     public void OnHotReloadClearCache(Type[]? types)
     {
         lock (_lock)

+ 5 - 16
src/Components/Endpoints/src/Builder/RazorComponentEndpointDataSourceFactory.cs

@@ -10,27 +10,16 @@ using static Microsoft.AspNetCore.Internal.LinkerFlags;
 
 namespace Microsoft.AspNetCore.Components.Infrastructure;
 
-internal class RazorComponentEndpointDataSourceFactory
+internal class RazorComponentEndpointDataSourceFactory(
+    RazorComponentEndpointFactory factory,
+    IEnumerable<RenderModeEndpointProvider> providers,
+    HotReloadService? hotReloadService = null)
 {
-    private readonly RazorComponentEndpointFactory _factory;
-    private readonly IEnumerable<RenderModeEndpointProvider> _providers;
-    private readonly HotReloadService? _hotReloadService;
-
-    public RazorComponentEndpointDataSourceFactory(
-        RazorComponentEndpointFactory factory,
-        IEnumerable<RenderModeEndpointProvider> providers,
-        HotReloadService? hotReloadService = null)
-    {
-        _factory = factory;
-        _providers = providers;
-        _hotReloadService = hotReloadService;
-    }
-
     public RazorComponentEndpointDataSource<TRootComponent> CreateDataSource<[DynamicallyAccessedMembers(Component)] TRootComponent>(IEndpointRouteBuilder endpoints)
     {
         var builder = ComponentApplicationBuilder.GetBuilder<TRootComponent>() ??
             DefaultRazorComponentApplication<TRootComponent>.Instance.GetBuilder();
 
-        return new RazorComponentEndpointDataSource<TRootComponent>(builder, _providers, endpoints, _factory, _hotReloadService);
+        return new RazorComponentEndpointDataSource<TRootComponent>(builder, providers, endpoints, factory, hotReloadService);
     }
 }

+ 1 - 1
src/Components/Endpoints/src/Builder/RazorComponentEndpointFactory.cs

@@ -15,7 +15,7 @@ namespace Microsoft.AspNetCore.Components.Endpoints;
 
 internal class RazorComponentEndpointFactory
 {
-    private static readonly HttpMethodMetadata HttpMethodsMetadata = new(new[] { HttpMethods.Get, HttpMethods.Post });
+    private static readonly HttpMethodMetadata HttpMethodsMetadata = new([HttpMethods.Get, HttpMethods.Post]);
 
 #pragma warning disable CA1822 // It's a singleton
     internal void AddEndpoints(

+ 23 - 6
src/Components/Endpoints/src/Builder/RazorComponentsEndpointConventionBuilder.cs

@@ -4,6 +4,7 @@
 using Microsoft.AspNetCore.Components;
 using Microsoft.AspNetCore.Components.Discovery;
 using Microsoft.AspNetCore.Components.Endpoints;
+using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Routing;
 
 namespace Microsoft.AspNetCore.Builder;
@@ -14,8 +15,6 @@ namespace Microsoft.AspNetCore.Builder;
 public sealed class RazorComponentsEndpointConventionBuilder : IEndpointConventionBuilder
 {
     private readonly object _lock;
-    private readonly ComponentApplicationBuilder _builder;
-    private readonly IEndpointRouteBuilder _endpointRouteBuilder;
     private readonly RazorComponentDataSourceOptions _options;
     private readonly List<Action<EndpointBuilder>> _conventions;
     private readonly List<Action<EndpointBuilder>> _finallyConventions;
@@ -29,8 +28,8 @@ public sealed class RazorComponentsEndpointConventionBuilder : IEndpointConventi
         List<Action<EndpointBuilder>> finallyConventions)
     {
         _lock = @lock;
-        _builder = builder;
-        _endpointRouteBuilder = endpointRouteBuilder;
+        ApplicationBuilder = builder;
+        EndpointRouteBuilder = endpointRouteBuilder;
         _options = options;
         _conventions = conventions;
         _finallyConventions = finallyConventions;
@@ -39,9 +38,15 @@ public sealed class RazorComponentsEndpointConventionBuilder : IEndpointConventi
     /// <summary>
     /// Gets the <see cref="ComponentApplicationBuilder"/> that is used to build the endpoints.
     /// </summary>
-    internal ComponentApplicationBuilder ApplicationBuilder => _builder;
+    internal ComponentApplicationBuilder ApplicationBuilder { get; }
 
-    internal IEndpointRouteBuilder EndpointRouteBuilder => _endpointRouteBuilder;
+    internal string? ManifestPath { get => _options.ManifestPath; set => _options.ManifestPath = value; }
+
+    internal bool ResourceCollectionConventionRegistered { get; set; }
+
+    internal IEndpointRouteBuilder EndpointRouteBuilder { get; }
+
+    internal event Action<RazorComponentEndpointUpdateContext>? BeforeCreateEndpoints;
 
     /// <inheritdoc/>
     public void Add(Action<EndpointBuilder> convention)
@@ -71,5 +76,17 @@ public sealed class RazorComponentsEndpointConventionBuilder : IEndpointConventi
     {
         _options.ConfiguredRenderModes.Add(renderMode);
     }
+
+    internal void OnBeforeCreateEndpoints(RazorComponentEndpointUpdateContext endpointContext) =>
+        BeforeCreateEndpoints?.Invoke(endpointContext);
+}
+
+internal class RazorComponentEndpointUpdateContext(
+    List<Endpoint> endpoints,
+    RazorComponentDataSourceOptions options)
+{
+    public List<Endpoint> Endpoints { get; } = endpoints;
+
+    public RazorComponentDataSourceOptions Options { get; } = options;
 }
 

+ 31 - 0
src/Components/Endpoints/src/Builder/RazorComponentsEndpointConventionBuilderExtensions.cs

@@ -2,6 +2,8 @@
 // The .NET Foundation licenses this file to you under the MIT license.
 
 using System.Reflection;
+using Microsoft.AspNetCore.Components;
+using Microsoft.AspNetCore.Components.Endpoints;
 
 namespace Microsoft.AspNetCore.Builder;
 
@@ -32,4 +34,33 @@ public static class RazorComponentsEndpointConventionBuilderExtensions
         }
         return builder;
     }
+
+    /// <summary>
+    /// Sets a <see cref="ResourceAssetCollection"/> and <see cref="ImportMapDefinition"/> metadata
+    /// for the component application.
+    /// </summary>
+    /// <param name="builder">The <see cref="RazorComponentsEndpointConventionBuilder"/>.</param>
+    /// <param name="manifestPath">The manifest associated with the assets.</param>
+    /// <returns>The <see cref="RazorComponentsEndpointConventionBuilder"/>.</returns>
+    /// <remarks>
+    /// The <paramref name="manifestPath"/> must match the path of the manifes file provided to
+    /// the <see cref="StaticAssetsEndpointRouteBuilderExtensions.MapStaticAssets(Routing.IEndpointRouteBuilder, string?)"/>
+    /// call.
+    /// </remarks>
+    public static RazorComponentsEndpointConventionBuilder WithStaticAssets(
+        this RazorComponentsEndpointConventionBuilder builder,
+        string? manifestPath = null)
+    {
+        ArgumentNullException.ThrowIfNull(builder);
+        builder.ManifestPath = manifestPath;
+        if (!builder.ResourceCollectionConventionRegistered)
+        {
+            builder.ResourceCollectionConventionRegistered = true;
+            var convention = new ResourceCollectionConvention(new ResourceCollectionResolver(builder.EndpointRouteBuilder));
+            builder.BeforeCreateEndpoints += convention.OnBeforeCreateEndpoints;
+            builder.Add(convention.ApplyConvention);
+        }
+
+        return builder;
+    }
 }

+ 11 - 2
src/Components/Endpoints/src/Builder/RazorComponentsEndpointRouteBuilderExtensions.cs

@@ -33,7 +33,15 @@ public static class RazorComponentsEndpointRouteBuilderExtensions
         AddBlazorWebJsEndpoint(endpoints);
         OpaqueRedirection.AddBlazorOpaqueRedirectionEndpoint(endpoints);
 
-        return GetOrCreateDataSource<TRootComponent>(endpoints).DefaultBuilder;
+        var result = GetOrCreateDataSource<TRootComponent>(endpoints).DefaultBuilder;
+
+        // Setup the convention to find the list of descriptors in the endpoint builder and
+        // populate a resource collection out of them.
+        // The user can call WithStaticAssets with a manifest path to override the manifest
+        // to use for the resource collection in case more than one has been mapped.
+        result.WithStaticAssets();
+
+        return result;
     }
 
     private static void AddBlazorWebJsEndpoint(IEndpointRouteBuilder endpoints)
@@ -67,7 +75,8 @@ public static class RazorComponentsEndpointRouteBuilderExtensions
 #endif
     }
 
-    private static RazorComponentEndpointDataSource<TRootComponent> GetOrCreateDataSource<[DynamicallyAccessedMembers(Component)] TRootComponent>(IEndpointRouteBuilder endpoints)
+    private static RazorComponentEndpointDataSource<TRootComponent> GetOrCreateDataSource<[DynamicallyAccessedMembers(Component)] TRootComponent>(
+        IEndpointRouteBuilder endpoints)
     {
         var dataSource = endpoints.DataSources.OfType<RazorComponentEndpointDataSource<TRootComponent>>().FirstOrDefault();
         if (dataSource == null)

+ 0 - 1
src/Components/Endpoints/src/Builder/RenderModeEndpointProvider.cs

@@ -43,7 +43,6 @@ public abstract class RenderModeEndpointProvider
         {
             builder.Metadata.Add(new RootComponentMetadata(rootComponent));
             builder.Metadata.Add(renderMode);
-
             foreach (var convention in conventions)
             {
                 convention(builder);

+ 63 - 0
src/Components/Endpoints/src/Builder/ResourceCollectionConvention.cs

@@ -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;
+using Microsoft.AspNetCore.Components.Endpoints;
+using Microsoft.AspNetCore.Components.Web;
+
+namespace Microsoft.AspNetCore.Builder;
+
+internal class ResourceCollectionConvention(ResourceCollectionResolver resolver)
+{
+    private string? _collectionUrl;
+    private ImportMapDefinition? _collectionEndpointImportMap;
+    private ResourceAssetCollection? _collection;
+    private ImportMapDefinition? _collectionImportMap;
+
+    public void OnBeforeCreateEndpoints(RazorComponentEndpointUpdateContext context)
+    {
+        if (resolver.IsRegistered(context.Options.ManifestPath))
+        {
+            _collection = resolver.ResolveResourceCollection(context.Options.ManifestPath);
+            _collectionImportMap = ImportMapDefinition.FromResourceCollection(_collection);
+
+            string? url = null;
+            ImportMapDefinition? map = null;
+            foreach (var renderMode in context.Options.ConfiguredRenderModes)
+            {
+                if (renderMode is InteractiveWebAssemblyRenderMode or InteractiveAutoRenderMode)
+                {
+                    (map, url) = ResourceCollectionUrlEndpoint.MapResourceCollectionEndpoints(
+                        context.Endpoints,
+                        "_framework/resource-collection{0}.js{1}",
+                        _collection);
+                    break;
+                }
+            }
+
+            if (url != null && map != null)
+            {
+                _collectionUrl = url;
+                _collectionEndpointImportMap = map;
+            }
+        }
+    }
+
+    public void ApplyConvention(EndpointBuilder eb)
+    {
+        // The user called MapStaticAssets
+        if (_collection != null && _collectionImportMap != null)
+        {
+            eb.Metadata.Add(_collection);
+
+            if (_collectionUrl != null)
+            {
+                eb.Metadata.Add(new ResourceCollectionUrlMetadata(_collectionUrl));
+            }
+
+            var importMap = _collectionEndpointImportMap == null ? _collectionImportMap :
+                ImportMapDefinition.Combine(_collectionImportMap, _collectionEndpointImportMap);
+            eb.Metadata.Add(importMap);
+        }
+    }
+}

+ 293 - 0
src/Components/Endpoints/src/Builder/ResourceCollectionUrlEndpoint.cs

@@ -0,0 +1,293 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Buffers;
+using System.Globalization;
+using System.IO.Compression;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.AspNetCore.Routing.Patterns;
+using Microsoft.Extensions.Primitives;
+using Microsoft.Net.Http.Headers;
+
+namespace Microsoft.AspNetCore.Components.Endpoints;
+
+internal partial class ResourceCollectionUrlEndpoint
+{
+    internal static (ImportMapDefinition, string) MapResourceCollectionEndpoints(
+        List<Endpoint> endpoints,
+        string resourceCollectionUrlFormat,
+        ResourceAssetCollection resourceCollection)
+    {
+        // We map an additional endpoint to serve the resources so webassembly can fetch the list of
+        // resources and use fingerprinted resources when running interactively.
+        // We expose the same endpoint in four different urls _framework/resource-collection(.<fingerprint>)?.js(.gz)? and
+        // with appropriate caching headers in both cases.
+        // The fingerprinted URL allows us to cache the resource collection forever and avoid an additional request
+        // to fetch the resource collection on subsequent visits.
+        var fingerprintSuffix = ComputeFingerprintSuffix(resourceCollection)[0..6];
+        // $"/_framework/resource-collection.{fingerprint}.js";
+        var fingerprintedResourceCollectionUrl = string.Format(CultureInfo.InvariantCulture, resourceCollectionUrlFormat, fingerprintSuffix, "");
+        // $"/_framework/resource-collection.{fingerprintSuffix}.js.gz";
+        var fingerprintedGzResourceCollectionUrl = string.Format(CultureInfo.InvariantCulture, resourceCollectionUrlFormat, fingerprintSuffix, ".gz");
+        // $"/_framework/resource-collection.js"
+        var resourceCollectionUrl = string.Format(CultureInfo.InvariantCulture, resourceCollectionUrlFormat, "", "");
+        // $"/_framework/resource-collection.js.gz"
+        var gzResourceCollectionUrl = string.Format(CultureInfo.InvariantCulture, resourceCollectionUrlFormat, "", ".gz");
+
+        var bytes = CreateContent(resourceCollection);
+        var gzipBytes = CreateGzipBytes(bytes);
+        var integrity = ComputeIntegrity(bytes);
+
+        var resourceCollectionEndpoints = new ResourceCollectionEndpointsBuilder(bytes, gzipBytes);
+        var builders = resourceCollectionEndpoints.CreateEndpoints(
+            fingerprintedResourceCollectionUrl,
+            fingerprintedGzResourceCollectionUrl,
+            resourceCollectionUrl,
+            gzResourceCollectionUrl);
+
+        foreach (var builder in builders)
+        {
+            var endpoint = builder.Build();
+            endpoints.Add(endpoint);
+        }
+
+        var importMapDefinition = new ImportMapDefinition(
+            new Dictionary<string, string>
+            {
+                [resourceCollectionUrl] = $"./{fingerprintedResourceCollectionUrl}",
+                [gzResourceCollectionUrl] = $"./{fingerprintedGzResourceCollectionUrl}",
+            },
+            scopes: null,
+            new Dictionary<string, string>
+            {
+                [$"./{fingerprintedResourceCollectionUrl}"] = integrity,
+                [$"./{fingerprintedGzResourceCollectionUrl}"] = integrity,
+            });
+
+        return (importMapDefinition, $"./{fingerprintedResourceCollectionUrl}");
+    }
+
+    private static string ComputeIntegrity(byte[] bytes)
+    {
+        Span<byte> hash = stackalloc byte[32];
+        SHA256.HashData(bytes, hash);
+        return $"sha256-{Convert.ToBase64String(hash)}";
+    }
+
+    private static byte[] CreateGzipBytes(byte[] bytes)
+    {
+        using var gzipContent = new MemoryStream();
+        using var gzipStream = new GZipStream(gzipContent, CompressionLevel.Optimal, leaveOpen: true);
+        gzipStream.Write(bytes);
+        gzipStream.Flush();
+        return gzipContent.ToArray();
+    }
+
+    private static byte[] CreateContent(ResourceAssetCollection resourceCollection)
+    {
+        var content = new MemoryStream();
+        var preamble = """
+            export function get() {
+                return
+            """u8;
+        content.Write(preamble);
+        var utf8Writer = new Utf8JsonWriter(content);
+        JsonSerializer.Serialize<IReadOnlyList<ResourceAsset>>(utf8Writer, resourceCollection, ResourceCollectionSerializerContext.Default.Options);
+        var epilogue = """
+            ;
+            }
+            """u8;
+        content.Write(epilogue);
+        content.Flush();
+        return content.ToArray();
+    }
+
+    private static string ComputeFingerprintSuffix(ResourceAssetCollection resourceCollection)
+    {
+        var resources = (IReadOnlyList<ResourceAsset>)resourceCollection;
+        var incrementalHash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
+        Span<byte> buffer = stackalloc byte[1024];
+        byte[]? rented = null;
+        Span<byte> result = stackalloc byte[incrementalHash.HashLengthInBytes];
+        foreach (var resource in resources)
+        {
+            var url = resource.Url;
+            AppendToHash(incrementalHash, buffer, ref rented, url);
+        }
+        incrementalHash.GetCurrentHash(result);
+        // Base64 encoding at most increases size by (4 * byteSize / 3 + 2),
+        // add an extra byte for the initial dot.
+        Span<char> fingerprintSpan = stackalloc char[(incrementalHash.HashLengthInBytes * 4 / 3) + 3];
+        var length = WebUtilities.WebEncoders.Base64UrlEncode(result, fingerprintSpan[1..]);
+        fingerprintSpan[0] = '.';
+        return fingerprintSpan[..(length + 1)].ToString();
+    }
+
+    private static void AppendToHash(IncrementalHash incrementalHash, Span<byte> buffer, ref byte[]? rented, string value)
+    {
+        if (Encoding.UTF8.TryGetBytes(value, buffer, out var written))
+        {
+            incrementalHash.AppendData(buffer[..written]);
+        }
+        else
+        {
+            var length = Encoding.UTF8.GetByteCount(value);
+            if (rented == null || rented.Length < length)
+            {
+                if (rented != null)
+                {
+                    ArrayPool<byte>.Shared.Return(rented);
+                }
+                rented = ArrayPool<byte>.Shared.Rent(length);
+                var bytesWritten = Encoding.UTF8.GetBytes(value, rented);
+                incrementalHash.AppendData(rented.AsSpan(0, bytesWritten));
+            }
+        }
+    }
+
+    [JsonSerializable(typeof(ResourceAssetCollection))]
+    [JsonSerializable(typeof(IReadOnlyList<ResourceAsset>))]
+    [JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, WriteIndented = false)]
+    private partial class ResourceCollectionSerializerContext : JsonSerializerContext
+    {
+    }
+
+    private class ResourceCollectionEndpointsBuilder
+    {
+        private readonly byte[] _content;
+        private readonly string _contentETag;
+        private readonly byte[] _gzipContent;
+        private readonly string[] _gzipContentETags;
+
+        public ResourceCollectionEndpointsBuilder(byte[] content, byte[] gzipContent)
+        {
+            _content = content;
+            _contentETag = ComputeETagTag(content);
+            _gzipContent = gzipContent;
+            _gzipContentETags = [$"W/ {_contentETag}", ComputeETagTag(gzipContent)];
+        }
+
+        private string ComputeETagTag(byte[] content)
+        {
+            Span<byte> data = stackalloc byte[32];
+            SHA256.HashData(content, data);
+            return $"\"{Convert.ToBase64String(data)}\"";
+        }
+
+        public async Task FingerprintedGzipContent(HttpContext context)
+        {
+            WriteCommonHeaders(context, _gzipContent);
+            WriteEncodingHeaders(context);
+            WriteFingerprintHeaders(context);
+            await context.Response.Body.WriteAsync(_gzipContent);
+        }
+
+        public async Task FingerprintedContent(HttpContext context)
+        {
+            WriteCommonHeaders(context, _content);
+            WriteFingerprintHeaders(context);
+            await context.Response.Body.WriteAsync(_content);
+        }
+
+        public async Task Content(HttpContext context)
+        {
+            WriteCommonHeaders(context, _content);
+            WriteNonFingerprintedHeaders(context);
+            await context.Response.Body.WriteAsync(_content);
+        }
+
+        public async Task GzipContent(HttpContext context)
+        {
+            WriteCommonHeaders(context, _gzipContent);
+            WriteEncodingHeaders(context);
+            WriteNonFingerprintedHeaders(context);
+            await context.Response.Body.WriteAsync(_gzipContent);
+        }
+
+        private void WriteEncodingHeaders(HttpContext context)
+        {
+            context.Response.Headers[HeaderNames.ContentEncoding] = "gzip";
+            context.Response.Headers[HeaderNames.Vary] = HeaderNames.AcceptEncoding;
+            context.Response.Headers.ETag = new StringValues(_gzipContentETags);
+        }
+
+        private void WriteNoEncodingHeaders(HttpContext context)
+        {
+            context.Response.Headers.ETag = new StringValues(_contentETag);
+        }
+
+        private static void WriteFingerprintHeaders(HttpContext context)
+        {
+            context.Response.Headers[HeaderNames.CacheControl] = "max-age=31536000, immutable";
+        }
+
+        private static void WriteNonFingerprintedHeaders(HttpContext context)
+        {
+            context.Response.Headers[HeaderNames.CacheControl] = "no-cache, must-revalidate";
+        }
+
+        private static void WriteCommonHeaders(HttpContext context, byte[] contents)
+        {
+            context.Response.ContentType = "application/javascript";
+            context.Response.ContentLength = contents.Length;
+        }
+
+        internal IEnumerable<RouteEndpointBuilder> CreateEndpoints(
+            string fingerprintedResourceCollectionUrl,
+            string fingerprintedGzResourceCollectionUrl,
+            string resourceCollectionUrl,
+            string gzResourceCollectionUrl)
+        {
+            var quality = 1 / (1 + _gzipContent.Length);
+            var encodingMetadata = new ContentEncodingMetadata("gzip", quality);
+
+            var fingerprintedGzBuilder = new RouteEndpointBuilder(
+                FingerprintedGzipContent,
+                RoutePatternFactory.Parse(fingerprintedGzResourceCollectionUrl),
+                -100);
+
+            var fingerprintedBuilder = new RouteEndpointBuilder(
+                FingerprintedContent,
+                RoutePatternFactory.Parse(fingerprintedResourceCollectionUrl),
+                -100);
+
+            var fingerprintedBuilderConeg = new RouteEndpointBuilder(
+                FingerprintedGzipContent,
+                RoutePatternFactory.Parse(fingerprintedResourceCollectionUrl),
+                -100);
+
+            fingerprintedBuilderConeg.Metadata.Add(encodingMetadata);
+
+            var gzBuilder = new RouteEndpointBuilder(
+                GzipContent,
+                RoutePatternFactory.Parse(gzResourceCollectionUrl),
+                -100);
+
+            var builder = new RouteEndpointBuilder(
+                Content,
+                RoutePatternFactory.Parse(resourceCollectionUrl),
+                -100);
+
+            var builderConeg = new RouteEndpointBuilder(
+                Content,
+                RoutePatternFactory.Parse(resourceCollectionUrl),
+                -100);
+
+            builderConeg.Metadata.Add(encodingMetadata);
+
+            return [
+                fingerprintedGzBuilder,
+                fingerprintedBuilder,
+                fingerprintedBuilderConeg,
+                gzBuilder,
+                builderConeg,
+                builder
+            ];
+        }
+    }
+}

+ 9 - 0
src/Components/Endpoints/src/Builder/ResourceCollectionUrlMetadata.cs

@@ -0,0 +1,9 @@
+// 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.Endpoints;
+
+internal class ResourceCollectionUrlMetadata(string url)
+{
+    public string Url { get; } = url;
+}

+ 2 - 0
src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs

@@ -72,6 +72,8 @@ public static class RazorComponentsServiceCollectionExtensions
         services.AddSupplyValueFromQueryProvider();
         services.TryAddCascadingValue(sp => sp.GetRequiredService<EndpointHtmlRenderer>().HttpContext);
 
+        services.TryAddScoped<ResourceCollectionProvider>();
+
         // Form handling
         services.AddSupplyValueFromFormProvider();
         services.TryAddScoped<AntiforgeryStateProvider, EndpointAntiforgeryStateProvider>();

+ 3 - 0
src/Components/Endpoints/src/Microsoft.AspNetCore.Components.Endpoints.csproj

@@ -30,11 +30,13 @@
     <Compile Include="$(SharedSourceRoot)Components\WebAssemblyComponentSerializationSettings.cs" LinkBase="DependencyInjection" />
     <Compile Include="$(SharedSourceRoot)Components\ServerComponentSerializationSettings.cs" LinkBase="DependencyInjection" />
     <Compile Include="$(SharedSourceRoot)Components\ServerComponent.cs" LinkBase="DependencyInjection" />
+    <Compile Include="$(SharedSourceRoot)Components\ResourceCollectionResolver.cs" LinkBase="Assets" />
     <Compile Include="$(RepoRoot)src\Shared\Components\ComponentParameter.cs" LinkBase="DependencyInjection" />
     <Compile Include="$(RepoRoot)src\Shared\Components\PrerenderComponentApplicationStore.cs" LinkBase="DependencyInjection" />
     <Compile Include="$(RepoRoot)src\Shared\Components\ProtectedPrerenderComponentApplicationStore.cs" LinkBase="DependencyInjection" />
     <Compile Include="$(RepoRoot)src\Shared\ClosedGenericMatcher\ClosedGenericMatcher.cs" LinkBase="FormMapping" />
     <Compile Include="$(ComponentsSharedSourceRoot)src\CacheHeaderSettings.cs" Link="Shared\CacheHeaderSettings.cs" />
+    <Compile Include="$(ComponentsSharedSourceRoot)src\ResourceCollectionProvider.cs" Link="Shared\ResourceCollectionProvider.cs" />
     <Compile Include="$(ComponentsSharedSourceRoot)src\DefaultAntiforgeryStateProvider.cs" LinkBase="Forms" />
     <Compile Include="$(SharedSourceRoot)LinkerFlags.cs" LinkBase="Shared" />
 
@@ -54,6 +56,7 @@
     <Reference Include="Microsoft.AspNetCore.Http.Abstractions" />
     <Reference Include="Microsoft.AspNetCore.Routing" />
     <Reference Include="Microsoft.AspNetCore.StaticFiles" />
+    <Reference Include="Microsoft.AspNetCore.StaticAssets" />
     <Reference Include="Microsoft.Extensions.FileProviders.Embedded" />
   </ItemGroup>
 

+ 15 - 0
src/Components/Endpoints/src/PublicAPI.Unshipped.txt

@@ -1,8 +1,23 @@
 #nullable enable
+Microsoft.AspNetCore.Components.ImportMap
+Microsoft.AspNetCore.Components.ImportMap.HttpContext.get -> Microsoft.AspNetCore.Http.HttpContext?
+Microsoft.AspNetCore.Components.ImportMap.HttpContext.set -> void
+Microsoft.AspNetCore.Components.ImportMap.ImportMap() -> void
+Microsoft.AspNetCore.Components.ImportMap.ImportMapDefinition.get -> Microsoft.AspNetCore.Components.ImportMapDefinition?
+Microsoft.AspNetCore.Components.ImportMap.ImportMapDefinition.set -> void
+Microsoft.AspNetCore.Components.ImportMapDefinition
+Microsoft.AspNetCore.Components.ImportMapDefinition.ImportMapDefinition(System.Collections.Generic.IReadOnlyDictionary<string!, string!>? imports, System.Collections.Generic.IReadOnlyDictionary<string!, System.Collections.Generic.IReadOnlyDictionary<string!, string!>!>? scopes, System.Collections.Generic.IReadOnlyDictionary<string!, string!>? integrity) -> void
+Microsoft.AspNetCore.Components.ImportMapDefinition.Imports.get -> System.Collections.Generic.IReadOnlyDictionary<string!, string!>?
+Microsoft.AspNetCore.Components.ImportMapDefinition.Integrity.get -> System.Collections.Generic.IReadOnlyDictionary<string!, string!>?
+Microsoft.AspNetCore.Components.ImportMapDefinition.Scopes.get -> System.Collections.Generic.IReadOnlyDictionary<string!, System.Collections.Generic.IReadOnlyDictionary<string!, string!>!>?
 Microsoft.AspNetCore.Components.Routing.RazorComponentsEndpointHttpContextExtensions
 Microsoft.AspNetCore.Components.Server.ServerAuthenticationStateProvider
 Microsoft.AspNetCore.Components.Server.ServerAuthenticationStateProvider.ServerAuthenticationStateProvider() -> void
 Microsoft.AspNetCore.Components.Server.ServerAuthenticationStateProvider.SetAuthenticationState(System.Threading.Tasks.Task<Microsoft.AspNetCore.Components.Authorization.AuthenticationState!>! authenticationStateTask) -> void
+override Microsoft.AspNetCore.Components.ImportMapDefinition.ToString() -> string!
 override Microsoft.AspNetCore.Components.Server.ServerAuthenticationStateProvider.GetAuthenticationStateAsync() -> System.Threading.Tasks.Task<Microsoft.AspNetCore.Components.Authorization.AuthenticationState!>!
+static Microsoft.AspNetCore.Builder.RazorComponentsEndpointConventionBuilderExtensions.WithStaticAssets(this Microsoft.AspNetCore.Builder.RazorComponentsEndpointConventionBuilder! builder, string? manifestPath = null) -> Microsoft.AspNetCore.Builder.RazorComponentsEndpointConventionBuilder!
 static Microsoft.AspNetCore.Components.Endpoints.Infrastructure.ComponentEndpointConventionBuilderHelper.GetEndpointRouteBuilder(Microsoft.AspNetCore.Builder.RazorComponentsEndpointConventionBuilder! builder) -> Microsoft.AspNetCore.Routing.IEndpointRouteBuilder!
+static Microsoft.AspNetCore.Components.ImportMapDefinition.Combine(params Microsoft.AspNetCore.Components.ImportMapDefinition![]! sources) -> Microsoft.AspNetCore.Components.ImportMapDefinition!
+static Microsoft.AspNetCore.Components.ImportMapDefinition.FromResourceCollection(Microsoft.AspNetCore.Components.ResourceAssetCollection! assets) -> Microsoft.AspNetCore.Components.ImportMapDefinition!
 static Microsoft.AspNetCore.Components.Routing.RazorComponentsEndpointHttpContextExtensions.AcceptsInteractiveRouting(this Microsoft.AspNetCore.Http.HttpContext! context) -> bool

+ 28 - 2
src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs

@@ -41,6 +41,7 @@ internal partial class EndpointHtmlRenderer : StaticHtmlRenderer, IComponentPrer
     private readonly RazorComponentsServiceOptions _options;
     private Task? _servicesInitializedTask;
     private HttpContext _httpContext = default!; // Always set at the start of an inbound call
+    private ResourceAssetCollection? _resourceCollection;
 
     // The underlying Renderer always tracks the pending tasks representing *full* quiescence, i.e.,
     // when everything (regardless of streaming SSR) is fully complete. In this subclass we also track
@@ -96,6 +97,8 @@ internal partial class EndpointHtmlRenderer : StaticHtmlRenderer, IComponentPrer
             }
         }
 
+        InitializeResourceCollection(httpContext);
+
         if (handler != null && form != null)
         {
             httpContext.RequestServices.GetRequiredService<HttpContextFormDataProvider>()
@@ -117,16 +120,39 @@ internal partial class EndpointHtmlRenderer : StaticHtmlRenderer, IComponentPrer
             // Saving RouteData to avoid routing twice in Router component
             var routingStateProvider = httpContext.RequestServices.GetRequiredService<EndpointRoutingStateProvider>();
             routingStateProvider.RouteData = new RouteData(componentType, httpContext.GetRouteData().Values);
-            if (httpContext.GetEndpoint() is RouteEndpoint endpoint)
+            if (httpContext.GetEndpoint() is RouteEndpoint routeEndpoint)
             {
-                routingStateProvider.RouteData.Template = endpoint.RoutePattern.RawText;
+                routingStateProvider.RouteData.Template = routeEndpoint.RoutePattern.RawText;
             }
         }
     }
 
+    private static void InitializeResourceCollection(HttpContext httpContext)
+    {
+
+        var endpoint = httpContext.GetEndpoint();
+        var resourceCollection = GetResourceCollection(httpContext);
+        var resourceCollectionUrl = resourceCollection != null && endpoint != null ?
+            endpoint.Metadata.GetMetadata<ResourceCollectionUrlMetadata>() :
+            null;
+
+        var resourceCollectionProvider = resourceCollectionUrl != null ? httpContext.RequestServices.GetService<ResourceCollectionProvider>() : null;
+        if (resourceCollectionUrl != null && resourceCollectionProvider != null)
+        {
+            resourceCollectionProvider.SetResourceCollectionUrl(resourceCollectionUrl.Url);
+            resourceCollectionProvider.SetResourceCollection(resourceCollection ?? ResourceAssetCollection.Empty);
+        }
+    }
+
     protected override ComponentState CreateComponentState(int componentId, IComponent component, ComponentState? parentComponentState)
         => new EndpointComponentState(this, componentId, component, parentComponentState);
 
+    /// <inheritdoc/>
+    protected override ResourceAssetCollection Assets =>
+        _resourceCollection ??= GetResourceCollection(_httpContext) ?? base.Assets;
+
+    private static ResourceAssetCollection? GetResourceCollection(HttpContext httpContext) => httpContext.GetEndpoint()?.Metadata.GetMetadata<ResourceAssetCollection>();
+
     protected override void AddPendingTask(ComponentState? componentState, Task task)
     {
         var streamRendering = componentState is null

+ 201 - 0
src/Components/Endpoints/test/Assets/ImportMapDefinitionTest.cs

@@ -0,0 +1,201 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Text.Json;
+using System.Threading.Tasks;
+
+namespace Microsoft.AspNetCore.Components.Endpoints.Assets;
+
+public class ImportMapDefinitionTest
+{
+    [Fact]
+    public void CanCreate_Basic_ImportMapDefinition()
+    {
+        // Arrange
+        var expectedJson = """
+            {
+              "imports": {
+                "jquery": "https://cdn.example.com/jquery.js"
+              }
+            }
+            """.Replace("\r\n", "\n");
+
+        var importMapDefinition = new ImportMapDefinition(
+            new Dictionary<string, string>
+            {
+                { "jquery", "https://cdn.example.com/jquery.js" },
+            },
+            null,
+            null
+            );
+
+        // Assert
+        Assert.Equal(expectedJson, importMapDefinition.ToJson().Replace("\r\n", "\n"));
+    }
+
+    [Fact]
+    public void CanCreate_Scoped_ImportMapDefinition()
+    {
+        // Arrange
+        var expectedJson = """
+            {
+              "scopes": {
+                "/scoped/": {
+                  "jquery": "https://cdn.example.com/jquery.js"
+                }
+              }
+            }
+            """.Replace("\r\n", "\n");
+
+        var importMapDefinition = new ImportMapDefinition(
+            null,
+            new Dictionary<string, IReadOnlyDictionary<string, string>>
+            {
+                ["/scoped/"] = new Dictionary<string, string>
+                {
+                    { "jquery", "https://cdn.example.com/jquery.js" },
+                }
+            },
+            null);
+
+        // Assert
+        Assert.Equal(expectedJson, importMapDefinition.ToJson().Replace("\r\n", "\n"));
+    }
+
+    [Fact]
+    public void CanCreate_ImportMap_WithIntegrity()
+    {
+        // Arrange
+        var expectedJson = """
+            {
+              "imports": {
+                "jquery": "https://cdn.example.com/jquery.js"
+              },
+              "integrity": {
+                "https://cdn.example.com/jquery.js": "sha384-abc123"
+              }
+            }
+            """.Replace("\r\n", "\n");
+
+        var importMapDefinition = new ImportMapDefinition(
+            new Dictionary<string, string>
+            {
+                { "jquery", "https://cdn.example.com/jquery.js" },
+            },
+            null,
+            new Dictionary<string, string>
+            {
+                { "https://cdn.example.com/jquery.js", "sha384-abc123" },
+            });
+
+        // Assert
+        Assert.Equal(expectedJson, importMapDefinition.ToJson().Replace("\r\n", "\n"));
+    }
+
+    [Fact]
+    public void CanBuildImportMap_FromResourceCollection()
+    {
+        // Arrange
+        var resourceAssetCollection = new ResourceAssetCollection(
+            [
+                new ResourceAsset(
+                    "jquery.fingerprint.js",
+                    [
+                        new ResourceAssetProperty("integrity", "sha384-abc123"),
+                        new ResourceAssetProperty("label", "jquery.js"),
+                    ])
+            ]);
+
+        var expectedJson = """
+            {
+              "imports": {
+                "./jquery.js": "./jquery.fingerprint.js"
+              },
+              "integrity": {
+                "jquery.fingerprint.js": "sha384-abc123"
+              }
+            }
+            """.Replace("\r\n", "\n");
+
+        // Act
+        var importMapDefinition = ImportMapDefinition.FromResourceCollection(resourceAssetCollection);
+
+        // Assert
+        Assert.Equal(expectedJson, importMapDefinition.ToJson().Replace("\r\n", "\n"));
+    }
+
+    [Fact]
+    public void CanCombine_ImportMaps()
+    {
+        // Arrange
+        var firstImportMap = new ImportMapDefinition(
+            new Dictionary<string, string>
+            {
+                { "jquery", "https://cdn.example.com/jquery.js" },
+            },
+            new Dictionary<string, IReadOnlyDictionary<string, string>>
+            {
+                ["/legacy/"] = new Dictionary<string, string>
+                {
+                    { "jquery", "https://legacy.example.com/jquery.js" },
+                }
+            },
+            new Dictionary<string, string>
+            {
+                { "https://cdn.example.com/jquery.js", "sha384-abc123" },
+            });
+
+        var secondImportMap = new ImportMapDefinition(
+            new Dictionary<string, string>
+            {
+                { "react", "https://cdn.example.com/react.js" },
+                { "jquery", "https://updated.example.com/jquery.js" }
+            },
+            new Dictionary<string, IReadOnlyDictionary<string, string>>
+            {
+                ["/scoped/"] = new Dictionary<string, string>
+                {
+                    { "jquery", "https://cdn.example.com/jquery.js" },
+                },
+                ["/legacy/"] = new Dictionary<string, string>
+                {
+                    { "jquery", "https://updated.example.com/jquery.js" },
+                }
+            },
+            new Dictionary<string, string>
+            {
+                { "https://cdn.example.com/react.js", "sha384-def456" },
+            });
+
+        var expectedImportMap = """
+            {
+              "imports": {
+                "jquery": "https://updated.example.com/jquery.js",
+                "react": "https://cdn.example.com/react.js"
+              },
+              "scopes": {
+                "/legacy/": {
+                  "jquery": "https://updated.example.com/jquery.js"
+                },
+                "/scoped/": {
+                  "jquery": "https://cdn.example.com/jquery.js"
+                }
+              },
+              "integrity": {
+                "https://cdn.example.com/jquery.js": "sha384-abc123",
+                "https://cdn.example.com/react.js": "sha384-def456"
+              }
+            }
+            """.Replace("\r\n", "\n");
+
+        // Act
+        var combinedImportMap = ImportMapDefinition.Combine(firstImportMap, secondImportMap);
+
+        // Assert
+        Assert.Equal(expectedImportMap, combinedImportMap.ToJson().Replace("\r\n", "\n"));
+    }
+}

+ 294 - 0
src/Components/Endpoints/test/Builder/RazorComponentsEndpointConventionBuilderExtensionsTest.cs

@@ -0,0 +1,294 @@
+// 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 Microsoft.AspNetCore.Components;
+using Microsoft.AspNetCore.Components.Discovery;
+using Microsoft.AspNetCore.Components.Endpoints;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.FileProviders;
+
+namespace Microsoft.AspNetCore.Builder;
+
+public class RazorComponentsEndpointConventionBuilderExtensionsTest
+{
+    [Fact]
+    public void WithStaticAssets_DoesNotAddResourceCollection_ToEndpoints_NoStaticAssetsMapped()
+    {
+        // Arrange
+        var endpointBuilder = new TestEndpointRouteBuilder();
+        var builder = CreateRazorComponentsAppBuilder(endpointBuilder);
+
+        // Act
+        builder.WithStaticAssets();
+
+        // Assert
+        Assert.All(endpointBuilder.DataSources.Skip(1).First().Endpoints, e =>
+        {
+            if (e.Metadata.GetMetadata<ComponentTypeMetadata>() == null)
+            {
+                return;
+            }
+
+            var metadata = e.Metadata.GetMetadata<ResourceAssetCollection>();
+            Assert.Null(metadata);
+        });
+    }
+
+    [Fact]
+    public void WithStaticAssets_DoesNotAddResourceCollection_ToEndpoints_NoMatchingStaticAssetsMapped()
+    {
+        // Arrange
+        var endpointBuilder = new TestEndpointRouteBuilder();
+        endpointBuilder.MapStaticAssets("TestManifests/Test.staticwebassets.endpoints.json");
+        var builder = CreateRazorComponentsAppBuilder(endpointBuilder);
+
+        // Act
+        builder.WithStaticAssets();
+
+        // Assert
+        Assert.All(endpointBuilder.DataSources.Skip(2).First().Endpoints, e =>
+        {
+            var metadata = e.Metadata.GetMetadata<ResourceAssetCollection>();
+            Assert.Null(metadata);
+        });
+    }
+
+    [Fact]
+    public void WithStaticAssets_AddsResourceCollection_ToEndpoints_NamedManifest()
+    {
+        // Arrange
+        var endpointBuilder = new TestEndpointRouteBuilder();
+        endpointBuilder.MapStaticAssets("TestManifests/Test.staticwebassets.endpoints.json");
+        var builder = CreateRazorComponentsAppBuilder(endpointBuilder);
+
+        // Act
+        builder.WithStaticAssets("TestManifests/Test.staticwebassets.endpoints.json");
+
+        // Assert
+        Assert.All(endpointBuilder.DataSources.Skip(2).First().Endpoints, e =>
+        {
+            var metadata = e.Metadata.GetMetadata<ResourceAssetCollection>();
+            Assert.NotNull(metadata);
+            var list = Assert.IsAssignableFrom<IReadOnlyList<ResourceAsset>>(metadata);
+            Assert.Equal(1, list.Count);
+            Assert.Equal("named.css", list[0].Url);
+        });
+    }
+
+    [Fact]
+    public void WithStaticAssets_AddsDefaultResourceCollection_ToEndpoints_ByDefault()
+    {
+        // Arrange
+        var endpointBuilder = new TestEndpointRouteBuilder();
+        endpointBuilder.MapStaticAssets();
+
+        // Act
+        var builder = CreateRazorComponentsAppBuilder(endpointBuilder);
+
+        // Assert
+        Assert.All(endpointBuilder.DataSources.Skip(2).First().Endpoints, e =>
+        {
+            var metadata = e.Metadata.GetMetadata<ResourceAssetCollection>();
+            Assert.NotNull(metadata);
+            var list = Assert.IsAssignableFrom<IReadOnlyList<ResourceAsset>>(metadata);
+            Assert.Equal(1, list.Count);
+            Assert.Equal("default.css", list[0].Url);
+        });
+    }
+
+    [Fact]
+    public void WithStaticAssets_AddsResourceCollection_ToEndpoints_DefaultManifest()
+    {
+        // Arrange
+        var endpointBuilder = new TestEndpointRouteBuilder();
+        endpointBuilder.MapStaticAssets();
+        var builder = CreateRazorComponentsAppBuilder(endpointBuilder);
+
+        // Act
+        builder.WithStaticAssets();
+
+        // Assert
+        Assert.All(endpointBuilder.DataSources.Skip(2).First().Endpoints, e =>
+        {
+            var metadata = e.Metadata.GetMetadata<ResourceAssetCollection>();
+            Assert.NotNull(metadata);
+            var list = Assert.IsAssignableFrom<IReadOnlyList<ResourceAsset>>(metadata);
+            Assert.Equal(1, list.Count);
+            Assert.Equal("default.css", list[0].Url);
+        });
+    }
+
+    [Fact]
+    public void WithStaticAssets_AddsDefaultResourceCollectionToEndpoints_WhenNoManifestProvided_EvenIfManyAvailable()
+    {
+        // Arrange
+        var endpointBuilder = new TestEndpointRouteBuilder();
+        endpointBuilder.MapStaticAssets();
+        endpointBuilder.MapStaticAssets("TestManifests/Test.staticwebassets.endpoints.json");
+
+        // Act
+        var builder = CreateRazorComponentsAppBuilder(endpointBuilder);
+
+        // Assert
+        Assert.All(endpointBuilder.DataSources.Skip(3).First().Endpoints, e =>
+        {
+            var metadata = e.Metadata.GetMetadata<ResourceAssetCollection>();
+            Assert.NotNull(metadata);
+            var list = Assert.IsAssignableFrom<IReadOnlyList<ResourceAsset>>(metadata);
+            Assert.Equal(1, list.Count);
+            Assert.Equal("default.css", list[0].Url);
+        });
+    }
+
+    [Fact]
+    public void WithStaticAssets_AddsMatchingResourceCollectionToEndpoints_WhenExplicitManifestProvided_EvenIfManyAvailable()
+    {
+        // Arrange
+        var endpointBuilder = new TestEndpointRouteBuilder();
+        endpointBuilder.MapStaticAssets();
+        endpointBuilder.MapStaticAssets("TestManifests/Test.staticwebassets.endpoints.json");
+        var builder = CreateRazorComponentsAppBuilder(endpointBuilder);
+
+        // Act
+        builder.WithStaticAssets("TestManifests/Test.staticwebassets.endpoints.json");
+
+        // Assert
+        Assert.All(endpointBuilder.DataSources.Skip(3).First().Endpoints, e =>
+        {
+            var metadata = e.Metadata.GetMetadata<ResourceAssetCollection>();
+            Assert.NotNull(metadata);
+            var list = Assert.IsAssignableFrom<IReadOnlyList<ResourceAsset>>(metadata);
+            Assert.Equal(1, list.Count);
+            Assert.Equal("named.css", list[0].Url);
+        });
+    }
+
+    [Fact]
+    public void WithStaticAssets_AddsCollectionFromGroup_WhenMappedInsideAnEndpointGroup()
+    {
+        // Arrange
+        var endpointBuilder = new TestEndpointRouteBuilder();
+        endpointBuilder.MapStaticAssets();
+
+        var group = endpointBuilder.MapGroup("/group");
+        group.MapStaticAssets("TestManifests/Test.staticwebassets.endpoints.json");
+        var builder = CreateRazorComponentsAppBuilder(group);
+
+        // Act
+        builder.WithStaticAssets("TestManifests/Test.staticwebassets.endpoints.json");
+
+        // Assert
+        var groupEndpoints = Assert.IsAssignableFrom<IEndpointRouteBuilder>(group).DataSources;
+        Assert.All(groupEndpoints.Skip(2).First().Endpoints, e =>
+        {
+            var metadata = e.Metadata.GetMetadata<ResourceAssetCollection>();
+            Assert.NotNull(metadata);
+            var list = Assert.IsAssignableFrom<IReadOnlyList<ResourceAsset>>(metadata);
+            Assert.Equal(1, list.Count);
+            Assert.Equal("named.css", list[0].Url);
+        });
+    }
+
+    [Fact]
+    public void WithStaticAssets_DoesNotAddResourceCollectionFromGroup_WhenMappingNotFound_InsideGroup()
+    {
+        // Arrange
+        var endpointBuilder = new TestEndpointRouteBuilder();
+        endpointBuilder.MapStaticAssets();
+
+        var group = endpointBuilder.MapGroup("/group");
+        group.MapStaticAssets("TestManifests/Test.staticwebassets.endpoints.json");
+        var builder = CreateRazorComponentsAppBuilder(group);
+
+        // Act
+        builder.WithStaticAssets();
+
+        // Assert
+        var groupEndpoints = Assert.IsAssignableFrom<IEndpointRouteBuilder>(group).DataSources;
+        Assert.All(groupEndpoints.Skip(2).First().Endpoints, e =>
+        {
+            var metadata = e.Metadata.GetMetadata<ResourceAssetCollection>();
+            Assert.Null(metadata);
+        });
+    }
+
+    private RazorComponentsEndpointConventionBuilder CreateRazorComponentsAppBuilder(IEndpointRouteBuilder endpointBuilder)
+    {
+        var builder = endpointBuilder.MapRazorComponents<App>();
+        builder.ApplicationBuilder.AddLibrary(new AssemblyComponentLibraryDescriptor(
+            "App",
+            [new PageComponentBuilder {
+                PageType = typeof(App),
+                RouteTemplates = ["/"],
+                AssemblyName = "App",
+            }],
+            []
+        ));
+        return builder;
+    }
+
+    private class TestEndpointRouteBuilder : IEndpointRouteBuilder
+    {
+        private readonly ApplicationBuilder _applicationBuilder;
+
+        public TestEndpointRouteBuilder()
+        {
+            _applicationBuilder = new ApplicationBuilder(ServiceProvider);
+        }
+
+        public IServiceProvider ServiceProvider { get; } = CreateServiceProvider();
+
+        private static IServiceProvider CreateServiceProvider()
+        {
+            var collection = new ServiceCollection();
+            collection.AddSingleton<IConfiguration>(new ConfigurationBuilder().Build());
+            collection.AddSingleton<IWebHostEnvironment>(new TestWebHostEnvironment());
+            collection.AddRazorComponents();
+            return collection.BuildServiceProvider();
+        }
+
+        public ICollection<EndpointDataSource> DataSources { get; } = [];
+
+        public IApplicationBuilder CreateApplicationBuilder()
+        {
+            return _applicationBuilder.New();
+        }
+
+        private class TestWebHostEnvironment : IWebHostEnvironment
+        {
+            public string ApplicationName { get; set; } = "TestApplication";
+            public string EnvironmentName { get; set; } = "TestEnvironment";
+            public string WebRootPath { get; set; } = "";
+            public IFileProvider WebRootFileProvider { get => ContentRootFileProvider; set { } }
+            public string ContentRootPath { get; set; } = Directory.GetCurrentDirectory();
+            public IFileProvider ContentRootFileProvider { get; set; } = CreateTestFileProvider();
+
+            private static TestFileProvider CreateTestFileProvider()
+            {
+                var provider = new TestFileProvider();
+                provider.AddFile("site.css", "body { color: red; }");
+                return provider;
+            }
+        }
+
+        private class TestDiagnosticSource : DiagnosticSource
+        {
+            public override bool IsEnabled(string name)
+            {
+                return false;
+            }
+
+            public override void Write(string name, object value) { }
+        }
+    }
+
+    private class App : IComponent
+    {
+        void IComponent.Attach(RenderHandle renderHandle) => throw new NotImplementedException();
+        Task IComponent.SetParametersAsync(ParameterView parameters) => throw new NotImplementedException();
+    }
+}

+ 38 - 0
src/Components/Endpoints/test/Builder/TestFileProvider/TestDirectoryContent.cs

@@ -0,0 +1,38 @@
+// 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;
+
+namespace Microsoft.Extensions.FileProviders;
+
+public class TestDirectoryContent : IDirectoryContents, IFileInfo
+{
+    private readonly IEnumerable<IFileInfo> _files;
+
+    public TestDirectoryContent(string name, IEnumerable<IFileInfo> files)
+    {
+        Name = name;
+        _files = files;
+    }
+
+    public bool Exists => true;
+
+    public long Length => throw new NotSupportedException();
+
+    public string PhysicalPath => throw new NotSupportedException();
+
+    public string Name { get; }
+
+    public DateTimeOffset LastModified => throw new NotSupportedException();
+
+    public bool IsDirectory => true;
+
+    public Stream CreateReadStream()
+    {
+        throw new NotSupportedException();
+    }
+
+    public IEnumerator<IFileInfo> GetEnumerator() => _files.GetEnumerator();
+
+    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
+}

+ 32 - 0
src/Components/Endpoints/test/Builder/TestFileProvider/TestFileChangeToken.cs

@@ -0,0 +1,32 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.Extensions.Primitives;
+
+public class TestFileChangeToken : IChangeToken
+{
+    public TestFileChangeToken(string filter = "")
+    {
+        Filter = filter;
+    }
+
+    public bool ActiveChangeCallbacks => false;
+
+    public bool HasChanged { get; set; }
+
+    public string Filter { get; }
+
+    public IDisposable RegisterChangeCallback(Action<object> callback, object state)
+    {
+        return new NullDisposable();
+    }
+
+    private sealed class NullDisposable : IDisposable
+    {
+        public void Dispose()
+        {
+        }
+    }
+
+    public override string ToString() => Filter;
+}

+ 42 - 0
src/Components/Endpoints/test/Builder/TestFileProvider/TestFileInfo.cs

@@ -0,0 +1,42 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Text;
+
+namespace Microsoft.Extensions.FileProviders;
+
+public class TestFileInfo : IFileInfo
+{
+    private string _content;
+
+    public bool IsDirectory => false;
+
+    public DateTimeOffset LastModified { get; set; }
+
+    public long Length { get; set; }
+
+    public string Name { get; set; }
+
+    public string PhysicalPath { get; set; }
+
+    public string Content
+    {
+        get { return _content; }
+        set
+        {
+            _content = value;
+            Length = Encoding.UTF8.GetByteCount(Content);
+        }
+    }
+
+    public bool Exists
+    {
+        get { return true; }
+    }
+
+    public Stream CreateReadStream()
+    {
+        var bytes = Encoding.UTF8.GetBytes(Content);
+        return new MemoryStream(bytes);
+    }
+}

+ 179 - 0
src/Components/Endpoints/test/Builder/TestFileProvider/TestFileProvider.cs

@@ -0,0 +1,179 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.Extensions.Primitives;
+
+namespace Microsoft.Extensions.FileProviders;
+
+public class TestFileProvider : IFileProvider
+{
+    private readonly Dictionary<string, IFileInfo> _lookup =
+        new Dictionary<string, IFileInfo>(StringComparer.Ordinal);
+    private readonly Dictionary<string, IDirectoryContents> _directoryContentsLookup =
+        new Dictionary<string, IDirectoryContents>();
+
+    private readonly Dictionary<string, TestFileChangeToken> _fileTriggers =
+        new Dictionary<string, TestFileChangeToken>(StringComparer.Ordinal);
+
+    public TestFileProvider() : this(string.Empty)
+    {
+    }
+
+    public TestFileProvider(string root)
+    {
+        Root = root;
+    }
+
+    public string Root { get; }
+
+    public virtual IDirectoryContents GetDirectoryContents(string subpath)
+    {
+        if (_directoryContentsLookup.TryGetValue(subpath, out var value))
+        {
+            return value;
+        }
+
+        return new NotFoundDirectoryContents();
+    }
+
+    public TestFileInfo AddFile(string path, string contents)
+    {
+        var fileInfo = new TestFileInfo
+        {
+            Content = contents,
+            PhysicalPath = Path.Combine(Root, NormalizeAndEnsureValidPhysicalPath(path)),
+            Name = Path.GetFileName(path),
+            LastModified = DateTime.UtcNow,
+        };
+
+        AddFile(path, fileInfo);
+
+        return fileInfo;
+    }
+
+    public TestDirectoryContent AddDirectoryContent(string path, IEnumerable<IFileInfo> files)
+    {
+        var directoryContent = new TestDirectoryContent(Path.GetFileName(path), files);
+        _directoryContentsLookup[path] = directoryContent;
+        return directoryContent;
+    }
+
+    public void AddFile(string path, IFileInfo contents)
+    {
+        _lookup[path] = contents;
+    }
+
+    public void DeleteFile(string path)
+    {
+        _lookup.Remove(path);
+    }
+
+    public virtual IFileInfo GetFileInfo(string subpath)
+    {
+        if (_lookup.TryGetValue(subpath, out var fileInfo))
+        {
+            return fileInfo;
+        }
+        else
+        {
+            return new NotFoundFileInfo();
+        }
+    }
+
+    public virtual TestFileChangeToken AddChangeToken(string filter)
+    {
+        var changeToken = new TestFileChangeToken(filter);
+        _fileTriggers[filter] = changeToken;
+
+        return changeToken;
+    }
+
+    public virtual IChangeToken Watch(string filter)
+    {
+        if (!_fileTriggers.TryGetValue(filter, out var changeToken) || changeToken.HasChanged)
+        {
+            changeToken = new TestFileChangeToken(filter);
+            _fileTriggers[filter] = changeToken;
+        }
+
+        return changeToken;
+    }
+
+    public TestFileChangeToken GetChangeToken(string filter)
+    {
+        return _fileTriggers[filter];
+    }
+
+    private static string NormalizeAndEnsureValidPhysicalPath(string filePath)
+    {
+        if (string.IsNullOrEmpty(filePath))
+        {
+            return filePath;
+        }
+
+        filePath = filePath.Replace('/', Path.DirectorySeparatorChar);
+
+        if (filePath[0] == Path.DirectorySeparatorChar)
+        {
+            filePath = filePath.Substring(1);
+        }
+
+        return filePath;
+    }
+
+    private sealed class NotFoundFileInfo : IFileInfo
+    {
+        public bool Exists
+        {
+            get
+            {
+                return false;
+            }
+        }
+
+        public bool IsDirectory
+        {
+            get
+            {
+                throw new NotImplementedException();
+            }
+        }
+
+        public DateTimeOffset LastModified
+        {
+            get
+            {
+                throw new NotImplementedException();
+            }
+        }
+
+        public long Length
+        {
+            get
+            {
+                throw new NotImplementedException();
+            }
+        }
+
+        public string Name
+        {
+            get
+            {
+                throw new NotImplementedException();
+            }
+        }
+
+        public string PhysicalPath
+        {
+            get
+            {
+                throw new NotImplementedException();
+            }
+        }
+
+        public Stream CreateReadStream()
+        {
+            throw new NotImplementedException();
+        }
+    }
+}

+ 1 - 1
src/Components/Endpoints/test/EndpointHtmlRendererTest.cs

@@ -1680,7 +1680,7 @@ public class EndpointHtmlRendererTest
         services.AddSingleton<PersistentComponentState>(sp => sp.GetRequiredService<ComponentStatePersistenceManager>().State);
         services.AddSingleton<AntiforgeryStateProvider, EndpointAntiforgeryStateProvider>();
         services.AddSingleton<ICascadingValueSupplier>(_ => new SupplyParameterFromFormValueProvider(null, ""));
-
+        services.AddScoped<ResourceCollectionProvider>();
         return services;
     }
 

+ 1 - 0
src/Components/Endpoints/test/HotReloadServiceTests.cs

@@ -11,6 +11,7 @@ using Microsoft.Extensions.DependencyInjection.Extensions;
 using Microsoft.AspNetCore.Components.Web;
 using Microsoft.AspNetCore.Routing.Patterns;
 using Microsoft.AspNetCore.Components.Endpoints.Infrastructure;
+using Microsoft.AspNetCore.Components.Infrastructure;
 
 namespace Microsoft.AspNetCore.Components.Endpoints.Tests;
 

+ 393 - 0
src/Components/Endpoints/test/ImportMapTest.cs

@@ -0,0 +1,393 @@
+// 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.ObjectModel;
+using System.Runtime.ExceptionServices;
+using Microsoft.AspNetCore.Components.Rendering;
+using Microsoft.AspNetCore.Components.RenderTree;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace Microsoft.AspNetCore.Components.Endpoints;
+
+public class ImportMapTest
+{
+    private readonly ImportMap _importMap;
+    private readonly TestRenderer _renderer;
+
+    public ImportMapTest()
+    {
+        var services = new ServiceCollection();
+        services.AddSingleton<ILoggerFactory>(NullLoggerFactory.Instance);
+        var serviceProvider = services.BuildServiceProvider();
+
+        _renderer = new TestRenderer(serviceProvider)
+        {
+            ShouldHandleExceptions = true
+        };
+        _importMap = (ImportMap)_renderer.InstantiateComponent<ImportMap>();
+    }
+
+    //[Fact]
+    //public async Task CanRunOnNavigateAsync()
+    //{
+    //    // Arrange
+    //    var called = false;
+    //    Action<NavigationContext> OnNavigateAsync = async (NavigationContext args) =>
+    //    {
+    //        await Task.CompletedTask;
+    //        called = true;
+    //    };
+    //    _importMap.OnNavigateAsync = new EventCallback<NavigationContext>(null, OnNavigateAsync);
+
+    //    // Act
+    //    await _renderer.Dispatcher.InvokeAsync(() => _importMap.RunOnNavigateAsync("http://example.com/jan", false));
+
+    //    // Assert
+    //    Assert.True(called);
+    //}
+
+    [Fact]
+    public async Task CanRenderImportMap()
+    {
+        // Arrange
+        var importMap = new ImportMap();
+        var importMapDefinition = new ImportMapDefinition(
+            new Dictionary<string, string>
+            {
+                { "jquery", "https://code.jquery.com/jquery-3.5.1.min.js" },
+                { "bootstrap", "https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js" }
+            },
+            new Dictionary<string, IReadOnlyDictionary<string, string>>
+            {
+                ["development"] = new Dictionary<string, string>
+                {
+                    { "jquery", "https://code.jquery.com/jquery-3.5.1.js" },
+                    { "bootstrap", "https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.js" }
+                }.AsReadOnly()
+            },
+            new Dictionary<string, string>
+            {
+                { "https://code.jquery.com/jquery-3.5.1.js", "sha384-jquery" },
+                { "https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.js", "sha256-bootstrap" }
+            });
+
+        importMap.ImportMapDefinition = importMapDefinition;
+        var id = _renderer.AssignRootComponentId(importMap);
+        // Act
+        await _renderer.Dispatcher.InvokeAsync(() => _renderer.RenderRootComponent(id));
+
+        // Assert
+        var frames = _renderer.GetCurrentRenderTreeFrames(id);
+        Assert.Equal(3, frames.Count);
+        Assert.Equal(RenderTreeFrameType.Element, frames.Array[0].FrameType);
+        Assert.Equal("script", frames.Array[0].ElementName);
+        Assert.Equal(RenderTreeFrameType.Attribute, frames.Array[1].FrameType);
+        Assert.Equal("type", frames.Array[1].AttributeName);
+        Assert.Equal("importmap", frames.Array[1].AttributeValue);
+        Assert.Equal(RenderTreeFrameType.Markup, frames.Array[2].FrameType);
+        Assert.Equal(importMapDefinition.ToJson(), frames.Array[2].TextContent);
+    }
+
+    [Fact]
+    public async Task ResolvesImportMap_FromHttpContext()
+    {
+        // Arrange
+        var importMap = new ImportMap();
+        var importMapDefinition = new ImportMapDefinition(
+            new Dictionary<string, string>
+            {
+                { "jquery", "https://code.jquery.com/jquery-3.5.1.min.js" },
+                { "bootstrap", "https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js" }
+            },
+            new Dictionary<string, IReadOnlyDictionary<string, string>>
+            {
+                ["development"] = new Dictionary<string, string>
+                {
+                    { "jquery", "https://code.jquery.com/jquery-3.5.1.js" },
+                    { "bootstrap", "https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.js" }
+                }.AsReadOnly()
+            },
+            new Dictionary<string, string>
+            {
+                { "https://code.jquery.com/jquery-3.5.1.js", "sha384-jquery" },
+                { "https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.js", "sha256-bootstrap" }
+            });
+
+        var id = _renderer.AssignRootComponentId(importMap);
+        var context = new DefaultHttpContext();
+        context.SetEndpoint(new Endpoint((ctx) => Task.CompletedTask, new EndpointMetadataCollection(importMapDefinition), "Test"));
+        importMap.HttpContext = context;
+
+        // Act
+        await _renderer.Dispatcher.InvokeAsync(() => _renderer.RenderRootComponent(id));
+
+        // Assert
+        var frames = _renderer.GetCurrentRenderTreeFrames(id);
+        Assert.Equal(3, frames.Count);
+        Assert.Equal(RenderTreeFrameType.Element, frames.Array[0].FrameType);
+        Assert.Equal("script", frames.Array[0].ElementName);
+        Assert.Equal(RenderTreeFrameType.Attribute, frames.Array[1].FrameType);
+        Assert.Equal("type", frames.Array[1].AttributeName);
+        Assert.Equal("importmap", frames.Array[1].AttributeValue);
+        Assert.Equal(RenderTreeFrameType.Markup, frames.Array[2].FrameType);
+        Assert.Equal(importMapDefinition.ToJson(), frames.Array[2].TextContent);
+    }
+
+    [Fact]
+    public async Task Rerenders_WhenImportmapChanges()
+    {
+        // Arrange
+        var importMap = new ImportMap();
+        var importMapDefinition = new ImportMapDefinition(
+            new Dictionary<string, string>
+            {
+                { "jquery", "https://code.jquery.com/jquery-3.5.1.min.js" },
+                { "bootstrap", "https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js" }
+            },
+            new Dictionary<string, IReadOnlyDictionary<string, string>>
+            {
+                ["development"] = new Dictionary<string, string>
+                {
+                    { "jquery", "https://code.jquery.com/jquery-3.5.1.js" },
+                    { "bootstrap", "https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.js" }
+                }.AsReadOnly()
+            },
+            new Dictionary<string, string>
+            {
+                { "https://code.jquery.com/jquery-3.5.1.js", "sha384-jquery" },
+                { "https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.js", "sha256-bootstrap" }
+            });
+
+        var otherImportMapDefinition = new ImportMapDefinition(
+            new Dictionary<string, string>
+            {
+                { "jquery", "./jquery-3.5.1.js" },
+                { "bootstrap", "./bootstrap/4.5.2/js/bootstrap.min.js" }
+            },
+            ReadOnlyDictionary<string, IReadOnlyDictionary<string, string>>.Empty,
+            ReadOnlyDictionary<string, string>.Empty);
+
+        var id = _renderer.AssignRootComponentId(importMap);
+        var context = new DefaultHttpContext();
+        context.SetEndpoint(new Endpoint((ctx) => Task.CompletedTask, new EndpointMetadataCollection(importMapDefinition), "Test"));
+        importMap.HttpContext = context;
+
+        // Act
+        await _renderer.Dispatcher.InvokeAsync(() => _renderer.RenderRootComponent(id));
+
+        var component = importMap as IComponent;
+        await _renderer.Dispatcher.InvokeAsync(async () =>
+        {
+            await component.SetParametersAsync(ParameterView.FromDictionary(new Dictionary<string, object>
+            {
+                { nameof(ImportMap.ImportMapDefinition), otherImportMapDefinition }
+            }));
+        });
+
+        await _renderer.Dispatcher.InvokeAsync(_renderer.ProcessPendingRender);
+
+        // Assert
+        var frames = _renderer.GetCurrentRenderTreeFrames(id);
+        Assert.Equal(3, frames.Count);
+        Assert.Equal(RenderTreeFrameType.Element, frames.Array[0].FrameType);
+        Assert.Equal("script", frames.Array[0].ElementName);
+        Assert.Equal(RenderTreeFrameType.Attribute, frames.Array[1].FrameType);
+        Assert.Equal("type", frames.Array[1].AttributeName);
+        Assert.Equal("importmap", frames.Array[1].AttributeValue);
+        Assert.Equal(RenderTreeFrameType.Markup, frames.Array[2].FrameType);
+        Assert.Equal(otherImportMapDefinition.ToJson(), frames.Array[2].TextContent);
+    }
+
+    [Fact]
+    public async Task DoesNot_Rerender_WhenImportmap_DoesNotChange()
+    {
+        // Arrange
+        var importMap = new ImportMap();
+        var importMapDefinition = new ImportMapDefinition(
+            new Dictionary<string, string>
+            {
+                { "jquery", "https://code.jquery.com/jquery-3.5.1.min.js" },
+                { "bootstrap", "https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js" }
+            },
+            new Dictionary<string, IReadOnlyDictionary<string, string>>
+            {
+                ["development"] = new Dictionary<string, string>
+                {
+                    { "jquery", "https://code.jquery.com/jquery-3.5.1.js" },
+                    { "bootstrap", "https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.js" }
+                }.AsReadOnly()
+            },
+            new Dictionary<string, string>
+            {
+                { "https://code.jquery.com/jquery-3.5.1.js", "sha384-jquery" },
+                { "https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.js", "sha256-bootstrap" }
+            });
+
+        var id = _renderer.AssignRootComponentId(importMap);
+        var context = new DefaultHttpContext();
+        context.SetEndpoint(new Endpoint((ctx) => Task.CompletedTask, new EndpointMetadataCollection(importMapDefinition), "Test"));
+        importMap.HttpContext = context;
+
+        // Act
+        await _renderer.Dispatcher.InvokeAsync(() => _renderer.RenderRootComponent(id));
+
+        var component = importMap as IComponent;
+        await _renderer.Dispatcher.InvokeAsync(async () =>
+        {
+            await component.SetParametersAsync(ParameterView.FromDictionary(new Dictionary<string, object>
+            {
+                { nameof(ImportMap.ImportMapDefinition), importMapDefinition }
+            }));
+        });
+
+        await _renderer.Dispatcher.InvokeAsync(_renderer.ProcessPendingRender);
+
+        // Assert
+        Assert.Equal(1, _renderer.CapturedBatch.UpdatedComponents.Count);
+        Assert.Equal(0, _renderer.CapturedBatch.UpdatedComponents.Array[0].Edits.Count);
+
+        var frames = _renderer.GetCurrentRenderTreeFrames(id);
+        Assert.Equal(3, frames.Count);
+        Assert.Equal(RenderTreeFrameType.Element, frames.Array[0].FrameType);
+        Assert.Equal("script", frames.Array[0].ElementName);
+        Assert.Equal(RenderTreeFrameType.Attribute, frames.Array[1].FrameType);
+        Assert.Equal("type", frames.Array[1].AttributeName);
+        Assert.Equal("importmap", frames.Array[1].AttributeValue);
+        Assert.Equal(RenderTreeFrameType.Markup, frames.Array[2].FrameType);
+        Assert.Equal(importMapDefinition.ToJson(), frames.Array[2].TextContent);
+    }
+
+    public class TestRenderer : Renderer
+    {
+        public TestRenderer(IServiceProvider serviceProvider) : base(serviceProvider, NullLoggerFactory.Instance)
+        {
+            Dispatcher = Dispatcher.CreateDefault();
+        }
+
+        public TestRenderer(IServiceProvider serviceProvider, IComponentActivator componentActivator)
+            : base(serviceProvider, NullLoggerFactory.Instance, componentActivator)
+        {
+            Dispatcher = Dispatcher.CreateDefault();
+        }
+
+        public override Dispatcher Dispatcher { get; }
+
+        public Action OnExceptionHandled { get; set; }
+
+        public Action<RenderBatch> OnUpdateDisplay { get; set; }
+
+        public Action OnUpdateDisplayComplete { get; set; }
+
+        public List<Exception> HandledExceptions { get; } = new List<Exception>();
+
+        public bool ShouldHandleExceptions { get; set; }
+
+        public Task NextRenderResultTask { get; set; } = Task.CompletedTask;
+
+        public RenderBatch CapturedBatch { get; set; }
+
+        private HashSet<TestRendererComponentState> UndisposedComponentStates { get; } = new();
+
+        protected override Task UpdateDisplayAsync(in RenderBatch renderBatch)
+        {
+            CapturedBatch = renderBatch;
+            return Task.CompletedTask;
+        }
+
+        public new int AssignRootComponentId(IComponent component)
+            => base.AssignRootComponentId(component);
+
+        public new void RemoveRootComponent(int componentId)
+            => base.RemoveRootComponent(componentId);
+
+        public new ArrayRange<RenderTreeFrame> GetCurrentRenderTreeFrames(int componentId)
+            => base.GetCurrentRenderTreeFrames(componentId);
+
+        public void RenderRootComponent(int componentId, ParameterView? parameters = default)
+        {
+            var task = Dispatcher.InvokeAsync(() => base.RenderRootComponentAsync(componentId, parameters ?? ParameterView.Empty));
+            UnwrapTask(task);
+        }
+
+        public new Task RenderRootComponentAsync(int componentId)
+            => Dispatcher.InvokeAsync(() => base.RenderRootComponentAsync(componentId));
+
+        public new Task RenderRootComponentAsync(int componentId, ParameterView parameters)
+            => Dispatcher.InvokeAsync(() => base.RenderRootComponentAsync(componentId, parameters));
+
+        public Task DispatchEventAsync(ulong eventHandlerId, EventArgs args)
+            => Dispatcher.InvokeAsync(() => base.DispatchEventAsync(eventHandlerId, null, args));
+
+        public new Task DispatchEventAsync(ulong eventHandlerId, EventFieldInfo eventFieldInfo, EventArgs args)
+            => Dispatcher.InvokeAsync(() => base.DispatchEventAsync(eventHandlerId, eventFieldInfo, args));
+
+        private static Task UnwrapTask(Task task)
+        {
+            // This should always be run synchronously
+            Assert.True(task.IsCompleted);
+            if (task.IsFaulted)
+            {
+                var exception = task.Exception.Flatten().InnerException;
+                while (exception is AggregateException e)
+                {
+                    exception = e.InnerException;
+                }
+
+                ExceptionDispatchInfo.Capture(exception).Throw();
+            }
+
+            return task;
+        }
+
+        public IComponent InstantiateComponent<T>()
+            => InstantiateComponent(typeof(T));
+
+        protected override void HandleException(Exception exception)
+        {
+            if (!ShouldHandleExceptions)
+            {
+                ExceptionDispatchInfo.Capture(exception).Throw();
+            }
+
+            HandledExceptions.Add(exception);
+            OnExceptionHandled?.Invoke();
+        }
+
+        public new void ProcessPendingRender()
+            => base.ProcessPendingRender();
+
+        protected override ComponentState CreateComponentState(int componentId, IComponent component, ComponentState parentComponentState)
+            => new TestRendererComponentState(this, componentId, component, parentComponentState);
+
+        protected override void Dispose(bool disposing)
+        {
+            base.Dispose(disposing);
+
+            if (UndisposedComponentStates.Count > 0)
+            {
+                throw new InvalidOperationException("Did not dispose all the ComponentState instances. This could lead to ArrayBuffer not returning buffers to its pool.");
+            }
+        }
+
+        class TestRendererComponentState : ComponentState, IAsyncDisposable
+        {
+            private readonly TestRenderer _renderer;
+
+            public TestRendererComponentState(Renderer renderer, int componentId, IComponent component, ComponentState parentComponentState)
+                : base(renderer, componentId, component, parentComponentState)
+            {
+                _renderer = (TestRenderer)renderer;
+                _renderer.UndisposedComponentStates.Add(this);
+            }
+
+            public override ValueTask DisposeAsync()
+            {
+                _renderer.UndisposedComponentStates.Remove(this);
+                return base.DisposeAsync();
+            }
+        }
+    }
+}

+ 5 - 0
src/Components/Endpoints/test/Microsoft.AspNetCore.Components.Endpoints.Tests.csproj

@@ -14,4 +14,9 @@
     <Reference Include="Microsoft.AspNetCore.Components.Endpoints" />
     <Reference Include="Microsoft.AspNetCore.Http" />
   </ItemGroup>
+
+  <ItemGroup>
+    <Content Update="TestApplication.staticwebassets.endpoints.json" CopyToOutputDirectory="Always" />
+    <Content Update="TestManifests\Test.staticwebassets.endpoints.json" CopyToOutputDirectory="Always" />
+  </ItemGroup>
 </Project>

+ 3 - 1
src/Components/Endpoints/test/RazorComponentEndpointFactoryTest.cs

@@ -20,7 +20,9 @@ public class RazorComponentEndpointFactoryTest
         var finallyConventions = new List<Action<EndpointBuilder>>();
         var testRenderMode = new TestRenderMode();
         var configuredRenderModes = new ConfiguredRenderModesMetadata(new[] { testRenderMode });
-        factory.AddEndpoints(endpoints, typeof(App), new PageComponentInfo(
+        factory.AddEndpoints(
+            endpoints,
+            typeof(App), new PageComponentInfo(
             "App",
             typeof(App),
             "/",

+ 17 - 0
src/Components/Endpoints/test/TestApplication.staticwebassets.endpoints.json

@@ -0,0 +1,17 @@
+{
+  "Version": 1,
+  "Endpoints": [
+    {
+      "Route": "default.css",
+      "AssetFile": "default.css",
+      "Selectors": [],
+      "EndpointProperties": [],
+      "ResponseHeaders": [
+        {
+          "Name": "ETag",
+          "Value": "\"Fake\""
+        }
+      ]
+    }
+  ]
+}

+ 17 - 0
src/Components/Endpoints/test/TestManifests/Test.staticwebassets.endpoints.json

@@ -0,0 +1,17 @@
+{
+  "Version": 1,
+  "Endpoints": [
+    {
+      "Route": "named.css",
+      "AssetFile": "named.css",
+      "Selectors": [],
+      "EndpointProperties": [],
+      "ResponseHeaders": [
+        {
+          "Name": "ETag",
+          "Value": "\"Fake\""
+        }
+      ]
+    }
+  ]
+}

+ 19 - 0
src/Components/Samples/BlazorUnitedApp.Client/BlazorUnitedApp.Client.csproj

@@ -0,0 +1,19 @@
+<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
+
+  <PropertyGroup>
+    <TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework>
+    <IsShippingPackage>false</IsShippingPackage>
+    <Nullable>enable</Nullable>
+    <StaticWebAssetProjectMode>Default</StaticWebAssetProjectMode>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <Reference Include="Microsoft.AspNetCore.Components.WebAssembly" />
+    <Reference Include="System.Net.Http.Json" />
+  </ItemGroup>
+
+  <ItemGroup>
+    <Folder Include="wwwroot\" />
+  </ItemGroup>
+
+</Project>

+ 1 - 0
src/Components/Samples/BlazorUnitedApp.Client/HelloWorld.razor

@@ -0,0 +1 @@
+<h3>Hello webassembly!</h3>

+ 8 - 0
src/Components/Samples/BlazorUnitedApp.Client/Program.cs

@@ -0,0 +1,8 @@
+// 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.WebAssembly.Hosting;
+
+var builder = WebAssemblyHostBuilder.CreateDefault(args);
+
+await builder.Build().RunAsync();

+ 41 - 0
src/Components/Samples/BlazorUnitedApp.Client/Properties/launchSettings.json

@@ -0,0 +1,41 @@
+{
+  "$schema": "http://json.schemastore.org/launchsettings.json",
+  "iisSettings": {
+    "windowsAuthentication": false,
+    "anonymousAuthentication": true,
+    "iisExpress": {
+      "applicationUrl": "http://localhost:59453",
+      "sslPort": 44337
+    }
+  },
+  "profiles": {
+    "http": {
+      "commandName": "Project",
+      "dotnetRunMessages": true,
+      "launchBrowser": true,
+      "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
+      "applicationUrl": "http://localhost:5023",
+      "environmentVariables": {
+        "ASPNETCORE_ENVIRONMENT": "Development"
+      }
+    },
+    "https": {
+      "commandName": "Project",
+      "dotnetRunMessages": true,
+      "launchBrowser": true,
+      "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
+      "applicationUrl": "https://localhost:7110;http://localhost:5023",
+      "environmentVariables": {
+        "ASPNETCORE_ENVIRONMENT": "Development"
+      }
+    },
+    "IIS Express": {
+      "commandName": "IISExpress",
+      "launchBrowser": true,
+      "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
+      "environmentVariables": {
+        "ASPNETCORE_ENVIRONMENT": "Development"
+      }
+    }
+  }
+}

+ 9 - 0
src/Components/Samples/BlazorUnitedApp.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 BlazorUnitedApp.Client

+ 7 - 5
src/Components/Samples/BlazorUnitedApp/App.razor

@@ -4,15 +4,17 @@
     <meta charset="utf-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <base href="/" />
-    <link rel="stylesheet" href="css/bootstrap/bootstrap.min.css" />   
-    <link rel="stylesheet" href="css/bootstrap-icons/bootstrap-icons.min.css" />
-    <link rel="stylesheet" href="css/site.css" />
-    <link href="BlazorUnitedApp.styles.css" rel="stylesheet" />
+    <link rel="stylesheet" href="@Assets["css/bootstrap/bootstrap.min.css"]" />
+    <link rel="stylesheet" href="@Assets["css/bootstrap-icons/bootstrap-icons.min.css"]" />
+    <link rel="stylesheet" href="@Assets["css/site.css"]" />
+    <link href="@Assets["BlazorUnitedApp.styles.css"]" rel="stylesheet" />
     <link rel="icon" type="image/png" href="favicon.png" />
+
+    <ImportMap />
     <HeadOutlet />
 </head>
 <body>
     <Routes />
-    <script src="_framework/blazor.web.js" suppress-error="BL9992"></script>
+    <script src=""_framework/blazor.web.js"></script>
 </body>
 </html>

+ 28 - 0
src/Components/Samples/BlazorUnitedApp/BlazorUnitedApp.csproj

@@ -6,14 +6,42 @@
     <Nullable>enable</Nullable>
   </PropertyGroup>
 
+  <ItemGroup>
+    <ProjectReference Include="..\BlazorUnitedApp.Client\BlazorUnitedApp.Client.csproj" />
+  </ItemGroup>
+
   <ItemGroup>
     <Reference Include="Microsoft.AspNetCore" />
     <Reference Include="Microsoft.AspNetCore.Components.Endpoints" />
     <Reference Include="Microsoft.AspNetCore.Components.Server" />
+    <Reference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" />
     <Reference Include="Microsoft.AspNetCore.HttpsPolicy" />
     <Reference Include="Microsoft.AspNetCore.StaticFiles" />
     <Reference Include="Microsoft.AspNetCore.StaticAssets" />
     <Reference Include="Microsoft.AspNetCore.Mvc" />
   </ItemGroup>
 
+  <Target Name="FixDevelopmentManifest" AfterTargets="GenerateStaticWebAssetsManifest">
+    <ComputeStaticWebAssetsTargetPaths
+      Assets="@(StaticWebAsset)"
+      PathPrefix=""
+      UseAlternatePathDirectorySeparator="true">
+      <Output TaskParameter="AssetsWithTargetPath" ItemName="_FixedAssets" />
+    </ComputeStaticWebAssetsTargetPaths>
+
+    <ItemGroup>
+      <_FixedAssets>
+        <RelativePath>$([System.String]::Copy('%(_FixedAssets.TargetPath)').Replace('%(_FixedAssets.BasePath)', ''))</RelativePath>
+      </_FixedAssets>
+    </ItemGroup>
+
+    <GenerateStaticWebAssetsDevelopmentManifest
+      DiscoveryPatterns="@(StaticWebAssetDiscoveryPattern)"
+      Assets="@(_FixedAssets)"
+      Source="$(PackageId)"
+      ManifestPath="$(StaticWebAssetDevelopmentManifestPath)">
+    </GenerateStaticWebAssetsDevelopmentManifest>
+
+  </Target>
+
 </Project>

+ 4 - 0
src/Components/Samples/BlazorUnitedApp/Pages/WebAssemblyComponent.razor

@@ -0,0 +1,4 @@
+@page "/webassembly"
+@using BlazorUnitedApp.Client
+
+<HelloWorld @rendermode="InteractiveWebAssembly"></HelloWorld>

+ 3 - 1
src/Components/Samples/BlazorUnitedApp/Program.cs

@@ -8,6 +8,7 @@ var builder = WebApplication.CreateBuilder(args);
 
 // Add services to the container.
 builder.Services.AddRazorComponents()
+    .AddInteractiveWebAssemblyComponents()
     .AddInteractiveServerComponents();
 
 builder.Services.AddSingleton<WeatherForecastService>();
@@ -29,6 +30,7 @@ app.UseAntiforgery();
 
 app.MapStaticAssets();
 app.MapRazorComponents<App>()
-    .AddInteractiveServerRenderMode();
+    .AddInteractiveServerRenderMode()
+    .AddInteractiveWebAssemblyRenderMode();
 
 app.Run();

+ 3 - 0
src/Components/Samples/BlazorUnitedApp/Properties/launchSettings.json

@@ -13,6 +13,7 @@
       "dotnetRunMessages": true,
       "launchBrowser": true,
       "applicationUrl": "http://localhost:5265",
+      "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
       "environmentVariables": {
         "ASPNETCORE_ENVIRONMENT": "Development"
       }
@@ -22,6 +23,7 @@
       "dotnetRunMessages": true,
       "launchBrowser": true,
       "applicationUrl": "https://localhost:7247;http://localhost:5265",
+      //"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
       "environmentVariables": {
         "ASPNETCORE_ENVIRONMENT": "Development"
       }
@@ -29,6 +31,7 @@
     "IIS Express": {
       "commandName": "IISExpress",
       "launchBrowser": true,
+      "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
       "environmentVariables": {
         "ASPNETCORE_ENVIRONMENT": "Development"
       }

+ 5 - 0
src/Components/Samples/BlazorUnitedApp/Shared/NavMenu.razor

@@ -24,6 +24,11 @@
                 <span class="bi bi-list-nested" aria-hidden="true"></span> Fetch data
             </NavLink>
         </div>
+        <div class="nav-item px-3">
+            <NavLink class="nav-link" href="webassembly">
+                <span class="bi bi-list-nested" aria-hidden="true"></span> Web assembly
+            </NavLink>
+        </div>
     </nav>
 </div>
 

+ 1 - 0
src/Components/Samples/BlazorUnitedApp/appsettings.Development.json

@@ -1,4 +1,5 @@
 {
+  "EnableStaticAssetsDevelopmentCaching": true,
   "DetailedErrors": true,
   "Logging": {
     "LogLevel": {

+ 4 - 2
src/Components/Server/src/Circuits/CircuitFactory.cs

@@ -39,7 +39,8 @@ internal sealed partial class CircuitFactory : ICircuitFactory
         string baseUri,
         string uri,
         ClaimsPrincipal user,
-        IPersistentComponentStateStore store)
+        IPersistentComponentStateStore store,
+        ResourceAssetCollection resourceCollection)
     {
         var scope = _scopeFactory.CreateAsyncScope();
         var jsRuntime = (RemoteJSRuntime)scope.ServiceProvider.GetRequiredService<IJSRuntime>();
@@ -80,7 +81,8 @@ internal sealed partial class CircuitFactory : ICircuitFactory
             serverComponentDeserializer,
             _loggerFactory.CreateLogger<RemoteRenderer>(),
             jsRuntime,
-            jsComponentInterop);
+            jsComponentInterop,
+            resourceCollection);
 
         // In Blazor Server we have already restored the app state, so we can get the handlers from DI.
         // In Blazor Web the state is provided in the first call to UpdateRootComponents, so we need to

+ 2 - 1
src/Components/Server/src/Circuits/ICircuitFactory.cs

@@ -13,5 +13,6 @@ internal interface ICircuitFactory
         string baseUri,
         string uri,
         ClaimsPrincipal user,
-        IPersistentComponentStateStore store);
+        IPersistentComponentStateStore store,
+        ResourceAssetCollection resourceCollection);
 }

+ 6 - 1
src/Components/Server/src/Circuits/RemoteRenderer.cs

@@ -24,6 +24,7 @@ internal partial class RemoteRenderer : WebRenderer
     private readonly CircuitOptions _options;
     private readonly IServerComponentDeserializer _serverComponentDeserializer;
     private readonly ILogger _logger;
+    private readonly ResourceAssetCollection _resourceCollection;
     internal readonly ConcurrentQueue<UnacknowledgedRenderBatch> _unacknowledgedRenderBatches = new ConcurrentQueue<UnacknowledgedRenderBatch>();
     private long _nextRenderId = 1;
     private bool _disposing;
@@ -44,19 +45,23 @@ internal partial class RemoteRenderer : WebRenderer
         IServerComponentDeserializer serverComponentDeserializer,
         ILogger logger,
         RemoteJSRuntime jsRuntime,
-        CircuitJSComponentInterop jsComponentInterop)
+        CircuitJSComponentInterop jsComponentInterop,
+        ResourceAssetCollection resourceCollection = null)
         : base(serviceProvider, loggerFactory, jsRuntime.ReadJsonSerializerOptions(), jsComponentInterop)
     {
         _client = client;
         _options = options;
         _serverComponentDeserializer = serverComponentDeserializer;
         _logger = logger;
+        _resourceCollection = resourceCollection;
 
         ElementReferenceContext = jsRuntime.ElementReferenceContext;
     }
 
     public override Dispatcher Dispatcher { get; } = Dispatcher.CreateDefault();
 
+    protected override ResourceAssetCollection Assets => _resourceCollection ?? base.Assets;
+
     protected override ComponentPlatform ComponentPlatform => _componentPlatform;
 
     protected override IComponentRenderMode? GetComponentRenderMode(IComponent component) => RenderMode.InteractiveServer;

+ 3 - 2
src/Components/Server/src/ComponentHub.cs

@@ -123,14 +123,15 @@ internal sealed partial class ComponentHub : Hub
             var store = !string.IsNullOrEmpty(applicationState) ?
                 new ProtectedPrerenderComponentApplicationStore(applicationState, _dataProtectionProvider) :
                 new ProtectedPrerenderComponentApplicationStore(_dataProtectionProvider);
-
+            var resourceCollection = Context.GetHttpContext().GetEndpoint()?.Metadata.GetMetadata<ResourceAssetCollection>();
             circuitHost = await _circuitFactory.CreateCircuitHostAsync(
                 components,
                 circuitClient,
                 baseUri,
                 uri,
                 Context.User,
-                store);
+                store,
+                resourceCollection);
 
             // Fire-and-forget the initialization process, because we can't block the
             // SignalR message loop (we'd get a deadlock if any of the initialization

+ 10 - 1
src/Components/Server/test/Circuits/ComponentHubTest.cs

@@ -6,6 +6,9 @@ using System.Security.Claims;
 using System.Text.RegularExpressions;
 using Microsoft.AspNetCore.Components.Server.Circuits;
 using Microsoft.AspNetCore.DataProtection;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Http.Connections.Features;
+using Microsoft.AspNetCore.Http.Features;
 using Microsoft.AspNetCore.SignalR;
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.Logging;
@@ -128,6 +131,11 @@ public class ComponentHubTest
         mockCaller.Setup(x => x.Caller).Returns(mockClientProxy.Object);
         hub.Clients = mockCaller.Object;
         var mockContext = new Mock<HubCallerContext>();
+        var feature = new FeatureCollection();
+        var httpContextFeature = new Mock<IHttpContextFeature>();
+        httpContextFeature.Setup(x => x.HttpContext).Returns(() => new DefaultHttpContext());
+        feature.Set(httpContextFeature.Object);
+        mockContext.Setup(x => x.Features).Returns(feature);
         mockContext.Setup(x => x.ConnectionId).Returns("123");
         hub.Context = mockContext.Object;
 
@@ -194,7 +202,8 @@ public class ComponentHubTest
             string baseUri,
             string uri,
             ClaimsPrincipal user,
-            IPersistentComponentStateStore store)
+            IPersistentComponentStateStore store,
+            ResourceAssetCollection resourceCollection)
         {
             var serviceScope = new Mock<IServiceScope>();
             var circuitHost = TestCircuitHost.Create(serviceScope: new AsyncServiceScope(serviceScope.Object));

+ 66 - 0
src/Components/Shared/src/ResourceCollectionProvider.cs

@@ -0,0 +1,66 @@
+// 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.CodeAnalysis;
+using Microsoft.AspNetCore.Components.Web;
+using Microsoft.JSInterop;
+
+namespace Microsoft.AspNetCore.Components;
+
+internal class ResourceCollectionProvider
+{
+    private const string ResourceCollectionUrlKey = "__ResourceCollectionUrl";
+    private string? _url;
+    private ResourceAssetCollection? _resourceCollection;
+    private readonly PersistentComponentState _state;
+    private readonly IJSRuntime _jsRuntime;
+
+    [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "Strings are not trimmed")]
+    public ResourceCollectionProvider(PersistentComponentState state, IJSRuntime jsRuntime)
+    {
+        _state = state;
+        _jsRuntime = jsRuntime;
+        _ = _state.TryTakeFromJson(ResourceCollectionUrlKey, out _url);
+    }
+
+    [MemberNotNull(nameof(_url))]
+    [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "Strings are not trimmed")]
+    internal void SetResourceCollectionUrl(string url)
+    {
+        if (_url != null)
+        {
+            throw new InvalidOperationException("The resource collection URL has already been set.");
+        }
+        _url = url;
+        PersistingComponentStateSubscription registration = default;
+        registration = _state.RegisterOnPersisting(() =>
+        {
+            _state.PersistAsJson(ResourceCollectionUrlKey, _url);
+            registration.Dispose();
+            return Task.CompletedTask;
+        }, RenderMode.InteractiveWebAssembly);
+    }
+
+    internal async Task<ResourceAssetCollection> GetResourceCollection()
+    {
+        _resourceCollection = _resourceCollection ??= await LoadResourceCollection();
+        return _resourceCollection;
+    }
+
+    internal void SetResourceCollection(ResourceAssetCollection resourceCollection)
+    {
+        _resourceCollection = resourceCollection;
+    }
+
+    private async Task<ResourceAssetCollection> LoadResourceCollection()
+    {
+        if (_url == null)
+        {
+            return ResourceAssetCollection.Empty;
+        }
+
+        var module = await _jsRuntime.InvokeAsync<IJSObjectReference>("import", _url);
+        var result = await module.InvokeAsync<ResourceAsset[]>("get");
+        return result == null ? ResourceAssetCollection.Empty : new ResourceAssetCollection(result);
+    }
+}

+ 3 - 0
src/Components/WebAssembly/DevServer/src/Server/Program.cs

@@ -29,12 +29,15 @@ public class Program
                 var name = Path.ChangeExtension(applicationPath, ".staticwebassets.runtime.json");
                 name = !File.Exists(name) ? Path.ChangeExtension(applicationPath, ".StaticWebAssets.xml") : name;
 
+                var endpointsManifest = Path.ChangeExtension(applicationPath, ".staticwebassets.endpoints.json");
+
                 var inMemoryConfiguration = new Dictionary<string, string?>
                 {
                     [WebHostDefaults.EnvironmentKey] = "Development",
                     ["Logging:LogLevel:Microsoft"] = "Warning",
                     ["Logging:LogLevel:Microsoft.Hosting.Lifetime"] = "Information",
                     [WebHostDefaults.StaticWebAssetsKey] = name,
+                    ["staticAssets"] = endpointsManifest,
                     ["ApplyCopHeaders"] = args.Contains("--apply-cop-headers").ToString()
                 };
 

+ 5 - 3
src/Components/WebAssembly/DevServer/src/Server/Startup.cs

@@ -49,7 +49,9 @@ internal sealed class Startup
             });
         }
 
-        app.UseBlazorFrameworkFiles();
+        //app.UseBlazorFrameworkFiles();
+        app.UseRouting();
+
         app.UseStaticFiles(new StaticFileOptions
         {
             // In development, serve everything, as there's no other way to configure it.
@@ -57,10 +59,10 @@ internal sealed class Startup
             ServeUnknownFileTypes = true,
         });
 
-        app.UseRouting();
-
         app.UseEndpoints(endpoints =>
         {
+            var manifest = configuration["staticAssets"]!;
+            endpoints.MapStaticAssets(manifest);
             endpoints.MapFallbackToFile("index.html", new StaticFileOptions
             {
                 OnPrepareResponse = fileContext =>

+ 0 - 2
src/Components/WebAssembly/Server/src/Builder/WebAssemblyComponentsEndpointOptions.cs

@@ -31,6 +31,4 @@ public sealed class WebAssemblyComponentsEndpointOptions
     /// Gets or sets the <see cref="string"/> that determines the static assets manifest path mapped to this app.
     /// </summary>
     public string? StaticAssetsManifestPath { get; set; }
-
-    internal bool ConventionsApplied { get; set; }
 }

+ 5 - 20
src/Components/WebAssembly/Server/src/Builder/WebAssemblyRazorComponentsEndpointConventionBuilderExtensions.cs

@@ -6,7 +6,7 @@ using Microsoft.AspNetCore.Components.Endpoints;
 using Microsoft.AspNetCore.Components.Endpoints.Infrastructure;
 using Microsoft.AspNetCore.Components.Web;
 using Microsoft.AspNetCore.Components.WebAssembly.Server;
-using Microsoft.AspNetCore.Routing;
+using Microsoft.AspNetCore.Hosting;
 using Microsoft.AspNetCore.StaticAssets.Infrastructure;
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.Hosting;
@@ -52,19 +52,17 @@ public static partial class WebAssemblyRazorComponentsEndpointConventionBuilderE
         ComponentEndpointConventionBuilderHelper.AddRenderMode(builder, new WebAssemblyRenderModeWithOptions(options));
 
         var endpointBuilder = ComponentEndpointConventionBuilderHelper.GetEndpointRouteBuilder(builder);
+        var environment = endpointBuilder.ServiceProvider.GetRequiredService<IWebHostEnvironment>();
 
         // If the static assets data source for the given manifest name is already added, then just wire-up the Blazor WebAssembly conventions.
         // MapStaticWebAssetEndpoints is idempotent and will not add the data source if it already exists.
-        if (HasStaticAssetDataSource(endpointBuilder, options.StaticAssetsManifestPath))
+        var descriptors = StaticAssetsEndpointDataSourceHelper.ResolveStaticAssetDescriptors(endpointBuilder, options.StaticAssetsManifestPath);
+        if (descriptors != null && descriptors.Count > 0)
         {
-            options.ConventionsApplied = true;
-            endpointBuilder.MapStaticAssets(options.StaticAssetsManifestPath)
-                .AddBlazorWebAssemblyConventions();
-
+            ComponentWebAssemblyConventions.AddBlazorWebAssemblyConventions(descriptors, environment);
             return builder;
         }
 
-        var environment = endpointBuilder.ServiceProvider.GetRequiredService<IHostEnvironment>();
         if (environment.IsDevelopment())
         {
             var logger = endpointBuilder.ServiceProvider.GetRequiredService<ILogger<WebAssemblyComponentsEndpointOptions>>();
@@ -81,19 +79,6 @@ public static partial class WebAssemblyRazorComponentsEndpointConventionBuilderE
         return builder;
     }
 
-    private static bool HasStaticAssetDataSource(IEndpointRouteBuilder endpointRouteBuilder, string? staticAssetsManifestName)
-    {
-        foreach (var ds in endpointRouteBuilder.DataSources)
-        {
-            if (StaticAssetsEndpointDataSourceHelper.IsStaticAssetsDataSource(ds, staticAssetsManifestName))
-            {
-                return true;
-            }
-        }
-
-        return false;
-    }
-
     internal static partial class Log
     {
         [LoggerMessage(1, LogLevel.Warning, $$"""Mapped static asset endpoints not found. Ensure '{{nameof(StaticAssetsEndpointRouteBuilderExtensions.MapStaticAssets)}}' is called before '{{nameof(AddInteractiveWebAssemblyRenderMode)}}'.""")]

+ 52 - 0
src/Components/WebAssembly/Server/src/ComponentWebAssemblyConventions.cs

@@ -0,0 +1,52 @@
+// 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.Hosting;
+using Microsoft.AspNetCore.StaticAssets;
+
+namespace Microsoft.AspNetCore.Components.WebAssembly.Server;
+
+internal static class ComponentWebAssemblyConventions
+{
+    private static readonly string? s_dotnetModifiableAssemblies = GetNonEmptyEnvironmentVariableValue("DOTNET_MODIFIABLE_ASSEMBLIES");
+    private static readonly string? s_aspnetcoreBrowserTools = GetNonEmptyEnvironmentVariableValue("__ASPNETCORE_BROWSER_TOOLS");
+
+    private static string? GetNonEmptyEnvironmentVariableValue(string name)
+        => Environment.GetEnvironmentVariable(name) is { Length: > 0 } value ? value : null;
+
+    internal static void AddBlazorWebAssemblyConventions(
+        IReadOnlyList<StaticAssetDescriptor> descriptors,
+        IWebHostEnvironment webHostEnvironment)
+    {
+        var headers = new List<StaticAssetResponseHeader>
+        {
+            new("Blazor-Environment", webHostEnvironment.EnvironmentName)
+        };
+
+        // DOTNET_MODIFIABLE_ASSEMBLIES is used by the runtime to initialize hot-reload specific environment variables and is configured
+        // by the launching process (dotnet-watch / Visual Studio).
+        // Always add the header if the environment variable is set, regardless of the kind of environment.
+        if (s_dotnetModifiableAssemblies != null)
+        {
+            headers.Add(new("DOTNET-MODIFIABLE-ASSEMBLIES", s_dotnetModifiableAssemblies));
+        }
+
+        // See https://github.com/dotnet/aspnetcore/issues/37357#issuecomment-941237000
+        // Translate the _ASPNETCORE_BROWSER_TOOLS environment configured by the browser tools agent in to a HTTP response header.
+        if (s_aspnetcoreBrowserTools != null)
+        {
+            headers.Add(new("ASPNETCORE-BROWSER-TOOLS", s_aspnetcoreBrowserTools));
+        }
+
+        for (var i = 0; i < descriptors.Count; i++)
+        {
+            var descriptor = descriptors[i];
+            if (descriptor.AssetPath.StartsWith("_framework/", StringComparison.OrdinalIgnoreCase))
+            {
+                descriptor.ResponseHeaders = [
+                    ..descriptor.ResponseHeaders,
+                    ..headers];
+            }
+        }
+    }
+}

+ 0 - 88
src/Components/WebAssembly/Server/src/ComponentsWebAssemblyStaticAssetsEndpointConventionBuilderExtensions.cs

@@ -1,88 +0,0 @@
-// 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.Builder;
-using Microsoft.AspNetCore.Hosting;
-using Microsoft.AspNetCore.Routing;
-using Microsoft.AspNetCore.StaticAssets;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Net.Http.Headers;
-
-namespace Microsoft.AspNetCore.Components.WebAssembly.Server;
-
-internal static class ComponentsWebAssemblyStaticAssetsEndpointConventionBuilderExtensions
-{
-    private static readonly string? s_dotnetModifiableAssemblies = GetNonEmptyEnvironmentVariableValue("DOTNET_MODIFIABLE_ASSEMBLIES");
-    private static readonly string? s_aspnetcoreBrowserTools = GetNonEmptyEnvironmentVariableValue("__ASPNETCORE_BROWSER_TOOLS");
-
-    private static string? GetNonEmptyEnvironmentVariableValue(string name)
-        => Environment.GetEnvironmentVariable(name) is { Length: > 0 } value ? value : null;
-
-    /// <summary>
-    /// Configures additional static web asset extensions logic for Blazor WebAssembly.
-    /// </summary>
-    /// <param name="builder"></param>
-    internal static void AddBlazorWebAssemblyConventions(this StaticAssetsEndpointConventionBuilder builder)
-    {
-        builder.Add(endpoint =>
-        {
-            if (endpoint is RouteEndpointBuilder { RoutePattern.RawText: { } pattern } && pattern.Contains("/_framework/", StringComparison.OrdinalIgnoreCase) &&
-            !pattern.Contains("/_framework/blazor.server.js", StringComparison.OrdinalIgnoreCase) && !pattern.Contains("/_framework/blazor.web.js", StringComparison.OrdinalIgnoreCase))
-            {
-                WrapEndpoint(endpoint);
-            }
-        });
-    }
-
-    private static void WrapEndpoint(EndpointBuilder endpoint)
-    {
-        var original = endpoint.RequestDelegate;
-        if (original == null)
-        {
-            return;
-        }
-
-        for (var i = 0; i < endpoint.Metadata.Count; i++)
-        {
-            if (endpoint.Metadata[i] is WebAssemblyConventionsAppliedMetadata)
-            {
-                // Already applied
-                return;
-            }
-        }
-
-        endpoint.Metadata.Add(new WebAssemblyConventionsAppliedMetadata());
-
-        // Note this mimics what UseBlazorFrameworkFiles does.
-        // The goal is to remove all this logic and push it to the build. For example, we should not have
-        // "Cache-Control" "no-cache" here as the build itself will add it.
-        // Similarly, we shouldn't add the `DOTNET-MODIFIABLE-ASSEMBLIES` and `ASPNETCORE-BROWSER-TOOLS` headers here.
-        // Those should be handled by the tooling, by hooking up on to the OnResponseStarting event and checking that the
-        // endpoint is for the appropriate web assembly file. (Very likely this is only needed for the blazor.boot.json file)
-        endpoint.RequestDelegate = (context) =>
-        {
-            var webHostEnvironment = context.RequestServices.GetRequiredService<IWebHostEnvironment>();
-            context.Response.Headers.Add("Blazor-Environment", webHostEnvironment.EnvironmentName);
-            context.Response.Headers.Add(HeaderNames.CacheControl, "no-cache");
-
-            // DOTNET_MODIFIABLE_ASSEMBLIES is used by the runtime to initialize hot-reload specific environment variables and is configured
-            // by the launching process (dotnet-watch / Visual Studio).
-            // Always add the header if the environment variable is set, regardless of the kind of environment.
-            if (s_dotnetModifiableAssemblies != null)
-            {
-                context.Response.Headers.Add("DOTNET-MODIFIABLE-ASSEMBLIES", s_dotnetModifiableAssemblies);
-            }
-
-            // See https://github.com/dotnet/aspnetcore/issues/37357#issuecomment-941237000
-            // Translate the _ASPNETCORE_BROWSER_TOOLS environment configured by the browser tools agent in to a HTTP response header.
-            if (s_aspnetcoreBrowserTools != null)
-            {
-                context.Response.Headers.Add("ASPNETCORE-BROWSER-TOOLS", s_aspnetcoreBrowserTools);
-            }
-
-            return original(context);
-        };
-    }
-
-    private sealed class WebAssemblyConventionsAppliedMetadata;
-}

+ 83 - 0
src/Components/WebAssembly/Server/src/WebAssemblyEndpointProvider.cs

@@ -0,0 +1,83 @@
+// 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.Builder;
+using Microsoft.AspNetCore.Components;
+using Microsoft.AspNetCore.Components.Endpoints.Infrastructure;
+using Microsoft.AspNetCore.Components.Web;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Routing;
+
+namespace Microsoft.Extensions.DependencyInjection;
+
+internal class WebAssemblyEndpointProvider(IServiceProvider services) : RenderModeEndpointProvider
+{
+    private const string ResourceCollectionKey = "__ResourceCollectionKey";
+
+    public override IEnumerable<RouteEndpointBuilder> GetEndpointBuilders(IComponentRenderMode renderMode, IApplicationBuilder applicationBuilder)
+    {
+        if (renderMode is not WebAssemblyRenderModeWithOptions wasmWithOptions)
+        {
+            return renderMode is InteractiveWebAssemblyRenderMode
+                ? throw new InvalidOperationException("Invalid render mode. Use AddInteractiveWebAssemblyRenderMode(Action<WebAssemblyComponentsEndpointOptions>) to configure the WebAssembly render mode.")
+                : [];
+        }
+        if (applicationBuilder.Properties[ResourceCollectionKey] is ResourceAssetCollection assetMap)
+        {
+            return [];
+        }
+        else
+        {
+            // In case the app didn't call MapStaticAssets, use the 8.0 approach to map the assets.
+            var endpointRouteBuilder = new WebAssemblyEndpointRouteBuilder(services, applicationBuilder);
+            var pathPrefix = wasmWithOptions.EndpointOptions?.PathPrefix;
+
+            applicationBuilder.UseBlazorFrameworkFiles(pathPrefix ?? default);
+            var app = applicationBuilder.Build();
+
+            endpointRouteBuilder.Map($"{pathPrefix}/_framework/{{*path}}", context =>
+            {
+                // Set endpoint to null so the static files middleware will handle the request.
+                context.SetEndpoint(null);
+
+                return app(context);
+            });
+
+            return endpointRouteBuilder.GetEndpoints();
+        }
+    }
+
+    public override bool Supports(IComponentRenderMode renderMode) =>
+        renderMode is InteractiveWebAssemblyRenderMode or InteractiveAutoRenderMode;
+
+    private class WebAssemblyEndpointRouteBuilder(IServiceProvider serviceProvider, IApplicationBuilder applicationBuilder) : IEndpointRouteBuilder
+    {
+        public IServiceProvider ServiceProvider { get; } = serviceProvider;
+
+        public ICollection<EndpointDataSource> DataSources { get; } = [];
+
+        public IApplicationBuilder CreateApplicationBuilder()
+        {
+            return applicationBuilder.New();
+        }
+
+        internal IEnumerable<RouteEndpointBuilder> GetEndpoints()
+        {
+            foreach (var ds in DataSources)
+            {
+                foreach (var endpoint in ds.Endpoints)
+                {
+                    var routeEndpoint = (RouteEndpoint)endpoint;
+                    var builder = new RouteEndpointBuilder(endpoint.RequestDelegate, routeEndpoint.RoutePattern, routeEndpoint.Order);
+                    for (var i = 0; i < routeEndpoint.Metadata.Count; i++)
+                    {
+                        var metadata = routeEndpoint.Metadata[i];
+                        builder.Metadata.Add(metadata);
+                    }
+
+                    yield return builder;
+                }
+            }
+        }
+    }
+}

+ 0 - 82
src/Components/WebAssembly/Server/src/WebAssemblyRazorComponentsBuilderExtensions.cs

@@ -1,14 +1,10 @@
 // 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.Builder;
 using Microsoft.AspNetCore.Components;
 using Microsoft.AspNetCore.Components.Authorization;
 using Microsoft.AspNetCore.Components.Endpoints.Infrastructure;
-using Microsoft.AspNetCore.Components.Web;
 using Microsoft.AspNetCore.Components.WebAssembly.Server;
-using Microsoft.AspNetCore.Http;
-using Microsoft.AspNetCore.Routing;
 using Microsoft.Extensions.DependencyInjection.Extensions;
 
 namespace Microsoft.Extensions.DependencyInjection;
@@ -50,82 +46,4 @@ public static class WebAssemblyRazorComponentsBuilderExtensions
 
         return builder;
     }
-
-    private class WebAssemblyEndpointProvider(IServiceProvider services) : RenderModeEndpointProvider
-    {
-        public override IEnumerable<RouteEndpointBuilder> GetEndpointBuilders(IComponentRenderMode renderMode, IApplicationBuilder applicationBuilder)
-        {
-            if (renderMode is not WebAssemblyRenderModeWithOptions wasmWithOptions)
-            {
-                return renderMode is InteractiveWebAssemblyRenderMode
-                    ? throw new InvalidOperationException("Invalid render mode. Use AddInteractiveWebAssemblyRenderMode(Action<WebAssemblyComponentsEndpointOptions>) to configure the WebAssembly render mode.")
-                    : (IEnumerable<RouteEndpointBuilder>)Array.Empty<RouteEndpointBuilder>();
-            }
-            if (wasmWithOptions is { EndpointOptions.ConventionsApplied: true })
-            {
-                return []; // No need to add additional endpoints to the DS, they are already added
-            }
-            else
-            {
-                // In case the app didn't call MapStaticAssets, use the 8.0 approach to map the assets.
-                var endpointRouteBuilder = new EndpointRouteBuilder(services, applicationBuilder);
-                var pathPrefix = wasmWithOptions.EndpointOptions?.PathPrefix;
-
-                applicationBuilder.UseBlazorFrameworkFiles(pathPrefix ?? default);
-                var app = applicationBuilder.Build();
-
-                endpointRouteBuilder.Map($"{pathPrefix}/_framework/{{*path}}", context =>
-                {
-                    // Set endpoint to null so the static files middleware will handle the request.
-                    context.SetEndpoint(null);
-
-                    return app(context);
-                });
-
-                return endpointRouteBuilder.GetEndpoints();
-            }
-        }
-
-        public override bool Supports(IComponentRenderMode renderMode) =>
-            renderMode is InteractiveWebAssemblyRenderMode or InteractiveAutoRenderMode;
-
-        private class EndpointRouteBuilder : IEndpointRouteBuilder
-        {
-            private readonly IApplicationBuilder _applicationBuilder;
-
-            public EndpointRouteBuilder(IServiceProvider serviceProvider, IApplicationBuilder applicationBuilder)
-            {
-                ServiceProvider = serviceProvider;
-                _applicationBuilder = applicationBuilder;
-            }
-
-            public IServiceProvider ServiceProvider { get; }
-
-            public ICollection<EndpointDataSource> DataSources { get; } = [];
-
-            public IApplicationBuilder CreateApplicationBuilder()
-            {
-                return _applicationBuilder.New();
-            }
-
-            internal IEnumerable<RouteEndpointBuilder> GetEndpoints()
-            {
-                foreach (var ds in DataSources)
-                {
-                    foreach (var endpoint in ds.Endpoints)
-                    {
-                        var routeEndpoint = (RouteEndpoint)endpoint;
-                        var builder = new RouteEndpointBuilder(endpoint.RequestDelegate, routeEndpoint.RoutePattern, routeEndpoint.Order);
-                        for (var i = 0; i < routeEndpoint.Metadata.Count; i++)
-                        {
-                            var metadata = routeEndpoint.Metadata[i];
-                            builder.Metadata.Add(metadata);
-                        }
-
-                        yield return builder;
-                    }
-                }
-            }
-        }
-    }
 }

+ 3 - 2
src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHost.cs

@@ -143,12 +143,13 @@ public sealed class WebAssemblyHost : IAsyncDisposable
         }
 
         var tcs = new TaskCompletionSource();
-
         using (cancellationToken.Register(() => tcs.TrySetResult()))
         {
             var loggerFactory = Services.GetRequiredService<ILoggerFactory>();
             var jsComponentInterop = new JSComponentInterop(_rootComponents.JSComponents);
-            _renderer = new WebAssemblyRenderer(Services, loggerFactory, jsComponentInterop);
+            var collectionProvider = Services.GetRequiredService<ResourceCollectionProvider>();
+            var collection = await collectionProvider.GetResourceCollection();
+            _renderer = new WebAssemblyRenderer(Services, collection, loggerFactory, jsComponentInterop);
 
             WebAssemblyNavigationManager.Instance.CreateLogger(loggerFactory);
 

+ 1 - 0
src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs

@@ -307,6 +307,7 @@ public sealed class WebAssemblyHostBuilder
         Services.AddSingleton<PersistentComponentState>(sp => sp.GetRequiredService<ComponentStatePersistenceManager>().State);
         Services.AddSingleton<AntiforgeryStateProvider, DefaultAntiforgeryStateProvider>();
         Services.AddSingleton<IErrorBoundaryLogger, WebAssemblyErrorBoundaryLogger>();
+        Services.AddSingleton<ResourceCollectionProvider>();
         Services.AddLogging(builder =>
         {
             builder.AddProvider(new WebAssemblyConsoleLoggerProvider(DefaultWebAssemblyJSRuntime.Instance));

+ 1 - 0
src/Components/WebAssembly/WebAssembly/src/Microsoft.AspNetCore.Components.WebAssembly.csproj

@@ -43,6 +43,7 @@
     <Compile Include="$(SharedSourceRoot)Components\PrerenderComponentApplicationStore.cs" />
     <Compile Include="$(SharedSourceRoot)LinkerFlags.cs" LinkBase="Shared" />
     <Compile Include="$(ComponentsSharedSourceRoot)src\DefaultAntiforgeryStateProvider.cs" LinkBase="Forms" />
+    <Compile Include="$(ComponentsSharedSourceRoot)src\ResourceCollectionProvider.cs" Link="Shared\ResourceCollectionProvider.cs" />
   </ItemGroup>
 
   <ItemGroup>

+ 6 - 1
src/Components/WebAssembly/WebAssembly/src/Rendering/WebAssemblyRenderer.cs

@@ -23,10 +23,11 @@ internal sealed partial class WebAssemblyRenderer : WebRenderer
 {
     private readonly ILogger _logger;
     private readonly Dispatcher _dispatcher;
+    private readonly ResourceAssetCollection _resourceCollection;
     private readonly IInternalJSImportMethods _jsMethods;
     private static readonly ComponentPlatform _componentPlatform = new("WebAssembly", isInteractive: true);
 
-    public WebAssemblyRenderer(IServiceProvider serviceProvider, ILoggerFactory loggerFactory, JSComponentInterop jsComponentInterop)
+    public WebAssemblyRenderer(IServiceProvider serviceProvider, ResourceAssetCollection resourceCollection, ILoggerFactory loggerFactory, JSComponentInterop jsComponentInterop)
         : base(serviceProvider, loggerFactory, DefaultWebAssemblyJSRuntime.Instance.ReadJsonSerializerOptions(), jsComponentInterop)
     {
         _logger = loggerFactory.CreateLogger<WebAssemblyRenderer>();
@@ -37,6 +38,8 @@ internal sealed partial class WebAssemblyRenderer : WebRenderer
             ? NullDispatcher.Instance
             : new WebAssemblyDispatcher();
 
+        _resourceCollection = resourceCollection;
+
         ElementReferenceContext = DefaultWebAssemblyJSRuntime.Instance.ElementReferenceContext;
         DefaultWebAssemblyJSRuntime.Instance.OnUpdateRootComponents += OnUpdateRootComponents;
     }
@@ -80,6 +83,8 @@ internal sealed partial class WebAssemblyRenderer : WebRenderer
         _jsMethods.EndUpdateRootComponents(batchId);
     }
 
+    protected override ResourceAssetCollection Assets => _resourceCollection;
+
     protected override ComponentPlatform ComponentPlatform => _componentPlatform;
 
     public override Dispatcher Dispatcher => _dispatcher;

+ 63 - 0
src/Components/test/E2ETest/ServerRenderingTests/ResourceCollectionTest.cs

@@ -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 System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Text.RegularExpressions;
+using System.Threading.Tasks;
+using Components.TestServer.RazorComponents;
+using Microsoft.AspNetCore.Components.E2ETest.Infrastructure;
+using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures;
+using Microsoft.AspNetCore.E2ETesting;
+using OpenQA.Selenium;
+using TestServer;
+using Xunit.Abstractions;
+
+namespace Microsoft.AspNetCore.Components.E2ETests.ServerRenderingTests;
+
+public partial class ResourceCollectionTest : ServerTestBase<BasicTestAppServerSiteFixture<RazorComponentEndpointsStartup<App>>>
+{
+    public ResourceCollectionTest(BrowserFixture browserFixture,
+        BasicTestAppServerSiteFixture<RazorComponentEndpointsStartup<App>> serverFixture,
+        ITestOutputHelper output)
+        : base(browserFixture, serverFixture, output)
+    {
+    }
+
+    [Fact]
+    public void StaticRendering_CanUseFingerprintedResources()
+    {
+        var url = $"{ServerPathBase}/resource-collection";
+        Navigate(url);
+
+        Browser.True(() => AppStylesRegex().IsMatch(Browser.Exists(By.Id("basic-app-styles")).Text));
+
+        Browser.Exists(By.Id("import-module")).Click();
+
+        Browser.True(() => JsModuleRegex().IsMatch(Browser.Exists(By.Id("js-module")).Text));
+    }
+
+    [Theory]
+    [InlineData("Server")]
+    [InlineData("WebAssembly")]
+    public void StaticRendering_CanUseFingerprintedResources_InteractiveModes(string renderMode)
+    {
+        var url = $"{ServerPathBase}/resource-collection?render-mode={renderMode}";
+        Navigate(url);
+
+        Browser.Equal(renderMode, () => Browser.Exists(By.Id("platform-name")).Text);
+
+        Browser.True(() => AppStylesRegex().IsMatch(Browser.Exists(By.Id("basic-app-styles")).Text));
+
+        Browser.Exists(By.Id("import-module")).Click();
+
+        Browser.True(() => JsModuleRegex().IsMatch(Browser.Exists(By.Id("js-module")).Text));
+    }
+
+    [GeneratedRegex("""BasicTestApp\.[a-zA-Z0-9]{10}\.styles\.css""")]
+    private static partial Regex AppStylesRegex();
+    [GeneratedRegex(""".*Index\.[a-zA-Z0-9]{10}\.mjs""")]
+    private static partial Regex JsModuleRegex();
+}

+ 0 - 1
src/Components/test/testassets/Components.TestServer/Components.TestServer.csproj

@@ -48,5 +48,4 @@
       <_Parameter2 Condition="'$(IsHelixJob)' == 'true'">..\BasicTestApp</_Parameter2>
     </AssemblyAttribute>
   </ItemGroup>
-
 </Project>

+ 44 - 0
src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/ResourceCollection/Index.razor

@@ -0,0 +1,44 @@
+@page "/resource-collection"
+@using Microsoft.AspNetCore.Components.Web;
+@using Microsoft.AspNetCore.Components.Endpoints;
+
+<PageTitle>Resource Collection</PageTitle>
+
+<HeadContent>
+  <ImportMap></ImportMap>
+</HeadContent>
+
+<h1>Hello from resource collection</h1>
+
+<p>
+    This page demonstrates the workings of resource collection.
+
+    The ResourceCollectionSample component will display fingerprinted URLs for the BasicTestApp.styles.css
+    and will trigger an import of a JavaScript module via it's human readable name.
+
+    Upon import, the script will append a new element to the dom and will display its import.meta.url, which
+    when the importmap works will show the fingerprinted URL.
+
+    The test runs in Static, Server and WebAssembly render modes.
+</p>
+
+<TestContentPackage.ResourceCollectionSample @rendermode="_renderMode"></TestContentPackage.ResourceCollectionSample>
+
+@code
+{
+    [SupplyParameterFromQuery(Name = "render-mode")]
+    public string SelectedRenderMode { get; set; }
+
+    private IComponentRenderMode? _renderMode;
+
+    protected override void OnInitialized()
+    {
+        var assets = Assets;
+        _renderMode = SelectedRenderMode switch
+        {
+            "Server" => RenderMode.InteractiveServer,
+            "WebAssembly" => RenderMode.InteractiveWebAssembly,
+            _ => null
+        };
+    }
+}

+ 4 - 0
src/Components/test/testassets/Components.TestServer/RemoteAuthenticationStartup.cs

@@ -28,7 +28,11 @@ public class RemoteAuthenticationStartup
             app.UseAntiforgery();
             app.UseEndpoints(endpoints =>
             {
+#if !DEBUG
                 endpoints.MapStaticAssets(Path.Combine("trimmed-or-threading", "Components.TestServer", "Components.TestServer.staticwebassets.endpoints.json"));
+#else
+                endpoints.MapStaticAssets("Components.TestServer.staticwebassets.endpoints.json");
+#endif
                 endpoints.MapRazorComponents<RemoteAuthenticationApp>()
                     .AddAdditionalAssemblies(Assembly.Load("Components.WasmRemoteAuthentication"))
                     .AddInteractiveWebAssemblyRenderMode(options => options.PathPrefix = "/WasmRemoteAuthentication");

+ 2 - 0
src/Components/test/testassets/Components.TestServer/appsettings.json

@@ -1,4 +1,6 @@
 {
+  "EnableStaticAssetsDevelopmentIntegrity": true,
+  "EnableStaticAssetsDevelopmentCaching": true,
   "Logging": {
     "IncludeScopes": false,
     "LogLevel": {

+ 4 - 0
src/Components/test/testassets/Components.TestServer/wwwroot/Index.mjs

@@ -0,0 +1,4 @@
+var element = document.createElement('p');
+element.id = 'js-module';
+element.innerHTML = import.meta.url;
+document.getElementById('import-module').after(element);

+ 22 - 0
src/Components/test/testassets/TestContentPackage/ResourceCollectionSample.razor

@@ -0,0 +1,22 @@
+@using Microsoft.JSInterop
+@using Microsoft.AspNetCore.Components.Web;
+@inject IJSRuntime JSRuntime
+
+<p id="platform-name">@Platform.Name</p>
+
+<p id="basic-app-styles">@Assets["BasicTestApp.styles.css"]</p>
+
+@if (!Platform.IsInteractive)
+{
+    <button id="import-module" onclick="import('./Index.mjs')">Import JS Module</button>    
+}else
+{
+    <button id="import-module" @onclick="ManualImport">Import JS Module</button>
+}
+
+@code{
+    private async Task ManualImport()
+    {
+        await JSRuntime.InvokeVoidAsync("import", "./Index.mjs");
+    }
+}

+ 1 - 1
src/Framework/App.Ref/src/CompatibilitySuppressions.xml

@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="utf-8"?>
-<!-- https://learn.microsoft.com/dotnet/fundamentals/package-validation/diagnostic-ids -->
+<!-- https://learn.microsoft.com/en-us/dotnet/fundamentals/package-validation/diagnostic-ids -->
 <Suppressions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
   <Suppression>
     <DiagnosticId>PKV004</DiagnosticId>

+ 1 - 1
src/Framework/App.Runtime/src/CompatibilitySuppressions.xml

@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="utf-8"?>
-<!-- https://learn.microsoft.com/dotnet/fundamentals/package-validation/diagnostic-ids -->
+<!-- https://learn.microsoft.com/en-us/dotnet/fundamentals/package-validation/diagnostic-ids -->
 <Suppressions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
   <Suppression>
     <DiagnosticId>PKV0001</DiagnosticId>

+ 29 - 4
src/Http/Routing/src/Matching/NegotiationMatcherPolicy.cs

@@ -1,7 +1,6 @@
 // Licensed to the .NET Foundation under one or more agreements.
 // The .NET Foundation licenses this file to you under the MIT license.
 
-using System.Linq;
 using Microsoft.AspNetCore.Http;
 using Microsoft.Extensions.Primitives;
 using Microsoft.Net.Http.Headers;
@@ -236,7 +235,7 @@ internal abstract class NegotiationMatcherPolicy<TNegotiateMetadata> : MatcherPo
         {
             var endpoint = endpoints[i];
             var metadata = GetMetadataValue(endpoint) ?? DefaultNegotiationValue;
-            if (!edges.TryGetValue(metadata, out var endpointsForType))
+            if (!edges.TryGetValue(metadata, out var _))
             {
                 edges.Add(metadata, []);
             }
@@ -286,7 +285,7 @@ internal abstract class NegotiationMatcherPolicy<TNegotiateMetadata> : MatcherPo
         {
             result[index] = new PolicyNodeEdge(
                 // Metadata quality is 0 for the edges that don't have metadata as we prefer serving from the endpoints that have metadata
-                new NegotiationEdgeKey(kvp.Key, kvp.Value.Select(e => GetMetadataQuality(e) ?? 0).ToArray()),
+                new NegotiationEdgeKey(kvp.Key, CalculateEndpointQualities(kvp.Value)),
                 kvp.Value);
             index++;
         }
@@ -294,6 +293,16 @@ internal abstract class NegotiationMatcherPolicy<TNegotiateMetadata> : MatcherPo
         return result;
     }
 
+    private double[] CalculateEndpointQualities(List<Endpoint> values)
+    {
+        var result = new double[values.Count];
+        for (var i = 0; i < values.Count; i++)
+        {
+            result[i] = GetMetadataQuality(values[i]) ?? 0;
+        }
+        return result;
+    }
+
     internal class NegotiationEdgeKey
     {
         public NegotiationEdgeKey(string negotiationValue, double[] endpointsQuality)
@@ -326,7 +335,7 @@ internal abstract class NegotiationMatcherPolicy<TNegotiateMetadata> : MatcherPo
         {
             var e = edges[i];
             var key = (NegotiationEdgeKey)e.State;
-            destinations[i] = (negotiationValue: key.NegotiationValue, quality: key.EndpointsQuality.Max(), destination: e.Destination);
+            destinations[i] = (negotiationValue: key.NegotiationValue, quality: Max(key.EndpointsQuality), destination: e.Destination);
         }
 
         // If any edge matches all negotiation values, then treat that as the 'exit'. This will
@@ -347,6 +356,22 @@ internal abstract class NegotiationMatcherPolicy<TNegotiateMetadata> : MatcherPo
         return CreateTable(exitDestination, destinations, noNegotiationHeaderDestination);
     }
 
+    private static double Max(double[] endpointsQuality)
+    {
+        if (endpointsQuality.Length == 0)
+        {
+            throw new InvalidOperationException("No quality values found.");
+        }
+
+        var result = endpointsQuality[0];
+        for (var i = 1; i < endpointsQuality.Length; i++)
+        {
+            result = Math.Max(result, endpointsQuality[i]);
+        }
+
+        return result;
+    }
+
     private protected abstract NegotiationPolicyJumpTable CreateTable(int exitDestination, (string negotiationValue, double quality, int destination)[] destinations, int noNegotiationHeaderDestination);
 
     private sealed class NegotiationMetadataEndpointComparer : EndpointMetadataComparer<TNegotiateMetadata>

+ 1 - 0
src/Http/WebUtilities/src/PublicAPI.Unshipped.txt

@@ -1 +1,2 @@
 #nullable enable
+static Microsoft.AspNetCore.WebUtilities.WebEncoders.Base64UrlEncode(System.ReadOnlySpan<byte> input, System.Span<char> output) -> int

+ 2 - 0
src/Mvc/Mvc.Core/src/Builder/ControllerActionEndpointConventionBuilder.cs

@@ -23,6 +23,8 @@ public sealed class ControllerActionEndpointConventionBuilder : IEndpointConvent
         _finallyConventions = finallyConventions;
     }
 
+    internal Dictionary<string, object> Items { get; set; } = [];
+
     /// <summary>
     /// Adds the specified convention to the builder. Conventions are used to customize <see cref="EndpointBuilder"/> instances.
     /// </summary>

+ 19 - 1
src/Mvc/Mvc.Core/src/Builder/ControllerEndpointRouteBuilderExtensions.cs

@@ -17,6 +17,8 @@ namespace Microsoft.AspNetCore.Builder;
 /// </summary>
 public static class ControllerEndpointRouteBuilderExtensions
 {
+    internal const string EndpointRouteBuilderKey = "__EndpointRouteBuilder";
+
     /// <summary>
     /// Adds endpoints for controller actions to the <see cref="IEndpointRouteBuilder"/> without specifying any routes.
     /// </summary>
@@ -28,7 +30,13 @@ public static class ControllerEndpointRouteBuilderExtensions
 
         EnsureControllerServices(endpoints);
 
-        return GetOrCreateDataSource(endpoints).DefaultBuilder;
+        var result = GetOrCreateDataSource(endpoints).DefaultBuilder;
+        if (!result.Items.ContainsKey(EndpointRouteBuilderKey))
+        {
+            result.Items[EndpointRouteBuilderKey] = endpoints;
+        }
+
+        return result;
     }
 
     /// <summary>
@@ -46,6 +54,11 @@ public static class ControllerEndpointRouteBuilderExtensions
         EnsureControllerServices(endpoints);
 
         var dataSource = GetOrCreateDataSource(endpoints);
+        if (!dataSource.DefaultBuilder.Items.ContainsKey(EndpointRouteBuilderKey))
+        {
+            dataSource.DefaultBuilder.Items[EndpointRouteBuilderKey] = endpoints;
+        }
+
         return dataSource.AddRoute(
             "default",
             "{controller=Home}/{action=Index}/{id?}",
@@ -90,6 +103,11 @@ public static class ControllerEndpointRouteBuilderExtensions
         EnsureControllerServices(endpoints);
 
         var dataSource = GetOrCreateDataSource(endpoints);
+        if (!dataSource.DefaultBuilder.Items.ContainsKey(EndpointRouteBuilderKey))
+        {
+            dataSource.DefaultBuilder.Items[EndpointRouteBuilderKey] = endpoints;
+        }
+
         return dataSource.AddRoute(
             name,
             pattern,

+ 41 - 0
src/Mvc/Mvc.Razor/src/TagHelpers/UrlResolutionTagHelper.cs

@@ -4,7 +4,9 @@
 using System.Buffers;
 using System.Diagnostics.CodeAnalysis;
 using System.Text.Encodings.Web;
+using Microsoft.AspNetCore.Components;
 using Microsoft.AspNetCore.Html;
+using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc.Rendering;
 using Microsoft.AspNetCore.Mvc.Routing;
 using Microsoft.AspNetCore.Mvc.ViewFeatures;
@@ -221,6 +223,8 @@ public class UrlResolutionTagHelper : TagHelper
             return false;
         }
 
+        trimmedUrl = GetVersionedResourceUrl(trimmedUrl);
+
         var urlHelper = UrlHelperFactory.GetUrlHelper(ViewContext);
         resolvedUrl = urlHelper.Content(trimmedUrl);
 
@@ -244,6 +248,8 @@ public class UrlResolutionTagHelper : TagHelper
             return false;
         }
 
+        trimmedUrl = GetVersionedResourceUrl(trimmedUrl);
+
         var urlHelper = UrlHelperFactory.GetUrlHelper(ViewContext);
         var appRelativeUrl = urlHelper.Content(trimmedUrl);
         var postTildeSlashUrlValue = trimmedUrl.Substring(2);
@@ -299,6 +305,41 @@ public class UrlResolutionTagHelper : TagHelper
         return true;
     }
 
+    private string GetVersionedResourceUrl(string value)
+    {
+        var assetCollection = GetAssetCollection();
+        if (assetCollection != null)
+        {
+            var (key, remainder) = ExtractKeyAndRest(value);
+
+            var src = assetCollection[key];
+            if (!string.Equals(src, key, StringComparison.Ordinal))
+            {
+                return $"~/{src}{value[remainder..]}";
+            }
+        }
+
+        return value;
+
+        static (string key, int rest) ExtractKeyAndRest(string value)
+        {
+            var lastNonWhitespaceChar = value.AsSpan().TrimEnd().LastIndexOfAnyExcept(ValidAttributeWhitespaceChars);
+            var keyEnd = lastNonWhitespaceChar > -1 ? lastNonWhitespaceChar + 1 : value.Length;
+            var key = value.AsSpan();
+            if (key.StartsWith("~/", StringComparison.Ordinal))
+            {
+                key = value.AsSpan()[2..keyEnd].Trim();
+            }
+
+            return (key.ToString(), keyEnd);
+        }
+    }
+
+    private ResourceAssetCollection? GetAssetCollection()
+    {
+        return ViewContext.HttpContext.GetEndpoint()?.Metadata.GetMetadata<ResourceAssetCollection>();
+    }
+
     private sealed class EncodeFirstSegmentContent : IHtmlContent
     {
         private readonly string _firstSegment;

+ 88 - 3
src/Mvc/Mvc.Razor/test/TagHelpers/UrlResolutionTagHelperTest.cs

@@ -1,7 +1,9 @@
 // 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;
 using Microsoft.AspNetCore.Html;
+using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc.Routing;
 using Microsoft.AspNetCore.Razor.TagHelpers;
 using Microsoft.Extensions.WebEncoders.Testing;
@@ -36,6 +38,29 @@ public class UrlResolutionTagHelperTest
         }
     }
 
+    public static TheoryData ResolvableUrlVersionData
+    {
+        get
+        {
+            // url, expectedHref
+            return new TheoryData<string, string>
+                {
+                   { "~/home/index.html", "/approot/home/index.fingerprint.html" },
+                   { "~/home/index.html\r\n", "/approot/home/index.fingerprint.html" },
+                   { "  ~/home/index.html", "/approot/home/index.fingerprint.html" },
+                   { "\u000C~/home/index.html\r\n", "/approot/home/index.fingerprint.html" },
+                   { "\t ~/home/index.html\n", "/approot/home/index.fingerprint.html" },
+                   { "\r\n~/home/index.html\u000C\t", "/approot/home/index.fingerprint.html" },
+                   { "\r~/home/index.html\t", "/approot/home/index.fingerprint.html" },
+                   { "\n~/home/index.html\u202F", "/approot/home/index.fingerprint.html\u202F" },
+                   {
+                        "~/home/index.html ~/secondValue/index.html",
+                        "/approot/home/index.html ~/secondValue/index.html"
+                   },
+                };
+        }
+    }
+
     [Fact]
     public void Process_DoesNothingIfTagNameIsNull()
     {
@@ -85,7 +110,10 @@ public class UrlResolutionTagHelperTest
         urlHelperFactory
             .Setup(f => f.GetUrlHelper(It.IsAny<ActionContext>()))
             .Returns(urlHelperMock.Object);
-        var tagHelper = new UrlResolutionTagHelper(urlHelperFactory.Object, new HtmlTestEncoder());
+        var tagHelper = new UrlResolutionTagHelper(urlHelperFactory.Object, new HtmlTestEncoder())
+        {
+            ViewContext = new Rendering.ViewContext { HttpContext = new DefaultHttpContext() }
+        };
 
         var context = new TagHelperContext(
             tagName: "a",
@@ -142,7 +170,10 @@ public class UrlResolutionTagHelperTest
         urlHelperFactory
             .Setup(f => f.GetUrlHelper(It.IsAny<ActionContext>()))
             .Returns(urlHelperMock.Object);
-        var tagHelper = new UrlResolutionTagHelper(urlHelperFactory.Object, new HtmlTestEncoder());
+        var tagHelper = new UrlResolutionTagHelper(urlHelperFactory.Object, new HtmlTestEncoder())
+        {
+            ViewContext = new Rendering.ViewContext { HttpContext = new DefaultHttpContext() }
+        };
 
         var context = new TagHelperContext(
             tagName: "a",
@@ -333,7 +364,10 @@ public class UrlResolutionTagHelperTest
         urlHelperFactory
             .Setup(f => f.GetUrlHelper(It.IsAny<ActionContext>()))
             .Returns(urlHelperMock.Object);
-        var tagHelper = new UrlResolutionTagHelper(urlHelperFactory.Object, new HtmlTestEncoder());
+        var tagHelper = new UrlResolutionTagHelper(urlHelperFactory.Object, new HtmlTestEncoder())
+        {
+            ViewContext = new Rendering.ViewContext { HttpContext = new DefaultHttpContext() }
+        };
 
         var context = new TagHelperContext(
             tagName: "a",
@@ -347,4 +381,55 @@ public class UrlResolutionTagHelperTest
             () => tagHelper.Process(context, tagHelperOutput));
         Assert.Equal(expectedExceptionMessage, exception.Message, StringComparer.Ordinal);
     }
+
+    [Theory]
+    [MemberData(nameof(ResolvableUrlVersionData))]
+    public void Process_ResolvesVersionedUrls_WhenResourceCollectionIsAvailable(string url, string expectedHref)
+    {
+        // Arrange
+        var tagHelperOutput = new TagHelperOutput(
+            tagName: "a",
+            attributes: new TagHelperAttributeList
+            {
+                    { "href", url }
+            },
+            getChildContentAsync: (useCachedResult, encoder) => Task.FromResult<TagHelperContent>(null));
+        var urlHelperMock = new Mock<IUrlHelper>();
+        urlHelperMock
+            .Setup(urlHelper => urlHelper.Content(It.IsAny<string>()))
+            .Returns(new Func<string, string>(value => "/approot" + value.Substring(1)));
+        var urlHelperFactory = new Mock<IUrlHelperFactory>();
+        urlHelperFactory
+            .Setup(f => f.GetUrlHelper(It.IsAny<ActionContext>()))
+            .Returns(urlHelperMock.Object);
+
+        var httpContext = new DefaultHttpContext();
+        httpContext.SetEndpoint(new Endpoint(
+            (context) => Task.CompletedTask,
+            new EndpointMetadataCollection(
+                [new ResourceAssetCollection([new("home/index.fingerprint.html", [new ResourceAssetProperty("label", "home/index.html")])])]),
+            "Test"));
+
+        var tagHelper = new UrlResolutionTagHelper(urlHelperFactory.Object, new HtmlTestEncoder())
+        {
+            ViewContext = new Rendering.ViewContext { HttpContext = httpContext }
+        };
+
+        var context = new TagHelperContext(
+            tagName: "a",
+            allAttributes: new TagHelperAttributeList(
+                Enumerable.Empty<TagHelperAttribute>()),
+            items: new Dictionary<object, object>(),
+            uniqueId: "test");
+
+        // Act
+        tagHelper.Process(context, tagHelperOutput);
+
+        // Assert
+        var attribute = Assert.Single(tagHelperOutput.Attributes);
+        Assert.Equal("href", attribute.Name, StringComparer.Ordinal);
+        var attributeValue = Assert.IsType<string>(attribute.Value);
+        Assert.Equal(expectedHref, attributeValue, StringComparer.Ordinal);
+        Assert.Equal(HtmlAttributeValueStyle.DoubleQuotes, attribute.ValueStyle);
+    }
 }

+ 2 - 0
src/Mvc/Mvc.RazorPages/src/Builder/PageActionEndpointConventionBuilder.cs

@@ -23,6 +23,8 @@ public sealed class PageActionEndpointConventionBuilder : IEndpointConventionBui
         _finallyConventions = finallyConventions;
     }
 
+    internal IDictionary<string, object> Items { get; set; } = new Dictionary<string, object>();
+
     /// <summary>
     /// Adds the specified convention to the builder. Conventions are used to customize <see cref="EndpointBuilder"/> instances.
     /// </summary>

+ 53 - 0
src/Mvc/Mvc.RazorPages/src/Builder/PageActionEndpointConventionBuilderResourceCollectionExtensions.cs

@@ -0,0 +1,53 @@
+// 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;
+using Microsoft.AspNetCore.Components.Endpoints;
+using Microsoft.AspNetCore.Routing;
+using static Microsoft.AspNetCore.Builder.RazorPagesEndpointRouteBuilderExtensions;
+
+namespace Microsoft.AspNetCore.Builder;
+
+/// <summary>
+/// Extensions for <see cref="PageActionEndpointConventionBuilder"/>.
+/// </summary>
+public static class PageActionEndpointConventionBuilderResourceCollectionExtensions
+{
+    private const string ResourceCollectionResolverKey = "__ResourceCollectionResolver";
+
+    /// <summary>
+    /// Adds a <see cref="ResourceAssetCollection"/> metadata instance to the endpoints.
+    /// </summary>
+    /// <param name="builder">The <see cref="PageActionEndpointConventionBuilder"/>.</param>
+    /// <param name="manifestPath">The manifest associated with the assets.</param>
+    /// <returns></returns>
+    public static PageActionEndpointConventionBuilder WithStaticAssets(
+        this PageActionEndpointConventionBuilder builder,
+        string? manifestPath = null)
+    {
+        ArgumentNullException.ThrowIfNull(builder);
+
+        if (builder.Items.TryGetValue(EndpointRouteBuilderKey, out var endpointBuilder))
+        {
+            var (resolver, registered) = builder.Items.TryGetValue(ResourceCollectionResolverKey, out var value)
+            ? ((ResourceCollectionResolver)value, true)
+            : (new ResourceCollectionResolver((IEndpointRouteBuilder)endpointBuilder), false);
+
+            resolver.ManifestName = manifestPath;
+            if (!registered)
+            {
+                builder.Items[ResourceCollectionResolverKey] = resolver;
+                var collection = resolver.ResolveResourceCollection();
+                var importMap = resolver.ResolveImportMap();
+
+                builder.Add(endpointBuilder =>
+                {
+                    endpointBuilder.Metadata.Add(collection);
+                    endpointBuilder.Metadata.Add(importMap);
+                });
+            }
+        }
+
+        return builder;
+    }
+}

+ 8 - 1
src/Mvc/Mvc.RazorPages/src/Builder/RazorPagesEndpointRouteBuilderExtensions.cs

@@ -17,6 +17,8 @@ namespace Microsoft.AspNetCore.Builder;
 /// </summary>
 public static class RazorPagesEndpointRouteBuilderExtensions
 {
+    internal const string EndpointRouteBuilderKey = "__EndpointRouteBuilder";
+
     /// <summary>
     /// Adds endpoints for Razor Pages to the <see cref="IEndpointRouteBuilder"/>.
     /// </summary>
@@ -28,7 +30,12 @@ public static class RazorPagesEndpointRouteBuilderExtensions
 
         EnsureRazorPagesServices(endpoints);
 
-        return GetOrCreateDataSource(endpoints).DefaultBuilder;
+        var builder = GetOrCreateDataSource(endpoints).DefaultBuilder;
+        if (!builder.Items.ContainsKey(EndpointRouteBuilderKey))
+        {
+            builder.Items[EndpointRouteBuilderKey] = endpoints;
+        }
+        return builder;
     }
 
     /// <summary>

+ 5 - 1
src/Mvc/Mvc.RazorPages/src/Infrastructure/PageActionEndpointDataSourceFactory.cs

@@ -24,6 +24,10 @@ internal sealed class PageActionEndpointDataSourceFactory
 
     public PageActionEndpointDataSource Create(OrderedEndpointsSequenceProvider orderProvider)
     {
-        return new PageActionEndpointDataSource(_dataSourceIdProvider, _actions, _endpointFactory, orderProvider);
+        return new PageActionEndpointDataSource(
+            _dataSourceIdProvider,
+            _actions,
+            _endpointFactory,
+            orderProvider);
     }
 }

+ 2 - 0
src/Mvc/Mvc.RazorPages/src/PublicAPI.Unshipped.txt

@@ -1 +1,3 @@
 #nullable enable
+Microsoft.AspNetCore.Builder.PageActionEndpointConventionBuilderResourceCollectionExtensions
+static Microsoft.AspNetCore.Builder.PageActionEndpointConventionBuilderResourceCollectionExtensions.WithStaticAssets(this Microsoft.AspNetCore.Builder.PageActionEndpointConventionBuilder! builder, string? manifestPath = null) -> Microsoft.AspNetCore.Builder.PageActionEndpointConventionBuilder!

+ 287 - 0
src/Mvc/Mvc.RazorPages/test/Builder/PageActionEndpointConventionBuilderResourceCollectionExtensionsTest.cs

@@ -0,0 +1,287 @@
+// 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 Microsoft.AspNetCore.Components;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Mvc.ApplicationParts;
+using Microsoft.AspNetCore.Mvc.Razor.Compilation;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.AspNetCore.Razor.Hosting;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.FileProviders;
+
+namespace Microsoft.AspNetCore.Builder;
+
+public class PageActionEndpointConventionBuilderResourceCollectionExtensionsTest
+{
+    [Fact]
+    public void WithStaticAssets_AddsEmptyResourceCollection_ToEndpoints_NoStaticAssetsMapped()
+    {
+        // Arrange
+        var endpointBuilder = new TestEndpointRouteBuilder();
+        var builder = endpointBuilder.MapRazorPages();
+
+        // Act
+        builder.WithStaticAssets();
+
+        // Assert
+        Assert.All(endpointBuilder.DataSources.First().Endpoints, e =>
+        {
+            var metadata = e.Metadata.GetMetadata<ResourceAssetCollection>();
+            Assert.NotNull(metadata);
+            var list = Assert.IsAssignableFrom<IReadOnlyList<ResourceAsset>>(metadata);
+            Assert.Equal(0, list.Count);
+        });
+    }
+
+    [Fact]
+    public void WithStaticAssets_AddsEmptyResourceCollection_ToEndpoints_NoMatchingStaticAssetsMapped()
+    {
+        // Arrange
+        var endpointBuilder = new TestEndpointRouteBuilder();
+        endpointBuilder.MapStaticAssets("TestManifests/Test.staticwebassets.endpoints.json");
+        var builder = endpointBuilder.MapRazorPages();
+
+        // Act
+        builder.WithStaticAssets();
+
+        // Assert
+        Assert.All(endpointBuilder.DataSources.Skip(1).First().Endpoints, e =>
+        {
+            var metadata = e.Metadata.GetMetadata<ResourceAssetCollection>();
+            Assert.NotNull(metadata);
+            var list = Assert.IsAssignableFrom<IReadOnlyList<ResourceAsset>>(metadata);
+            Assert.Equal(0, list.Count);
+        });
+    }
+
+    [Fact]
+    public void WithStaticAssets_AddsResourceCollection_ToEndpoints_NamedManifest()
+    {
+        // Arrange
+        var endpointBuilder = new TestEndpointRouteBuilder();
+        endpointBuilder.MapStaticAssets("TestManifests/Test.staticwebassets.endpoints.json");
+        var builder = endpointBuilder.MapRazorPages();
+
+        // Act
+        builder.WithStaticAssets("TestManifests/Test.staticwebassets.endpoints.json");
+
+        // Assert
+        Assert.All(endpointBuilder.DataSources.Skip(1).First().Endpoints, e =>
+        {
+            var metadata = e.Metadata.GetMetadata<ResourceAssetCollection>();
+            Assert.NotNull(metadata);
+            var list = Assert.IsAssignableFrom<IReadOnlyList<ResourceAsset>>(metadata);
+            Assert.Equal(1, list.Count);
+            Assert.Equal("named.css", list[0].Url);
+        });
+    }
+
+    [Fact]
+    public void WithStaticAssets_AddsResourceCollection_ToEndpoints_DefaultManifest()
+    {
+        // Arrange
+        var endpointBuilder = new TestEndpointRouteBuilder();
+        endpointBuilder.MapStaticAssets();
+        var builder = endpointBuilder.MapRazorPages();
+
+        // Act
+        builder.WithStaticAssets();
+
+        // Assert
+        Assert.All(endpointBuilder.DataSources.Skip(1).First().Endpoints, e =>
+        {
+            var metadata = e.Metadata.GetMetadata<ResourceAssetCollection>();
+            Assert.NotNull(metadata);
+            var list = Assert.IsAssignableFrom<IReadOnlyList<ResourceAsset>>(metadata);
+            Assert.Equal(1, list.Count);
+            Assert.Equal("default.css", list[0].Url);
+        });
+    }
+
+    [Fact]
+    public void WithStaticAssets_AddsDefaultResourceCollectionToEndpoints_WhenNoManifestProvided_EvenIfManyAvailable()
+    {
+        // Arrange
+        var endpointBuilder = new TestEndpointRouteBuilder();
+        endpointBuilder.MapStaticAssets();
+        endpointBuilder.MapStaticAssets("TestManifests/Test.staticwebassets.endpoints.json");
+        var builder = endpointBuilder.MapRazorPages();
+
+        // Act
+        builder.WithStaticAssets();
+
+        // Assert
+        Assert.All(endpointBuilder.DataSources.Skip(2).First().Endpoints, e =>
+        {
+            var metadata = e.Metadata.GetMetadata<ResourceAssetCollection>();
+            Assert.NotNull(metadata);
+            var list = Assert.IsAssignableFrom<IReadOnlyList<ResourceAsset>>(metadata);
+            Assert.Equal(1, list.Count);
+            Assert.Equal("default.css", list[0].Url);
+        });
+    }
+
+    [Fact]
+    public void WithStaticAssets_AddsMatchingResourceCollectionToEndpoints_WhenExplicitManifestProvided_EvenIfManyAvailable()
+    {
+        // Arrange
+        var endpointBuilder = new TestEndpointRouteBuilder();
+        endpointBuilder.MapStaticAssets();
+        endpointBuilder.MapStaticAssets("TestManifests/Test.staticwebassets.endpoints.json");
+        var builder = endpointBuilder.MapRazorPages();
+
+        // Act
+        builder.WithStaticAssets("TestManifests/Test.staticwebassets.endpoints.json");
+
+        // Assert
+        Assert.All(endpointBuilder.DataSources.Skip(2).First().Endpoints, e =>
+        {
+            var metadata = e.Metadata.GetMetadata<ResourceAssetCollection>();
+            Assert.NotNull(metadata);
+            var list = Assert.IsAssignableFrom<IReadOnlyList<ResourceAsset>>(metadata);
+            Assert.Equal(1, list.Count);
+            Assert.Equal("named.css", list[0].Url);
+        });
+    }
+
+    [Fact]
+    public void WithStaticAssets_AddsCollectionFromGroup_WhenMappedInsideAnEndpointGroup()
+    {
+        // Arrange
+        var endpointBuilder = new TestEndpointRouteBuilder();
+        endpointBuilder.MapStaticAssets();
+
+        var group = endpointBuilder.MapGroup("/group");
+        group.MapStaticAssets("TestManifests/Test.staticwebassets.endpoints.json");
+        var builder = group.MapRazorPages();
+
+        // Act
+        builder.WithStaticAssets("TestManifests/Test.staticwebassets.endpoints.json");
+
+        // Assert
+        var groupEndpoints = Assert.IsAssignableFrom<IEndpointRouteBuilder>(group).DataSources;
+        Assert.All(groupEndpoints.Skip(1).First().Endpoints, e =>
+        {
+            var metadata = e.Metadata.GetMetadata<ResourceAssetCollection>();
+            Assert.NotNull(metadata);
+            var list = Assert.IsAssignableFrom<IReadOnlyList<ResourceAsset>>(metadata);
+            Assert.Equal(1, list.Count);
+            Assert.Equal("named.css", list[0].Url);
+        });
+    }
+
+    [Fact]
+    public void WithStaticAssets_AddsEmptyCollectionFromGroup_WhenMappingNotFound_InsideGroup()
+    {
+        // Arrange
+        var endpointBuilder = new TestEndpointRouteBuilder();
+        endpointBuilder.MapStaticAssets();
+
+        var group = endpointBuilder.MapGroup("/group");
+        group.MapStaticAssets("TestManifests/Test.staticwebassets.endpoints.json");
+        var builder = group.MapRazorPages();
+
+        // Act
+        builder.WithStaticAssets();
+
+        // Assert
+        var groupEndpoints = Assert.IsAssignableFrom<IEndpointRouteBuilder>(group).DataSources;
+        Assert.All(groupEndpoints.Skip(1).First().Endpoints, e =>
+        {
+            var metadata = e.Metadata.GetMetadata<ResourceAssetCollection>();
+            Assert.NotNull(metadata);
+            var list = Assert.IsAssignableFrom<IReadOnlyList<ResourceAsset>>(metadata);
+            Assert.Equal(0, list.Count);
+        });
+    }
+
+    private class TestEndpointRouteBuilder : IEndpointRouteBuilder
+    {
+        private readonly ApplicationBuilder _applicationBuilder;
+
+        public TestEndpointRouteBuilder()
+        {
+            _applicationBuilder = new ApplicationBuilder(ServiceProvider);
+        }
+
+        public IServiceProvider ServiceProvider { get; } = CreateServiceProvider();
+
+        private static IServiceProvider CreateServiceProvider()
+        {
+            var collection = new ServiceCollection();
+            collection.AddSingleton<IConfiguration>(new ConfigurationBuilder().Build());
+            collection.AddSingleton<IWebHostEnvironment>(new TestWebHostEnvironment());
+            collection.AddSingleton(new ApplicationPartManager());
+            collection.AddSingleton(new DiagnosticListener("Microsoft.AspNetCore"));
+            collection.AddSingleton<DiagnosticSource>(new TestDiagnosticSource());
+            collection.AddLogging();
+            collection.AddOptions();
+            collection.AddMvc()
+                .ConfigureApplicationPartManager(apm =>
+                {
+                    apm.FeatureProviders.Clear();
+                    apm.FeatureProviders.Add(new TestRazorPagesFeatureProvider());
+                });
+            return collection.BuildServiceProvider();
+        }
+
+        public ICollection<EndpointDataSource> DataSources { get; } = [];
+
+        public IApplicationBuilder CreateApplicationBuilder()
+        {
+            return _applicationBuilder.New();
+        }
+
+        private class TestRazorPagesFeatureProvider : IApplicationFeatureProvider<ViewsFeature>
+        {
+            public void PopulateFeature(IEnumerable<ApplicationPart> parts, ViewsFeature feature)
+            {
+                feature.ViewDescriptors.Clear();
+                feature.ViewDescriptors.Add(new CompiledViewDescriptor(TestRazorCompiledItem.CreateForPage(typeof(Index), "/Pages/Index.cshtml")));
+            }
+        }
+
+        [Route("/")]
+        private class Index : PageBase
+        {
+            public object Model { get; set; }
+
+            public override Task ExecuteAsync()
+            {
+                throw new NotImplementedException();
+            }
+        }
+
+        private class TestWebHostEnvironment : IWebHostEnvironment
+        {
+            public string ApplicationName { get; set; } = "TestApplication";
+            public string EnvironmentName { get; set; } = "TestEnvironment";
+            public string WebRootPath { get; set; } = "";
+            public IFileProvider WebRootFileProvider { get => ContentRootFileProvider; set { } }
+            public string ContentRootPath { get; set; } = Directory.GetCurrentDirectory();
+            public IFileProvider ContentRootFileProvider { get; set; } = CreateTestFileProvider();
+
+            private static TestFileProvider CreateTestFileProvider()
+            {
+                var provider = new TestFileProvider();
+                provider.AddFile("site.css", "body { color: red; }");
+                return provider;
+            }
+        }
+
+        private class TestDiagnosticSource : DiagnosticSource
+        {
+            public override bool IsEnabled(string name)
+            {
+                return false;
+            }
+
+            public override void Write(string name, object value) { }
+        }
+    }
+}
+

+ 7 - 1
src/Mvc/Mvc.RazorPages/test/Microsoft.AspNetCore.Mvc.RazorPages.Test.csproj

@@ -1,4 +1,4 @@
-<Project Sdk="Microsoft.NET.Sdk">
+<Project Sdk="Microsoft.NET.Sdk">
 
   <PropertyGroup>
     <TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework>
@@ -8,8 +8,14 @@
     <Compile Include="..\..\Mvc.Core\test\Routing\ActionEndpointDataSourceBaseTest.cs" Link="Infrastructure\ActionEndpointDataSourceBaseTest.cs" />
   </ItemGroup>
 
+  <ItemGroup>
+    <Content Include="TestManifests\*.json" CopyToOutputDirectory="Always" />
+    <Content Include="TestApplication.staticwebassets.endpoints.json" CopyToOutputDirectory="Always" />
+  </ItemGroup>
+
   <ItemGroup>
     <Reference Include="Microsoft.AspNetCore.Mvc.RazorPages" />
+    <Reference Include="Microsoft.AspNetCore.Mvc" />
     <ProjectReference Include="..\..\shared\Mvc.Views.TestCommon\Microsoft.AspNetCore.Mvc.Views.TestCommon.csproj" />
     <ProjectReference Include="..\..\shared\Mvc.TestDiagnosticListener\Microsoft.AspNetCore.Mvc.TestDiagnosticListener.csproj" />
   </ItemGroup>

+ 17 - 0
src/Mvc/Mvc.RazorPages/test/TestApplication.staticwebassets.endpoints.json

@@ -0,0 +1,17 @@
+{
+  "Version": 1,
+  "Endpoints": [
+    {
+      "Route": "default.css",
+      "AssetFile": "default.css",
+      "Selectors": [],
+      "EndpointProperties": [],
+      "ResponseHeaders": [
+        {
+          "Name": "ETag",
+          "Value": "\"Fake\""
+        }
+      ]
+    }
+  ]
+}

+ 17 - 0
src/Mvc/Mvc.RazorPages/test/TestManifests/Test.staticwebassets.endpoints.json

@@ -0,0 +1,17 @@
+{
+  "Version": 1,
+  "Endpoints": [
+    {
+      "Route": "named.css",
+      "AssetFile": "named.css",
+      "Selectors": [],
+      "EndpointProperties": [],
+      "ResponseHeaders": [
+        {
+          "Name": "ETag",
+          "Value": "\"Fake\""
+        }
+      ]
+    }
+  ]
+}

+ 22 - 1
src/Mvc/Mvc.TagHelpers/src/ImageTagHelper.cs

@@ -123,8 +123,9 @@ public class ImageTagHelper : UrlResolutionTagHelper
             // pipeline have touched the value. If the value is already encoded this ImageTagHelper may
             // not function properly.
             Src = output.Attributes[SrcAttributeName].Value as string;
+            var src = GetVersionedResourceUrl(Src);
 
-            output.Attributes.SetAttribute(SrcAttributeName, FileVersionProvider.AddFileVersionToPath(ViewContext.HttpContext.Request.PathBase, Src));
+            output.Attributes.SetAttribute(SrcAttributeName, src);
         }
     }
 
@@ -135,4 +136,24 @@ public class ImageTagHelper : UrlResolutionTagHelper
             FileVersionProvider = ViewContext.HttpContext.RequestServices.GetRequiredService<IFileVersionProvider>();
         }
     }
+
+    private string GetVersionedResourceUrl(string url)
+    {
+        if (AppendVersion == true)
+        {
+            var pathBase = ViewContext.HttpContext.Request.PathBase;
+            if (ResourceCollectionUtilities.TryResolveFromAssetCollection(ViewContext, url, out var resolvedUrl))
+            {
+                url = resolvedUrl;
+                return url;
+            }
+
+            if (url != null)
+            {
+                url = FileVersionProvider.AddFileVersionToPath(pathBase, url);
+            }
+        }
+
+        return url;
+    }
 }

+ 25 - 7
src/Mvc/Mvc.TagHelpers/src/LinkTagHelper.cs

@@ -276,11 +276,12 @@ public class LinkTagHelper : UrlResolutionTagHelper
 
             if (Href != null)
             {
+                var href = GetVersionedResourceUrl(Href);
                 var index = output.Attributes.IndexOfName(HrefAttributeName);
                 var existingAttribute = output.Attributes[index];
                 output.Attributes[index] = new TagHelperAttribute(
                     existingAttribute.Name,
-                    FileVersionProvider.AddFileVersionToPath(ViewContext.HttpContext.Request.PathBase, Href),
+                    href,
                     existingAttribute.ValueStyle);
             }
         }
@@ -452,7 +453,7 @@ public class LinkTagHelper : UrlResolutionTagHelper
             var valueToWrite = fallbackHrefs[i];
             if (AppendVersion == true)
             {
-                valueToWrite = FileVersionProvider.AddFileVersionToPath(ViewContext.HttpContext.Request.PathBase, fallbackHrefs[i]);
+                valueToWrite = GetVersionedResourceUrl(fallbackHrefs[i]);
             }
 
             // Must HTML-encode the href attribute value to ensure the written <link/> element is valid. Must also
@@ -520,11 +521,7 @@ public class LinkTagHelper : UrlResolutionTagHelper
 
     private void AppendVersionedHref(string hrefName, string hrefValue, TagHelperContent builder)
     {
-        if (AppendVersion == true)
-        {
-            hrefValue = FileVersionProvider.AddFileVersionToPath(ViewContext.HttpContext.Request.PathBase, hrefValue);
-        }
-
+        hrefValue = GetVersionedResourceUrl(hrefValue);
         builder
             .AppendHtml(hrefName)
             .AppendHtml("=\"")
@@ -532,6 +529,27 @@ public class LinkTagHelper : UrlResolutionTagHelper
             .AppendHtml("\" ");
     }
 
+    private string GetVersionedResourceUrl(string url)
+    {
+        if (AppendVersion == true)
+        {
+            var pathBase = ViewContext.HttpContext.Request.PathBase;
+
+            if (ResourceCollectionUtilities.TryResolveFromAssetCollection(ViewContext, url, out var resolvedUrl))
+            {
+                url = resolvedUrl;
+                return url;
+            }
+
+            if (url != null)
+            {
+                url = FileVersionProvider.AddFileVersionToPath(pathBase, url);
+            }
+        }
+
+        return url;
+    }
+
     private enum Mode
     {
         /// <summary>

+ 4 - 0
src/Mvc/Mvc.TagHelpers/src/PublicAPI.Unshipped.txt

@@ -1 +1,5 @@
 #nullable enable
+~Microsoft.AspNetCore.Mvc.TagHelpers.ScriptTagHelper.ImportMap.get -> Microsoft.AspNetCore.Components.ImportMapDefinition
+~Microsoft.AspNetCore.Mvc.TagHelpers.ScriptTagHelper.ImportMap.set -> void
+~Microsoft.AspNetCore.Mvc.TagHelpers.ScriptTagHelper.Type.get -> string
+~Microsoft.AspNetCore.Mvc.TagHelpers.ScriptTagHelper.Type.set -> void

+ 62 - 0
src/Mvc/Mvc.TagHelpers/src/ResourceCollectionUtilities.cs

@@ -0,0 +1,62 @@
+// 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;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc.Rendering;
+
+namespace Microsoft.AspNetCore.Mvc.TagHelpers;
+
+internal static class ResourceCollectionUtilities
+{
+    internal static bool TryResolveFromAssetCollection(ViewContext viewContext, string url, out string resolvedUrl)
+    {
+        var pathBase = viewContext.HttpContext.Request.PathBase;
+        var assetCollection = viewContext.HttpContext.GetEndpoint()?.Metadata.GetMetadata<ResourceAssetCollection>();
+        if (assetCollection != null)
+        {
+            var value = url.StartsWith('/') ? url[1..] : url;
+            if (assetCollection.IsContentSpecificUrl(value))
+            {
+                resolvedUrl = url;
+                return true;
+            }
+
+            var src = assetCollection[value];
+            if (!string.Equals(src, value, StringComparison.Ordinal))
+            {
+                resolvedUrl = url.StartsWith('/') ? $"/{src}" : src;
+                return true;
+            }
+
+            if (pathBase.HasValue && url.StartsWith(pathBase, StringComparison.OrdinalIgnoreCase))
+            {
+                var length = pathBase.Value.EndsWith('/') ? pathBase.Value.Length : pathBase.Value.Length + 1;
+                var relativePath = url[length..];
+                if (assetCollection.IsContentSpecificUrl(relativePath))
+                {
+                    resolvedUrl = url;
+                    return true;
+                }
+
+                src = assetCollection[relativePath];
+                if (!string.Equals(src, relativePath, StringComparison.Ordinal))
+                {
+                    if (pathBase.Value.EndsWith('/'))
+                    {
+                        resolvedUrl = $"{pathBase}{src}";
+                        return true;
+                    }
+                    else
+                    {
+                        resolvedUrl = $"{pathBase}/{src}";
+                        return true;
+                    }
+                }
+            }
+        }
+
+        resolvedUrl = null;
+        return false;
+    }
+}

+ 55 - 3
src/Mvc/Mvc.TagHelpers/src/ScriptTagHelper.cs

@@ -3,8 +3,10 @@
 
 using System.Diagnostics;
 using System.Text.Encodings.Web;
+using Microsoft.AspNetCore.Components;
 using Microsoft.AspNetCore.Hosting;
 using Microsoft.AspNetCore.Html;
+using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Mvc.Razor.Infrastructure;
 using Microsoft.AspNetCore.Mvc.Razor.TagHelpers;
 using Microsoft.AspNetCore.Mvc.Routing;
@@ -28,6 +30,8 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers;
 [HtmlTargetElement("script", Attributes = FallbackSrcExcludeAttributeName)]
 [HtmlTargetElement("script", Attributes = FallbackTestExpressionAttributeName)]
 [HtmlTargetElement("script", Attributes = AppendVersionAttributeName)]
+[HtmlTargetElement("script", Attributes = TypeAttributeName)]
+[HtmlTargetElement("script", Attributes = ImportMapAttributeName)]
 public class ScriptTagHelper : UrlResolutionTagHelper
 {
     private const string SrcIncludeAttributeName = "asp-src-include";
@@ -40,6 +44,9 @@ public class ScriptTagHelper : UrlResolutionTagHelper
     private const string SrcAttributeName = "src";
     private const string IntegrityAttributeName = "integrity";
     private const string AppendVersionAttributeName = "asp-append-version";
+    private const string TypeAttributeName = "type";
+    private const string ImportMapAttributeName = "asp-importmap";
+
     private static readonly Func<Mode, Mode, int> Compare = (a, b) => a - b;
     private StringWriter _stringWriter;
 
@@ -115,6 +122,12 @@ public class ScriptTagHelper : UrlResolutionTagHelper
     [HtmlAttributeName(SrcAttributeName)]
     public string Src { get; set; }
 
+    /// <summary>
+    /// Type of the script.
+    /// </summary>
+    [HtmlAttributeName(TypeAttributeName)]
+    public string Type { get; set; }
+
     /// <summary>
     /// A comma separated list of globbed file patterns of JavaScript scripts to load.
     /// The glob patterns are assessed relative to the application's 'webroot' setting.
@@ -174,6 +187,16 @@ public class ScriptTagHelper : UrlResolutionTagHelper
     [HtmlAttributeName(FallbackTestExpressionAttributeName)]
     public string FallbackTestExpression { get; set; }
 
+    /// <summary>
+    /// The <see cref="ImportMapDefinition"/> to use for the document.
+    /// </summary>
+    /// <remarks>
+    /// If this is not set and the type value is "importmap",
+    /// the import map will be retrieved by default from the current <see cref="Endpoint.Metadata"/>.
+    /// </remarks>
+    [HtmlAttributeName(ImportMapAttributeName)]
+    public ImportMapDefinition ImportMap { get; set; }
+
     /// <summary>
     /// Gets the <see cref="IWebHostEnvironment"/> for the application.
     /// </summary>
@@ -217,6 +240,25 @@ public class ScriptTagHelper : UrlResolutionTagHelper
         ArgumentNullException.ThrowIfNull(context);
         ArgumentNullException.ThrowIfNull(output);
 
+        if (string.Equals(Type, "importmap", StringComparison.OrdinalIgnoreCase))
+        {
+            // This is an importmap script, we'll write out the import map and
+            // stop processing.
+            var importMap = ImportMap ?? ViewContext.HttpContext.GetEndpoint()?.Metadata.GetMetadata<ImportMapDefinition>();
+            if (importMap == null)
+            {
+                // No importmap found, nothing to do.
+                output.SuppressOutput();
+                return;
+            }
+
+            output.TagName = "script";
+            output.TagMode = TagMode.StartTagAndEndTag;
+            output.Attributes.SetAttribute("type", "importmap");
+            output.Content.SetHtmlContent(importMap.ToString());
+            return;
+        }
+
         // Pass through attribute that is also a well-known HTML attribute.
         if (Src != null)
         {
@@ -240,14 +282,14 @@ public class ScriptTagHelper : UrlResolutionTagHelper
         if (AppendVersion == true)
         {
             EnsureFileVersionProvider();
-
+            var versionedSrc = GetVersionedSrc(Src);
             if (Src != null)
             {
                 var index = output.Attributes.IndexOfName(SrcAttributeName);
                 var existingAttribute = output.Attributes[index];
                 output.Attributes[index] = new TagHelperAttribute(
                     existingAttribute.Name,
-                    FileVersionProvider.AddFileVersionToPath(ViewContext.HttpContext.Request.PathBase, Src),
+                    versionedSrc,
                     existingAttribute.ValueStyle);
             }
         }
@@ -366,7 +408,17 @@ public class ScriptTagHelper : UrlResolutionTagHelper
     {
         if (AppendVersion == true)
         {
-            srcValue = FileVersionProvider.AddFileVersionToPath(ViewContext.HttpContext.Request.PathBase, srcValue);
+            var pathBase = ViewContext.HttpContext.Request.PathBase;
+            if (ResourceCollectionUtilities.TryResolveFromAssetCollection(ViewContext, srcValue, out var resolvedUrl))
+            {
+                srcValue = resolvedUrl;
+                return srcValue;
+            }
+
+            if (srcValue != null)
+            {
+                srcValue = FileVersionProvider.AddFileVersionToPath(pathBase, srcValue);
+            }
         }
 
         return srcValue;

Некоторые файлы не были показаны из-за большого количества измененных файлов