Skip to content

Commit 39c9b61

Browse files
committed
Client reconnects when state's available on the server
1 parent a2c8a34 commit 39c9b61

26 files changed

+1138
-168
lines changed

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

Lines changed: 48 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,42 @@ import { OutOfProcessRenderBatch } from './Rendering/RenderBatch/OutOfProcessRen
66
import { internalFunctions as uriHelperFunctions } from './Services/UriHelper';
77
import { renderBatch } from './Rendering/Renderer';
88
import { fetchBootConfigAsync, loadEmbeddedResourcesAsync } from './BootCommon';
9+
import { CircuitHandler } from './Platform/Circuits/CircuitHandler';
10+
import { AutoReconnectCircuitHandler } from './Platform/Circuits/AutoReconnectCircuitHandler';
911

10-
let connection : signalR.HubConnection;
12+
async function boot() {
13+
const circuitHandlers: CircuitHandler[] = [ new AutoReconnectCircuitHandler() ];
14+
window['Blazor'].circuitHandlers = circuitHandlers;
1115

12-
function boot() {
1316
// In the background, start loading the boot config and any embedded resources
1417
const embeddedResourcesPromise = fetchBootConfigAsync().then(bootConfig => {
1518
return loadEmbeddedResourcesAsync(bootConfig);
1619
});
1720

18-
connection = new signalR.HubConnectionBuilder()
21+
const initialConnection = await initializeConnection(circuitHandlers);
22+
23+
// Ensure any embedded resources have been loaded before starting the app
24+
await embeddedResourcesPromise;
25+
const circuitId = await initialConnection.invoke<string>(
26+
'StartCircuit',
27+
uriHelperFunctions.getLocationHref(),
28+
uriHelperFunctions.getBaseURI()
29+
);
30+
31+
window['Blazor'].reconnect = async () => {
32+
const reconnection = await initializeConnection(circuitHandlers);
33+
if (!await reconnection.invoke<Boolean>('ConnectCircuit', circuitId)) {
34+
throw new Error('Failed to reconnect to the server. The supplied circuitId is invalid.');
35+
}
36+
37+
circuitHandlers.forEach(h => h.onConnectionUp && h.onConnectionUp());
38+
};
39+
40+
circuitHandlers.forEach(h => h.onConnectionUp && h.onConnectionUp());
41+
}
42+
43+
async function initializeConnection(circuitHandlers: CircuitHandler[]): Promise<signalR.HubConnection> {
44+
const connection = new signalR.HubConnectionBuilder()
1945
.withUrl('_blazor')
2046
.withHubProtocol(new MessagePackHubProtocol())
2147
.configureLogging(signalR.LogLevel.Information)
@@ -33,40 +59,31 @@ function boot() {
3359
}
3460
});
3561

36-
connection.on('JS.Error', unhandledError);
37-
38-
connection.start()
39-
.then(async () => {
40-
DotNet.attachDispatcher({
41-
beginInvokeDotNetFromJS: (callId, assemblyName, methodIdentifier, dotNetObjectId, argsJson) => {
42-
connection.send('BeginInvokeDotNetFromJS', callId ? callId.toString() : null, assemblyName, methodIdentifier, dotNetObjectId || 0, argsJson);
43-
}
44-
});
45-
46-
// Ensure any embedded resources have been loaded before starting the app
47-
await embeddedResourcesPromise;
48-
49-
connection.send(
50-
'StartCircuit',
51-
uriHelperFunctions.getLocationHref(),
52-
uriHelperFunctions.getBaseURI()
53-
);
54-
})
55-
.catch(unhandledError);
56-
57-
// Temporary undocumented API to help with https://github.com/aspnet/Blazor/issues/1339
58-
// This will be replaced once we implement proper connection management (reconnects, etc.)
59-
window['Blazor'].onServerConnectionClose = connection.onclose.bind(connection);
62+
connection.onclose(error => circuitHandlers.forEach(h => h.onConnectionDown && h.onConnectionDown(error)));
63+
connection.on('JS.Error', error => unhandledError(connection, error));
64+
65+
window['Blazor']._internal.forceCloseConnection = () => connection.stop();
66+
67+
try {
68+
await connection.start();
69+
} catch (ex) {
70+
unhandledError(connection, ex);
71+
}
72+
73+
DotNet.attachDispatcher({
74+
beginInvokeDotNetFromJS: (callId, assemblyName, methodIdentifier, dotNetObjectId, argsJson) => {
75+
connection.send('BeginInvokeDotNetFromJS', callId ? callId.toString() : null, assemblyName, methodIdentifier, dotNetObjectId || 0, argsJson);
76+
}
77+
});
78+
79+
return connection;
6080
}
6181

62-
function unhandledError(err) {
82+
function unhandledError(connection: signalR.HubConnection, err: Error) {
6383
console.error(err);
6484

6585
// Disconnect on errors.
6686
//
67-
// TODO: it would be nice to have some kind of experience for what happens when you're
68-
// trying to interact with an app that's disconnected.
69-
//
7087
// Trying to call methods on the connection after its been closed will throw.
7188
if (connection) {
7289
connection.stop();
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { CircuitHandler } from './CircuitHandler';
2+
export class AutoReconnectCircuitHandler implements CircuitHandler {
3+
modal: HTMLDivElement;
4+
message: Text;
5+
isConnected: boolean | null;
6+
7+
constructor(private maxRetries: number = 5, private retryInterval: number = 3000) {
8+
this.modal = document.createElement('div');
9+
this.modal.className = 'modal';
10+
this.message = document.createTextNode('');
11+
this.modal.appendChild(this.message);
12+
document.addEventListener('DOMContentLoaded', () => document.body.appendChild(this.modal));
13+
this.isConnected = null;
14+
}
15+
onConnectionUp() {
16+
this.modal.style.display = 'none';
17+
}
18+
async onConnectionDown() {
19+
this.message.textContent = 'Attempting to reconnect to the server...';
20+
21+
this.modal.style.display = 'block';
22+
const delay = () => new Promise((resolve) => setTimeout(resolve, this.retryInterval));
23+
for (let i = 0; i < this.maxRetries; i++) {
24+
await delay();
25+
try {
26+
await window['Blazor'].reconnect();
27+
break;
28+
} catch (err) {
29+
console.error(err);
30+
}
31+
}
32+
33+
this.message.textContent = 'Failed to connect to server.';
34+
}
35+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export interface CircuitHandler {
2+
/** Invoked when a server connection is established or re-established after a connection failure.
3+
*/
4+
onConnectionUp?() : void;
5+
6+
/** Invoked when a server connection is dropped.
7+
* @param {Error} error Optionally argument containing the error that caused the connection to close (if any).
8+
*/
9+
onConnectionDown?(error?: Error): void;
10+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
.modal {
2+
position: fixed;
3+
top: 0;
4+
right: 0;
5+
bottom: 0;
6+
left: 0;
7+
z-index: 1000;
8+
display: none;
9+
overflow: hidden;
10+
background-color: #fff;
11+
opacity: 0.8;
12+
text-align: center;
13+
font-weight: bold;
14+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System;
5+
using System.Threading;
6+
using System.Threading.Tasks;
7+
using Microsoft.AspNetCore.SignalR;
8+
9+
namespace Microsoft.AspNetCore.Components.Server.Circuits
10+
{
11+
internal class CircuitClientProxy : IClientProxy
12+
{
13+
public static readonly CircuitClientProxy OfflineClient = new CircuitClientProxy();
14+
15+
private CircuitClientProxy()
16+
{
17+
Connected = false;
18+
}
19+
20+
public CircuitClientProxy(IClientProxy clientProxy, string connectionId)
21+
{
22+
Transfer(clientProxy, connectionId);
23+
}
24+
25+
public bool Connected { get; private set; }
26+
27+
public string ConnectionId { get; private set; }
28+
29+
public IClientProxy Client { get; private set; }
30+
31+
public void Transfer(IClientProxy clientProxy, string connectionId)
32+
{
33+
Client = clientProxy ?? throw new ArgumentNullException(nameof(clientProxy));
34+
ConnectionId = connectionId ?? throw new ArgumentNullException(nameof(connectionId));
35+
Connected = true;
36+
}
37+
38+
public void SetDisconnected()
39+
{
40+
Connected = false;
41+
}
42+
43+
public Task SendCoreAsync(string method, object[] args, CancellationToken cancellationToken = default)
44+
{
45+
if (Client == null)
46+
{
47+
throw new InvalidOperationException($"{nameof(SendCoreAsync)} cannot be invoked with an offline client.");
48+
}
49+
50+
return Client.SendCoreAsync(method, args, cancellationToken);
51+
}
52+
}
53+
}

src/Components/Server/src/Circuits/CircuitFactory.cs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,14 @@
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

44
using Microsoft.AspNetCore.Http;
5-
using Microsoft.AspNetCore.SignalR;
65

76
namespace Microsoft.AspNetCore.Components.Server.Circuits
87
{
98
internal abstract class CircuitFactory
109
{
1110
public abstract CircuitHost CreateCircuitHost(
1211
HttpContext httpContext,
13-
IClientProxy client,
12+
CircuitClientProxy client,
1413
string uriAbsolute,
1514
string baseUriAbsolute);
1615
}

src/Components/Server/src/Circuits/CircuitHandler.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,13 @@ public abstract class CircuitHandler
6767
/// <param name="circuit">The <see cref="Circuit"/>.</param>
6868
/// <param name="cancellationToken">The <see cref="CancellationToken"/>.</param>
6969
/// <returns><see cref="Task"/> that represents the asynchronous execution operation.</returns>
70+
/// <remarks>
71+
/// While <see cref="OnConnectionUpAsync(Circuit, CancellationToken)"/> is always invoked when a client
72+
/// connects to the server, <see cref="OnConnectionDownAsync(Circuit, CancellationToken)"/> may not be invoked
73+
/// if a client reconnects prior to the server identifying a client disconnect.
74+
/// </remarks>
7075
public virtual Task OnConnectionDownAsync(Circuit circuit, CancellationToken cancellationToken) => Task.CompletedTask;
7176

72-
7377
/// <summary>
7478
/// Invoked when a new circuit is being discarded.
7579
/// </summary>

0 commit comments

Comments
 (0)