UNPKG

@deepstream/client

Version:
636 lines (568 loc) 22 kB
import { CONNECTION_STATE, EVENT, JSONObject, TOPIC, Message, CONNECTION_ACTION, ParseResult, PARSER_ACTION, AUTH_ACTION } from '../constants' import { parseData } from '@deepstream/protobuf/dist/src/message-parser' import { StateMachine } from '../util/state-machine' import {Services, Socket} from '../deepstream-client' import { Options } from '../client-options' import * as utils from '../util/utils' import { Emitter } from '../util/emitter' import * as pkg from '../../package.json' export type AuthenticationCallback = (success: boolean, clientData: JSONObject | null) => void export type ResumeCallback = (error?: JSONObject) => void const enum TRANSITIONS { INITIALISED = 'initialised', CONNECTED = 'connected', CHALLENGE = 'challenge', AUTHENTICATE = 'authenticate', RECONNECT = 'reconnect', CHALLENGE_ACCEPTED = 'accepted', CHALLENGE_DENIED = 'challenge-denied', CONNECTION_REDIRECTED = 'redirected', TOO_MANY_AUTH_ATTEMPTS = 'too-many-auth-attempts', CLOSE = 'close', CLOSED = 'closed', UNSUCCESFUL_LOGIN = 'unsuccesful-login', SUCCESFUL_LOGIN = 'succesful-login', ERROR = 'error', LOST = 'connection-lost', PAUSE = 'pause', OFFLINE = 'offline', RESUME = 'resume', EXIT_LIMBO = 'exit-limbo', AUTHENTICATION_TIMEOUT = 'authentication-timeout' } export class Connection { public emitter: Emitter public isInLimbo: boolean private internalEmitter: Emitter private stateMachine: StateMachine private authParams: JSONObject | null private clientData: JSONObject | null private authCallback: AuthenticationCallback | null private resumeCallback: ResumeCallback | null private readonly originalUrl: string private url: string private heartbeatIntervalTimeout: number | null private endpoint: Socket | null private handlers: Map<TOPIC, Function> private reconnectTimeout: number | null = null private reconnectionAttempt: number private limboTimeout: number | null constructor (private services: Services, private options: Options, url: string, emitter: Emitter) { this.authParams = null this.handlers = new Map() this.authCallback = null this.resumeCallback = null this.emitter = emitter this.internalEmitter = new Emitter() this.isInLimbo = true this.clientData = null this.heartbeatIntervalTimeout = null this.endpoint = null this.reconnectionAttempt = 0 this.limboTimeout = null let isReconnecting = false let firstOpen = true this.stateMachine = new StateMachine( this.services.logger, { init: CONNECTION_STATE.CLOSED, onStateChanged: (newState: string, oldState: string) => { if (newState === oldState) { return } emitter.emit(EVENT.CONNECTION_STATE_CHANGED, newState) if (newState === CONNECTION_STATE.RECONNECTING) { this.isInLimbo = true isReconnecting = true if (oldState !== CONNECTION_STATE.CLOSED) { this.internalEmitter.emit(EVENT.CONNECTION_LOST) this.limboTimeout = this.services.timerRegistry.add({ duration: this.options.offlineBufferTimeout, context: this, callback: () => { this.isInLimbo = false this.internalEmitter.emit(EVENT.EXIT_LIMBO) } }) } } else if (newState === CONNECTION_STATE.OPEN && (isReconnecting || firstOpen)) { firstOpen = false this.isInLimbo = false this.internalEmitter.emit(EVENT.CONNECTION_REESTABLISHED) this.services.timerRegistry.remove(this.limboTimeout as number) } }, transitions: [ { name: TRANSITIONS.INITIALISED, from: CONNECTION_STATE.CLOSED, to: CONNECTION_STATE.INITIALISING }, { name: TRANSITIONS.CONNECTED, from: CONNECTION_STATE.INITIALISING, to: CONNECTION_STATE.AWAITING_CONNECTION }, { name: TRANSITIONS.CONNECTED, from: CONNECTION_STATE.REDIRECTING, to: CONNECTION_STATE.AWAITING_CONNECTION }, { name: TRANSITIONS.CONNECTED, from: CONNECTION_STATE.RECONNECTING, to: CONNECTION_STATE.AWAITING_CONNECTION }, { name: TRANSITIONS.CHALLENGE, from: CONNECTION_STATE.AWAITING_CONNECTION, to: CONNECTION_STATE.CHALLENGING }, { name: TRANSITIONS.CONNECTION_REDIRECTED, from: CONNECTION_STATE.CHALLENGING, to: CONNECTION_STATE.REDIRECTING }, { name: TRANSITIONS.CHALLENGE_DENIED, from: CONNECTION_STATE.CHALLENGING, to: CONNECTION_STATE.CHALLENGE_DENIED }, { name: TRANSITIONS.CHALLENGE_ACCEPTED, from: CONNECTION_STATE.CHALLENGING, to: CONNECTION_STATE.AWAITING_AUTHENTICATION, handler: this.onAwaitingAuthentication.bind(this) }, { name: TRANSITIONS.AUTHENTICATION_TIMEOUT, from: CONNECTION_STATE.AWAITING_CONNECTION, to: CONNECTION_STATE.AUTHENTICATION_TIMEOUT }, { name: TRANSITIONS.AUTHENTICATION_TIMEOUT, from: CONNECTION_STATE.AWAITING_AUTHENTICATION, to: CONNECTION_STATE.AUTHENTICATION_TIMEOUT }, { name: TRANSITIONS.AUTHENTICATE, from: CONNECTION_STATE.AWAITING_AUTHENTICATION, to: CONNECTION_STATE.AUTHENTICATING }, { name: TRANSITIONS.UNSUCCESFUL_LOGIN, from: CONNECTION_STATE.AUTHENTICATING, to: CONNECTION_STATE.AWAITING_AUTHENTICATION }, { name: TRANSITIONS.SUCCESFUL_LOGIN, from: CONNECTION_STATE.AUTHENTICATING, to: CONNECTION_STATE.OPEN }, { name: TRANSITIONS.TOO_MANY_AUTH_ATTEMPTS, from: CONNECTION_STATE.AUTHENTICATING, to: CONNECTION_STATE.TOO_MANY_AUTH_ATTEMPTS }, { name: TRANSITIONS.TOO_MANY_AUTH_ATTEMPTS, from: CONNECTION_STATE.AWAITING_AUTHENTICATION, to: CONNECTION_STATE.TOO_MANY_AUTH_ATTEMPTS }, { name: TRANSITIONS.AUTHENTICATION_TIMEOUT, from: CONNECTION_STATE.AWAITING_AUTHENTICATION, to: CONNECTION_STATE.AUTHENTICATION_TIMEOUT }, { name: TRANSITIONS.RECONNECT, from: CONNECTION_STATE.RECONNECTING, to: CONNECTION_STATE.RECONNECTING }, { name: TRANSITIONS.CLOSED, from: CONNECTION_STATE.CLOSING, to: CONNECTION_STATE.CLOSED }, { name: TRANSITIONS.OFFLINE, from: CONNECTION_STATE.PAUSING, to: CONNECTION_STATE.OFFLINE }, { name: TRANSITIONS.ERROR, to: CONNECTION_STATE.RECONNECTING }, { name: TRANSITIONS.LOST, to: CONNECTION_STATE.RECONNECTING }, { name: TRANSITIONS.RESUME, to: CONNECTION_STATE.RECONNECTING }, { name: TRANSITIONS.PAUSE, to: CONNECTION_STATE.PAUSING }, { name: TRANSITIONS.CLOSE, to: CONNECTION_STATE.CLOSING }, ] } ) this.stateMachine.transition(TRANSITIONS.INITIALISED) this.originalUrl = utils.parseUrl(url, this.options.path) this.url = this.originalUrl if (!options.lazyConnect) { this.createEndpoint() } } public get isConnected (): boolean { return this.stateMachine.state === CONNECTION_STATE.OPEN } public onLost (callback: Function): void { this.internalEmitter.on(EVENT.CONNECTION_LOST, callback) } public removeOnLost (callback: Function): void { this.internalEmitter.off(EVENT.CONNECTION_LOST, callback) } public onReestablished (callback: Function): void { this.internalEmitter.on(EVENT.CONNECTION_REESTABLISHED, callback) } public removeOnReestablished (callback: Function): void { this.internalEmitter.off(EVENT.CONNECTION_REESTABLISHED, callback) } public onExitLimbo (callback: Function): void { this.internalEmitter.on(EVENT.EXIT_LIMBO, callback) } public registerHandler (topic: TOPIC, callback: Function): void { this.handlers.set(topic, callback) } public sendMessage (message: Message): void { if (!this.isOpen()) { this.services.logger.error(message, EVENT.IS_CLOSED) return } if (this.endpoint) { this.endpoint.sendParsedMessage(message) } } /** * Sends the specified authentication parameters * to the server. Can be called up to <maxAuthAttempts> * times for the same connection. * * @param {Object} authParams A map of user defined auth parameters. * E.g. { username:<String>, password:<String> } * @param {Function} callback A callback that will be invoked with the authenticationr result */ public authenticate (authCallback: AuthenticationCallback): void public authenticate (authParms: JSONObject | null, callback: AuthenticationCallback): void public authenticate (authParamsOrCallback?: JSONObject | AuthenticationCallback | null, callback?: AuthenticationCallback | null): void { if ( authParamsOrCallback && typeof authParamsOrCallback !== 'object' && typeof authParamsOrCallback !== 'function' ) { throw new Error('invalid argument authParamsOrCallback') } if (callback && typeof callback !== 'function') { throw new Error('invalid argument callback') } if ( this.stateMachine.state === CONNECTION_STATE.CHALLENGE_DENIED || this.stateMachine.state === CONNECTION_STATE.TOO_MANY_AUTH_ATTEMPTS || this.stateMachine.state === CONNECTION_STATE.AUTHENTICATION_TIMEOUT ) { this.services.logger.error({ topic: TOPIC.CONNECTION }, EVENT.IS_CLOSED) return } if (authParamsOrCallback) { // @ts-ignore this.authParams = typeof authParamsOrCallback === 'object' ? authParamsOrCallback : {} } if (authParamsOrCallback && typeof authParamsOrCallback === 'function') { this.authCallback = authParamsOrCallback as AuthenticationCallback } else if (callback) { this.authCallback = callback } else { this.authCallback = () => {} } // if (this.stateMachine.state === CONNECTION_STATE.CLOSED && !this.endpoint) { // this.createEndpoint() // return // } if (this.stateMachine.state === CONNECTION_STATE.AWAITING_AUTHENTICATION && this.authParams) { this.sendAuthParams() } if (!this.endpoint) { this.createEndpoint() } } /* * Returns the current connection state. */ public getConnectionState (): CONNECTION_STATE { return this.stateMachine.state } private isOpen (): boolean { const connState = this.getConnectionState() return connState !== CONNECTION_STATE.CLOSED && connState !== CONNECTION_STATE.ERROR && connState !== CONNECTION_STATE.CLOSING } /** * Closes the connection. Using this method * will prevent the client from reconnecting. */ public close (): void { this.services.timerRegistry.remove(this.heartbeatIntervalTimeout!) this.sendMessage({ topic: TOPIC.CONNECTION, action: CONNECTION_ACTION.CLOSING }) this.stateMachine.transition(TRANSITIONS.CLOSE) } public pause (): void { this.stateMachine.transition(TRANSITIONS.PAUSE) this.services.timerRegistry.remove(this.heartbeatIntervalTimeout!) if (this.endpoint) { this.endpoint.close() } } public resume (callback: ResumeCallback): void { this.stateMachine.transition(TRANSITIONS.RESUME) this.resumeCallback = callback this.tryReconnect() } /** * Creates the endpoint to connect to using the url deepstream * was initialised with. */ private createEndpoint (): void { this.endpoint = this.services.socketFactory(this.url, this.options.socketOptions, this.options.heartbeatInterval) this.endpoint.onopened = this.onOpen.bind(this) this.endpoint.onerror = this.onError.bind(this) this.endpoint.onclosed = this.onClose.bind(this) this.endpoint.onparsedmessages = this.onMessages.bind(this) } /******************************** ****** Endpoint Callbacks ****** /********************************/ /** * Will be invoked once the connection is established. The client * can't send messages yet, and needs to get a connection ACK or REDIRECT * from the server before authenticating */ private onOpen (): void { this.clearReconnect() this.checkHeartBeat() this.stateMachine.transition(TRANSITIONS.CONNECTED) this.sendMessage({ topic: TOPIC.CONNECTION, action: CONNECTION_ACTION.CHALLENGE, url: this.originalUrl, protocolVersion: '0.1a', sdkVersion: pkg.version, sdkType: 'javascript' }) this.stateMachine.transition(TRANSITIONS.CHALLENGE) } /** * Callback for generic connection errors. Forwards * the error to the client. * * The connection is considered broken once this method has been * invoked. */ private onError (error: any) { /* * If the implementation isn't listening on the error event this will throw * an error. So let's defer it to allow the reconnection to kick in. */ setTimeout(() => { let msg if (error.code === 'ECONNRESET' || error.code === 'ECONNREFUSED') { msg = `Can't connect! Deepstream server unreachable on ${this.originalUrl}` } else { msg = error } this.services.logger.error({ topic: TOPIC.CONNECTION }, EVENT.CONNECTION_ERROR, msg) }, 1) this.services.timerRegistry.remove(this.heartbeatIntervalTimeout!) this.stateMachine.transition(TRANSITIONS.ERROR) this.tryReconnect() } /** * Callback when the connection closes. This might have been a deliberate * close triggered by the client or the result of the connection getting * lost. * * In the latter case the client will try to reconnect using the configured * strategy. */ private onClose (): void { this.services.timerRegistry.remove(this.heartbeatIntervalTimeout!) if (this.stateMachine.state === CONNECTION_STATE.REDIRECTING) { this.createEndpoint() return } if ( this.stateMachine.state === CONNECTION_STATE.CHALLENGE_DENIED || this.stateMachine.state === CONNECTION_STATE.TOO_MANY_AUTH_ATTEMPTS || this.stateMachine.state === CONNECTION_STATE.AUTHENTICATION_TIMEOUT ) { return } if (this.stateMachine.state === CONNECTION_STATE.CLOSING) { this.stateMachine.transition(TRANSITIONS.CLOSED) return } if (this.stateMachine.state === CONNECTION_STATE.PAUSING) { this.stateMachine.transition(TRANSITIONS.OFFLINE) return } this.stateMachine.transition(TRANSITIONS.LOST) this.tryReconnect() } /** * Callback for messages received on the connection. */ private onMessages (parseResults: ParseResult[]): void { parseResults.forEach(parseResult => { if (parseResult.parseError) { this.services.logger.error( { topic: TOPIC.PARSER }, parseResult.action, parseResult.raw && parseResult.raw.toString() ) return } const message: any = parseResult // NOTE: parseData mutates the message object adding the parsedData property and returns true when succesful const res = parseData(message) if (res !== true) { this.services.logger.error({ topic: TOPIC.PARSER }, PARSER_ACTION.INVALID_MESSAGE, res) } if (message === null) { return } if (message.topic === TOPIC.CONNECTION) { this.handleConnectionResponse(message) return } if (message.topic === TOPIC.AUTH) { this.handleAuthResponse(message) return } const handler = this.handlers.get(message.topic) if (!handler) { // this should never happen return } handler(message) }) } /** * Sends authentication params to the server. Please note, this * doesn't use the queued message mechanism, but rather sends the message directly */ private sendAuthParams (): void { this.stateMachine.transition(TRANSITIONS.AUTHENTICATE) this.sendMessage({ topic: TOPIC.AUTH, action: AUTH_ACTION.REQUEST, parsedData: this.authParams }) } /** * Ensures that a heartbeat was not missed more than once, otherwise it considers the connection * to have been lost and closes it for reconnection. */ private checkHeartBeat (): void { const heartBeatTolerance = this.options.heartbeatInterval * 2 if (!this.endpoint) { return } if (this.endpoint.getTimeSinceLastMessage() > heartBeatTolerance) { this.services.timerRegistry.remove(this.heartbeatIntervalTimeout!) this.services.logger.error({ topic: TOPIC.CONNECTION }, EVENT.HEARTBEAT_TIMEOUT) this.endpoint.close() return } this.heartbeatIntervalTimeout = this.services.timerRegistry.add({ duration: this.options.heartbeatInterval, callback: this.checkHeartBeat, context: this }) } /** * If the connection drops or is closed in error this * method schedules increasing reconnection intervals * * If the number of failed reconnection attempts exceeds * options.maxReconnectAttempts the connection is closed */ private tryReconnect (): void { if (this.reconnectTimeout !== null) { return } if (this.reconnectionAttempt < this.options.maxReconnectAttempts) { this.stateMachine.transition(TRANSITIONS.RECONNECT) this.reconnectTimeout = this.services.timerRegistry.add({ callback: this.tryOpen, context: this, duration: Math.min( this.options.maxReconnectInterval, this.options.reconnectIntervalIncrement * this.reconnectionAttempt ) }) this.reconnectionAttempt++ return } this.emitter.emit(EVENT[EVENT.MAX_RECONNECTION_ATTEMPTS_REACHED], this.reconnectionAttempt) this.clearReconnect() this.close() } /** * Attempts to open a errourosly closed connection */ private tryOpen (): void { if (this.stateMachine.state !== CONNECTION_STATE.REDIRECTING) { this.url = this.originalUrl } this.createEndpoint() this.reconnectTimeout = null } /** * Stops all further reconnection attempts, * either because the connection is open again * or because the maximal number of reconnection * attempts has been exceeded */ private clearReconnect (): void { this.services.timerRegistry.remove(this.reconnectTimeout!) this.reconnectTimeout = null this.reconnectionAttempt = 0 } /** * The connection response will indicate whether the deepstream connection * can be used or if it should be forwarded to another instance. This * allows us to introduce load-balancing if needed. * * If authentication parameters are already provided this will kick of * authentication immediately. The actual 'open' event won't be emitted * by the client until the authentication is successful. * * If a challenge is recieved, the user will send the url to the server * in response to get the appropriate redirect. If the URL is invalid the * server will respond with a REJECTION resulting in the client connection * being permanently closed. * * If a redirect is recieved, this connection is closed and updated with * a connection to the url supplied in the message. */ private handleConnectionResponse (message: Message): void { if (message.action === CONNECTION_ACTION.ACCEPT) { this.stateMachine.transition(TRANSITIONS.CHALLENGE_ACCEPTED) return } if (message.action === CONNECTION_ACTION.REJECT) { this.stateMachine.transition(TRANSITIONS.CHALLENGE_DENIED) if (this.endpoint) { this.endpoint.close() } return } if (message.action === CONNECTION_ACTION.REDIRECT) { this.url = message.url as string this.stateMachine.transition(TRANSITIONS.CONNECTION_REDIRECTED) if (this.endpoint) { this.endpoint.close() } return } if (message.action === CONNECTION_ACTION.AUTHENTICATION_TIMEOUT) { this.stateMachine.transition(TRANSITIONS.AUTHENTICATION_TIMEOUT) this.services.logger.error(message) } } /** * Callback for messages received for the AUTH topic. If * the authentication was successful this method will * open the connection and send all messages that the client * tried to send so far. */ private handleAuthResponse (message: Message): void { if (message.action === AUTH_ACTION.TOO_MANY_AUTH_ATTEMPTS) { this.stateMachine.transition(TRANSITIONS.TOO_MANY_AUTH_ATTEMPTS) this.services.logger.error(message) return } if (message.action === AUTH_ACTION.AUTH_UNSUCCESSFUL) { this.stateMachine.transition(TRANSITIONS.UNSUCCESFUL_LOGIN) this.onAuthUnSuccessful(message.parsedData) return } if (message.action === AUTH_ACTION.AUTH_SUCCESSFUL) { this.stateMachine.transition(TRANSITIONS.SUCCESFUL_LOGIN) this.onAuthSuccessful(message.parsedData) return } } private onAwaitingAuthentication (): void { if (this.authParams) { this.sendAuthParams() } } private onAuthSuccessful (clientData: any): void { this.updateClientData(clientData) if (this.resumeCallback) { this.resumeCallback() this.resumeCallback = null } if (this.authCallback === null) { return } this.authCallback(true, this.clientData) this.authCallback = null } private onAuthUnSuccessful (clientData?: any): void { const reason = { reason: clientData || EVENT[EVENT.INVALID_AUTHENTICATION_DETAILS] } if (this.resumeCallback) { this.resumeCallback(reason) this.resumeCallback = null } if (this.authCallback === null) { this.emitter.emit(EVENT.REAUTHENTICATION_FAILURE, reason) return } this.authCallback(false, reason) this.authCallback = null } private updateClientData (data: any) { const newClientData = data || null if ( this.clientData === null && (newClientData === null || Object.keys(newClientData).length === 0) ) { return } if (!utils.deepEquals(this.clientData, data)) { this.emitter.emit(EVENT.CLIENT_DATA_CHANGED, Object.assign({}, newClientData)) this.clientData = newClientData } } }