943 lines
46 KiB
JavaScript
943 lines
46 KiB
JavaScript
"use strict";
|
|
// Licensed to the .NET Foundation under one or more agreements.
|
|
// The .NET Foundation licenses this file to you under the MIT license.
|
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
exports.HubConnection = exports.HubConnectionState = void 0;
|
|
const HandshakeProtocol_1 = require("./HandshakeProtocol");
|
|
const Errors_1 = require("./Errors");
|
|
const IHubProtocol_1 = require("./IHubProtocol");
|
|
const ILogger_1 = require("./ILogger");
|
|
const Subject_1 = require("./Subject");
|
|
const Utils_1 = require("./Utils");
|
|
const MessageBuffer_1 = require("./MessageBuffer");
|
|
const DEFAULT_TIMEOUT_IN_MS = 30 * 1000;
|
|
const DEFAULT_PING_INTERVAL_IN_MS = 15 * 1000;
|
|
const DEFAULT_STATEFUL_RECONNECT_BUFFER_SIZE = 100000;
|
|
/** Describes the current state of the {@link HubConnection} to the server. */
|
|
var HubConnectionState;
|
|
(function (HubConnectionState) {
|
|
/** The hub connection is disconnected. */
|
|
HubConnectionState["Disconnected"] = "Disconnected";
|
|
/** The hub connection is connecting. */
|
|
HubConnectionState["Connecting"] = "Connecting";
|
|
/** The hub connection is connected. */
|
|
HubConnectionState["Connected"] = "Connected";
|
|
/** The hub connection is disconnecting. */
|
|
HubConnectionState["Disconnecting"] = "Disconnecting";
|
|
/** The hub connection is reconnecting. */
|
|
HubConnectionState["Reconnecting"] = "Reconnecting";
|
|
})(HubConnectionState = exports.HubConnectionState || (exports.HubConnectionState = {}));
|
|
/** Represents a connection to a SignalR Hub. */
|
|
class HubConnection {
|
|
/** @internal */
|
|
// Using a public static factory method means we can have a private constructor and an _internal_
|
|
// create method that can be used by HubConnectionBuilder. An "internal" constructor would just
|
|
// be stripped away and the '.d.ts' file would have no constructor, which is interpreted as a
|
|
// public parameter-less constructor.
|
|
static create(connection, logger, protocol, reconnectPolicy, serverTimeoutInMilliseconds, keepAliveIntervalInMilliseconds, statefulReconnectBufferSize) {
|
|
return new HubConnection(connection, logger, protocol, reconnectPolicy, serverTimeoutInMilliseconds, keepAliveIntervalInMilliseconds, statefulReconnectBufferSize);
|
|
}
|
|
constructor(connection, logger, protocol, reconnectPolicy, serverTimeoutInMilliseconds, keepAliveIntervalInMilliseconds, statefulReconnectBufferSize) {
|
|
this._nextKeepAlive = 0;
|
|
this._freezeEventListener = () => {
|
|
this._logger.log(ILogger_1.LogLevel.Warning, "The page is being frozen, this will likely lead to the connection being closed and messages being lost. For more information see the docs at https://learn.microsoft.com/aspnet/core/signalr/javascript-client#bsleep");
|
|
};
|
|
Utils_1.Arg.isRequired(connection, "connection");
|
|
Utils_1.Arg.isRequired(logger, "logger");
|
|
Utils_1.Arg.isRequired(protocol, "protocol");
|
|
this.serverTimeoutInMilliseconds = serverTimeoutInMilliseconds !== null && serverTimeoutInMilliseconds !== void 0 ? serverTimeoutInMilliseconds : DEFAULT_TIMEOUT_IN_MS;
|
|
this.keepAliveIntervalInMilliseconds = keepAliveIntervalInMilliseconds !== null && keepAliveIntervalInMilliseconds !== void 0 ? keepAliveIntervalInMilliseconds : DEFAULT_PING_INTERVAL_IN_MS;
|
|
this._statefulReconnectBufferSize = statefulReconnectBufferSize !== null && statefulReconnectBufferSize !== void 0 ? statefulReconnectBufferSize : DEFAULT_STATEFUL_RECONNECT_BUFFER_SIZE;
|
|
this._logger = logger;
|
|
this._protocol = protocol;
|
|
this.connection = connection;
|
|
this._reconnectPolicy = reconnectPolicy;
|
|
this._handshakeProtocol = new HandshakeProtocol_1.HandshakeProtocol();
|
|
this.connection.onreceive = (data) => this._processIncomingData(data);
|
|
this.connection.onclose = (error) => this._connectionClosed(error);
|
|
this._callbacks = {};
|
|
this._methods = {};
|
|
this._closedCallbacks = [];
|
|
this._reconnectingCallbacks = [];
|
|
this._reconnectedCallbacks = [];
|
|
this._invocationId = 0;
|
|
this._receivedHandshakeResponse = false;
|
|
this._connectionState = HubConnectionState.Disconnected;
|
|
this._connectionStarted = false;
|
|
this._cachedPingMessage = this._protocol.writeMessage({ type: IHubProtocol_1.MessageType.Ping });
|
|
}
|
|
/** Indicates the state of the {@link HubConnection} to the server. */
|
|
get state() {
|
|
return this._connectionState;
|
|
}
|
|
/** Represents the connection id of the {@link HubConnection} on the server. The connection id will be null when the connection is either
|
|
* in the disconnected state or if the negotiation step was skipped.
|
|
*/
|
|
get connectionId() {
|
|
return this.connection ? (this.connection.connectionId || null) : null;
|
|
}
|
|
/** Indicates the url of the {@link HubConnection} to the server. */
|
|
get baseUrl() {
|
|
return this.connection.baseUrl || "";
|
|
}
|
|
/**
|
|
* Sets a new url for the HubConnection. Note that the url can only be changed when the connection is in either the Disconnected or
|
|
* Reconnecting states.
|
|
* @param {string} url The url to connect to.
|
|
*/
|
|
set baseUrl(url) {
|
|
if (this._connectionState !== HubConnectionState.Disconnected && this._connectionState !== HubConnectionState.Reconnecting) {
|
|
throw new Error("The HubConnection must be in the Disconnected or Reconnecting state to change the url.");
|
|
}
|
|
if (!url) {
|
|
throw new Error("The HubConnection url must be a valid url.");
|
|
}
|
|
this.connection.baseUrl = url;
|
|
}
|
|
/** Starts the connection.
|
|
*
|
|
* @returns {Promise<void>} A Promise that resolves when the connection has been successfully established, or rejects with an error.
|
|
*/
|
|
start() {
|
|
this._startPromise = this._startWithStateTransitions();
|
|
return this._startPromise;
|
|
}
|
|
async _startWithStateTransitions() {
|
|
if (this._connectionState !== HubConnectionState.Disconnected) {
|
|
return Promise.reject(new Error("Cannot start a HubConnection that is not in the 'Disconnected' state."));
|
|
}
|
|
this._connectionState = HubConnectionState.Connecting;
|
|
this._logger.log(ILogger_1.LogLevel.Debug, "Starting HubConnection.");
|
|
try {
|
|
await this._startInternal();
|
|
if (Utils_1.Platform.isBrowser) {
|
|
// Log when the browser freezes the tab so users know why their connection unexpectedly stopped working
|
|
window.document.addEventListener("freeze", this._freezeEventListener);
|
|
}
|
|
this._connectionState = HubConnectionState.Connected;
|
|
this._connectionStarted = true;
|
|
this._logger.log(ILogger_1.LogLevel.Debug, "HubConnection connected successfully.");
|
|
}
|
|
catch (e) {
|
|
this._connectionState = HubConnectionState.Disconnected;
|
|
this._logger.log(ILogger_1.LogLevel.Debug, `HubConnection failed to start successfully because of error '${e}'.`);
|
|
return Promise.reject(e);
|
|
}
|
|
}
|
|
async _startInternal() {
|
|
this._stopDuringStartError = undefined;
|
|
this._receivedHandshakeResponse = false;
|
|
// Set up the promise before any connection is (re)started otherwise it could race with received messages
|
|
const handshakePromise = new Promise((resolve, reject) => {
|
|
this._handshakeResolver = resolve;
|
|
this._handshakeRejecter = reject;
|
|
});
|
|
await this.connection.start(this._protocol.transferFormat);
|
|
try {
|
|
let version = this._protocol.version;
|
|
if (!this.connection.features.reconnect) {
|
|
// Stateful Reconnect starts with HubProtocol version 2, newer clients connecting to older servers will fail to connect due to
|
|
// the handshake only supporting version 1, so we will try to send version 1 during the handshake to keep old servers working.
|
|
version = 1;
|
|
}
|
|
const handshakeRequest = {
|
|
protocol: this._protocol.name,
|
|
version,
|
|
};
|
|
this._logger.log(ILogger_1.LogLevel.Debug, "Sending handshake request.");
|
|
await this._sendMessage(this._handshakeProtocol.writeHandshakeRequest(handshakeRequest));
|
|
this._logger.log(ILogger_1.LogLevel.Information, `Using HubProtocol '${this._protocol.name}'.`);
|
|
// defensively cleanup timeout in case we receive a message from the server before we finish start
|
|
this._cleanupTimeout();
|
|
this._resetTimeoutPeriod();
|
|
this._resetKeepAliveInterval();
|
|
await handshakePromise;
|
|
// It's important to check the stopDuringStartError instead of just relying on the handshakePromise
|
|
// being rejected on close, because this continuation can run after both the handshake completed successfully
|
|
// and the connection was closed.
|
|
if (this._stopDuringStartError) {
|
|
// It's important to throw instead of returning a rejected promise, because we don't want to allow any state
|
|
// transitions to occur between now and the calling code observing the exceptions. Returning a rejected promise
|
|
// will cause the calling continuation to get scheduled to run later.
|
|
// eslint-disable-next-line @typescript-eslint/no-throw-literal
|
|
throw this._stopDuringStartError;
|
|
}
|
|
const useStatefulReconnect = this.connection.features.reconnect || false;
|
|
if (useStatefulReconnect) {
|
|
this._messageBuffer = new MessageBuffer_1.MessageBuffer(this._protocol, this.connection, this._statefulReconnectBufferSize);
|
|
this.connection.features.disconnected = this._messageBuffer._disconnected.bind(this._messageBuffer);
|
|
this.connection.features.resend = () => {
|
|
if (this._messageBuffer) {
|
|
return this._messageBuffer._resend();
|
|
}
|
|
};
|
|
}
|
|
if (!this.connection.features.inherentKeepAlive) {
|
|
await this._sendMessage(this._cachedPingMessage);
|
|
}
|
|
}
|
|
catch (e) {
|
|
this._logger.log(ILogger_1.LogLevel.Debug, `Hub handshake failed with error '${e}' during start(). Stopping HubConnection.`);
|
|
this._cleanupTimeout();
|
|
this._cleanupPingTimer();
|
|
// HttpConnection.stop() should not complete until after the onclose callback is invoked.
|
|
// This will transition the HubConnection to the disconnected state before HttpConnection.stop() completes.
|
|
await this.connection.stop(e);
|
|
throw e;
|
|
}
|
|
}
|
|
/** Stops the connection.
|
|
*
|
|
* @returns {Promise<void>} A Promise that resolves when the connection has been successfully terminated, or rejects with an error.
|
|
*/
|
|
async stop() {
|
|
// Capture the start promise before the connection might be restarted in an onclose callback.
|
|
const startPromise = this._startPromise;
|
|
this.connection.features.reconnect = false;
|
|
this._stopPromise = this._stopInternal();
|
|
await this._stopPromise;
|
|
try {
|
|
// Awaiting undefined continues immediately
|
|
await startPromise;
|
|
}
|
|
catch (e) {
|
|
// This exception is returned to the user as a rejected Promise from the start method.
|
|
}
|
|
}
|
|
_stopInternal(error) {
|
|
if (this._connectionState === HubConnectionState.Disconnected) {
|
|
this._logger.log(ILogger_1.LogLevel.Debug, `Call to HubConnection.stop(${error}) ignored because it is already in the disconnected state.`);
|
|
return Promise.resolve();
|
|
}
|
|
if (this._connectionState === HubConnectionState.Disconnecting) {
|
|
this._logger.log(ILogger_1.LogLevel.Debug, `Call to HttpConnection.stop(${error}) ignored because the connection is already in the disconnecting state.`);
|
|
return this._stopPromise;
|
|
}
|
|
const state = this._connectionState;
|
|
this._connectionState = HubConnectionState.Disconnecting;
|
|
this._logger.log(ILogger_1.LogLevel.Debug, "Stopping HubConnection.");
|
|
if (this._reconnectDelayHandle) {
|
|
// We're in a reconnect delay which means the underlying connection is currently already stopped.
|
|
// Just clear the handle to stop the reconnect loop (which no one is waiting on thankfully) and
|
|
// fire the onclose callbacks.
|
|
this._logger.log(ILogger_1.LogLevel.Debug, "Connection stopped during reconnect delay. Done reconnecting.");
|
|
clearTimeout(this._reconnectDelayHandle);
|
|
this._reconnectDelayHandle = undefined;
|
|
this._completeClose();
|
|
return Promise.resolve();
|
|
}
|
|
if (state === HubConnectionState.Connected) {
|
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
|
this._sendCloseMessage();
|
|
}
|
|
this._cleanupTimeout();
|
|
this._cleanupPingTimer();
|
|
this._stopDuringStartError = error || new Errors_1.AbortError("The connection was stopped before the hub handshake could complete.");
|
|
// HttpConnection.stop() should not complete until after either HttpConnection.start() fails
|
|
// or the onclose callback is invoked. The onclose callback will transition the HubConnection
|
|
// to the disconnected state if need be before HttpConnection.stop() completes.
|
|
return this.connection.stop(error);
|
|
}
|
|
async _sendCloseMessage() {
|
|
try {
|
|
await this._sendWithProtocol(this._createCloseMessage());
|
|
}
|
|
catch {
|
|
// Ignore, this is a best effort attempt to let the server know the client closed gracefully.
|
|
}
|
|
}
|
|
/** Invokes a streaming hub method on the server using the specified name and arguments.
|
|
*
|
|
* @typeparam T The type of the items returned by the server.
|
|
* @param {string} methodName The name of the server method to invoke.
|
|
* @param {any[]} args The arguments used to invoke the server method.
|
|
* @returns {IStreamResult<T>} An object that yields results from the server as they are received.
|
|
*/
|
|
stream(methodName, ...args) {
|
|
const [streams, streamIds] = this._replaceStreamingParams(args);
|
|
const invocationDescriptor = this._createStreamInvocation(methodName, args, streamIds);
|
|
// eslint-disable-next-line prefer-const
|
|
let promiseQueue;
|
|
const subject = new Subject_1.Subject();
|
|
subject.cancelCallback = () => {
|
|
const cancelInvocation = this._createCancelInvocation(invocationDescriptor.invocationId);
|
|
delete this._callbacks[invocationDescriptor.invocationId];
|
|
return promiseQueue.then(() => {
|
|
return this._sendWithProtocol(cancelInvocation);
|
|
});
|
|
};
|
|
this._callbacks[invocationDescriptor.invocationId] = (invocationEvent, error) => {
|
|
if (error) {
|
|
subject.error(error);
|
|
return;
|
|
}
|
|
else if (invocationEvent) {
|
|
// invocationEvent will not be null when an error is not passed to the callback
|
|
if (invocationEvent.type === IHubProtocol_1.MessageType.Completion) {
|
|
if (invocationEvent.error) {
|
|
subject.error(new Error(invocationEvent.error));
|
|
}
|
|
else {
|
|
subject.complete();
|
|
}
|
|
}
|
|
else {
|
|
subject.next((invocationEvent.item));
|
|
}
|
|
}
|
|
};
|
|
promiseQueue = this._sendWithProtocol(invocationDescriptor)
|
|
.catch((e) => {
|
|
subject.error(e);
|
|
delete this._callbacks[invocationDescriptor.invocationId];
|
|
});
|
|
this._launchStreams(streams, promiseQueue);
|
|
return subject;
|
|
}
|
|
_sendMessage(message) {
|
|
this._resetKeepAliveInterval();
|
|
return this.connection.send(message);
|
|
}
|
|
/**
|
|
* Sends a js object to the server.
|
|
* @param message The js object to serialize and send.
|
|
*/
|
|
_sendWithProtocol(message) {
|
|
if (this._messageBuffer) {
|
|
return this._messageBuffer._send(message);
|
|
}
|
|
else {
|
|
return this._sendMessage(this._protocol.writeMessage(message));
|
|
}
|
|
}
|
|
/** Invokes a hub method on the server using the specified name and arguments. Does not wait for a response from the receiver.
|
|
*
|
|
* The Promise returned by this method resolves when the client has sent the invocation to the server. The server may still
|
|
* be processing the invocation.
|
|
*
|
|
* @param {string} methodName The name of the server method to invoke.
|
|
* @param {any[]} args The arguments used to invoke the server method.
|
|
* @returns {Promise<void>} A Promise that resolves when the invocation has been successfully sent, or rejects with an error.
|
|
*/
|
|
send(methodName, ...args) {
|
|
const [streams, streamIds] = this._replaceStreamingParams(args);
|
|
const sendPromise = this._sendWithProtocol(this._createInvocation(methodName, args, true, streamIds));
|
|
this._launchStreams(streams, sendPromise);
|
|
return sendPromise;
|
|
}
|
|
/** Invokes a hub method on the server using the specified name and arguments.
|
|
*
|
|
* The Promise returned by this method resolves when the server indicates it has finished invoking the method. When the promise
|
|
* resolves, the server has finished invoking the method. If the server method returns a result, it is produced as the result of
|
|
* resolving the Promise.
|
|
*
|
|
* @typeparam T The expected return type.
|
|
* @param {string} methodName The name of the server method to invoke.
|
|
* @param {any[]} args The arguments used to invoke the server method.
|
|
* @returns {Promise<T>} A Promise that resolves with the result of the server method (if any), or rejects with an error.
|
|
*/
|
|
invoke(methodName, ...args) {
|
|
const [streams, streamIds] = this._replaceStreamingParams(args);
|
|
const invocationDescriptor = this._createInvocation(methodName, args, false, streamIds);
|
|
const p = new Promise((resolve, reject) => {
|
|
// invocationId will always have a value for a non-blocking invocation
|
|
this._callbacks[invocationDescriptor.invocationId] = (invocationEvent, error) => {
|
|
if (error) {
|
|
reject(error);
|
|
return;
|
|
}
|
|
else if (invocationEvent) {
|
|
// invocationEvent will not be null when an error is not passed to the callback
|
|
if (invocationEvent.type === IHubProtocol_1.MessageType.Completion) {
|
|
if (invocationEvent.error) {
|
|
reject(new Error(invocationEvent.error));
|
|
}
|
|
else {
|
|
resolve(invocationEvent.result);
|
|
}
|
|
}
|
|
else {
|
|
reject(new Error(`Unexpected message type: ${invocationEvent.type}`));
|
|
}
|
|
}
|
|
};
|
|
const promiseQueue = this._sendWithProtocol(invocationDescriptor)
|
|
.catch((e) => {
|
|
reject(e);
|
|
// invocationId will always have a value for a non-blocking invocation
|
|
delete this._callbacks[invocationDescriptor.invocationId];
|
|
});
|
|
this._launchStreams(streams, promiseQueue);
|
|
});
|
|
return p;
|
|
}
|
|
on(methodName, newMethod) {
|
|
if (!methodName || !newMethod) {
|
|
return;
|
|
}
|
|
methodName = methodName.toLowerCase();
|
|
if (!this._methods[methodName]) {
|
|
this._methods[methodName] = [];
|
|
}
|
|
// Preventing adding the same handler multiple times.
|
|
if (this._methods[methodName].indexOf(newMethod) !== -1) {
|
|
return;
|
|
}
|
|
this._methods[methodName].push(newMethod);
|
|
}
|
|
off(methodName, method) {
|
|
if (!methodName) {
|
|
return;
|
|
}
|
|
methodName = methodName.toLowerCase();
|
|
const handlers = this._methods[methodName];
|
|
if (!handlers) {
|
|
return;
|
|
}
|
|
if (method) {
|
|
const removeIdx = handlers.indexOf(method);
|
|
if (removeIdx !== -1) {
|
|
handlers.splice(removeIdx, 1);
|
|
if (handlers.length === 0) {
|
|
delete this._methods[methodName];
|
|
}
|
|
}
|
|
}
|
|
else {
|
|
delete this._methods[methodName];
|
|
}
|
|
}
|
|
/** Registers a handler that will be invoked when the connection is closed.
|
|
*
|
|
* @param {Function} callback The handler that will be invoked when the connection is closed. Optionally receives a single argument containing the error that caused the connection to close (if any).
|
|
*/
|
|
onclose(callback) {
|
|
if (callback) {
|
|
this._closedCallbacks.push(callback);
|
|
}
|
|
}
|
|
/** Registers a handler that will be invoked when the connection starts reconnecting.
|
|
*
|
|
* @param {Function} callback The handler that will be invoked when the connection starts reconnecting. Optionally receives a single argument containing the error that caused the connection to start reconnecting (if any).
|
|
*/
|
|
onreconnecting(callback) {
|
|
if (callback) {
|
|
this._reconnectingCallbacks.push(callback);
|
|
}
|
|
}
|
|
/** Registers a handler that will be invoked when the connection successfully reconnects.
|
|
*
|
|
* @param {Function} callback The handler that will be invoked when the connection successfully reconnects.
|
|
*/
|
|
onreconnected(callback) {
|
|
if (callback) {
|
|
this._reconnectedCallbacks.push(callback);
|
|
}
|
|
}
|
|
_processIncomingData(data) {
|
|
this._cleanupTimeout();
|
|
if (!this._receivedHandshakeResponse) {
|
|
data = this._processHandshakeResponse(data);
|
|
this._receivedHandshakeResponse = true;
|
|
}
|
|
// Data may have all been read when processing handshake response
|
|
if (data) {
|
|
// Parse the messages
|
|
const messages = this._protocol.parseMessages(data, this._logger);
|
|
for (const message of messages) {
|
|
if (this._messageBuffer && !this._messageBuffer._shouldProcessMessage(message)) {
|
|
// Don't process the message, we are either waiting for a SequenceMessage or received a duplicate message
|
|
continue;
|
|
}
|
|
switch (message.type) {
|
|
case IHubProtocol_1.MessageType.Invocation:
|
|
this._invokeClientMethod(message)
|
|
.catch((e) => {
|
|
this._logger.log(ILogger_1.LogLevel.Error, `Invoke client method threw error: ${(0, Utils_1.getErrorString)(e)}`);
|
|
});
|
|
break;
|
|
case IHubProtocol_1.MessageType.StreamItem:
|
|
case IHubProtocol_1.MessageType.Completion: {
|
|
const callback = this._callbacks[message.invocationId];
|
|
if (callback) {
|
|
if (message.type === IHubProtocol_1.MessageType.Completion) {
|
|
delete this._callbacks[message.invocationId];
|
|
}
|
|
try {
|
|
callback(message);
|
|
}
|
|
catch (e) {
|
|
this._logger.log(ILogger_1.LogLevel.Error, `Stream callback threw error: ${(0, Utils_1.getErrorString)(e)}`);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
case IHubProtocol_1.MessageType.Ping:
|
|
// Don't care about pings
|
|
break;
|
|
case IHubProtocol_1.MessageType.Close: {
|
|
this._logger.log(ILogger_1.LogLevel.Information, "Close message received from server.");
|
|
const error = message.error ? new Error("Server returned an error on close: " + message.error) : undefined;
|
|
if (message.allowReconnect === true) {
|
|
// It feels wrong not to await connection.stop() here, but processIncomingData is called as part of an onreceive callback which is not async,
|
|
// this is already the behavior for serverTimeout(), and HttpConnection.Stop() should catch and log all possible exceptions.
|
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
|
this.connection.stop(error);
|
|
}
|
|
else {
|
|
// We cannot await stopInternal() here, but subsequent calls to stop() will await this if stopInternal() is still ongoing.
|
|
this._stopPromise = this._stopInternal(error);
|
|
}
|
|
break;
|
|
}
|
|
case IHubProtocol_1.MessageType.Ack:
|
|
if (this._messageBuffer) {
|
|
this._messageBuffer._ack(message);
|
|
}
|
|
break;
|
|
case IHubProtocol_1.MessageType.Sequence:
|
|
if (this._messageBuffer) {
|
|
this._messageBuffer._resetSequence(message);
|
|
}
|
|
break;
|
|
default:
|
|
this._logger.log(ILogger_1.LogLevel.Warning, `Invalid message type: ${message.type}.`);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
this._resetTimeoutPeriod();
|
|
}
|
|
_processHandshakeResponse(data) {
|
|
let responseMessage;
|
|
let remainingData;
|
|
try {
|
|
[remainingData, responseMessage] = this._handshakeProtocol.parseHandshakeResponse(data);
|
|
}
|
|
catch (e) {
|
|
const message = "Error parsing handshake response: " + e;
|
|
this._logger.log(ILogger_1.LogLevel.Error, message);
|
|
const error = new Error(message);
|
|
this._handshakeRejecter(error);
|
|
throw error;
|
|
}
|
|
if (responseMessage.error) {
|
|
const message = "Server returned handshake error: " + responseMessage.error;
|
|
this._logger.log(ILogger_1.LogLevel.Error, message);
|
|
const error = new Error(message);
|
|
this._handshakeRejecter(error);
|
|
throw error;
|
|
}
|
|
else {
|
|
this._logger.log(ILogger_1.LogLevel.Debug, "Server handshake complete.");
|
|
}
|
|
this._handshakeResolver();
|
|
return remainingData;
|
|
}
|
|
_resetKeepAliveInterval() {
|
|
if (this.connection.features.inherentKeepAlive) {
|
|
return;
|
|
}
|
|
// Set the time we want the next keep alive to be sent
|
|
// Timer will be setup on next message receive
|
|
this._nextKeepAlive = new Date().getTime() + this.keepAliveIntervalInMilliseconds;
|
|
this._cleanupPingTimer();
|
|
}
|
|
_resetTimeoutPeriod() {
|
|
if (!this.connection.features || !this.connection.features.inherentKeepAlive) {
|
|
// Set the timeout timer
|
|
this._timeoutHandle = setTimeout(() => this.serverTimeout(), this.serverTimeoutInMilliseconds);
|
|
// Set keepAlive timer if there isn't one
|
|
if (this._pingServerHandle === undefined) {
|
|
let nextPing = this._nextKeepAlive - new Date().getTime();
|
|
if (nextPing < 0) {
|
|
nextPing = 0;
|
|
}
|
|
// The timer needs to be set from a networking callback to avoid Chrome timer throttling from causing timers to run once a minute
|
|
this._pingServerHandle = setTimeout(async () => {
|
|
if (this._connectionState === HubConnectionState.Connected) {
|
|
try {
|
|
await this._sendMessage(this._cachedPingMessage);
|
|
}
|
|
catch {
|
|
// We don't care about the error. It should be seen elsewhere in the client.
|
|
// The connection is probably in a bad or closed state now, cleanup the timer so it stops triggering
|
|
this._cleanupPingTimer();
|
|
}
|
|
}
|
|
}, nextPing);
|
|
}
|
|
}
|
|
}
|
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
serverTimeout() {
|
|
// The server hasn't talked to us in a while. It doesn't like us anymore ... :(
|
|
// Terminate the connection, but we don't need to wait on the promise. This could trigger reconnecting.
|
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
|
this.connection.stop(new Error("Server timeout elapsed without receiving a message from the server."));
|
|
}
|
|
async _invokeClientMethod(invocationMessage) {
|
|
const methodName = invocationMessage.target.toLowerCase();
|
|
const methods = this._methods[methodName];
|
|
if (!methods) {
|
|
this._logger.log(ILogger_1.LogLevel.Warning, `No client method with the name '${methodName}' found.`);
|
|
// No handlers provided by client but the server is expecting a response still, so we send an error
|
|
if (invocationMessage.invocationId) {
|
|
this._logger.log(ILogger_1.LogLevel.Warning, `No result given for '${methodName}' method and invocation ID '${invocationMessage.invocationId}'.`);
|
|
await this._sendWithProtocol(this._createCompletionMessage(invocationMessage.invocationId, "Client didn't provide a result.", null));
|
|
}
|
|
return;
|
|
}
|
|
// Avoid issues with handlers removing themselves thus modifying the list while iterating through it
|
|
const methodsCopy = methods.slice();
|
|
// Server expects a response
|
|
const expectsResponse = invocationMessage.invocationId ? true : false;
|
|
// We preserve the last result or exception but still call all handlers
|
|
let res;
|
|
let exception;
|
|
let completionMessage;
|
|
for (const m of methodsCopy) {
|
|
try {
|
|
const prevRes = res;
|
|
res = await m.apply(this, invocationMessage.arguments);
|
|
if (expectsResponse && res && prevRes) {
|
|
this._logger.log(ILogger_1.LogLevel.Error, `Multiple results provided for '${methodName}'. Sending error to server.`);
|
|
completionMessage = this._createCompletionMessage(invocationMessage.invocationId, `Client provided multiple results.`, null);
|
|
}
|
|
// Ignore exception if we got a result after, the exception will be logged
|
|
exception = undefined;
|
|
}
|
|
catch (e) {
|
|
exception = e;
|
|
this._logger.log(ILogger_1.LogLevel.Error, `A callback for the method '${methodName}' threw error '${e}'.`);
|
|
}
|
|
}
|
|
if (completionMessage) {
|
|
await this._sendWithProtocol(completionMessage);
|
|
}
|
|
else if (expectsResponse) {
|
|
// If there is an exception that means either no result was given or a handler after a result threw
|
|
if (exception) {
|
|
completionMessage = this._createCompletionMessage(invocationMessage.invocationId, `${exception}`, null);
|
|
}
|
|
else if (res !== undefined) {
|
|
completionMessage = this._createCompletionMessage(invocationMessage.invocationId, null, res);
|
|
}
|
|
else {
|
|
this._logger.log(ILogger_1.LogLevel.Warning, `No result given for '${methodName}' method and invocation ID '${invocationMessage.invocationId}'.`);
|
|
// Client didn't provide a result or throw from a handler, server expects a response so we send an error
|
|
completionMessage = this._createCompletionMessage(invocationMessage.invocationId, "Client didn't provide a result.", null);
|
|
}
|
|
await this._sendWithProtocol(completionMessage);
|
|
}
|
|
else {
|
|
if (res) {
|
|
this._logger.log(ILogger_1.LogLevel.Error, `Result given for '${methodName}' method but server is not expecting a result.`);
|
|
}
|
|
}
|
|
}
|
|
_connectionClosed(error) {
|
|
this._logger.log(ILogger_1.LogLevel.Debug, `HubConnection.connectionClosed(${error}) called while in state ${this._connectionState}.`);
|
|
// Triggering this.handshakeRejecter is insufficient because it could already be resolved without the continuation having run yet.
|
|
this._stopDuringStartError = this._stopDuringStartError || error || new Errors_1.AbortError("The underlying connection was closed before the hub handshake could complete.");
|
|
// If the handshake is in progress, start will be waiting for the handshake promise, so we complete it.
|
|
// If it has already completed, this should just noop.
|
|
if (this._handshakeResolver) {
|
|
this._handshakeResolver();
|
|
}
|
|
this._cancelCallbacksWithError(error || new Error("Invocation canceled due to the underlying connection being closed."));
|
|
this._cleanupTimeout();
|
|
this._cleanupPingTimer();
|
|
if (this._connectionState === HubConnectionState.Disconnecting) {
|
|
this._completeClose(error);
|
|
}
|
|
else if (this._connectionState === HubConnectionState.Connected && this._reconnectPolicy) {
|
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
|
this._reconnect(error);
|
|
}
|
|
else if (this._connectionState === HubConnectionState.Connected) {
|
|
this._completeClose(error);
|
|
}
|
|
// If none of the above if conditions were true were called the HubConnection must be in either:
|
|
// 1. The Connecting state in which case the handshakeResolver will complete it and stopDuringStartError will fail it.
|
|
// 2. The Reconnecting state in which case the handshakeResolver will complete it and stopDuringStartError will fail the current reconnect attempt
|
|
// and potentially continue the reconnect() loop.
|
|
// 3. The Disconnected state in which case we're already done.
|
|
}
|
|
_completeClose(error) {
|
|
if (this._connectionStarted) {
|
|
this._connectionState = HubConnectionState.Disconnected;
|
|
this._connectionStarted = false;
|
|
if (this._messageBuffer) {
|
|
this._messageBuffer._dispose(error !== null && error !== void 0 ? error : new Error("Connection closed."));
|
|
this._messageBuffer = undefined;
|
|
}
|
|
if (Utils_1.Platform.isBrowser) {
|
|
window.document.removeEventListener("freeze", this._freezeEventListener);
|
|
}
|
|
try {
|
|
this._closedCallbacks.forEach((c) => c.apply(this, [error]));
|
|
}
|
|
catch (e) {
|
|
this._logger.log(ILogger_1.LogLevel.Error, `An onclose callback called with error '${error}' threw error '${e}'.`);
|
|
}
|
|
}
|
|
}
|
|
async _reconnect(error) {
|
|
const reconnectStartTime = Date.now();
|
|
let previousReconnectAttempts = 0;
|
|
let retryError = error !== undefined ? error : new Error("Attempting to reconnect due to a unknown error.");
|
|
let nextRetryDelay = this._getNextRetryDelay(previousReconnectAttempts++, 0, retryError);
|
|
if (nextRetryDelay === null) {
|
|
this._logger.log(ILogger_1.LogLevel.Debug, "Connection not reconnecting because the IRetryPolicy returned null on the first reconnect attempt.");
|
|
this._completeClose(error);
|
|
return;
|
|
}
|
|
this._connectionState = HubConnectionState.Reconnecting;
|
|
if (error) {
|
|
this._logger.log(ILogger_1.LogLevel.Information, `Connection reconnecting because of error '${error}'.`);
|
|
}
|
|
else {
|
|
this._logger.log(ILogger_1.LogLevel.Information, "Connection reconnecting.");
|
|
}
|
|
if (this._reconnectingCallbacks.length !== 0) {
|
|
try {
|
|
this._reconnectingCallbacks.forEach((c) => c.apply(this, [error]));
|
|
}
|
|
catch (e) {
|
|
this._logger.log(ILogger_1.LogLevel.Error, `An onreconnecting callback called with error '${error}' threw error '${e}'.`);
|
|
}
|
|
// Exit early if an onreconnecting callback called connection.stop().
|
|
if (this._connectionState !== HubConnectionState.Reconnecting) {
|
|
this._logger.log(ILogger_1.LogLevel.Debug, "Connection left the reconnecting state in onreconnecting callback. Done reconnecting.");
|
|
return;
|
|
}
|
|
}
|
|
while (nextRetryDelay !== null) {
|
|
this._logger.log(ILogger_1.LogLevel.Information, `Reconnect attempt number ${previousReconnectAttempts} will start in ${nextRetryDelay} ms.`);
|
|
await new Promise((resolve) => {
|
|
this._reconnectDelayHandle = setTimeout(resolve, nextRetryDelay);
|
|
});
|
|
this._reconnectDelayHandle = undefined;
|
|
if (this._connectionState !== HubConnectionState.Reconnecting) {
|
|
this._logger.log(ILogger_1.LogLevel.Debug, "Connection left the reconnecting state during reconnect delay. Done reconnecting.");
|
|
return;
|
|
}
|
|
try {
|
|
await this._startInternal();
|
|
this._connectionState = HubConnectionState.Connected;
|
|
this._logger.log(ILogger_1.LogLevel.Information, "HubConnection reconnected successfully.");
|
|
if (this._reconnectedCallbacks.length !== 0) {
|
|
try {
|
|
this._reconnectedCallbacks.forEach((c) => c.apply(this, [this.connection.connectionId]));
|
|
}
|
|
catch (e) {
|
|
this._logger.log(ILogger_1.LogLevel.Error, `An onreconnected callback called with connectionId '${this.connection.connectionId}; threw error '${e}'.`);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
catch (e) {
|
|
this._logger.log(ILogger_1.LogLevel.Information, `Reconnect attempt failed because of error '${e}'.`);
|
|
if (this._connectionState !== HubConnectionState.Reconnecting) {
|
|
this._logger.log(ILogger_1.LogLevel.Debug, `Connection moved to the '${this._connectionState}' from the reconnecting state during reconnect attempt. Done reconnecting.`);
|
|
// The TypeScript compiler thinks that connectionState must be Connected here. The TypeScript compiler is wrong.
|
|
if (this._connectionState === HubConnectionState.Disconnecting) {
|
|
this._completeClose();
|
|
}
|
|
return;
|
|
}
|
|
retryError = e instanceof Error ? e : new Error(e.toString());
|
|
nextRetryDelay = this._getNextRetryDelay(previousReconnectAttempts++, Date.now() - reconnectStartTime, retryError);
|
|
}
|
|
}
|
|
this._logger.log(ILogger_1.LogLevel.Information, `Reconnect retries have been exhausted after ${Date.now() - reconnectStartTime} ms and ${previousReconnectAttempts} failed attempts. Connection disconnecting.`);
|
|
this._completeClose();
|
|
}
|
|
_getNextRetryDelay(previousRetryCount, elapsedMilliseconds, retryReason) {
|
|
try {
|
|
return this._reconnectPolicy.nextRetryDelayInMilliseconds({
|
|
elapsedMilliseconds,
|
|
previousRetryCount,
|
|
retryReason,
|
|
});
|
|
}
|
|
catch (e) {
|
|
this._logger.log(ILogger_1.LogLevel.Error, `IRetryPolicy.nextRetryDelayInMilliseconds(${previousRetryCount}, ${elapsedMilliseconds}) threw error '${e}'.`);
|
|
return null;
|
|
}
|
|
}
|
|
_cancelCallbacksWithError(error) {
|
|
const callbacks = this._callbacks;
|
|
this._callbacks = {};
|
|
Object.keys(callbacks)
|
|
.forEach((key) => {
|
|
const callback = callbacks[key];
|
|
try {
|
|
callback(null, error);
|
|
}
|
|
catch (e) {
|
|
this._logger.log(ILogger_1.LogLevel.Error, `Stream 'error' callback called with '${error}' threw error: ${(0, Utils_1.getErrorString)(e)}`);
|
|
}
|
|
});
|
|
}
|
|
_cleanupPingTimer() {
|
|
if (this._pingServerHandle) {
|
|
clearTimeout(this._pingServerHandle);
|
|
this._pingServerHandle = undefined;
|
|
}
|
|
}
|
|
_cleanupTimeout() {
|
|
if (this._timeoutHandle) {
|
|
clearTimeout(this._timeoutHandle);
|
|
}
|
|
}
|
|
_createInvocation(methodName, args, nonblocking, streamIds) {
|
|
if (nonblocking) {
|
|
if (streamIds.length !== 0) {
|
|
return {
|
|
target: methodName,
|
|
arguments: args,
|
|
streamIds,
|
|
type: IHubProtocol_1.MessageType.Invocation,
|
|
};
|
|
}
|
|
else {
|
|
return {
|
|
target: methodName,
|
|
arguments: args,
|
|
type: IHubProtocol_1.MessageType.Invocation,
|
|
};
|
|
}
|
|
}
|
|
else {
|
|
const invocationId = this._invocationId;
|
|
this._invocationId++;
|
|
if (streamIds.length !== 0) {
|
|
return {
|
|
target: methodName,
|
|
arguments: args,
|
|
invocationId: invocationId.toString(),
|
|
streamIds,
|
|
type: IHubProtocol_1.MessageType.Invocation,
|
|
};
|
|
}
|
|
else {
|
|
return {
|
|
target: methodName,
|
|
arguments: args,
|
|
invocationId: invocationId.toString(),
|
|
type: IHubProtocol_1.MessageType.Invocation,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
_launchStreams(streams, promiseQueue) {
|
|
if (streams.length === 0) {
|
|
return;
|
|
}
|
|
// Synchronize stream data so they arrive in-order on the server
|
|
if (!promiseQueue) {
|
|
promiseQueue = Promise.resolve();
|
|
}
|
|
// We want to iterate over the keys, since the keys are the stream ids
|
|
// eslint-disable-next-line guard-for-in
|
|
for (const streamId in streams) {
|
|
streams[streamId].subscribe({
|
|
complete: () => {
|
|
promiseQueue = promiseQueue.then(() => this._sendWithProtocol(this._createCompletionMessage(streamId)));
|
|
},
|
|
error: (err) => {
|
|
let message;
|
|
if (err instanceof Error) {
|
|
message = err.message;
|
|
}
|
|
else if (err && err.toString) {
|
|
message = err.toString();
|
|
}
|
|
else {
|
|
message = "Unknown error";
|
|
}
|
|
promiseQueue = promiseQueue.then(() => this._sendWithProtocol(this._createCompletionMessage(streamId, message)));
|
|
},
|
|
next: (item) => {
|
|
promiseQueue = promiseQueue.then(() => this._sendWithProtocol(this._createStreamItemMessage(streamId, item)));
|
|
},
|
|
});
|
|
}
|
|
}
|
|
_replaceStreamingParams(args) {
|
|
const streams = [];
|
|
const streamIds = [];
|
|
for (let i = 0; i < args.length; i++) {
|
|
const argument = args[i];
|
|
if (this._isObservable(argument)) {
|
|
const streamId = this._invocationId;
|
|
this._invocationId++;
|
|
// Store the stream for later use
|
|
streams[streamId] = argument;
|
|
streamIds.push(streamId.toString());
|
|
// remove stream from args
|
|
args.splice(i, 1);
|
|
}
|
|
}
|
|
return [streams, streamIds];
|
|
}
|
|
_isObservable(arg) {
|
|
// This allows other stream implementations to just work (like rxjs)
|
|
return arg && arg.subscribe && typeof arg.subscribe === "function";
|
|
}
|
|
_createStreamInvocation(methodName, args, streamIds) {
|
|
const invocationId = this._invocationId;
|
|
this._invocationId++;
|
|
if (streamIds.length !== 0) {
|
|
return {
|
|
target: methodName,
|
|
arguments: args,
|
|
invocationId: invocationId.toString(),
|
|
streamIds,
|
|
type: IHubProtocol_1.MessageType.StreamInvocation,
|
|
};
|
|
}
|
|
else {
|
|
return {
|
|
target: methodName,
|
|
arguments: args,
|
|
invocationId: invocationId.toString(),
|
|
type: IHubProtocol_1.MessageType.StreamInvocation,
|
|
};
|
|
}
|
|
}
|
|
_createCancelInvocation(id) {
|
|
return {
|
|
invocationId: id,
|
|
type: IHubProtocol_1.MessageType.CancelInvocation,
|
|
};
|
|
}
|
|
_createStreamItemMessage(id, item) {
|
|
return {
|
|
invocationId: id,
|
|
item,
|
|
type: IHubProtocol_1.MessageType.StreamItem,
|
|
};
|
|
}
|
|
_createCompletionMessage(id, error, result) {
|
|
if (error) {
|
|
return {
|
|
error,
|
|
invocationId: id,
|
|
type: IHubProtocol_1.MessageType.Completion,
|
|
};
|
|
}
|
|
return {
|
|
invocationId: id,
|
|
result,
|
|
type: IHubProtocol_1.MessageType.Completion,
|
|
};
|
|
}
|
|
_createCloseMessage() {
|
|
return { type: IHubProtocol_1.MessageType.Close };
|
|
}
|
|
}
|
|
exports.HubConnection = HubConnection;
|
|
//# sourceMappingURL=HubConnection.js.map
|