Skip to content

Client reconnects when state's available on the server #7395

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 11 commits into from
Mar 5, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
</Target>

<ItemGroup>
<ProjectReference Condition="'$(BuildNodeJS)' != 'false'" Include="$(RepositoryRoot)src\Components\Browser.JS\src\Microsoft.AspNetCore.Components.Browser.JS.npmproj" ReferenceOutputAssembly="false" />
<ProjectReference Condition="'$(BuildNodeJS)' != 'false'" Include="$(RepositoryRoot)src\Components\Browser.JS\Microsoft.AspNetCore.Components.Browser.JS.npmproj" ReferenceOutputAssembly="false" />
<Reference Include="Microsoft.AspNetCore.Components" />
<Reference Include="Microsoft.Extensions.CommandLineUtils.Sources" />
<Reference Include="Microsoft.Extensions.FileProviders.Composite" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<Import Project="$([MSBuild]::GetDirectoryNameOfFileAbove($(MSBuildThisFileDirectory), Directory.Build.props))\Directory.Build.props" />

<PropertyGroup>
<IsTestProject>false</IsTestProject>
<IsTestProject>true</IsTestProject>
<IsPackable>false</IsPackable>
</PropertyGroup>

Expand Down
14 changes: 14 additions & 0 deletions src/Components/Browser.JS/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

module.exports = {
globals: {
"ts-jest": {
"tsConfig": "./tests/tsconfig.json",
"babeConfig": true,
"diagnostics": true
}
},
preset: 'ts-jest',
testEnvironment: 'jsdom'
};
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,19 @@
"description": "",
"main": "index.js",
"scripts": {
"build:debug": "webpack --mode development",
"build:production": "webpack --mode production",
"test": "echo \"Error: no test specified\" && exit 1"
"build:debug": "cd src && webpack --mode development --config ./webpack.config.js",
"build:production": "cd src && webpack --mode production --config ./webpack.config.js",
"test": "jest"
},
"devDependencies": {
"@aspnet/signalr": "^1.0.0",
"@aspnet/signalr-protocol-msgpack": "^1.0.0",
"@dotnet/jsinterop": "^0.1.1",
"@types/jsdom": "11.0.6",
"@types/jest": "^24.0.6",
"@types/emscripten": "0.0.31",
"jest": "^24.1.0",
"ts-jest": "^24.0.0",
"ts-loader": "^4.4.1",
"typescript": "^2.9.2",
"webpack": "^4.12.0",
Expand Down
80 changes: 49 additions & 31 deletions src/Components/Browser.JS/src/Boot.Server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,43 @@ import { OutOfProcessRenderBatch } from './Rendering/RenderBatch/OutOfProcessRen
import { internalFunctions as uriHelperFunctions } from './Services/UriHelper';
import { renderBatch } from './Rendering/Renderer';
import { fetchBootConfigAsync, loadEmbeddedResourcesAsync } from './BootCommon';
import { CircuitHandler } from './Platform/Circuits/CircuitHandler';
import { AutoReconnectCircuitHandler } from './Platform/Circuits/AutoReconnectCircuitHandler';

let connection : signalR.HubConnection;
async function boot() {
const circuitHandlers: CircuitHandler[] = [ new AutoReconnectCircuitHandler() ];
window['Blazor'].circuitHandlers = circuitHandlers;

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

connection = new signalR.HubConnectionBuilder()
const initialConnection = await initializeConnection(circuitHandlers);

// Ensure any embedded resources have been loaded before starting the app
await embeddedResourcesPromise;
const circuitId = await initialConnection.invoke<string>(
'StartCircuit',
uriHelperFunctions.getLocationHref(),
uriHelperFunctions.getBaseURI()
);

window['Blazor'].reconnect = async () => {
const reconnection = await initializeConnection(circuitHandlers);
if (!(await reconnection.invoke<Boolean>('ConnectCircuit', circuitId))) {
return false;
}

circuitHandlers.forEach(h => h.onConnectionUp && h.onConnectionUp());
return true;
};

circuitHandlers.forEach(h => h.onConnectionUp && h.onConnectionUp());
}

async function initializeConnection(circuitHandlers: CircuitHandler[]): Promise<signalR.HubConnection> {
const connection = new signalR.HubConnectionBuilder()
.withUrl('_blazor')
.withHubProtocol(new MessagePackHubProtocol())
.configureLogging(signalR.LogLevel.Information)
Expand All @@ -33,40 +60,31 @@ function boot() {
}
});

connection.on('JS.Error', unhandledError);

connection.start()
.then(async () => {
DotNet.attachDispatcher({
beginInvokeDotNetFromJS: (callId, assemblyName, methodIdentifier, dotNetObjectId, argsJson) => {
connection.send('BeginInvokeDotNetFromJS', callId ? callId.toString() : null, assemblyName, methodIdentifier, dotNetObjectId || 0, argsJson);
}
});

// Ensure any embedded resources have been loaded before starting the app
await embeddedResourcesPromise;

connection.send(
'StartCircuit',
uriHelperFunctions.getLocationHref(),
uriHelperFunctions.getBaseURI()
);
})
.catch(unhandledError);

// Temporary undocumented API to help with https://github.com/aspnet/Blazor/issues/1339
// This will be replaced once we implement proper connection management (reconnects, etc.)
window['Blazor'].onServerConnectionClose = connection.onclose.bind(connection);
connection.onclose(error => circuitHandlers.forEach(h => h.onConnectionDown && h.onConnectionDown(error)));
connection.on('JS.Error', error => unhandledError(connection, error));

window['Blazor']._internal.forceCloseConnection = () => connection.stop();

try {
await connection.start();
} catch (ex) {
unhandledError(connection, ex);
}

DotNet.attachDispatcher({
beginInvokeDotNetFromJS: (callId, assemblyName, methodIdentifier, dotNetObjectId, argsJson) => {
connection.send('BeginInvokeDotNetFromJS', callId ? callId.toString() : null, assemblyName, methodIdentifier, dotNetObjectId || 0, argsJson);
}
});

return connection;
}

function unhandledError(err) {
function unhandledError(connection: signalR.HubConnection, err: Error) {
console.error(err);

// Disconnect on errors.
//
// TODO: it would be nice to have some kind of experience for what happens when you're
// trying to interact with an app that's disconnected.
//
// Trying to call methods on the connection after its been closed will throw.
if (connection) {
connection.stop();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { CircuitHandler } from './CircuitHandler';
import { UserSpecifiedDisplay } from './UserSpecifiedDisplay';
import { DefaultReconnectDisplay } from './DefaultReconnectDisplay';
import { ReconnectDisplay } from './ReconnectDisplay';
export class AutoReconnectCircuitHandler implements CircuitHandler {
static readonly MaxRetries = 5;
static readonly RetryInterval = 3000;
static readonly DialogId = 'components-reconnect-modal';
reconnectDisplay: ReconnectDisplay;

constructor() {
this.reconnectDisplay = new DefaultReconnectDisplay(document);
document.addEventListener('DOMContentLoaded', () => {
const modal = document.getElementById(AutoReconnectCircuitHandler.DialogId);
if (modal) {
this.reconnectDisplay = new UserSpecifiedDisplay(modal);
}
});
}
onConnectionUp() : void{
this.reconnectDisplay.hide();
}

delay() : Promise<void>{
return new Promise((resolve) => setTimeout(resolve, AutoReconnectCircuitHandler.RetryInterval));
}

async onConnectionDown() : Promise<void> {
this.reconnectDisplay.show();

for (let i = 0; i < AutoReconnectCircuitHandler.MaxRetries; i++) {
await this.delay();
try {
const result = await window['Blazor'].reconnect();
if (!result) {
// If the server responded and refused to reconnect, stop auto-retrying.
break;
}
return;
} catch (err) {
console.error(err);
}
}

this.reconnectDisplay.failed();
}
}
10 changes: 10 additions & 0 deletions src/Components/Browser.JS/src/Platform/Circuits/CircuitHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export interface CircuitHandler {
/** Invoked when a server connection is established or re-established after a connection failure.
*/
onConnectionUp?() : void;

/** Invoked when a server connection is dropped.
* @param {Error} error Optionally argument containing the error that caused the connection to close (if any).
*/
onConnectionDown?(error?: Error): void;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { ReconnectDisplay } from "./ReconnectDisplay";
import { AutoReconnectCircuitHandler } from "./AutoReconnectCircuitHandler";
export class DefaultReconnectDisplay implements ReconnectDisplay {
modal: HTMLDivElement;
message: HTMLHeadingElement;
button: HTMLButtonElement;
addedToDom: boolean = false;
constructor(private document: Document) {
this.modal = this.document.createElement('div');
this.modal.id = AutoReconnectCircuitHandler.DialogId;

const modalStyles = [
"position: fixed",
"top: 0",
"right: 0",
"bottom: 0",
"left: 0",
"z-index: 1000",
"display: none",
"overflow: hidden",
"background-color: #fff",
"opacity: 0.8",
"text-align: center",
"font-weight: bold"
];

this.modal.style.cssText = modalStyles.join(';');
this.modal.innerHTML = '<h5 style="margin-top: 20px"></h5><button style="margin:5px auto 5px">Retry?</button>';
this.message = this.modal.querySelector('h5')!;
this.button = this.modal.querySelector('button')!;

this.button.addEventListener('click', () => window['Blazor'].reconnect());
}
show(): void {
if (!this.addedToDom) {
this.addedToDom = true;
this.document.body.appendChild(this.modal);
}
this.modal.style.display = 'block';
this.button.style.display = 'none';
this.message.textContent = 'Attempting to reconnect to the server...';
}
hide(): void {
this.modal.style.display = 'none';
}
failed(): void {
this.button.style.display = 'block';
this.message.textContent = 'Failed to reconnect to the server.';
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export interface ReconnectDisplay {
show(): void;
hide(): void;
failed(): void;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { ReconnectDisplay } from "./ReconnectDisplay";
export class UserSpecifiedDisplay implements ReconnectDisplay {
static readonly ShowClassName = 'components-reconnect-show';
static readonly HideClassName = 'components-reconnect-hide';
static readonly FailedClassName = 'components-reconnect-failed';
constructor(private dialog: HTMLElement) {
}
show(): void {
this.removeClasses();
this.dialog.classList.add(UserSpecifiedDisplay.ShowClassName);
}
hide(): void {
this.removeClasses();
this.dialog.classList.add(UserSpecifiedDisplay.HideClassName);
}
failed(): void {
this.removeClasses();
this.dialog.classList.add(UserSpecifiedDisplay.FailedClassName);
}
private removeClasses() {
this.dialog.classList.remove(UserSpecifiedDisplay.ShowClassName, UserSpecifiedDisplay.HideClassName, UserSpecifiedDisplay.FailedClassName);
}
}
2 changes: 2 additions & 0 deletions src/Components/Browser.JS/src/Services/UriHelper.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import '@dotnet/jsinterop';

let hasRegisteredEventListeners = false;

// Will be initialized once someone registers
Expand Down
14 changes: 1 addition & 13 deletions src/Components/Browser.JS/src/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,15 +1,3 @@
{
"compilerOptions": {
"noImplicitAny": false,
"noEmitOnError": true,
"removeComments": false,
"sourceMap": true,
"target": "es5",
"lib": ["es2015", "dom"],
"strict": true
},
"exclude": [
"node_modules",
"wwwroot"
]
"extends": "../tsconfig.base.json"
}
Loading