@ssv/signalr-client
Version:
SignalR client library built on top of @microsoft/signalr. This gives you more features and easier to use.
354 lines (344 loc) • 17.4 kB
JavaScript
import { of, BehaviorSubject, Subject, Observable, from, throwError, timer, merge, EMPTY } from 'rxjs';
import { distinctUntilChanged, skipUntil, filter, map, switchMap, tap, takeUntil, first, delay, retry, delayWhen, take, scan, catchError, skip } from 'rxjs/operators';
import { HubConnectionBuilder, HttpTransportType } from '@microsoft/signalr';
const errorCodes = {
retryLimitsReached: "error.retry-limits-reached"
};
var DesiredConnectionStatus;
(function (DesiredConnectionStatus) {
DesiredConnectionStatus["disconnected"] = "disconnected";
DesiredConnectionStatus["connected"] = "connected";
})(DesiredConnectionStatus || (DesiredConnectionStatus = {}));
var ConnectionStatus;
(function (ConnectionStatus) {
ConnectionStatus["disconnected"] = "disconnected";
ConnectionStatus["connecting"] = "connecting";
ConnectionStatus["connected"] = "connected";
})(ConnectionStatus || (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 of(undefined);
}
const errorReasonName = "error";
const disconnectedState = Object.freeze({ status: ConnectionStatus.disconnected });
const connectedState = Object.freeze({ status: ConnectionStatus.connected });
const connectingState = Object.freeze({ status: 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 BehaviorSubject(disconnectedState);
desiredState$ = new BehaviorSubject(DesiredConnectionStatus.disconnected);
internalConnStatus$ = new BehaviorSubject(InternalConnectionStatus.disconnected);
connectionBuilder = new HubConnectionBuilder();
_destroy$ = new Subject();
waitUntilConnect$ = this.connectionState$.pipe(distinctUntilChanged((x, y) => x.status === y.status), skipUntil(this.connectionState$.pipe(filter(x => x.status === 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 BehaviorSubject(connectionOption);
const connection$ = this.hubConnectionOptions$.pipe(map(connectionOpts => [connectionOpts, this.internalConnStatus$.value]), switchMap(([connectionOpts, prevConnectionStatus]) => this._disconnect().pipe(map(() => this.mergeConnectionData(connectionOpts)), map(buildQueryString), 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: ConnectionStatus.disconnected,
reason: errorReasonName,
data: { name: err.name, message: err.message }
});
}
else {
console.warn(`${this.source} session disconnected`);
this._connectionState$.next(disconnectedState);
}
});
}), tap(() => this.internalConnStatus$.next(InternalConnectionStatus.ready)), filter(() => prevConnectionStatus === InternalConnectionStatus.connected), switchMap(() => this.openConnection()))), takeUntil(this._destroy$));
const desiredDisconnected$ = this.desiredState$.pipe(filter(status => status === DesiredConnectionStatus.disconnected),
// eslint-disable-next-line max-len
// tap(status => console.warn(">>>> [desiredDisconnected$] desired disconnected", { internalConnStatus$: this.internalConnStatus$.value, desiredStatus: status })),
tap(() => {
if (this._connectionState$.value.status !== ConnectionStatus.disconnected) {
// console.warn(">>>> [desiredDisconnected$] _disconnect");
// note: ideally delayWhen disconnect first, though for some reason obs not bubbling
this._disconnect();
}
}), tap(() => this._connectionState$.next(disconnectedState)), takeUntil(this._destroy$));
const reconnectOnDisconnect$ = this.reconnectOnDisconnect$().pipe(takeUntil(this._destroy$));
[
desiredDisconnected$,
reconnectOnDisconnect$,
connection$
].forEach((x) => x.subscribe());
}
connect(data) {
// console.warn("[connect] init", data);
this.desiredState$.next(DesiredConnectionStatus.connected);
if (this._connectionState$.value.status !== 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(switchMap(() => this.internalConnStatus$.pipe(tap(x => {
if (x === InternalConnectionStatus.disconnected) {
this.hubConnectionOptions$.next(this.hubConnectionOptions$.value);
}
}), skipUntil(this.internalConnStatus$.pipe(filter(x => x === InternalConnectionStatus.ready))), first())), switchMap(() => this.openConnection()));
}
disconnect() {
// console.warn("[disconnect] init");
this.desiredState$.next(DesiredConnectionStatus.disconnected);
return this.untilDisconnects$();
}
setData(getData) {
const connection = this.hubConnectionOptions$.value;
connection.getData = getData;
this.hubConnectionOptions$.next(connection);
}
on(methodName) {
const stream$ = new 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 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(delay(1), // workaround - when connection disconnects, stream errors fires before `signalr.onClose`
filter(() => this.internalConnStatus$.value === InternalConnectionStatus.connected), switchMap(() => this.send("StreamUnsubscribe", methodName, ...args))).subscribe();
};
});
return this.activateStreamWithRetry(stream$);
}
send(methodName, ...args) {
return from(this.hubConnection.send(methodName.toString(), ...args));
}
invoke(methodName, ...args) {
return 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 !== ConnectionStatus.disconnected
? from(this.hubConnection.stop())
: emptyNext();
}
untilDisconnects$() {
return this.connectionState$.pipe(first(state => state.status !== ConnectionStatus.connected), map(() => undefined));
}
untilDesiredDisconnects$() {
return this.desiredState$.pipe(first(state => state === DesiredConnectionStatus.disconnected), map(() => undefined));
}
openConnection() {
// console.warn("[openConnection]");
return emptyNext().pipe(
// tap(x => console.warn(">>>> openConnection - attempting to connect", x)),
tap(() => this._connectionState$.next(connectingState)), switchMap(() => from(this.hubConnection.start())),
// tap(x => console.warn(">>>> [openConnection] - connection established", x)),
retry({
delay: (error, retryCount) => {
if (this.retry.maximumAttempts && retryCount > this.retry.maximumAttempts) {
return 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: 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 timer(nextRetryMs);
}
}), tap(() => this.internalConnStatus$.next(InternalConnectionStatus.connected)), tap(() => this._connectionState$.next(connectedState)), takeUntil(this.untilDesiredDisconnects$()));
}
activateStreamWithRetry(stream$) {
return this.waitUntilConnect$.pipe(switchMap(() => stream$.pipe(retry({
delay: () => timer(1).pipe(// workaround - when connection disconnects, stream errors fires before `signalr.onClose`
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(filter(x => x.status === 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(switchMap(() => this._connectionState$.pipe(switchMap(() => timer(this.retry.autoReconnectRecoverInterval || 900000)), // 15 minutes default
take(1), takeUntil(this.connectionState$.pipe(filter(x => x.status === ConnectionStatus.connected))))));
const onDisconnect$ = this.desiredState$.pipe(filter(state => state === DesiredConnectionStatus.disconnected));
return merge(onDisconnect$, maxAttemptReset$).pipe(switchMap(() => onServerErrorDisconnect$.pipe(scan(attempts => attempts += 1, 0), map(retryCount => ({
retryCount,
nextRetryMs: retryCount ? getReconnectionDelay(this.retry, retryCount) : 0
})), switchMap(({ retryCount, nextRetryMs }) => {
if (this.retry.maximumAttempts && retryCount > this.retry.maximumAttempts) {
return throwError(() => new Error(errorCodes.retryLimitsReached));
}
const delay$ = !nextRetryMs
? emptyNext()
: timer(nextRetryMs).pipe(map(() => undefined));
return delay$.pipe(
// tap(() => console.error(`${this.source} [reconnectOnDisconnect$] :: retrying`, {
// retryCount,
// nextRetryMs,
// maximumAttempts: this.retry.maximumAttempts,
// })),
switchMap(() => this.connect()),
// tap(() => console.error(">>>> [reconnectOnDisconnect$] connected")),
takeUntil(this.untilDesiredDisconnects$()));
}), catchError(() => EMPTY), takeUntil(this.desiredState$.pipe(skip(1), filter(state => state === 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: HttpTransportType.WebSockets
};
}
else if (!connectionOption.options.transport) {
connectionOption.options.transport = 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";
export { ConnectionStatus, DesiredConnectionStatus, HubConnection, HubConnectionFactory, VERSION, errorCodes };