Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

[Blazor] Runtime APIs to support fingerprinting #56076

Closed
javiercn opened this issue Jun 4, 2024 · 6 comments · Fixed by #56045
Closed

[Blazor] Runtime APIs to support fingerprinting #56076

javiercn opened this issue Jun 4, 2024 · 6 comments · Fixed by #56045
Assignees
Labels
api-ready-for-review API is ready for formal API review - https://github.com/dotnet/apireviews area-blazor Includes: Blazor, Razor Components
Milestone

Comments

@javiercn
Copy link
Member

javiercn commented Jun 4, 2024

Description

These set of APIs allow Blazor, MVC and Razor pages to map well-known URLs to content-specific (fingerprinted URLs).

Scenarios

Startup/Program APIs

The list of assets is defined on a per-endpoint basis. MVC and RazorPages opt-in to the feature via a call to WithResourceCollection following the call to MapRazorPages() or MapControllers().

This call adds a ResourceAssetCollection to the Metadata collection of the endpoints, which contains the mapping between human-readable URLs and content-specific URLs.

From there, other components can retrieve the mapping from endpoint metadata.

app.MapStaticAssets();

app.MapRazorPages()
+   .WithResourceCollection();

Since we can have calls to map different manifests, WithResourceCollection takes a parameter to provide the Id for the matching MatchStaticAssets.

app.MapStaticAssets("WebassemblyApp1.Client");
app.MapStaticAssets("WebassemblyApp2.Client");

app.MapRazorPages()
+   .WithResourceCollection("WebassemblyApp1.Client");

Consuming the mapped assets

The assets are consumed indirectly from MVC and Razor Pages via existing Script, Image, Link, and Url tag helpers.

In Blazor, the assets are consumed via the Assets property in ComponentBase which exposes an indexer to resolve the fingerprinted url for a given asset.

<link rel="stylesheet" href="@Assets["app.css"]" />
<link rel="stylesheet" href="@Assets["BlazorWeb-CSharp.styles.css"]" />

This in the future will be cleaner with a compiler feature similar to the Url tag helper in MVC that transforms "~/app.css" into the above code inside the href attribute.

In addition to this, there are two built-in components to generate an importmap for scripts. See here for details.

TL;DR: Creates a mapping for calls to import and script type="module" that optionally includes integrity information. A sample:

<script type="importmap">
  {
    "imports": {
      "square": "./module/shapes/square.js"
    },
    "integrity": {
      "./module/shapes/square.js": "sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
    }
  }
</script>

There is an ImportMap blazor component

<ImportMap />

and in MVC we extended the script tag helper to support it

<script type="importmap"></script>

Layering

I included this section to explain the reasons for some of the APIs below.

flowchart LR
    subgraph AspNet[ASP.NET Framework reference]
        StaticAssets[Static Assets]
        Endpoints[Components Endpoints SSR]
        MVCRp[Mvc and Razor Pages]
    end
    Components[Components]
    ComponentsWasm[Components Webassembly]
    ComponentsWebView[Components WebView]
    ComponentsWasm-->Components
    ComponentsWebView-->Components
    Endpoints-->Components
    Endpoints-->StaticAssets
    MVCRp-->Endpoints
Loading

Two important things:

  • Components doesn't have a reference to ASP.NET Core because it runs in other environments, and is what Razor Class Libraries target.
  • MVC and Razor Pages consume the SSR support for components.

With this in mind, information for the assets is exposed differently from StaticAssets and Components

flowchart TD
    subgraph AspNet[ASP.NET Framework reference]
        subgraph StaticAssets[Static Assets]
            StaticAssetDescriptor
            StaticAssetSelector
            StaticAssetProperty
            StaticAssetResponseHeader
        end
        Endpoints[Components Endpoints SSR]
        MVCRp[Mvc and Razor Pages]
    end
    subgraph Components[Components]
       ResourceAssetCollection
       ResourceAsset
       ResourceAssetProperty
       ImportMapDefinition
    end
    ComponentsWasm[Components Webassembly]
    ComponentsWebView[Components WebView]
    ComponentsWasm-->Components
    ComponentsWebView-->Components
    Endpoints-->Components
    Endpoints-->StaticAssets
    MVCRp-->Endpoints
Loading
  • ResourceAsset and its related types represent Blazor's view of a resource, which only includes things that are useful when including that resource in markup.
  • ResourceAssetDescriptor and its related types are the "HTTP" view of the file/endpoint that we mapped.

The Component endpoints assembly is the one responsible for mapping the StaticAssetDescriptors into the ResourceAssetCollection so that Blazor and MVC can consume them.

Microsoft.AspNetCore.Components.dll

namespace Microsoft.AspNetCore.Components;

public abstract class ComponentBase : IComponent, IHandleEvent, IHandleAfterRender
{
+    protected ResourceAssetCollection Assets { get; }
}

public readonly struct RenderHandle
{
+    public ResourceAssetCollection Assets { get; }
}

public abstract partial class Renderer : IDisposable, IAsyncDisposable
{
+    protected internal virtual ResourceAssetCollection Assets { get; } = ResourceAssetCollection.Empty;
}

+public class ResourceAssetCollection : IReadOnlyList<ResourceAsset>
+{
+   public static readonly ResourceAssetCollection Empty = new([]);
+   public ResourceAssetCollection(IReadOnlyList<ResourceAsset> resources);
+   public string this[string key];
+   public bool IsContentSpecificUrl(string path);
+}

+public class ResourceAsset(string url, IReadOnlyList<ResourceAssetProperty>? properties)
+{
+   public string Url { get; } = url;
+   public IReadOnlyList<ResourceAssetProperty>? Properties { get; } = properties;
+}

+public class ResourceAssetProperty(string name, string value)
+{
+   public string Name { get; } = name;
+   public string Value { get; } = value;
+}

Microsoft.AspNetCore.Components.Endpoints

namespace Microsoft.AspNetCore.Components;

+public class ImportMap : IComponent
+{
+    [CascadingParameter] public HttpContext? HttpContext { get; set; } = null;
+    [Parameter] public ImportMapDefinition? ImportMapDefinition { get; set; }
+}

+public class ImportMapDefinition
+{
+   public ImportMapDefinition(
+        IReadOnlyDictionary<string, string>? imports,
+        IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>>? scopes,
+        IReadOnlyDictionary<string, string>? integrity)

+   public static ImportMapDefinition FromResourceCollection(ResourceAssetCollection assets);
+   public static ImportMapDefinition Combine(params ImportMapDefinition[] sources);

+   public IReadOnlyDictionary<string, string>? Imports { get; }
+   public IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>>? Scopes { get; }
+   public IReadOnlyDictionary<string, string>? Integrity { get; }
+   public override string ToString();
+}

public static class RazorComponentsEndpointConventionBuilderExtensions
{
+    public static RazorComponentsEndpointConventionBuilder WithResourceCollection(
+    this RazorComponentsEndpointConventionBuilder builder,
+    string? manifestPath = null);
}

Assembly Microsoft.AspNetCore.WebUtilities

namespace Microsoft.AspNetCore.WebUtilities;

public class WebEncoders
{
+    Base64UrlEncode(System.ReadOnlySpan<byte> input, System.Span<char> output)
}

Assembly Microsoft.AspNetCore.Mvc.RazorPages

namespace Microsoft.AspNetCore.Builder;

+public static class PageActionEndpointConventionBuilderResourceCollectionExtensions
+{
+    static WithResourceCollection(this Microsoft.AspNetCore.Builder.PageActionEndpointConventionBuilder! builder, string? manifestPath = null): Microsoft.AspNetCore.Builder.PageActionEndpointConventionBuilder!
+}

Microsoft.AspNetCore.Mvc.ViewFeatures

namespace Microsoft.AspNetCore.Builder;

+public static class ControllerActionEndpointConventionBuilderResourceCollectionExtensions
+{
+    static WithResourceCollection(this Microsoft.AspNetCore.Builder.ControllerActionEndpointConventionBuilder! builder, string? manifestPath = null): Microsoft.AspNetCore.Builder.ControllerActionEndpointConventionBuilder!
+}

Assembly Microsoft.AspNetCore.Mvc.TagHelpers.dll

namespace Microsoft.AspNetCore.Mvc.TagHelpers;

public class ScriptTagHelper
{
+    public string Type { get; set; }
+    public ImportMapDefinition ImportMap { get; set; }
}

Assembly Microsoft.AspNetCore.StaticAssets.dll

namespace Microsoft.AspNetCore.StaticAssets;

+public sealed class StaticAssetDescriptor
+{
+    public required string Route { get; set; }
+    public required string AssetPath { get; set; }
+    public IReadOnlyList<StaticAssetSelector> Selectors { get; set; }
+    public IReadOnlyList<StaticAssetProperty> Properties { get; set; }
+    public IReadOnlyList<StaticAssetResponseHeader> ResponseHeaders { get; set; }
+}

+public sealed class StaticAssetResponseHeader(string name, string value)
+{
+    public string Name { get; } = name;
+    public string Value { get; } = value;
+}

+public sealed class StaticAssetProperty(string name, string value)
+{
+    public string Name { get; } = name;
+    public string Value { get; } = value;
+}

+public sealed class StaticAssetSelector(string name, string value, string quality)
+{
+   public string Name { get; } = name;
+   public string Value { get; } = value;
+    public string Quality { get; } = quality;
+}
namespace Microsoft.AspNetCore.StaticAssets.Infrastructure;

public static class StaticAssetsEndpointDataSourceHelper
{
+    public static bool HasStaticAssetsDataSource(IEndpointRouteBuilder builder, string? staticAssetsManifestPath = null);

+    public static IReadOnlyList<StaticAssetDescriptor> ResolveStaticAssetDescriptors(
        IEndpointRouteBuilder endpointRouteBuilder,
        string? manifestPath)
}
@dotnet-issue-labeler dotnet-issue-labeler bot added the area-blazor Includes: Blazor, Razor Components label Jun 4, 2024
@javiercn javiercn self-assigned this Jun 4, 2024
@javiercn javiercn added the api-ready-for-review API is ready for formal API review - https://github.com/dotnet/apireviews label Jun 4, 2024
Copy link
Contributor

Thank you for submitting this for API review. This will be reviewed by @dotnet/aspnet-api-review at the next meeting of the ASP.NET Core API Review group. Please ensure you take a look at the API review process documentation and ensure that:

  • The PR contains changes to the reference-assembly that describe the API change. Or, you have included a snippet of reference-assembly-style code that illustrates the API change.
  • The PR describes the impact to users, both positive (useful new APIs) and negative (breaking changes).
  • Someone is assigned to "champion" this change in the meeting, and they understand the impact and design of the change.

@amcasey
Copy link
Member

amcasey commented Jun 6, 2024

[API Review]

  • This is follow-up to the map-static-assets work
  • A fingerprint builds version info into the name to simplify caching
    • If the page says you need mycss.ab9de.css and you have it, you don't have to worry that it has changed
  • We're not worried about hash collisions with SHA256
  • If there's no mapping for a given asset, it's left as-is
  • It's hard to connect the names "MapStaticAssets" and "WithResourceCollection"
    • The name "Assets" is taken in blazor, so we can't just use that
    • We don't want to over-specialize to the static asset mapping meaning
    • There's some confusion between the act of collection and a collection object
    • We have types in multiple assemblies so "static assets" is a disambiguator best suited to use in the static assets assembly (so we don't want to use that name elsewhere)
  • Seal classes where possible
  • Why isn't the asset collection exposed as a service?
    • It would have to be scoped
    • Seems less bad than threading it down to ComponentBase via the Renderer, even though it's not obviously related to rendering
    • Could we at least make RenderHandle.Assets internal?
      • No, we need it for ImportMap
  • From the user's perspective, the function of WithResourceCollection is to populate ComponentBase.Assets (from static assets)
  • ResourceAsset.Properties is a list, rather than a map in case we want/need/see duplicate keys in the future
  • RazorComponentsEndpointConventionBuilderExtensions.WithResourceCollection should require non-nullable manifestPath
  • ResourceAsset.Url should be ResourceAsset.Path (to match our usual pattern)
    • But what if there's a query string?

Current status is that we need to figure out a good name for the main public API. We can't review the other pieces properly until we understand that.

@amcasey
Copy link
Member

amcasey commented Jun 13, 2024

[API Review]

  • Blazor discussed and proposes "PopulateResourceCollectionFromStaticAssets"

    • In web terminology, an image etc is called a "resource"
    • "Resource" includes the metadata as well
    • The "R" in URI
    • Static assets are a variety of resource
  • Maybe "AssetResourceCollection" would be clearer than "ResourceAssetCollection"

    • In the sense that it's a type of resource, rather than a type of asset
  • MapStaticWebAssets maps endpoints; MapRazorPages maps pages; PopulateResourceCollectionFromStaticAssets connects the two

  • Why isn't PopulateResourceCollectionFromStaticAssets part of MapRazorPages (for the default manifest path)?

    • Feels like it would affect scenarios (MVC, razor pages) that don't want it (by default?)
      • They won't be calling MapStaticAssets, so it should do nothing (i.e. it's effectively opt-in when you call MapStaticAssets)
    • There might be a large implementation cost to implementing that consistently across scenarios
    • This would only work for the default manifest path, so this doesn't resolve the naming problem (still need it for non-default manifest paths)
  • If you try to use @Assets["somefile.js"] without having called MapStaticAssets, it will just be the identity function

  • We don't want to call the component property "Resources" because that would probably collide with a resx file

  • We remain concerned about a case where you pull in the wrong asset-related type via completion and auto-imports and then you end up with a bunch of name conflicts

  • The two names users will actually see an use are of the extension method and the component property

    • The type name will generally be implicit
  • WithStaticAssets seems preferable to WithAssets for consistency with MapStaticAssets

  • An asset has a path; a resource has a URL

  • ResourceAssetCollection seems more important than StaticAssetDescriptor, so it should get the better name

    • StaticAssetDescriptor is a great name in context, but that type won't be seen by many users
  • "ResourceAsset" => "StaticAssetResource"

    • Will they get jumbled with StaticAsset* from the StaticAsset assembly in completion?
      • Use "AssetResource" instead
  • Proposal [rejected]:

    • "ResourceAsset" => "AssetResource"
      • Why?
      • We think the main reason was that we wanted "StaticAssetResource", which we can't have for language service reasons
    • "ResourceCollection" => "AssetResourceCollection"
    • "WithResourceCollection" => "WithStaticAssets"
    • "ResourceAsset.Url" => "AssetResource.Path"
  • In the future, if other varieties of assets are added to the AssetResourceCollection, they'll get their own extension method, rather than expanding the functionality of WithStaticAssets

  • Accepted name changes:

    • "WithResourceCollection" => "WithStaticAssets"
    • "ResourceCollection" => "ResourceAssetCollection"
    • "ResourceAsset.Url" => "ResourceAsset.Path"

@felixcicatt
Copy link

It seems the ScriptTagHelper doesn't render the type attribute anymore, for instance in this example, resulting in the following error in the browser:
Uncaught SyntaxError: Cannot use import statement outside a module

<script type="module">
    import * as lit from "https://cdn.jsdelivr.net/gh/lit/dist@3/core/lit-core.min.js"
    console.log(lit)
</script>

@amcasey
Copy link
Member

amcasey commented Aug 19, 2024

@felixcicatt Can you please file a new issue? Feel free to tag this one if you believe it's related.

@jsakamoto
Copy link

@amcasey I logged it.
#57664

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
api-ready-for-review API is ready for formal API review - https://github.com/dotnet/apireviews area-blazor Includes: Blazor, Razor Components
Projects
None yet
Development

Successfully merging a pull request may close this issue.

4 participants