Skip to content

Commit b4443ab

Browse files
committed
[Design] Client reconnects when state's available on the server
1 parent e3d0dc0 commit b4443ab

File tree

10 files changed

+290
-69
lines changed

10 files changed

+290
-69
lines changed

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

+81-54
Original file line numberDiff line numberDiff line change
@@ -6,70 +6,97 @@ 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';
910

10-
let connection : signalR.HubConnection;
11+
async function boot(reconnect: boolean = false) {
12+
const circuitHandlers = new Array<CircuitHandler>();
13+
window['Blazor'].addCircuitHandler = (circuitHandler: CircuitHandler) => circuitHandlers.push(circuitHandler);
1114

12-
function boot() {
13-
// In the background, start loading the boot config and any embedded resources
14-
const embeddedResourcesPromise = fetchBootConfigAsync().then(bootConfig => {
15-
return loadEmbeddedResourcesAsync(bootConfig);
16-
});
15+
await startCicuit();
1716

18-
connection = new signalR.HubConnectionBuilder()
19-
.withUrl('_blazor')
20-
.withHubProtocol(new MessagePackHubProtocol())
21-
.configureLogging(signalR.LogLevel.Information)
22-
.build();
17+
async function startCicuit(): Promise<void> {
18+
// In the background, start loading the boot config and any embedded resources
19+
const embeddedResourcesPromise = fetchBootConfigAsync().then(bootConfig => {
20+
return loadEmbeddedResourcesAsync(bootConfig);
21+
});
22+
23+
const initialConnection = await initializeConnection();
24+
25+
// Ensure any embedded resources have been loaded before starting the app
26+
await embeddedResourcesPromise;
27+
const circuitId = await initialConnection.invoke<string>(
28+
'StartCircuit',
29+
uriHelperFunctions.getLocationHref(),
30+
uriHelperFunctions.getBaseURI()
31+
);
32+
33+
window['Blazor'].reconnect = async () => {
34+
const reconnection = await initializeConnection();
35+
if (!await reconnection.invoke<Boolean>('ConnectCircuit', circuitId)) {
36+
throw "Failed to reconnect to the server";
37+
}
38+
39+
circuitHandlers.forEach(h => h.onConnectionUp());
40+
};
41+
42+
circuitHandlers.forEach(h => h.onConnectionUp());
43+
}
44+
45+
async function initializeConnection(): Promise<signalR.HubConnection> {
46+
const connection = new signalR.HubConnectionBuilder()
47+
.withUrl('_blazor')
48+
.withHubProtocol(new MessagePackHubProtocol())
49+
.configureLogging(signalR.LogLevel.Information)
50+
.build();
51+
connection.on('JS.BeginInvokeJS', DotNet.jsCallDispatcher.beginInvokeJSFromDotNet);
52+
connection.on('JS.RenderBatch', (browserRendererId: number, renderId: number, batchData: Uint8Array) => {
53+
try {
54+
renderBatch(browserRendererId, new OutOfProcessRenderBatch(batchData));
55+
connection.send('OnRenderCompleted', renderId, null);
56+
}
57+
catch (ex) {
58+
// If there's a rendering exception, notify server *and* throw on client
59+
connection.send('OnRenderCompleted', renderId, ex.toString());
60+
throw ex;
61+
}
62+
});
63+
64+
connection.onclose(error => circuitHandlers.forEach(h => h.onConnectionDown(error)));
65+
connection.on('JS.Error', unhandledError.bind(connection));
66+
67+
window['Blazor'].closeConnection = async() => {
68+
await connection.stop();
69+
DotNet.attachDispatcher({
70+
beginInvokeDotNetFromJS: (...args) => {}});
71+
}
2372

24-
connection.on('JS.BeginInvokeJS', DotNet.jsCallDispatcher.beginInvokeJSFromDotNet);
25-
connection.on('JS.RenderBatch', (browserRendererId: number, renderId: number, batchData: Uint8Array) => {
2673
try {
27-
renderBatch(browserRendererId, new OutOfProcessRenderBatch(batchData));
28-
connection.send('OnRenderCompleted', renderId, null);
74+
await connection.start();
2975
} catch (ex) {
30-
// If there's a rendering exception, notify server *and* throw on client
31-
connection.send('OnRenderCompleted', renderId, ex.toString());
32-
throw ex;
76+
unhandledError.call(connection, ex);
3377
}
34-
});
3578

36-
connection.on('JS.Error', unhandledError);
79+
DotNet.attachDispatcher({
80+
beginInvokeDotNetFromJS: (callId, assemblyName, methodIdentifier, dotNetObjectId, argsJson) => {
81+
connection.send('BeginInvokeDotNetFromJS', callId ? callId.toString() : null, assemblyName, methodIdentifier, dotNetObjectId || 0, argsJson);
82+
}
83+
});
3784

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);
60-
}
85+
return connection;
86+
}
6187

62-
function unhandledError(err) {
63-
console.error(err);
64-
65-
// Disconnect on errors.
66-
//
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-
//
70-
// Trying to call methods on the connection after its been closed will throw.
71-
if (connection) {
72-
connection.stop();
88+
function unhandledError(this: signalR.HubConnection, err) {
89+
console.error(err);
90+
91+
// Disconnect on errors.
92+
//
93+
// TODO: it would be nice to have some kind of experience for what happens when you're
94+
// trying to interact with an app that's disconnected.
95+
//
96+
// Trying to call methods on the connection after its been closed will throw.
97+
if (this) {
98+
this.stop();
99+
}
73100
}
74101
}
75102

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export abstract class CircuitHandler {
2+
onConnectionUp() : void{
3+
}
4+
5+
onConnectionDown(error?: Error): void{
6+
}
7+
}

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

+24-9
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
using Microsoft.AspNetCore.Components.Browser.Rendering;
99
using Microsoft.AspNetCore.Components.Builder;
1010
using Microsoft.AspNetCore.Components.Hosting;
11-
using Microsoft.AspNetCore.SignalR;
1211
using Microsoft.Extensions.DependencyInjection;
1312
using Microsoft.JSInterop;
1413

@@ -50,7 +49,7 @@ public static void SetCurrentCircuitHost(CircuitHost circuitHost)
5049

5150
public CircuitHost(
5251
IServiceScope scope,
53-
IClientProxy client,
52+
DelegatingClientProxy client,
5453
RendererRegistry rendererRegistry,
5554
RemoteRenderer renderer,
5655
Action<IComponentsApplicationBuilder> configure,
@@ -77,7 +76,7 @@ public CircuitHost(
7776

7877
public Circuit Circuit { get; }
7978

80-
public IClientProxy Client { get; }
79+
public DelegatingClientProxy Client { get; }
8180

8281
public IJSRuntime JSRuntime { get; }
8382

@@ -108,10 +107,7 @@ await Renderer.InvokeAsync(async () =>
108107
await _circuitHandlers[i].OnCircuitOpenedAsync(Circuit, cancellationToken);
109108
}
110109

111-
for (var i = 0; i < _circuitHandlers.Length; i++)
112-
{
113-
await _circuitHandlers[i].OnConnectionUpAsync(Circuit, cancellationToken);
114-
}
110+
await OnConnectionUpAsync(cancellationToken);
115111
});
116112

117113
_initialized = true;
@@ -135,14 +131,33 @@ await Renderer.Invoke(() =>
135131
}
136132
}
137133

138-
public async ValueTask DisposeAsync()
134+
public Task OnConnectionUpAsync(CancellationToken cancellationToken)
139135
{
140-
await Renderer.InvokeAsync(async () =>
136+
return Renderer.InvokeAsync(async () =>
137+
{
138+
for (var i = 0; i < _circuitHandlers.Length; i++)
139+
{
140+
await _circuitHandlers[i].OnConnectionUpAsync(Circuit, cancellationToken);
141+
}
142+
});
143+
}
144+
145+
public Task OnConnectionDownAsync()
146+
{
147+
return Renderer.InvokeAsync(async () =>
141148
{
142149
for (var i = 0; i < _circuitHandlers.Length; i++)
143150
{
144151
await _circuitHandlers[i].OnConnectionDownAsync(Circuit, default);
145152
}
153+
});
154+
}
155+
156+
public async ValueTask DisposeAsync()
157+
{
158+
await Renderer.InvokeAsync(async () =>
159+
{
160+
await OnConnectionDownAsync();
146161

147162
for (var i = 0; i < _circuitHandlers.Length; i++)
148163
{
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
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 Microsoft.Extensions.Caching.Memory;
7+
using Microsoft.Extensions.Primitives;
8+
9+
namespace Microsoft.AspNetCore.Components.Server.Circuits
10+
{
11+
internal class CircuitRegistry
12+
{
13+
private readonly MemoryCache _circuitHostRegistry;
14+
15+
public CircuitRegistry()
16+
{
17+
_circuitHostRegistry = new MemoryCache(new MemoryCacheOptions
18+
{
19+
SizeLimit = 100,
20+
});
21+
}
22+
23+
public void AddInactiveCircuit(CircuitHost circuitHost)
24+
{
25+
var tokenSource = new CancellationTokenSource();
26+
var entryOptions = new MemoryCacheEntryOptions
27+
{
28+
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(20),
29+
Size = 1,
30+
ExpirationTokens =
31+
{
32+
new CancellationChangeToken(tokenSource.Token),
33+
},
34+
PostEvictionCallbacks =
35+
{
36+
new PostEvictionCallbackRegistration
37+
{
38+
EvictionCallback = OnEntryEvicted,
39+
},
40+
},
41+
};
42+
43+
_circuitHostRegistry.Set(circuitHost.CircuitId, new CacheEntry(circuitHost, tokenSource), entryOptions);
44+
}
45+
46+
public bool TryGetInactiveCircuit(string circuitId, out CircuitHost host)
47+
{
48+
if (_circuitHostRegistry.TryGetValue(circuitId, out CacheEntry entry))
49+
{
50+
// Mark the entry as invalid.
51+
entry.TokenSource.Cancel();
52+
host = entry.Host;
53+
return true;
54+
}
55+
56+
host = null;
57+
return false;
58+
}
59+
60+
private void OnEntryEvicted(object key, object value, EvictionReason reason, object state)
61+
{
62+
if (reason == EvictionReason.Removed || reason == EvictionReason.Replaced || reason == EvictionReason.TokenExpired)
63+
{
64+
// If we were responsible for invalidating the entry, ignore.
65+
return;
66+
}
67+
68+
// For every thing else, notify the user.
69+
var entry = (CacheEntry)value;
70+
71+
// Fire off a dispose. We don't need to wait for it (?)
72+
_ = entry.Host.DisposeAsync();
73+
}
74+
75+
private class CacheEntry
76+
{
77+
public CacheEntry(CircuitHost host, CancellationTokenSource tokenSource)
78+
{
79+
Host = host;
80+
TokenSource = tokenSource;
81+
}
82+
83+
public CircuitHost Host { get; }
84+
public CancellationTokenSource TokenSource { get; }
85+
}
86+
}
87+
}

src/Components/Server/src/Circuits/DefaultCircuitFactory.cs

+3-2
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,9 @@ public override CircuitHost CreateCircuitHost(HttpContext httpContext, IClientPr
3939
throw new InvalidOperationException(message);
4040
}
4141

42+
var delegatingClient = new DelegatingClientProxy { Client = client };
4243
var scope = _scopeFactory.CreateScope();
43-
var jsRuntime = new RemoteJSRuntime(client);
44+
var jsRuntime = new RemoteJSRuntime(delegatingClient);
4445
var rendererRegistry = new RendererRegistry();
4546
var dispatcher = Renderer.CreateDefaultDispatcher();
4647
var renderer = new RemoteRenderer(
@@ -56,7 +57,7 @@ public override CircuitHost CreateCircuitHost(HttpContext httpContext, IClientPr
5657

5758
var circuitHost = new CircuitHost(
5859
scope,
59-
client,
60+
delegatingClient,
6061
rendererRegistry,
6162
renderer,
6263
config,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
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.Threading;
5+
using System.Threading.Tasks;
6+
using Microsoft.AspNetCore.SignalR;
7+
8+
namespace Microsoft.AspNetCore.Components.Server.Circuits
9+
{
10+
internal class DelegatingClientProxy : IClientProxy
11+
{
12+
public IClientProxy Client { get; set; }
13+
14+
public Task SendCoreAsync(string method, object[] args, CancellationToken cancellationToken = default)
15+
=> Client.SendCoreAsync(method, args, cancellationToken);
16+
}
17+
}

0 commit comments

Comments
 (0)