UNPKG

websocket-as-promised

Version:

A WebSocket client library providing Promise-based API for connecting, disconnecting and messaging with server

451 lines (408 loc) 12.2 kB
/** * WebSocket with promise api */ /** * @external Channel */ const Channel = require('chnl'); // todo: maybe remove PromiseController and just use promised-map with 2 items? const PromiseController = require('promise-controller'); const { PromisedMap } = require('promised-map'); // todo: maybe remove Requests and just use promised-map? const Requests = require('./requests'); const defaultOptions = require('./options'); const {throwIf, isPromise} = require('./utils'); // see: https://developer.mozilla.org/en-US/docs/Web/API/WebSocket#Ready_state_constants const STATE = { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3, }; /** * @typicalname wsp */ class WebSocketAsPromised { /** * Constructor. Unlike original WebSocket it does not immediately open connection. * Please call `open()` method to connect. * * @param {String} url WebSocket URL * @param {Options} [options] */ constructor(url, options) { this._assertOptions(options); this._url = url; this._options = Object.assign({}, defaultOptions, options); this._requests = new Requests(); this._promisedMap = new PromisedMap(); this._ws = null; this._wsSubscription = null; this._createOpeningController(); this._createClosingController(); this._createChannels(); } /** * Returns original WebSocket instance created by `options.createWebSocket`. * * @returns {WebSocket} */ get ws() { return this._ws; } /** * Returns WebSocket url. * * @returns {String} */ get url() { return this._url; } /** * Is WebSocket connection in opening state. * * @returns {Boolean} */ get isOpening() { return Boolean(this._ws && this._ws.readyState === STATE.CONNECTING); } /** * Is WebSocket connection opened. * * @returns {Boolean} */ get isOpened() { return Boolean(this._ws && this._ws.readyState === STATE.OPEN); } /** * Is WebSocket connection in closing state. * * @returns {Boolean} */ get isClosing() { return Boolean(this._ws && this._ws.readyState === STATE.CLOSING); } /** * Is WebSocket connection closed. * * @returns {Boolean} */ get isClosed() { return Boolean(!this._ws || this._ws.readyState === STATE.CLOSED); } /** * Event channel triggered when connection is opened. * * @see https://vitalets.github.io/chnl/#channel * @example * wsp.onOpen.addListener(() => console.log('Connection opened')); * * @returns {Channel} */ get onOpen() { return this._onOpen; } /** * Event channel triggered every time when message is sent to server. * * @see https://vitalets.github.io/chnl/#channel * @example * wsp.onSend.addListener(data => console.log('Message sent', data)); * * @returns {Channel} */ get onSend() { return this._onSend; } /** * Event channel triggered every time when message received from server. * * @see https://vitalets.github.io/chnl/#channel * @example * wsp.onMessage.addListener(message => console.log(message)); * * @returns {Channel} */ get onMessage() { return this._onMessage; } /** * Event channel triggered every time when received message is successfully unpacked. * For example, if you are using JSON transport, the listener will receive already JSON parsed data. * * @see https://vitalets.github.io/chnl/#channel * @example * wsp.onUnpackedMessage.addListener(data => console.log(data.foo)); * * @returns {Channel} */ get onUnpackedMessage() { return this._onUnpackedMessage; } /** * Event channel triggered every time when response to some request comes. * Received message considered a response if requestId is found in it. * * @see https://vitalets.github.io/chnl/#channel * @example * wsp.onResponse.addListener(data => console.log(data)); * * @returns {Channel} */ get onResponse() { return this._onResponse; } /** * Event channel triggered when connection closed. * Listener accepts single argument `{code, reason}`. * * @see https://vitalets.github.io/chnl/#channel * @example * wsp.onClose.addListener(event => console.log(`Connections closed: ${event.reason}`)); * * @returns {Channel} */ get onClose() { return this._onClose; } /** * Event channel triggered when by Websocket 'error' event. * * @see https://vitalets.github.io/chnl/#channel * @example * wsp.onError.addListener(event => console.error(event)); * * @returns {Channel} */ get onError() { return this._onError; } /** * Opens WebSocket connection. If connection already opened, promise will be resolved with "open event". * * @returns {Promise<Event>} */ open() { if (this.isClosing) { return Promise.reject(new Error(`Can't open WebSocket while closing.`)); } if (this.isOpened) { return this._opening.promise; } return this._opening.call(() => { this._opening.promise.catch(e => this._cleanup(e)); this._createWS(); }); } /** * Performs request and waits for response. * * @param {*} data * @param {Object} [options] * @param {String|Number} [options.requestId=<auto-generated>] * @param {Number} [options.timeout=0] * @returns {Promise} */ sendRequest(data, options = {}) { const requestId = options.requestId || `${Math.random()}`; const timeout = options.timeout !== undefined ? options.timeout : this._options.timeout; return this._requests.create(requestId, () => { this._assertRequestIdHandlers(); const finalData = this._options.attachRequestId(data, requestId); this.sendPacked(finalData); }, timeout); } /** * Packs data with `options.packMessage` and sends to the server. * * @param {*} data */ sendPacked(data) { this._assertPackingHandlers(); const message = this._options.packMessage(data); this.send(message); } /** * Sends data without packing. * * @param {String|Blob|ArrayBuffer} data */ send(data) { throwIf(!this.isOpened, `Can't send data because WebSocket is not opened.`); this._ws.send(data); this._onSend.dispatchAsync(data); } /** * Waits for particular message to come. * * @param {Function} predicate function to check incoming message. * @param {Object} [options] * @param {Number} [options.timeout=0] * @param {Error} [options.timeoutError] * @returns {Promise} * * @example * const response = await wsp.waitUnpackedMessage(data => data && data.foo === 'bar'); */ waitUnpackedMessage(predicate, options = {}) { throwIf(typeof predicate !== 'function', `Predicate must be a function, got ${typeof predicate}`); if (options.timeout) { setTimeout(() => { if (this._promisedMap.has(predicate)) { const error = options.timeoutError || new Error('Timeout'); this._promisedMap.reject(predicate, error); } }, options.timeout); } return this._promisedMap.set(predicate); } /** * Closes WebSocket connection. If connection already closed, promise will be resolved with "close event". * * @param {number=} [code=1000] A numeric value indicating the status code. * @param {string=} [reason] A human-readable reason for closing connection. * @returns {Promise<Event>} */ close(code, reason) { // https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/close return this.isClosed ? Promise.resolve(this._closing.value) : this._closing.call(() => this._ws.close(code, reason)); } /** * Removes all listeners from WSP instance. Useful for cleanup. */ removeAllListeners() { this._onOpen.removeAllListeners(); this._onMessage.removeAllListeners(); this._onUnpackedMessage.removeAllListeners(); this._onResponse.removeAllListeners(); this._onSend.removeAllListeners(); this._onClose.removeAllListeners(); this._onError.removeAllListeners(); } _createOpeningController() { const connectionTimeout = this._options.connectionTimeout || this._options.timeout; this._opening = new PromiseController({ timeout: connectionTimeout, timeoutReason: `Can't open WebSocket within allowed timeout: ${connectionTimeout} ms.` }); } _createClosingController() { const closingTimeout = this._options.timeout; this._closing = new PromiseController({ timeout: closingTimeout, timeoutReason: `Can't close WebSocket within allowed timeout: ${closingTimeout} ms.` }); } _createChannels() { this._onOpen = new Channel(); this._onMessage = new Channel(); this._onUnpackedMessage = new Channel(); this._onResponse = new Channel(); this._onSend = new Channel(); this._onClose = new Channel(); this._onError = new Channel(); } _createWS() { this._ws = this._options.createWebSocket(this._url); this._wsSubscription = new Channel.Subscription([ { channel: this._ws, event: 'open', listener: e => this._handleOpen(e) }, { channel: this._ws, event: 'message', listener: e => this._handleMessage(e) }, { channel: this._ws, event: 'error', listener: e => this._handleError(e) }, { channel: this._ws, event: 'close', listener: e => this._handleClose(e) }, ]).on(); } _handleOpen(event) { this._onOpen.dispatchAsync(event); this._opening.resolve(event); } _handleMessage(event) { const data = this._options.extractMessageData(event); this._onMessage.dispatchAsync(data); this._tryUnpack(data); } _tryUnpack(data) { if (this._options.unpackMessage) { data = this._options.unpackMessage(data); if (isPromise(data)) { data.then(data => this._handleUnpackedData(data)); } else { this._handleUnpackedData(data); } } } _handleUnpackedData(data) { if (data !== undefined) { // todo: maybe trigger onUnpackedMessage always? this._onUnpackedMessage.dispatchAsync(data); this._tryHandleResponse(data); } this._tryHandleWaitingMessage(data); } _tryHandleResponse(data) { if (this._options.extractRequestId) { const requestId = this._options.extractRequestId(data); if (requestId) { this._onResponse.dispatchAsync(data, requestId); this._requests.resolve(requestId, data); } } } _tryHandleWaitingMessage(data) { this._promisedMap.forEach((_, predicate) => { let isMatched = false; try { isMatched = predicate(data); } catch (e) { this._promisedMap.reject(predicate, e); return; } if (isMatched) { this._promisedMap.resolve(predicate, data); } }); } _handleError(event) { this._onError.dispatchAsync(event); } _handleClose(event) { this._onClose.dispatchAsync(event); this._closing.resolve(event); const error = new Error(`WebSocket closed with reason: ${event.reason || event.message} (${event.code}).`); if (this._opening.isPending) { this._opening.reject(error); } this._cleanup(error); } _cleanupWS() { if (this._wsSubscription) { this._wsSubscription.off(); this._wsSubscription = null; } this._ws = null; } _cleanup(error) { this._cleanupWS(); this._requests.rejectAll(error); } _assertOptions(options) { Object.keys(options || {}).forEach(key => { if (!defaultOptions.hasOwnProperty(key)) { throw new Error(`Unknown option: ${key}`); } }); } _assertPackingHandlers() { const { packMessage, unpackMessage } = this._options; throwIf(!packMessage || !unpackMessage, `Please define 'options.packMessage / options.unpackMessage' for sending packed messages.` ); } _assertRequestIdHandlers() { const { attachRequestId, extractRequestId } = this._options; throwIf(!attachRequestId || !extractRequestId, `Please define 'options.attachRequestId / options.extractRequestId' for sending requests.` ); } } module.exports = WebSocketAsPromised;