UNPKG

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