Skip to content

[Blazor] Generate Link headers based on StaticWebAssets manifest properties #61166

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Apr 3, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ public void ApplyConvention(EndpointBuilder eb)
if (_collection != null && _collectionImportMap != null)
{
eb.Metadata.Add(_collection);
eb.Metadata.Add(new ResourcePreloadCollection(_collection));

if (_collectionUrl != null)
{
Expand Down
100 changes: 100 additions & 0 deletions src/Components/Endpoints/src/Builder/ResourcePreloadCollection.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// 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;
using Microsoft.Extensions.Primitives;

namespace Microsoft.AspNetCore.Components.Endpoints;

internal class ResourcePreloadCollection
{
private readonly Dictionary<string, StringValues> _storage = new();

public ResourcePreloadCollection(ResourceAssetCollection assets)
{
var headerBuilder = new StringBuilder();
var headers = new Dictionary<string, List<(int Order, string Value)>>();
foreach (var asset in assets)
{
if (asset.Properties == null)
{
continue;
}

// Use preloadgroup property to identify assets that should be preloaded
string? group = null;
foreach (var property in asset.Properties)
{
if (property.Name.Equals("preloadgroup", StringComparison.OrdinalIgnoreCase))
{
group = property.Value ?? string.Empty;
break;
}
}

if (group == null)
{
continue;
}

var header = CreateHeader(headerBuilder, asset.Url, asset.Properties);
if (!headers.TryGetValue(group, out var groupHeaders))
{
groupHeaders = headers[group] = new List<(int Order, string Value)>();
}

groupHeaders.Add(header);
}

foreach (var group in headers)
{
_storage[group.Key ?? string.Empty] = group.Value.OrderBy(h => h.Order).Select(h => h.Value).ToArray();
}
}

private static (int order, string header) CreateHeader(StringBuilder headerBuilder, string url, IEnumerable<ResourceAssetProperty> properties)
{
headerBuilder.Clear();
headerBuilder.Append('<');
headerBuilder.Append(url);
headerBuilder.Append('>');

int order = 0;
foreach (var property in properties)
{
if (property.Name.Equals("preloadrel", StringComparison.OrdinalIgnoreCase))
{
headerBuilder.Append("; rel=").Append(property.Value);
}
else if (property.Name.Equals("preloadas", StringComparison.OrdinalIgnoreCase))
{
headerBuilder.Append("; as=").Append(property.Value);
}
else if (property.Name.Equals("preloadpriority", StringComparison.OrdinalIgnoreCase))
{
headerBuilder.Append("; fetchpriority=").Append(property.Value);
}
else if (property.Name.Equals("preloadcrossorigin", StringComparison.OrdinalIgnoreCase))
{
headerBuilder.Append("; crossorigin=").Append(property.Value);
}
else if (property.Name.Equals("integrity", StringComparison.OrdinalIgnoreCase))
{
headerBuilder.Append("; integrity=\"").Append(property.Value).Append('"');
}
else if (property.Name.Equals("preloadorder", StringComparison.OrdinalIgnoreCase))
{
if (!int.TryParse(property.Value, out order))
{
order = 0;
}
}
}

return (order, headerBuilder.ToString());
}

public bool TryGetLinkHeaders(string group, out StringValues linkHeaders)
=> _storage.TryGetValue(group, out linkHeaders);
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Primitives;

namespace Microsoft.AspNetCore.Components.Endpoints;

Expand Down Expand Up @@ -275,6 +276,12 @@ private void WriteComponentHtml(int componentId, TextWriter output, bool allowBo
{
if (_httpContext.RequestServices.GetRequiredService<WebAssemblySettingsEmitter>().TryGetSettingsOnce(out var settings))
{
if (marker.Type is ComponentMarker.WebAssemblyMarkerType)
{
// Preload WebAssembly assets when using WebAssembly (not Auto) mode
AppendWebAssemblyPreloadHeaders();
}

var settingsJson = JsonSerializer.Serialize(settings, ServerComponentSerializationSettings.JsonSerializationOptions);
output.Write($"<!--Blazor-WebAssembly:{settingsJson}-->");
}
Expand Down Expand Up @@ -311,6 +318,15 @@ private void WriteComponentHtml(int componentId, TextWriter output, bool allowBo
}
}

private void AppendWebAssemblyPreloadHeaders()
{
var preloads = _httpContext.GetEndpoint()?.Metadata.GetMetadata<ResourcePreloadCollection>();
if (preloads != null && preloads.TryGetLinkHeaders("webassembly", out var linkHeaders))
{
_httpContext.Response.Headers.Link = StringValues.Concat(_httpContext.Response.Headers.Link, linkHeaders);
}
}

private static bool IsProgressivelyEnhancedNavigation(HttpRequest request)
{
// For enhanced nav, the Blazor JS code controls the "accept" header precisely, so we can be very specific about the format
Expand Down
73 changes: 72 additions & 1 deletion src/Components/Endpoints/test/EndpointHtmlRendererTest.cs
Original file line number Diff line number Diff line change
@@ -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.Net.Http;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.RegularExpressions;
Expand Down Expand Up @@ -71,6 +70,78 @@ public async Task CanRender_ParameterlessComponent_ClientMode()
Assert.Empty(httpContext.Items);
}

[Fact]
public async Task CanPreload_WebAssembly_ResourceAssets()
{
// Arrange
var httpContext = GetHttpContext();
var writer = new StringWriter();

httpContext.SetEndpoint(
new Endpoint(
ctx => Task.CompletedTask,
new EndpointMetadataCollection([
new ResourcePreloadCollection(
new ResourceAssetCollection([
new ResourceAsset("second.js", [
new ResourceAssetProperty("preloadrel", "preload"),
new ResourceAssetProperty("preloadas", "script"),
new ResourceAssetProperty("preloadpriority", "high"),
new ResourceAssetProperty("preloadcrossorigin", "anonymous"),
new ResourceAssetProperty("integrity", "abcd"),
new ResourceAssetProperty("preloadorder", "2"),
new ResourceAssetProperty("preloadgroup", "webassembly")
]),
new ResourceAsset("first.js", [
new ResourceAssetProperty("preloadrel", "preload"),
new ResourceAssetProperty("preloadas", "script"),
new ResourceAssetProperty("preloadpriority", "high"),
new ResourceAssetProperty("preloadcrossorigin", "anonymous"),
new ResourceAssetProperty("integrity", "abcd"),
new ResourceAssetProperty("preloadorder", "1"),
new ResourceAssetProperty("preloadgroup", "webassembly")
]),
new ResourceAsset("preload-nowebassembly.js", [
new ResourceAssetProperty("preloadrel", "preload"),
new ResourceAssetProperty("preloadas", "script"),
new ResourceAssetProperty("preloadpriority", "high"),
new ResourceAssetProperty("preloadcrossorigin", "anonymous"),
new ResourceAssetProperty("integrity", "abcd"),
new ResourceAssetProperty("preloadorder", "1"),
new ResourceAssetProperty("preloadgroup", "abcd")
]),
new ResourceAsset("nopreload.js", [
new ResourceAssetProperty("integrity", "abcd")
])
])
)
]),
"TestEndpoint"
)
);

// Act
var result = await renderer.PrerenderComponentAsync(httpContext, typeof(SimpleComponent), new InteractiveWebAssemblyRenderMode(prerender: false), ParameterView.Empty);
await renderer.Dispatcher.InvokeAsync(() => result.WriteTo(writer, HtmlEncoder.Default));

// Assert
Assert.Equal(2, httpContext.Response.Headers.Link.Count);

var firstPreloadLink = httpContext.Response.Headers.Link[0];
Assert.Contains("<first.js>", firstPreloadLink);
Assert.Contains("rel=preload", firstPreloadLink);
Assert.Contains("as=script", firstPreloadLink);
Assert.Contains("fetchpriority=high", firstPreloadLink);
Assert.Contains("integrity=\"abcd\"", firstPreloadLink);

var secondPreloadLink = httpContext.Response.Headers.Link[1];
Assert.Contains("<second.js>", secondPreloadLink);
Assert.Contains("rel=preload", secondPreloadLink);
Assert.Contains("as=script", secondPreloadLink);
Assert.Contains("fetchpriority=high", secondPreloadLink);
Assert.Contains("integrity=\"abcd\"", secondPreloadLink);
}

[Fact]
public async Task CanPrerender_ParameterlessComponent_ClientMode()
{
Expand Down
83 changes: 80 additions & 3 deletions src/Shared/Components/ResourceCollectionResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,21 @@ public ResourceAssetCollection ResolveResourceCollection()
#if !MVC_VIEWFEATURES
string? label = null;
string? integrity = null;
string? preloadRel = null;
string? preloadAs = null;
string? preloadPriority = null;
string? preloadCrossorigin = null;
string? preloadOrder = null;
string? preloadGroup = null;
#else
string label = null;
string integrity = null;
string preloadRel = null;
string preloadAs = null;
string preloadPriority = null;
string preloadCrossorigin = null;
string preloadOrder = null;
string preloadGroup = null;
#endif

// If there's a selector this means that this is an alternative representation for a resource, so skip it.
Expand All @@ -59,15 +71,44 @@ public ResourceAssetCollection ResolveResourceCollection()
label = property.Value;
foundProperties++;
}

else if (property.Name.Equals("integrity", StringComparison.OrdinalIgnoreCase))
{
integrity = property.Value;
foundProperties++;
}
else if (property.Name.Equals("preloadrel", StringComparison.OrdinalIgnoreCase))
{
preloadRel = property.Value;
foundProperties++;
}
else if (property.Name.Equals("preloadas", StringComparison.OrdinalIgnoreCase))
{
preloadAs = property.Value;
foundProperties++;
}
else if (property.Name.Equals("preloadpriority", StringComparison.OrdinalIgnoreCase))
{
preloadPriority = property.Value;
foundProperties++;
}
else if (property.Name.Equals("preloadcrossorigin", StringComparison.OrdinalIgnoreCase))
{
preloadCrossorigin = property.Value;
foundProperties++;
}
else if (property.Name.Equals("preloadorder", StringComparison.OrdinalIgnoreCase))
{
preloadOrder = property.Value;
foundProperties++;
}
else if (property.Name.Equals("preloadgroup", StringComparison.OrdinalIgnoreCase))
{
preloadGroup = property.Value;
foundProperties++;
}
}

AddResource(resources, descriptor, label, integrity, foundProperties);
AddResource(resources, descriptor, label, integrity, preloadRel, preloadAs, preloadPriority, preloadCrossorigin, preloadOrder, preloadGroup, foundProperties);
}
}

Expand Down Expand Up @@ -97,11 +138,23 @@ private static void AddResource(
#if !MVC_VIEWFEATURES
string? label,
string? integrity,
string? preloadRel,
string? preloadAs,
string? preloadPriority,
string? preloadCrossorigin,
string? preloadOrder,
string? preloadGroup,
#else
string label,
string integrity,
string preloadRel,
string preloadAs,
string preloadPriority,
string preloadCrossorigin,
string preloadOrder,
string preloadGroup,
#endif
int foundProperties)
int foundProperties)
{
if (label != null || integrity != null)
{
Expand All @@ -115,6 +168,30 @@ private static void AddResource(
{
properties[index++] = new ResourceAssetProperty("integrity", integrity);
}
if (preloadRel != null)
{
properties[index++] = new ResourceAssetProperty("preloadrel", preloadRel);
}
if (preloadAs != null)
{
properties[index++] = new ResourceAssetProperty("preloadas", preloadAs);
}
if (preloadPriority != null)
{
properties[index++] = new ResourceAssetProperty("preloadpriority", preloadPriority);
}
if (preloadCrossorigin != null)
{
properties[index++] = new ResourceAssetProperty("preloadcrossorigin", preloadCrossorigin);
}
if (preloadOrder != null)
{
properties[index++] = new ResourceAssetProperty("preloadorder", preloadOrder);
}
if (preloadGroup != null)
{
properties[index++] = new ResourceAssetProperty("preloadgroup", preloadGroup);
}

resources.Add(new ResourceAsset(descriptor.Route, properties));
}
Expand Down
Loading