@ssv/signalr-client
Version:
SignalR client library built on top of @microsoft/signalr. This gives you more features and easier to use.
359 lines (348 loc) • 18.1 kB
JavaScript
'use strict';
var rxjs = require('rxjs');
var operators = require('rxjs/operators');
var signalr = require('@microsoft/signalr');
const errorCodes = {
retryLimitsReached: "error.retry-limits-reached"
};
exports.DesiredConnectionStatus = void 0;
(function (DesiredConnectionStatus) {
DesiredConnectionStatus["disconnected"] = "disconnected";
DesiredConnectionStatus["connected"] = "connected";
})(exports.DesiredConnectionStatus || (exports.DesiredConnectionStatus = {}));
exports.ConnectionStatus = void 0;
(function (ConnectionStatus) {
ConnectionStatus["disconnected"] = "disconnected";
ConnectionStatus["connecting"] = "connecting";
ConnectionStatus["connected"] = "connected";
})(exports.ConnectionStatus || (exports.ConnectionStatus = {}));
var InternalConnectionStatus;
(function (InternalConnectionStatus) {
InternalConnectionStatus["disconnected"] = "disconnected";
InternalConnectionStatus["ready"] = "ready";
InternalConnectionStatus["connected"] = "connected";
})(InternalConnectionStatus || (InternalConnectionStatus = {}));
function random(min, max) {
return Math.floor(Math.random() * (max - min + 1) + min);
}
function getReconnectionDelay(retryOptions, retryCount) {
if (retryOptions.customStrategy) {
return retryOptions.customStrategy(retryOptions, retryCount);
}
if (retryOptions.randomBackOffStrategy) {
return randomBackOffStrategyDelay(retryOptions.randomBackOffStrategy, retryCount);
}
if (retryOptions.backOffStrategy) {
return backOffStrategyDelay(retryOptions.backOffStrategy, retryCount);
}
if (retryOptions.randomStrategy) {
return randomStrategyDelay(retryOptions.randomStrategy);
}
return defaultStrategy(retryCount);
}
function randomStrategyDelay(randomStrategy) {
return random(randomStrategy.min, randomStrategy.max) * randomStrategy.intervalMs;
}
function randomBackOffStrategyDelay(randomStrategy, retryCount) {
let maxValue = Math.min(retryCount, randomStrategy.max);
maxValue = randomStrategy.min >= maxValue ? (randomStrategy.min + 2) : maxValue;
return random(randomStrategy.min, maxValue) * randomStrategy.intervalMs;
}
function backOffStrategyDelay(backOffStrategy, retryCount) {
return Math.min(retryCount * backOffStrategy.delayRetriesMs, backOffStrategy.maxDelayRetriesMs);
}
function defaultStrategy(retryCount) {
return randomBackOffStrategyDelay({ min: 3, max: 15, intervalMs: 1000 }, retryCount);
}
function buildQueryString(data) {
let queryString = "";
if (!data) {
return queryString;
}
// eslint-disable-next-line guard-for-in
for (const key in data) {
queryString += queryString === "" ? "?" : "&";
queryString += `${key}=${data[key]}`;
}
return queryString;
}
function emptyNext() {
return rxjs.of(undefined);
}
const errorReasonName = "error";
const disconnectedState = Object.freeze({ status: exports.ConnectionStatus.disconnected });
const connectedState = Object.freeze({ status: exports.ConnectionStatus.connected });
const connectingState = Object.freeze({ status: exports.ConnectionStatus.connecting });
// todo: rename HubClient?
class HubConnection {
get connectionState() { return this._connectionState$.value; }
/** Gets the connection state. */
get connectionState$() { return this._connectionState$.asObservable(); }
/** Gets the key for the hub client. */
get key() { return this._key; }
/** Gets the `connectionId` of the hub connection (from SignalR). */
get connectionId() { return this.hubConnection?.connectionId; }
_key;
source;
hubConnection;
retry;
hubConnectionOptions$;
_connectionState$ = new rxjs.BehaviorSubject(disconnectedState);
desiredState$ = new rxjs.BehaviorSubject(exports.DesiredConnectionStatus.disconnected);
internalConnStatus$ = new rxjs.BehaviorSubject(InternalConnectionStatus.disconnected);
connectionBuilder = new signalr.HubConnectionBuilder();
_destroy$ = new rxjs.Subject();
waitUntilConnect$ = this.connectionState$.pipe(operators.distinctUntilChanged((x, y) => x.status === y.status), operators.skipUntil(this.connectionState$.pipe(operators.filter(x => x.status === exports.ConnectionStatus.connected))));
constructor(connectionOption) {
this._key = connectionOption.key;
this.source = `[${connectionOption.key}] HubConnection ::`;
this.retry = connectionOption.options && connectionOption.options.retry ? connectionOption.options.retry : {};
this.hubConnectionOptions$ = new rxjs.BehaviorSubject(connectionOption);
const connection$ = this.hubConnectionOptions$.pipe(operators.map(connectionOpts => [connectionOpts, this.internalConnStatus$.value]), operators.switchMap(([connectionOpts, prevConnectionStatus]) => this._disconnect().pipe(operators.map(() => this.mergeConnectionData(connectionOpts)), operators.map(buildQueryString), operators.tap(queryString => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.connectionBuilder.withUrl(`${connectionOpts.endpointUri}${queryString}`, connectionOpts.options);
if (connectionOpts.protocol) {
this.connectionBuilder = this.connectionBuilder.withHubProtocol(connectionOpts.protocol);
}
this.hubConnection = this.connectionBuilder.build();
connectionOpts.configureSignalRHubConnection?.(this.hubConnection);
this.hubConnection.onclose(err => {
this.internalConnStatus$.next(InternalConnectionStatus.disconnected);
if (err) {
console.error(`${this.source} session disconnected with errors`, { name: err.name, message: err.message });
this._connectionState$.next({
status: exports.ConnectionStatus.disconnected,
reason: errorReasonName,
data: { name: err.name, message: err.message }
});
}
else {
console.warn(`${this.source} session disconnected`);
this._connectionState$.next(disconnectedState);
}
});
}), operators.tap(() => this.internalConnStatus$.next(InternalConnectionStatus.ready)), operators.filter(() => prevConnectionStatus === InternalConnectionStatus.connected), operators.switchMap(() => this.openConnection()))), operators.takeUntil(this._destroy$));
const desiredDisconnected$ = this.desiredState$.pipe(operators.filter(status => status === exports.DesiredConnectionStatus.disconnected),
// eslint-disable-next-line max-len
// tap(status => console.warn(">>>> [desiredDisconnected$] desired disconnected", { internalConnStatus$: this.internalConnStatus$.value, desiredStatus: status })),
operators.tap(() => {
if (this._connectionState$.value.status !== exports.ConnectionStatus.disconnected) {
// console.warn(">>>> [desiredDisconnected$] _disconnect");
// note: ideally delayWhen disconnect first, though for some reason obs not bubbling
this._disconnect();
}
}), operators.tap(() => this._connectionState$.next(disconnectedState)), operators.takeUntil(this._destroy$));
const reconnectOnDisconnect$ = this.reconnectOnDisconnect$().pipe(operators.takeUntil(this._destroy$));
[
desiredDisconnected$,
reconnectOnDisconnect$,
connection$
].forEach((x) => x.subscribe());
}
connect(data) {
// console.warn("[connect] init", data);
this.desiredState$.next(exports.DesiredConnectionStatus.connected);
if (this._connectionState$.value.status !== exports.ConnectionStatus.disconnected) {
console.warn(`${this.source} session already connecting/connected`);
return emptyNext();
}
if (data) {
this.setData(data);
}
// todo: refactor this and use subject only instead
return emptyNext().pipe(operators.switchMap(() => this.internalConnStatus$.pipe(operators.tap(x => {
if (x === InternalConnectionStatus.disconnected) {
this.hubConnectionOptions$.next(this.hubConnectionOptions$.value);
}
}), operators.skipUntil(this.internalConnStatus$.pipe(operators.filter(x => x === InternalConnectionStatus.ready))), operators.first())), operators.switchMap(() => this.openConnection()));
}
disconnect() {
// console.warn("[disconnect] init");
this.desiredState$.next(exports.DesiredConnectionStatus.disconnected);
return this.untilDisconnects$();
}
setData(getData) {
const connection = this.hubConnectionOptions$.value;
connection.getData = getData;
this.hubConnectionOptions$.next(connection);
}
on(methodName) {
const stream$ = new rxjs.Observable((observer) => {
const updateEvent = (latestValue) => observer.next(latestValue);
this.hubConnection.on(methodName.toString(), updateEvent);
return () => this.hubConnection.off(methodName.toString(), updateEvent);
});
return this.activateStreamWithRetry(stream$);
}
stream(methodName, ...args) {
const stream$ = new rxjs.Observable((observer) => {
this.hubConnection.stream(methodName.toString(), ...args).subscribe({
closed: false,
next: item => observer.next(item),
error: err => {
if (err && err.message !== "Invocation cancelled due to connection being closed.") {
observer.error(err);
}
},
complete: () => observer.complete()
});
return () => {
emptyNext().pipe(operators.delay(1), // workaround - when connection disconnects, stream errors fires before `signalr.onClose`
operators.filter(() => this.internalConnStatus$.value === InternalConnectionStatus.connected), operators.switchMap(() => this.send("StreamUnsubscribe", methodName, ...args))).subscribe();
};
});
return this.activateStreamWithRetry(stream$);
}
send(methodName, ...args) {
return rxjs.from(this.hubConnection.send(methodName.toString(), ...args));
}
invoke(methodName, ...args) {
return rxjs.from(this.hubConnection.invoke(methodName.toString(), ...args));
}
dispose() {
this.disconnect();
this.desiredState$.complete();
this._connectionState$.complete();
this.internalConnStatus$.complete();
this._destroy$.next();
this._destroy$.complete();
}
_disconnect() {
// console.warn("[_disconnect] init", this.internalConnStatus$.value, this._connectionState$.value);
return this._connectionState$.value.status !== exports.ConnectionStatus.disconnected
? rxjs.from(this.hubConnection.stop())
: emptyNext();
}
untilDisconnects$() {
return this.connectionState$.pipe(operators.first(state => state.status !== exports.ConnectionStatus.connected), operators.map(() => undefined));
}
untilDesiredDisconnects$() {
return this.desiredState$.pipe(operators.first(state => state === exports.DesiredConnectionStatus.disconnected), operators.map(() => undefined));
}
openConnection() {
// console.warn("[openConnection]");
return emptyNext().pipe(
// tap(x => console.warn(">>>> openConnection - attempting to connect", x)),
operators.tap(() => this._connectionState$.next(connectingState)), operators.switchMap(() => rxjs.from(this.hubConnection.start())),
// tap(x => console.warn(">>>> [openConnection] - connection established", x)),
operators.retry({
delay: (error, retryCount) => {
if (this.retry.maximumAttempts && retryCount > this.retry.maximumAttempts) {
return rxjs.throwError(() => new Error(errorCodes.retryLimitsReached));
}
const nextRetryMs = getReconnectionDelay(this.retry, retryCount);
// eslint-disable-next-line max-len
console.debug(`${this.source} connect :: retrying`, { retryCount, maximumAttempts: this.retry.maximumAttempts, nextRetryMs });
this._connectionState$.next({
status: exports.ConnectionStatus.connecting,
reason: "reconnecting",
data: { retryCount, maximumAttempts: this.retry.maximumAttempts, nextRetryMs }
});
// todo: refactor with subject instead e.g.
this.hubConnectionOptions$.next(this.hubConnectionOptions$.value);
return rxjs.timer(nextRetryMs);
}
}), operators.tap(() => this.internalConnStatus$.next(InternalConnectionStatus.connected)), operators.tap(() => this._connectionState$.next(connectedState)), operators.takeUntil(this.untilDesiredDisconnects$()));
}
activateStreamWithRetry(stream$) {
return this.waitUntilConnect$.pipe(operators.switchMap(() => stream$.pipe(operators.retry({
delay: () => rxjs.timer(1).pipe(// workaround - when connection disconnects, stream errors fires before `signalr.onClose`
operators.delayWhen(() => this.waitUntilConnect$))
}))));
}
mergeConnectionData(options) {
let data = {};
if (options.defaultData) {
data = options.defaultData();
}
if (options.getData) {
const specificData = options.getData();
data = { ...data, ...specificData };
}
return data;
}
reconnectOnDisconnect$() {
const onServerErrorDisconnect$ = this._connectionState$.pipe(operators.filter(x => x.status === exports.ConnectionStatus.disconnected && x.reason === errorReasonName));
// this is a fallback for when max attempts are reached and will emit to reset the max attempt after a duration
const maxAttemptReset$ = onServerErrorDisconnect$.pipe(operators.switchMap(() => this._connectionState$.pipe(operators.switchMap(() => rxjs.timer(this.retry.autoReconnectRecoverInterval || 900000)), // 15 minutes default
operators.take(1), operators.takeUntil(this.connectionState$.pipe(operators.filter(x => x.status === exports.ConnectionStatus.connected))))));
const onDisconnect$ = this.desiredState$.pipe(operators.filter(state => state === exports.DesiredConnectionStatus.disconnected));
return rxjs.merge(onDisconnect$, maxAttemptReset$).pipe(operators.switchMap(() => onServerErrorDisconnect$.pipe(operators.scan(attempts => attempts += 1, 0), operators.map(retryCount => ({
retryCount,
nextRetryMs: retryCount ? getReconnectionDelay(this.retry, retryCount) : 0
})), operators.switchMap(({ retryCount, nextRetryMs }) => {
if (this.retry.maximumAttempts && retryCount > this.retry.maximumAttempts) {
return rxjs.throwError(() => new Error(errorCodes.retryLimitsReached));
}
const delay$ = !nextRetryMs
? emptyNext()
: rxjs.timer(nextRetryMs).pipe(operators.map(() => undefined));
return delay$.pipe(
// tap(() => console.error(`${this.source} [reconnectOnDisconnect$] :: retrying`, {
// retryCount,
// nextRetryMs,
// maximumAttempts: this.retry.maximumAttempts,
// })),
operators.switchMap(() => this.connect()),
// tap(() => console.error(">>>> [reconnectOnDisconnect$] connected")),
operators.takeUntil(this.untilDesiredDisconnects$()));
}), operators.catchError(() => rxjs.EMPTY), operators.takeUntil(this.desiredState$.pipe(operators.skip(1), operators.filter(state => state === exports.DesiredConnectionStatus.disconnected))))));
}
}
class HubConnectionFactory {
source = "HubConnectionFactory ::";
hubConnections = {};
create(...connectionOptions) {
for (const connectionOption of connectionOptions) {
if (!connectionOption.key) {
throw new Error(`${this.source} create :: connection key not set`);
}
if (!connectionOption.endpointUri) {
throw new Error(`${this.source} create :: connection endpointUri not set for ${connectionOption.key}`);
}
if (!connectionOption.options) {
connectionOption.options = {
transport: signalr.HttpTransportType.WebSockets
};
}
else if (!connectionOption.options.transport) {
connectionOption.options.transport = signalr.HttpTransportType.WebSockets;
}
this.hubConnections[connectionOption.key] = new HubConnection(connectionOption);
}
return this;
}
get(key) {
const hub = this.hubConnections[key];
if (hub) {
return hub;
}
throw new Error(`${this.source} get :: connection key not found '${key}'`);
}
remove(key) {
const hub = this.hubConnections[key];
if (hub) {
hub.dispose();
delete this.hubConnections[key];
}
}
connectAll() {
// eslint-disable-next-line guard-for-in
for (const hubKey in this.hubConnections) {
const hub = this.hubConnections[hubKey];
hub.connect();
}
}
disconnectAll() {
// eslint-disable-next-line guard-for-in
for (const hubKey in this.hubConnections) {
const hub = this.hubConnections[hubKey];
hub.disconnect();
}
}
}
const VERSION = "0.0.0-PLACEHOLDER";
exports.HubConnection = HubConnection;
exports.HubConnectionFactory = HubConnectionFactory;
exports.VERSION = VERSION;
exports.errorCodes = errorCodes;