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 10 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// 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)
{
if (assets != null)
{
var headers = new List<(string? Group, int Order, string Value)>();
foreach (var asset in assets)
{
if (asset.Properties == null)
{
continue;
}

// Use preloadgroup=webassembly to identify assets that should to 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 = new StringBuilder();
header.Append('<');
header.Append(asset.Url);
header.Append('>');

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

if (header != null)
{
headers.Add((group, order, header.ToString()));
}
}

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the order here "override" the fetch priority?

Also, rather than starting with a list, could we start already with a Dictionary<string, List> or something like that?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The idea is that within the same fetch priority, you can define the order, as throttling may apply

}
}
}

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