├── .gitignore ├── circle.yml ├── test ├── .eslintrc ├── webworker.js ├── CustomEvent.js ├── tests.html └── tests.js ├── .eslintrc ├── bower.json ├── LICENSE ├── package.json ├── Gruntfile.js ├── README.md └── robust-websocket.js /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | npm-debug.log -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | node: 3 | version: 6 4 | environment: 5 | SAUCE_USERNAME: robustwebsocket -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "mocha": true, 4 | }, 5 | "globals": { 6 | "chai": true, 7 | "pollUntilPassing": true, 8 | "RobustWebSocket": true, 9 | "should": true, 10 | "sinon": true 11 | } 12 | } -------------------------------------------------------------------------------- /test/webworker.js: -------------------------------------------------------------------------------- 1 | importScripts( 2 | '../node_modules/object.assign/dist/browser.js', 3 | './CustomEvent.js', 4 | '../robust-websocket.js' 5 | ) 6 | 7 | var ws = new RobustWebSocket('ws://localhost:9999/echo') 8 | ws.onopen = function() { 9 | ws.send('hello') 10 | } 11 | ws.addEventListener('message', function(event) { 12 | if (event.data === 'hello') { 13 | ws.close() 14 | postMessage('howdy') 15 | } 16 | }) -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint:recommended", 3 | "env": { 4 | "browser": true, 5 | "amd": true, 6 | "es6": true 7 | }, 8 | "rules": { 9 | "brace-style": [1, "1tbs", { "allowSingleLine": true }], 10 | "comma-spacing": 1, 11 | "curly": 1, 12 | "key-spacing": 1, 13 | "no-return-assign": 1, 14 | "no-shadow": 1, 15 | "no-trailing-spaces": 1, 16 | "quotes": [1, "single"], 17 | "space-infix-ops": 1 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "robust-websocket", 3 | "main": "robust-websocket.js", 4 | "homepage": "https://github.com/appuri/robust-websocket", 5 | "authors": [ 6 | "Appuri, Inc " 7 | ], 8 | "description": "A robust reconnecting WebSocket client for the browser", 9 | "moduleType": [ 10 | "amd", 11 | "globals", 12 | "node" 13 | ], 14 | "keywords": [ 15 | "websocket", 16 | "browser", 17 | "client", 18 | "websocket-client", 19 | "reconnecting", 20 | "retrying" 21 | ], 22 | "license": "ISC", 23 | "ignore": [ 24 | "**/.*", 25 | "node_modules", 26 | "Gruntfile.js", 27 | "bower_components", 28 | "test" 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015, Nathan Black 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 14 | 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "robust-websocket", 3 | "version": "0.2.1", 4 | "description": "A robust reconnecting WebSocket client for the browser", 5 | "main": "robust-websocket.js", 6 | "scripts": { 7 | "test": "node -e \"require('grunt').tasks(['test']);\"" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/appuri/robust-websocket.git" 12 | }, 13 | "keywords": [ 14 | "websocket", 15 | "browser", 16 | "client", 17 | "websocket-client", 18 | "reconnecting", 19 | "retrying" 20 | ], 21 | "author": "Appuri, Inc ", 22 | "license": "ISC", 23 | "bugs": { 24 | "url": "https://github.com/appuri/robust-websocket/issues" 25 | }, 26 | "homepage": "https://github.com/appuri/robust-websocket#readme", 27 | "devDependencies": { 28 | "bluebird": "^3.4.6", 29 | "chai": "^3.5.0", 30 | "grunt": "^0.4.5", 31 | "grunt-contrib-connect": "^0.11.2", 32 | "grunt-saucelabs": "^8.6.1", 33 | "mocha": "^3.0.2", 34 | "object.assign": "^4.0.4", 35 | "qs": "^6.2.1", 36 | "sinon-browser-only": "^1.12.1", 37 | "sinon-chai": "^2.8.0", 38 | "ws": "^1.1.1" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /test/CustomEvent.js: -------------------------------------------------------------------------------- 1 | (function(global) { 2 | 3 | if (typeof global.CustomEvent === 'function') return 4 | 5 | function CustomEvent(event, params) { 6 | params = params || { 7 | bubbles: false, 8 | cancelable: false, 9 | detail: undefined 10 | } 11 | 12 | var evt 13 | if (typeof document !== 'undefined') { 14 | evt = document.createEvent('CustomEvent') 15 | evt.initCustomEvent(event, params.bubbles, params.cancelable, params.detail) 16 | } else { 17 | // This is not a proper polyfill at all. If you need a CustomEvent in a WebWorker, 18 | // please benefit the rest of the world and submit a pull request to your favorite 19 | // CustomEvent polyfil like https://github.com/kaesetoast/customevent-polyfill/issues/1 20 | evt = { 21 | type: event, 22 | detail: params.detail, 23 | bubbles: false, 24 | cancelable: false, 25 | preventDefault: function() {}, 26 | stopPropagation: function() {} 27 | } 28 | } 29 | 30 | return evt 31 | } 32 | 33 | if (global.Event) { 34 | CustomEvent.prototype = global.Event.prototype 35 | } 36 | 37 | global.CustomEvent = CustomEvent 38 | })(this) -------------------------------------------------------------------------------- /test/tests.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | RobustWebSocket Tests 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 26 | 27 | 28 | 29 | 30 | 85 | 86 | -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | module.exports = function(grunt) { 2 | const 3 | url = require('url'), 4 | qs = require('qs'), 5 | ws = require('ws'), 6 | ErrorCodes = require(require.resolve('ws').replace('index.js', 'lib/ErrorCodes')) 7 | 8 | // stub this out so we can do more through testing on the client side 9 | ErrorCodes.isValidErrorCode = () => true 10 | 11 | grunt.initConfig({ 12 | connect: { 13 | server: { 14 | options: { 15 | base: '', 16 | port: 9999, 17 | keepalive: !!process.env.KEEPALIVE, 18 | onCreateServer: function(server/*, connect, options*/) { 19 | const wss = new ws.Server({ server }) 20 | wss.on('connection', function (socket) { 21 | var path = url.parse(socket.upgradeReq.url), 22 | query = qs.parse(path.query) 23 | 24 | socket.on('message', function (message) { 25 | if (path.pathname.startsWith('/echo')) { 26 | socket.send(message) 27 | } 28 | }) 29 | 30 | if (query.greet) { 31 | socket.send(query.greet) 32 | } 33 | 34 | if (query.exitCode) { 35 | setTimeout(function() { 36 | console.log('closing connection with code %d, message %s', query.exitCode, query.exitMessage) 37 | socket.close(Number(query.exitCode), query.exitMessage) 38 | }, Number(query.delay || 500)) 39 | } 40 | }) 41 | } 42 | } 43 | } 44 | }, 45 | 'saucelabs-custom': { 46 | all: { 47 | options: { 48 | urls: ['http://127.0.0.1:9999/test/tests.html'], 49 | tunnelTimeout: 5, 50 | build: process.env.CIRCLE_SHA1 || 0, 51 | concurrency: 3, 52 | tunnelArgs: ['--vm-version', 'dev-varnish'], 53 | browsers: [{ 54 | browserName: 'iphone', 55 | platform: 'OS X 10.10', 56 | version: '9.3' 57 | }, { 58 | browserName: 'safari', 59 | platform: 'OS X 10.11', 60 | version: '9' 61 | }, { 62 | browserName: 'safari', 63 | version: '8' 64 | }, { 65 | browserName: 'android', 66 | platform: 'Linux', 67 | version: '5.0' 68 | }, { 69 | browserName: 'googlechrome', 70 | platform: 'linux' 71 | }, { 72 | browserName: 'firefox', 73 | platform: 'linux' 74 | // }, { 75 | // browserName: 'microsoftedge', 76 | // platform: 'win10' 77 | }, { 78 | browserName: 'internet explorer', 79 | version: '11' 80 | }], 81 | testname: 'RobustWebSocket tests', 82 | tags: [process.env.CIRCLE_BRANCH || 'local'] 83 | } 84 | } 85 | } 86 | }) 87 | 88 | grunt.loadNpmTasks('grunt-saucelabs') 89 | grunt.loadNpmTasks('grunt-contrib-connect') 90 | 91 | grunt.registerTask('test', ['connect', 'saucelabs-custom']) 92 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # robust-websocket 2 | 3 | #### A robust, reconnecting WebSocket client for the browser 4 | 5 | [![SauceLabs Test Status](https://saucelabs.com/browser-matrix/robustwebsocket.svg)](https://saucelabs.com/u/robustwebsocket) 6 | 7 | `robust-websocket` is a wrapper around the standard [WebSocket] class that implements the same interface, but can reconnect when disconnected or the user's computer comes back online. 8 | 9 | It is error-code aware and will not reconnect on 1008 (HTTP 400 equivalent) and 1011 (HTTP 500 equivalent) by default. This behavior is fully configurable via the `shouldConnect` (see [Usage](https://github.com/appuri/robust-websocket#usage)). 10 | 11 | ### Compared to [reconnecting-websocket](https://github.com/joewalnes/reconnecting-websocket) 12 | 13 | - Tests! You know it works like stated and regressions will be caught. 14 | - Is aware of online and offline, and won't burn up the users battery and CPU reconnected when offline, and will reconnect when it is online again. 15 | - Natively aware of error codes 16 | - Any kind of reconnect strategy is possible via functional composition 17 | 18 | ## Usage 19 | 20 | [CodePen Example](https://codepen.io/nathanboktae/pen/RoLXmw) 21 | 22 | Use it as you would a normal websocket: 23 | 24 | ```javascript 25 | var ws = new RobustWebSocket('ws://echo.websocket.org/') 26 | 27 | ws.addEventListener('open', function(event) { 28 | ws.send('Hello!') 29 | }) 30 | 31 | ws.addEventListener('message', function(event) { 32 | console.log('we got: ' + event.data) 33 | }) 34 | ``` 35 | 36 | But with an optional set of options you can specify as a 3rd parameter 37 | 38 | ```javascript 39 | var ws = new RobustWebSocket('ws://echo.websocket.org/', null { 40 | // The number of milliseconds to wait before a connection is considered to have timed out. Defaults to 4 seconds. 41 | timeout: 4000, 42 | // A function that given a CloseEvent or an online event (https://developer.mozilla.org/en-US/docs/Online_and_offline_events) and the `RobustWebSocket`, 43 | // will return the number of milliseconds to wait to reconnect, or a non-Number to not reconnect. 44 | // see below for more examples; below is the default functionality. 45 | shouldReconnect: function(event, ws) { 46 | if (event.code === 1008 || event.code === 1011) return 47 | return [0, 3000, 10000][ws.attempts] 48 | }, 49 | // A boolean indicating whether or not to open the connection automatically. Defaults to true, matching native [WebSocket] behavior. 50 | // You can open the websocket by calling `open()` when you are ready. You can close and re-open the RobustWebSocket instance as much as you wish. 51 | automaticOpen: true, 52 | // A boolean indicating whether to disable subscribing to the connectivity events provided by the browser. 53 | // By default RobustWebSocket instances use connectivity events to avoid triggering reconnection when the browser is offline. This flag is provided in the unlikely event of cases where this may not be desired. 54 | ignoreConnectivityEvents: false 55 | }) 56 | ``` 57 | 58 | #### `shouldReconnect` Examples 59 | 60 | Reconnect with an exponetial backoff on all errors 61 | ```javascript 62 | function shouldReconnect(event, ws) { 63 | return Math.pow(1.5, ws.attempts) * 500 64 | } 65 | ``` 66 | 67 | Reconnect immediately but only 20 times per RobustWebSocket instance 68 | ```javascript 69 | function shouldReconnect(event, ws) { 70 | return ws.reconnects <= 20 && 0 71 | } 72 | ``` 73 | 74 | Reconnect only on some whitelisted codes, and only 3 attempts, except on online events, then connect immediately 75 | ```javascript 76 | function shouldReconnect(event, ws) { 77 | if (event.type === 'online') return 0 78 | return [1006,1011,1012].indexOf(event.code) && [1000,5000,10000][ws.attempt] 79 | } 80 | ``` 81 | 82 | See documentation for [CloseEvent] and [online event](https://developer.mozilla.org/en-US/docs/Online_and_offline_events), the two types of events that `shouldReconnect` will receive. 83 | 84 | Typically, websockets closed with code `1000` indicate that the socket 85 | closed normally. In these cases, `robust-websocket` won't call 86 | `shouldReconnect` (and will not attempt to reconnect), unless you set 87 | `shouldReconnect.handle1000` to `true`. 88 | 89 | ### Polyfills needed 90 | 91 | You may need these polyfills to support older browsers 92 | 93 | - [Object.assign](http://kangax.github.io/compat-table/es6/#test-Object_static_methods_Object.assign) - [npm package](https://www.npmjs.com/package/object.assign) or 24-line [MDN snippet](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign) 94 | - [CustomEvent](http://caniuse.com/#search=CustomEvent) - [npm package](https://www.npmjs.com/package/customevent-polyfill) or 15-line [MDN snippet](https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent/CustomEvent) 95 | 96 | [WebSocket]: https://developer.mozilla.org/en-US/docs/Web/API/WebSocket 97 | [CloseEvent]: https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent 98 | -------------------------------------------------------------------------------- /robust-websocket.js: -------------------------------------------------------------------------------- 1 | (function(factory, global) { 2 | if (typeof define === 'function' && define.amd) { 3 | define(function() { 4 | return factory(global, navigator) 5 | }) 6 | } else if (typeof exports === 'object' && typeof module === 'object') { 7 | module.exports = factory(global, navigator) 8 | } else { 9 | // mock the navigator object when under test since `navigator.onLine` is read only 10 | global.RobustWebSocket = factory(global, typeof Mocha !== 'undefined' ? Mocha : navigator) 11 | } 12 | })(function(global, navigator) { 13 | 14 | var RobustWebSocket = function(url, protocols, userOptions) { 15 | var realWs = { close: function() {} }, 16 | connectTimeout, 17 | self = this, 18 | attempts = 0, 19 | reconnects = -1, 20 | reconnectWhenOnlineAgain = false, 21 | explicitlyClosed = false, 22 | pendingReconnect, 23 | opts = Object.assign({}, 24 | RobustWebSocket.defaultOptions, 25 | typeof userOptions === 'function' ? { shouldReconnect: userOptions } : userOptions 26 | ) 27 | 28 | if (typeof opts.timeout !== 'number') { 29 | throw new Error('timeout must be the number of milliseconds to timeout a connection attempt') 30 | } 31 | 32 | if (typeof opts.shouldReconnect !== 'function') { 33 | throw new Error('shouldReconnect must be a function that returns the number of milliseconds to wait for a reconnect attempt, or null or undefined to not reconnect.') 34 | } 35 | 36 | ['bufferedAmount', 'url', 'readyState', 'protocol', 'extensions'].forEach(function(readOnlyProp) { 37 | Object.defineProperty(self, readOnlyProp, { 38 | get: function() { return realWs[readOnlyProp] } 39 | }) 40 | }) 41 | 42 | function clearPendingReconnectIfNeeded() { 43 | if (pendingReconnect) { 44 | clearTimeout(pendingReconnect) 45 | pendingReconnect = null 46 | } 47 | } 48 | 49 | var ononline = function(event) { 50 | if (reconnectWhenOnlineAgain) { 51 | clearPendingReconnectIfNeeded() 52 | reconnect(event) 53 | } 54 | }, 55 | onoffline = function() { 56 | reconnectWhenOnlineAgain = true 57 | realWs.close(1000) 58 | }, 59 | connectivityEventsAttached = false 60 | 61 | function detachConnectivityEvents() { 62 | if (connectivityEventsAttached) { 63 | global.removeEventListener('online', ononline) 64 | global.removeEventListener('offline', onoffline) 65 | connectivityEventsAttached = false 66 | } 67 | } 68 | 69 | function attachConnectivityEvents() { 70 | if (!connectivityEventsAttached) { 71 | global.addEventListener('online', ononline) 72 | global.addEventListener('offline', onoffline) 73 | connectivityEventsAttached = true 74 | } 75 | } 76 | 77 | self.send = function() { 78 | return realWs.send.apply(realWs, arguments) 79 | } 80 | 81 | self.close = function(code, reason) { 82 | if (typeof code !== 'number') { 83 | reason = code 84 | code = 1000 85 | } 86 | 87 | clearPendingReconnectIfNeeded() 88 | reconnectWhenOnlineAgain = false 89 | explicitlyClosed = true 90 | detachConnectivityEvents() 91 | 92 | return realWs.close(code, reason) 93 | } 94 | 95 | self.open = function() { 96 | if (realWs.readyState !== WebSocket.OPEN && realWs.readyState !== WebSocket.CONNECTING) { 97 | clearPendingReconnectIfNeeded() 98 | reconnectWhenOnlineAgain = false 99 | explicitlyClosed = false 100 | 101 | newWebSocket() 102 | } 103 | } 104 | 105 | function reconnect(event) { 106 | if ((!opts.shouldReconnect.handle1000 && event.code === 1000) || explicitlyClosed) { 107 | attempts = 0 108 | return 109 | } 110 | if (navigator.onLine === false) { 111 | reconnectWhenOnlineAgain = true 112 | return 113 | } 114 | 115 | var delay = opts.shouldReconnect(event, self) 116 | if (typeof delay === 'number') { 117 | pendingReconnect = setTimeout(newWebSocket, delay) 118 | } 119 | } 120 | 121 | Object.defineProperty(self, 'listeners', { 122 | value: { 123 | open: [function(event) { 124 | if (connectTimeout) { 125 | clearTimeout(connectTimeout) 126 | connectTimeout = null 127 | } 128 | event.reconnects = ++reconnects 129 | event.attempts = attempts 130 | attempts = 0 131 | reconnectWhenOnlineAgain = false 132 | }], 133 | close: [reconnect] 134 | } 135 | }) 136 | 137 | Object.defineProperty(self, 'attempts', { 138 | get: function() { return attempts }, 139 | enumerable: true 140 | }) 141 | 142 | Object.defineProperty(self, 'reconnects', { 143 | get: function() { return reconnects }, 144 | enumerable: true 145 | }) 146 | 147 | function newWebSocket() { 148 | pendingReconnect = null 149 | realWs = new WebSocket(url, protocols || undefined) 150 | realWs.binaryType = self.binaryType 151 | 152 | attempts++ 153 | self.dispatchEvent(Object.assign(new CustomEvent('connecting'), { 154 | attempts: attempts, 155 | reconnects: reconnects 156 | })) 157 | 158 | connectTimeout = setTimeout(function() { 159 | connectTimeout = null 160 | detachConnectivityEvents() 161 | self.dispatchEvent(Object.assign(new CustomEvent('timeout'), { 162 | attempts: attempts, 163 | reconnects: reconnects 164 | })) 165 | }, opts.timeout) 166 | 167 | ;['open', 'close', 'message', 'error'].forEach(function(stdEvent) { 168 | realWs.addEventListener(stdEvent, function(event) { 169 | self.dispatchEvent(event) 170 | 171 | var cb = self['on' + stdEvent] 172 | if (typeof cb === 'function') { 173 | return cb.apply(self, arguments) 174 | } 175 | }) 176 | }) 177 | 178 | if (!opts.ignoreConnectivityEvents) { 179 | attachConnectivityEvents() 180 | } 181 | } 182 | 183 | if (opts.automaticOpen) { 184 | newWebSocket() 185 | } 186 | } 187 | 188 | RobustWebSocket.defaultOptions = { 189 | // the time to wait before a successful connection 190 | // before the attempt is considered to have timed out 191 | timeout: 4000, 192 | // Given a CloseEvent or OnlineEvent and the RobustWebSocket state, 193 | // should a reconnect be attempted? Return the number of milliseconds to wait 194 | // to reconnect (or null or undefined to not), rather than true or false 195 | shouldReconnect: function(event, ws) { 196 | if (event.code === 1008 || event.code === 1011) return 197 | return [0, 3000, 10000][ws.attempts] 198 | }, 199 | 200 | // Flag to control whether attachement to navigator online/offline events 201 | // should be disabled. 202 | ignoreConnectivityEvents: false, 203 | 204 | // Create and connect the WebSocket when the instance is instantiated. 205 | // Defaults to true to match standard WebSocket behavior 206 | automaticOpen: true 207 | } 208 | 209 | RobustWebSocket.prototype.binaryType = 'blob' 210 | 211 | // Taken from MDN https://developer.mozilla.org/en-US/docs/Web/API/EventTarget 212 | RobustWebSocket.prototype.addEventListener = function(type, callback) { 213 | if (!(type in this.listeners)) { 214 | this.listeners[type] = [] 215 | } 216 | this.listeners[type].push(callback) 217 | } 218 | 219 | RobustWebSocket.prototype.removeEventListener = function(type, callback) { 220 | if (!(type in this.listeners)) { 221 | return 222 | } 223 | var stack = this.listeners[type] 224 | for (var i = 0, l = stack.length; i < l; i++) { 225 | if (stack[i] === callback) { 226 | stack.splice(i, 1) 227 | return 228 | } 229 | } 230 | } 231 | 232 | RobustWebSocket.prototype.dispatchEvent = function(event) { 233 | if (!(event.type in this.listeners)) { 234 | return 235 | } 236 | var stack = this.listeners[event.type] 237 | for (var i = 0, l = stack.length; i < l; i++) { 238 | stack[i].call(this, event) 239 | } 240 | } 241 | 242 | return RobustWebSocket 243 | }, typeof window != 'undefined' ? window : (typeof global != 'undefined' ? global : this)); 244 | -------------------------------------------------------------------------------- /test/tests.js: -------------------------------------------------------------------------------- 1 | describe('RobustWebSocket', function() { 2 | var ws, serverUrl = location.origin.replace('http', 'ws'), 3 | isSafari = window.webkitCancelAnimationFrame && !window.webkitRTCPeerConnection, 4 | isIE = Object.hasOwnProperty.call(window, 'ActiveXObject'), 5 | isEdge = !!window.MSStream, 6 | isIEOrEdge = isIE || isEdge 7 | 8 | this.retries(3) 9 | 10 | afterEach(function() { 11 | Mocha.onLine = true 12 | try { 13 | if (ws) { 14 | ws.listeners.length = 0 15 | ws.onclose = null 16 | ws.close() 17 | } 18 | } catch (e) {} 19 | }) 20 | 21 | function wrap(fn, done) { 22 | return function() { 23 | try { 24 | fn.apply(this, arguments) 25 | } catch(e) { 26 | done(e) 27 | } 28 | } 29 | } 30 | 31 | describe('web standards behavior', function() { 32 | it('should forward messages and errors to the client via event listeners', function(done) { 33 | ws = new RobustWebSocket(serverUrl + '/echo') 34 | 35 | ws.addEventListener('open', wrap(function(evt) { 36 | this.should.equal(ws) 37 | evt.target.should.be.instanceof(WebSocket) 38 | evt.reconnects.should.equal(0) 39 | evt.attempts.should.equal(1) 40 | ws.send('hello!') 41 | }, done)) 42 | 43 | var onmessage = sinon.spy(function(evt) { 44 | evt.data.should.equal('hello!') 45 | evt.target.should.be.instanceof(WebSocket) 46 | ws.close() 47 | }) 48 | ws.addEventListener('message', wrap(onmessage, done)) 49 | 50 | ws.addEventListener('close', wrap(function() { 51 | onmessage.should.have.been.calledOnce 52 | done() 53 | }, done)) 54 | }) 55 | 56 | it('should forward messages and errors to the client via on* properties', function(done) { 57 | ws = new RobustWebSocket(serverUrl + '/echo') 58 | 59 | ws.onopen = wrap(function(evt) { 60 | this.should.equal(ws) 61 | evt.target.should.be.instanceof(WebSocket) 62 | evt.reconnects.should.equal(0) 63 | evt.attempts.should.equal(1) 64 | ws.send('hello!') 65 | }, done) 66 | 67 | ws.onmessage = sinon.spy(wrap(function(evt) { 68 | evt.data.should.equal('hello!') 69 | evt.target.should.be.instanceof(WebSocket) 70 | ws.close() 71 | }, done)) 72 | 73 | ws.addEventListener('close', wrap(function(evt) { 74 | ws.onmessage.should.have.been.calledOnce 75 | evt.code.should.equal(1000) 76 | done() 77 | }, done)) 78 | }) 79 | 80 | it('should proxy read only properties', function() { 81 | ws = new RobustWebSocket(serverUrl) 82 | ws.url.should.contain(serverUrl) 83 | ws.protocol.should.equal('') 84 | ws.readyState.should.equal(WebSocket.CONNECTING) 85 | ws.bufferedAmount.should.equal(0) 86 | 87 | return pollUntilPassing(function() { 88 | ws.readyState.should.equal(WebSocket.OPEN) 89 | }) 90 | }) 91 | 92 | it('should rethrow errors', !isIEOrEdge && function() { 93 | (function() { 94 | new RobustWebSocket('localhost:11099') 95 | }).should.throw(Error) 96 | 97 | ;(function() { 98 | ws = new RobustWebSocket(serverUrl) 99 | ws.send() 100 | }).should.throw(Error) 101 | }) 102 | 103 | it('should work in a web worker', !isSafari && !isEdge && function(done) { 104 | var worker = new Worker('./webworker.js') 105 | 106 | worker.onmessage = function(event) { 107 | event.data.should.equal('howdy') 108 | done() 109 | } 110 | }) 111 | 112 | it('should work with different binary types') 113 | it('should support the protocols parameter') 114 | }) 115 | 116 | function shouldNotReconnect(code) { 117 | return function() { 118 | ws = new RobustWebSocket(serverUrl + '/?exitCode=' + code + '&exitMessage=alldone') 119 | ws.onclose = sinon.spy(function(evt) { 120 | if (!isIEOrEdge) { 121 | evt.code.should.equal(code) 122 | evt.reason.should.equal('alldone') 123 | } 124 | }) 125 | ws.onopen = sinon.spy() 126 | 127 | return pollUntilPassing(function() { 128 | ws.onclose.should.have.been.calledOnce 129 | ws.onopen.should.have.been.calledOnce 130 | ws.readyState.should.equal(WebSocket.CLOSED) 131 | }).then(function() { 132 | return Promise.delay(1000) 133 | }).then(function() { 134 | ws.onclose.should.have.been.calledOnce 135 | ws.onopen.should.have.been.calledOnce 136 | ws.readyState.should.equal(WebSocket.CLOSED) 137 | }) 138 | } 139 | } 140 | 141 | describe('robustness', function() { 142 | it('should reconnect when a server reboots (1012)', function() { 143 | ws = new RobustWebSocket(serverUrl + '/?exitCode=1012&exitMessage=alldone&delay=250') 144 | ws.onclose = sinon.spy(function(evt) { 145 | if (!isIEOrEdge) { 146 | evt.code.should.equal(1012) 147 | evt.reason.should.equal('alldone') 148 | } 149 | }) 150 | ws.onopen = sinon.spy() 151 | 152 | return pollUntilPassing(function() { 153 | ws.onopen.callCount.should.be.greaterThan(2) 154 | ws.onclose.callCount.should.be.greaterThan(1) 155 | }) 156 | }) 157 | 158 | it('should not reconnect on normal disconnects (1000)', !isIEOrEdge && shouldNotReconnect(1000)) 159 | 160 | it('should call shouldReconnect on normal disconnects if handle1000 is true', !isIEOrEdge && function() { 161 | // Issue #14 162 | var attemptLog = [], 163 | rounds = 0, 164 | shouldReconnect = sinon.spy(function(event, ws) { 165 | event.type.should.equal('close') 166 | event.currentTarget.should.be.instanceof(WebSocket) 167 | // ws.attempts is reset on each successful open, so we separately track the number of open-close 168 | // cycles using `rounds` 169 | attemptLog.push(ws.attempts) 170 | return rounds++ < 2 && 100 171 | }) 172 | shouldReconnect.handle1000 = true; 173 | 174 | ws = new RobustWebSocket(serverUrl + '/?exitCode=1000&exitMessage=alldone&delay=250', null, { 175 | shouldReconnect: shouldReconnect 176 | }) 177 | ws.onclose = sinon.spy(function(evt) { 178 | evt.code.should.equal(1000) 179 | }) 180 | return pollUntilPassing(function() { 181 | attemptLog.should.deep.equal([0, 0, 0]) 182 | ws.onclose.should.have.been.calledThrice 183 | shouldReconnect.should.have.been.calledThrice 184 | ws.readyState.should.equal(WebSocket.CLOSED) 185 | }) 186 | }) 187 | 188 | it('should not reconnect 1008 by default (HTTP 400 equvalent)', !isIEOrEdge && shouldNotReconnect(1008)) 189 | it('should not reconnect 1011 by default (HTTP 500 equvalent)', !isIEOrEdge && shouldNotReconnect(1011)) 190 | 191 | it('should emit connecting events when reconnecting (1001)', function() { 192 | ws = new RobustWebSocket(serverUrl + '/?exitCode=1001') 193 | ws.onclose = sinon.spy(function(evt) { 194 | !isIEOrEdge && evt.code.should.equal(1001) 195 | evt.reason.should.equal('') 196 | }) 197 | 198 | var reconnectingListener = sinon.spy() 199 | ws.addEventListener('connecting', reconnectingListener) 200 | 201 | return pollUntilPassing(function() { 202 | reconnectingListener.should.have.been.called 203 | var event = reconnectingListener.lastCall.args[0] 204 | event.type.should.equal('connecting') 205 | event.attempts.should.equal(1) 206 | }) 207 | }) 208 | 209 | // Safari never calls the onerror callback. The connection will just timeout in that case. 210 | it('should retry the initial connection if it failed', !isSafari && function() { 211 | var attemptLog = [], 212 | shouldReconnect = sinon.spy(function(event, ws) { 213 | event.type.should.equal('close') 214 | event.currentTarget.should.be.instanceof(WebSocket) 215 | // since ws.attempts refers to the current attempts on the websocket, we need to save them 216 | // rather than use sinon.firstCall.args[0].attempts 217 | attemptLog.push(ws.attempts) 218 | return ws.attempts < 3 && 500 219 | }) 220 | 221 | ws = new RobustWebSocket('ws://localhost:88', null, { 222 | shouldReconnect: shouldReconnect 223 | }) 224 | ws.onclose = sinon.spy(function(evt) { 225 | evt.code.should.equal(1006) 226 | evt.reason.should.equal('') 227 | }) 228 | ws.onerror = sinon.spy(function(e) { 229 | e.type.should.equal('error') 230 | }) 231 | 232 | return pollUntilPassing(function() { 233 | ws.onerror.should.have.been.calledThrice 234 | ws.onclose.should.have.been.calledThrice 235 | shouldReconnect.should.have.been.calledThrice 236 | ws.readyState.should.equal(WebSocket.CLOSED) 237 | 238 | attemptLog.should.deep.equal([1, 2, 3]) 239 | }).then(function() { 240 | return Promise.delay(1500) 241 | }).then(function() { 242 | ws.onclose.should.have.been.calledThrice 243 | ws.readyState.should.equal(WebSocket.CLOSED) 244 | }) 245 | }) 246 | 247 | it('should not try to reconnect while offline, trying again when online', function() { 248 | this.timeout(8000) 249 | Mocha.onLine = false 250 | var shouldReconnect = sinon.spy(function() { return 0 }) 251 | 252 | ws = new RobustWebSocket(serverUrl + '/?exitCode=1002&delay=500', null, shouldReconnect) 253 | ws.onopen = sinon.spy() 254 | 255 | return pollUntilPassing(function() { 256 | ws.onopen.should.have.been.calledOnce 257 | shouldReconnect.should.have.not.been.called 258 | }).then(function() { 259 | return Promise.delay(1000) 260 | }).then(function() { 261 | ws.onopen.should.have.been.calledOnce 262 | shouldReconnect.should.have.not.been.called 263 | 264 | Mocha.onLine = true 265 | window.dispatchEvent(new CustomEvent('online')) 266 | 267 | return pollUntilPassing(function() { 268 | shouldReconnect.should.have.been.calledOnce 269 | ws.onopen.should.have.been.calledTwice 270 | }) 271 | }) 272 | }) 273 | 274 | it('should immediately close the websocket when going offline rather than waiting for a timeout', function() { 275 | this.timeout(8000) 276 | var shouldReconnect = sinon.spy(function() { return 0 }) 277 | 278 | ws = new RobustWebSocket(serverUrl + '/echo', null, shouldReconnect) 279 | ws.onopen = sinon.spy() 280 | ws.onclose = sinon.spy() 281 | 282 | return pollUntilPassing(function() { 283 | ws.onopen.should.have.been.calledOnce 284 | shouldReconnect.should.have.not.been.called 285 | }).then(function() { 286 | return Promise.delay(100) 287 | }).then(function() { 288 | window.dispatchEvent(new CustomEvent('offline')) 289 | return pollUntilPassing(function() { 290 | ws.onclose.should.have.been.calledOnce 291 | ws.readyState.should.equal(WebSocket.CLOSED) 292 | }) 293 | }).then(function() { 294 | return Promise.delay(1000) 295 | }).then(function() { 296 | ws.onclose.should.have.been.calledOnce 297 | ws.readyState.should.equal(WebSocket.CLOSED) 298 | 299 | window.dispatchEvent(new CustomEvent('online')) 300 | return pollUntilPassing(function() { 301 | ws.onopen.should.have.been.calledTwice 302 | shouldReconnect.should.have.been.calledOnce 303 | }) 304 | }).then(function() { 305 | return Promise.delay(1000) 306 | }).then(function() { 307 | ws.onopen.should.have.been.calledTwice 308 | shouldReconnect.should.have.been.calledOnce 309 | ws.readyState.should.equal(WebSocket.OPEN) 310 | }) 311 | }) 312 | 313 | it('should not reconnect a websocket that was explicitly closed when going back online', function() { 314 | ws = new RobustWebSocket(serverUrl + '/echo', null, function() { return 0 }) 315 | ws.onopen = sinon.spy() 316 | ws.onclose = sinon.spy() 317 | 318 | return pollUntilPassing(function() { 319 | ws.readyState.should.equal(WebSocket.OPEN) 320 | }).then(function() { 321 | Mocha.onLine = false 322 | ws.close() 323 | 324 | return pollUntilPassing(function() { 325 | ws.readyState.should.equal(WebSocket.CLOSED) 326 | ws.onclose.should.have.been.calledOnce 327 | }) 328 | }).then(function() { 329 | return Promise.delay(300) 330 | }).then(function() { 331 | window.dispatchEvent(new CustomEvent('online')) 332 | return Promise.delay(500) 333 | }).then(function() { 334 | ws.onclose.should.have.been.calledOnce 335 | ws.onclose.should.have.been.calledOnce 336 | }) 337 | }) 338 | }) 339 | 340 | describe('extra features', function() { 341 | it('should emit a timeout event if the connection timed out') 342 | 343 | it('should allow the socket to be reopened', function() { 344 | ws = new RobustWebSocket(serverUrl + '/echo') 345 | ws.onclose = sinon.spy() 346 | ws.onopen = sinon.spy() 347 | 348 | return pollUntilPassing(function() { 349 | ws.onopen.should.have.been.calledOnce 350 | ws.onclose.should.have.not.been.called 351 | ws.readyState.should.equal(WebSocket.OPEN) 352 | }).then(function() { 353 | ws.close() 354 | 355 | return pollUntilPassing(function() { 356 | ws.onopen.should.have.been.calledOnce 357 | ws.onclose.should.have.been.calledOnce 358 | ws.readyState.should.equal(WebSocket.CLOSED) 359 | }) 360 | }).then(function() { 361 | return Promise.delay(100) 362 | }).then(function() { 363 | ws.open() 364 | 365 | return pollUntilPassing(function() { 366 | ws.onopen.should.have.been.calledTwice 367 | ws.onclose.should.have.been.calledOnce 368 | ws.readyState.should.equal(WebSocket.OPEN) 369 | }) 370 | }) 371 | }) 372 | 373 | it('should not reconnect if the socket is already opened when open is called', function() { 374 | ws = new RobustWebSocket(serverUrl + '/echo') 375 | ws.onclose = sinon.spy() 376 | ws.onopen = sinon.spy() 377 | 378 | return pollUntilPassing(function() { 379 | ws.onopen.should.have.been.calledOnce 380 | ws.readyState.should.equal(WebSocket.OPEN) 381 | }).then(function() { 382 | return Promise.delay(100) 383 | }).then(function() { 384 | ws.open() 385 | return Promise.delay(300) 386 | }).then(function() { 387 | ws.onopen.should.have.been.calledOnce 388 | ws.onclose.should.have.not.been.called 389 | ws.readyState.should.equal(WebSocket.OPEN) 390 | }) 391 | }) 392 | 393 | it('should not automatically open the connection if requested', function() { 394 | ws = new RobustWebSocket(serverUrl + '/echo', null, { 395 | automaticOpen: false 396 | }) 397 | ws.onclose = sinon.spy() 398 | ws.onopen = sinon.spy() 399 | 400 | return Promise.delay(400).then(function() { 401 | ws.onopen.should.have.not.been.called 402 | ws.onclose.should.have.not.been.called 403 | should.not.exist(ws.readyState) 404 | 405 | ws.open() 406 | 407 | return pollUntilPassing(function() { 408 | ws.onopen.should.have.been.calledOnce 409 | ws.readyState.should.equal(WebSocket.OPEN) 410 | }) 411 | }) 412 | }) 413 | 414 | it('should not close a socket if ignoreConnectivityEvents is in use', function() { 415 | ws = new RobustWebSocket(serverUrl + '/echo', null, { 416 | ignoreConnectivityEvents: true, 417 | shouldReconnect: function() { return 0 } 418 | }) 419 | ws.onclose = sinon.spy() 420 | 421 | return pollUntilPassing(function() { 422 | ws.readyState.should.equal(WebSocket.OPEN) 423 | }).then(function() { 424 | Mocha.onLine = false 425 | 426 | return Promise.delay(300) 427 | }).then(function() { 428 | ws.readyState.should.equal(WebSocket.OPEN) 429 | ws.onclose.should.not.have.been.called 430 | }) 431 | }) 432 | }) 433 | }) --------------------------------------------------------------------------------