File: Circuits\RemoteJSRuntime.cs
Web Access
Project: src\src\Components\Server\src\Microsoft.AspNetCore.Components.Server.csproj (Microsoft.AspNetCore.Components.Server)
// 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.Concurrent;
using System.Text.Json;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.JSInterop;
using Microsoft.JSInterop.Infrastructure;
 
namespace Microsoft.AspNetCore.Components.Server.Circuits;
 
#pragma warning disable CA1852 // Seal internal types
internal partial class RemoteJSRuntime : JSRuntime
#pragma warning restore CA1852 // Seal internal types
{
    private readonly CircuitOptions _options;
    private readonly ILogger<RemoteJSRuntime> _logger;
    private CircuitClientProxy _clientProxy;
    private readonly ConcurrentDictionary<long, DotNetStreamReference> _pendingDotNetToJSStreams = new();
    private bool _permanentlyDisconnected;
    private readonly long _maximumIncomingBytes;
    private int _byteArraysToBeRevivedTotalBytes;
 
    internal int RemoteJSDataStreamNextInstanceId;
    internal readonly Dictionary<long, RemoteJSDataStream> RemoteJSDataStreamInstances = new();
 
    public ElementReferenceContext ElementReferenceContext { get; }
 
    public bool IsInitialized => _clientProxy is not null;
 
    internal bool IsPermanentlyDisconnected => _permanentlyDisconnected;
 
    /// <summary>
    /// Notifies when a runtime exception occurred.
    /// </summary>
    public event EventHandler<Exception>? UnhandledException;
 
    public RemoteJSRuntime(
        IOptions<CircuitOptions> circuitOptions,
        IOptions<HubOptions<ComponentHub>> componentHubOptions,
        ILogger<RemoteJSRuntime> logger)
    {
        _options = circuitOptions.Value;
        _maximumIncomingBytes = componentHubOptions.Value.MaximumReceiveMessageSize ?? long.MaxValue;
        _logger = logger;
        DefaultAsyncTimeout = _options.JSInteropDefaultCallTimeout;
        ElementReferenceContext = new WebElementReferenceContext(this);
        JsonSerializerOptions.Converters.Add(new ElementReferenceJsonConverter(ElementReferenceContext));
    }
 
    public JsonSerializerOptions ReadJsonSerializerOptions() => JsonSerializerOptions;
 
    internal void Initialize(CircuitClientProxy clientProxy)
    {
        _clientProxy = clientProxy ?? throw new ArgumentNullException(nameof(clientProxy));
    }
 
    internal void RaiseUnhandledException(Exception ex)
    {
        UnhandledException?.Invoke(this, ex);
    }
 
    protected override void EndInvokeDotNet(DotNetInvocationInfo invocationInfo, in DotNetInvocationResult invocationResult)
    {
        if (!invocationResult.Success)
        {
            Log.InvokeDotNetMethodException(_logger, invocationInfo, invocationResult.Exception);
            string errorMessage;
 
            if (_options.DetailedErrors)
            {
                errorMessage = invocationResult.Exception.ToString();
            }
            else
            {
                errorMessage = $"There was an exception invoking '{invocationInfo.MethodIdentifier}'";
                if (invocationInfo.AssemblyName != null)
                {
                    errorMessage += $" on assembly '{invocationInfo.AssemblyName}'";
                }
 
                errorMessage += $". For more details turn on detailed exceptions in '{nameof(CircuitOptions)}.{nameof(CircuitOptions.DetailedErrors)}'";
            }
 
            _clientProxy.SendAsync("JS.EndInvokeDotNet",
                invocationInfo.CallId,
                /* success */ false,
                errorMessage);
        }
        else
        {
            Log.InvokeDotNetMethodSuccess(_logger, invocationInfo);
            _clientProxy.SendAsync("JS.EndInvokeDotNet",
                invocationInfo.CallId,
                /* success */ true,
                invocationResult.ResultJson);
        }
    }
 
    protected override void SendByteArray(int id, byte[] data)
    {
        _clientProxy.SendAsync("JS.ReceiveByteArray", id, data);
    }
 
    protected override void BeginInvokeJS(long asyncHandle, string identifier, string argsJson, JSCallResultType resultType, long targetInstanceId)
    {
        if (_clientProxy is null)
        {
            if (_permanentlyDisconnected)
            {
                throw new JSDisconnectedException(
               "JavaScript interop calls cannot be issued at this time. This is because the circuit has disconnected " +
               "and is being disposed.");
            }
            else
            {
                throw new InvalidOperationException(
                    "JavaScript interop calls cannot be issued at this time. This is because the component is being " +
                    "statically rendered. When prerendering is enabled, JavaScript interop calls can only be performed " +
                    "during the OnAfterRenderAsync lifecycle method.");
            }
        }
 
        Log.BeginInvokeJS(_logger, asyncHandle, identifier);
 
        _clientProxy.SendAsync("JS.BeginInvokeJS", asyncHandle, identifier, argsJson, (int)resultType, targetInstanceId);
    }
 
    protected override void ReceiveByteArray(int id, byte[] data)
    {
        if (id == 0)
        {
            // Starting a new transfer, clear out number of bytes read.
            _byteArraysToBeRevivedTotalBytes = 0;
        }
 
        if (_maximumIncomingBytes - data.Length < _byteArraysToBeRevivedTotalBytes)
        {
            throw new ArgumentOutOfRangeException(nameof(data), "Exceeded the maximum byte array transfer limit for a call.");
        }
 
        // We also store the total number of bytes seen so far to compare against
        // the MaximumIncomingBytes limit.
        // We take the larger of the size of the array or 4, to ensure we're not inundated
        // with small/empty arrays.
        _byteArraysToBeRevivedTotalBytes += Math.Max(4, data.Length);
 
        base.ReceiveByteArray(id, data);
    }
 
    protected override async Task TransmitStreamAsync(long streamId, DotNetStreamReference dotNetStreamReference)
    {
        if (!_pendingDotNetToJSStreams.TryAdd(streamId, dotNetStreamReference))
        {
            throw new ArgumentException($"The stream {streamId} is already pending.");
        }
 
        // SignalR only supports streaming being initiated from the JS side, so we have to ask it to
        // start the stream. We'll give it a maximum of 10 seconds to do so, after which we give up
        // and discard it.
        var cancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(10)).Token;
        cancellationToken.Register(() =>
        {
            // If by now the stream hasn't been claimed for sending, stop tracking it
            if (_pendingDotNetToJSStreams.TryRemove(streamId, out var timedOutStream) && !timedOutStream.LeaveOpen)
            {
                timedOutStream.Stream.Dispose();
            }
        });
 
        await _clientProxy.SendAsync("JS.BeginTransmitStream", streamId);
    }
 
    public bool TryClaimPendingStreamForSending(long streamId, out DotNetStreamReference pendingStream)
    {
        if (_pendingDotNetToJSStreams.TryRemove(streamId, out pendingStream))
        {
            return true;
        }
 
        pendingStream = default;
        return false;
    }
 
    public void MarkPermanentlyDisconnected()
    {
        _permanentlyDisconnected = true;
        _clientProxy = null;
    }
 
    protected override async Task<Stream> ReadJSDataAsStreamAsync(IJSStreamReference jsStreamReference, long totalLength, CancellationToken cancellationToken = default)
        => await RemoteJSDataStream.CreateRemoteJSDataStreamAsync(this, jsStreamReference, totalLength, _maximumIncomingBytes, _options.JSInteropDefaultCallTimeout, cancellationToken);
 
    public static partial class Log
    {
        [LoggerMessage(1, LogLevel.Debug, "Begin invoke JS interop '{AsyncHandle}': '{FunctionIdentifier}'", EventName = "BeginInvokeJS")]
        internal static partial void BeginInvokeJS(ILogger logger, long asyncHandle, string functionIdentifier);
 
        [LoggerMessage(2, LogLevel.Debug, "There was an error invoking the static method '[{AssemblyName}]::{MethodIdentifier}' with callback id '{CallbackId}'.", EventName = "InvokeStaticDotNetMethodException")]
        private static partial void InvokeStaticDotNetMethodException(ILogger logger, string assemblyName, string methodIdentifier, string? callbackId, Exception exception);
 
        [LoggerMessage(4, LogLevel.Debug, "There was an error invoking the instance method '{MethodIdentifier}' on reference '{DotNetObjectReference}' with callback id '{CallbackId}'.", EventName = "InvokeInstanceDotNetMethodException")]
        private static partial void InvokeInstanceDotNetMethodException(ILogger logger, string methodIdentifier, long dotNetObjectReference, string? callbackId, Exception exception);
 
        [LoggerMessage(3, LogLevel.Debug, "Invocation of '[{AssemblyName}]::{MethodIdentifier}' with callback id '{CallbackId}' completed successfully.", EventName = "InvokeStaticDotNetMethodSuccess")]
        private static partial void InvokeStaticDotNetMethodSuccess(ILogger<RemoteJSRuntime> logger, string assemblyName, string methodIdentifier, string? callbackId);
 
        [LoggerMessage(5, LogLevel.Debug, "Invocation of '{MethodIdentifier}' on reference '{DotNetObjectReference}' with callback id '{CallbackId}' completed successfully.", EventName = "InvokeInstanceDotNetMethodSuccess")]
        private static partial void InvokeInstanceDotNetMethodSuccess(ILogger<RemoteJSRuntime> logger, string methodIdentifier, long dotNetObjectReference, string? callbackId);
 
        internal static void InvokeDotNetMethodException(ILogger logger, in DotNetInvocationInfo invocationInfo, Exception exception)
        {
            if (invocationInfo.AssemblyName != null)
            {
                InvokeStaticDotNetMethodException(logger, invocationInfo.AssemblyName, invocationInfo.MethodIdentifier, invocationInfo.CallId, exception);
            }
            else
            {
                InvokeInstanceDotNetMethodException(logger, invocationInfo.MethodIdentifier, invocationInfo.DotNetObjectId, invocationInfo.CallId, exception);
            }
        }
 
        internal static void InvokeDotNetMethodSuccess(ILogger<RemoteJSRuntime> logger, in DotNetInvocationInfo invocationInfo)
        {
            if (invocationInfo.AssemblyName != null)
            {
                InvokeStaticDotNetMethodSuccess(logger, invocationInfo.AssemblyName, invocationInfo.MethodIdentifier, invocationInfo.CallId);
            }
            else
            {
                InvokeInstanceDotNetMethodSuccess(logger, invocationInfo.MethodIdentifier, invocationInfo.DotNetObjectId, invocationInfo.CallId);
            }
        }
    }
}