API review: Web.JS. Fixes #12229 (#12361)

* Some initial tidying on Boot.Server.ts, though can't make much difference until stateful prerendering is removed

* In Web.JS, rename ILogger to Logger to match TypeScript conventions

* Move reconnection options into BlazorOptions

* In Web.JS, eliminate collection of CircuitHandlers and just have one ReconnectionHandler

* Expose Blazor.defaultReconnectionHandler

* Update binaries
This commit is contained in:
Steve Sanderson 2019-07-19 18:23:41 +01:00 committed by GitHub
parent e808a4f2ed
commit e451597a0a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 264 additions and 206 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -3,52 +3,33 @@ import './GlobalExports';
import * as signalR from '@aspnet/signalr';
import { MessagePackHubProtocol } from '@aspnet/signalr-protocol-msgpack';
import { shouldAutoStart } from './BootCommon';
import { CircuitHandler } from './Platform/Circuits/CircuitHandler';
import { AutoReconnectCircuitHandler } from './Platform/Circuits/AutoReconnectCircuitHandler';
import RenderQueue from './Platform/Circuits/RenderQueue';
import { RenderQueue } from './Platform/Circuits/RenderQueue';
import { ConsoleLogger } from './Platform/Logging/Loggers';
import { LogLevel, ILogger } from './Platform/Logging/ILogger';
import { LogLevel, Logger } from './Platform/Logging/Logger';
import { discoverPrerenderedCircuits, startCircuit } from './Platform/Circuits/CircuitManager';
import { setEventDispatcher } from './Rendering/RendererEventDispatcher';
type SignalRBuilder = (builder: signalR.HubConnectionBuilder) => void;
interface BlazorOptions {
configureSignalR: SignalRBuilder;
logLevel: LogLevel;
}
import { resolveOptions, BlazorOptions } from './Platform/Circuits/BlazorOptions';
import { DefaultReconnectionHandler } from './Platform/Circuits/DefaultReconnectionHandler';
let renderingFailed = false;
let started = false;
async function boot(userOptions?: Partial<BlazorOptions>): Promise<void> {
if (started) {
throw new Error('Blazor has already started.');
}
started = true;
const defaultOptions: BlazorOptions = {
configureSignalR: (_) => { },
logLevel: LogLevel.Warning,
};
const options: BlazorOptions = { ...defaultOptions, ...userOptions };
// For development.
// Simply put a break point here and modify the log level during
// development to get traces.
// In the future we will allow for users to configure this.
// Establish options to be used
const options = resolveOptions(userOptions);
const logger = new ConsoleLogger(options.logLevel);
window['Blazor'].defaultReconnectionHandler = new DefaultReconnectionHandler(logger);
options.reconnectionHandler = options.reconnectionHandler || window['Blazor'].defaultReconnectionHandler;
logger.log(LogLevel.Information, 'Starting up blazor server-side application.');
const circuitHandlers: CircuitHandler[] = [new AutoReconnectCircuitHandler(logger)];
window['Blazor'].circuitHandlers = circuitHandlers;
// pass options.configureSignalR to configure the signalR.HubConnectionBuilder
const initialConnection = await initializeConnection(options, circuitHandlers, logger);
// Initialize statefully prerendered circuits and their components
// Note: This will all be removed soon
const initialConnection = await initializeConnection(options, logger);
const circuits = discoverPrerenderedCircuits(document);
for (let i = 0; i < circuits.length; i++) {
const circuit = circuits[i];
@ -59,7 +40,6 @@ async function boot(userOptions?: Partial<BlazorOptions>): Promise<void> {
}
const circuit = await startCircuit(initialConnection);
if (!circuit) {
logger.log(LogLevel.Information, 'No preregistered components to render.');
}
@ -69,14 +49,15 @@ async function boot(userOptions?: Partial<BlazorOptions>): Promise<void> {
// We can't reconnect after a failure, so exit early.
return false;
}
const reconnection = existingConnection || await initializeConnection(options, circuitHandlers, logger);
const reconnection = existingConnection || await initializeConnection(options, logger);
const results = await Promise.all(circuits.map(circuit => circuit.reconnect(reconnection)));
if (reconnectionFailed(results)) {
return false;
}
circuitHandlers.forEach(h => h.onConnectionUp && h.onConnectionUp());
options.reconnectionHandler!.onConnectionUp();
return true;
};
@ -97,8 +78,7 @@ async function boot(userOptions?: Partial<BlazorOptions>): Promise<void> {
}
}
async function initializeConnection(options: Required<BlazorOptions>, circuitHandlers: CircuitHandler[], logger: ILogger): Promise<signalR.HubConnection> {
async function initializeConnection(options: BlazorOptions, logger: Logger): Promise<signalR.HubConnection> {
const hubProtocol = new MessagePackHubProtocol();
(hubProtocol as unknown as { name: string }).name = 'blazorpack';
@ -124,7 +104,7 @@ async function initializeConnection(options: Required<BlazorOptions>, circuitHan
queue.processBatch(batchId, batchData, connection);
});
connection.onclose(error => !renderingFailed && circuitHandlers.forEach(h => h.onConnectionDown && h.onConnectionDown(error)));
connection.onclose(error => !renderingFailed && options.reconnectionHandler!.onConnectionDown(options.reconnectionOptions, error));
connection.on('JS.Error', error => unhandledError(connection, error, logger));
window['Blazor']._internal.forceCloseConnection = () => connection.stop();
@ -147,7 +127,7 @@ async function initializeConnection(options: Required<BlazorOptions>, circuitHan
return connection;
}
function unhandledError(connection: signalR.HubConnection, err: Error, logger: ILogger): void {
function unhandledError(connection: signalR.HubConnection, err: Error, logger: Logger): void {
logger.log(LogLevel.Error, err);
// Disconnect on errors.
@ -160,6 +140,7 @@ function unhandledError(connection: signalR.HubConnection, err: Error, logger: I
}
window['Blazor'].start = boot;
if (shouldAutoStart()) {
boot();
}

View File

@ -40,7 +40,7 @@ interface BootJsonData {
// Tells you if the script was added without <script src="..." autostart="false"></script>
export function shouldAutoStart() {
return document &&
return !!(document &&
document.currentScript &&
document.currentScript.getAttribute('autostart') !== 'false';
}
document.currentScript.getAttribute('autostart') !== 'false');
}

View File

@ -1,55 +0,0 @@
import { CircuitHandler } from './CircuitHandler';
import { UserSpecifiedDisplay } from './UserSpecifiedDisplay';
import { DefaultReconnectDisplay } from './DefaultReconnectDisplay';
import { ReconnectDisplay } from './ReconnectDisplay';
import { ILogger, LogLevel } from '../Logging/ILogger';
export class AutoReconnectCircuitHandler implements CircuitHandler {
public static readonly MaxRetries = 5;
public static readonly RetryInterval = 3000;
public static readonly DialogId = 'components-reconnect-modal';
public reconnectDisplay: ReconnectDisplay;
public logger: ILogger;
public constructor(logger: ILogger) {
this.logger = logger;
this.reconnectDisplay = new DefaultReconnectDisplay(document);
document.addEventListener('DOMContentLoaded', () => {
const modal = document.getElementById(AutoReconnectCircuitHandler.DialogId);
if (modal) {
this.reconnectDisplay = new UserSpecifiedDisplay(modal);
}
});
}
public onConnectionUp(): void {
this.reconnectDisplay.hide();
}
public delay(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, AutoReconnectCircuitHandler.RetryInterval));
}
public 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) {
this.logger.log(LogLevel.Error, err);
}
}
this.reconnectDisplay.failed();
}
}

View File

@ -0,0 +1,40 @@
import { LogLevel } from '../Logging/Logger';
export interface BlazorOptions {
configureSignalR: (builder: signalR.HubConnectionBuilder) => void;
logLevel: LogLevel;
reconnectionOptions: ReconnectionOptions;
reconnectionHandler?: ReconnectionHandler;
}
export function resolveOptions(userOptions?: Partial<BlazorOptions>): BlazorOptions {
const result = { ...defaultOptions, ...userOptions };
// The spread operator can't be used for a deep merge, so do the same for subproperties
if (userOptions && userOptions.reconnectionOptions) {
result.reconnectionOptions = { ...defaultOptions.reconnectionOptions, ...userOptions.reconnectionOptions };
}
return result;
}
export interface ReconnectionOptions {
maxRetries: number;
retryIntervalMilliseconds: number;
dialogId: string;
}
export interface ReconnectionHandler {
onConnectionDown(options: ReconnectionOptions, error?: Error): void;
onConnectionUp(): void;
}
const defaultOptions: BlazorOptions = {
configureSignalR: (_) => { },
logLevel: LogLevel.Warning,
reconnectionOptions: {
maxRetries: 5,
retryIntervalMilliseconds: 3000,
dialogId: 'components-reconnect-modal',
},
};

View File

@ -1,10 +0,0 @@
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;
}

View File

@ -1,5 +1,5 @@
import { ReconnectDisplay } from './ReconnectDisplay';
import { AutoReconnectCircuitHandler } from './AutoReconnectCircuitHandler';
export class DefaultReconnectDisplay implements ReconnectDisplay {
modal: HTMLDivElement;
@ -9,9 +9,9 @@ export class DefaultReconnectDisplay implements ReconnectDisplay {
addedToDom: boolean = false;
constructor(private document: Document) {
constructor(dialogId: string, private document: Document) {
this.modal = this.document.createElement('div');
this.modal.id = AutoReconnectCircuitHandler.DialogId;
this.modal.id = dialogId;
const modalStyles = [
'position: fixed',

View File

@ -0,0 +1,82 @@
import { ReconnectionHandler, ReconnectionOptions } from './BlazorOptions';
import { ReconnectDisplay } from './ReconnectDisplay';
import { DefaultReconnectDisplay } from './DefaultReconnectDisplay';
import { UserSpecifiedDisplay } from './UserSpecifiedDisplay';
import { Logger, LogLevel } from '../Logging/Logger';
export class DefaultReconnectionHandler implements ReconnectionHandler {
private readonly _logger: Logger;
private readonly _overrideDisplay?: ReconnectDisplay;
private readonly _reconnectCallback: () => Promise<boolean>;
private _currentReconnectionProcess: ReconnectionProcess | null = null;
constructor(logger: Logger, overrideDisplay?: ReconnectDisplay, reconnectCallback?: () => Promise<boolean>) {
this._logger = logger;
this._overrideDisplay = overrideDisplay;
this._reconnectCallback = reconnectCallback || (() => window['Blazor'].reconnect());
}
onConnectionDown (options: ReconnectionOptions, error?: Error) {
if (!this._currentReconnectionProcess) {
this._currentReconnectionProcess = new ReconnectionProcess(options, this._logger, this._reconnectCallback, this._overrideDisplay);
}
}
onConnectionUp() {
if (this._currentReconnectionProcess) {
this._currentReconnectionProcess.dispose();
this._currentReconnectionProcess = null;
}
}
};
class ReconnectionProcess {
readonly reconnectDisplay: ReconnectDisplay;
isDisposed = false;
constructor(options: ReconnectionOptions, private logger: Logger, private reconnectCallback: () => Promise<boolean>, display?: ReconnectDisplay) {
const modal = document.getElementById(options.dialogId);
this.reconnectDisplay = display || (modal
? new UserSpecifiedDisplay(modal)
: new DefaultReconnectDisplay(options.dialogId, document));
this.reconnectDisplay.show();
this.attemptPeriodicReconnection(options);
}
public dispose() {
this.isDisposed = true;
this.reconnectDisplay.hide();
}
async attemptPeriodicReconnection(options: ReconnectionOptions) {
for (let i = 0; i < options.maxRetries; i++) {
await this.delay(options.retryIntervalMilliseconds);
if (this.isDisposed) {
break;
}
try {
// reconnectCallback will asynchronously return:
// - true to mean success
// - false to mean we reached the server, but it rejected the connection (e.g., unknown circuit ID)
// - exception to mean we didn't reach the server (this can be sync or async)
const result = await this.reconnectCallback();
if (!result) {
// If the server responded and refused to reconnect, stop auto-retrying.
break;
}
return;
} catch (err) {
// We got an exception so will try again momentarily
this.logger.log(LogLevel.Error, err);
}
}
this.reconnectDisplay.failed();
}
delay(durationMilliseconds: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, durationMilliseconds));
}
}

View File

@ -1,23 +1,23 @@
import { renderBatch } from '../../Rendering/Renderer';
import { OutOfProcessRenderBatch } from '../../Rendering/RenderBatch/OutOfProcessRenderBatch';
import { ILogger, LogLevel } from '../Logging/ILogger';
import { Logger, LogLevel } from '../Logging/Logger';
import { HubConnection } from '@aspnet/signalr';
export default class RenderQueue {
export class RenderQueue {
private static renderQueues = new Map<number, RenderQueue>();
private nextBatchId = 2;
public browserRendererId: number;
public logger: ILogger;
public logger: Logger;
public constructor(browserRendererId: number, logger: ILogger) {
public constructor(browserRendererId: number, logger: Logger) {
this.browserRendererId = browserRendererId;
this.logger = logger;
}
public static getOrCreateQueue(browserRendererId: number, logger: ILogger): RenderQueue {
public static getOrCreateQueue(browserRendererId: number, logger: Logger): RenderQueue {
const queue = this.renderQueues.get(browserRendererId);
if (queue) {
return queue;

View File

@ -24,7 +24,7 @@ export enum LogLevel {
}
/** An abstraction that provides a sink for diagnostic messages. */
export interface ILogger { // eslint-disable-line @typescript-eslint/interface-name-prefix
export interface Logger { // eslint-disable-line @typescript-eslint/interface-name-prefix
/** Called by the framework to emit a diagnostic message.
*
* @param {LogLevel} logLevel The severity level of the message.

View File

@ -1,9 +1,9 @@
/* eslint-disable no-console */
import { ILogger, LogLevel } from './ILogger';
import { Logger, LogLevel } from './Logger';
export class NullLogger implements ILogger {
public static instance: ILogger = new NullLogger();
export class NullLogger implements Logger {
public static instance: Logger = new NullLogger();
private constructor() { }
@ -11,7 +11,7 @@ export class NullLogger implements ILogger {
}
}
export class ConsoleLogger implements ILogger {
export class ConsoleLogger implements Logger {
private readonly minimumLogLevel: LogLevel;
public constructor(minimumLogLevel: LogLevel) {

View File

@ -1,78 +0,0 @@
import { AutoReconnectCircuitHandler } from "../src/Platform/Circuits/AutoReconnectCircuitHandler";
import { UserSpecifiedDisplay } from "../src/Platform/Circuits/UserSpecifiedDisplay";
import { DefaultReconnectDisplay } from "../src/Platform/Circuits/DefaultReconnectDisplay";
import { ReconnectDisplay } from "../src/Platform/Circuits/ReconnectDisplay";
import { NullLogger} from '../src/Platform/Logging/Loggers';
import '../src/GlobalExports';
describe('AutoReconnectCircuitHandler', () => {
it('creates default element', () => {
const handler = new AutoReconnectCircuitHandler(NullLogger.instance);
document.dispatchEvent(new Event('DOMContentLoaded'));
expect(handler.reconnectDisplay).toBeInstanceOf(DefaultReconnectDisplay);
});
it('locates user-specified handler', () => {
const element = document.createElement('div');
element.id = 'components-reconnect-modal';
document.body.appendChild(element);
const handler = new AutoReconnectCircuitHandler(NullLogger.instance);
document.dispatchEvent(new Event('DOMContentLoaded'));
expect(handler.reconnectDisplay).toBeInstanceOf(UserSpecifiedDisplay);
document.body.removeChild(element);
});
const TestDisplay = jest.fn<ReconnectDisplay, any[]>(() => ({
show: jest.fn(),
hide: jest.fn(),
failed: jest.fn()
}));
it('hides display on connection up', () => {
const handler = new AutoReconnectCircuitHandler(NullLogger.instance);
const testDisplay = new TestDisplay();
handler.reconnectDisplay = testDisplay;
handler.onConnectionUp();
expect(testDisplay.hide).toHaveBeenCalled();
});
it('shows display on connection down', async () => {
const handler = new AutoReconnectCircuitHandler(NullLogger.instance);
handler.delay = () => Promise.resolve();
const reconnect = jest.fn().mockResolvedValue(true);
window['Blazor'].reconnect = reconnect;
const testDisplay = new TestDisplay();
handler.reconnectDisplay = testDisplay;
await handler.onConnectionDown();
expect(testDisplay.show).toHaveBeenCalled();
expect(testDisplay.failed).not.toHaveBeenCalled();
expect(reconnect).toHaveBeenCalledTimes(1);
});
it('invokes failed if reconnect fails', async () => {
const handler = new AutoReconnectCircuitHandler(NullLogger.instance);
handler.delay = () => Promise.resolve();
const reconnect = jest.fn().mockRejectedValue(new Error('some error'));
window.console.error = jest.fn();
window['Blazor'].reconnect = reconnect;
const testDisplay = new TestDisplay();
handler.reconnectDisplay = testDisplay;
await handler.onConnectionDown();
expect(testDisplay.show).toHaveBeenCalled();
expect(testDisplay.failed).toHaveBeenCalled();
expect(reconnect).toHaveBeenCalledTimes(AutoReconnectCircuitHandler.MaxRetries);
});
});

View File

@ -6,13 +6,13 @@ describe('DefaultReconnectDisplay', () => {
it ('adds element to the body on show', () => {
const testDocument = new JSDOM().window.document;
const display = new DefaultReconnectDisplay(testDocument);
const display = new DefaultReconnectDisplay('test-dialog-id', testDocument);
display.show();
const element = testDocument.body.querySelector('div');
expect(element).toBeDefined();
expect(element!.id).toBe(AutoReconnectCircuitHandler.DialogId);
expect(element!.id).toBe('test-dialog-id');
expect(element!.style.display).toBe('block');
expect(display.message.textContent).toBe('Attempting to reconnect to the server...');
@ -21,7 +21,7 @@ describe('DefaultReconnectDisplay', () => {
it ('does not add element to the body multiple times', () => {
const testDocument = new JSDOM().window.document;
const display = new DefaultReconnectDisplay(testDocument);
const display = new DefaultReconnectDisplay('test-dialog-id', testDocument);
display.show();
display.show();
@ -31,7 +31,7 @@ describe('DefaultReconnectDisplay', () => {
it ('hides element', () => {
const testDocument = new JSDOM().window.document;
const display = new DefaultReconnectDisplay(testDocument);
const display = new DefaultReconnectDisplay('test-dialog-id', testDocument);
display.hide();
@ -40,7 +40,7 @@ describe('DefaultReconnectDisplay', () => {
it ('updates message on fail', () => {
const testDocument = new JSDOM().window.document;
const display = new DefaultReconnectDisplay(testDocument);
const display = new DefaultReconnectDisplay('test-dialog-id', testDocument);
display.show();
display.failed();

View File

@ -0,0 +1,98 @@
import '../src/GlobalExports';
import { UserSpecifiedDisplay } from '../src/Platform/Circuits/UserSpecifiedDisplay';
import { DefaultReconnectionHandler } from '../src/Platform/Circuits/DefaultReconnectionHandler';
import { NullLogger} from '../src/Platform/Logging/Loggers';
import { resolveOptions, ReconnectionOptions } from "../src/Platform/Circuits/BlazorOptions";
import { ReconnectDisplay } from '../src/Platform/Circuits/ReconnectDisplay';
const defaultReconnectionOptions = resolveOptions().reconnectionOptions;
describe('DefaultReconnectionHandler', () => {
it('toggles user-specified UI on disconnection/connection', () => {
const element = attachUserSpecifiedUI(defaultReconnectionOptions);
const handler = new DefaultReconnectionHandler(NullLogger.instance);
// Shows on disconnection
handler.onConnectionDown(defaultReconnectionOptions);
expect(element.className).toBe(UserSpecifiedDisplay.ShowClassName);
// Hides on reconnection
handler.onConnectionUp();
expect(element.className).toBe(UserSpecifiedDisplay.HideClassName);
document.body.removeChild(element);
});
it('hides display on connection up, and stops retrying', async () => {
const testDisplay = createTestDisplay();
const reconnect = jest.fn().mockResolvedValue(true);
const handler = new DefaultReconnectionHandler(NullLogger.instance, testDisplay, reconnect);
handler.onConnectionDown({
maxRetries: 1000,
retryIntervalMilliseconds: 100,
dialogId: 'ignored'
});
handler.onConnectionUp();
expect(testDisplay.hide).toHaveBeenCalled();
await delay(200);
expect(reconnect).not.toHaveBeenCalled();
});
it('shows display on connection down', async () => {
const testDisplay = createTestDisplay();
const reconnect = jest.fn().mockResolvedValue(true);
const handler = new DefaultReconnectionHandler(NullLogger.instance, testDisplay, reconnect);
handler.onConnectionDown({
maxRetries: 1000,
retryIntervalMilliseconds: 100,
dialogId: 'ignored'
});
expect(testDisplay.show).toHaveBeenCalled();
expect(testDisplay.failed).not.toHaveBeenCalled();
expect(reconnect).not.toHaveBeenCalled();
await delay(150);
expect(reconnect).toHaveBeenCalledTimes(1);
});
it('invokes failed if reconnect fails', async () => {
const testDisplay = createTestDisplay();
const reconnect = jest.fn().mockRejectedValue(null);
const handler = new DefaultReconnectionHandler(NullLogger.instance, testDisplay, reconnect);
window.console.error = jest.fn();
handler.onConnectionDown({
maxRetries: 3,
retryIntervalMilliseconds: 20,
dialogId: 'ignored'
});
await delay(100);
expect(testDisplay.show).toHaveBeenCalled();
expect(testDisplay.failed).toHaveBeenCalled();
expect(reconnect).toHaveBeenCalledTimes(3);
});
});
function attachUserSpecifiedUI(options: ReconnectionOptions): Element {
const element = document.createElement('div');
element.id = options.dialogId;
element.className = UserSpecifiedDisplay.HideClassName;
document.body.appendChild(element);
return element;
}
function delay(durationMilliseconds: number) {
return new Promise(resolve => setTimeout(resolve, durationMilliseconds));
}
function createTestDisplay(): ReconnectDisplay {
return {
show: jest.fn(),
hide: jest.fn(),
failed: jest.fn()
};
}

View File

@ -1,6 +1,6 @@
(global as any).DotNet = { attachReviver: jest.fn() };
import RenderQueue from '../src/Platform/Circuits/RenderQueue';
import { RenderQueue } from '../src/Platform/Circuits/RenderQueue';
import { NullLogger } from '../src/Platform/Logging/Loggers';
import * as signalR from '@aspnet/signalr';