Skip to content

Enable Blazor Server and WebAssembly components to run in the same document #48294

New issue

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

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

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
May 23, 2023
Merged
2 changes: 1 addition & 1 deletion src/Components/Components.slnf
Original file line number Diff line number Diff line change
Expand Up @@ -148,4 +148,4 @@
"src\\WebEncoders\\src\\Microsoft.Extensions.WebEncoders.csproj"
]
}
}
}
9 changes: 4 additions & 5 deletions src/Components/Shared/src/TransmitDataStreamToJS.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ namespace Microsoft.AspNetCore.Components;
/// </Summary>
internal static class TransmitDataStreamToJS
{
internal static async Task TransmitStreamAsync(IJSRuntime runtime, long streamId, DotNetStreamReference dotNetStreamReference)
internal static async Task TransmitStreamAsync(IJSRuntime runtime, string methodIdentifier, long streamId, DotNetStreamReference dotNetStreamReference)
{
var buffer = ArrayPool<byte>.Shared.Rent(32 * 1024);

Expand All @@ -21,18 +21,18 @@ internal static async Task TransmitStreamAsync(IJSRuntime runtime, long streamId
int bytesRead;
while ((bytesRead = await dotNetStreamReference.Stream.ReadAsync(buffer)) > 0)
{
await runtime.InvokeVoidAsync("Blazor._internal.receiveDotNetDataStream", streamId, buffer, bytesRead, null);
await runtime.InvokeVoidAsync(methodIdentifier, streamId, buffer, bytesRead, null);
}

// Notify client that the stream has completed
await runtime.InvokeVoidAsync("Blazor._internal.receiveDotNetDataStream", streamId, Array.Empty<byte>(), 0, null);
await runtime.InvokeVoidAsync(methodIdentifier, streamId, Array.Empty<byte>(), 0, null);
}
catch (Exception ex)
{
try
{
// Attempt to notify the client of the error.
await runtime.InvokeVoidAsync("Blazor._internal.receiveDotNetDataStream", streamId, Array.Empty<byte>(), 0, ex.Message);
await runtime.InvokeVoidAsync(methodIdentifier, streamId, Array.Empty<byte>(), 0, ex.Message);
}
catch
{
Expand All @@ -51,5 +51,4 @@ internal static async Task TransmitStreamAsync(IJSRuntime runtime, long streamId
}
}
}

}
2 changes: 1 addition & 1 deletion src/Components/Web.JS/dist/Release/blazor.server.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/Components/Web.JS/dist/Release/blazor.web.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/Components/Web.JS/dist/Release/blazor.webview.js

Large diffs are not rendered by default.

194 changes: 194 additions & 0 deletions src/Components/Web.JS/src/Boot.Server.Common.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

import { DotNet } from '@microsoft/dotnet-js-interop';
import { Blazor } from './GlobalExports';
import { HubConnectionBuilder, HubConnection, HttpTransportType } from '@microsoft/signalr';
import { MessagePackHubProtocol } from '@microsoft/signalr-protocol-msgpack';
import { showErrorNotification } from './BootErrors';
import { RenderQueue } from './Platform/Circuits/RenderQueue';
import { ConsoleLogger } from './Platform/Logging/Loggers';
import { LogLevel, Logger } from './Platform/Logging/Logger';
import { CircuitDescriptor } from './Platform/Circuits/CircuitManager';
import { resolveOptions, CircuitStartOptions } from './Platform/Circuits/CircuitStartOptions';
import { DefaultReconnectionHandler } from './Platform/Circuits/DefaultReconnectionHandler';
import { attachRootComponentToLogicalElement } from './Rendering/Renderer';
import { discoverPersistedState, ServerComponentDescriptor } from './Services/ComponentDescriptorDiscovery';
import { sendJSDataStream } from './Platform/Circuits/CircuitStreamingInterop';
import { fetchAndInvokeInitializers } from './JSInitializers/JSInitializers.Server';

let renderingFailed = false;
let connection: HubConnection;
let circuit: CircuitDescriptor;
let dispatcher: DotNet.ICallDispatcher;

export async function startCircuit(userOptions?: Partial<CircuitStartOptions>, components?: ServerComponentDescriptor[]): Promise<void> {
// Establish options to be used
const options = resolveOptions(userOptions);
const jsInitializer = await fetchAndInvokeInitializers(options);

const logger = new ConsoleLogger(options.logLevel);

Blazor.reconnect = async (existingConnection?: HubConnection): Promise<boolean> => {
if (renderingFailed) {
// We can't reconnect after a failure, so exit early.
return false;
}

const reconnection = existingConnection || await initializeConnection(options, logger, circuit);
if (!(await circuit.reconnect(reconnection))) {
logger.log(LogLevel.Information, 'Reconnection attempt to the circuit was rejected by the server. This may indicate that the associated state is no longer available on the server.');
return false;
}

options.reconnectionHandler!.onConnectionUp();

return true;
};
Blazor.defaultReconnectionHandler = new DefaultReconnectionHandler(logger);

options.reconnectionHandler = options.reconnectionHandler || Blazor.defaultReconnectionHandler;
logger.log(LogLevel.Information, 'Starting up Blazor server-side application.');

const appState = discoverPersistedState(document);
circuit = new CircuitDescriptor(components || [], appState || '');

// Configure navigation via SignalR
Blazor._internal.navigationManager.listenForNavigationEvents((uri: string, state: string | undefined, intercepted: boolean): Promise<void> => {
return connection.send('OnLocationChanged', uri, state, intercepted);
}, (callId: number, uri: string, state: string | undefined, intercepted: boolean): Promise<void> => {
return connection.send('OnLocationChanging', callId, uri, state, intercepted);
});

Blazor._internal.forceCloseConnection = () => connection.stop();
Blazor._internal.sendJSDataStream = (data: ArrayBufferView | Blob, streamId: number, chunkSize: number) => sendJSDataStream(connection, data, streamId, chunkSize);

dispatcher = DotNet.attachDispatcher({
beginInvokeDotNetFromJS: (callId, assemblyName, methodIdentifier, dotNetObjectId, argsJson): void => {
connection.send('BeginInvokeDotNetFromJS', callId ? callId.toString() : null, assemblyName, methodIdentifier, dotNetObjectId || 0, argsJson);
},
endInvokeJSFromDotNet: (asyncHandle, succeeded, argsJson): void => {
connection.send('EndInvokeJSFromDotNet', asyncHandle, succeeded, argsJson);
},
sendByteArray: (id: number, data: Uint8Array): void => {
connection.send('ReceiveByteArray', id, data);
},
});
Comment on lines +66 to +76
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we make this a bit more idiomatic?

In this multi dispatching world I would have expected for us to new up a dispatcher instance and configure it. With a server implementation and a webassembly implementation.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just so I'm understanding correctly, is the only change you're suggesting that we change it from DotNet.attachDispatcher(...) to something like new DotNet.CallDispatcher(...)?

There already is a server and client implementation with the way things are currently set up. attachDispatcher() new-s up a new CallDispatcher instance, plus does some additional checks like "if this is the first dispatcher, make it the default" and "if this is not the first dispatcher, remove the default".

I guess the one advantage of keeping the attachDispatcher() way of doing things is that it can return an ICallDispatcher, which has a more restrictive public API compared to the non-exported CallDispatcher implementation.


const initialConnection = await initializeConnection(options, logger, circuit);
const circuitStarted = await circuit.startCircuit(initialConnection);
if (!circuitStarted) {
logger.log(LogLevel.Error, 'Failed to start the circuit.');
return;
}

let disconnectSent = false;
const cleanup = () => {
if (!disconnectSent) {
const data = new FormData();
const circuitId = circuit.circuitId!;
data.append('circuitId', circuitId);
disconnectSent = navigator.sendBeacon('_blazor/disconnect', data);
}
};

Blazor.disconnect = cleanup;

window.addEventListener('unload', cleanup, { capture: false, once: true });

logger.log(LogLevel.Information, 'Blazor server-side application started.');

jsInitializer.invokeAfterStartedCallbacks(Blazor);
}

async function initializeConnection(options: CircuitStartOptions, logger: Logger, circuit: CircuitDescriptor): Promise<HubConnection> {
const hubProtocol = new MessagePackHubProtocol();
(hubProtocol as unknown as { name: string }).name = 'blazorpack';

const connectionBuilder = new HubConnectionBuilder()
.withUrl('_blazor')
.withHubProtocol(hubProtocol);

options.configureSignalR(connectionBuilder);

const newConnection = connectionBuilder.build();

newConnection.on('JS.AttachComponent', (componentId, selector) => attachRootComponentToLogicalElement(0, circuit.resolveElement(selector), componentId, false));
newConnection.on('JS.BeginInvokeJS', dispatcher.beginInvokeJSFromDotNet.bind(dispatcher));
newConnection.on('JS.EndInvokeDotNet', dispatcher.endInvokeDotNetFromJS.bind(dispatcher));
newConnection.on('JS.ReceiveByteArray', dispatcher.receiveByteArray.bind(dispatcher));

newConnection.on('JS.BeginTransmitStream', (streamId: number) => {
const readableStream = new ReadableStream({
start(controller) {
newConnection.stream('SendDotNetStreamToJS', streamId).subscribe({
next: (chunk: Uint8Array) => controller.enqueue(chunk),
complete: () => controller.close(),
error: (err) => controller.error(err),
});
},
});

dispatcher.supplyDotNetStream(streamId, readableStream);
});

const renderQueue = RenderQueue.getOrCreate(logger);
newConnection.on('JS.RenderBatch', (batchId: number, batchData: Uint8Array) => {
logger.log(LogLevel.Debug, `Received render batch with id ${batchId} and ${batchData.byteLength} bytes.`);
renderQueue.processBatch(batchId, batchData, newConnection);
});

newConnection.on('JS.EndLocationChanging', Blazor._internal.navigationManager.endLocationChanging);

newConnection.onclose(error => !renderingFailed && options.reconnectionHandler!.onConnectionDown(options.reconnectionOptions, error));
newConnection.on('JS.Error', error => {
renderingFailed = true;
unhandledError(newConnection, error, logger);
showErrorNotification();
});

try {
await newConnection.start();
connection = newConnection;
} catch (ex: any) {
unhandledError(newConnection, ex as Error, logger);

if (ex.errorType === 'FailedToNegotiateWithServerError') {
// Connection with the server has been interrupted, and we're in the process of reconnecting.
// Throw this exception so it can be handled at the reconnection layer, and don't show the
// error notification.
throw ex;
} else {
showErrorNotification();
}

if (ex.innerErrors) {
if (ex.innerErrors.some(e => e.errorType === 'UnsupportedTransportError' && e.transport === HttpTransportType.WebSockets)) {
logger.log(LogLevel.Error, 'Unable to connect, please ensure you are using an updated browser that supports WebSockets.');
} else if (ex.innerErrors.some(e => e.errorType === 'FailedToStartTransportError' && e.transport === HttpTransportType.WebSockets)) {
logger.log(LogLevel.Error, 'Unable to connect, please ensure WebSockets are available. A VPN or proxy may be blocking the connection.');
} else if (ex.innerErrors.some(e => e.errorType === 'DisabledTransportError' && e.transport === HttpTransportType.LongPolling)) {
logger.log(LogLevel.Error, 'Unable to initiate a SignalR connection to the server. This might be because the server is not configured to support WebSockets. For additional details, visit https://aka.ms/blazor-server-websockets-error.');
}
}
}

// Check if the connection is established using the long polling transport,
// using the `features.inherentKeepAlive` property only present with long polling.
if ((newConnection as any).connection?.features?.inherentKeepAlive) {
logger.log(LogLevel.Warning, 'Failed to connect via WebSockets, using the Long Polling fallback transport. This may be due to a VPN or proxy blocking the connection. To troubleshoot this, visit https://aka.ms/blazor-server-using-fallback-long-polling.');
}

return newConnection;
}

function unhandledError(connection: HubConnection, err: Error, logger: Logger): void {
logger.log(LogLevel.Error, err);

// Disconnect on errors.
//
// Trying to call methods on the connection after its been closed will throw.
if (connection) {
connection.stop();
}
}
Loading