diff --git a/src/Components/Server/src/Circuits/CircuitHost.cs b/src/Components/Server/src/Circuits/CircuitHost.cs index 4e06b1a9e202..bfca2263eba8 100644 --- a/src/Components/Server/src/Circuits/CircuitHost.cs +++ b/src/Components/Server/src/Circuits/CircuitHost.cs @@ -1,14 +1,19 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Buffers; using System.Globalization; using System.Security.Claims; using System.Text.Json; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.JSInterop; using Microsoft.JSInterop.Infrastructure; namespace Microsoft.AspNetCore.Components.Server.Circuits @@ -440,6 +445,56 @@ internal async Task ReceiveJSDataChunk(long streamId, long chunkId, byte[] } } + public async Task SendDotNetStreamAsync(DotNetStreamReference dotNetStreamReference, long streamId, byte[] buffer) + { + AssertInitialized(); + AssertNotDisposed(); + + try + { + return await Renderer.Dispatcher.InvokeAsync(async () => await dotNetStreamReference.Stream.ReadAsync(buffer)); + } + catch (Exception ex) + { + // An error completing stream interop means that the user sent invalid data, a well-behaved + // client won't do this. + Log.SendDotNetStreamException(_logger, streamId, ex); + await TryNotifyClientErrorAsync(Client, GetClientErrorMessage(ex, "Unable to send .NET stream.")); + UnhandledException?.Invoke(this, new UnhandledExceptionEventArgs(ex, isTerminating: false)); + return 0; + } + } + + public async Task TryClaimPendingStream(long streamId) + { + AssertInitialized(); + AssertNotDisposed(); + + DotNetStreamReference dotNetStreamReference = null; + + try + { + return await Renderer.Dispatcher.InvokeAsync(() => + { + if (!JSRuntime.TryClaimPendingStreamForSending(streamId, out dotNetStreamReference)) + { + throw new InvalidOperationException($"The stream with ID {streamId} is not available. It may have timed out."); + } + + return dotNetStreamReference; + }); + } + catch (Exception ex) + { + // An error completing stream interop means that the user sent invalid data, a well-behaved + // client won't do this. + Log.SendDotNetStreamException(_logger, streamId, ex); + await TryNotifyClientErrorAsync(Client, GetClientErrorMessage(ex, "Unable to locate .NET stream.")); + UnhandledException?.Invoke(this, new UnhandledExceptionEventArgs(ex, isTerminating: false)); + return default; + } + } + // OnLocationChangedAsync is used in a fire-and-forget context, so it's responsible for its own // error handling. public async Task OnLocationChangedAsync(string uri, bool intercepted) @@ -615,6 +670,7 @@ private static class Log private static readonly Action _receiveByteArraySuccess; private static readonly Action _receiveByteArrayException; private static readonly Action _receiveJSDataChunkException; + private static readonly Action _sendDotNetStreamException; private static readonly Action _dispatchEventFailedToParseEventData; private static readonly Action _dispatchEventFailedToDispatchEvent; private static readonly Action _locationChange; @@ -660,6 +716,7 @@ private static class EventIds public static readonly EventId ReceiveByteArraySucceeded = new EventId(213, "ReceiveByteArraySucceeded"); public static readonly EventId ReceiveByteArrayException = new EventId(214, "ReceiveByteArrayException"); public static readonly EventId ReceiveJSDataChunkException = new EventId(215, "ReceiveJSDataChunkException"); + public static readonly EventId SendDotNetStreamException = new EventId(216, "SendDotNetStreamException"); } static Log() @@ -790,9 +847,14 @@ static Log() "The ReceiveByteArray call with id '{id}' failed."); _receiveJSDataChunkException = LoggerMessage.Define( + LogLevel.Debug, + EventIds.ReceiveJSDataChunkException, + "The ReceiveJSDataChunk call with stream id '{streamId}' failed."); + + _sendDotNetStreamException = LoggerMessage.Define( LogLevel.Debug, - EventIds.ReceiveJSDataChunkException, - "The ReceiveJSDataChunk call with stream id '{streamId}' failed."); + EventIds.SendDotNetStreamException, + "The SendDotNetStreamAsync call with id '{id}' failed."); _dispatchEventFailedToParseEventData = LoggerMessage.Define( LogLevel.Debug, @@ -856,9 +918,10 @@ public static void CircuitHandlerFailed(ILogger logger, CircuitHandler handler, public static void EndInvokeDispatchException(ILogger logger, Exception ex) => _endInvokeDispatchException(logger, ex); public static void EndInvokeJSFailed(ILogger logger, long asyncHandle, string arguments) => _endInvokeJSFailed(logger, asyncHandle, arguments, null); public static void EndInvokeJSSucceeded(ILogger logger, long asyncCall) => _endInvokeJSSucceeded(logger, asyncCall, null); - internal static void ReceiveByteArraySuccess(ILogger logger, long id) => _receiveByteArraySuccess(logger, id, null); - internal static void ReceiveByteArrayException(ILogger logger, long id, Exception ex) => _receiveByteArrayException(logger, id, ex); - internal static void ReceiveJSDataChunkException(ILogger logger, long streamId, Exception ex) => _receiveJSDataChunkException(logger, streamId, ex); + public static void ReceiveByteArraySuccess(ILogger logger, long id) => _receiveByteArraySuccess(logger, id, null); + public static void ReceiveByteArrayException(ILogger logger, long id, Exception ex) => _receiveByteArrayException(logger, id, ex); + public static void ReceiveJSDataChunkException(ILogger logger, long streamId, Exception ex) => _receiveJSDataChunkException(logger, streamId, ex); + public static void SendDotNetStreamException(ILogger logger, long streamId, Exception ex) => _sendDotNetStreamException(logger, streamId, ex); public static void DispatchEventFailedToParseEventData(ILogger logger, Exception ex) => _dispatchEventFailedToParseEventData(logger, ex); public static void DispatchEventFailedToDispatchEvent(ILogger logger, string eventHandlerId, Exception ex) => _dispatchEventFailedToDispatchEvent(logger, eventHandlerId ?? "", ex); diff --git a/src/Components/Server/src/Circuits/RemoteJSRuntime.cs b/src/Components/Server/src/Circuits/RemoteJSRuntime.cs index 1c18ba50ded5..a35bd958a955 100644 --- a/src/Components/Server/src/Circuits/RemoteJSRuntime.cs +++ b/src/Components/Server/src/Circuits/RemoteJSRuntime.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Text.Json; @@ -20,6 +21,7 @@ internal class RemoteJSRuntime : JSRuntime private readonly CircuitOptions _options; private readonly ILogger _logger; private CircuitClientProxy _clientProxy; + private readonly ConcurrentDictionary _pendingDotNetToJSStreams = new(); private bool _permanentlyDisconnected; private readonly long _maximumIncomingBytes; private int _byteArraysToBeRevivedTotalBytes; @@ -151,6 +153,40 @@ protected override void ReceiveByteArray(int id, byte[] data) 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; diff --git a/src/Components/Server/src/ComponentHub.cs b/src/Components/Server/src/ComponentHub.cs index 154ed91a41be..4461e9812a96 100644 --- a/src/Components/Server/src/ComponentHub.cs +++ b/src/Components/Server/src/ComponentHub.cs @@ -6,6 +6,7 @@ using System.Diagnostics; using System.Runtime.CompilerServices; using System.Text.Json; +using System.Threading.Channels; using System.Threading.Tasks; using Microsoft.AspNetCore.Components.Server.Circuits; using Microsoft.AspNetCore.DataProtection; @@ -242,6 +243,41 @@ public async ValueTask ReceiveJSDataChunk(long streamId, long chunkId, byt return await circuitHost.ReceiveJSDataChunk(streamId, chunkId, chunk, error); } + public async IAsyncEnumerable> SendDotNetStreamToJS(long streamId) + { + var circuitHost = await GetActiveCircuitAsync(); + if (circuitHost == null) + { + yield break; + } + + var dotNetStreamReference = await circuitHost.TryClaimPendingStream(streamId); + if (dotNetStreamReference == default) + { + yield break; + } + + var buffer = ArrayPool.Shared.Rent(32 * 1024); + + try + { + int bytesRead; + while ((bytesRead = await circuitHost.SendDotNetStreamAsync(dotNetStreamReference, streamId, buffer)) > 0) + { + yield return new ArraySegment(buffer, 0, bytesRead); + } + } + finally + { + ArrayPool.Shared.Return(buffer, clearArray: true); + + if (!dotNetStreamReference.LeaveOpen) + { + dotNetStreamReference.Stream?.Dispose(); + } + } + } + public async ValueTask OnRenderCompleted(long renderId, string errorMessageOrNull) { var circuitHost = await GetActiveCircuitAsync(); @@ -325,6 +361,9 @@ private static class Log private static readonly Action _invalidCircuitId = LoggerMessage.Define(LogLevel.Debug, new EventId(8, "InvalidCircuitId"), "ConnectAsync received an invalid circuit id '{CircuitIdSecret}'"); + private static readonly Action _sendingDotNetStreamFailed = + LoggerMessage.Define(LogLevel.Debug, new EventId(9, "SendingDotNetStreamFailed"), "Sending the .NET stream data to JS failed"); + public static void ReceivedConfirmationForBatch(ILogger logger, long batchId) => _receivedConfirmationForBatch(logger, batchId, null); public static void CircuitAlreadyInitialized(ILogger logger, CircuitId circuitId) => _circuitAlreadyInitialized(logger, circuitId, null); @@ -337,6 +376,8 @@ private static class Log public static void CircuitInitializationFailed(ILogger logger, Exception exception) => _circuitInitializationFailed(logger, exception); + public static void SendingDotNetStreamFailed(ILogger logger, Exception exception) => _sendingDotNetStreamFailed(logger, exception); + public static void CreatedCircuit(ILogger logger, CircuitId circuitId, string circuitSecret, string connectionId) { // Redact the secret unless tracing is on. diff --git a/src/Components/Shared/src/TransmitDataStreamToJS.cs b/src/Components/Shared/src/TransmitDataStreamToJS.cs new file mode 100644 index 000000000000..e320997056ce --- /dev/null +++ b/src/Components/Shared/src/TransmitDataStreamToJS.cs @@ -0,0 +1,56 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; +using Microsoft.JSInterop; + +namespace Microsoft.AspNetCore.Components +{ + /// + /// A stream that pulls each chunk on demand using JavaScript interop. This implementation is used for + /// WebAssembly and WebView applications. + /// + internal static class TransmitDataStreamToJS + { + internal static async Task TransmitStreamAsync(IJSRuntime runtime, long streamId, DotNetStreamReference dotNetStreamReference) + { + var buffer = ArrayPool.Shared.Rent(32 * 1024); + + try + { + int bytesRead; + while ((bytesRead = await dotNetStreamReference.Stream.ReadAsync(buffer)) > 0) + { + await runtime.InvokeVoidAsync("Blazor._internal.receiveDotNetDataStream", streamId, buffer, bytesRead, null); + } + + // Notify client that the stream has completed + await runtime.InvokeVoidAsync("Blazor._internal.receiveDotNetDataStream", streamId, Array.Empty(), 0, null); + } + catch (Exception ex) + { + try + { + // Attempt to notify the client of the error. + await runtime.InvokeVoidAsync("Blazor._internal.receiveDotNetDataStream", streamId, Array.Empty(), 0, ex.Message); + } + catch + { + // JS Interop encountered an issue, unable to send error message to JS. + } + + throw; + } + finally + { + ArrayPool.Shared.Return(buffer, clearArray: true); + + if (!dotNetStreamReference.LeaveOpen) + { + dotNetStreamReference.Stream?.Dispose(); + } + } + } + + } +} diff --git a/src/Components/Web.JS/src/Boot.Server.ts b/src/Components/Web.JS/src/Boot.Server.ts index 534bc7e04c29..5e23268a67da 100644 --- a/src/Components/Web.JS/src/Boot.Server.ts +++ b/src/Components/Web.JS/src/Boot.Server.ts @@ -101,6 +101,20 @@ async function initializeConnection(options: CircuitStartOptions, logger: Logger connection.on('JS.EndInvokeDotNet', DotNet.jsCallDispatcher.endInvokeDotNetFromJS); connection.on('JS.ReceiveByteArray', DotNet.jsCallDispatcher.receiveByteArray); + connection.on('JS.BeginTransmitStream', (streamId: number) => { + const readableStream = new ReadableStream({ + start(controller) { + connection.stream('SendDotNetStreamToJS', streamId).subscribe({ + next: (chunk: Uint8Array) => controller.enqueue(chunk), + complete: () => controller.close(), + error: (err) => controller.error(err), + }); + } + }); + + DotNet.jsCallDispatcher.supplyDotNetStream(streamId, readableStream); + }); + const renderQueue = RenderQueue.getOrCreate(logger); connection.on('JS.RenderBatch', (batchId: number, batchData: Uint8Array) => { logger.log(LogLevel.Debug, `Received render batch with id ${batchId} and ${batchData.byteLength} bytes.`); diff --git a/src/Components/Web.JS/src/GlobalExports.ts b/src/Components/Web.JS/src/GlobalExports.ts index 26c2a447a271..21ab215dda89 100644 --- a/src/Components/Web.JS/src/GlobalExports.ts +++ b/src/Components/Web.JS/src/GlobalExports.ts @@ -9,7 +9,7 @@ import { DefaultReconnectionHandler } from './Platform/Circuits/DefaultReconnect import { CircuitStartOptions } from './Platform/Circuits/CircuitStartOptions'; import { WebAssemblyStartOptions } from './Platform/WebAssemblyStartOptions'; import { Platform, Pointer, System_String, System_Array, System_Object, System_Boolean, System_Byte, System_Int } from './Platform/Platform'; -import { getNextChunk } from './StreamingInterop'; +import { getNextChunk, receiveDotNetDataStream } from './StreamingInterop'; import { RootComponentsFunctions } from './Rendering/JSRootComponents'; import { attachWebRendererInterop } from './Rendering/WebRendererInteropMethods'; @@ -56,6 +56,7 @@ interface IBlazor { getSatelliteAssemblies?: any, sendJSDataStream?: (data: any, streamId: number, chunkSize: number) => void, getJSDataStreamChunk?: (data: any, position: number, chunkSize: number) => Promise, + receiveDotNetDataStream?: (streamId: number, data: any, bytesRead: number, errorMessage: string) => void, attachWebRendererInterop?: typeof attachWebRendererInterop, // APIs invoked by hot reload @@ -76,6 +77,7 @@ export const Blazor: IBlazor = { PageTitle, InputFile, getJSDataStreamChunk: getNextChunk, + receiveDotNetDataStream: receiveDotNetDataStream, attachWebRendererInterop, }, }; diff --git a/src/Components/Web.JS/src/Platform/WebView/WebViewIpcReceiver.ts b/src/Components/Web.JS/src/Platform/WebView/WebViewIpcReceiver.ts index 123aedbc783c..6975215eaadb 100644 --- a/src/Components/Web.JS/src/Platform/WebView/WebViewIpcReceiver.ts +++ b/src/Components/Web.JS/src/Platform/WebView/WebViewIpcReceiver.ts @@ -5,6 +5,7 @@ import { attachRootComponentToElement, renderBatch } from '../../Rendering/Rende import { setApplicationIsTerminated, tryDeserializeMessage } from './WebViewIpcCommon'; import { sendRenderCompleted } from './WebViewIpcSender'; import { internalFunctions as navigationManagerFunctions } from '../../Services/NavigationManager'; +import { receiveDotNetDataStream } from '../../StreamingInterop'; export function startIpcReceiver() { const messageHandlers = { diff --git a/src/Components/Web.JS/src/StreamingInterop.ts b/src/Components/Web.JS/src/StreamingInterop.ts index dac06d60439b..34d374040455 100644 --- a/src/Components/Web.JS/src/StreamingInterop.ts +++ b/src/Components/Web.JS/src/StreamingInterop.ts @@ -1,3 +1,5 @@ +import { DotNet } from '@microsoft/dotnet-js-interop'; + export async function getNextChunk(data: ArrayBufferView | Blob, position: number, nextChunkSize: number): Promise { if (data instanceof Blob) { return await getChunkFromBlob(data, position, nextChunkSize); @@ -17,3 +19,28 @@ function getChunkFromArrayBufferView(data: ArrayBufferView, position: number, ne const nextChunkData = new Uint8Array(data.buffer, data.byteOffset + position, nextChunkSize); return nextChunkData; } + +const transmittingDotNetToJSStreams = new Map>(); +export function receiveDotNetDataStream(streamId: number, data: Uint8Array, bytesRead: number, errorMessage: string): void { + let streamController = transmittingDotNetToJSStreams.get(streamId); + if (!streamController) { + const readableStream = new ReadableStream({ + start(controller) { + transmittingDotNetToJSStreams.set(streamId, controller); + streamController = controller; + } + }); + + DotNet.jsCallDispatcher.supplyDotNetStream(streamId, readableStream); + } + + if (errorMessage) { + streamController!.error(errorMessage); + transmittingDotNetToJSStreams.delete(streamId); + } else if (bytesRead === 0) { + streamController!.close(); + transmittingDotNetToJSStreams.delete(streamId); + } else { + streamController!.enqueue(data.length === bytesRead ? data : data.subarray(0, bytesRead)); + } +} diff --git a/src/Components/WebAssembly/WebAssembly/src/Microsoft.AspNetCore.Components.WebAssembly.csproj b/src/Components/WebAssembly/WebAssembly/src/Microsoft.AspNetCore.Components.WebAssembly.csproj index 635fd625c0e0..edd1d5ef6c81 100644 --- a/src/Components/WebAssembly/WebAssembly/src/Microsoft.AspNetCore.Components.WebAssembly.csproj +++ b/src/Components/WebAssembly/WebAssembly/src/Microsoft.AspNetCore.Components.WebAssembly.csproj @@ -24,6 +24,7 @@ + diff --git a/src/Components/WebAssembly/WebAssembly/src/Services/DefaultWebAssemblyJSRuntime.cs b/src/Components/WebAssembly/WebAssembly/src/Services/DefaultWebAssemblyJSRuntime.cs index bf3fba7c1efb..8aead33fd35e 100644 --- a/src/Components/WebAssembly/WebAssembly/src/Services/DefaultWebAssemblyJSRuntime.cs +++ b/src/Components/WebAssembly/WebAssembly/src/Services/DefaultWebAssemblyJSRuntime.cs @@ -8,6 +8,7 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.WebAssembly.Hosting; using Microsoft.JSInterop; using Microsoft.JSInterop.Infrastructure; @@ -98,5 +99,11 @@ public static void NotifyByteArrayAvailable(int id) /// protected override Task ReadJSDataAsStreamAsync(IJSStreamReference jsStreamReference, long totalLength, CancellationToken cancellationToken = default) => Task.FromResult(PullFromJSDataStream.CreateJSDataStream(this, jsStreamReference, totalLength, cancellationToken)); + + /// + protected override Task TransmitStreamAsync(long streamId, DotNetStreamReference dotNetStreamReference) + { + return TransmitDataStreamToJS.TransmitStreamAsync(this, streamId, dotNetStreamReference); + } } } diff --git a/src/Components/WebView/WebView/src/Microsoft.AspNetCore.Components.WebView.csproj b/src/Components/WebView/WebView/src/Microsoft.AspNetCore.Components.WebView.csproj index d7a6d9de5bd3..075ed8d99f99 100644 --- a/src/Components/WebView/WebView/src/Microsoft.AspNetCore.Components.WebView.csproj +++ b/src/Components/WebView/WebView/src/Microsoft.AspNetCore.Components.WebView.csproj @@ -21,7 +21,8 @@ - + + diff --git a/src/Components/WebView/WebView/src/Services/WebViewJSRuntime.cs b/src/Components/WebView/WebView/src/Services/WebViewJSRuntime.cs index 0d4e33bf87c5..8c62fbac423c 100644 --- a/src/Components/WebView/WebView/src/Services/WebViewJSRuntime.cs +++ b/src/Components/WebView/WebView/src/Services/WebViewJSRuntime.cs @@ -7,6 +7,7 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using Microsoft.AspNetCore.Components; using Microsoft.JSInterop; using Microsoft.JSInterop.Infrastructure; @@ -53,5 +54,10 @@ protected override void SendByteArray(int id, byte[] data) protected override Task ReadJSDataAsStreamAsync(IJSStreamReference jsStreamReference, long totalLength, CancellationToken cancellationToken = default) => Task.FromResult(PullFromJSDataStream.CreateJSDataStream(this, jsStreamReference, totalLength, cancellationToken)); + + protected override Task TransmitStreamAsync(long streamId, DotNetStreamReference dotNetStreamReference) + { + return TransmitDataStreamToJS.TransmitStreamAsync(this, streamId, dotNetStreamReference); + } } } diff --git a/src/Components/test/E2ETest/Tests/InteropTest.cs b/src/Components/test/E2ETest/Tests/InteropTest.cs index 5181a5b99fac..e3e8c46b18df 100644 --- a/src/Components/test/E2ETest/Tests/InteropTest.cs +++ b/src/Components/test/E2ETest/Tests/InteropTest.cs @@ -65,6 +65,8 @@ public void CanInvokeDotNetMethods() ["roundTripByteArrayWrapperObjectAsyncFromDotNet"] = @"StrVal: Some String, IntVal: 100000, ByteArrayVal: 1,5,7,15,35,200", ["jsToDotNetStreamReturnValueAsync"] = "Success", ["jsToDotNetStreamWrapperObjectReturnValueAsync"] = "Success", + ["dotNetToJSReceiveDotNetStreamReferenceAsync"] = "Success", + ["dotNetToJSReceiveDotNetStreamWrapperReferenceAsync"] = "Success", ["jsToDotNetStreamParameterAsync"] = @"""Success""", ["jsToDotNetStreamWrapperObjectParameterAsync"] = @"""Success""", ["AsyncThrowSyncException"] = @"""System.InvalidOperationException: Threw a sync exception!", @@ -88,6 +90,8 @@ public void CanInvokeDotNetMethods() ["jsObjectReferenceModule"] = "Returned from module!", ["syncGenericInstanceMethod"] = @"""Initial value""", ["asyncGenericInstanceMethod"] = @"""Updated value 1""", + ["requestDotNetStreamReferenceAsync"] = @"""Success""", + ["requestDotNetStreamWrapperReferenceAsync"] = @"""Success""", }; var expectedSyncValues = new Dictionary @@ -133,6 +137,8 @@ public void CanInvokeDotNetMethods() ["returnPrimitive"] = "123", ["returnArray"] = "first,second", ["genericInstanceMethod"] = @"""Updated value 2""", + ["requestDotNetStreamReference"] = @"""Success""", + ["requestDotNetStreamWrapperReference"] = @"""Success""", }; // Include the sync assertions only when running under WebAssembly @@ -155,8 +161,15 @@ public void CanInvokeDotNetMethods() foreach (var expectedValue in expectedValues) { - var currentValue = Browser.Exists(By.Id(expectedValue.Key)); - actualValues.Add(expectedValue.Key, currentValue.Text); + try + { + var currentValue = Browser.Exists(By.Id(expectedValue.Key)); + actualValues.Add(expectedValue.Key, currentValue.Text); + } + catch (Exception ex) + { + throw new Exception($"Failed to find test id {expectedValue.Key} in DOM.", ex); + } } // Assert diff --git a/src/Components/test/testassets/BasicTestApp/InteropComponent.razor b/src/Components/test/testassets/BasicTestApp/InteropComponent.razor index cd0fbd5cb3e6..49671da348ec 100644 --- a/src/Components/test/testassets/BasicTestApp/InteropComponent.razor +++ b/src/Components/test/testassets/BasicTestApp/InteropComponent.razor @@ -196,6 +196,12 @@ await ValidateStreamValuesAsync("jsToDotNetStreamWrapperObjectReturnValueAsync", dataWrapperReferenceStream); } + var dotNetStreamReference = DotNetStreamReferenceInterop.GetDotNetStreamReference(); + ReturnValues["dotNetToJSReceiveDotNetStreamReferenceAsync"] = await JSRuntime.InvokeAsync("jsInteropTests.receiveDotNetStreamReference", dotNetStreamReference); + + var dotNetStreamReferenceWrapper = DotNetStreamReferenceInterop.GetDotNetStreamWrapperReference(); + ReturnValues["dotNetToJSReceiveDotNetStreamWrapperReferenceAsync"] = await JSRuntime.InvokeAsync("jsInteropTests.receiveDotNetStreamWrapperReference", dotNetStreamReferenceWrapper); + Invocations = invocations; DoneWithInterop = true; } diff --git a/src/Components/test/testassets/BasicTestApp/InteropTest/DotNetStreamReferenceInterop.cs b/src/Components/test/testassets/BasicTestApp/InteropTest/DotNetStreamReferenceInterop.cs new file mode 100644 index 000000000000..6cc4b7aaaf31 --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/InteropTest/DotNetStreamReferenceInterop.cs @@ -0,0 +1,60 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IO; +using System.Threading.Tasks; +using Microsoft.JSInterop; + +namespace BasicTestApp.InteropTest +{ + public class DotNetStreamReferenceInterop + { + [JSInvokable] + public static DotNetStreamReference GetDotNetStreamReference() + { + var data = new byte[100000]; + for (var i = 0; i < data.Length; i++) + { + data[i] = (byte)(i % 256); + } + + var dataStream = new MemoryStream(data); + var streamRef = new DotNetStreamReference(dataStream); + return streamRef; + } + + [JSInvokable] + public static Task GetDotNetStreamReferenceAsync() + { + return Task.FromResult(GetDotNetStreamReference()); + } + + [JSInvokable] + public static DotNetStreamReferenceWrapper GetDotNetStreamWrapperReference() + { + var wrapper = new DotNetStreamReferenceWrapper() + { + StrVal = "somestr", + DotNetStreamReferenceVal = GetDotNetStreamReference(), + IntVal = 25, + }; + + return wrapper; + } + + [JSInvokable] + public static Task GetDotNetStreamWrapperReferenceAsync() + { + return Task.FromResult(GetDotNetStreamWrapperReference()); + } + + public class DotNetStreamReferenceWrapper + { + public string StrVal { get; set; } + + public DotNetStreamReference DotNetStreamReferenceVal { get; set; } + + public int IntVal { get; set; } + } + } +} diff --git a/src/Components/test/testassets/BasicTestApp/wwwroot/js/jsinteroptests.js b/src/Components/test/testassets/BasicTestApp/wwwroot/js/jsinteroptests.js index 6006e544cc96..af90a946230e 100644 --- a/src/Components/test/testassets/BasicTestApp/wwwroot/js/jsinteroptests.js +++ b/src/Components/test/testassets/BasicTestApp/wwwroot/js/jsinteroptests.js @@ -49,6 +49,13 @@ async function invokeDotNetInteropMethodsAsync(shouldSupportSyncInterop, dotNetO var returnedByteArrayWrapper = DotNet.invokeMethod(assemblyName, 'RoundTripByteArrayWrapperObject', byteArrayWrapper); results['roundTripByteArrayWrapperObjectFromJS'] = returnedByteArrayWrapper; + // Note the following .NET Stream Reference E2E tests are synchronous for the test execution + // however the validation is async (due to the nature of stream validations). + var streamRef = DotNet.invokeMethod(assemblyName, 'GetDotNetStreamReference'); + results['requestDotNetStreamReference'] = await validateDotNetStreamReference(streamRef); + var streamWrapper = DotNet.invokeMethod(assemblyName, 'GetDotNetStreamWrapperReference'); + results['requestDotNetStreamWrapperReference'] = await validateDotNetStreamWrapperReference(streamWrapper); + var instanceMethodResult = instanceMethodsTarget.invokeMethod('InstanceMethod', { stringValue: 'My string', dtoByRef: dotNetObjectByRef @@ -112,6 +119,11 @@ async function invokeDotNetInteropMethodsAsync(shouldSupportSyncInterop, dotNetO var streamWrapper = { 'strVal': "SomeStr", 'jsStreamReferenceVal': jsStreamReference, 'intVal': 5 }; results['jsToDotNetStreamWrapperObjectParameterAsync'] = await DotNet.invokeMethodAsync(assemblyName, 'JSToDotNetStreamWrapperObjectParameterAsync', streamWrapper); + var streamRef = await DotNet.invokeMethodAsync(assemblyName, 'GetDotNetStreamReferenceAsync'); + results['requestDotNetStreamReferenceAsync'] = await validateDotNetStreamReference(streamRef); + var wrapper = await DotNet.invokeMethodAsync(assemblyName, 'GetDotNetStreamWrapperReferenceAsync'); + results['requestDotNetStreamWrapperReferenceAsync'] = await validateDotNetStreamWrapperReference(wrapper); + const instanceMethodAsync = await instanceMethodsTarget.invokeMethodAsync('InstanceMethodAsync', { stringValue: 'My string', dtoByRef: dotNetObjectByRef @@ -216,7 +228,9 @@ window.jsInteropTests = { returnJSObjectReference: returnJSObjectReference, addViaJSObjectReference: addViaJSObjectReference, receiveDotNetObjectByRef: receiveDotNetObjectByRef, - receiveDotNetObjectByRefAsync: receiveDotNetObjectByRefAsync + receiveDotNetObjectByRefAsync: receiveDotNetObjectByRefAsync, + receiveDotNetStreamReference: receiveDotNetStreamReference, + receiveDotNetStreamWrapperReference: receiveDotNetStreamWrapperReference, }; function returnPrimitive() { @@ -395,3 +409,24 @@ function receiveDotNetObjectByRefAsync(incomingData) { }; }); } + +async function validateDotNetStreamReference(streamRef) { + const data = new Uint8Array(await streamRef.arrayBuffer()); + const isValid = data.length == 100000 && data.every((value, index) => value == index % 256); + return isValid ? "Success" : `Failure, got length ${data.length} with data ${data}`; +} + +async function validateDotNetStreamWrapperReference(wrapper) { + const isValid = await validateDotNetStreamReference(wrapper.dotNetStreamReferenceVal) == "Success" && + wrapper.strVal == "somestr" && + wrapper.intVal == 25; + return isValid ? "Success" : `Failure, got ${JSON.stringify(wrapper)}`; +} + +async function receiveDotNetStreamReference(streamRef) { + return await validateDotNetStreamReference(streamRef); +} + +async function receiveDotNetStreamWrapperReference(wrapper) { + return await validateDotNetStreamWrapperReference(wrapper); +} diff --git a/src/JSInterop/Microsoft.JSInterop.JS/src/src/Microsoft.JSInterop.ts b/src/JSInterop/Microsoft.JSInterop.JS/src/src/Microsoft.JSInterop.ts index 99709b53d83c..e51f45db2f86 100644 --- a/src/JSInterop/Microsoft.JSInterop.JS/src/src/Microsoft.JSInterop.ts +++ b/src/JSInterop/Microsoft.JSInterop.JS/src/src/Microsoft.JSInterop.ts @@ -5,7 +5,15 @@ export module DotNet { export type JsonReviver = ((key: any, value: any) => any); const jsonRevivers: JsonReviver[] = []; + const byteArraysToBeRevived = new Map(); + const pendingDotNetToJSStreams = new Map(); + + const jsObjectIdKey = "__jsObjectId"; + const dotNetObjectRefKey = '__dotNetObject'; + const byteArrayRefKey = '__byte[]'; + const dotNetStreamRefKey = '__dotNetStream'; + const jsStreamReferenceLengthKey = "__jsStreamReferenceLength"; class JSObject { _cachedFunctions: Map; @@ -47,9 +55,6 @@ export module DotNet { } } - const jsObjectIdKey = "__jsObjectId"; - const jsStreamReferenceLengthKey = "__jsStreamReferenceLength"; - const pendingAsyncCalls: { [id: number]: PendingAsyncCall } = {}; const windowJSObjectId = 0; const cachedJSObjectsById: { [id: number]: JSObject } = { @@ -406,7 +411,27 @@ export module DotNet { */ receiveByteArray: (id: number, data: Uint8Array): void => { byteArraysToBeRevived.set(id, data); - } + }, + + /** + * Supplies a stream of data being sent from .NET. + * + * @param streamId The identifier previously passed to JSRuntime's BeginTransmittingStream in .NET code + * @param stream The stream data. + */ + supplyDotNetStream: (streamId: number, stream: ReadableStream) => { + if (pendingDotNetToJSStreams.has(streamId)) { + // The receiver is already waiting, so we can resolve the promise now and stop tracking this + const pendingStream = pendingDotNetToJSStreams.get(streamId)!; + pendingDotNetToJSStreams.delete(streamId); + pendingStream.resolve!(stream); + } else { + // The receiver hasn't started waiting yet, so track a pre-completed entry it can attach to later + const pendingStream = new PendingStream(); + pendingStream.resolve!(stream); + pendingDotNetToJSStreams.set(streamId, pendingStream); + } + }, } function formatError(error: any): string { @@ -453,12 +478,10 @@ export module DotNet { } } - const dotNetObjectRefKey = '__dotNetObject'; - const byteArrayRefKey = '__byte[]'; attachReviver(function reviveReference(key: any, value: any) { if (value && typeof value === 'object') { if (value.hasOwnProperty(dotNetObjectRefKey)) { - return new DotNetObject(value.__dotNetObject); + return new DotNetObject(value[dotNetObjectRefKey]); } else if (value.hasOwnProperty(jsObjectIdKey)) { const id = value[jsObjectIdKey]; const jsObject = cachedJSObjectsById[id]; @@ -474,8 +497,11 @@ export module DotNet { if (byteArray === undefined) { throw new Error(`Byte array index '${index}' does not exist.`); } + byteArraysToBeRevived.delete(index); return byteArray; + } else if (value.hasOwnProperty(dotNetStreamRefKey)) { + return new DotNetStream(value[dotNetStreamRefKey]) } } @@ -483,6 +509,55 @@ export module DotNet { return value; }); + class DotNetStream { + private _streamPromise: Promise; + + constructor(streamId: number) { + // This constructor runs when we're JSON-deserializing some value from the .NET side. + // At this point we might already have started receiving the stream, or maybe it will come later. + // We have to handle both possible orderings, but we can count on it coming eventually because + // it's not something the developer gets to control, and it would be an error if it doesn't. + if (pendingDotNetToJSStreams.has(streamId)) { + // We've already started receiving the stream, so no longer need to track it as pending + this._streamPromise = pendingDotNetToJSStreams.get(streamId)?.streamPromise!; + pendingDotNetToJSStreams.delete(streamId); + } else { + // We haven't started receiving it yet, so add an entry to track it as pending + const pendingStream = new PendingStream(); + pendingDotNetToJSStreams.set(streamId, pendingStream); + this._streamPromise = pendingStream.streamPromise; + } + } + + /** + * Supplies a readable stream of data being sent from .NET. + */ + stream(): Promise { + return this._streamPromise; + } + + /** + * Supplies a array buffer of data being sent from .NET. + * Note there is a JavaScript limit on the size of the ArrayBuffer equal to approximately 2GB. + */ + async arrayBuffer(): Promise { + return new Response(await this.stream()).arrayBuffer(); + } + } + + class PendingStream { + streamPromise: Promise; + resolve?: (value: ReadableStream) => void; + reject?: (reason: any) => void; + + constructor() { + this.streamPromise = new Promise((resolve, reject) => { + this.resolve = resolve; + this.reject = reject; + }); + } + } + function createJSCallResult(returnValue: any, resultType: JSCallResultType) { switch (resultType) { case JSCallResultType.Default: diff --git a/src/JSInterop/Microsoft.JSInterop/src/DotNetStreamReference.cs b/src/JSInterop/Microsoft.JSInterop/src/DotNetStreamReference.cs new file mode 100644 index 000000000000..d1e89b73b71e --- /dev/null +++ b/src/JSInterop/Microsoft.JSInterop/src/DotNetStreamReference.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.JSInterop +{ + /// + /// Represents the reference to a .NET stream sent to JavaScript. + /// + public sealed class DotNetStreamReference : IDisposable + { + /// + /// Create a reference to a .NET stream sent to JavaScript. + /// + /// The stream being sent to JavaScript. + /// A flag that indicates whether the stream should be left open after transmission. + public DotNetStreamReference(Stream stream, bool leaveOpen = false) + { + Stream = stream ?? throw new ArgumentNullException(nameof(stream)); + LeaveOpen = leaveOpen; + } + + /// + /// The stream being sent to JavaScript. + /// + public Stream Stream { get; } + + /// + /// A flag that indicates whether the stream should be left open after transmission. + /// + public bool LeaveOpen { get; } + + /// + public void Dispose() + { + if (!LeaveOpen) + { + Stream.Dispose(); + } + } + } +} diff --git a/src/JSInterop/Microsoft.JSInterop/src/Infrastructure/DotNetStreamReferenceJsonConverter.cs b/src/JSInterop/Microsoft.JSInterop/src/Infrastructure/DotNetStreamReferenceJsonConverter.cs new file mode 100644 index 000000000000..438f144fcb62 --- /dev/null +++ b/src/JSInterop/Microsoft.JSInterop/src/Infrastructure/DotNetStreamReferenceJsonConverter.cs @@ -0,0 +1,37 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.JSInterop.Infrastructure +{ + internal sealed class DotNetStreamReferenceJsonConverter : JsonConverter + { + private static readonly JsonEncodedText DotNetStreamRefKey = JsonEncodedText.Encode("__dotNetStream"); + + public DotNetStreamReferenceJsonConverter(JSRuntime jsRuntime) + { + JSRuntime = jsRuntime; + } + + public JSRuntime JSRuntime { get; } + + public override DotNetStreamReference Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => throw new NotSupportedException($"{nameof(DotNetStreamReference)} cannot be supplied from JavaScript to .NET because the stream contents have already been transferred."); + + public override void Write(Utf8JsonWriter writer, DotNetStreamReference value, JsonSerializerOptions options) + { + // We only serialize a DotNetStreamReference using this converter when we're supplying that info + // to JS. We want to transmit the stream immediately as part of this process, so that the .NET side + // doesn't have to hold onto the stream waiting for JS to request it. If a developer doesn't really + // want to send the data, they shouldn't include the DotNetStreamReference in the object graph + // they are sending to the JS side. + var id = JSRuntime.BeginTransmittingStream(value); + writer.WriteStartObject(); + writer.WriteNumber(DotNetStreamRefKey, id); + writer.WriteEndObject(); + } + } +} diff --git a/src/JSInterop/Microsoft.JSInterop/src/JSRuntime.cs b/src/JSInterop/Microsoft.JSInterop/src/JSRuntime.cs index 8f8cffc1612c..ba4f00bc99bd 100644 --- a/src/JSInterop/Microsoft.JSInterop/src/JSRuntime.cs +++ b/src/JSInterop/Microsoft.JSInterop/src/JSRuntime.cs @@ -1,14 +1,10 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; using System.Collections.Concurrent; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; -using System.IO; using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; using Microsoft.JSInterop.Infrastructure; using static Microsoft.AspNetCore.Internal.LinkerFlags; @@ -42,6 +38,7 @@ protected JSRuntime() new DotNetObjectReferenceJsonConverterFactory(this), new JSObjectReferenceJsonConverter(this), new JSStreamReferenceJsonConverter(this), + new DotNetStreamReferenceJsonConverter(this), new ByteArrayJsonConverter(this), } }; @@ -262,6 +259,34 @@ internal void EndInvokeJS(long taskId, bool succeeded, ref Utf8JsonReader jsonRe } } + /// + /// Transmits the stream data from .NET to JS. Subclasses should override this method and provide + /// an implementation that transports the data to JS and calls DotNet.jsCallDispatcher.supplyDotNetStream. + /// + /// An identifier for the stream. + /// Reference to the .NET stream along with whether the stream should be left open. + protected internal virtual Task TransmitStreamAsync(long streamId, DotNetStreamReference dotNetStreamReference) + { + if (!dotNetStreamReference.LeaveOpen) + { + dotNetStreamReference.Stream.Dispose(); + } + + throw new NotSupportedException("The current JS runtime does not support sending streams from .NET to JS."); + } + + internal long BeginTransmittingStream(DotNetStreamReference dotNetStreamReference) + { + // It's fine to share the ID sequence + var streamId = Interlocked.Increment(ref _nextObjectReferenceId); + + // Fire and forget sending the stream so the client can proceed to + // reading the stream. + _ = TransmitStreamAsync(streamId, dotNetStreamReference); + + return streamId; + } + internal long TrackObjectReference(DotNetObjectReference dotNetObjectReference) where TValue : class { if (dotNetObjectReference == null) diff --git a/src/JSInterop/Microsoft.JSInterop/src/PublicAPI.Unshipped.txt b/src/JSInterop/Microsoft.JSInterop/src/PublicAPI.Unshipped.txt index c1c3f1adc9a2..cca17c3ed563 100644 --- a/src/JSInterop/Microsoft.JSInterop/src/PublicAPI.Unshipped.txt +++ b/src/JSInterop/Microsoft.JSInterop/src/PublicAPI.Unshipped.txt @@ -5,6 +5,11 @@ Microsoft.JSInterop.IJSStreamReference.OpenReadStreamAsync(long maxAllowedSize = Microsoft.JSInterop.Implementation.JSStreamReference Microsoft.JSInterop.Implementation.JSStreamReference.Length.get -> long Microsoft.JSInterop.Implementation.JSObjectReferenceJsonWorker +Microsoft.JSInterop.DotNetStreamReference +Microsoft.JSInterop.DotNetStreamReference.Dispose() -> void +Microsoft.JSInterop.DotNetStreamReference.DotNetStreamReference(System.IO.Stream! stream, bool leaveOpen = false) -> void +Microsoft.JSInterop.DotNetStreamReference.LeaveOpen.get -> bool +Microsoft.JSInterop.DotNetStreamReference.Stream.get -> System.IO.Stream! Microsoft.JSInterop.Infrastructure.DotNetInvocationResult.ResultJson.get -> string? Microsoft.JSInterop.JSCallResultType.JSStreamReference = 2 -> Microsoft.JSInterop.JSCallResultType Microsoft.JSInterop.JSRuntime.Dispose() -> void @@ -37,6 +42,7 @@ static Microsoft.JSInterop.JSRuntimeExtensions.InvokeVoidAsync(this Microsoft.JS *REMOVED*static Microsoft.JSInterop.JSRuntimeExtensions.InvokeVoidAsync(this Microsoft.JSInterop.IJSRuntime! jsRuntime, string! identifier, params object![]! args) -> System.Threading.Tasks.ValueTask static Microsoft.JSInterop.JSRuntimeExtensions.InvokeVoidAsync(this Microsoft.JSInterop.IJSRuntime! jsRuntime, string! identifier, params object?[]? args) -> System.Threading.Tasks.ValueTask virtual Microsoft.JSInterop.JSRuntime.ReadJSDataAsStreamAsync(Microsoft.JSInterop.IJSStreamReference! jsStreamReference, long totalLength, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +virtual Microsoft.JSInterop.JSRuntime.TransmitStreamAsync(long streamId, Microsoft.JSInterop.DotNetStreamReference! dotNetStreamReference) -> System.Threading.Tasks.Task! virtual Microsoft.JSInterop.JSRuntime.ReceiveByteArray(int id, byte[]! data) -> void virtual Microsoft.JSInterop.JSRuntime.SendByteArray(int id, byte[]! data) -> void Microsoft.JSInterop.JSDisconnectedException diff --git a/src/JSInterop/Microsoft.JSInterop/test/Infrastructure/DotNetStreamReferenceJsonConverterTest.cs b/src/JSInterop/Microsoft.JSInterop/test/Infrastructure/DotNetStreamReferenceJsonConverterTest.cs new file mode 100644 index 000000000000..c1197795e4ad --- /dev/null +++ b/src/JSInterop/Microsoft.JSInterop/test/Infrastructure/DotNetStreamReferenceJsonConverterTest.cs @@ -0,0 +1,61 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.IO; +using System.Text.Json; +using Microsoft.JSInterop.Implementation; +using Xunit; + +namespace Microsoft.JSInterop.Infrastructure +{ + public class DotNetStreamReferenceJsonConverterTest + { + private readonly JSRuntime JSRuntime = new TestJSRuntime(); + private readonly JsonSerializerOptions JsonSerializerOptions; + + public DotNetStreamReferenceJsonConverterTest() + { + JsonSerializerOptions = JSRuntime.JsonSerializerOptions; + JsonSerializerOptions.Converters.Add(new DotNetStreamReferenceJsonConverter(JSRuntime)); + } + + [Fact] + public void Read_Throws() + { + // Arrange + var json = "{}"; + + // Act & Assert + var ex = Assert.Throws(() => JsonSerializer.Deserialize(json, JsonSerializerOptions)); + Assert.StartsWith("DotNetStreamReference cannot be supplied from JavaScript to .NET because the stream contents have already been transferred.", ex.Message); + } + + [Fact] + public void Write_WritesValidJson() + { + // Arrange + var streamRef = new DotNetStreamReference(new MemoryStream()); + + // Act + var json = JsonSerializer.Serialize(streamRef, JsonSerializerOptions); + + // Assert + Assert.Equal("{\"__dotNetStream\":1}", json); + } + + [Fact] + public void Write_WritesMultipleValidJson() + { + // Arrange + var streamRef = new DotNetStreamReference(new MemoryStream()); + + // Act & Assert + for (var i = 1; i <= 10; i++) + { + var json = JsonSerializer.Serialize(streamRef, JsonSerializerOptions); + Assert.Equal($"{{\"__dotNetStream\":{i}}}", json); + } + } + } +} diff --git a/src/JSInterop/Microsoft.JSInterop/test/JSRuntimeTest.cs b/src/JSInterop/Microsoft.JSInterop/test/JSRuntimeTest.cs index 72b47a523180..8611a3f5ce76 100644 --- a/src/JSInterop/Microsoft.JSInterop/test/JSRuntimeTest.cs +++ b/src/JSInterop/Microsoft.JSInterop/test/JSRuntimeTest.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.IO; using System.Linq; using System.Text; using System.Text.Json; @@ -396,6 +397,20 @@ public void ReceiveByteArray_ThrowsExceptionIfUnexpectedId() Assert.Equal("Element id '7' cannot be added to the byte arrays to be revived with length '2'.", ex.Message); } + [Fact] + public void BeginTransmittingStream_MultipleStreams() + { + // Arrange + var runtime = new TestJSRuntime(); + var streamRef = new DotNetStreamReference(new MemoryStream()); + + // Act & Assert + for (var i = 1; i <= 10; i++) + { + Assert.Equal(i, runtime.BeginTransmittingStream(streamRef)); + } + } + [Fact] public async void ReadJSDataAsStreamAsync_ThrowsNotSupportedException() { @@ -477,6 +492,12 @@ protected override void BeginInvokeJS(long asyncHandle, string identifier, strin ArgsJson = argsJson, }); } + + protected internal override Task TransmitStreamAsync(long streamId, DotNetStreamReference dotNetStreamReference) + { + // No-op + return Task.CompletedTask; + } } } } diff --git a/src/JSInterop/Microsoft.JSInterop/test/TestJSRuntime.cs b/src/JSInterop/Microsoft.JSInterop/test/TestJSRuntime.cs index 3028d0038038..f87f11de6ea2 100644 --- a/src/JSInterop/Microsoft.JSInterop/test/TestJSRuntime.cs +++ b/src/JSInterop/Microsoft.JSInterop/test/TestJSRuntime.cs @@ -22,5 +22,11 @@ protected internal override void SendByteArray(int id, byte[] data) { // No-op } + + protected internal override Task TransmitStreamAsync(long streamId, DotNetStreamReference dotNetStreamReference) + { + // No-op + return Task.CompletedTask; + } } }