Skip to content

Proposal: Add an HTTP-based JS to .NET interop option to unblock large‑payload scenarios in Blazor Server #62255

Closed
@vgallegob

Description

@vgallegob

Is there an existing issue for this?

  • I have searched the existing issues

Is your feature request related to a problem? Please describe the problem.

1  Problem Statement

Blazor Server currently routes every browser⇄server interaction through a single SignalR WebSocket. When a Blazor component needs to exchange very large messages—e.g., 18 MB OmniSharp completion lists or multi‑MB file uploads—the socket is monopolized, freezing the UI.

  • Large messages stall the circuit
    The OmniSharp completionItems response can exceed 18 MB. While that payload moves, no other WebSocket messages can be processed, making the IDE appear frozen. Large file uploads cause the same problem.

  • Extra plumbing required
    Avoiding this requires building bespoke REST APIs or gRPC services and duplicating auth, model binding, and serialization logic—breaking the smooth “stay in Blazor land” workflow.

Describe the solution you'd like

2  Proposed Solution

This proposal introduces an opt‑in HTTP POST interop API that lets JavaScript call arbitrary .NET instance methods in parallel to the SignalR channel, keeping the UI responsive without forcing developers to create separate API endpoints.

Introduce a secondary transport for JS interop:

window.dotnet.invokeHttpAsync(methodIdentifier, arg1, arg2, ...)
                       ↧
POST /_blazor/interop
  { circuitId: "12341234", instanceId: "45674567", method: "GetCompletions", args: [ ... ] }
                       ↧
Server invokes the instance method (scoped to circuit)
                       ↧
HTTP 200 + JSON‑serialized result
  • Transport  Standard HTTP POST with a JSON body.

  • Auth  Leverages existing cookie (or token) auth; same policies as the circuit.

  • Concurrency  Requests are independent and can run in parallel; no ordering guarantees (unlike SignalR).

  • Circuit context  Requests carry the circuit ID header, so IJSRuntime, HttpContext, DI scopes, etc., behave as expected.

3  Benefits

  • 🚀 Responsive UI: Big payloads no longer block SignalR; rendering stays snappy.

  • 🧩 Single mental model: Developers stay within the familiar JS‑interop pattern, no separate controllers, minimal APIs or gRPC services for edge cases.

  • ⚡ Awaitable results: invokeHttpAsync returns a Promise resolved with the deserialized C# - result.

  • 🔌 Opt‑in: Existing apps keep current behavior; only calls that choose HTTP use it.


4  Limitations & Considerations

  • Out‑of‑order delivery  This is desirable for high‑bandwidth tasks but differs from SignalR’s ordered guarantees.

  • Only POST initially  GET, streaming, or custom media types can come later.

  • Security  Requires an explicit attribute (e.g., [JSInvokable(Http = true)]) to opt in per method.

  • Circuit lifetime  If the circuit dies mid‑request, the HTTP call should return 409/410 so the client can retry or display an error.


5 Proposed API Surface

// C#
public class CompletionService
{
    [JSInvokable(Http = true)]
    public async Task<CompletionResult> GetCompletions(string code, int position)
        => ...;
}
// JavaScript
const result = await DotNet.invokeHttpAsync(
    "CompletionService.GetCompletions",
    code, 
    cursorPos
);
// result is already parsed JSON

Optional parameters:

DotNet.invokeHttpAsync(method, args, {
    signal: abortController.signal, // cancellation
    headers: { "x-custom": "foo" } // extra headers
});

Additional context

6  Request for Feedback

  • API shape: invokeHttpAsync vs. invokeViaHttp?

  • Attribute contract:  simple [JSInvokable(Http = true)] or a dedicated [HttpInvokable]?

  • Streaming:  should we add IAsyncEnumerable<T> support in v1?

  • Error handling: HTTP status codes ↔ JS exceptions mapping.

  • Custom Authentication: HTTP request might require some customization for authentication from JS.

  • Have you ever thought about this feature or found yourself having to create APIs to call component methods?

  • Could this be usefull for SSR?


7  Next Steps

If the team is interested, I’m happy to try to submit a PR with a clean, reflection‑free implementation. But I might need help.


Bottom line: An HTTP‑based interop path solves a real pain point for Blazor Server apps dealing with huge payloads—without forcing developers to abandon the elegant JS‑interop model. I’d love your thoughts and guidance on making this a first‑class feature!


8  Proof of Concept

I have successfully developed a reflection‑based PoC that solves all the problems stated:

  1. Registers an minimal API endpoint.

  2. In it, it resolves the target component from the CircuitRegistry.

  3. Deserializes args[], invokes the method using InvokeAsync, serializes the return value, and streams it back.

In production we’d replace reflection with official hooks.

window.invokeDotNetObjectViaHttp = async (dotNetInstance, methodName, ...argsArray ) => {

    const circuitId = dotNetInstance._callDispatcher._dotNetCallDispatcher._circuitId; 
    const dotNetObjectId = dotNetInstance._id;  
    const assemblyName = null;    
    const methodName2 = methodName;    
    const callId = crypto.randomUUID();         

    return invokeDotNetViaHttp(
        circuitId,
        dotNetObjectId,
        assemblyName,
        methodName2,
        callId,
        argsArray
    );
}

window.invokeDotNetViaHttp = async (
    circuitId,
    dotNetObjectId,
    assemblyName,
    methodName,
    callId,
    argsArray
) => {
    const url = `/api/blazorinterop/${encodeURIComponent(circuitId)}/${dotNetObjectId}/${encodeURIComponent(assemblyName)}/${encodeURIComponent(methodName)}/${encodeURIComponent(callId)}`;

    const response = await fetch(url, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(argsArray),
    });

    if (!response.ok) {
        const errorText = await response.text();
        throw new Error(`Server returned ${response.status}: ${errorText}`);
    }

    const text = await response.text();
    return text ? JSON.parse(text) : undefined;
}

// somwhere in a js interop call..
const response = await window.invokeDotNetObjectViaHttp(_dotNetInstance, "GetCompletionAsync", code, request, filterKind, filterParametersOnly )
internal static class CircuitInterop
{

    private static readonly Lazy<Type> CircuitRegistryType = new( () =>
        Type.GetType( "Microsoft.AspNetCore.Components.Server.Circuits.CircuitRegistry, Microsoft.AspNetCore.Components.Server" )
        ?? throw new InvalidOperationException( "CircuitRegistry type not found" ) );

    private static readonly Lazy<PropertyInfo> ConnectedCircuitsProp = new( () =>
        CircuitRegistryType.Value.GetProperty( "ConnectedCircuits", BindingFlags.Instance | BindingFlags.NonPublic )
        ?? throw new InvalidOperationException( "ConnectedCircuits property not found" ) );

    private static readonly Lazy<PropertyInfo> KeysProp = new( () =>
        ConnectedCircuitsProp.Value.PropertyType.GetProperty( "Keys" )
        ?? throw new InvalidOperationException( "Keys property not found" ) );

    private static readonly Lazy<MethodInfo> TryGetValueMI = new( () =>
        ConnectedCircuitsProp.Value.PropertyType.GetMethod( "TryGetValue" )
        ?? throw new InvalidOperationException( "TryGetValue method not found" ) );

    private static readonly Lazy<Type> CircuitIdType = new( () =>
        CircuitRegistryType.Value.Assembly.GetType( "Microsoft.AspNetCore.Components.Server.Circuits.CircuitId" )
        ?? throw new InvalidOperationException( "CircuitId type not found" ) );

    private static readonly Lazy<PropertyInfo> SecretProp = new( () =>
        CircuitIdType.Value.GetProperty( "Secret", BindingFlags.Public | BindingFlags.Instance )
        ?? throw new InvalidOperationException( "CircuitId.Secret property not found" ) );

    private static readonly Lazy<Type> CircuitHostType = new( () =>
        CircuitRegistryType.Value.Assembly.GetType( "Microsoft.AspNetCore.Components.Server.Circuits.CircuitHost" )
        ?? throw new InvalidOperationException( "CircuitHost type not found" ) );

    private static readonly Lazy<PropertyInfo> RendererProp = new( () =>
        CircuitHostType.Value.GetProperty( "Renderer", BindingFlags.Instance | BindingFlags.Public )
        ?? throw new InvalidOperationException( "CircuitHost.Renderer property not found" ) );

    private static readonly Lazy<PropertyInfo> DispatcherProp = new( () =>
        RendererProp.Value.PropertyType.GetProperty( "Dispatcher", BindingFlags.Instance | BindingFlags.Public )
        ?? throw new InvalidOperationException( "Renderer.Dispatcher property not found" ) );

    private static readonly Lazy<MethodInfo> CheckAccessMI = new( () =>
        DispatcherProp.Value.PropertyType.GetMethod( "CheckAccess", BindingFlags.Instance | BindingFlags.Public )
        ?? throw new InvalidOperationException( "Dispatcher.CheckAccess not found" ) );

    private static readonly Lazy<PropertyInfo> JsRuntimeProp = new( () =>
        CircuitHostType.Value.GetProperty( "JSRuntime", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic )
        ?? CircuitHostType.Value.GetProperty( "JavaScriptRuntime", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic )
        ?? throw new InvalidOperationException( "CircuitHost JSRuntime property not found" ) );

    private static readonly Lazy<Type> DotNetDispatcherType = new( () =>
        Type.GetType( "Microsoft.JSInterop.Infrastructure.DotNetDispatcher, Microsoft.JSInterop" )
        ?? throw new InvalidOperationException( "DotNetDispatcher type not found" ) );

    private static readonly Lazy<Type> DotNetInvocationInfoType = new( () =>
        DotNetDispatcherType.Value.Assembly.GetType( "Microsoft.JSInterop.Infrastructure.DotNetInvocationInfo" )
        ?? throw new InvalidOperationException( "DotNetInvocationInfo type not found" ) );

    private static readonly Lazy<MethodInfo> InvokeSyncMI = new( () =>
        DotNetDispatcherType.Value.GetMethod( "InvokeSynchronously", BindingFlags.Static | BindingFlags.NonPublic )
        ?? throw new InvalidOperationException( "InvokeSynchronously method not found" ) );

    private static readonly Lazy<MethodInfo> GetTaskByTypeMI = new( () =>
        DotNetDispatcherType.Value.GetMethod( "GetTaskByType", BindingFlags.Static | BindingFlags.NonPublic )
        ?? throw new InvalidOperationException( "GetTaskByType method not found" ) );

    private static readonly Func<IJSRuntime, JsonSerializerOptions?> GetJsonOpts = (new Func<Func<IJSRuntime, JsonSerializerOptions?>>(() =>
    {
        var prop = typeof( JSRuntime )
            .GetProperty( "JsonSerializerOptions", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic )
            ?? throw new InvalidOperationException( "JSRuntime.JsonSerializerOptions property not found" );

        var getter = prop.GetGetMethod( nonPublic: true )!;          // always exists
        return ( IJSRuntime rt ) => (JsonSerializerOptions?)getter.Invoke( rt, null );
    }))();

    private static readonly Lazy<MethodInfo> GetObjRefMI = new( () => typeof( JSRuntime )
        .GetMethod( "GetObjectReference", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic ) 
        ?? throw new InvalidOperationException( "JSRuntime.GetObjectReference method not found" ) ); 

    private static readonly Lazy<MethodInfo> InvokeAsyncTupleMI = new( () =>
    {
        var dispType = DispatcherProp.Value.PropertyType;

        foreach ( var m in dispType.GetMethods( BindingFlags.Instance | BindingFlags.Public ) )
        {
            if ( !m.IsGenericMethodDefinition || m.Name != "InvokeAsync" ) continue;

            var p = m.GetParameters();
            if ( p.Length != 1 ) continue;

            var paramType = p[0].ParameterType;
            if ( !paramType.IsGenericType || paramType.GetGenericTypeDefinition() != typeof( Func<> ) ) continue;

            var inner = paramType.GenericTypeArguments[0];
            if ( !inner.IsGenericType || inner.GetGenericTypeDefinition() != typeof( Task<> ) ) continue;

            return m.MakeGenericMethod( typeof( (bool success, object? result, string? error) ) );
        }

        throw new InvalidOperationException( "Dispatcher.InvokeAsync<T>(Func<Task<T>>) overload not found" );
    } );


    private static bool TryGetHost( object registry, string secret, out object host )
    {
        var dict = ConnectedCircuitsProp.Value.GetValue( registry ) ?? throw new InvalidOperationException( "ConnectedCircuits is null" );

        foreach ( var key in (IEnumerable)KeysProp.Value.GetValue( dict )! )
        {
            if ( (string)SecretProp.Value.GetValue( key )! != secret ) continue;

            var args = new object?[] { key, null };
            if ( (bool)TryGetValueMI.Value.Invoke( dict, args )! )
            {
                host = args[1]!;
                return true;
            }
        }

        host = null!;
        return false;
    }

    private static async Task<(bool success, object? result, string? error)> InvokeDotNetAsync(
        IJSRuntime jsRuntime,
        string? assemblyName,
        string methodIdentifier,
        long dotNetObjectId,
        string callId,
        string argsJson )
    {
        object invocationInfo = Activator.CreateInstance( DotNetInvocationInfoType.Value, assemblyName == "null" ? null : assemblyName, methodIdentifier, dotNetObjectId, callId )!;

        object? targetInstance = null;

        if ( dotNetObjectId != 0 )
        {
            targetInstance = GetObjRefMI.Value.Invoke( jsRuntime, new object[] { dotNetObjectId } );
        }

        object? syncResult;
        try
        {
            syncResult = InvokeSyncMI.Value.Invoke( null, new[] { jsRuntime, invocationInfo, targetInstance!, argsJson } );
        }
        catch ( Exception ex )
        {
            return (false, null, ExceptionDispatchInfo.Capture( ex ).SourceException.ToString());
        }

        Task? awaitable = null;
        object? finalResult = null;

        switch ( syncResult )
        {
            case Task t:
                awaitable = t;
                break;
            case ValueTask vt:
                awaitable = vt.AsTask();
                break;
            default:
                var srType = syncResult?.GetType();
                if ( srType is { IsGenericType: true } && srType.GetGenericTypeDefinition() == typeof( ValueTask<> ) )
                {
                    awaitable = (Task)GetTaskByTypeMI.Value.Invoke( null, new[] { srType.GenericTypeArguments[0], syncResult! } )!;
                }
                else
                {
                    finalResult = syncResult;
                }
                break;
        }

        if ( awaitable is not null )
        {
            try { await awaitable.ConfigureAwait( false ); }
            catch ( Exception ex )
            {
                return (false, null, ExceptionDispatchInfo.Capture( ex ).SourceException.ToString());
            }

            var atype = awaitable.GetType();
            if ( atype.IsGenericType && atype.GetProperty( "Result" ) is { } resProp ) finalResult = resProp.GetValue( awaitable );
        }

        return (true, finalResult, null);
    }

    public static void MapHttpInterop( this WebApplication app )
    {
        app.MapPost(
            "/api/blazorinterop/{circuitId}/{dotNetObjectId}/{assemblyName}/{methodName}/{callId}",
            async ( string circuitId,
                   long dotNetObjectId,
                   string? assemblyName,
                   string methodName,
                   string callId,
                   HttpContext ctx,
                   IServiceProvider sp ) =>
            {
                if ( sp.GetService( CircuitRegistryType.Value ) is not { } registry ) return Results.Problem( "CircuitRegistry service not found" );

                if ( !TryGetHost( registry, circuitId, out var host ) ) return Results.NotFound( "CircuitHost not found" );

                var renderer = RendererProp.Value.GetValue( host )!;
                var dispatcher = DispatcherProp.Value.GetValue( renderer )! ?? throw new InvalidOperationException( "Dispatcher is null" );

                var jsRuntime = (IJSRuntime)JsRuntimeProp.Value.GetValue( host )!;

                string argsJson;
                using var reader = new StreamReader( ctx.Request.Body );
                argsJson = await reader.ReadToEndAsync().ConfigureAwait( false );

                async Task<(bool success, object? result, string? error)> Work() => await InvokeDotNetAsync( jsRuntime, assemblyName, methodName, dotNetObjectId, callId, argsJson ).ConfigureAwait( false );

                (bool success, object? result, string? error) outcome;

                if ( (bool)CheckAccessMI.Value.Invoke( dispatcher, null )! )
                {
                    outcome = await Work();
                }
                else
                {
                    var taskObj = InvokeAsyncTupleMI.Value.Invoke( dispatcher, new object[] { (Func<Task<(bool, object?, string?)>>)Work } )!;
                    outcome = await (Task<(bool, object?, string?)>)taskObj;
                }

                if ( !outcome.success ) return Results.Problem( outcome.error );

                if ( outcome.result is null ) return Results.NoContent();

                var opts = GetJsonOpts( jsRuntime ) ?? new JsonSerializerOptions();

                return Results.Json( outcome.result, opts );        
            } );
    }
}
@code {
    [JSInvokable]
    public async Task<CompletionResponse> GetCompletionAsync(string code, CompletionRequest request, BlazorMonaco.Languages.CompletionItemKind? filterKind = null, bool? filterParams = false )
    {
        ....    
    }
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions