UNPKG

@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
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 };