Skip to content

Commit 4dc663a

Browse files
authored
Blazor Byte Array Interop Support (#33015)
* Blazorserver Byte Array Interop Support * Byte Array Interop in WASM (#33104) * E2E Tests * PR Feedback
1 parent ef40046 commit 4dc663a

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+864
-76
lines changed

src/Components/Server/src/BlazorPack/BlazorPackHubProtocolWorker.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

44
using System;
5+
using System.Buffers;
56
using System.IO;
67
using MessagePack;
78
using Microsoft.AspNetCore.SignalR.Protocol;
@@ -34,6 +35,20 @@ protected override object DeserializeObject(ref MessagePackReader reader, Type t
3435
{
3536
return reader.ReadSingle();
3637
}
38+
else if (type == typeof(byte[]))
39+
{
40+
var bytes = reader.ReadBytes();
41+
if (!bytes.HasValue)
42+
{
43+
return null;
44+
}
45+
else if (bytes.Value.Length == 0)
46+
{
47+
return Array.Empty<byte>();
48+
}
49+
50+
return bytes.Value.ToArray();
51+
}
3752
}
3853
catch (Exception ex)
3954
{
@@ -75,6 +90,10 @@ protected override void Serialize(ref MessagePackWriter writer, Type type, objec
7590
writer.Write(bytes);
7691
break;
7792

93+
case byte[] byteArray:
94+
writer.Write(byteArray);
95+
break;
96+
7897
default:
7998
throw new FormatException($"Unsupported argument type {type}");
8099
}

src/Components/Server/src/Circuits/CircuitHost.cs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,31 @@ await Renderer.Dispatcher.InvokeAsync(() =>
388388
}
389389
}
390390

391+
// ReceiveByteArray is used in a fire-and-forget context, so it's responsible for its own
392+
// error handling.
393+
internal async Task ReceiveByteArray(int id, byte[] data)
394+
{
395+
AssertInitialized();
396+
AssertNotDisposed();
397+
398+
try
399+
{
400+
await Renderer.Dispatcher.InvokeAsync(() =>
401+
{
402+
Log.ReceiveByteArraySuccess(_logger, id);
403+
DotNetDispatcher.ReceiveByteArray(JSRuntime, id, data);
404+
});
405+
}
406+
catch (Exception ex)
407+
{
408+
// An error completing JS interop means that the user sent invalid data, a well-behaved
409+
// client won't do this.
410+
Log.ReceiveByteArrayException(_logger, id, ex);
411+
await TryNotifyClientErrorAsync(Client, GetClientErrorMessage(ex, "Invalid byte array."));
412+
UnhandledException?.Invoke(this, new UnhandledExceptionEventArgs(ex, isTerminating: false));
413+
}
414+
}
415+
391416
// DispatchEvent is used in a fire-and-forget context, so it's responsible for its own
392417
// error handling.
393418
public async Task DispatchEvent(string eventDescriptorJson, string eventArgsJson)
@@ -603,6 +628,8 @@ private static class Log
603628
private static readonly Action<ILogger, Exception> _endInvokeDispatchException;
604629
private static readonly Action<ILogger, long, string, Exception> _endInvokeJSFailed;
605630
private static readonly Action<ILogger, long, Exception> _endInvokeJSSucceeded;
631+
private static readonly Action<ILogger, long, Exception> _receiveByteArraySuccess;
632+
private static readonly Action<ILogger, long, Exception> _receiveByteArrayException;
606633
private static readonly Action<ILogger, Exception> _dispatchEventFailedToParseEventData;
607634
private static readonly Action<ILogger, string, Exception> _dispatchEventFailedToDispatchEvent;
608635
private static readonly Action<ILogger, string, CircuitId, Exception> _locationChange;
@@ -645,6 +672,8 @@ private static class EventIds
645672
public static readonly EventId LocationChangeFailed = new EventId(210, "LocationChangeFailed");
646673
public static readonly EventId LocationChangeFailedInCircuit = new EventId(211, "LocationChangeFailedInCircuit");
647674
public static readonly EventId OnRenderCompletedFailed = new EventId(212, "OnRenderCompletedFailed");
675+
public static readonly EventId ReceiveByteArraySucceeded = new EventId(213, "ReceiveByteArraySucceeded");
676+
public static readonly EventId ReceiveByteArrayException = new EventId(214, "ReceiveByteArrayException");
648677
}
649678

650679
static Log()
@@ -764,6 +793,16 @@ static Log()
764793
EventIds.EndInvokeJSSucceeded,
765794
"The JS interop call with callback id '{AsyncCall}' succeeded.");
766795

796+
_receiveByteArraySuccess = LoggerMessage.Define<long>(
797+
LogLevel.Debug,
798+
EventIds.ReceiveByteArraySucceeded,
799+
"The ReceiveByteArray call with id '{id}' succeeded.");
800+
801+
_receiveByteArrayException = LoggerMessage.Define<long>(
802+
LogLevel.Debug,
803+
EventIds.ReceiveByteArrayException,
804+
"The ReceiveByteArray call with id '{id}' failed.");
805+
767806
_dispatchEventFailedToParseEventData = LoggerMessage.Define(
768807
LogLevel.Debug,
769808
EventIds.DispatchEventFailedToParseEventData,
@@ -826,6 +865,8 @@ public static void CircuitHandlerFailed(ILogger logger, CircuitHandler handler,
826865
public static void EndInvokeDispatchException(ILogger logger, Exception ex) => _endInvokeDispatchException(logger, ex);
827866
public static void EndInvokeJSFailed(ILogger logger, long asyncHandle, string arguments) => _endInvokeJSFailed(logger, asyncHandle, arguments, null);
828867
public static void EndInvokeJSSucceeded(ILogger logger, long asyncCall) => _endInvokeJSSucceeded(logger, asyncCall, null);
868+
internal static void ReceiveByteArraySuccess(ILogger logger, long id) => _receiveByteArraySuccess(logger, id, null);
869+
internal static void ReceiveByteArrayException(ILogger logger, long id, Exception ex) => _receiveByteArrayException(logger, id, ex);
829870
public static void DispatchEventFailedToParseEventData(ILogger logger, Exception ex) => _dispatchEventFailedToParseEventData(logger, ex);
830871
public static void DispatchEventFailedToDispatchEvent(ILogger logger, string eventHandlerId, Exception ex) => _dispatchEventFailedToDispatchEvent(logger, eventHandlerId ?? "", ex);
831872

src/Components/Server/src/Circuits/RemoteJSRuntime.cs

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,22 @@ internal class RemoteJSRuntime : JSRuntime
1717
private readonly ILogger<RemoteJSRuntime> _logger;
1818
private CircuitClientProxy _clientProxy;
1919
private bool _permanentlyDisconnected;
20+
private readonly long _maximumIncomingBytes;
21+
private int _byteArraysToBeRevivedTotalBytes;
2022

2123
public ElementReferenceContext ElementReferenceContext { get; }
2224

2325
public bool IsInitialized => _clientProxy is not null;
2426

25-
public RemoteJSRuntime(IOptions<CircuitOptions> options, ILogger<RemoteJSRuntime> logger)
27+
public RemoteJSRuntime(
28+
IOptions<CircuitOptions> circuitOptions,
29+
IOptions<HubOptions> hubOptions,
30+
ILogger<RemoteJSRuntime> logger)
2631
{
27-
_options = options.Value;
32+
_options = circuitOptions.Value;
33+
_maximumIncomingBytes = hubOptions.Value.MaximumReceiveMessageSize is null
34+
? long.MaxValue
35+
: hubOptions.Value.MaximumReceiveMessageSize.Value;
2836
_logger = logger;
2937
DefaultAsyncTimeout = _options.JSInteropDefaultCallTimeout;
3038
ElementReferenceContext = new WebElementReferenceContext(this);
@@ -75,6 +83,11 @@ protected override void EndInvokeDotNet(DotNetInvocationInfo invocationInfo, in
7583
}
7684
}
7785

86+
protected override void SendByteArray(int id, byte[] data)
87+
{
88+
_clientProxy.SendAsync("JS.ReceiveByteArray", id, data);
89+
}
90+
7891
protected override void BeginInvokeJS(long asyncHandle, string identifier, string argsJson, JSCallResultType resultType, long targetInstanceId)
7992
{
8093
if (_clientProxy is null)
@@ -99,6 +112,28 @@ protected override void BeginInvokeJS(long asyncHandle, string identifier, strin
99112
_clientProxy.SendAsync("JS.BeginInvokeJS", asyncHandle, identifier, argsJson, (int)resultType, targetInstanceId);
100113
}
101114

115+
protected override void ReceiveByteArray(int id, byte[] data)
116+
{
117+
if (id == 0)
118+
{
119+
// Starting a new transfer, clear out number of bytes read.
120+
_byteArraysToBeRevivedTotalBytes = 0;
121+
}
122+
123+
if (_maximumIncomingBytes - data.Length < _byteArraysToBeRevivedTotalBytes)
124+
{
125+
throw new ArgumentOutOfRangeException("Exceeded the maximum byte array transfer limit for a call.");
126+
}
127+
128+
// We also store the total number of bytes seen so far to compare against
129+
// the MaximumIncomingBytes limit.
130+
// We take the larger of the size of the array or 4, to ensure we're not inundated
131+
// with small/empty arrays.
132+
_byteArraysToBeRevivedTotalBytes += Math.Max(4, data.Length);
133+
134+
base.ReceiveByteArray(id, data);
135+
}
136+
102137
public void MarkPermanentlyDisconnected()
103138
{
104139
_permanentlyDisconnected = true;

src/Components/Server/src/ComponentHub.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,17 @@ public async ValueTask EndInvokeJSFromDotNet(long asyncHandle, bool succeeded, s
209209
_ = circuitHost.EndInvokeJSFromDotNet(asyncHandle, succeeded, arguments);
210210
}
211211

212+
public async ValueTask ReceiveByteArray(int id, byte[] data)
213+
{
214+
var circuitHost = await GetActiveCircuitAsync();
215+
if (circuitHost == null)
216+
{
217+
return;
218+
}
219+
220+
await circuitHost.ReceiveByteArray(id, data);
221+
}
222+
212223
public async ValueTask DispatchBrowserEvent(string eventDescriptor, string eventArgs)
213224
{
214225
var circuitHost = await GetActiveCircuitAsync();

src/Components/Server/test/Circuits/CircuitHostTest.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
using System.Reflection;
88
using System.Threading;
99
using System.Threading.Tasks;
10-
using Microsoft.AspNetCore.Components.Web.Rendering;
1110
using Microsoft.AspNetCore.DataProtection;
1211
using Microsoft.AspNetCore.SignalR;
1312
using Microsoft.Extensions.DependencyInjection;

src/Components/Server/test/Circuits/TestCircuitHost.cs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33

44
using System;
55
using System.Collections.Generic;
6-
using Microsoft.AspNetCore.Components.Web.Rendering;
76
using Microsoft.AspNetCore.SignalR;
87
using Microsoft.Extensions.DependencyInjection;
98
using Microsoft.Extensions.Logging;
@@ -29,7 +28,7 @@ public static CircuitHost Create(
2928
{
3029
serviceScope = serviceScope ?? new AsyncServiceScope(Mock.Of<IServiceScope>());
3130
clientProxy = clientProxy ?? new CircuitClientProxy(Mock.Of<IClientProxy>(), Guid.NewGuid().ToString());
32-
var jsRuntime = new RemoteJSRuntime(Options.Create(new CircuitOptions()), Mock.Of<ILogger<RemoteJSRuntime>>());
31+
var jsRuntime = new RemoteJSRuntime(Options.Create(new CircuitOptions()), Options.Create(new HubOptions()), Mock.Of<ILogger<RemoteJSRuntime>>());
3332

3433
if (remoteRenderer == null)
3534
{
@@ -42,7 +41,7 @@ public static CircuitHost Create(
4241
null);
4342
}
4443

45-
handlers = handlers ?? Array.Empty<CircuitHandler>();
44+
handlers ??= Array.Empty<CircuitHandler>();
4645
return new TestCircuitHost(
4746
circuitId is null ? new CircuitId(Guid.NewGuid().ToString(), Guid.NewGuid().ToString()) : circuitId.Value,
4847
serviceScope.Value,

src/Components/Shared/src/ArrayBuilder.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ namespace Ignitor
1414
namespace Microsoft.AspNetCore.Components.WebView
1515
#elif COMPONENTS_SERVER
1616
namespace Microsoft.AspNetCore.Components.Server.Circuits
17+
#elif JS_INTEROP
18+
namespace Microsoft.JSInterop.Infrastructure
1719
#else
1820
namespace Microsoft.AspNetCore.Components.RenderTree
1921
#endif

src/Components/Web.JS/dist/Release/blazor.server.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Components/Web.JS/dist/Release/blazor.webview.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Components/Web.JS/src/Boot.Server.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ async function initializeConnection(options: CircuitStartOptions, logger: Logger
105105
connection.on('JS.AttachComponent', (componentId, selector) => attachRootComponentToLogicalElement(0, circuit.resolveElement(selector), componentId));
106106
connection.on('JS.BeginInvokeJS', DotNet.jsCallDispatcher.beginInvokeJSFromDotNet);
107107
connection.on('JS.EndInvokeDotNet', DotNet.jsCallDispatcher.endInvokeDotNetFromJS);
108+
connection.on('JS.ReceiveByteArray', DotNet.jsCallDispatcher.receiveByteArray);
108109

109110
const renderQueue = RenderQueue.getOrCreate(logger);
110111
connection.on('JS.RenderBatch', (batchId: number, batchData: Uint8Array) => {
@@ -134,6 +135,9 @@ async function initializeConnection(options: CircuitStartOptions, logger: Logger
134135
endInvokeJSFromDotNet: (asyncHandle, succeeded, argsJson): void => {
135136
connection.send('EndInvokeJSFromDotNet', asyncHandle, succeeded, argsJson);
136137
},
138+
sendByteArray: (id: number, data: Uint8Array): void => {
139+
connection.send('ReceiveByteArray', id, data);
140+
},
137141
});
138142

139143
return connection;

src/Components/Web.JS/src/Boot.WebAssembly.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
1+
/* eslint-disable array-element-newline */
12
import { DotNet } from '@microsoft/dotnet-js-interop';
23
import { Blazor } from './GlobalExports';
34
import * as Environment from './Environment';
4-
import { monoPlatform } from './Platform/Mono/MonoPlatform';
5+
import { byteArrayBeingTransferred, monoPlatform } from './Platform/Mono/MonoPlatform';
56
import { renderBatch, getRendererer, attachRootComponentToElement, attachRootComponentToLogicalElement } from './Rendering/Renderer';
67
import { SharedMemoryRenderBatch } from './Rendering/RenderBatch/SharedMemoryRenderBatch';
78
import { shouldAutoStart } from './BootCommon';
89
import { setEventDispatcher } from './Rendering/Events/EventDispatcher';
910
import { WebAssemblyResourceLoader } from './Platform/WebAssemblyResourceLoader';
1011
import { WebAssemblyConfigLoader } from './Platform/WebAssemblyConfigLoader';
1112
import { BootConfigResult } from './Platform/BootConfig';
12-
import { Pointer, System_Boolean, System_String } from './Platform/Platform';
13+
import { Pointer, System_Array, System_Boolean, System_Byte, System_Int, System_Object, System_String } from './Platform/Platform';
1314
import { WebAssemblyStartOptions } from './Platform/WebAssemblyStartOptions';
1415
import { WebAssemblyComponentAttacher } from './Platform/WebAssemblyComponentAttacher';
1516
import { discoverComponents, discoverPersistedState, WebAssemblyComponentDescriptor } from './Services/ComponentDescriptorDiscovery';
@@ -45,6 +46,8 @@ async function boot(options?: Partial<WebAssemblyStartOptions>): Promise<void> {
4546
// Configure JS interop
4647
Blazor._internal.invokeJSFromDotNet = invokeJSFromDotNet;
4748
Blazor._internal.endInvokeDotNetFromJS = endInvokeDotNetFromJS;
49+
Blazor._internal.receiveByteArray = receiveByteArray;
50+
Blazor._internal.retrieveByteArray = retrieveByteArray;
4851

4952
// Configure environment for execution under Mono WebAssembly with shared-memory rendering
5053
const platform = Environment.setPlatform(monoPlatform);
@@ -161,6 +164,21 @@ function endInvokeDotNetFromJS(callId: System_String, success: System_Boolean, r
161164
DotNet.jsCallDispatcher.endInvokeDotNetFromJS(callIdString, successBool, resultJsonOrErrorMessageString);
162165
}
163166

167+
function receiveByteArray(id: System_Int, data: System_Array<System_Byte>): void {
168+
const idLong = id as any as number;
169+
const dataByteArray = monoPlatform.toUint8Array(data);
170+
DotNet.jsCallDispatcher.receiveByteArray(idLong, dataByteArray);
171+
}
172+
173+
function retrieveByteArray(): System_Object {
174+
if (byteArrayBeingTransferred === null) {
175+
throw new Error('Byte array not available for transfer');
176+
}
177+
178+
const typedArray = BINDING.js_typed_array_to_array(byteArrayBeingTransferred);
179+
return typedArray;
180+
}
181+
164182
Blazor.start = boot;
165183
if (shouldAutoStart()) {
166184
boot().catch(error => {

src/Components/Web.JS/src/Boot.WebView.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { shouldAutoStart } from './BootCommon';
44
import { internalFunctions as navigationManagerFunctions } from './Services/NavigationManager';
55
import { setEventDispatcher } from './Rendering/Events/EventDispatcher';
66
import { startIpcReceiver } from './Platform/WebView/WebViewIpcReceiver';
7-
import { sendBrowserEvent, sendAttachPage, sendBeginInvokeDotNetFromJS, sendEndInvokeJSFromDotNet, sendLocationChanged } from './Platform/WebView/WebViewIpcSender';
7+
import { sendBrowserEvent, sendAttachPage, sendBeginInvokeDotNetFromJS, sendEndInvokeJSFromDotNet, sendByteArray, sendLocationChanged } from './Platform/WebView/WebViewIpcSender';
88
import { InputFile } from './InputFile';
99

1010
let started = false;
@@ -20,6 +20,7 @@ async function boot(): Promise<void> {
2020
DotNet.attachDispatcher({
2121
beginInvokeDotNetFromJS: sendBeginInvokeDotNetFromJS,
2222
endInvokeJSFromDotNet: sendEndInvokeJSFromDotNet,
23+
sendByteArray: sendByteArray,
2324
});
2425

2526
Blazor._internal.InputFile = InputFile;

src/Components/Web.JS/src/GlobalExports.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { InputFile } from './InputFile';
77
import { DefaultReconnectionHandler } from './Platform/Circuits/DefaultReconnectionHandler';
88
import { CircuitStartOptions } from './Platform/Circuits/CircuitStartOptions';
99
import { WebAssemblyStartOptions } from './Platform/WebAssemblyStartOptions';
10-
import { Platform, Pointer, System_String, System_Array, System_Object, System_Boolean } from './Platform/Platform';
10+
import { Platform, Pointer, System_String, System_Array, System_Object, System_Boolean, System_Byte, System_Int } from './Platform/Platform';
1111

1212
interface IBlazor {
1313
navigateTo: (uri: string, forceLoad: boolean, replace: boolean) => void;
@@ -27,6 +27,8 @@ interface IBlazor {
2727
InputFile?: typeof InputFile,
2828
invokeJSFromDotNet?: (callInfo: Pointer, arg0: any, arg1: any, arg2: any) => any;
2929
endInvokeDotNetFromJS?: (callId: System_String, success: System_Boolean, resultJsonOrErrorMessage: System_String) => void;
30+
receiveByteArray?: (id: System_Int, data: System_Array<System_Byte>) => void;
31+
retrieveByteArray?: () => System_Object;
3032
getPersistedState?: () => System_String;
3133
attachRootComponentToElement?: (arg0: any, arg1: any, arg2: any) => void;
3234
registeredComponents?: {

src/Components/Web.JS/src/InputFile.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,9 +101,9 @@ async function ensureArrayBufferReadyForSharedMemoryInterop(elem: InputElement,
101101
getFileById(elem, fileId).arrayBuffer = arrayBuffer;
102102
}
103103

104-
async function readFileData(elem: InputElement, fileId: number, startOffset: number, count: number): Promise<string> {
104+
async function readFileData(elem: InputElement, fileId: number, startOffset: number, count: number): Promise<Uint8Array> {
105105
const arrayBuffer = await getArrayBufferFromFileAsync(elem, fileId);
106-
return btoa(String.fromCharCode.apply(null, new Uint8Array(arrayBuffer, startOffset, count) as unknown as number[]));
106+
return new Uint8Array(arrayBuffer, startOffset, count);
107107
}
108108

109109
export function getFileById(elem: InputElement, fileId: number): BrowserFile {
@@ -134,4 +134,4 @@ function getArrayBufferFromFileAsync(elem: InputElement, fileId: number): Promis
134134
}
135135

136136
return file.readPromise;
137-
}
137+
}

0 commit comments

Comments
 (0)