UNPKG

@adonisjs/websocket-client

Version:

Websocket client for AdonisJs

800 lines (718 loc) 17.1 kB
'use strict' /** * adonis-websocket-client * * (c) Harminder Virk <virk@adonisjs.com> * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ import Emitter from 'emittery' import { stringify } from 'query-string' import wsp from '@adonisjs/websocket-packet' import debug from '../Debug/index.js' import Socket from '../Socket/index.js' import JsonEncoder from '../JsonEncoder/index.js' /** * Returns the ws protocol based upon HTTP or HTTPS * * @returns {String} * */ const wsProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws' /** * Connection class is used to make a TCP/Socket connection * with the server. It relies on Native Websocket browser * support. * * @class Connection * * @param {String} url * @param {Object} options */ export default class Connection extends Emitter { constructor (url, options) { super() url = url || `${wsProtocol}://${window.location.host}` /** * Connection options * * @type {Object} */ this.options = Object.assign({ path: 'adonis-ws', reconnection: true, reconnectionAttempts: 10, reconnectionDelay: 1000, query: null, encoder: JsonEncoder }, options) if (process.env.NODE_ENV !== 'production') { debug('connection options %o', this.options) } /** * The state connection is in * * @type {String} */ this._connectionState = 'idle' /** * Number of reconnection attempts being made * * @type {Number} */ this._reconnectionAttempts = 0 /** * All packets are sent in sequence to the server. So we need to * maintain a queue and process one at a time * * @type {Array} */ this._packetsQueue = [] /** * Whether or not the queue is in process * * @type {Boolean} */ this._processingQueue = false /** * As per Adonis protocol, the client must ping * the server after x interval * * @type {Timer} */ this._pingTimer = null /** * Extended query is merged with the query params * user pass * * @type {Object} */ this._extendedQuery = {} /** * Base URL for the websocket connection * * @type {String} */ this._url = `${url.replace(/\/$/, '')}/${this.options.path}` /** * Subscriptions for a single connection * * @type {Object} */ this.subscriptions = {} /** * Handler called when `close` is emitted from the * subscription */ this.removeSubscription = ({ topic }) => { delete this.subscriptions[topic] } } /** * Computed value to decide, whether or not to reconnect * * @method shouldReconnect * * @return {Boolean} */ get shouldReconnect () { return this._connectionState !== 'terminated' && this.options.reconnection && this.options.reconnectionAttempts > this._reconnectionAttempts } /** * Clean references * * @method _cleanup * * @return {void} * * @private */ _cleanup () { clearInterval(this._pingTimer) this.ws = null this._pingTimer = null } /** * Calls a callback passing subscription to it * * @method _subscriptionsIterator * * @param {Function} callback * * @return {void} * * @private */ _subscriptionsIterator (callback) { Object.keys(this.subscriptions).forEach((sub) => callback(this.subscriptions[sub], sub)) } /** * Calls the callback when there is a subscription for * the topic mentioned in the packet * * @method _ensureSubscription * * @param {Object} packet * @param {Function} cb * * @return {void} * * @private */ _ensureSubscription (packet, cb) { const socket = this.getSubscription(packet.d.topic) if (!socket) { if (process.env.NODE_ENV !== 'production') { debug('cannot consume packet since %s topic has no active subscription %j', packet.d.topic, packet) } return } cb(socket, packet) } /** * Process the packets queue by sending one packet at a time * * @method _processQueue * * @return {void} * * @private */ _processQueue () { if (this._processingQueue || !this._packetsQueue.length) { return } /** * Turn on the processing flag * * @type {Boolean} */ this._processingQueue = true this.options.encoder.encode(this._packetsQueue.shift(), (error, payload) => { if (error) { if (process.env.NODE_ENV !== 'production') { debug('encode error %j', error) } return } this.write(payload) /** * Turn off the processing flag and re call the processQueue to send * the next message * * @type {Boolean} */ this._processingQueue = false this._processQueue() }) } /** * As soon as connection is ready, we start listening * for new message * * @method _onOpen * * @return {void} * * @private */ _onOpen () { if (process.env.NODE_ENV !== 'production') { debug('opened') } } /** * When received connection error * * @method _onError * * @param {Event} event * * @return {void} * * @private */ _onError (event) { if (process.env.NODE_ENV !== 'production') { debug('error %O', event) } this._subscriptionsIterator((subscription) => (subscription.serverError())) this.emit('error', event) } /** * Initiates reconnect with the server by moving * all subscriptions to pending state * * @method _reconnect * * @return {void} * * @private */ _reconnect () { this._reconnectionAttempts++ this.emit('reconnect', this._reconnectionAttempts) setTimeout(() => { this._connectionState = 'reconnect' this.connect() }, this.options.reconnectionDelay * this._reconnectionAttempts) } /** * When connection closes * * @method _onClose * * @param {Event} event * * @return {void} * * @private */ _onClose (event) { if (process.env.NODE_ENV !== 'production') { debug('closing from %s state', this._connectionState) } this._cleanup() /** * Force subscriptions to terminate */ this._subscriptionsIterator((subscription) => subscription.terminate()) this .emit('close', this) .then(() => { this.shouldReconnect ? this._reconnect() : this.clearListeners() }) .catch(() => { this.shouldReconnect ? this._reconnect() : this.clearListeners() }) } /** * When a new message was received * * @method _onMessage * * @param {Event} event * * @return {void} * * @private */ _onMessage (event) { this.options.encoder.decode(event.data, (decodeError, packet) => { if (decodeError) { if (process.env.NODE_ENV !== 'production') { debug('packet dropped, decode error %o', decodeError) } return } this._handleMessage(packet) }) } /** * Handles the message packet based upon it's type * * @method _handleMessage * * @param {Object} packet * * @return {void} * * @private */ _handleMessage (packet) { if (wsp.isOpenPacket(packet)) { if (process.env.NODE_ENV !== 'production') { debug('open packet') } this._handleOpen(packet) return } if (wsp.isJoinAckPacket(packet)) { if (process.env.NODE_ENV !== 'production') { debug('join ack packet') } this._handleJoinAck(packet) return } if (wsp.isJoinErrorPacket(packet)) { if (process.env.NODE_ENV !== 'production') { debug('join error packet') } this._handleJoinError(packet) return } if (wsp.isLeaveAckPacket(packet)) { if (process.env.NODE_ENV !== 'production') { debug('leave ack packet') } this._handleLeaveAck(packet) return } if (wsp.isLeaveErrorPacket(packet)) { if (process.env.NODE_ENV !== 'production') { debug('leave error packet') } this._handleLeaveError(packet) return } if (wsp.isLeavePacket(packet)) { if (process.env.NODE_ENV !== 'production') { debug('leave packet') } this._handleServerLeave(packet) return } if (wsp.isEventPacket(packet)) { if (process.env.NODE_ENV !== 'production') { debug('event packet') } this._handleEvent(packet) return } if (wsp.isPongPacket(packet)) { if (process.env.NODE_ENV !== 'production') { debug('pong packet') } return } if (process.env.NODE_ENV !== 'production') { debug('invalid packet type %d', packet.t) } } /** * Emits the open emit and send subscription packets * for pre-existing subscriptions * * @method _handleOpen * * @param {Object} packet * * @return {void} * * @private */ _handleOpen (packet) { this._connectionState = 'open' this.emit('open', packet.d) /** * Setup a timer to ping the server, telling * client is awake */ this._pingTimer = setInterval(() => { this.sendPacket(wsp.pingPacket()) }, packet.d.clientInterval) /** * Sending packets to make pending subscriptions */ if (process.env.NODE_ENV !== 'production') { debug('processing pre connection subscriptions %o', Object.keys(this.subscriptions)) } this._subscriptionsIterator((subscription) => { this._sendSubscriptionPacket(subscription.topic) }) } /** * Handles the join acknowledgement for a subscription * * @method _handleJoinAck * * @param {Object} packet * * @return {void} * * @private */ _handleJoinAck (packet) { this._ensureSubscription(packet, (socket) => socket.joinAck()) } /** * Handles the join error for a subscription * * @method _handleJoinError * * @param {Object} packet * * @return {void} * * @private */ _handleJoinError (packet) { this._ensureSubscription(packet, (socket, packet) => socket.joinError(packet.d)) } /** * Acknowledges the subscription leave * * @method _handleLeaveAck * * @param {Object} packet * * @return {void} * * @private */ _handleLeaveAck (packet) { this._ensureSubscription(packet, (socket) => socket.leaveAck()) } /** * Handles leave error for a subscription * * @method _handleLeaveError * * @param {Object} packet * * @return {void} * * @private */ _handleLeaveError (packet) { this._ensureSubscription(packet, (socket, packet) => socket.leaveError(packet.d)) } /** * Handles when server initiates the subscription leave * * @method _handleServerLeave * * @param {Object} packet * * @return {void} * * @private */ _handleServerLeave (packet) { this._ensureSubscription(packet, (socket, packet) => socket.leaveAck()) } /** * Handles the event packet for a subscription * * @method _handleEvent * * @param {Object} packet * * @return {void} * * @private */ _handleEvent (packet) { this._ensureSubscription(packet, (socket, packet) => socket.serverEvent(packet.d)) } /** * Sends the subscription packet for a given topic * * @method sendSubscriptionPacket * * @param {String} topic * * @return {void} * * @private */ _sendSubscriptionPacket (topic) { if (process.env.NODE_ENV !== 'production') { debug('initiating subscription for %s topic with server', topic) } this.sendPacket(wsp.joinPacket(topic)) } /** * Instantiate the websocket connection * * @method connect * * @return {void} */ connect () { const query = stringify(Object.assign({}, this.options.query, this._extendedQuery)) const url = query ? `${this._url}?${query}` : this._url if (process.env.NODE_ENV !== 'production') { debug('creating socket connection on %s url', url) } this.ws = new window.WebSocket(url) this.ws.onclose = (event) => this._onClose(event) this.ws.onerror = (event) => this._onError(event) this.ws.onopen = (event) => this._onOpen(event) this.ws.onmessage = (event) => this._onMessage(event) return this } /** * Writes the payload on the open connection * * @method write * * @param {String} payload * * @return {void} */ write (payload) { if (this.ws.readyState !== window.WebSocket.OPEN) { if (process.env.NODE_ENV !== 'production') { debug('connection is not in open state, current state %s', this.ws.readyState) } return } this.ws.send(payload) } /** * Sends a packet by encoding it first * * @method _sendPacket * * @param {Object} packet * * @return {void} */ sendPacket (packet) { this._packetsQueue.push(packet) this._processQueue() } /** * Returns the subscription instance for a given topic * * @method getSubscription * * @param {String} topic * * @return {Socket} */ getSubscription (topic) { return this.subscriptions[topic] } /** * Returns a boolean telling, whether connection has * a subscription for a given topic or not * * @method hasSubcription * * @param {String} topic * * @return {Boolean} */ hasSubcription (topic) { return !!this.getSubscription(topic) } /** * Create a new subscription with the server * * @method subscribe * * @param {String} topic * * @return {Socket} */ subscribe (topic) { if (!topic || typeof (topic) !== 'string') { throw new Error('subscribe method expects topic to be a valid string') } if (this.subscriptions[topic]) { throw new Error('Cannot subscribe to same topic twice. Instead use getSubscription') } const socket = new Socket(topic, this) socket.on('close', this.removeSubscription) /** * Storing reference to the socket */ this.subscriptions[topic] = socket /** * Sending join request to the server, the subscription will * be considered ready, once server acknowledges it */ if (this._connectionState === 'open') { this._sendSubscriptionPacket(topic) } return socket } /** * Sends event for a given topic * * @method sendEvent * * @param {String} topic * @param {String} event * @param {Mixed} data * * @return {void} * * @throws {Error} If topic or event are not passed * @throws {Error} If there is no active subscription for the given topic */ sendEvent (topic, event, data) { if (!topic || !event) { throw new Error('topic and event name is required to call sendEvent method') } /** * Make sure there is an active subscription for the topic. Though server will * bounce the message, there is no point in hammering it */ const subscription = this.getSubscription(topic) if (!subscription) { throw new Error(`There is no active subscription for ${topic} topic`) } /** * If subscription state is not open, then we should not publish * messages. * * The reason we have this check on connection and not socket, * is coz we don't want anyone to use the connection object * and send packets, even when subscription is closed. */ if (subscription.state !== 'open') { throw new Error(`Cannot emit since subscription socket is in ${this.state} state`) } if (process.env.NODE_ENV !== 'production') { debug('sending event on %s topic', topic) } this.sendPacket(wsp.eventPacket(topic, event, data)) } /** * Use JWT token to authenticate the user * * @method withJwtToken * * @param {String} token * * @chainable */ withJwtToken (token) { this._extendedQuery.token = token return this } /** * Use basic auth credentials to login the user * * @method withBasicAuth * * @param {String} username * @param {String} password * * @chainable */ withBasicAuth (username, password) { this._extendedQuery.basic = window.btoa(`${username}:${password}`) return this } /** * Use personal API token to authenticate the user * * @method withApiToken * * @param {String} token * * @return {String} */ withApiToken (token) { this._extendedQuery.token = token return this } /** * Forcefully close the connection * * @method close * * @return {void} */ close () { this._connectionState = 'terminated' this.ws.close() } }